claude-dev-env 1.21.2 → 1.22.1

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.
@@ -0,0 +1,224 @@
1
+ """Shared helpers for grant_project_claude_permissions and revoke_project_claude_permissions.
2
+
3
+ Writes to ~/.claude/settings.json are atomic and permission-preserving: the
4
+ target file's existing POSIX mode is captured, a sibling temp file is
5
+ created via os.open with O_CREAT | O_EXCL and the preserved mode, content
6
+ is written, then os.replace swaps it into place. Output is serialized with
7
+ sort_keys=True for a stable on-disk layout; the first run on a hand-ordered
8
+ settings file produces a one-time re-sort diff, subsequent writes are stable.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import stat
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, NoReturn
17
+
18
+
19
+ TEXT_FILE_ENCODING: str = "utf-8"
20
+ GLOB_METACHARACTERS_IN_PATH: tuple[str, ...] = (
21
+ "*",
22
+ "?",
23
+ "[",
24
+ "]",
25
+ "(",
26
+ ")",
27
+ "{",
28
+ "}",
29
+ ",",
30
+ )
31
+
32
+ JSON_INDENT_SPACES: int = 2
33
+ PERMISSION_ALLOW_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read")
34
+
35
+ DEFAULT_SETTINGS_FILE_MODE: int = 0o600
36
+ ATOMIC_WRITE_TEMPORARY_SUFFIX: str = ".tmp"
37
+
38
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE: str = (
39
+ "Trusted local workspace: {project_path}/.claude/** is the user's "
40
+ "project Claude Code config tree; edits inside are routine"
41
+ )
42
+
43
+
44
+ def exit_with_error(message: str) -> NoReturn:
45
+ print(f"Error: {message}", file=sys.stderr)
46
+ raise SystemExit(1)
47
+
48
+
49
+ def path_contains_glob_metacharacters(candidate_path: str) -> bool:
50
+ return any(
51
+ each_character in candidate_path
52
+ for each_character in GLOB_METACHARACTERS_IN_PATH
53
+ )
54
+
55
+
56
+ def path_contains_whitespace(candidate_path: str) -> bool:
57
+ return any(each_character.isspace() for each_character in candidate_path)
58
+
59
+
60
+ def get_current_project_path() -> str:
61
+ normalized_project_path = str(Path.cwd()).replace("\\", "/")
62
+ if path_contains_glob_metacharacters(normalized_project_path):
63
+ raise ValueError(
64
+ f"Current directory path contains glob metacharacters and cannot "
65
+ f"be used to build permission rules safely: {normalized_project_path}"
66
+ )
67
+ if path_contains_whitespace(normalized_project_path):
68
+ raise ValueError(
69
+ f"Current directory path contains whitespace and cannot be used "
70
+ f"to build permission rules safely: {normalized_project_path}"
71
+ )
72
+ return normalized_project_path
73
+
74
+
75
+ def build_permission_rule(tool_name: str, project_path: str) -> str:
76
+ return f"{tool_name}({project_path}/.claude/**)"
77
+
78
+
79
+ def build_permission_rules(
80
+ project_path: str, permission_allow_tools: tuple[str, ...]
81
+ ) -> list[str]:
82
+ return [
83
+ build_permission_rule(each_tool, project_path)
84
+ for each_tool in permission_allow_tools
85
+ ]
86
+
87
+
88
+ def load_settings(settings_path: Path) -> dict[str, Any]:
89
+ if not settings_path.exists():
90
+ return {}
91
+ parsed_settings: dict[str, Any] = {}
92
+ try:
93
+ raw_text = settings_path.read_text(encoding=TEXT_FILE_ENCODING)
94
+ except OSError as read_error:
95
+ exit_with_error(f"Failed to read {settings_path}: {read_error}")
96
+ try:
97
+ parsed_settings = json.loads(raw_text)
98
+ except json.JSONDecodeError as decode_error:
99
+ exit_with_error(
100
+ f"Refusing to modify {settings_path}: existing file is not valid JSON "
101
+ f"({decode_error}). Fix or back up the file manually, then re-run."
102
+ )
103
+ if not isinstance(parsed_settings, dict):
104
+ exit_with_error(
105
+ f"Refusing to modify {settings_path}: existing file's root is "
106
+ f"{type(parsed_settings).__name__}, not a JSON object. Fix or back up "
107
+ f"the file manually, then re-run."
108
+ )
109
+ return parsed_settings
110
+
111
+
112
+ def get_mode_to_preserve(settings_path: Path) -> int:
113
+ try:
114
+ stat_result = os.stat(settings_path)
115
+ except FileNotFoundError:
116
+ return DEFAULT_SETTINGS_FILE_MODE
117
+ except OSError as stat_error:
118
+ exit_with_error(f"Failed to stat {settings_path}: {stat_error}")
119
+ return stat.S_IMODE(stat_result.st_mode)
120
+
121
+
122
+ def write_atomically_with_mode(
123
+ temporary_path: Path, serialized_content: str, file_mode: int
124
+ ) -> None:
125
+ file_descriptor = os.open(
126
+ str(temporary_path),
127
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
128
+ file_mode,
129
+ )
130
+ with os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING) as writer:
131
+ writer.write(serialized_content)
132
+
133
+
134
+ def save_settings(settings_path: Path, settings: dict[str, Any]) -> None:
135
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
136
+ serialized_settings = json.dumps(
137
+ settings, indent=JSON_INDENT_SPACES, sort_keys=True
138
+ )
139
+ temporary_path = settings_path.with_suffix(
140
+ settings_path.suffix + ATOMIC_WRITE_TEMPORARY_SUFFIX
141
+ )
142
+ mode_to_preserve = get_mode_to_preserve(settings_path)
143
+ try:
144
+ try:
145
+ write_atomically_with_mode(
146
+ temporary_path, serialized_settings, mode_to_preserve
147
+ )
148
+ os.replace(str(temporary_path), str(settings_path))
149
+ except OSError as os_error:
150
+ exit_with_error(
151
+ f"Failed to write settings atomically to {settings_path}: {os_error}"
152
+ )
153
+ finally:
154
+ if temporary_path.exists():
155
+ try:
156
+ temporary_path.unlink()
157
+ except OSError:
158
+ pass
159
+
160
+
161
+ def append_if_missing(target_list: list[str], new_value: str) -> bool:
162
+ if new_value in target_list:
163
+ return False
164
+ target_list.append(new_value)
165
+ return True
166
+
167
+
168
+ def ensure_dict_section(
169
+ settings: dict[str, Any], section_name: str
170
+ ) -> dict[str, Any]:
171
+ """Return an existing dict section or create an empty one if absent.
172
+
173
+ A missing key and an explicit JSON null are treated identically: both
174
+ produce a fresh empty dict stored back into settings. Any other non-dict
175
+ value (string, list, number, bool) calls exit_with_error to avoid
176
+ overwriting user data.
177
+ """
178
+ existing_section = settings.get(section_name)
179
+ if existing_section is None:
180
+ replacement_section: dict[str, Any] = {}
181
+ settings[section_name] = replacement_section
182
+ return replacement_section
183
+ if not isinstance(existing_section, dict):
184
+ exit_with_error(
185
+ f"Refusing to modify settings key {section_name!r}: existing value "
186
+ f"is {type(existing_section).__name__}, not a JSON object. Fix or "
187
+ f"remove the key manually, then re-run."
188
+ )
189
+ return existing_section
190
+
191
+
192
+ def ensure_list_entry(section: dict[str, Any], entry_name: str) -> list[Any]:
193
+ """Return an existing list entry or create an empty one if absent.
194
+
195
+ A missing key and an explicit JSON null are treated identically: both
196
+ produce a fresh empty list stored back into the section. Any other
197
+ non-list value (string, dict, number, bool) calls exit_with_error to
198
+ avoid overwriting user data.
199
+ """
200
+ existing_entry = section.get(entry_name)
201
+ if existing_entry is None:
202
+ replacement_entry: list[Any] = []
203
+ section[entry_name] = replacement_entry
204
+ return replacement_entry
205
+ if not isinstance(existing_entry, list):
206
+ exit_with_error(
207
+ f"Refusing to modify settings entry {entry_name!r}: existing value "
208
+ f"is {type(existing_entry).__name__}, not a JSON array. Fix or "
209
+ f"remove the entry manually, then re-run."
210
+ )
211
+ return existing_entry
212
+
213
+
214
+ def prune_empty_list_then_empty_section(
215
+ settings: dict[str, Any], section_key: str, list_key: str
216
+ ) -> None:
217
+ section = settings.get(section_key)
218
+ if not isinstance(section, dict):
219
+ return
220
+ list_entry = section.get(list_key)
221
+ if isinstance(list_entry, list) and len(list_entry) == 0:
222
+ del section[list_key]
223
+ if len(section) == 0:
224
+ del settings[section_key]
@@ -0,0 +1,110 @@
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
+ from typing import Any
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+
15
+ from _claude_permissions_common import ( # noqa: E402
16
+ append_if_missing,
17
+ build_permission_rules,
18
+ ensure_dict_section,
19
+ ensure_list_entry,
20
+ exit_with_error,
21
+ get_current_project_path,
22
+ load_settings,
23
+ save_settings,
24
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
25
+ PERMISSION_ALLOW_TOOLS,
26
+ )
27
+
28
+
29
+ CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
30
+
31
+
32
+ def is_valid_project_root(candidate_path: Path) -> bool:
33
+ git_marker_path = candidate_path / ".git"
34
+ claude_marker_path = candidate_path / ".claude"
35
+ return git_marker_path.exists() or claude_marker_path.exists()
36
+
37
+
38
+ def add_rules_to_allow_list(settings: dict[str, Any], rules_to_add: list[str]) -> int:
39
+ permissions_section = ensure_dict_section(settings, "permissions")
40
+ existing_allow_list = ensure_list_entry(permissions_section, "allow")
41
+ return sum(
42
+ 1
43
+ for each_rule in rules_to_add
44
+ if append_if_missing(existing_allow_list, each_rule)
45
+ )
46
+
47
+
48
+ def add_directory_to_additional_directories(
49
+ settings: dict[str, Any], directory_path: str
50
+ ) -> int:
51
+ permissions_section = ensure_dict_section(settings, "permissions")
52
+ existing_directories = ensure_list_entry(
53
+ permissions_section, "additionalDirectories"
54
+ )
55
+ if append_if_missing(existing_directories, directory_path):
56
+ return 1
57
+ return 0
58
+
59
+
60
+ def add_auto_mode_environment_entry(settings: dict[str, Any], entry_text: str) -> int:
61
+ auto_mode_section = ensure_dict_section(settings, "autoMode")
62
+ existing_environment = ensure_list_entry(auto_mode_section, "environment")
63
+ if append_if_missing(existing_environment, entry_text):
64
+ return 1
65
+ return 0
66
+
67
+
68
+ def grant_permissions_for_current_directory() -> None:
69
+ project_root_path = Path.cwd()
70
+ if not is_valid_project_root(project_root_path):
71
+ print(
72
+ f"ERROR: cwd {project_root_path} is not a project root "
73
+ f"(no .git or .claude). Run from a project root.",
74
+ file=sys.stderr,
75
+ )
76
+ raise SystemExit(1)
77
+ project_path = get_current_project_path()
78
+ permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
79
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
80
+ project_path=project_path
81
+ )
82
+ settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
83
+ rules_added_count = add_rules_to_allow_list(settings, permission_rules)
84
+ directories_added_count = add_directory_to_additional_directories(
85
+ settings, project_path
86
+ )
87
+ environment_entries_added_count = add_auto_mode_environment_entry(
88
+ settings, environment_entry
89
+ )
90
+ total_changes_count = (
91
+ rules_added_count + directories_added_count + environment_entries_added_count
92
+ )
93
+ if total_changes_count == 0:
94
+ print(f"Project path: {project_path}")
95
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
96
+ print("No changes needed; settings file left untouched.")
97
+ return
98
+ save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
99
+ print(f"Project path: {project_path}")
100
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
101
+ print(f"Allow rules added: {rules_added_count} of {len(permission_rules)}")
102
+ print(f"Additional directories added: {directories_added_count}")
103
+ print(f"Auto-mode environment entries added: {environment_entries_added_count}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ try:
108
+ grant_permissions_for_current_directory()
109
+ except ValueError as path_error:
110
+ exit_with_error(str(path_error))
@@ -0,0 +1,136 @@
1
+ """Revoke the permissions previously granted by grant_project_claude_permissions.
2
+
3
+ Run from the same project root you previously granted. Removes the matching
4
+ allow rules, the additionalDirectories entry, and the autoMode environment
5
+ entry from ~/.claude/settings.json. Safe to run when no prior grant exists.
6
+ After removals, prunes any newly empty lists and their parent permissions or
7
+ autoMode sections so repeated grant/revoke cycles leave no dead structure.
8
+ """
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+
16
+ from _claude_permissions_common import ( # noqa: E402
17
+ build_permission_rules,
18
+ exit_with_error,
19
+ get_current_project_path,
20
+ load_settings,
21
+ prune_empty_list_then_empty_section,
22
+ save_settings,
23
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
24
+ PERMISSION_ALLOW_TOOLS,
25
+ )
26
+
27
+
28
+ CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
29
+
30
+
31
+ def is_valid_project_root(candidate_path: Path) -> bool:
32
+ git_marker_path = candidate_path / ".git"
33
+ claude_marker_path = candidate_path / ".claude"
34
+ return git_marker_path.exists() or claude_marker_path.exists()
35
+
36
+
37
+ def remove_values_from_list(target_list: list[str], values_to_remove: set[str]) -> int:
38
+ original_length = len(target_list)
39
+ target_list[:] = [
40
+ each_value for each_value in target_list if each_value not in values_to_remove
41
+ ]
42
+ return original_length - len(target_list)
43
+
44
+
45
+ def remove_rules_from_allow_list(
46
+ settings: dict[str, Any], rules_to_remove: list[str]
47
+ ) -> int:
48
+ permissions_section = settings.get("permissions")
49
+ if not isinstance(permissions_section, dict):
50
+ return 0
51
+ existing_allow_list = permissions_section.get("allow")
52
+ if not isinstance(existing_allow_list, list):
53
+ return 0
54
+ return remove_values_from_list(existing_allow_list, set(rules_to_remove))
55
+
56
+
57
+ def remove_directory_from_additional_directories(
58
+ settings: dict[str, Any], directory_path: str
59
+ ) -> int:
60
+ permissions_section = settings.get("permissions")
61
+ if not isinstance(permissions_section, dict):
62
+ return 0
63
+ existing_directories = permissions_section.get("additionalDirectories")
64
+ if not isinstance(existing_directories, list):
65
+ return 0
66
+ return remove_values_from_list(existing_directories, {directory_path})
67
+
68
+
69
+ def remove_auto_mode_environment_entry(
70
+ settings: dict[str, Any], entry_text: str
71
+ ) -> int:
72
+ auto_mode_section = settings.get("autoMode")
73
+ if not isinstance(auto_mode_section, dict):
74
+ return 0
75
+ existing_environment = auto_mode_section.get("environment")
76
+ if not isinstance(existing_environment, list):
77
+ return 0
78
+ return remove_values_from_list(existing_environment, {entry_text})
79
+
80
+
81
+ def prune_settings_after_revoke(settings: dict[str, Any]) -> None:
82
+ prune_empty_list_then_empty_section(settings, "permissions", "allow")
83
+ prune_empty_list_then_empty_section(
84
+ settings, "permissions", "additionalDirectories"
85
+ )
86
+ prune_empty_list_then_empty_section(settings, "autoMode", "environment")
87
+
88
+
89
+ def revoke_permissions_for_current_directory() -> None:
90
+ project_root_path = Path.cwd()
91
+ if not is_valid_project_root(project_root_path):
92
+ print(
93
+ f"ERROR: cwd {project_root_path} is not a project root "
94
+ f"(no .git or .claude). Run from a project root.",
95
+ file=sys.stderr,
96
+ )
97
+ raise SystemExit(1)
98
+ project_path = get_current_project_path()
99
+ permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
100
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
101
+ project_path=project_path
102
+ )
103
+ settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
104
+ rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
105
+ directories_removed_count = remove_directory_from_additional_directories(
106
+ settings, project_path
107
+ )
108
+ environment_entries_removed_count = remove_auto_mode_environment_entry(
109
+ settings, environment_entry
110
+ )
111
+ total_changes_count = (
112
+ rules_removed_count
113
+ + directories_removed_count
114
+ + environment_entries_removed_count
115
+ )
116
+ if total_changes_count == 0:
117
+ print(f"Project path: {project_path}")
118
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
119
+ print("No changes to revoke; settings file left untouched.")
120
+ return
121
+ prune_settings_after_revoke(settings)
122
+ save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
123
+ print(f"Project path: {project_path}")
124
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
125
+ print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
126
+ print(f"Additional directories removed: {directories_removed_count}")
127
+ print(
128
+ f"Auto-mode environment entries removed: {environment_entries_removed_count}"
129
+ )
130
+
131
+
132
+ if __name__ == "__main__":
133
+ try:
134
+ revoke_permissions_for_current_directory()
135
+ except ValueError as path_error:
136
+ exit_with_error(str(path_error))
@@ -0,0 +1,203 @@
1
+ ---
2
+ name: findbugs
3
+ description: >-
4
+ Audits the current branch's pull request as a whole for bugs by spawning the
5
+ code-quality-agent against the full PR diff with zero conversation context.
6
+ Returns P0/P1/P2 findings with file:line evidence and a verified-clean
7
+ coverage list. Read-only — never modifies code. Triggers: '/findbugs',
8
+ 'find bugs in this PR', 'audit the PR', 'bug audit on the branch'.
9
+ ---
10
+
11
+ # Findbugs
12
+
13
+ **Core principle:** A clean-room bug audit on the entire pull request. The audit agent receives the PR diff and nothing else — no chat history, no prior framing, no implicit "we already looked at this." Independence is the point.
14
+
15
+ ## When this skill applies
16
+
17
+ User types `/findbugs` or asks for a bug audit on the current branch's PR. Typical moment: PR is up (draft or ready), and the user wants an independent second pair of eyes before merge or before requesting human review.
18
+
19
+ If the current branch has no associated PR and no diff against the default branch, say so and stop. Do not invent scope.
20
+
21
+ ## The Process
22
+
23
+ ### Step 1: Resolve PR scope
24
+
25
+ Determine the audit target in this order:
26
+
27
+ 1. **Open PR for current branch.** Run `gh pr view --json number,baseRefName,headRefName,url` from the working directory. If a PR exists, capture its number, base ref, head ref, and URL.
28
+ 2. **No PR but a remote default branch exists.** Diff against the default branch's merge-base: `git merge-base HEAD origin/<default>` then `git diff <merge-base>...HEAD`. Treat this as the audit scope.
29
+ 3. **Neither.** Respond exactly: `No PR or upstream diff found. Push the branch or open a PR first.` and stop.
30
+
31
+ ### Step 2: Capture the full PR diff
32
+
33
+ Resolve the temp diff path **once, Claude-side**, before invoking any shell command. Use Python's `tempfile.gettempdir()` which honors `TMPDIR`, `TEMP`, and `TMP` in the platform-correct order and falls back to `C:\Users\<user>\AppData\Local\Temp` on Windows or `/tmp` on Unix. Avoid hand-rolled env var chains. The lookup works on macOS, Linux, Windows cmd.exe, and PowerShell:
34
+
35
+ ```
36
+ import tempfile
37
+ diff_temp_path = Path(tempfile.gettempdir()) / f"findbugs-pr-{os.getpid()}.patch"
38
+ ```
39
+
40
+ `os.getpid()` supplies the per-invocation suffix that prevents collisions with parallel `/findbugs` runs (a UUID or timestamp is equally acceptable). Capture the resolved absolute path as `<diff_temp_path>` and pass that **literal** path to every shell command that follows. Shell-side parameter expansion (`${TMPDIR:-/tmp}`, `$$`, `%TEMP%`) is forbidden because cmd.exe and PowerShell do not honor it.
41
+
42
+ When a PR exists: `gh pr diff <number> -R <owner>/<repo> > "<diff_temp_path>"`.
43
+
44
+ When falling back to merge-base diff: `git diff <merge-base>...HEAD > "<diff_temp_path>"`.
45
+
46
+ The audit's authoritative scope is this single diff file. Do not inject extra files, related history, or "files Claude edited this session" — those introduce anchoring bias.
47
+
48
+ ### Step 3: Spawn the code-quality-agent — clean room
49
+
50
+ Call the Agent tool with:
51
+
52
+ - `subagent_type: code-quality-agent`
53
+ - `model: sonnet`
54
+ - `description: "PR bug audit"`
55
+ - `run_in_background: false` — the user invoked `/findbugs` to get a result on this turn
56
+
57
+ **The agent prompt must be self-contained and context-free.** Specifically:
58
+
59
+ - **No references to the orchestrator's conversation.** Forbidden phrases: "as we discussed," "the earlier issue," "given our prior work," "the bug from last turn," "you previously identified."
60
+ - **No hints about expected outcomes.** Do not pre-stage findings, do not suggest where bugs probably are, do not name files as "the suspicious one." The agent forms its own hypotheses.
61
+ - **No instructions to favor or skip particular categories** beyond the standard category list. No "skip the typing stuff" or "focus on the clipboard logic" — those bias the audit.
62
+ - **Minimal background.** Identify the repo, branch, base branch, and PR URL only. Do not summarize what the PR does.
63
+
64
+ The XML prompt skeleton:
65
+
66
+ ```xml
67
+ <context>
68
+ <repo>owner/repo</repo>
69
+ <branch>head ref</branch>
70
+ <base_branch>base ref</base_branch>
71
+ <pr_url>url or "none"</pr_url>
72
+ </context>
73
+
74
+ <scope>
75
+ <diff_path><diff_temp_path> (absolute scoped temp path from Step 2)</diff_path>
76
+ <scope_rule>Audit only lines added or modified in the diff. Pre-existing code on untouched lines is out of scope.</scope_rule>
77
+ </scope>
78
+
79
+ <bug_categories>
80
+ Investigate each explicitly:
81
+ A. API contract verification (signatures, return types, async/await correctness)
82
+ B. Selector / query / engine compatibility
83
+ C. Resource cleanup and lifecycle (file handles, connections, processes, locks)
84
+ D. Variable scoping, ordering, and unbound references
85
+ E. Dead code and unused imports
86
+ F. Silent failures (catch-all excepts, unconditional success returns, missing error propagation)
87
+ G. Off-by-one, bounds, and integer overflow
88
+ H. Security boundaries (injection, path traversal, auth bypass, secret leakage)
89
+ I. Concurrency hazards (race conditions, missing awaits, shared mutable state)
90
+ J. Magic values and configuration drift
91
+ </bug_categories>
92
+
93
+ <constraints>
94
+ Read-only. Report findings only. Do not modify code, do not propose
95
+ full diffs, do not commit, do not push. Cite file:line for every claim.
96
+ When the diff alone does not provide enough context to confirm or deny
97
+ a bug, list it under "Open questions" rather than asserting.
98
+ </constraints>
99
+
100
+ <output_format>
101
+ P0 = will not run / data corruption
102
+ P1 = regression or silent failure
103
+ P2 = dead code, minor smell
104
+
105
+ ## Summary
106
+ Total: N (P0=N, P1=N, P2=N)
107
+
108
+ ## Findings
109
+ ### [P_] short title
110
+ File: file/path:line
111
+ Category: A-J
112
+ Issue: 2-3 sentence description with concrete trace
113
+ Evidence: code excerpt or grep result
114
+
115
+ ## Verified clean
116
+ Per category investigated, name the evidence and the conclusion.
117
+
118
+ ## Open questions
119
+ Anything ambiguous from the diff alone.
120
+ </output_format>
121
+ ```
122
+
123
+ ### Step 4: Surface findings, then clean up
124
+
125
+ When the agent returns, report concisely:
126
+
127
+ - One-line totals: `N P0 / N P1 / N P2 — K categories cleared`
128
+ - Each finding's `file:line`, category, and one-sentence description
129
+ - The cleared categories so the user can see coverage breadth
130
+ - Any open questions the agent could not resolve from the diff alone
131
+
132
+ Offer the next step without auto-executing it: `Want me to spawn clean-coder to fix the P0/P1 findings?`
133
+
134
+ Delete the scoped temp diff at `<diff_temp_path>` after the audit completes (or moves to a fix flow). Temporary diff files do not belong in the working tree.
135
+
136
+ Do not paste the full agent transcript or the XML prompt unless the user asks.
137
+
138
+ ## Output Format
139
+
140
+ ```
141
+ N P0 / N P1 / N P2 — K categories cleared
142
+
143
+ P1 — short title
144
+ file/path.py:NN — one-sentence description (category: <name>)
145
+
146
+ P2 — short title
147
+ file/path.py:NN — one-sentence description (category: <name>)
148
+
149
+ Verified clean: <category>, <category>, <category>
150
+
151
+ Open questions:
152
+ <if any>
153
+
154
+ Want me to spawn clean-coder to fix the P0/P1 findings?
155
+ ```
156
+
157
+ ## Constraints
158
+
159
+ - **Read-only.** The skill never edits code, never pushes, never commits.
160
+ - **Foreground spawn.** The user is waiting for the result on this turn.
161
+ - **PR-scoped, not session-scoped.** The audit covers the entire PR diff regardless of which files were edited in this conversation.
162
+ - **Clean-room prompt.** The agent's prompt is self-contained — no references to chat history, no anchoring hints, no expected outcomes.
163
+ - **No clean-coder auto-spawn.** Always ask before fixing.
164
+ - **Trust the agent's verdict.** Pass through P0/P1/P2 categorizations as the agent assigned them; do not re-rank.
165
+ - **Temp file cleanup.** Delete the scoped `<diff_temp_path>` when the audit ends.
166
+
167
+ ## Examples
168
+
169
+ <example>
170
+ User: `/findbugs`
171
+ Claude: [resolves PR #42 from current branch, fetches full diff, spawns code-quality-agent foreground with self-contained prompt, returns]
172
+
173
+ `1 P0 / 2 P1 / 0 P2 — 7 categories cleared`
174
+
175
+ `P0 — race condition on shared cache write`
176
+ ` src/cache.py:88 — concurrent writers can both pass the existence check before either writes (category: concurrency)`
177
+
178
+ `P1 — silent paste failure`
179
+ ` utils/clipboard.py:33 — validated_paste returns success without verifying the post-paste state (category: silent failure)`
180
+
181
+ `P1 — unbound variable on early-exception path`
182
+ ` src/processor.py:283 — scheduling_log referenced after try/finally where it may be unbound (category: scoping)`
183
+
184
+ `Verified clean: API contract, selector compatibility, resource cleanup, dead code, off-by-one, security boundaries, magic values`
185
+
186
+ `Open questions: none`
187
+
188
+ `Want me to spawn clean-coder to fix the P0 + P1s?`
189
+ </example>
190
+
191
+ <example>
192
+ User: `/findbugs`
193
+ Claude: `No PR or upstream diff found. Push the branch or open a PR first.`
194
+ </example>
195
+
196
+ <example>
197
+ User: `/findbugs` (branch with no PR but commits ahead of main)
198
+ Claude: [falls back to `git diff origin/main...HEAD`, runs audit on that diff scope]
199
+ </example>
200
+
201
+ ## Why this design
202
+
203
+ Anchoring bias is the failure mode of context-rich audits. An agent that inherits "we just fixed three bugs in clipboard_utils.py" subconsciously scopes its hunt around clipboard_utils.py and pattern-matches on the same bug shapes. A clean-room audit on the full PR diff treats every file equally, considers every category, and surfaces things the orchestrator session never looked at. The diff is the contract; everything else is noise.