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.
- package/hooks/blocking/code_rules_enforcer.py +109 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/windows_rmtree_blocker.py +45 -49
- package/hooks/config/session_env_cleanup_constants.py +3 -1
- package/hooks/config/test_session_env_cleanup_constants.py +6 -1
- package/hooks/hooks.json +12 -0
- package/hooks/observability/instructions_loaded_logger.py +38 -0
- package/hooks/observability/test_instructions_loaded_logger.py +85 -0
- package/hooks/session/session_env_cleanup.py +5 -4
- package/hooks/session/test_session_env_cleanup.py +2 -0
- package/package.json +1 -1
- package/rules/code-standards.md +0 -2
- package/rules/file-global-constants.md +4 -0
- package/rules/shell-invocation-policy.md +144 -0
- package/rules/windows-filesystem-safe.md +1 -3
- package/scripts/Audit-ShellPolicy.ps1 +142 -0
- package/scripts/Migrate-ShellPolicy.ps1 +192 -0
- package/skills/auditing-claude-config/SKILL.md +171 -0
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +34 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +34 -12
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
- package/skills/caveman/SKILL.md +38 -0
- package/skills/pr-converge/SKILL.md +218 -0
- package/skills/rebase/SKILL.md +15 -8
|
@@ -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.
|
package/skills/bugteam/SKILL.md
CHANGED
|
@@ -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.
|