claude-dev-env 2.2.1 → 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 (43) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  8. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  11. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  12. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/code-verifier.md +1 -1
  18. package/hooks/blocking/config/verified_commit_constants.py +11 -0
  19. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  20. package/hooks/blocking/test_verification_verdict_store.py +93 -0
  21. package/hooks/blocking/verification_verdict_store.py +91 -0
  22. package/package.json +1 -1
  23. package/scripts/CLAUDE.md +2 -1
  24. package/scripts/claude-chain.example.json +15 -3
  25. package/scripts/claude_chain_runner.py +130 -27
  26. package/scripts/claude_chain_usage.py +346 -0
  27. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  28. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
  29. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  30. package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
  31. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  32. package/scripts/test_claude_chain_runner.py +412 -6
  33. package/scripts/test_claude_chain_usage.py +534 -0
  34. package/skills/orchestrator/SKILL.md +1 -20
  35. package/skills/orchestrator-refresh/SKILL.md +1 -1
  36. package/skills/pr-converge/SKILL.md +3 -3
  37. package/skills/pr-converge/reference/examples.md +3 -3
  38. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  39. package/skills/pr-converge/reference/ground-rules.md +3 -3
  40. package/skills/pr-converge/reference/per-tick.md +5 -5
  41. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  42. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  43. package/skills/team-advisor/SKILL.md +3 -2
@@ -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))
@@ -8,6 +8,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
8
8
  |---|---|
9
9
  | `claude_permissions_constants.py` | `grant_project_claude_permissions.py` and `revoke_project_claude_permissions.py` — permission rule strings and settings.json keys |
10
10
  | `claude_settings_keys_constants.py` | Top-level `~/.claude/settings.json` key names used across the permission helpers |
11
+ | `stale_worktree_rule_sweep_constants.py` | `stale_worktree_rule_sweep.py` — the worktrees subdirectory name, the worktree path segment count, the rule-string delimiters, the `.claude` home path marker, and `get_claude_worktrees_root` |
11
12
  | `code_rules_gate_constants.py` | `code_rules_gate.py` — file extensions, git diff subcommands, test filename patterns |
12
13
  | `inline_duplicate_body_span_constants.py` | `code_rules_gate.py` — regex and capture-group indices for the same-file inline-duplicate message, which carries both the helper and the enclosing span the gate reconstructs the union from |
13
14
  | `fix_hookspath_constants.py` | `fix_hookspath.py` — verification suffix and related strings |