claude-dev-env 1.32.0 → 1.34.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.
@@ -53,9 +53,7 @@ Two things to know about the handler:
53
53
  If a skill or runbook genuinely needs a one-line shell invocation, the equivalent without `ignore_errors=True` is:
54
54
 
55
55
  ```bash
56
- python -c "import os, shutil, stat, sys; \
57
- def _h(f, p, *_): os.chmod(p, stat.S_IWRITE); f(p); \
58
- shutil.rmtree(r'<path>', **({'onexc': _h} if sys.version_info >= (3, 12) else {'onerror': _h}))"
56
+ python -c "import os, shutil, stat, sys; h = lambda f, p, *_: (os.chmod(p, stat.S_IWRITE), f(p)); shutil.rmtree(r'<path>', **({'onexc': h} if sys.version_info >= (3, 12) else {'onerror': h}))"
59
57
  ```
60
58
 
61
59
  Prefer the multi-line `force_rmtree` helper — the one-liner is hard to read and easy to mis-quote.
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ Scans Claude Code settings*.json files for legacy shell-invocation patterns
5
+ that violate the pwsh-only policy.
6
+
7
+ .DESCRIPTION
8
+ Walks the configured project roots, finds every settings.json,
9
+ settings.local.json, and settings.local.json.template, and counts permission
10
+ rule strings that invoke powershell.exe / Windows PowerShell 5.1 / bash -c /
11
+ cmd /c instead of pwsh. Prints exactly one summary line and exits 0 when
12
+ clean or 1 when violations remain.
13
+
14
+ .PARAMETER Roots
15
+ One or more directories to scan recursively. Defaults to the user's known
16
+ Claude Code project parents.
17
+
18
+ .PARAMETER Verbose
19
+ Use the standard PowerShell -Verbose switch to print per-file violation
20
+ detail in addition to the summary line.
21
+
22
+ .OUTPUTS
23
+ One line on stdout:
24
+ POLICY: OK
25
+ POLICY: VIOLATIONS=<count> IN=<n> FILES UNPARSEABLE=<m> FILES
26
+ POLICY: UNPARSEABLE=<m> FILES (audit unsound)
27
+
28
+ .EXAMPLE
29
+ pwsh -NoProfile -File Audit-ShellPolicy.ps1
30
+ pwsh -NoProfile -File Audit-ShellPolicy.ps1 -Verbose
31
+ pwsh -NoProfile -File Audit-ShellPolicy.ps1 -Roots 'Y:\Projects'
32
+ #>
33
+ [CmdletBinding()]
34
+ param(
35
+ [string[]]$Roots = @(
36
+ (Join-Path $env:USERPROFILE '.claude'),
37
+ 'Y:\Projects',
38
+ 'Y:\Information Technology\Scripts',
39
+ 'Y:\Python',
40
+ 'Y:\claude-settings'
41
+ )
42
+ )
43
+
44
+ Set-StrictMode -Version Latest
45
+ $ErrorActionPreference = 'Stop'
46
+
47
+ $caseInsensitive = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
48
+ $violationPatterns = @(
49
+ [regex]::new('^Bash\(powershell(?:\.exe)?(?:[\s:].*)?\)$', $caseInsensitive)
50
+ [regex]::new('^Bash\(bash\s+(?:-c|--login|--rcfile|--init-file)\b.*\)$', $caseInsensitive)
51
+ [regex]::new('^Bash\(cmd(?:\.exe)?\s+/c\b.*\)$', $caseInsensitive)
52
+ [regex]::new('^Bash\(pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command\s+["'']?&\s+[''"].*\)$', $caseInsensitive)
53
+ )
54
+
55
+ $settingsFileNames = @(
56
+ 'settings.json',
57
+ 'settings.local.json',
58
+ 'settings.local.json.template'
59
+ )
60
+
61
+ function Test-RuleViolatesPolicy {
62
+ param([string]$Rule)
63
+ foreach ($pattern in $violationPatterns) {
64
+ if ($pattern.IsMatch($Rule)) { return $true }
65
+ }
66
+ return $false
67
+ }
68
+
69
+ function Test-HasProperty {
70
+ param($Target, [string]$Name)
71
+ if ($null -eq $Target) { return $false }
72
+ if ($Target -isnot [psobject]) { return $false }
73
+ return ($Target.PSObject.Properties.Name -contains $Name)
74
+ }
75
+
76
+ function Get-PermissionRuleArrays {
77
+ param([Parameter(Mandatory)] $SettingsObject)
78
+ $arrays = @()
79
+ if (-not (Test-HasProperty -Target $SettingsObject -Name 'permissions')) { return $arrays }
80
+ $permissions = $SettingsObject.permissions
81
+ foreach ($key in @('allow', 'ask')) {
82
+ if (-not (Test-HasProperty -Target $permissions -Name $key)) { continue }
83
+ $maybeArray = $permissions.$key
84
+ if ($null -ne $maybeArray) { $arrays += , $maybeArray }
85
+ }
86
+ return $arrays
87
+ }
88
+
89
+ $totalViolations = 0
90
+ $filesWithViolations = 0
91
+ $unparseableFileCount = 0
92
+ $scannedFileCount = 0
93
+ $existingRoots = $Roots | Where-Object { Test-Path $_ }
94
+
95
+ foreach ($root in $existingRoots) {
96
+ $candidateFiles = Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
97
+ Where-Object { $settingsFileNames -contains $_.Name }
98
+ foreach ($file in $candidateFiles) {
99
+ $rawContent = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue
100
+ if ([string]::IsNullOrWhiteSpace($rawContent)) { continue }
101
+ try {
102
+ $parsed = $rawContent | ConvertFrom-Json -ErrorAction Stop
103
+ } catch {
104
+ $unparseableFileCount++
105
+ Write-Warning "Skipped (invalid JSON): $($file.FullName)"
106
+ continue
107
+ }
108
+ $scannedFileCount++
109
+ $fileViolationCount = 0
110
+ foreach ($ruleArray in (Get-PermissionRuleArrays -SettingsObject $parsed)) {
111
+ foreach ($rule in $ruleArray) {
112
+ if ($rule -is [string] -and (Test-RuleViolatesPolicy -Rule $rule)) {
113
+ $fileViolationCount++
114
+ Write-Verbose " $($file.FullName): $rule"
115
+ }
116
+ }
117
+ }
118
+ if ($fileViolationCount -gt 0) {
119
+ $totalViolations += $fileViolationCount
120
+ $filesWithViolations++
121
+ }
122
+ }
123
+ }
124
+
125
+ if ($scannedFileCount -eq 0 -and $unparseableFileCount -eq 0) {
126
+ Write-Warning 'No settings files found in any of the configured roots — audit is vacuous.'
127
+ Write-Output 'POLICY: NO FILES SCANNED'
128
+ exit 1
129
+ }
130
+
131
+ if ($totalViolations -eq 0 -and $unparseableFileCount -eq 0) {
132
+ Write-Output ('POLICY: OK SCANNED={0} FILES' -f $scannedFileCount)
133
+ exit 0
134
+ }
135
+
136
+ if ($totalViolations -eq 0 -and $unparseableFileCount -gt 0) {
137
+ Write-Output ('POLICY: UNPARSEABLE={0} FILES SCANNED={1} FILES (audit unsound)' -f $unparseableFileCount, $scannedFileCount)
138
+ exit 1
139
+ }
140
+
141
+ Write-Output ('POLICY: VIOLATIONS={0} IN={1} FILES UNPARSEABLE={2} FILES SCANNED={3} FILES' -f $totalViolations, $filesWithViolations, $unparseableFileCount, $scannedFileCount)
142
+ exit 1
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ Rewrites legacy shell-invocation rules in Claude Code settings*.json files
5
+ to the pwsh-only canonical form.
6
+
7
+ .DESCRIPTION
8
+ Walks the configured project roots, finds every settings.json,
9
+ settings.local.json, and settings.local.json.template, and rewrites permission
10
+ rule strings that invoke powershell / powershell.exe / bash -c / cmd /c
11
+ into their pwsh equivalents per the migration mapping in
12
+ rules/shell-invocation-policy.md. Defaults to dry-run; pass -Apply to write
13
+ changes. Prints exactly one summary line.
14
+
15
+ .PARAMETER Roots
16
+ One or more directories to scan recursively. Defaults to the user's known
17
+ Claude Code project parents.
18
+
19
+ .PARAMETER Apply
20
+ Write changes back to disk. Without this switch, runs in dry-run mode and
21
+ reports what would change without modifying any files.
22
+
23
+ .OUTPUTS
24
+ One line on stdout:
25
+ DRY RUN: would migrate <count> rules IN=<n> FILES UNPARSEABLE=<m> FILES
26
+ MIGRATED: <count> rules IN=<n> FILES UNPARSEABLE=<m> FILES
27
+ MIGRATED: 0 rules SCANNED=<n> FILES UNPARSEABLE=<m> FILES (already compliant)
28
+ MIGRATED: NO FILES SCANNED UNPARSEABLE=<m> FILES
29
+
30
+ .EXAMPLE
31
+ pwsh -NoProfile -File Migrate-ShellPolicy.ps1
32
+ pwsh -NoProfile -File Migrate-ShellPolicy.ps1 -Apply
33
+ pwsh -NoProfile -File Migrate-ShellPolicy.ps1 -Apply -Verbose
34
+
35
+ .NOTES
36
+ Files are written as UTF-8 without BOM to preserve cross-platform
37
+ compatibility and avoid corrupting first-line JSON parsing.
38
+ #>
39
+ [CmdletBinding()]
40
+ param(
41
+ [string[]]$Roots = @(
42
+ (Join-Path $env:USERPROFILE '.claude'),
43
+ 'Y:\Projects',
44
+ 'Y:\Information Technology\Scripts',
45
+ 'Y:\Python',
46
+ 'Y:\claude-settings'
47
+ ),
48
+ [switch]$Apply
49
+ )
50
+
51
+ Set-StrictMode -Version Latest
52
+ $ErrorActionPreference = 'Stop'
53
+
54
+ $caseInsensitiveOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
55
+ $ruleRewrites = @(
56
+ @{ Pattern = [regex]::new('^Bash\(powershell\.exe:\*\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh:*)' }
57
+ @{ Pattern = [regex]::new('^Bash\(powershell:\*\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh:*)' }
58
+ @{ Pattern = [regex]::new('^Bash\((?:powershell|pwsh)(?:\.exe)?\s+(?:-NoProfile\s+)?-Command\s+"&\s+''([^'']+)''(.*?)"\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File ''$1''$2)' }
59
+ @{ Pattern = [regex]::new('^Bash\((?:powershell|pwsh)(?:\.exe)?\s+(?:-NoProfile\s+)?-Command\s+''&\s+"([^"]+)"(.*?)''\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File "$1"$2)' }
60
+ @{ Pattern = [regex]::new('^Bash\(powershell\.exe\s+-Command\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
61
+ @{ Pattern = [regex]::new('^Bash\(powershell\s+-Command\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
62
+ @{ Pattern = [regex]::new('^Bash\(powershell\.exe\s+-NoProfile\s+-Command\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
63
+ @{ Pattern = [regex]::new('^Bash\(powershell\s+-NoProfile\s+-Command\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
64
+ @{ Pattern = [regex]::new('^Bash\(powershell\.exe\s+-NoProfile\s+-File\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File $1)' }
65
+ @{ Pattern = [regex]::new('^Bash\(powershell\s+-NoProfile\s+-File\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File $1)' }
66
+ @{ Pattern = [regex]::new('^Bash\(powershell\.exe\s+-File\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File $1)' }
67
+ @{ Pattern = [regex]::new('^Bash\(powershell\s+-File\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -File $1)' }
68
+ @{ Pattern = [regex]::new('^Bash\(bash\s+-c\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
69
+ @{ Pattern = [regex]::new('^Bash\(cmd\.exe\s+/c\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
70
+ @{ Pattern = [regex]::new('^Bash\(cmd\s+/c\s+(.*)\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command $1)' }
71
+ @{ Pattern = [regex]::new('^Bash\(bash\s+--login\b.*\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command ''Write-Error "manual conversion needed for bash --login rule"; exit 1'')' }
72
+ @{ Pattern = [regex]::new('^Bash\(bash\s+--rcfile\b.*\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command ''Write-Error "manual conversion needed for bash --rcfile rule"; exit 1'')' }
73
+ @{ Pattern = [regex]::new('^Bash\(bash\s+--init-file\b.*\)$', $caseInsensitiveOptions); Replacement = 'Bash(pwsh -NoProfile -Command ''Write-Error "manual conversion needed for bash --init-file rule"; exit 1'')' }
74
+ )
75
+
76
+ $settingsFileNames = @(
77
+ 'settings.json',
78
+ 'settings.local.json',
79
+ 'settings.local.json.template'
80
+ )
81
+
82
+ function Get-MigratedRule {
83
+ param([string]$Rule)
84
+ foreach ($rewrite in $ruleRewrites) {
85
+ if ($rewrite.Pattern.IsMatch($Rule)) {
86
+ return $rewrite.Pattern.Replace($Rule, $rewrite.Replacement)
87
+ }
88
+ }
89
+ return $Rule
90
+ }
91
+
92
+ function Test-HasProperty {
93
+ param($Target, [string]$Name)
94
+ if ($null -eq $Target) { return $false }
95
+ if ($Target -isnot [psobject]) { return $false }
96
+ return ($Target.PSObject.Properties.Name -contains $Name)
97
+ }
98
+
99
+ function Convert-PermissionsArrays {
100
+ param($SettingsObject)
101
+ $rewriteCount = 0
102
+ if (-not (Test-HasProperty -Target $SettingsObject -Name 'permissions')) { return $rewriteCount }
103
+ $permissions = $SettingsObject.permissions
104
+ foreach ($key in @('allow', 'ask')) {
105
+ if (-not (Test-HasProperty -Target $permissions -Name $key)) { continue }
106
+ $existingArray = $permissions.$key
107
+ if ($null -eq $existingArray) { continue }
108
+ $newArray = @()
109
+ $seenMigratedStrings = [System.Collections.Generic.HashSet[string]]::new()
110
+ foreach ($rule in $existingArray) {
111
+ if ($rule -is [string]) {
112
+ $migrated = Get-MigratedRule -Rule $rule
113
+ $isRewrite = ($migrated -ne $rule)
114
+ if ($seenMigratedStrings.Contains($migrated)) {
115
+ if ($isRewrite) {
116
+ Write-Verbose " dedupe-collapsed: '$rule' -> '$migrated' (duplicate of earlier rewrite)"
117
+ $rewriteCount++
118
+ }
119
+ continue
120
+ }
121
+ [void]$seenMigratedStrings.Add($migrated)
122
+ if ($isRewrite) {
123
+ Write-Verbose " rewrite: '$rule' -> '$migrated'"
124
+ $rewriteCount++
125
+ }
126
+ $newArray += $migrated
127
+ } else {
128
+ $newArray += $rule
129
+ }
130
+ }
131
+ $permissions.$key = $newArray
132
+ }
133
+ return $rewriteCount
134
+ }
135
+
136
+ $totalRewrites = 0
137
+ $filesChanged = 0
138
+ $scannedFileCount = 0
139
+ $unparseableFileCount = 0
140
+ $existingRoots = $Roots | Where-Object { Test-Path $_ }
141
+
142
+ foreach ($root in $existingRoots) {
143
+ $candidateFiles = Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
144
+ Where-Object { $settingsFileNames -contains $_.Name }
145
+ foreach ($file in $candidateFiles) {
146
+ $rawContent = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue
147
+ if ([string]::IsNullOrWhiteSpace($rawContent)) { continue }
148
+ try {
149
+ $parsed = $rawContent | ConvertFrom-Json -ErrorAction Stop
150
+ } catch {
151
+ $unparseableFileCount++
152
+ Write-Warning "Skipped (invalid JSON): $($file.FullName)"
153
+ continue
154
+ }
155
+ $scannedFileCount++
156
+ Write-Verbose "Scanning: $($file.FullName)"
157
+ $rewriteCount = Convert-PermissionsArrays -SettingsObject $parsed
158
+ if ($rewriteCount -gt 0) {
159
+ $totalRewrites += $rewriteCount
160
+ $filesChanged++
161
+ if ($Apply) {
162
+ $newJson = $parsed | ConvertTo-Json -Depth 100
163
+ [IO.File]::WriteAllText($file.FullName, $newJson, [Text.UTF8Encoding]::new($false))
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ if ($scannedFileCount -eq 0 -and $unparseableFileCount -eq 0) {
170
+ Write-Warning 'No settings files found in any of the configured roots — migration is vacuous.'
171
+ Write-Output 'MIGRATED: NO FILES SCANNED UNPARSEABLE=0 FILES'
172
+ exit 1
173
+ }
174
+
175
+ if ($scannedFileCount -eq 0 -and $unparseableFileCount -gt 0) {
176
+ Write-Output ('MIGRATED: NO FILES SCANNED UNPARSEABLE={0} FILES (migration unsound)' -f $unparseableFileCount)
177
+ exit 1
178
+ }
179
+
180
+ if ($totalRewrites -eq 0) {
181
+ Write-Output ('MIGRATED: 0 rules SCANNED={0} FILES UNPARSEABLE={1} FILES (already compliant)' -f $scannedFileCount, $unparseableFileCount)
182
+ if ($unparseableFileCount -gt 0) { exit 1 }
183
+ exit 0
184
+ }
185
+
186
+ if ($Apply) {
187
+ Write-Output ('MIGRATED: {0} rules IN={1} FILES SCANNED={2} FILES UNPARSEABLE={3} FILES' -f $totalRewrites, $filesChanged, $scannedFileCount, $unparseableFileCount)
188
+ } else {
189
+ Write-Output ('DRY RUN: would migrate {0} rules IN={1} FILES SCANNED={2} FILES UNPARSEABLE={3} FILES' -f $totalRewrites, $filesChanged, $scannedFileCount, $unparseableFileCount)
190
+ }
191
+ if ($unparseableFileCount -gt 0) { exit 1 }
192
+ exit 0
@@ -0,0 +1,171 @@
1
+ ---
2
+ name: auditing-claude-config
3
+ description: Audits a Claude Code setup (user CLAUDE.md, ~/.claude/rules/, project .claude/) for context-budget waste — duplicate @-imports, eagerly-loaded rules that should be path-scoped or converted to skills, oversized always-on files, and rules duplicating existing skills. Produces a migration table with line-count savings. Use when reviewing the always-on instruction load, when sessions feel sluggish, when /memory shows surprising loads, when adding new rules, or for periodic config hygiene. Optionally verifies findings empirically via an InstructionsLoaded probe hook that logs every load event with its load_reason and parent_file_path.
4
+ ---
5
+
6
+ # Auditing Claude Config
7
+
8
+ This skill audits what gets eagerly loaded into every Claude Code session and identifies wins — duplicate imports, lazy-load candidates, skill-conversion candidates, and pointer-shrink opportunities. It is grounded in three Anthropic docs cited at the bottom.
9
+
10
+ ## When to invoke
11
+
12
+ - `/memory` shows files the user did not expect to be loaded
13
+ - The user is adding new rules and wants to know if the preload is growing past the recommended ceiling (CLAUDE.md target: under 200 lines)
14
+ - Sessions feel sluggish or adherence to rules has degraded (the docs warn that bloated CLAUDE.md files cause Claude to ignore actual instructions)
15
+ - A new template or shared `.claude/` directory has just been adopted
16
+ - Periodic hygiene — quarterly is a reasonable cadence
17
+
18
+ ## Background facts the audit relies on
19
+
20
+ These come from the official Claude Code documentation; do not re-derive them.
21
+
22
+ | Fact | Source phrasing |
23
+ |---|---|
24
+ | `@path` imports in CLAUDE.md and rules expand into context **at launch** — they are not pointers | "Imported files are expanded and loaded into context at launch alongside the CLAUDE.md that references them" |
25
+ | Splitting into `@`-imports does **not** reduce context | "Splitting into `@path` imports helps organization but does not reduce context, since imported files load at launch" |
26
+ | Files in `.claude/rules/` without `paths:` frontmatter load **every** session | "Rules without `paths` frontmatter are loaded unconditionally and apply to all files" |
27
+ | Path-scoped rules load **lazily** when matching files are accessed | "Rules can be scoped to specific files using YAML frontmatter with the `paths` field. These conditional rules only apply when Claude is working with files matching the specified patterns" |
28
+ | Skills preload **metadata only** | "At startup, only the metadata (name and description) from all Skills is pre-loaded. Claude reads SKILL.md only when the Skill becomes relevant, and reads additional files only as needed" |
29
+ | `@`-imports inside fenced/inline code blocks do not trigger imports | Empirical (verified in this skill's source session) — referenced files alongside backtick-wrapped `@` paths do not appear in session-start context |
30
+
31
+ ## Audit workflow
32
+
33
+ ### Step 1 — Inventory the always-loaded set
34
+
35
+ ```
36
+ Files to count:
37
+ ~/.claude/CLAUDE.md
38
+ ~/.claude/CLAUDE.local.md (if present)
39
+ ./CLAUDE.md (project root)
40
+ ./.claude/CLAUDE.md (project alt)
41
+ ./CLAUDE.local.md
42
+ every file in ~/.claude/rules/ without `paths:` frontmatter
43
+ every file in ./.claude/rules/ without `paths:` frontmatter
44
+ every file referenced via @-import from any of the above (recursively, max depth 5)
45
+ ```
46
+
47
+ Count lines with `wc -l` (cygwin/Git Bash) or `Get-Content … | Measure-Object -Line` (PowerShell). Sum is the always-loaded line budget.
48
+
49
+ Flag the result against the docs:
50
+ - CLAUDE.md alone over 200 lines → strong nudge to slim
51
+ - Total preload over ~1,000 lines → likely losing instruction adherence
52
+
53
+ ### Step 2 — Find duplicate `@`-imports
54
+
55
+ Search every always-loaded file (CLAUDE.md and every rule file without `paths:`) for `@`-references. Build a multimap of `imported_path → [referrer_path, ...]`. Any entry with two or more referrers is loading the import twice into context.
56
+
57
+ Fix: delete the import from one of the parents (keep it in the file with the broader scope, typically CLAUDE.md).
58
+
59
+ ### Step 3 — Classify each rule
60
+
61
+ For every rule in `~/.claude/rules/` (and project `.claude/rules/`), apply this matrix:
62
+
63
+ | Rule body describes | Verdict | Action |
64
+ |---|---|---|
65
+ | Behavior that applies every turn (TDD, conservative-action, ask-via-tool, etc.) | Keep always-on | No change |
66
+ | File-type-specific patterns (Python idioms, JS/TS, Windows fs, test patterns) | Path-scope | Add `paths:` frontmatter with appropriate globs |
67
+ | A multi-step workflow or procedure | Convert to skill | Move body to `~/.claude/skills/<name>/SKILL.md`, leave a 2-3 line pointer rule |
68
+ | Content already covered by an existing skill in `~/.claude/skills/` | Shrink to pointer | Replace body with a 2-3 line reference to the skill |
69
+ | Reference doc consumed only by one rule | Inline or co-locate | Move into the consumer rule or skill, drop the standalone doc |
70
+
71
+ When suggesting `paths:` globs, derive them from the rule's body — do not guess. Examples:
72
+ - Body discusses `shutil.rmtree`, `os.unlink` → `paths: ["**/*.py"]`
73
+ - Body discusses `mkdirSync`, `fs.promises` → `paths: ["**/*.{mjs,js,ts}"]`
74
+ - Body discusses pytest fixtures, test naming → `paths: ["**/test_*.py", "**/*_test.py", "**/conftest.py"]`
75
+
76
+ ### Step 4 — Produce the migration table
77
+
78
+ Output one table with these columns: `Rule | Lines today | Verdict | Specific action | Lines removed from preload`. Total the savings. Express as both an absolute line count and a percentage of step 1's baseline.
79
+
80
+ ### Step 5 — Stage the changes
81
+
82
+ Group recommendations by risk:
83
+ - **Zero-risk:** duplicate-import deletion (largest single win in most setups)
84
+ - **Low-risk:** adding `paths:` frontmatter (the docs guarantee fallback to "applies to all files" if syntax is wrong; verify with `/memory`)
85
+ - **Medium-risk:** moving content into skills (changes when content reaches Claude — skill-discovery dependent)
86
+ - **Author-required:** shrinking rules to pointers (requires deciding what content survives)
87
+
88
+ ## Empirical verification (optional)
89
+
90
+ If the audit's recommendations rest on assumptions about lazy-load behavior — especially `@`-imports nested inside path-scoped rules — install this probe hook to capture every `InstructionsLoaded` event.
91
+
92
+ ### Hook script
93
+
94
+ Path: `~/.claude/hooks/observability/instructions_loaded_logger.py`
95
+
96
+ ```python
97
+ #!/usr/bin/env python3
98
+ import json
99
+ import sys
100
+ from datetime import datetime, timezone
101
+ from pathlib import Path
102
+
103
+
104
+ def main() -> int:
105
+ log_path = Path.home() / ".claude" / "logs" / "instructions_loaded.jsonl"
106
+ try:
107
+ payload = json.load(sys.stdin)
108
+ record = {
109
+ "timestamp": datetime.now(timezone.utc).isoformat(),
110
+ "file_path": payload.get("file_path"),
111
+ "load_reason": payload.get("load_reason"),
112
+ "memory_type": payload.get("memory_type"),
113
+ "trigger_file_path": payload.get("trigger_file_path"),
114
+ "parent_file_path": payload.get("parent_file_path"),
115
+ "globs": payload.get("globs"),
116
+ "session_id": payload.get("session_id"),
117
+ }
118
+ except Exception as exception:
119
+ record = {
120
+ "timestamp": datetime.now(timezone.utc).isoformat(),
121
+ "error": str(exception),
122
+ }
123
+ try:
124
+ log_path.parent.mkdir(parents=True, exist_ok=True)
125
+ with log_path.open("a", encoding="utf-8") as log_file:
126
+ log_file.write(json.dumps(record) + "\n")
127
+ except OSError:
128
+ pass
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ raise SystemExit(main())
134
+ ```
135
+
136
+ ### settings.json registration
137
+
138
+ Merge under `hooks.InstructionsLoaded` in `~/.claude/settings.json`:
139
+
140
+ ```json
141
+ {
142
+ "matcher": "session_start|nested_traversal|path_glob_match|include|compact",
143
+ "hooks": [
144
+ {
145
+ "type": "command",
146
+ "command": "python ~/.claude/hooks/observability/instructions_loaded_logger.py"
147
+ }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ ### Test protocol
153
+
154
+ 1. Start a fresh Claude Code session in a directory with no test files (`*.test.*`, `test_*.py`, etc.).
155
+ 2. Read `~/.claude/logs/instructions_loaded.jsonl`. Every entry should have `load_reason: "session_start"` (plus any `nested_traversal` for ancestor CLAUDE.md files). Entries for path-scoped rules should be absent.
156
+ 3. Open a `.test.tsx` (or `test_foo.py`) file. Re-read the log. New entries should have `load_reason: "path_glob_match"` for the rule itself, and `load_reason: "include"` (with `parent_file_path` pointing at the rule) for any `@`-imports nested inside it.
157
+ 4. Acceptance: a rule classified as path-scoped does not appear in step 2 but does appear in step 3. Its nested imports follow the same pattern.
158
+
159
+ ## Output format
160
+
161
+ Always end an audit run with:
162
+ 1. **Baseline:** total always-loaded lines today, broken down by file
163
+ 2. **Findings:** the migration table from step 4
164
+ 3. **Recommended next step:** the single highest-leverage change (usually duplicate-import deletion)
165
+ 4. **Open questions:** anything not verified empirically
166
+
167
+ ## Sources
168
+
169
+ - [Claude Code — How Claude remembers your project](https://code.claude.com/docs/en/memory)
170
+ - [Claude Code — Hooks (InstructionsLoaded)](https://code.claude.com/docs/en/hooks)
171
+ - [Claude API — Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
@@ -35,8 +35,47 @@ cd into `<worktree_path>` before any git, gh, or file operation.
35
35
  H. Security boundaries (injection, path traversal, auth bypass, secret leakage)
36
36
  I. Concurrency hazards (race conditions, missing awaits, shared mutable state)
37
37
  J. Magic values and configuration drift
38
+ Copilot-derived addendum (K–N) — verify each one explicitly. Return at
39
+ least one finding per category OR a verified-clean entry that names the
40
+ exact files and lines you walked.
41
+ K. Collection naming. Every tuple, list, set, dict, mapping, or sequence
42
+ parameter must follow the CODE_RULES.md §5 "Extended naming rules"
43
+ prefix discipline:
44
+ - module-level constant whose value is a tuple/list/set/dict/frozenset
45
+ literal MUST start with `ALL_` (e.g. `ALL_THEMES_INSERT_REQUIRED_COLUMN_NAMES`)
46
+ - function/method parameter whose annotation is `list[...]`, `tuple[...]`,
47
+ `set[...]`, `dict[...]`, `Iterable[...]`, `Sequence[...]`, `Mapping[...]`,
48
+ or `frozenset[...]` MUST start with `all_` (e.g. `all_column_value_pairs`)
49
+ - exempt: dict/map names that follow the `X_by_Y` pattern (e.g.
50
+ `price_by_product`)
51
+ L. Library print / direct stdout. In any module that is not a CLI entry
52
+ point (`__main__`, `*_cli.py`, `scripts/*.py`), every `print(...)`,
53
+ `sys.stdout.write(...)`, `sys.stderr.write(...)` call is a finding.
54
+ The fix is to route through a `logger` call OR to make the output
55
+ stream an explicit parameter so callers can redirect it.
56
+ M. String-literal magic values. Treat domain-identifier string literals
57
+ (database column names, table names, HTTP header names, status enums,
58
+ environment-variable names) inside a function body as magic values
59
+ even when the existing number-only check would let them pass. The
60
+ fix is to extract them into `config/` and reference the imported
61
+ name. Do not flag plain log messages, error messages, or one-off
62
+ human-readable strings.
63
+ N. Wrapper plumb-through. When a public function delegates to an
64
+ inner function defined in the same package, every optional kwarg
65
+ accepted by the inner function MUST appear in the public wrapper
66
+ unless the wrapper docstring explicitly states the kwarg is fixed
67
+ to a sentinel default. Silently dropping `loud_banner_stream`,
68
+ `timeout`, `dry_run`, or any similar optional kwarg is a finding.
38
69
  </bug_categories>
39
70
 
71
+ <copilot_derived_addendum_source>
72
+ The K–N categories were added after Copilot raised real findings on
73
+ PR #70 (writer.py / summary.py) and PR #73 (constants.py / writer.py /
74
+ tracker.py) that converged "0 P0 / 0 P1 / 0 P2" under the original
75
+ A–J rubric. See ~/.claude/skills/bugteam/reference/copilot-gap-analysis.md
76
+ for the inventory and the validators that now back categories K and L.
77
+ </copilot_derived_addendum_source>
78
+
40
79
  <constraints>
41
80
  - Read-only on source code: the audit does not modify any source file.
42
81
  - Cite file:line for every finding.
@@ -81,7 +81,9 @@ The fix script removes any non-canonical local-scope override on the active repo
81
81
  [ ] Step 0: project permissions granted
82
82
  [ ] Step 1: PR scope resolved
83
83
  [ ] Step 2: agent team created + loop state set
84
+ [ ] Step 2.6: INITIAL standards review against cumulative PR diff
84
85
  [ ] Step 3: cycle complete (converged | cap reached | stuck | error)
86
+ [ ] Step 3.5: FINAL standards review against cumulative PR diff
85
87
  [ ] Step 4: team torn down + working tree clean
86
88
  [ ] Step 4.5: PR description rewritten (or skip warning logged)
87
89
  [ ] Step 5: project permissions revoked
@@ -203,6 +205,23 @@ jq -n \
203
205
 
204
206
  **Endpoints:** `POST .../pulls/{pull}/reviews`; `POST .../pulls/{pull}/comments/{id}/replies`; fallback `POST .../issues/{issue}/comments` (`issue` = PR number).
205
207
 
208
+ ### Step 2.6: INITIAL standards review (once, before Loop 1 audit)
209
+
210
+ Run BEFORE the first pre-audit gate fires. Spawn a fresh `code-quality-agent`
211
+ teammate inside the same team and drive it through the K–N addendum (see
212
+ PROMPTS.md `<copilot_derived_addendum_source>`). The teammate audits the
213
+ cumulative PR diff (`gh pr diff <N>`) instead of a single loop's incremental
214
+ patch; clean-room context is preserved by the same agent-team isolation as
215
+ the per-loop bugfind teammate. Findings are posted using the same Step 2.5
216
+ review-shape with body `## /bugteam INITIAL standards review against PR #<N>
217
+ cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. Findings advance the audit/fix
218
+ cycle exactly as if they had been raised in Loop 1: the lead increments
219
+ `loop_count` to 1, sets `last_action = "audited"` with the merged
220
+ `last_findings`, and Step 3 begins on the FIX branch. When the INITIAL
221
+ review returns zero findings, `loop_count` stays at 0 and Step 3 begins on
222
+ the AUDIT branch as before. Failure on this phase logs the error and
223
+ proceeds to Step 3 unchanged so the legacy A–J cycle still runs.
224
+
206
225
  ### Step 3: The cycle
207
226
 
208
227
  Run the AUDIT-FIX cycle for each PR in all_prs, reusing the same team across PRs. The 10-loop cap applies per PR. Exit reasons (converged, cap reached, stuck, error) are tracked per PR; the final report lists one outcome line per PR.
@@ -285,6 +304,19 @@ Pass finding comment URLs/ids from `loop_comment_index` in XML. Replies: `Fixed
285
304
 
286
305
  [`PROMPTS.md`](PROMPTS.md): fix XML + schema. Verify: `git rev-parse HEAD` advanced; `git fetch origin <branch> && git rev-parse origin/<branch>` matches `HEAD`. Unchanged HEAD → `stuck — bugfix teammate could not address findings`.
287
306
 
307
+ ### Step 3.5: FINAL standards review (once, after convergence)
308
+
309
+ Run AFTER Step 3 exits with `converged`, `cap reached`, or `stuck`, and
310
+ BEFORE Step 4 teardown. Spawn one more fresh `code-quality-agent` teammate;
311
+ audit the cumulative PR diff against the K–N addendum a second time. Post
312
+ the review with body `## /bugteam FINAL standards review against PR #<N>
313
+ cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. When findings remain, the
314
+ exit reason is upgraded to `error: final standards review found <P0>+<P1>+<P2>
315
+ unresolved finding(s)` and the loop log gains an extra `final-review` line.
316
+ A clean FINAL review preserves the existing exit reason. Failure on this
317
+ phase logs the error and continues to Step 4 unchanged so teardown,
318
+ permission revoke, and the final report still run.
319
+
288
320
  ### Step 4: Teardown
289
321
 
290
322
  1. For each live teammate: `SendMessage(to="<name>", message={"type": "shutdown_request", "reason": "bugteam cycle ending"})`. `approve: false` on cleanup → log and continue.
@@ -329,8 +361,10 @@ Final commit: <current_HEAD_sha7>
329
361
  Net change: <total_files> files, +<total_add>/-<total_del>
330
362
 
331
363
  Loop log:
364
+ initial standards review: 1P0 0P1 2P2
332
365
  1 audit: 3P0 2P1 0P2
333
366
  ...
367
+ final standards review: 0P0 0P1 0P2
334
368
  ```
335
369
 
336
370
  `cap reached` → suggest `/findbugs`. `stuck` → which findings. `error` → detail + loop.