claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  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/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -0,0 +1,107 @@
1
+ """Constants and rule-format readers for stale_worktree_rule_sweep.
2
+
3
+ The worktrees root is derived from the same ~/.claude home the permission
4
+ grant script mints its rules under, so the location is named in exactly one
5
+ place. A worktree rule points at a directory one or more segments below that
6
+ root (flat or nested layouts)::
7
+
8
+ Edit(<claude home>/worktrees/<worktree name>/.claude/**)
9
+ ^^^^^^^^^^^^^^^
10
+ segment 1 (flat)
11
+
12
+ Edit(<claude home>/worktrees/<repository>/<worktree name>/.claude/**)
13
+ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
14
+ nested layout
15
+
16
+ worktree_directory_for_rule(rule, root) -> project directory under root
17
+
18
+ A rule whose target path lies outside the worktrees root reads back as None,
19
+ so the sweep leaves it untouched.
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from pr_loop_shared_constants.claude_permissions_constants import (
25
+ get_claude_user_settings_path,
26
+ )
27
+
28
+ WORKTREES_SUBDIRECTORY_NAME: str = "worktrees"
29
+
30
+ MINIMUM_WORKTREE_PATH_SEGMENT_COUNT: int = 1
31
+
32
+ RULE_PATH_OPEN_DELIMITER: str = "("
33
+
34
+ RULE_PATH_CLOSE_DELIMITER: str = ")"
35
+
36
+ CLAUDE_HOME_PATH_MARKER: str = "/.claude"
37
+
38
+
39
+ def get_claude_worktrees_root() -> Path:
40
+ """Return the ~/.claude/worktrees directory the grant rules point under.
41
+
42
+ Reuses get_claude_user_settings_path so the ~/.claude home is resolved
43
+ in one place: the settings file's parent directory is the home, and the
44
+ worktrees live directly beneath it.
45
+
46
+ Returns:
47
+ The absolute path to the user's worktrees root directory.
48
+ """
49
+ claude_home_directory = get_claude_user_settings_path().parent
50
+ return claude_home_directory / WORKTREES_SUBDIRECTORY_NAME
51
+
52
+
53
+ def extract_rule_target_path(rule: str) -> str | None:
54
+ """Return the path a permission rule targets, or None when unparsable.
55
+
56
+ A rule reads `Tool(<path>)`; the target is the text between the first
57
+ open delimiter and the last close delimiter::
58
+
59
+ Edit(/repo/wt/.claude/**) -> /repo/wt/.claude/**
60
+ not a rule -> None
61
+
62
+ Args:
63
+ rule: The permission rule string to read.
64
+
65
+ Returns:
66
+ The target path string, or None when the delimiters are absent.
67
+ """
68
+ open_index = rule.find(RULE_PATH_OPEN_DELIMITER)
69
+ close_index = rule.rfind(RULE_PATH_CLOSE_DELIMITER)
70
+ if open_index == -1 or close_index <= open_index:
71
+ return None
72
+ return rule[open_index + 1 : close_index]
73
+
74
+
75
+ def worktree_directory_for_rule(rule: str, worktrees_root: Path) -> Path | None:
76
+ """Return the worktree directory a rule targets, or None when it is not one.
77
+
78
+ Keeps every path segment below the worktrees root so flat
79
+ (`worktrees/<name>`) and nested (`worktrees/<repo>/<name>`) layouts
80
+ both resolve to the on-disk project directory::
81
+
82
+ Edit(<root>/flat-wt/.claude/**) -> <root>/flat-wt
83
+ Edit(<root>/repo/wt/.claude/**) -> <root>/repo/wt
84
+ Edit(/elsewhere/project/.claude/**) -> None
85
+
86
+ Args:
87
+ rule: The permission rule string to read.
88
+ worktrees_root: The ~/.claude/worktrees directory rules live under.
89
+
90
+ Returns:
91
+ The worktree directory, or None when the target lies outside the
92
+ worktrees root or has no path segments under it.
93
+ """
94
+ target_path = extract_rule_target_path(rule)
95
+ if target_path is None:
96
+ return None
97
+ project_portion = target_path.rsplit(CLAUDE_HOME_PATH_MARKER, 1)[0]
98
+ if not project_portion:
99
+ return None
100
+ try:
101
+ relative_path = Path(project_portion).relative_to(worktrees_root)
102
+ except ValueError:
103
+ return None
104
+ all_segments = relative_path.parts
105
+ if len(all_segments) < MINIMUM_WORKTREE_PATH_SEGMENT_COUNT:
106
+ return None
107
+ return worktrees_root.joinpath(*all_segments)
@@ -41,6 +41,9 @@ from pr_loop_shared_constants.claude_settings_keys_constants import (
41
41
  CLAUDE_SETTINGS_ENVIRONMENT_KEY,
42
42
  CLAUDE_SETTINGS_PERMISSIONS_KEY,
43
43
  )
44
+ from stale_worktree_rule_sweep import ( # noqa: E402
45
+ sweep_stale_worktree_rules_from_settings,
46
+ )
44
47
 
45
48
 
46
49
  def remove_values_from_list(
@@ -219,6 +222,7 @@ def revoke_permissions_for_current_directory() -> None:
219
222
  ALL_AGENT_CONFIG_PATH_PATTERNS,
220
223
  )
221
224
  settings = load_settings(claude_user_settings_path)
225
+ worktree_sweep_removed_count = sweep_stale_worktree_rules_from_settings(settings)
222
226
  allow_rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
223
227
  deny_rules_removed_count = remove_rules_from_deny_list(
224
228
  settings, all_agent_config_deny_rules
@@ -234,6 +238,7 @@ def revoke_permissions_for_current_directory() -> None:
234
238
  + deny_rules_removed_count
235
239
  + directories_removed_count
236
240
  + environment_entries_removed_count
241
+ + worktree_sweep_removed_count
237
242
  )
238
243
  if total_changes_count == 0:
239
244
  print(f"Project path: {project_path}")
@@ -0,0 +1,107 @@
1
+ """Sweep permission rules that point at deleted worktrees, then deduplicate.
2
+
3
+ The grant flow mints per-worktree allow/deny rules into ~/.claude/settings.json
4
+ and the revoke flow removes them, but a revoke that never runs leaves rules
5
+ for deleted worktrees behind, and they pile up run after run. Before the grant
6
+ and revoke flows write, they call this sweep so the settings hold no rule for a
7
+ worktree directory that is gone::
8
+
9
+ allow: Edit(<root>/repo/live/.claude/**) live on disk -> kept
10
+ Edit(<root>/repo/gone/.claude/**) deleted -> dropped
11
+ Edit(<root>/repo/live/.claude/**) duplicate -> dropped
12
+
13
+ A rule whose target lies outside the worktrees root is left untouched.
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+ from _claude_permissions_common import remove_matching_entries_from_list
19
+ from pr_loop_shared_constants.claude_settings_keys_constants import (
20
+ CLAUDE_SETTINGS_ALLOW_KEY,
21
+ CLAUDE_SETTINGS_DENY_KEY,
22
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
23
+ )
24
+ from pr_loop_shared_constants.stale_worktree_rule_sweep_constants import (
25
+ get_claude_worktrees_root,
26
+ worktree_directory_for_rule,
27
+ )
28
+
29
+
30
+ def _is_stale_worktree_rule(candidate_rule: object, worktrees_root: Path) -> bool:
31
+ if not isinstance(candidate_rule, str):
32
+ return False
33
+ worktree_directory = worktree_directory_for_rule(candidate_rule, worktrees_root)
34
+ if worktree_directory is None:
35
+ return False
36
+ return not worktree_directory.exists()
37
+
38
+
39
+ def _deduplicate_list_preserving_order(all_entries: list[object]) -> int:
40
+ all_unique_entries: list[object] = []
41
+ for each_entry in all_entries:
42
+ if each_entry not in all_unique_entries:
43
+ all_unique_entries.append(each_entry)
44
+ removed_count = len(all_entries) - len(all_unique_entries)
45
+ all_entries[:] = all_unique_entries
46
+ return removed_count
47
+
48
+
49
+ def _sweep_and_deduplicate_rule_list(
50
+ all_rules: list[object], worktrees_root: Path
51
+ ) -> int:
52
+ stale_removed_count = remove_matching_entries_from_list(
53
+ all_rules,
54
+ lambda candidate_rule: _is_stale_worktree_rule(candidate_rule, worktrees_root),
55
+ )
56
+ duplicate_removed_count = _deduplicate_list_preserving_order(all_rules)
57
+ return stale_removed_count + duplicate_removed_count
58
+
59
+
60
+ def _permission_rule_list(
61
+ all_settings: dict[str, object], list_key: str
62
+ ) -> list[object] | None:
63
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
64
+ if not isinstance(permissions_section, dict):
65
+ return None
66
+ rule_list = permissions_section.get(list_key)
67
+ if not isinstance(rule_list, list):
68
+ return None
69
+ return rule_list
70
+
71
+
72
+ def sweep_and_deduplicate_permission_lists(
73
+ all_settings: dict[str, object], worktrees_root: Path
74
+ ) -> int:
75
+ """Drop stale worktree rules and duplicates from the allow and deny lists.
76
+
77
+ Args:
78
+ all_settings: The parsed ~/.claude/settings.json dictionary, mutated
79
+ in place.
80
+ worktrees_root: The ~/.claude/worktrees directory rules live under.
81
+
82
+ Returns:
83
+ The total count of rules removed across both lists.
84
+ """
85
+ total_removed_count = 0
86
+ for each_list_key in (CLAUDE_SETTINGS_ALLOW_KEY, CLAUDE_SETTINGS_DENY_KEY):
87
+ rule_list = _permission_rule_list(all_settings, each_list_key)
88
+ if rule_list is None:
89
+ continue
90
+ total_removed_count += _sweep_and_deduplicate_rule_list(
91
+ rule_list, worktrees_root
92
+ )
93
+ return total_removed_count
94
+
95
+
96
+ def sweep_stale_worktree_rules_from_settings(all_settings: dict[str, object]) -> int:
97
+ """Sweep the settings against the real ~/.claude/worktrees root on disk.
98
+
99
+ Args:
100
+ all_settings: The parsed ~/.claude/settings.json dictionary, mutated
101
+ in place.
102
+
103
+ Returns:
104
+ The total count of rules removed across the allow and deny lists.
105
+ """
106
+ worktrees_root = get_claude_worktrees_root()
107
+ return sweep_and_deduplicate_permission_lists(all_settings, worktrees_root)
@@ -27,6 +27,8 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
27
27
  | `test_reviewer_availability.py` | `reviewer_availability.py` end-to-end: Copilot and Bugbot availability, opt-out via `CLAUDE_REVIEWS_DISABLED`, and every Copilot quota outcome |
28
28
  | `test_reviewer_availability_constants.py` | `pr_loop_shared_constants/reviewer_availability_constants.py` |
29
29
  | `test_revoke_project_claude_permissions.py` | `revoke_project_claude_permissions.py` end-to-end |
30
+ | `test_stale_worktree_rule_sweep.py` | `stale_worktree_rule_sweep.py` sweep and deduplication end-to-end |
31
+ | `test_stale_worktree_rule_sweep_constants.py` | `pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py` worktrees-root resolution and rule-format readers |
30
32
  | `test_agent_config_carveout.py` | Agent-config deny-rule carve-out logic |
31
33
  | `conftest.py` | Shared pytest fixtures |
32
34
 
@@ -15,7 +15,6 @@ import json
15
15
  import sys
16
16
  from pathlib import Path
17
17
  from types import ModuleType
18
- from typing import Any
19
18
 
20
19
  import pytest
21
20
 
@@ -79,7 +78,7 @@ def _seed_grant_then_run(
79
78
  fake_settings_path: Path,
80
79
  fake_project_root: Path,
81
80
  monkeypatch: pytest.MonkeyPatch,
82
- pre_existing_settings: dict[str, Any],
81
+ pre_existing_settings: dict[str, object],
83
82
  ) -> None:
84
83
  fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
85
84
  grant_module = _load_grant_module()
@@ -96,7 +95,7 @@ def _seed_revoke_then_run(
96
95
  fake_settings_path: Path,
97
96
  fake_project_root: Path,
98
97
  monkeypatch: pytest.MonkeyPatch,
99
- pre_existing_settings: dict[str, Any],
98
+ pre_existing_settings: dict[str, object],
100
99
  ) -> None:
101
100
  fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
102
101
  revoke_module = _load_revoke_module()
@@ -119,6 +118,24 @@ def _project_path_as_posix(fake_project_root: Path) -> str:
119
118
  return str(fake_project_root).replace("\\", "/")
120
119
 
121
120
 
121
+ def _two_stale_trust_entries(project_path_posix: str) -> tuple[str, str]:
122
+ base_entry = f"Trusted local workspace: {project_path_posix}/.claude/**"
123
+ return f"{base_entry} wording form one", f"{base_entry} wording form two"
124
+
125
+
126
+ def _project_trust_entry_count(
127
+ environment_list: list[object], project_path_posix: str
128
+ ) -> int:
129
+ project_marker = f"{project_path_posix}/.claude/**"
130
+ return sum(
131
+ 1
132
+ for each_entry in environment_list
133
+ if isinstance(each_entry, str)
134
+ and each_entry.startswith("Trusted local workspace:")
135
+ and project_marker in each_entry
136
+ )
137
+
138
+
122
139
  def test_grant_writes_deny_rules_for_every_tool_and_pattern(
123
140
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
124
141
  ) -> None:
@@ -140,14 +157,13 @@ def test_grant_writes_deny_rules_for_every_tool_and_pattern(
140
157
  )
141
158
 
142
159
 
143
- def test_grant_writes_glob_deny_rules_for_every_agent_config_pattern(
160
+ def test_grant_mints_only_edit_and_read_rules(
144
161
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
145
162
  ) -> None:
146
- """Glob must be in the deny tuple so agent-config paths require approval.
147
-
148
- The AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE promises Edit/Write/Read/Glob
149
- trust EXCEPT for agent-config files. Glob deny rules are how the EXCEPT
150
- clause is honored for the Glob tool.
163
+ """Claude Code matches file operations against Edit(path) and Read(path)
164
+ rules only, so a grant run mints those two tools alone. A Write(path) or
165
+ Glob(path) rule is ignored by the permission checker and prints a startup
166
+ warning, so no minted rule may name either tool.
151
167
  """
152
168
  fake_project_root = _make_fake_project(tmp_path)
153
169
  fake_settings_path = tmp_path / "settings.json"
@@ -157,15 +173,15 @@ def test_grant_writes_glob_deny_rules_for_every_agent_config_pattern(
157
173
  )
158
174
  capsys.readouterr()
159
175
  written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
160
- deny_list = written_settings["permissions"]["deny"]
161
- project_path_posix = _project_path_as_posix(fake_project_root)
162
- assert "Glob" in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
163
- assert "Glob" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
164
- for each_pattern in constants_module.ALL_AGENT_CONFIG_PATH_PATTERNS:
165
- expected_glob_rule = f"Glob({project_path_posix}/.claude/{each_pattern})"
166
- assert expected_glob_rule in deny_list, (
167
- f"deny list missing expected Glob rule {expected_glob_rule!r}"
168
- )
176
+ permissions_section = written_settings["permissions"]
177
+ minted_rules = permissions_section["allow"] + permissions_section["deny"]
178
+ assert "Write" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
179
+ assert "Write" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
180
+ assert "Glob" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
181
+ assert minted_rules, "grant run wrote no permission rules"
182
+ for each_rule in minted_rules:
183
+ assert "Write(" not in each_rule, f"unmatched Write rule: {each_rule!r}"
184
+ assert "Glob(" not in each_rule, f"unmatched Glob rule: {each_rule!r}"
169
185
 
170
186
 
171
187
  def test_grant_purges_stale_trust_entries_then_writes_current_template(
@@ -174,18 +190,10 @@ def test_grant_purges_stale_trust_entries_then_writes_current_template(
174
190
  fake_project_root = _make_fake_project(tmp_path)
175
191
  fake_settings_path = tmp_path / "settings.json"
176
192
  project_path_posix = _project_path_as_posix(fake_project_root)
177
- stale_entry_a = (
178
- f"Trusted local workspace: {project_path_posix}/.claude/** old wording form A"
179
- )
180
- stale_entry_b = (
181
- f"Trusted local workspace: {project_path_posix}/.claude/** "
182
- f"different earlier wording"
183
- )
193
+ stale_entry_a, stale_entry_b = _two_stale_trust_entries(project_path_posix)
184
194
  unrelated_entry = "Some unrelated environment hint"
185
- pre_existing_settings: dict[str, Any] = {
186
- "autoMode": {
187
- "environment": [stale_entry_a, stale_entry_b, unrelated_entry],
188
- },
195
+ pre_existing_settings: dict[str, object] = {
196
+ "autoMode": {"environment": [stale_entry_a, stale_entry_b, unrelated_entry]},
189
197
  }
190
198
  _seed_grant_then_run(
191
199
  fake_settings_path,
@@ -199,14 +207,7 @@ def test_grant_purges_stale_trust_entries_then_writes_current_template(
199
207
  assert stale_entry_a not in environment_list
200
208
  assert stale_entry_b not in environment_list
201
209
  assert unrelated_entry in environment_list
202
- matching_trust_entries = [
203
- each_entry
204
- for each_entry in environment_list
205
- if isinstance(each_entry, str)
206
- and each_entry.startswith("Trusted local workspace:")
207
- and f"{project_path_posix}/.claude/**" in each_entry
208
- ]
209
- assert len(matching_trust_entries) == 1
210
+ assert _project_trust_entry_count(environment_list, project_path_posix) == 1
210
211
  assert "Stale auto-mode environment entries purged" in captured.out
211
212
 
212
213
 
@@ -223,10 +224,8 @@ def test_revoke_removes_deny_rules(
223
224
  constants_module.ALL_AGENT_CONFIG_DENY_TOOLS,
224
225
  constants_module.ALL_AGENT_CONFIG_PATH_PATTERNS,
225
226
  )
226
- pre_existing_settings: dict[str, Any] = {
227
- "permissions": {
228
- "deny": list(all_deny_rules),
229
- },
227
+ pre_existing_settings: dict[str, object] = {
228
+ "permissions": {"deny": list(all_deny_rules)},
230
229
  }
231
230
  _seed_revoke_then_run(
232
231
  fake_settings_path,
@@ -248,16 +247,11 @@ def test_revoke_removes_every_legacy_trust_entry_for_project(
248
247
  fake_project_root = _make_fake_project(tmp_path)
249
248
  fake_settings_path = tmp_path / "settings.json"
250
249
  project_path_posix = _project_path_as_posix(fake_project_root)
251
- legacy_entry_a = (
252
- f"Trusted local workspace: {project_path_posix}/.claude/** template revision A"
253
- )
254
- legacy_entry_b = (
255
- f"Trusted local workspace: {project_path_posix}/.claude/** template revision B"
256
- )
250
+ legacy_entry_a, legacy_entry_b = _two_stale_trust_entries(project_path_posix)
257
251
  unrelated_other_project_entry = (
258
252
  "Trusted local workspace: /some/other/project/.claude/** still valid"
259
253
  )
260
- pre_existing_settings: dict[str, Any] = {
254
+ pre_existing_settings: dict[str, object] = {
261
255
  "autoMode": {
262
256
  "environment": [
263
257
  legacy_entry_a,
@@ -293,43 +287,31 @@ def test_is_trust_entry_for_project_predicate_filters_by_prefix_and_project_path
293
287
  common_module = _load_common_module()
294
288
  project_path_posix = "/fake/proj"
295
289
  trust_prefix = "Trusted local workspace:"
296
- non_string_value: object = 42
297
- assert (
298
- common_module.is_trust_entry_for_project(
299
- non_string_value, project_path_posix, trust_prefix
300
- )
301
- is False
302
- )
303
290
  wrong_prefix_entry = (
304
291
  f"Something else: {project_path_posix}/.claude/** with marker token"
305
292
  )
306
- assert (
307
- common_module.is_trust_entry_for_project(
308
- wrong_prefix_entry, project_path_posix, trust_prefix
309
- )
310
- is False
311
- )
312
293
  different_project_entry = (
313
294
  "Trusted local workspace: /other/project/.claude/** unrelated"
314
295
  )
315
- assert (
316
- common_module.is_trust_entry_for_project(
317
- different_project_entry, project_path_posix, trust_prefix
318
- )
319
- is False
320
- )
321
296
  matching_entry = (
322
297
  f"Trusted local workspace: {project_path_posix}/.claude/** any wording form"
323
298
  )
324
- assert (
325
- common_module.is_trust_entry_for_project(
326
- matching_entry, project_path_posix, trust_prefix
299
+ predicate_cases: list[tuple[object, bool]] = [
300
+ (42, False),
301
+ (wrong_prefix_entry, False),
302
+ (different_project_entry, False),
303
+ (matching_entry, True),
304
+ ]
305
+ for candidate_entry, expected_result in predicate_cases:
306
+ assert (
307
+ common_module.is_trust_entry_for_project(
308
+ candidate_entry, project_path_posix, trust_prefix
309
+ )
310
+ is expected_result
327
311
  )
328
- is True
329
- )
330
312
 
331
313
 
332
- def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
314
+ def test_is_trust_entry_for_project_rejects_cross_project_suffix_collision() -> None:
333
315
  """When the project_path is a path suffix of an unrelated entry's path,
334
316
  the predicate must reject the unrelated entry (the boundary anchor case)."""
335
317
  common_module = _load_common_module()
@@ -25,16 +25,14 @@ constants_module = _load_constants_module()
25
25
 
26
26
 
27
27
  def test_exposes_all_permission_allow_tools_tuple() -> None:
28
- assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
28
+ assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Read")
29
+ assert "Write" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
29
30
 
30
31
 
31
- def test_exposes_all_agent_config_deny_tools_tuple_with_glob() -> None:
32
- assert constants_module.ALL_AGENT_CONFIG_DENY_TOOLS == (
33
- "Edit",
34
- "Write",
35
- "Read",
36
- "Glob",
37
- )
32
+ def test_exposes_all_agent_config_deny_tools_tuple() -> None:
33
+ assert constants_module.ALL_AGENT_CONFIG_DENY_TOOLS == ("Edit", "Read")
34
+ assert "Write" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
35
+ assert "Glob" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
38
36
  assert "Glob" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
39
37
 
40
38
 
@@ -56,13 +54,9 @@ def test_template_derives_human_readable_pattern_list_from_pattern_tuple() -> No
56
54
  template_text: str = constants_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
57
55
  assert "{project_path}" in template_text
58
56
  for each_pattern in constants_module.ALL_AGENT_CONFIG_PATH_PATTERNS:
59
- if each_pattern.endswith("/**"):
60
- directory_name = each_pattern[: -len("/**")]
61
- expected_phrase = f"anything under {directory_name}/"
62
- elif each_pattern == "mcp.json":
63
- expected_phrase = "the mcp.json file"
64
- else:
65
- expected_phrase = each_pattern
57
+ expected_phrase = constants_module._describe_agent_config_pattern_for_humans(
58
+ each_pattern
59
+ )
66
60
  assert expected_phrase in template_text, (
67
61
  f"template missing derived phrase for pattern {each_pattern!r}: "
68
62
  f"expected {expected_phrase!r}"
@@ -29,9 +29,20 @@ def _load_grant_module() -> ModuleType:
29
29
  return module
30
30
 
31
31
 
32
+ def _nested_list_at(settings: dict[str, object], section_key: str, list_key: str) -> (
33
+ list[object]
34
+ ):
35
+ section = settings[section_key]
36
+ assert isinstance(section, dict)
37
+ nested_list = section[list_key]
38
+ assert isinstance(nested_list, list)
39
+ return nested_list
40
+
41
+
32
42
  def test_module_imports_constants_from_config_modules() -> None:
33
43
  grant_module = _load_grant_module()
34
- assert grant_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
44
+ assert grant_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Read")
45
+ assert grant_module.ALL_AGENT_CONFIG_DENY_TOOLS == ("Edit", "Read")
35
46
  assert "{project_path}" in grant_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
36
47
  assert grant_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
37
48
 
@@ -47,3 +58,82 @@ def test_grant_module_guards_sys_path_insert_against_duplicates() -> None:
47
58
  "grant_project_claude_permissions.py must guard sys.path.insert against "
48
59
  "duplicate entries on reload (consistent with sibling modules)"
49
60
  )
61
+
62
+
63
+ def test_add_rules_to_allow_list_appends_new_rules_then_skips_duplicates() -> None:
64
+ grant_module = _load_grant_module()
65
+ settings: dict[str, object] = {}
66
+ new_rules = ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"]
67
+ first_added_count = grant_module.add_rules_to_allow_list(settings, new_rules)
68
+ assert first_added_count == 2
69
+ allow_list = _nested_list_at(settings, "permissions", "allow")
70
+ assert new_rules[0] in allow_list
71
+ assert new_rules[1] in allow_list
72
+ second_added_count = grant_module.add_rules_to_allow_list(settings, new_rules)
73
+ assert second_added_count == 0
74
+
75
+
76
+ def test_add_rules_to_deny_list_appends_new_rules_then_skips_duplicates() -> None:
77
+ grant_module = _load_grant_module()
78
+ settings: dict[str, object] = {}
79
+ new_rules = ["Edit(/proj/.claude/hooks/**)", "Read(/proj/.claude/hooks/**)"]
80
+ first_added_count = grant_module.add_rules_to_deny_list(settings, new_rules)
81
+ assert first_added_count == 2
82
+ deny_list = _nested_list_at(settings, "permissions", "deny")
83
+ assert new_rules[0] in deny_list
84
+ assert new_rules[1] in deny_list
85
+ second_added_count = grant_module.add_rules_to_deny_list(settings, new_rules)
86
+ assert second_added_count == 0
87
+
88
+
89
+ def test_add_directory_to_additional_directories_is_idempotent() -> None:
90
+ grant_module = _load_grant_module()
91
+ settings: dict[str, object] = {}
92
+ project_directory = "/proj"
93
+ first_added_count = grant_module.add_directory_to_additional_directories(
94
+ settings, project_directory
95
+ )
96
+ assert first_added_count == 1
97
+ directories = _nested_list_at(settings, "permissions", "additionalDirectories")
98
+ assert project_directory in directories
99
+ second_added_count = grant_module.add_directory_to_additional_directories(
100
+ settings, project_directory
101
+ )
102
+ assert second_added_count == 0
103
+
104
+
105
+ def test_add_auto_mode_environment_entry_is_idempotent() -> None:
106
+ grant_module = _load_grant_module()
107
+ settings: dict[str, object] = {}
108
+ entry_text = "Trusted local workspace: /proj/.claude/** current wording"
109
+ first_added_count = grant_module.add_auto_mode_environment_entry(
110
+ settings, entry_text
111
+ )
112
+ assert first_added_count == 1
113
+ environment = _nested_list_at(settings, "autoMode", "environment")
114
+ assert entry_text in environment
115
+ second_added_count = grant_module.add_auto_mode_environment_entry(
116
+ settings, entry_text
117
+ )
118
+ assert second_added_count == 0
119
+
120
+
121
+ def test_purge_stale_trust_entries_removes_matches_and_keeps_protected() -> None:
122
+ grant_module = _load_grant_module()
123
+ trust_prefix = grant_module.AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
124
+ stale_entry = "Trusted local workspace: /proj/.claude/** stale wording"
125
+ protected_entry = "Trusted local workspace: /proj/.claude/** current wording"
126
+ unrelated_entry = "Some unrelated environment hint"
127
+ settings: dict[str, object] = {
128
+ "autoMode": {
129
+ "environment": [stale_entry, protected_entry, unrelated_entry],
130
+ },
131
+ }
132
+ purged_count = grant_module.purge_stale_trust_entries(
133
+ settings, "/proj", trust_prefix, protected_entry=protected_entry
134
+ )
135
+ assert purged_count == 1
136
+ remaining_environment = _nested_list_at(settings, "autoMode", "environment")
137
+ assert stale_entry not in remaining_environment
138
+ assert protected_entry in remaining_environment
139
+ assert unrelated_entry in remaining_environment