claude-dev-env 2.3.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 (290) hide show
  1. package/CLAUDE.md +53 -48
  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 +3 -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 +3 -4
  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/skill-writer-agent.md +84 -0
  31. package/agents/test_agent_frontmatter.py +67 -18
  32. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
  33. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  34. package/bin/CLAUDE.md +68 -5
  35. package/bin/ever-shipped-skills.mjs +1 -0
  36. package/bin/install-constants.mjs +88 -0
  37. package/bin/install.mjs +1138 -114
  38. package/bin/install.prune.test.mjs +869 -19
  39. package/bin/install.test.mjs +906 -2
  40. package/commands/implement.md +1 -1
  41. package/commands/right-size.md +1 -1
  42. package/docs/CLAUDE.md +2 -0
  43. package/docs/host-pool-health-monitor.md +102 -0
  44. package/docs/references/CLAUDE.md +5 -2
  45. package/docs/references/advisor-tool.md +13 -0
  46. package/docs/references/code-review-enforcement.md +107 -0
  47. package/docs/references/team-advisor-skill.md +14 -0
  48. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  49. package/hooks/blocking/CLAUDE.md +9 -1
  50. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  51. package/hooks/blocking/code_review_gate_deny.py +74 -0
  52. package/hooks/blocking/code_review_pr_create_gate.py +198 -0
  53. package/hooks/blocking/code_review_push_gate.py +145 -0
  54. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
  55. package/hooks/blocking/code_review_stamp_store.py +233 -0
  56. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  57. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  58. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  59. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  60. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  61. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  62. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  63. package/hooks/blocking/config/__init__.py +5 -5
  64. package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
  65. package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
  66. package/hooks/blocking/config/verified_commit_constants.py +160 -155
  67. package/hooks/blocking/conftest.py +2 -0
  68. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  69. package/hooks/blocking/destructive_command_blocker.py +19 -6
  70. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  71. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  72. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  73. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  74. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  75. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  76. package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
  77. package/hooks/blocking/test_code_review_push_gate.py +205 -0
  78. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
  79. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  80. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  81. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  82. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  83. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  84. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  85. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  86. package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
  87. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  88. package/hooks/blocking/test_verification_verdict_store.py +974 -903
  89. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  90. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  91. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  92. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  93. package/hooks/blocking/verification_verdict_store.py +1039 -1014
  94. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  95. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  96. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  97. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  98. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  99. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  100. package/hooks/git-hooks/pre_push.py +89 -2
  101. package/hooks/git-hooks/test_pre_push.py +128 -0
  102. package/hooks/hooks.json +26 -1
  103. package/hooks/hooks_constants/CLAUDE.md +3 -1
  104. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  105. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  106. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  107. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  108. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  109. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  110. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  111. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  112. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  113. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  114. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  115. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  116. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  117. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  118. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  119. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  120. package/hooks/pyproject.toml +1 -0
  121. package/hooks/validators/CLAUDE.md +2 -0
  122. package/hooks/validators/config/__init__.py +0 -0
  123. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  124. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  125. package/hooks/validators/conftest.py +4 -0
  126. package/hooks/validators/mypy_integration.py +63 -50
  127. package/hooks/validators/pyproject_config_discovery.py +101 -0
  128. package/hooks/validators/ruff_integration.py +257 -25
  129. package/hooks/validators/run_all_validators.py +223 -19
  130. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  131. package/hooks/validators/test_mypy_integration.py +32 -0
  132. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +160 -2
  135. package/hooks/validators/test_run_all_validators.py +140 -68
  136. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  137. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  138. package/package.json +10 -2
  139. package/rules/CLAUDE.md +1 -0
  140. package/rules/docstring-prose-matches-implementation.md +45 -67
  141. package/rules/durable-post-artifacts.md +7 -0
  142. package/rules/state-what-is.md +25 -0
  143. package/rules/verified-commit-gate-skip.md +1 -1
  144. package/scripts/CLAUDE.md +1 -0
  145. package/scripts/Capture-PoolHealth.ps1 +410 -0
  146. package/scripts/_code_review_test_support.py +404 -0
  147. package/scripts/claude_chain_runner.py +141 -1
  148. package/scripts/codec_forwarding_test_support.py +83 -0
  149. package/scripts/conftest.py +23 -0
  150. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
  151. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
  152. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  153. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  154. package/scripts/invoke_code_review.py +550 -38
  155. package/scripts/resolve_worker_spawn.py +626 -619
  156. package/scripts/spawn_grok_batch.py +672 -672
  157. package/scripts/test_claude_chain_runner.py +131 -0
  158. package/scripts/test_invoke_code_review_chain.py +70 -0
  159. package/scripts/test_invoke_code_review_cli.py +192 -0
  160. package/scripts/test_invoke_code_review_codec.py +77 -0
  161. package/scripts/test_invoke_code_review_contract.py +256 -0
  162. package/scripts/test_invoke_code_review_git.py +123 -0
  163. package/scripts/test_invoke_code_review_mode.py +99 -0
  164. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  165. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  166. package/skills/CLAUDE.md +2 -0
  167. package/skills/auditing-claude-config/SKILL.md +114 -114
  168. package/skills/autoconverge/SKILL.md +427 -421
  169. package/skills/autoconverge/reference/convergence.md +26 -4
  170. package/skills/autoconverge/reference/multi-pr.md +6 -1
  171. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  172. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  173. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  174. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  175. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
  176. package/skills/autoconverge/workflow/converge.mjs +191 -8
  177. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  178. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  179. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  180. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  181. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  182. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  183. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  184. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  185. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  186. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  187. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  188. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  189. package/skills/closeout/SKILL.md +33 -50
  190. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  191. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  192. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  193. package/skills/condensing-instructions/SKILL.md +81 -0
  194. package/skills/copilot-review/SKILL.md +119 -119
  195. package/skills/e-code-review/SKILL.md +52 -0
  196. package/skills/e-code-review/reference/fix.md +54 -0
  197. package/skills/e-code-review/reference/loop.md +43 -0
  198. package/skills/e-code-review/reference/low.md +57 -0
  199. package/skills/e-code-review/reference/medium.md +153 -0
  200. package/skills/e-code-review/reference/xhigh.md +182 -0
  201. package/skills/e-simplify/SKILL.md +97 -0
  202. package/skills/fresh-branch/CLAUDE.md +2 -0
  203. package/skills/fresh-branch/SKILL.md +2 -0
  204. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  205. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  206. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  207. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  208. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  209. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  210. package/skills/issue-tracker/SKILL.md +92 -0
  211. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  212. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  213. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  214. package/skills/orchestrator/SKILL.md +162 -21
  215. package/skills/orchestrator/scripts/status_gate.py +625 -0
  216. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  217. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  218. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  219. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  220. package/skills/orchestrator-refresh/SKILL.md +110 -35
  221. package/skills/plan-to-pr/SKILL.md +155 -0
  222. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  223. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  224. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  225. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  226. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  227. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  228. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  229. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  230. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  231. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  232. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  233. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  234. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  235. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  236. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  237. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  238. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  239. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  240. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  241. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  242. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  243. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  244. package/skills/pr-converge/SKILL.md +472 -469
  245. package/skills/pr-converge/reference/examples.md +3 -3
  246. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  247. package/skills/pr-converge/reference/ground-rules.md +7 -4
  248. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  249. package/skills/pr-converge/reference/per-tick.md +5 -5
  250. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  251. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  252. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  253. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  254. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  255. package/skills/pr-fix-protocol/SKILL.md +1 -1
  256. package/skills/privacy-hygiene/SKILL.md +68 -68
  257. package/skills/prototype/SKILL.md +86 -0
  258. package/skills/prototype/reference/honest-limitations.md +23 -0
  259. package/skills/prototype/reference/promotion-tasks.md +23 -0
  260. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  261. package/skills/prototype/scripts/conftest.py +15 -0
  262. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  263. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  264. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  265. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  266. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  267. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  268. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  269. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  270. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  271. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  272. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  273. package/skills/prototype/workflows/promotion.md +27 -0
  274. package/skills/prototype/workflows/sandbox.md +35 -0
  275. package/skills/release-notes-html/SKILL.md +164 -0
  276. package/skills/skill-builder/CLAUDE.md +3 -3
  277. package/skills/skill-builder/SKILL.md +5 -5
  278. package/skills/skill-builder/references/CLAUDE.md +1 -1
  279. package/skills/skill-builder/references/delegation-map.md +3 -3
  280. package/skills/skill-builder/references/description-field.md +1 -1
  281. package/skills/skill-builder/references/skill-modularity.md +2 -3
  282. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  283. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  284. package/skills/skill-builder/workflows/new-skill.md +2 -2
  285. package/skills/task-build/CLAUDE.md +8 -7
  286. package/skills/task-build/SKILL.md +16 -8
  287. package/skills/task-build/reference/tool-routing.md +19 -0
  288. package/skills/team-advisor/SKILL.md +2 -2
  289. package/scripts/test_invoke_code_review.py +0 -672
  290. 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)