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
@@ -15,10 +15,12 @@ if parent_directory not in sys.path:
15
15
  sys.path.insert(0, parent_directory)
16
16
 
17
17
  from _claude_permissions_common import ( # noqa: E402
18
+ all_project_path_aliases_for_reap,
18
19
  build_agent_config_deny_rules,
19
20
  build_permission_rules,
20
21
  exit_with_error,
21
22
  get_current_project_path,
23
+ is_inert_file_permission_rule,
22
24
  is_trust_entry_for_project,
23
25
  is_valid_project_root,
24
26
  load_settings,
@@ -29,8 +31,12 @@ from _claude_permissions_common import ( # noqa: E402
29
31
  from pr_loop_shared_constants.claude_permissions_constants import (
30
32
  ALL_AGENT_CONFIG_DENY_TOOLS,
31
33
  ALL_AGENT_CONFIG_PATH_PATTERNS,
34
+ ALL_LEGACY_PERMISSION_REAP_TOOLS,
32
35
  ALL_PERMISSION_ALLOW_TOOLS,
33
36
  AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
37
+ INERT_RULES_REMOVED_LOG_PREFIX,
38
+ PROJECT_LOCAL_INERT_RULES_REMOVED_LOG_PREFIX,
39
+ get_claude_project_local_settings_path,
34
40
  get_claude_user_settings_path,
35
41
  )
36
42
  from pr_loop_shared_constants.claude_settings_keys_constants import (
@@ -46,6 +52,179 @@ from stale_worktree_rule_sweep import ( # noqa: E402
46
52
  )
47
53
 
48
54
 
55
+ def _reap_tool_names(all_mint_tool_names: tuple[str, ...]) -> tuple[str, ...]:
56
+ """Return mint tool names plus legacy Write/Glob forms a revoke run reaps.
57
+
58
+ Keeps mint tuples (Edit/Read) untouched and widens only the revoke surface
59
+ so inert pre-#158 Write()/Glob() rules are removed with the current mint set.
60
+ """
61
+ return all_mint_tool_names + ALL_LEGACY_PERMISSION_REAP_TOOLS
62
+
63
+
64
+ def remove_inert_file_permission_rules(all_settings: dict[str, object]) -> int:
65
+ """Remove Write/Glob/NotebookEdit allow and deny rules path-agnostically.
66
+
67
+ ::
68
+
69
+ remove_inert_file_permission_rules(
70
+ {"permissions": {"allow": ["Write(/wt/**)", "Edit(/p/.claude/**)"]}}
71
+ ) # ok: 1, allow left with Edit only
72
+
73
+ Claude only honors Edit for file permission checks; leftover Write, Glob,
74
+ and NotebookEdit rules from older grants only produce startup warnings.
75
+
76
+ Args:
77
+ all_settings: Parsed Claude user settings dictionary (mutated in place).
78
+
79
+ Returns:
80
+ Number of inert rules removed from allow and deny combined.
81
+ """
82
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
83
+ if not isinstance(permissions_section, dict):
84
+ return 0
85
+ total_removed_count = 0
86
+ for each_list_key in (CLAUDE_SETTINGS_ALLOW_KEY, CLAUDE_SETTINGS_DENY_KEY):
87
+ existing_rule_list = permissions_section.get(each_list_key)
88
+ if not isinstance(existing_rule_list, list):
89
+ continue
90
+ total_removed_count += remove_matching_entries_from_list(
91
+ existing_rule_list, is_inert_file_permission_rule
92
+ )
93
+ return total_removed_count
94
+
95
+
96
+ def build_project_scoped_allow_rules_for_reap(project_path: str) -> list[str]:
97
+ """Build allow rules for every path alias of *project_path* (incl. $HOME).
98
+
99
+ ::
100
+
101
+ build_project_scoped_allow_rules_for_reap("/repo")
102
+ # ok: includes Edit(/repo/.claude/**) and Write(/repo/.claude/**)
103
+
104
+ Args:
105
+ project_path: POSIX project root from get_current_project_path.
106
+
107
+ Returns:
108
+ Allow rule strings covering mint plus legacy tools for each path alias.
109
+ """
110
+ all_allow_rules: list[str] = []
111
+ for each_path_alias in all_project_path_aliases_for_reap(project_path):
112
+ all_allow_rules.extend(
113
+ build_permission_rules(
114
+ each_path_alias, _reap_tool_names(ALL_PERMISSION_ALLOW_TOOLS)
115
+ )
116
+ )
117
+ return all_allow_rules
118
+
119
+
120
+ def build_project_scoped_deny_rules_for_reap(project_path: str) -> list[str]:
121
+ """Build agent-config deny rules for every path alias of *project_path*.
122
+
123
+ Args:
124
+ project_path: POSIX project root from get_current_project_path.
125
+
126
+ Returns:
127
+ Deny rule strings for mint plus legacy tools across path aliases.
128
+ """
129
+ all_deny_rules: list[str] = []
130
+ for each_path_alias in all_project_path_aliases_for_reap(project_path):
131
+ all_deny_rules.extend(
132
+ build_agent_config_deny_rules(
133
+ each_path_alias,
134
+ _reap_tool_names(ALL_AGENT_CONFIG_DENY_TOOLS),
135
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
136
+ )
137
+ )
138
+ return all_deny_rules
139
+
140
+
141
+ def _remove_directory_and_trust_for_path_aliases(
142
+ all_settings: dict[str, object], project_path: str
143
+ ) -> tuple[int, int]:
144
+ """Remove additionalDirectories and trust entries for each path alias."""
145
+ directories_removed_count = 0
146
+ environment_entries_removed_count = 0
147
+ for each_path_alias in all_project_path_aliases_for_reap(project_path):
148
+ directories_removed_count += remove_directory_from_additional_directories(
149
+ all_settings, each_path_alias
150
+ )
151
+ environment_entries_removed_count += remove_trust_entries_for_project(
152
+ all_settings, each_path_alias, AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
153
+ )
154
+ return directories_removed_count, environment_entries_removed_count
155
+
156
+
157
+ def strip_inert_rules_from_settings_file(settings_path: Path) -> int:
158
+ """Load *settings_path*, strip inert tools, save when anything changed.
159
+
160
+ ::
161
+
162
+ strip_inert_rules_from_settings_file(missing_path) # ok: 0
163
+ strip_inert_rules_from_settings_file(local_with_Write) # ok: N > 0
164
+
165
+ Args:
166
+ settings_path: User or project-local Claude settings JSON path.
167
+
168
+ Returns:
169
+ Number of inert rules removed; 0 when the file is missing or clean.
170
+ """
171
+ if not settings_path.is_file():
172
+ return 0
173
+ settings = load_settings(settings_path)
174
+ inert_rules_removed_count = remove_inert_file_permission_rules(settings)
175
+ if inert_rules_removed_count == 0:
176
+ return 0
177
+ prune_empty_list_then_empty_section(
178
+ settings,
179
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
180
+ CLAUDE_SETTINGS_ALLOW_KEY,
181
+ )
182
+ prune_empty_list_then_empty_section(
183
+ settings,
184
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
185
+ CLAUDE_SETTINGS_DENY_KEY,
186
+ )
187
+ save_settings(settings_path, settings)
188
+ return inert_rules_removed_count
189
+
190
+
191
+ def _print_revoke_summary(
192
+ project_path: str,
193
+ claude_user_settings_path: Path,
194
+ allow_rules_removed_count: int,
195
+ permission_rules_count: int,
196
+ deny_rules_removed_count: int,
197
+ agent_config_deny_rules_count: int,
198
+ inert_rules_removed_count: int,
199
+ project_local_inert_rules_removed_count: int,
200
+ directories_removed_count: int,
201
+ environment_entries_removed_count: int,
202
+ ) -> None:
203
+ """Print the post-revoke summary lines to stdout."""
204
+ inert_rules_removed_log_prefix = INERT_RULES_REMOVED_LOG_PREFIX
205
+ project_local_inert_rules_removed_log_prefix = (
206
+ PROJECT_LOCAL_INERT_RULES_REMOVED_LOG_PREFIX
207
+ )
208
+ print(f"Project path: {project_path}")
209
+ print(f"Settings file: {claude_user_settings_path}")
210
+ print(
211
+ f"Allow rules removed: {allow_rules_removed_count} of {permission_rules_count}"
212
+ )
213
+ print(
214
+ f"Deny rules removed: {deny_rules_removed_count} of "
215
+ f"{agent_config_deny_rules_count}"
216
+ )
217
+ print(f"{inert_rules_removed_log_prefix}{inert_rules_removed_count}")
218
+ print(
219
+ f"{project_local_inert_rules_removed_log_prefix}"
220
+ f"{project_local_inert_rules_removed_count}"
221
+ )
222
+ print(f"Additional directories removed: {directories_removed_count}")
223
+ print(
224
+ f"Auto-mode environment entries removed: {environment_entries_removed_count}"
225
+ )
226
+
227
+
49
228
  def remove_values_from_list(
50
229
  all_target_list: list[object], all_values_to_remove: set[str]
51
230
  ) -> int:
@@ -192,13 +371,79 @@ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
192
371
  )
193
372
 
194
373
 
374
+ def _apply_user_settings_revoke(
375
+ all_settings: dict[str, object],
376
+ project_path: str,
377
+ all_permission_rules: list[str],
378
+ all_agent_config_deny_rules: list[str],
379
+ ) -> tuple[int, int, int, int, int, int]:
380
+ """Apply project-scoped and inert removals to the user settings dict.
381
+
382
+ Returns:
383
+ Counts: allow, deny, directories, environment, worktree_sweep, inert.
384
+ """
385
+ worktree_sweep_removed_count = sweep_stale_worktree_rules_from_settings(
386
+ all_settings
387
+ )
388
+ inert_rules_removed_count = remove_inert_file_permission_rules(all_settings)
389
+ allow_rules_removed_count = remove_rules_from_allow_list(
390
+ all_settings, all_permission_rules
391
+ )
392
+ deny_rules_removed_count = remove_rules_from_deny_list(
393
+ all_settings, all_agent_config_deny_rules
394
+ )
395
+ directories_removed_count, environment_entries_removed_count = (
396
+ _remove_directory_and_trust_for_path_aliases(all_settings, project_path)
397
+ )
398
+ return (
399
+ allow_rules_removed_count,
400
+ deny_rules_removed_count,
401
+ directories_removed_count,
402
+ environment_entries_removed_count,
403
+ worktree_sweep_removed_count,
404
+ inert_rules_removed_count,
405
+ )
406
+
407
+
408
+ def _require_valid_project_root_or_exit() -> Path:
409
+ """Return cwd when it is a project root, otherwise exit with an error."""
410
+ project_root_path = Path.cwd()
411
+ if is_valid_project_root(project_root_path):
412
+ return project_root_path
413
+ print(
414
+ f"ERROR: cwd {project_root_path} is not a project root "
415
+ f"(no .git or .claude). Run from a project root.",
416
+ file=sys.stderr,
417
+ )
418
+ raise SystemExit(1)
419
+
420
+
421
+ def _sum_user_settings_change_counts(
422
+ allow_rules_removed_count: int,
423
+ deny_rules_removed_count: int,
424
+ directories_removed_count: int,
425
+ environment_entries_removed_count: int,
426
+ worktree_sweep_removed_count: int,
427
+ inert_rules_removed_count: int,
428
+ ) -> int:
429
+ """Return the total number of user-settings mutations for this revoke."""
430
+ return (
431
+ allow_rules_removed_count
432
+ + deny_rules_removed_count
433
+ + directories_removed_count
434
+ + environment_entries_removed_count
435
+ + worktree_sweep_removed_count
436
+ + inert_rules_removed_count
437
+ )
438
+
439
+
195
440
  def revoke_permissions_for_current_directory() -> None:
196
441
  """Revoke permissions previously granted for the current project directory.
197
442
 
198
- Reads the current project path, constructs the matching allow and deny
199
- permission rules, removes them from ~/.claude/settings.json, removes
200
- every trust entry for the project from autoMode.environment, and prunes
201
- any newly empty sections.
443
+ Removes matching allow/deny rules from ~/.claude/settings.json, strips
444
+ inert Write/Glob/NotebookEdit rules from user settings and project
445
+ ``.claude/settings.local.json`` when present, clears trust entries, and
446
+ prunes empty sections.
202
447
 
203
448
  Raises:
204
449
  SystemExit: When the current directory is not a valid project root.
@@ -206,59 +451,56 @@ def revoke_permissions_for_current_directory() -> None:
206
451
  contains glob metacharacters.
207
452
  """
208
453
  claude_user_settings_path: Path = get_claude_user_settings_path()
209
- project_root_path = Path.cwd()
210
- if not is_valid_project_root(project_root_path):
211
- print(
212
- f"ERROR: cwd {project_root_path} is not a project root "
213
- f"(no .git or .claude). Run from a project root.",
214
- file=sys.stderr,
215
- )
216
- raise SystemExit(1)
454
+ project_root_path = _require_valid_project_root_or_exit()
217
455
  project_path = get_current_project_path()
218
- permission_rules = build_permission_rules(project_path, ALL_PERMISSION_ALLOW_TOOLS)
219
- all_agent_config_deny_rules = build_agent_config_deny_rules(
220
- project_path,
221
- ALL_AGENT_CONFIG_DENY_TOOLS,
222
- ALL_AGENT_CONFIG_PATH_PATTERNS,
456
+ all_permission_rules = build_project_scoped_allow_rules_for_reap(project_path)
457
+ all_agent_config_deny_rules = build_project_scoped_deny_rules_for_reap(
458
+ project_path
223
459
  )
224
- settings = load_settings(claude_user_settings_path)
225
- worktree_sweep_removed_count = sweep_stale_worktree_rules_from_settings(settings)
226
- allow_rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
227
- deny_rules_removed_count = remove_rules_from_deny_list(
228
- settings, all_agent_config_deny_rules
460
+ all_settings = load_settings(claude_user_settings_path)
461
+ (
462
+ allow_rules_removed_count,
463
+ deny_rules_removed_count,
464
+ directories_removed_count,
465
+ environment_entries_removed_count,
466
+ worktree_sweep_removed_count,
467
+ inert_rules_removed_count,
468
+ ) = _apply_user_settings_revoke(
469
+ all_settings,
470
+ project_path,
471
+ all_permission_rules,
472
+ all_agent_config_deny_rules,
229
473
  )
230
- directories_removed_count = remove_directory_from_additional_directories(
231
- settings, project_path
474
+ user_settings_changes_count = _sum_user_settings_change_counts(
475
+ allow_rules_removed_count,
476
+ deny_rules_removed_count,
477
+ directories_removed_count,
478
+ environment_entries_removed_count,
479
+ worktree_sweep_removed_count,
480
+ inert_rules_removed_count,
232
481
  )
233
- environment_entries_removed_count = remove_trust_entries_for_project(
234
- settings, project_path, AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
482
+ if user_settings_changes_count > 0:
483
+ prune_settings_after_revoke(all_settings)
484
+ save_settings(claude_user_settings_path, all_settings)
485
+ project_local_inert_rules_removed_count = strip_inert_rules_from_settings_file(
486
+ get_claude_project_local_settings_path(project_root_path)
235
487
  )
236
- total_changes_count = (
237
- allow_rules_removed_count
238
- + deny_rules_removed_count
239
- + directories_removed_count
240
- + environment_entries_removed_count
241
- + worktree_sweep_removed_count
242
- )
243
- if total_changes_count == 0:
488
+ if user_settings_changes_count + project_local_inert_rules_removed_count == 0:
244
489
  print(f"Project path: {project_path}")
245
490
  print(f"Settings file: {claude_user_settings_path}")
246
491
  print("No changes to revoke; settings file left untouched.")
247
492
  return
248
- prune_settings_after_revoke(settings)
249
- save_settings(claude_user_settings_path, settings)
250
- print(f"Project path: {project_path}")
251
- print(f"Settings file: {claude_user_settings_path}")
252
- print(
253
- f"Allow rules removed: {allow_rules_removed_count} of {len(permission_rules)}"
254
- )
255
- print(
256
- f"Deny rules removed: {deny_rules_removed_count} of "
257
- f"{len(all_agent_config_deny_rules)}"
258
- )
259
- print(f"Additional directories removed: {directories_removed_count}")
260
- print(
261
- f"Auto-mode environment entries removed: {environment_entries_removed_count}"
493
+ _print_revoke_summary(
494
+ project_path,
495
+ claude_user_settings_path,
496
+ allow_rules_removed_count,
497
+ len(all_permission_rules),
498
+ deny_rules_removed_count,
499
+ len(all_agent_config_deny_rules),
500
+ inert_rules_removed_count,
501
+ project_local_inert_rules_removed_count,
502
+ directories_removed_count,
503
+ environment_entries_removed_count,
262
504
  )
263
505
 
264
506
 
@@ -13,14 +13,23 @@ import pytest
13
13
  def _load_common_module() -> ModuleType:
14
14
  module_path = Path(__file__).parent.parent / "_claude_permissions_common.py"
15
15
  parent_directory = str(module_path.parent.resolve())
16
- if parent_directory not in sys.path:
17
- sys.path.insert(0, parent_directory)
16
+ if parent_directory in sys.path:
17
+ sys.path.remove(parent_directory)
18
+ sys.path.insert(0, parent_directory)
19
+ for each_module_name in list(sys.modules):
20
+ if each_module_name == "pr_loop_shared_constants" or each_module_name.startswith(
21
+ "pr_loop_shared_constants."
22
+ ):
23
+ del sys.modules[each_module_name]
24
+ if each_module_name == "_claude_permissions_common":
25
+ del sys.modules[each_module_name]
18
26
  spec = importlib.util.spec_from_file_location(
19
27
  "_claude_permissions_common", module_path
20
28
  )
21
29
  assert spec is not None
22
30
  assert spec.loader is not None
23
31
  module = importlib.util.module_from_spec(spec)
32
+ sys.modules["_claude_permissions_common"] = module
24
33
  spec.loader.exec_module(module)
25
34
  return module
26
35
 
@@ -187,3 +196,34 @@ def test_is_valid_project_root_uses_extracted_directory_marker_constants() -> No
187
196
  assert '".git"' not in source_text
188
197
  assert "'.claude'" not in source_text
189
198
  assert '".claude"' not in source_text
199
+
200
+
201
+ def test_permission_rule_tool_name_extracts_tool_prefix() -> None:
202
+ assert common.permission_rule_tool_name("Edit(/repo/.claude/**)") == "Edit"
203
+ assert common.permission_rule_tool_name("Write(c:/Users/jon/.claude/worktrees/x/**)") == (
204
+ "Write"
205
+ )
206
+ assert common.permission_rule_tool_name(None) is None
207
+ assert common.permission_rule_tool_name("not-a-rule") is None
208
+
209
+
210
+ def test_is_inert_file_permission_rule_for_write_glob_notebookedit() -> None:
211
+ assert common.is_inert_file_permission_rule("Write(/wt/.claude/**)") is True
212
+ assert common.is_inert_file_permission_rule("Glob(/wt/.claude/hooks/**)") is True
213
+ assert common.is_inert_file_permission_rule("NotebookEdit(**/.claude/worktrees/**)") is True
214
+ assert common.is_inert_file_permission_rule("Edit(/repo/.claude/**)") is False
215
+ assert common.is_inert_file_permission_rule("Bash(echo hi)") is False
216
+
217
+
218
+ def test_all_project_path_aliases_for_reap_adds_home_alias_when_project_is_home() -> None:
219
+ all_aliases = common.all_project_path_aliases_for_reap(
220
+ "C:/Users/jon", home_directory_path="C:/Users/jon"
221
+ )
222
+ assert all_aliases == ("C:/Users/jon", "$HOME")
223
+
224
+
225
+ def test_all_project_path_aliases_for_reap_skips_home_alias_for_other_projects() -> None:
226
+ all_aliases = common.all_project_path_aliases_for_reap(
227
+ "C:/dev/other", home_directory_path="C:/Users/jon"
228
+ )
229
+ assert all_aliases == ("C:/dev/other",)
@@ -36,6 +36,42 @@ def test_exposes_all_agent_config_deny_tools_tuple() -> None:
36
36
  assert "Glob" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
37
37
 
38
38
 
39
+ def test_exposes_all_legacy_permission_reap_tools_tuple() -> None:
40
+ assert constants_module.ALL_LEGACY_PERMISSION_REAP_TOOLS == ("Write", "Glob")
41
+ assert "NotebookEdit" not in constants_module.ALL_LEGACY_PERMISSION_REAP_TOOLS
42
+ assert "Edit" not in constants_module.ALL_LEGACY_PERMISSION_REAP_TOOLS
43
+ assert "Read" not in constants_module.ALL_LEGACY_PERMISSION_REAP_TOOLS
44
+
45
+
46
+ def test_exposes_all_inert_file_permission_tools_tuple() -> None:
47
+ assert constants_module.ALL_INERT_FILE_PERMISSION_TOOLS == (
48
+ "Write",
49
+ "Glob",
50
+ "NotebookEdit",
51
+ )
52
+ assert "Edit" not in constants_module.ALL_INERT_FILE_PERMISSION_TOOLS
53
+ assert "Read" not in constants_module.ALL_INERT_FILE_PERMISSION_TOOLS
54
+ assert "Write" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
55
+ assert "NotebookEdit" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
56
+ assert "NotebookEdit" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
57
+
58
+
59
+ def test_exposes_home_project_path_alias() -> None:
60
+ assert constants_module.HOME_PROJECT_PATH_ALIAS == "$HOME"
61
+
62
+
63
+ def test_get_claude_project_local_settings_path_uses_settings_local_filename() -> None:
64
+ project_root = Path("C:/dev/Projects/LLM Plugins/claude-dev-env")
65
+ local_settings_path = constants_module.get_claude_project_local_settings_path(
66
+ project_root
67
+ )
68
+ assert local_settings_path.name == constants_module.CLAUDE_SETTINGS_LOCAL_FILENAME
69
+ assert local_settings_path.parent.name == (
70
+ constants_module.CLAUDE_SETTINGS_DIRECTORY_NAME
71
+ )
72
+ assert local_settings_path.parent.parent == project_root
73
+
74
+
39
75
  def test_auto_mode_environment_entry_template_is_format_string() -> None:
40
76
  rendered_template_text = (
41
77
  constants_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
@@ -74,6 +74,7 @@ def initialize_git_repository(repository_root: Path) -> None:
74
74
  run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
75
75
  run_git_in_repository(repository_root, "config", "user.name", "Test")
76
76
  run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
77
+ run_git_in_repository(repository_root, "config", "core.autocrlf", "false")
77
78
  disabled_hooks_directory = repository_root / "disabled-git-hooks"
78
79
  disabled_hooks_directory.mkdir()
79
80
  run_git_in_repository(
@@ -88,7 +89,7 @@ def commit_all_files(repository_root: Path, commit_message: str) -> None:
88
89
 
89
90
  def write_file(file_path: Path, content: str) -> None:
90
91
  file_path.parent.mkdir(parents=True, exist_ok=True)
91
- file_path.write_text(content, encoding="utf-8")
92
+ file_path.write_text(content, encoding="utf-8", newline="")
92
93
 
93
94
 
94
95
  def stage_file(repository_root: Path, relative_path: str) -> None:
@@ -2799,3 +2800,101 @@ def test_run_pytest_for_group_treats_no_tests_collected_as_a_pass_per_batch(
2799
2800
  "a batch that collects no tests is not a failure; only a real pytest "
2800
2801
  "failure exit code may block the commit"
2801
2802
  )
2803
+
2804
+
2805
+ _PNG_SIGNATURE_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00" * 16
2806
+ _SKIPPING_UNREADABLE_MARKER = "skipping unreadable file"
2807
+
2808
+
2809
+ def _write_png(file_path: Path) -> None:
2810
+ """Write a minimal PNG-signature binary file at *file_path*."""
2811
+ file_path.parent.mkdir(parents=True, exist_ok=True)
2812
+ file_path.write_bytes(_PNG_SIGNATURE_BYTES)
2813
+
2814
+
2815
+ def _clean_module_source() -> str:
2816
+ """Return Python source the enforcer accepts as a clean new module."""
2817
+ return '"""Clean module for binary-flood gate tests."""\n\n\ndef greet() -> str:\n return "hello"\n'
2818
+
2819
+
2820
+ def test_diff_mode_tracked_new_png_does_not_flood_stderr(
2821
+ temporary_git_repository: Path,
2822
+ monkeypatch: pytest.MonkeyPatch,
2823
+ capsys: pytest.CaptureFixture[str],
2824
+ ) -> None:
2825
+ """A newly committed PNG must not emit a UTF-8 skip line on the push surface."""
2826
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2827
+ commit_all_files(temporary_git_repository, "baseline")
2828
+ _write_png(temporary_git_repository / "assets" / "icon.png")
2829
+ commit_all_files(temporary_git_repository, "add icon png")
2830
+ monkeypatch.chdir(temporary_git_repository)
2831
+
2832
+ exit_code = gate_module.main(["--base", "HEAD~1"])
2833
+
2834
+ captured = capsys.readouterr()
2835
+ assert exit_code == 0
2836
+ assert _SKIPPING_UNREADABLE_MARKER not in captured.err
2837
+ assert "icon.png" not in captured.err
2838
+
2839
+
2840
+ def test_diff_mode_untracked_png_does_not_flood_stderr(
2841
+ temporary_git_repository: Path,
2842
+ monkeypatch: pytest.MonkeyPatch,
2843
+ capsys: pytest.CaptureFixture[str],
2844
+ ) -> None:
2845
+ """An untracked PNG joins the combined surface and must not flood stderr."""
2846
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2847
+ commit_all_files(temporary_git_repository, "baseline")
2848
+ _write_png(temporary_git_repository / "untracked_icon.png")
2849
+ monkeypatch.chdir(temporary_git_repository)
2850
+
2851
+ exit_code = gate_module.main(["--base", "HEAD"])
2852
+
2853
+ captured = capsys.readouterr()
2854
+ assert exit_code == 0
2855
+ assert _SKIPPING_UNREADABLE_MARKER not in captured.err
2856
+ assert "untracked_icon.png" not in captured.err
2857
+
2858
+
2859
+ def test_diff_mode_image_only_surface_exits_zero_not_empty_file_set(
2860
+ temporary_git_repository: Path,
2861
+ monkeypatch: pytest.MonkeyPatch,
2862
+ capsys: pytest.CaptureFixture[str],
2863
+ ) -> None:
2864
+ """Git reports image files, yet zero code files means a clean pass (not EMPTY_FILE_SET)."""
2865
+ write_file(temporary_git_repository / "seed.txt", "seed\n")
2866
+ commit_all_files(temporary_git_repository, "seed")
2867
+ _write_png(temporary_git_repository / "only_image.png")
2868
+ commit_all_files(temporary_git_repository, "image only")
2869
+ monkeypatch.chdir(temporary_git_repository)
2870
+
2871
+ exit_code = gate_module.main(["--base", "HEAD~1"])
2872
+
2873
+ captured = capsys.readouterr()
2874
+ assert exit_code == 0
2875
+ assert exit_code != gate_module.EMPTY_FILE_SET_EXIT_CODE
2876
+ assert _SKIPPING_UNREADABLE_MARKER not in captured.err
2877
+ assert gate_module.EMPTY_FILE_SET_MESSAGE not in captured.err
2878
+ assert "inspected 0 file(s)" in captured.err
2879
+
2880
+
2881
+ def test_diff_mode_mixed_py_and_png_validates_py_silently_skips_png(
2882
+ temporary_git_repository: Path,
2883
+ monkeypatch: pytest.MonkeyPatch,
2884
+ capsys: pytest.CaptureFixture[str],
2885
+ ) -> None:
2886
+ """A mixed surface line-maps and validates the .py; the PNG is skipped with no flood."""
2887
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2888
+ commit_all_files(temporary_git_repository, "baseline")
2889
+ write_file(temporary_git_repository / "fresh_module.py", _clean_module_source())
2890
+ _write_png(temporary_git_repository / "mixed_icon.png")
2891
+ commit_all_files(temporary_git_repository, "add py and png")
2892
+ monkeypatch.chdir(temporary_git_repository)
2893
+
2894
+ exit_code = gate_module.main(["--base", "HEAD~1"])
2895
+
2896
+ captured = capsys.readouterr()
2897
+ assert exit_code == 0
2898
+ assert _SKIPPING_UNREADABLE_MARKER not in captured.err
2899
+ assert "inspected 1 file(s)" in captured.err
2900
+ assert "mixed_icon.png" not in captured.err