claude-dev-env 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  9. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  10. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  11. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  12. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  13. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  14. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  15. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  17. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  18. package/_shared/pr-loop/worker-spawn.md +1 -1
  19. package/agents/code-verifier.md +1 -1
  20. package/hooks/blocking/code_rules_shared.py +4 -2
  21. package/hooks/blocking/config/verified_commit_constants.py +11 -0
  22. package/hooks/blocking/pii_payload_scan.py +78 -20
  23. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  24. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  25. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +93 -0
  27. package/hooks/blocking/verification_verdict_store.py +91 -0
  28. package/hooks/validators/ruff_integration.py +2 -1
  29. package/hooks/validators/run_all_validators.py +460 -24
  30. package/hooks/validators/test_ruff_integration.py +21 -0
  31. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  32. package/package.json +1 -1
  33. package/rules/CLAUDE.md +1 -0
  34. package/rules/cleanup-command-forms.md +23 -0
  35. package/scripts/CLAUDE.md +2 -1
  36. package/scripts/check.ps1 +32 -6
  37. package/scripts/claude-chain.example.json +15 -3
  38. package/scripts/claude_chain_runner.py +130 -27
  39. package/scripts/claude_chain_usage.py +346 -0
  40. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  41. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
  42. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  43. package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
  44. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  45. package/scripts/test_claude_chain_runner.py +412 -6
  46. package/scripts/test_claude_chain_usage.py +534 -0
  47. package/skills/orchestrator/SKILL.md +1 -20
  48. package/skills/orchestrator-refresh/SKILL.md +1 -1
  49. package/skills/pr-converge/SKILL.md +3 -3
  50. package/skills/pr-converge/reference/examples.md +3 -3
  51. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  52. package/skills/pr-converge/reference/ground-rules.md +3 -3
  53. package/skills/pr-converge/reference/per-tick.md +5 -5
  54. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  55. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  56. package/skills/team-advisor/SKILL.md +3 -2
  57. package/skills/usage-pause/SKILL.md +5 -4
  58. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  59. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  60. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
package/CLAUDE.md CHANGED
@@ -43,7 +43,7 @@ Run every multi-step code task in two phases:
43
43
  1. **Coders** — one coder agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the advisor (see beginning of this file).
44
44
  2. **Verification** — when the coders finish, the main session spawns the `code-verifier` agent in a fresh context, but you must first verify that their work is based on upstream's origin main (aka: the commit live on github). It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Before it emits the verdict, it puts the draft through one strongest-tier validation subagent — selected per the advisor protocol's host detection and tier ladder — that tries to refute it, and it re-checks and corrects any part the validator overturns. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
45
45
 
46
- Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. One exemption is mechanical, not discretionary: a diff whose every changed file is non-code or has an unchanged Python AST once docstrings are stripped (docs, docstrings, comments). One escape hatch is manual and narrow: appending `# verify-skip` as a trailing shell comment (outside every quoted region) to the blocked commit or push command bypasses the gate for that one command — allowed only when the branch surface is the same code a code-verifier already passed clean and the gate is blocking on a verdict that doesn't cover it (an unminted verdict, staging churn, a reverted concurrent write); any real code change since the clean verdict runs a fresh verification instead. Full rule: `~/.claude/rules/verified-commit-gate-skip.md`.
46
+ Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. One exemption is mechanical, not discretionary: a diff whose every changed file is non-code (docs, images), a pytest test file by name (`test_*.py`, `*_test.py`, or `conftest.py`), or a Python file whose docstring-stripped AST is unchanged (docstring, comment, or formatting-only edits). One escape hatch is manual and narrow: appending `# verify-skip` as a trailing shell comment (outside every quoted region) to the blocked commit or push command bypasses the gate for that one command — allowed only when the branch surface is the same code a code-verifier already passed clean and the gate is blocking on a verdict that doesn't cover it (an unminted verdict, staging churn, a reverted concurrent write); any real code change since the clean verdict runs a fresh verification instead. Full rule: `~/.claude/rules/verified-commit-gate-skip.md`.
47
47
 
48
48
  ## Sub-agent Output Validation
49
49
 
@@ -30,7 +30,7 @@ A third-party (non-Claude) harness cannot spawn a Claude `session-advisor` throu
30
30
  python "$HOME/.claude/scripts/claude_chain_runner.py" -- -p --model <alias> --effort <effort> --output-format json
31
31
  ```
32
32
 
33
- Use `--model fable --effort high` on Fable; use `--model opus --effort max` on Opus. The chain runner walks `~/.claude/claude-chain.json` (binaries such as `claude`, `claude-ev`, `claude-editor`, `claude-mel`) and fails over only on a usage-limit signature.
33
+ Use `--model fable --effort high` on Fable; use `--model opus --effort max` on Opus. The chain runner probes weekly remaining via `claude_chain_usage` / the usage-pause OAuth probe, ranks accounts in `~/.claude/claude-chain.json` highest remaining first, and fails over only on a usage-limit signature.
34
34
  4. Stop at the first successful bind. Record `{tier, result: "cli"}` and set `selected_tier` to that tier. Persist `session_id` from the JSON events (any event carries it; reply text is the `type == "result"` event's `.result` field). Run every bind and every later consult with cwd set to the repo root the work is for — Claude sessions are project-scoped by working directory.
35
35
  5. **Fail closed:** when every candidate fails (chain exhausted or model unavailable), set `selected_tier = null` and a `fallback_reason`, report that the advisor is unreachable, and **stop**. Do **not** answer ENDORSE / CORRECTION / PLAN / STOP as this third-party session. Do **not** self-endorse.
36
36
  6. Paste the **Third-party host** Advisor block into every executor spawn prompt — never the Claude SendMessage block. Executors report to the orchestrating session; that session consults the bound Claude CLI advisor and relays the four-signal reply.
@@ -122,7 +122,7 @@ The orchestrating session owns the Claude CLI advisor bind for the whole run —
122
122
 
123
123
  ## CLI chain
124
124
 
125
- The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" -- <claude args...>`. It walks `~/.claude/claude-chain.json` and fails over to the next binary only on a usage-limit signature, so a usage-limited primary account still gets served.
125
+ The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" -- <claude args...>`. It ranks accounts in `~/.claude/claude-chain.json` by weekly remaining (via `claude_chain_usage` / the usage-pause OAuth probe) and fails over to the next ranked binary only on a usage-limit signature, so a usage-limited first try still gets served.
126
126
 
127
127
  **Third-party host:** this runner is the **primary** advisor bind and consult path (see **Host profiles → Third-party host**). Map each walk attempt to `--model <alias>` and the effort flags there. When the walk exhausts, fail closed.
128
128
 
@@ -11,6 +11,7 @@ Python scripts invoked at runtime by the PR-loop skills. Each script is a standa
11
11
  | `post_audit_thread.py` | Posts an audit review (APPROVE / REQUEST_CHANGES) to a draft PR via the GitHub reviews API; reads the body skeleton from `audit-reply-template.md` at runtime |
12
12
  | `grant_project_claude_permissions.py` | Writes idempotent allow-rules and `additionalDirectories` entries into `~/.claude/settings.json` so subagents can edit the project's `.claude/` tree without prompting |
13
13
  | `revoke_project_claude_permissions.py` | Removes the allow-rules and entries that `grant_project_claude_permissions.py` wrote; safe to run when no prior grant exists |
14
+ | `stale_worktree_rule_sweep.py` | Drops allow/deny rules pointing at deleted `~/.claude/worktrees/<repo>/<worktree>` directories and deduplicates the rule arrays; run by the grant and revoke flows before they write |
14
15
  | `code_rules_gate.py` | Pre-commit gate that runs `code_rules_enforcer` checks on staged Python files before a fix commit lands, and the terminology sweep over the staged diff |
15
16
  | `terminology_sweep.py` | Flags a prose term that near-misses an identifier introduced on added code lines of a unified diff (shared leading word, divergent tail) |
16
17
  | `reviews_disabled.py` | Shared helper for the reviewer opt-out and opt-in gates; parses `CLAUDE_REVIEWS_DISABLED` tokens `bugteam`, `bugbot`, `copilot`, and `codex`, plus `CLAUDE_REVIEWS_ENABLED` for bugbot opt-in (bugbot is off by default and runs only when `CLAUDE_REVIEWS_ENABLED` lists it) |
@@ -1,284 +1,306 @@
1
- """Grant Edit/Write/Read permissions on the current directory's .claude tree.
2
-
3
- Run from the project root whose .claude/** you want a Claude Code session
4
- (including spawned subagents) to edit without prompting. Writes idempotent
5
- entries into the user-scope settings at ~/.claude/settings.json and prints
6
- the changes applied. No-op when the entries already exist.
7
- """
8
-
9
- import sys
10
- from pathlib import Path
11
-
12
- parent_directory = str(Path(__file__).resolve().parent)
13
- if parent_directory not in sys.path:
14
- sys.path.insert(0, parent_directory)
15
-
16
- from _claude_permissions_common import ( # noqa: E402
17
- append_if_missing,
18
- build_agent_config_deny_rules,
19
- build_permission_rules,
20
- ensure_dict_section,
21
- ensure_list_entry,
22
- exit_with_error,
23
- get_current_project_path,
24
- is_trust_entry_for_project,
25
- is_valid_project_root,
26
- load_settings,
27
- remove_matching_entries_from_list,
28
- save_settings,
29
- )
30
- from pr_loop_shared_constants.claude_permissions_constants import (
31
- ALL_AGENT_CONFIG_DENY_TOOLS,
32
- ALL_AGENT_CONFIG_PATH_PATTERNS,
33
- ALL_PERMISSION_ALLOW_TOOLS,
34
- AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
35
- AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
36
- get_claude_user_settings_path,
37
- )
38
- from pr_loop_shared_constants.claude_settings_keys_constants import (
39
- CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
40
- CLAUDE_SETTINGS_ALLOW_KEY,
41
- CLAUDE_SETTINGS_AUTO_MODE_KEY,
42
- CLAUDE_SETTINGS_DENY_KEY,
43
- CLAUDE_SETTINGS_ENVIRONMENT_KEY,
44
- CLAUDE_SETTINGS_PERMISSIONS_KEY,
45
- )
46
-
47
-
48
- def add_rules_to_allow_list(
49
- all_settings: dict[str, object], all_rules_to_add: list[str]
50
- ) -> int:
51
- """Add permission rules to the settings allow list.
52
-
53
- Args:
54
- all_settings: The parsed settings dictionary.
55
- all_rules_to_add: Permission rule strings to append.
56
-
57
- Returns:
58
- Number of rules actually added (new entries).
59
- """
60
- permissions_section = ensure_dict_section(
61
- all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
62
- )
63
- existing_allow_list = ensure_list_entry(
64
- permissions_section, CLAUDE_SETTINGS_ALLOW_KEY
65
- )
66
- return sum(
67
- 1
68
- for each_rule in all_rules_to_add
69
- if append_if_missing(existing_allow_list, each_rule)
70
- )
71
-
72
-
73
- def add_rules_to_deny_list(
74
- all_settings: dict[str, object], all_rules_to_add: list[str]
75
- ) -> int:
76
- """Add permission rules to the settings deny list.
77
-
78
- Deny rules take precedence over allow rules in Claude Code's permission
79
- matching, so writing agent-config paths into the deny list forces a
80
- per-edit user approval even when a broader allow rule would cover them.
81
-
82
- Args:
83
- all_settings: The parsed settings dictionary.
84
- all_rules_to_add: Permission rule strings to append.
85
-
86
- Returns:
87
- Number of rules actually added (new entries).
88
- """
89
- permissions_section = ensure_dict_section(
90
- all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
91
- )
92
- existing_deny_list = ensure_list_entry(
93
- permissions_section, CLAUDE_SETTINGS_DENY_KEY
94
- )
95
- return sum(
96
- 1
97
- for each_rule in all_rules_to_add
98
- if append_if_missing(existing_deny_list, each_rule)
99
- )
100
-
101
-
102
- def add_directory_to_additional_directories(
103
- all_settings: dict[str, object], directory_path: str
104
- ) -> int:
105
- """Add a project path to the additionalDirectories allow list.
106
-
107
- Args:
108
- all_settings: The parsed settings dictionary.
109
- directory_path: The project directory path to add.
110
-
111
- Returns:
112
- 1 when the entry was added, 0 when it already existed.
113
- """
114
- permissions_section = ensure_dict_section(
115
- all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
116
- )
117
- existing_directories = ensure_list_entry(
118
- permissions_section, CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
119
- )
120
- if append_if_missing(existing_directories, directory_path):
121
- return 1
122
- return 0
123
-
124
-
125
- def add_auto_mode_environment_entry(
126
- all_settings: dict[str, object], entry_text: str
127
- ) -> int:
128
- """Add an auto-mode environment entry for the project.
129
-
130
- Args:
131
- all_settings: The parsed settings dictionary.
132
- entry_text: The environment entry text to add.
133
-
134
- Returns:
135
- 1 when the entry was added, 0 when it already existed.
136
- """
137
- auto_mode_section = ensure_dict_section(
138
- all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
139
- )
140
- existing_environment = ensure_list_entry(
141
- auto_mode_section, CLAUDE_SETTINGS_ENVIRONMENT_KEY
142
- )
143
- if append_if_missing(existing_environment, entry_text):
144
- return 1
145
- return 0
146
-
147
-
148
- def purge_stale_trust_entries(
149
- all_settings: dict[str, object],
150
- project_path: str,
151
- prefix: str,
152
- protected_entry: str | None = None,
153
- ) -> int:
154
- """Remove every prior trust entry for the project from autoMode.environment.
155
-
156
- A trust entry is any string in autoMode.environment whose prefix matches
157
- the trust-entry marker and that contains the project's .claude/** path.
158
- Purging stale entries before adding the current template prevents
159
- accumulation across template revisions. The optional protected_entry
160
- survives the purge so an entry byte-identical to the one about to be
161
- re-added is not removed and re-added on every invocation, preserving the
162
- idempotency contract documented on grant_permissions_for_current_directory.
163
-
164
- Args:
165
- all_settings: The parsed settings dictionary.
166
- project_path: The POSIX-style project root path.
167
- prefix: The literal prefix that marks a trust entry.
168
- protected_entry: Optional entry text that, when byte-equal to a
169
- candidate, prevents removal. Pass the freshly-formatted current
170
- template entry from grant to preserve idempotency. Revoke passes
171
- None so every matching entry is removed.
172
-
173
- Returns:
174
- Number of stale entries removed.
175
- """
176
- auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
177
- if not isinstance(auto_mode_section, dict):
178
- return 0
179
- existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
180
- if not isinstance(existing_environment, list):
181
- return 0
182
-
183
- def _should_purge_candidate(candidate_entry: object) -> bool:
184
- if not is_trust_entry_for_project(candidate_entry, project_path, prefix):
185
- return False
186
- if protected_entry is not None and candidate_entry == protected_entry:
187
- return False
188
- return True
189
-
190
- return remove_matching_entries_from_list(
191
- existing_environment,
192
- _should_purge_candidate,
193
- )
194
-
195
-
196
- def grant_permissions_for_current_directory() -> None:
197
- """Grant Edit/Write/Read permissions for the current project directory.
198
-
199
- Reads the current project path, constructs permission rules from config
200
- constants, and writes them to ~/.claude/settings.json atomically. Adds
201
- deny rules for agent-config paths so edits to settings, hooks, commands,
202
- agents, skills, mcp.json, and CLAUDE.md still require per-edit user
203
- approval. Purges any prior trust entries for this project before writing
204
- the current template to prevent accumulation across template revisions.
205
-
206
- Raises:
207
- SystemExit: When the current directory is not a valid project root.
208
- ValueError: Propagated from get_current_project_path() when the path
209
- contains glob metacharacters.
210
- """
211
- claude_user_settings_path: Path = get_claude_user_settings_path()
212
- project_root_path = Path.cwd()
213
- if not is_valid_project_root(project_root_path):
214
- print(
215
- f"ERROR: cwd {project_root_path} is not a project root "
216
- f"(no .git or .claude). Run from a project root.",
217
- file=sys.stderr,
218
- )
219
- raise SystemExit(1)
220
- project_path = get_current_project_path()
221
- all_permission_rules = build_permission_rules(
222
- project_path, ALL_PERMISSION_ALLOW_TOOLS
223
- )
224
- all_agent_config_deny_rules = build_agent_config_deny_rules(
225
- project_path,
226
- ALL_AGENT_CONFIG_DENY_TOOLS,
227
- ALL_AGENT_CONFIG_PATH_PATTERNS,
228
- )
229
- environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
230
- project_path=project_path
231
- )
232
- settings = load_settings(claude_user_settings_path)
233
- allow_rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
234
- deny_rules_added_count = add_rules_to_deny_list(
235
- settings, all_agent_config_deny_rules
236
- )
237
- directories_added_count = add_directory_to_additional_directories(
238
- settings, project_path
239
- )
240
- stale_trust_entries_purged_count = purge_stale_trust_entries(
241
- settings,
242
- project_path,
243
- AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
244
- protected_entry=environment_entry,
245
- )
246
- environment_entries_added_count = add_auto_mode_environment_entry(
247
- settings, environment_entry
248
- )
249
- total_changes_count = (
250
- allow_rules_added_count
251
- + deny_rules_added_count
252
- + directories_added_count
253
- + stale_trust_entries_purged_count
254
- + environment_entries_added_count
255
- )
256
- if total_changes_count == 0:
257
- print(f"Project path: {project_path}")
258
- print(f"Settings file: {claude_user_settings_path}")
259
- print("No changes needed; settings file left untouched.")
260
- return
261
- save_settings(claude_user_settings_path, settings)
262
- print(f"Project path: {project_path}")
263
- print(f"Settings file: {claude_user_settings_path}")
264
- print(
265
- f"Allow rules added: {allow_rules_added_count} of {len(all_permission_rules)}"
266
- )
267
- print(
268
- f"Deny rules added: {deny_rules_added_count} of "
269
- f"{len(all_agent_config_deny_rules)}"
270
- )
271
- print(f"Additional directories added: {directories_added_count}")
272
- if stale_trust_entries_purged_count > 0:
273
- print(
274
- f"Stale auto-mode environment entries purged: "
275
- f"{stale_trust_entries_purged_count}"
276
- )
277
- print(f"Auto-mode environment entries added: {environment_entries_added_count}")
278
-
279
-
280
- if __name__ == "__main__":
281
- try:
282
- grant_permissions_for_current_directory()
283
- except ValueError as path_error:
284
- exit_with_error(str(path_error))
1
+ """Grant Edit/Read permissions on the current directory's .claude tree.
2
+
3
+ Run from the project root whose .claude/** you want a Claude Code session
4
+ (including spawned subagents) to edit without prompting. Writes idempotent
5
+ entries into the user-scope settings at ~/.claude/settings.json and prints
6
+ the changes applied. No-op when the entries already exist.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ parent_directory = str(Path(__file__).resolve().parent)
13
+ if parent_directory not in sys.path:
14
+ sys.path.insert(0, parent_directory)
15
+
16
+ from _claude_permissions_common import ( # noqa: E402
17
+ append_if_missing,
18
+ build_agent_config_deny_rules,
19
+ build_permission_rules,
20
+ ensure_dict_section,
21
+ ensure_list_entry,
22
+ exit_with_error,
23
+ get_current_project_path,
24
+ is_trust_entry_for_project,
25
+ is_valid_project_root,
26
+ load_settings,
27
+ remove_matching_entries_from_list,
28
+ save_settings,
29
+ )
30
+ from pr_loop_shared_constants.claude_permissions_constants import (
31
+ ALL_AGENT_CONFIG_DENY_TOOLS,
32
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
33
+ ALL_PERMISSION_ALLOW_TOOLS,
34
+ AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
35
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
36
+ get_claude_user_settings_path,
37
+ )
38
+ from pr_loop_shared_constants.claude_settings_keys_constants import (
39
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
40
+ CLAUDE_SETTINGS_ALLOW_KEY,
41
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
42
+ CLAUDE_SETTINGS_DENY_KEY,
43
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
44
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
45
+ )
46
+ from stale_worktree_rule_sweep import ( # noqa: E402
47
+ sweep_stale_worktree_rules_from_settings,
48
+ )
49
+
50
+
51
+ def add_rules_to_allow_list(
52
+ all_settings: dict[str, object], all_rules_to_add: list[str]
53
+ ) -> int:
54
+ """Add permission rules to the settings allow list.
55
+
56
+ Args:
57
+ all_settings: The parsed settings dictionary.
58
+ all_rules_to_add: Permission rule strings to append.
59
+
60
+ Returns:
61
+ Number of rules actually added (new entries).
62
+ """
63
+ permissions_section = ensure_dict_section(
64
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
65
+ )
66
+ existing_allow_list = ensure_list_entry(
67
+ permissions_section, CLAUDE_SETTINGS_ALLOW_KEY
68
+ )
69
+ return sum(
70
+ 1
71
+ for each_rule in all_rules_to_add
72
+ if append_if_missing(existing_allow_list, each_rule)
73
+ )
74
+
75
+
76
+ def add_rules_to_deny_list(
77
+ all_settings: dict[str, object], all_rules_to_add: list[str]
78
+ ) -> int:
79
+ """Add permission rules to the settings deny list.
80
+
81
+ Deny rules take precedence over allow rules in Claude Code's permission
82
+ matching, so writing agent-config paths into the deny list forces a
83
+ per-edit user approval even when a broader allow rule would cover them.
84
+
85
+ Args:
86
+ all_settings: The parsed settings dictionary.
87
+ all_rules_to_add: Permission rule strings to append.
88
+
89
+ Returns:
90
+ Number of rules actually added (new entries).
91
+ """
92
+ permissions_section = ensure_dict_section(
93
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
94
+ )
95
+ existing_deny_list = ensure_list_entry(
96
+ permissions_section, CLAUDE_SETTINGS_DENY_KEY
97
+ )
98
+ return sum(
99
+ 1
100
+ for each_rule in all_rules_to_add
101
+ if append_if_missing(existing_deny_list, each_rule)
102
+ )
103
+
104
+
105
+ def add_directory_to_additional_directories(
106
+ all_settings: dict[str, object], directory_path: str
107
+ ) -> int:
108
+ """Add a project path to the additionalDirectories allow list.
109
+
110
+ Args:
111
+ all_settings: The parsed settings dictionary.
112
+ directory_path: The project directory path to add.
113
+
114
+ Returns:
115
+ 1 when the entry was added, 0 when it already existed.
116
+ """
117
+ permissions_section = ensure_dict_section(
118
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
119
+ )
120
+ existing_directories = ensure_list_entry(
121
+ permissions_section, CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
122
+ )
123
+ if append_if_missing(existing_directories, directory_path):
124
+ return 1
125
+ return 0
126
+
127
+
128
+ def add_auto_mode_environment_entry(
129
+ all_settings: dict[str, object], entry_text: str
130
+ ) -> int:
131
+ """Add an auto-mode environment entry for the project.
132
+
133
+ Args:
134
+ all_settings: The parsed settings dictionary.
135
+ entry_text: The environment entry text to add.
136
+
137
+ Returns:
138
+ 1 when the entry was added, 0 when it already existed.
139
+ """
140
+ auto_mode_section = ensure_dict_section(
141
+ all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
142
+ )
143
+ existing_environment = ensure_list_entry(
144
+ auto_mode_section, CLAUDE_SETTINGS_ENVIRONMENT_KEY
145
+ )
146
+ if append_if_missing(existing_environment, entry_text):
147
+ return 1
148
+ return 0
149
+
150
+
151
+ def _is_stale_trust_entry(
152
+ candidate_entry: object,
153
+ project_path: str,
154
+ prefix: str,
155
+ protected_entry: str | None,
156
+ ) -> bool:
157
+ if not is_trust_entry_for_project(candidate_entry, project_path, prefix):
158
+ return False
159
+ if protected_entry is not None and candidate_entry == protected_entry:
160
+ return False
161
+ return True
162
+
163
+
164
+ def purge_stale_trust_entries(
165
+ all_settings: dict[str, object],
166
+ project_path: str,
167
+ prefix: str,
168
+ protected_entry: str | None = None,
169
+ ) -> int:
170
+ """Remove every prior trust entry for the project from autoMode.environment.
171
+
172
+ Args:
173
+ all_settings: The parsed settings dictionary.
174
+ project_path: The POSIX-style project root path.
175
+ prefix: The literal prefix that marks a trust entry.
176
+ protected_entry: When byte-equal to a candidate, prevents its removal.
177
+
178
+ Returns:
179
+ Number of stale entries removed.
180
+ """
181
+ auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
182
+ if not isinstance(auto_mode_section, dict):
183
+ return 0
184
+ existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
185
+ if not isinstance(existing_environment, list):
186
+ return 0
187
+ return remove_matching_entries_from_list(
188
+ existing_environment,
189
+ lambda candidate_entry: _is_stale_trust_entry(
190
+ candidate_entry, project_path, prefix, protected_entry
191
+ ),
192
+ )
193
+
194
+
195
+ def _resolve_project_path_or_exit() -> str:
196
+ project_root_path = Path.cwd()
197
+ if not is_valid_project_root(project_root_path):
198
+ print(
199
+ f"ERROR: cwd {project_root_path} is not a project root "
200
+ f"(no .git or .claude). Run from a project root.",
201
+ file=sys.stderr,
202
+ )
203
+ raise SystemExit(1)
204
+ return get_current_project_path()
205
+
206
+
207
+ def _build_grant_rule_sets(
208
+ project_path: str,
209
+ ) -> tuple[list[str], list[str], str]:
210
+ all_permission_rules = build_permission_rules(
211
+ project_path, ALL_PERMISSION_ALLOW_TOOLS
212
+ )
213
+ all_agent_config_deny_rules = build_agent_config_deny_rules(
214
+ project_path,
215
+ ALL_AGENT_CONFIG_DENY_TOOLS,
216
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
217
+ )
218
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
219
+ project_path=project_path
220
+ )
221
+ return (all_permission_rules, all_agent_config_deny_rules, environment_entry)
222
+
223
+
224
+ def _apply_grant(
225
+ all_settings: dict[str, object],
226
+ project_path: str,
227
+ all_grant_rule_sets: tuple[list[str], list[str], str],
228
+ ) -> tuple[int, int, int, int, int]:
229
+ all_permission_rules, all_agent_config_deny_rules, environment_entry = (
230
+ all_grant_rule_sets
231
+ )
232
+ allow_added = add_rules_to_allow_list(all_settings, all_permission_rules)
233
+ deny_added = add_rules_to_deny_list(all_settings, all_agent_config_deny_rules)
234
+ dirs_added = add_directory_to_additional_directories(all_settings, project_path)
235
+ purged = purge_stale_trust_entries(
236
+ all_settings,
237
+ project_path,
238
+ AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
239
+ protected_entry=environment_entry,
240
+ )
241
+ env_added = add_auto_mode_environment_entry(all_settings, environment_entry)
242
+ return (allow_added, deny_added, dirs_added, purged, env_added)
243
+
244
+
245
+ def _print_no_change_notice(
246
+ project_path: str, claude_user_settings_path: Path
247
+ ) -> None:
248
+ print(f"Project path: {project_path}")
249
+ print(f"Settings file: {claude_user_settings_path}")
250
+ print("No changes needed; settings file left untouched.")
251
+
252
+
253
+ def _print_grant_summary(
254
+ project_path: str,
255
+ claude_user_settings_path: Path,
256
+ all_grant_rule_sets: tuple[list[str], list[str], str],
257
+ all_change_counts: tuple[int, int, int, int, int],
258
+ ) -> None:
259
+ all_permission_rules, all_agent_config_deny_rules, _ = all_grant_rule_sets
260
+ allow_added, deny_added, dirs_added, purged, env_added = all_change_counts
261
+ print(f"Project path: {project_path}")
262
+ print(f"Settings file: {claude_user_settings_path}")
263
+ print(f"Allow rules added: {allow_added} of {len(all_permission_rules)}")
264
+ print(f"Deny rules added: {deny_added} of {len(all_agent_config_deny_rules)}")
265
+ print(f"Additional directories added: {dirs_added}")
266
+ if purged > 0:
267
+ print(f"Stale auto-mode environment entries purged: {purged}")
268
+ print(f"Auto-mode environment entries added: {env_added}")
269
+
270
+
271
+ def grant_permissions_for_current_directory() -> None:
272
+ """Grant Edit/Read permissions for the current project directory.
273
+
274
+ Builds allow and agent-config deny rules from config, writes them to the
275
+ user settings, and prints the counts applied. A fresh grant reports::
276
+
277
+ Allow rules added: 2 of 2
278
+ Deny rules added: 14 of 14
279
+ Additional directories added: 1
280
+ Auto-mode environment entries added: 1
281
+
282
+ Raises:
283
+ SystemExit: When the current directory is not a valid project root.
284
+ ValueError: Propagated from get_current_project_path() when the path
285
+ contains glob metacharacters.
286
+ """
287
+ claude_user_settings_path: Path = get_claude_user_settings_path()
288
+ project_path = _resolve_project_path_or_exit()
289
+ grant_rule_sets = _build_grant_rule_sets(project_path)
290
+ settings = load_settings(claude_user_settings_path)
291
+ worktree_sweep_removed_count = sweep_stale_worktree_rules_from_settings(settings)
292
+ change_counts = _apply_grant(settings, project_path, grant_rule_sets)
293
+ if sum(change_counts) + worktree_sweep_removed_count == 0:
294
+ _print_no_change_notice(project_path, claude_user_settings_path)
295
+ return
296
+ save_settings(claude_user_settings_path, settings)
297
+ _print_grant_summary(
298
+ project_path, claude_user_settings_path, grant_rule_sets, change_counts
299
+ )
300
+
301
+
302
+ if __name__ == "__main__":
303
+ try:
304
+ grant_permissions_for_current_directory()
305
+ except ValueError as path_error:
306
+ exit_with_error(str(path_error))