claude-dev-env 1.33.0 → 1.34.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.
package/hooks/hooks.json CHANGED
@@ -224,6 +224,18 @@
224
224
  }
225
225
  ]
226
226
  }
227
+ ],
228
+ "InstructionsLoaded": [
229
+ {
230
+ "matcher": "session_start|nested_traversal|path_glob_match|include|compact",
231
+ "hooks": [
232
+ {
233
+ "type": "command",
234
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/instructions_loaded_logger.py",
235
+ "timeout": 10
236
+ }
237
+ ]
238
+ }
227
239
  ]
228
240
  }
229
241
  }
@@ -0,0 +1,38 @@
1
+ import json
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+
7
+ def main() -> int:
8
+ log_path = Path.home() / ".claude" / "logs" / "instructions_loaded.jsonl"
9
+ payload_fields = (
10
+ "file_path",
11
+ "load_reason",
12
+ "memory_type",
13
+ "trigger_file_path",
14
+ "parent_file_path",
15
+ "globs",
16
+ "session_id",
17
+ )
18
+ try:
19
+ payload = json.load(sys.stdin)
20
+ record = {"timestamp": datetime.now(timezone.utc).isoformat()}
21
+ for each_field_name in payload_fields:
22
+ record[each_field_name] = payload.get(each_field_name)
23
+ except Exception as exception:
24
+ record = {
25
+ "timestamp": datetime.now(timezone.utc).isoformat(),
26
+ "error": str(exception),
27
+ }
28
+ try:
29
+ log_path.parent.mkdir(parents=True, exist_ok=True)
30
+ with log_path.open("a", encoding="utf-8") as log_file:
31
+ log_file.write(json.dumps(record) + "\n")
32
+ except OSError:
33
+ pass
34
+ return 0
35
+
36
+
37
+ if __name__ == "__main__":
38
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ """Tests for instructions_loaded_logger observability hook."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ SCRIPT_PATH = Path(__file__).parent / "instructions_loaded_logger.py"
12
+
13
+
14
+ def _run_hook(payload: dict, fake_home: Path) -> subprocess.CompletedProcess[str]:
15
+ child_environment = os.environ.copy()
16
+ child_environment["HOME"] = str(fake_home)
17
+ child_environment["USERPROFILE"] = str(fake_home)
18
+ return subprocess.run(
19
+ [sys.executable, str(SCRIPT_PATH)],
20
+ input=json.dumps(payload),
21
+ text=True,
22
+ capture_output=True,
23
+ check=False,
24
+ env=child_environment,
25
+ )
26
+
27
+
28
+ def test_should_write_record_with_known_payload_fields_to_jsonl_log() -> None:
29
+ with tempfile.TemporaryDirectory() as fake_home_string:
30
+ fake_home = Path(fake_home_string)
31
+ payload = {
32
+ "file_path": "/tmp/CLAUDE.md",
33
+ "load_reason": "session_start",
34
+ "memory_type": "User",
35
+ "trigger_file_path": "/tmp/trigger",
36
+ "parent_file_path": "/tmp/parent",
37
+ "globs": ["**/*.py"],
38
+ "session_id": "abc-123",
39
+ }
40
+ completed = _run_hook(payload, fake_home)
41
+ assert completed.returncode == 0, completed.stderr
42
+ log_path = fake_home / ".claude" / "logs" / "instructions_loaded.jsonl"
43
+ assert log_path.exists()
44
+ record = json.loads(log_path.read_text(encoding="utf-8").strip())
45
+ assert record["file_path"] == "/tmp/CLAUDE.md"
46
+ assert record["load_reason"] == "session_start"
47
+ assert record["session_id"] == "abc-123"
48
+ assert "timestamp" in record
49
+
50
+
51
+ def test_should_exit_zero_and_record_error_when_stdin_payload_is_invalid_json() -> None:
52
+ with tempfile.TemporaryDirectory() as fake_home_string:
53
+ fake_home = Path(fake_home_string)
54
+ completed = subprocess.run(
55
+ [sys.executable, str(SCRIPT_PATH)],
56
+ input="not json",
57
+ text=True,
58
+ capture_output=True,
59
+ check=False,
60
+ env={**os.environ, "HOME": str(fake_home), "USERPROFILE": str(fake_home)},
61
+ )
62
+ assert completed.returncode == 0, completed.stderr
63
+ log_path = fake_home / ".claude" / "logs" / "instructions_loaded.jsonl"
64
+ assert log_path.exists()
65
+ record = json.loads(log_path.read_text(encoding="utf-8").strip())
66
+ assert "error" in record
67
+ assert "timestamp" in record
68
+
69
+
70
+ def test_should_exit_zero_when_log_directory_creation_fails() -> None:
71
+ with tempfile.TemporaryDirectory() as fake_home_string:
72
+ fake_home = Path(fake_home_string)
73
+ blocking_file = fake_home / ".claude"
74
+ blocking_file.write_text("not a directory", encoding="utf-8")
75
+ payload = {
76
+ "file_path": "/tmp/CLAUDE.md",
77
+ "load_reason": "path_glob_match",
78
+ "memory_type": "User",
79
+ "trigger_file_path": None,
80
+ "parent_file_path": None,
81
+ "globs": None,
82
+ "session_id": "abc-123",
83
+ }
84
+ completed = _run_hook(payload, fake_home)
85
+ assert completed.returncode == 0, completed.stderr
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.33.0",
3
+ "version": "1.34.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,8 +3,6 @@
3
3
  > **MANDATORY REFERENCE:** CODE_RULES.md - Load for ALL code generation.
4
4
  > This is the single source of truth for code standards. Non-negotiable.
5
5
 
6
- @~/.claude/docs/CODE_RULES.md
7
-
8
6
  **Key principles (see CODE_RULES.md for complete reference):**
9
7
  - Self-documenting code (no comments)
10
8
  - Centralized configuration (one source of truth)
@@ -1,3 +1,7 @@
1
+ ---
2
+ paths: **/*.py
3
+ ---
4
+
1
5
  # File-Global Constants
2
6
 
3
7
  This rule extends the `constants-location` rule defined in `~/.claude/docs/CODE_RULES.md` — see the ⚡ HOOK-ENFORCED RULES table, Constants location row.
@@ -0,0 +1,144 @@
1
+ # Shell Invocation Policy (pwsh-only)
2
+
3
+ **When this applies:** Every shell command issued through the `Bash` tool on Windows.
4
+
5
+ ## What to use
6
+
7
+ ### Pattern A — Run a `.ps1` script with named arguments
8
+
9
+ ```
10
+ pwsh -NoProfile -File 'Y:\absolute\path\to\Build-Skyline.ps1' -RunTests -Tag staging
11
+ ```
12
+
13
+ Use this when a `.ps1` script accepts named parameters. The `-File` form exposes the script path as a flat token, so `permissions.allow` rules of the form `Bash(pwsh -NoProfile -File *)` match the invocation directly.
14
+
15
+ ### Pattern B — Run an inline expression
16
+
17
+ ```
18
+ pwsh -NoProfile -Command "Get-Date -Format o"
19
+ ```
20
+
21
+ Use this for one or two lines of work that does not need a script file. Quote the entire `-Command` argument with double quotes; use single quotes inside for embedded strings.
22
+
23
+ ### Pattern C — Multi-line inline script with a here-string
24
+
25
+ ```
26
+ pwsh -NoProfile -Command @'
27
+ $projects = Get-ChildItem -Path 'Y:\Projects\LLM Plugins' -Directory
28
+ $projects | Where-Object Name -Like 'claude*' | Select-Object FullName
29
+ '@
30
+ ```
31
+
32
+ Use this for multi-line logic without a separate `.ps1` file. The `@'...'@` form is literal — variables and backticks inside are not expanded.
33
+
34
+ ### Pattern D — The built-in `PowerShell` tool
35
+
36
+ Use the `PowerShell` tool directly when the entire workflow is PowerShell and does not pipe through external `Bash`-tool-native commands. The built-in tool already runs PowerShell 7+ from `C:\Program Files\PowerShell\7\pwsh.exe`. It supports `run_in_background` for long-running tasks, which `Bash` invocations of `pwsh` do not.
37
+
38
+ ## Migration mapping (replace left with right)
39
+
40
+ | Existing pattern | Replacement |
41
+ |---|---|
42
+ | `powershell -Command "X"` | `pwsh -NoProfile -Command "X"` |
43
+ | `powershell.exe -Command "X"` | `pwsh -NoProfile -Command "X"` |
44
+ | `powershell -File path.ps1` | `pwsh -NoProfile -File 'path.ps1'` |
45
+ | `powershell.exe -File path.ps1` | `pwsh -NoProfile -File 'path.ps1'` |
46
+ | `powershell -Command "& 'path.ps1' -A v"` | `pwsh -NoProfile -File 'path.ps1' -A v` |
47
+ | `bash -c "X"` | `pwsh -NoProfile -Command "X"` |
48
+ | `cmd /c X` | `pwsh -NoProfile -Command "X"` |
49
+ | `cmd.exe /c X` | `pwsh -NoProfile -Command "X"` |
50
+ | `Bash(powershell:*)` (settings.json) | `Bash(pwsh:*)` |
51
+ | `Bash(powershell.exe:*)` (settings.json) | `Bash(pwsh:*)` |
52
+
53
+ ## Common operations in pwsh
54
+
55
+ | Task | pwsh syntax |
56
+ |---|---|
57
+ | List directory names | `Get-ChildItem -Path 'X' -Directory -Name` |
58
+ | Read a whole file | `Get-Content -Path 'X' -Raw` |
59
+ | Write file (UTF-8 no BOM) | `[IO.File]::WriteAllText('X', $content, [Text.UTF8Encoding]::new($false))` |
60
+ | Test a path | `Test-Path 'X'` |
61
+ | Remove a directory | `Remove-Item -Path 'X' -Recurse -Force` |
62
+ | Activate a venv | `& 'Y:\path\.venv\Scripts\Activate.ps1'` |
63
+ | Run venv-Python | `& 'Y:\path\.venv\Scripts\python.exe' script.py` |
64
+ | Set env var (current process) | `$env:NAME = 'value'` |
65
+ | Pipe to ripgrep | `Get-ChildItem | Select-String -Pattern 'X'` |
66
+ | First match in a stream | `Select-Object -First 1` |
67
+
68
+ The `&` call operator is appropriate for invoking an executable at a path — for example, `& '<venv>\Scripts\python.exe' script.py`. The forbidden form is wrapping a script path inside `pwsh -Command "& 'X' -A v"`, where the call operator is inside a `-Command` payload and breaks `permissions.allow` matching. Use `pwsh -File 'X' -A v` instead for that case.
69
+
70
+ ## External binaries usable from pwsh
71
+
72
+ Invoke these directly without wrapping:
73
+
74
+ - `git` — `git status`, `git log --oneline -10`, `git -C 'path' status`
75
+ - `gh` — `gh pr create`, `gh issue list`
76
+ - `python`, `pip` (via venv path: `& '.venv\Scripts\python.exe'`)
77
+ - `node`, `npm`, `npx`
78
+ - `rg` (ripgrep), `fd`, `es.exe` (Everything search)
79
+ - `pytest`, `mypy`, `pyright` (via venv)
80
+
81
+ ## Verification
82
+
83
+ To confirm pwsh is correctly installed and routed:
84
+
85
+ ```
86
+ pwsh -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"
87
+ ```
88
+
89
+ Expected output: `7.x.x.x` or higher. The verified install at the time of writing this rule is `7.5.5.0` at `C:\Program Files\PowerShell\7\pwsh.exe`.
90
+
91
+ ## Permission allowlist (settings.json `permissions.allow`)
92
+
93
+ These entries pre-approve canonical pwsh invocations:
94
+
95
+ ```
96
+ Bash(pwsh -NoProfile -File *)
97
+ Bash(pwsh -File *)
98
+ Bash(pwsh -NoProfile -Command *)
99
+ Bash(pwsh -Command *)
100
+ Bash(pwsh:*)
101
+ PowerShell
102
+ ```
103
+
104
+ The `PowerShell` entry auto-approves the built-in tool.
105
+
106
+ ## Permission denylist (settings.json `permissions.deny`)
107
+
108
+ These entries block legacy shells:
109
+
110
+ ```
111
+ Bash(powershell *)
112
+ Bash(powershell.exe *)
113
+ Bash(powershell:*)
114
+ Bash(powershell.exe:*)
115
+ Bash(bash -c *)
116
+ Bash(bash --login *)
117
+ Bash(bash --rcfile *)
118
+ Bash(bash --init-file *)
119
+ Bash(cmd /c *)
120
+ Bash(cmd.exe /c *)
121
+ ```
122
+
123
+ ## Migration scripts
124
+
125
+ Two scripts ship with this rule, located at `packages/claude-dev-env/scripts/`:
126
+
127
+ - `Audit-ShellPolicy.ps1` — scans `settings*.json` files under the configured project roots and prints one summary line: `POLICY: OK` or `POLICY: VIOLATIONS=<count> IN=<n> FILES`. Exit code 0 when clean, 1 when violations remain. Use this as a check before merging.
128
+ - `Migrate-ShellPolicy.ps1` — applies the migration mapping to `settings*.json` files in place. Defaults to dry-run; pass `-Apply` to write changes. Prints one summary line: `MIGRATED: <count> rules IN=<n> FILES` or `DRY RUN: would migrate <count> rules IN=<n> FILES`.
129
+
130
+ Run order: audit → migrate (dry run) → migrate (apply) → audit.
131
+
132
+ ## Enforcement layers
133
+
134
+ 1. **`permissions.allow`** pre-approves the canonical patterns so Claude never gets a prompt for them.
135
+ 2. **`permissions.deny`** blocks the legacy patterns at the permission layer.
136
+ 3. **`pwsh_enforcer.py`** PreToolUse hook catches edge cases that wildcard syntax misses (compound commands, process wrappers, alternate spellings). Source: `packages/claude-dev-env/hooks/blocking/pwsh_enforcer.py`.
137
+ 4. **Migration scripts** keep existing `settings.local.json` files in compliance.
138
+
139
+ ## Precedent
140
+
141
+ This rule mirrors the convention from [ProteoWizard/pwiz-ai](https://github.com/ProteoWizard/pwiz-ai) `CLAUDE.md`:
142
+
143
+ > "Always use `pwsh` (PowerShell 7), never `powershell` (5.1). The Bash tool uses Git Bash, which has limited Windows tool access. Route commands through PowerShell when needed."
144
+ > "Never use the `&` call operator — it breaks permissions matching. Use `-File` instead, which supports arguments directly."
@@ -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)
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: caveman
3
+ description: "Inline counterpart to the caveman agent — trim noise from `$ARGUMENTS` (or the previous assistant message) and reply with the trimmed text only, no report, no Agent spawn. Triggers: /caveman, caveman this, trim this, make it terse, caveman voice."
4
+ argument-hint: "[text to trim, or omit and the model uses the previous assistant message]"
5
+ ---
6
+
7
+ # Caveman
8
+
9
+ Inline counterpart to the `caveman` agent at `packages/claude-dev-env/agents/caveman.md`. Use this skill when the trim target is conversational text in the current turn rather than a separate file or multi-section artifact. The model performs the trim itself in the next reply — no `Agent` invocation, no structured report, just the trimmed text.
10
+
11
+ ## Instructions
12
+
13
+ 1. **Resolve the source.** If `$ARGUMENTS` is non-empty, that text is the trim source. Otherwise the source is the previous assistant message in the current conversation.
14
+
15
+ 2. **Trim the same noise categories the agent trims:**
16
+
17
+ | Noise type | Example |
18
+ |---|---|
19
+ | Preamble / recap | "As discussed above, this skill will..." |
20
+ | Hedging | "This might, in some cases, potentially..." |
21
+ | Filler transitions | "Now, moving on to..." / "It's worth noting that..." |
22
+ | Restatement | the same point made twice in different words |
23
+ | Empty future-proofing | parameters, sections, or fields with no current consumer |
24
+ | Dead examples | examples that duplicate another example without adding coverage |
25
+ | Pleasantries | "Hope this helps." / "Feel free to..." |
26
+ | Vague qualifiers | "various", "several", "a number of" — replace with the actual count or cut |
27
+
28
+ 3. **Preserve verbatim:** code, commands, paths, URLs, errors, JSON, schemas, frontmatter, counts, version strings, identifiers, safety/destructive-op language, and anything the user explicitly flagged as keep-as-is.
29
+
30
+ 4. **Output only the trimmed text.** No `trimmed / removed / preserved-verbatim / flagged` report. No commentary about what was cut. No preamble like "Here is the trimmed version:". The trimmed text is the entire reply.
31
+
32
+ ## Escape hatch
33
+
34
+ If trimming would drop a safety warning, collapse a deliberate distinction, or you are unsure whether a span is load-bearing, leave that span in place verbatim. Do not flag, narrate, or ask about the preserved span — instruction 4 still applies, so the trimmed text (with the preserved span included) remains the entire reply. Terse is for noise, not for substance.
35
+
36
+ ## When NOT to use
37
+
38
+ Use the `caveman` agent (via `Task` / `Agent` tool with `subagent_type: caveman`) when the input is a file path, a multi-section artifact requiring the structured `trimmed / removed / preserved-verbatim / flagged` report, or any delegated workflow. This skill is for inline conversational text only.
@@ -0,0 +1,220 @@
1
+ ---
2
+ name: pr-converge
3
+ description: >-
4
+ Drives the current PR to convergence by alternating Cursor Bugbot and the
5
+ in-house bugteam audit. Each invocation runs one tick of work in the main
6
+ session: fetches the latest reviewer state, applies TDD fixes for any
7
+ findings, pushes one commit per tick, replies inline, and re-triggers the
8
+ reviewer. To loop automatically, invoke as `/loop /pr-converge` — the /loop
9
+ skill self-paces re-entry via ScheduleWakeup. Convergence requires a
10
+ back-to-back clean cycle (bugbot CLEAN immediately followed by bugteam CLEAN
11
+ with no intervening fixes), at which point the PR is flipped to ready for
12
+ review and the loop terminates. Triggers: '/pr-converge', 'drive PR to
13
+ convergence', 'loop bugbot and bugteam', 'babysit bugbot and bugteam',
14
+ 'until both are clean', 'converge this PR'.
15
+ ---
16
+
17
+ # PR Converge
18
+
19
+ Runs one tick of the bugbot ↔ bugteam convergence loop in the main session. Designed to be invoked under `/loop /pr-converge` so the parent's ScheduleWakeup paces re-entry. Self-terminates the loop on convergence (back-to-back clean) by flipping the PR to ready for review and omitting the next ScheduleWakeup.
20
+
21
+ ## Why the work runs in the main session, not a background subagent
22
+
23
+ `ScheduleWakeup` is a primitive of the parent harness; it is not exposed to `general-purpose` subagents. A prior version of this skill spawned a background subagent and instructed it to call `ScheduleWakeup` at the end of each tick. The subagent's tool registry returned "No matching deferred tools found" for `ScheduleWakeup`, so the loop could never self-perpetuate — it ran exactly one tick and stalled. Running the loop in the main session via `/loop /pr-converge` puts the work on the same harness that owns `ScheduleWakeup`, eliminating that failure mode.
24
+
25
+ ## When this skill applies
26
+
27
+ The user is on a PR branch and wants both reviewers — Cursor's Bugbot AND the in-house `/bugteam` audit — to keep re-reviewing after each push, with findings auto-addressed between ticks. The PR stays in draft until convergence; on convergence the skill flips it to ready for review.
28
+
29
+ ## Invocation modes
30
+
31
+ - **`/loop /pr-converge`** (recommended): loops automatically. The /loop skill runs each tick and uses ScheduleWakeup to pace re-entry. Termination on convergence is automatic; the skill omits the next wakeup at the convergence tick.
32
+ - **`/pr-converge`** (manual): runs exactly one tick and returns. Useful for ad-hoc state checks or for advancing the loop one step manually. The user re-runs the skill (or wraps it in `/loop`) to continue.
33
+
34
+ ## State across ticks
35
+
36
+ Track the following in plain text in the assistant's response so subsequent ticks can re-read it from conversation context:
37
+
38
+ - `phase`: `BUGBOT` or `BUGTEAM`. Start in `BUGBOT` on the first tick of a fresh loop.
39
+ - `bugbot_clean_at`: the HEAD SHA at which bugbot last reported clean, or `null`. Reset to `null` whenever a new commit is pushed.
40
+ - `inline_lag_streak`: integer counter, initialized to `0`. Tracks consecutive ticks where bugbot's review body indicates findings against `current_head` but the inline-comments API returns zero matching comments. Reset to `0` on any other branch outcome.
41
+ - `tick_count`: integer, initialized to `0`. Increment on every tick to enforce the safety cap.
42
+
43
+ Each tick begins by reading the prior tick's state line from the most recent assistant message and ends by emitting the updated state line.
44
+
45
+ ## Per-tick work
46
+
47
+ ### Step 1: Resolve current HEAD and PR context
48
+
49
+ Read the prior tick's state line from the most recent assistant message (or initialize all fields if none). **Increment `tick_count` by 1.** This is the increment referenced in the **State across ticks** section; without it the safety cap (Step 3.5, §Safety cap) never fires.
50
+
51
+ ```bash
52
+ gh pr view --json number,url,headRefOid,baseRefName,headRefName,isDraft
53
+ ```
54
+
55
+ Capture `number` (`<NUMBER>`), `headRefOid` (`current_head`), owner/repo (from `url`), branch name (`<BRANCH>`).
56
+
57
+ ### Step 2: Branch on `phase`
58
+
59
+ #### `phase == BUGBOT`
60
+
61
+ a. Fetch the latest Cursor Bugbot review:
62
+ ```bash
63
+ gh api repos/<OWNER>/<REPO>/pulls/<NUMBER>/reviews \
64
+ --jq '[.[] | select(.user.login=="cursor[bot]")] | sort_by(.submitted_at) | last'
65
+ ```
66
+ Capture `commit_id`, `state`, `submitted_at`, and the body. Bugbot's body contains either `Cursor Bugbot has reviewed your changes and found <N> potential issue` (findings exist) or text indicating no issues found.
67
+
68
+ b. Fetch unaddressed inline comments from `cursor[bot]` on `current_head`:
69
+ ```bash
70
+ gh api repos/<OWNER>/<REPO>/pulls/<NUMBER>/comments \
71
+ --jq "[.[] | select(.user.login==\"cursor[bot]\") | select(.commit_id==\"$current_head\")]"
72
+ ```
73
+
74
+ c. Decide (the four branches below cover every input combination — match the first branch whose predicate holds):
75
+ - **No bugbot review yet, OR latest bugbot review's `commit_id` differs from `current_head`:** Re-trigger bugbot (Step 3), set `bugbot_clean_at = null`, reset `inline_lag_streak = 0`, schedule next wakeup, return.
76
+ - **Latest review's `commit_id == current_head` AND zero unaddressed inline findings AND review body indicates clean:** Set `bugbot_clean_at = current_head`. Reset `inline_lag_streak = 0`. Transition `phase = BUGTEAM`. Continue to bugteam branch in this same tick — back-to-back convergence requires bugteam to run against the same HEAD before the next wakeup is scheduled.
77
+ - **Latest review's `commit_id == current_head` with unaddressed inline findings (review body indicates findings):** Apply the **Fix protocol** below to address them. Reset `inline_lag_streak = 0`. The fix protocol pushes a new commit, which sets `current_head` to the new SHA, sets `bugbot_clean_at = null`, replies inline on each thread, and re-triggers bugbot. Schedule next wakeup, return.
78
+ - **Latest review's `commit_id == current_head` AND review body indicates findings AND inline-comments API returns zero matching comments for `current_head`:** Treat as transient API propagation lag — bugbot publishes the review body and inline comments through separate API operations and the two writes can briefly desync. Increment `inline_lag_streak`. When `inline_lag_streak >= 3`, escalate as a hard blocker (bugbot review is structurally inconsistent — body claims findings while inline anchors stay empty across three consecutive ticks); report and terminate. Otherwise schedule next wakeup at `delaySeconds: 60` (lag is short-lived) and return; the inline comments should appear on the next tick.
79
+
80
+ #### `phase == BUGTEAM`
81
+
82
+ a. Run the in-house bugteam audit on the current PR by invoking the `Skill` tool in the main session:
83
+
84
+ ```
85
+ Skill({skill: "bugteam", args: "https://github.com/<OWNER>/<REPO>/pull/<NUMBER>"})
86
+ ```
87
+
88
+ The main session is the team lead, so `TeamCreate` fires from the orchestrator and `/bugteam` emits its CODE_RULES gate output, teammate spawn lines, and audit progress as expected. The skill audits the current PR against CODE_RULES, posts review threads, and converges or stops at its own internal cap. Wait for it to complete; capture exit and final summary.
89
+
90
+ b. **Re-resolve current HEAD now** because `/bugteam` may have pushed commits during its run. The `current_head` from Step 1 is potentially stale at this point:
91
+ ```bash
92
+ new_head=$(gh api repos/<OWNER>/<REPO>/pulls/<NUMBER> --jq '.head.sha')
93
+ ```
94
+ If `new_head != current_head`, set `current_head = new_head` AND set `bugbot_clean_at = null`. The new commits from bugteam invalidate bugbot's prior clean.
95
+
96
+ c. Inspect bugteam's output. Bugteam reports either `convergence (zero findings)` or a list of unfixed findings with file:line.
97
+
98
+ d. Decide based on the (post-bugteam) state — order matters; check pushed-during-bugteam FIRST so a convergence report against a stale HEAD never falsely terminates:
99
+ - **bugteam pushed during this tick (i.e., `bugbot_clean_at` was just reset to `null` in step b):** Re-trigger bugbot in this same tick (Step 3) so the new HEAD enters bugbot's queue immediately, transition `phase = BUGBOT`, schedule next wakeup, return. The new commit needs a fresh bugbot review before convergence can be claimed.
100
+ - **bugteam reports convergence AND `bugbot_clean_at == current_head` (no push during this tick):** This is back-to-back clean. Mark the PR ready for review:
101
+ ```bash
102
+ gh pr ready <NUMBER> --repo <OWNER>/<REPO>
103
+ ```
104
+ Report to the user in one sentence: "PR #<NUMBER> converged: bugbot CLEAN at <SHA>, bugteam CLEAN at <SHA>; marked ready for review." **Omit the next ScheduleWakeup call** — this terminates the /loop.
105
+ - **bugteam reports convergence BUT `bugbot_clean_at != current_head` (no push during this tick):** Bugteam reached zero findings without committing, yet bugbot still needs re-confirmation against this HEAD. This branch is reachable only when state diverged BETWEEN ticks — for example, the user pushed a manual commit between two wakeups, leaving `current_head` ahead of the SHA bugbot last cleaned. Transition `phase = BUGBOT`, schedule next wakeup, return.
106
+ - **bugteam reports findings without committing fixes:** apply the **Fix protocol** below (which always re-triggers bugbot after the push), transition `phase = BUGBOT`, schedule next wakeup, return.
107
+
108
+ ### Step 3: Re-trigger bugbot
109
+
110
+ Used in Step 2 BUGBOT branch 1, in Step 2 BUGTEAM branch 1, and in the Fix protocol. Post a literal `bugbot run` PR comment. Write the body via the Write tool to a temp file, then pass it with `--body-file` (per the gh-body-file rule):
111
+
112
+ ```bash
113
+ gh pr comment <NUMBER> --repo <OWNER>/<REPO> --body-file <path/to/bugbot_run.md>
114
+ ```
115
+
116
+ The body file contains exactly the literal phrase `bugbot run` followed by a newline. Use that phrase exactly — empirically the only re-trigger Cursor Bugbot recognizes; alternative phrasings (`re-review`, `bugbot please`, etc.) silently no-op.
117
+
118
+ ### Step 3.5: Enforce the safety cap
119
+
120
+ Before scheduling the next wakeup, evaluate `tick_count`. When `tick_count >= 30`, stop and report per the **Stop conditions** safety-cap branch (§Safety cap) — **omit Step 4 entirely**. Reaching this many rounds means something structural is wrong with the loop and continuing wastes work. Otherwise proceed to Step 4.
121
+
122
+ ### Step 4: Schedule the next wakeup (only when invoked under `/loop`)
123
+
124
+ **Skip this step entirely when the skill was invoked as bare `/pr-converge`** (manual mode). Manual mode runs exactly one tick and returns without scheduling — the user re-runs the skill or wraps it in `/loop` to continue. References elsewhere in this document to "schedule next wakeup, return" mean Step 4 below; under manual mode every such reference becomes "return" only.
125
+
126
+ Detect manual mode by inspecting the conversation context: when the most recent user message that triggered this run was `/pr-converge` (no `/loop` prefix and no prior `ScheduleWakeup` chain entry that fired with `prompt: "/loop /pr-converge"`), this is manual mode. When the run was triggered by the parent's /loop wakeup chain or the user typed `/loop /pr-converge`, this is loop mode.
127
+
128
+ In **loop mode**, call `ScheduleWakeup` with:
129
+
130
+ - `delaySeconds: 270` whenever bugbot was just re-triggered (whether by Step 3 directly, by the Fix protocol's mandatory re-trigger, or by BUGTEAM branch 1's same-tick re-trigger). Bugbot finishes a review in 1–4 minutes, so 270s stays under the 5-minute prompt-cache TTL while giving a margin past bugbot's typical upper bound. The single exception is the BUGBOT inline-lag branch, which uses `delaySeconds: 60` because no re-trigger fired and the only thing being awaited is GitHub's inline-comments API catching up.
131
+ - `reason`: one short sentence on what is being awaited, including the current `phase` and `bugbot_clean_at` SHA when set.
132
+ - `prompt: "/loop /pr-converge"` — re-enters this skill via /loop on the next firing.
133
+
134
+ **On convergence (loop mode):** omit the ScheduleWakeup call entirely. The /loop terminates because no next wakeup was scheduled.
135
+
136
+ ## Fix protocol
137
+
138
+ Used by both phases when findings exist:
139
+
140
+ - Read each referenced file:line.
141
+ - Write a failing test first when the finding has behavior to test. For pure doc, comment, or naming nits with no behavior, go straight to the fix.
142
+ - Implement the fix.
143
+ - Stage the affected files and create one new commit on the existing branch:
144
+ ```bash
145
+ git add <files> && git commit -m "fix(review): <brief summary>"
146
+ ```
147
+ Honor pre-commit and pre-push hooks; when a hook rejects, read its message, fix the underlying issue, retry. Hook rejections flag real underlying issues worth investigating.
148
+ - Push the new commit:
149
+ ```bash
150
+ git push origin <BRANCH>
151
+ ```
152
+ Capture the new HEAD SHA. Set `current_head` to it. Set `bugbot_clean_at = null`.
153
+ - Reply inline on each addressed comment thread using `--body-file` (per gh-body-file rule):
154
+ ```bash
155
+ gh api -X POST repos/<OWNER>/<REPO>/pulls/<NUMBER>/comments/<comment_id>/replies \
156
+ --field body=@<path/to/reply.md>
157
+ ```
158
+ - **Always re-trigger bugbot (Step 3 above) after pushing a fix**, regardless of which phase originated the findings. Any new commit invalidates bugbot's prior clean by definition, so bugbot must re-review the new HEAD before convergence can be claimed. Re-triggering in the same tick saves a full wakeup cycle compared to deferring the trigger to the next tick.
159
+
160
+ ## Stop conditions
161
+
162
+ - **Convergence** (back-to-back clean as defined in Step 2 BUGTEAM second branch — `bugteam reports convergence AND bugbot_clean_at == current_head` with no push during this tick): mark PR ready for review, report one-sentence summary, omit ScheduleWakeup.
163
+ - **Hard blocker:** API auth failure persists across two ticks, a CI regression whose root cause falls outside this PR, a hook rejection investigated through three commits and still unresolved, `inline_lag_streak >= 3`, or `/bugteam` itself reports a stuck state. Report the specific blocker and the diagnosis, then omit ScheduleWakeup.
164
+ - **User stops the loop:** user says "stop the converge loop" → omit ScheduleWakeup on the next tick.
165
+ - **Safety cap:** `tick_count >= 30` (evaluated in Step 3.5) → omit ScheduleWakeup, report the cap was hit. See §Safety cap below for rationale.
166
+
167
+ ## Safety cap
168
+
169
+ When `tick_count >= 30`, stop and report. That many rounds means something structural is wrong with the loop. (Higher than copilot-review's 20-tick cap because two reviewers run sequentially per round.) The increment lives in Step 1; the evaluation lives in Step 3.5.
170
+
171
+ ## Ground rules
172
+
173
+ - **Append commits.** Each tick adds at most one new fix commit. Multiple findings within one tick collapse into a single commit; the next tick handles the next round.
174
+ - **`bugbot_clean_at` resets on every push.** A new commit invalidates bugbot's prior clean by definition — bugbot must re-review the new HEAD before convergence can be claimed.
175
+ - **Back-to-back clean is the ONLY termination criterion.** Convergence requires both reviewers clean against the same HEAD with no intervening fixes; either reviewer clean alone counts as in-progress.
176
+ - **The `bugbot run` comment is load-bearing.** Use the literal phrase `bugbot run` exactly — empirically the only re-trigger Cursor Bugbot recognizes; alternative phrasings silently no-op.
177
+ - **`gh pr ready` is the convergence action.** Mark the PR ready for review and stop there. Merge, additional reviewers, title, and body remain the user's decisions; the skill's contract ends at "ready for review."
178
+ - **Honor pre-push and pre-commit hooks.** When a hook rejects the change, read its output, fix the underlying issue (the failing test, the missing constant, the broken import), and retry.
179
+
180
+ ## Examples
181
+
182
+ <example>
183
+ User: `/loop /pr-converge`
184
+ Claude: [reads PR context, runs one tick of bugbot phase, schedules next wakeup at 270s with prompt `/loop /pr-converge`, returns]
185
+ </example>
186
+
187
+ <example>
188
+ User: `/pr-converge`
189
+ Claude: [runs one tick manually, reports state, does NOT schedule a wakeup; user re-runs to advance]
190
+ </example>
191
+
192
+ <example>
193
+ Tick fires in BUGBOT phase, latest bugbot review is against an older commit.
194
+ Claude: [posts `bugbot run` comment, sets `bugbot_clean_at = null`, schedules next wakeup at 270s, returns]
195
+ </example>
196
+
197
+ <example>
198
+ Tick fires in BUGBOT phase, bugbot has 2 unaddressed findings on HEAD.
199
+ Claude: [TDD-fixes both, one commit, pushes, replies inline on both threads, posts `bugbot run`, schedules next wakeup at 270s, returns]
200
+ </example>
201
+
202
+ <example>
203
+ Tick fires in BUGBOT phase, bugbot is clean against HEAD.
204
+ Claude: [sets `bugbot_clean_at = HEAD`, transitions `phase = BUGTEAM`, runs `/bugteam` in the same tick]
205
+ </example>
206
+
207
+ <example>
208
+ In BUGTEAM phase, /bugteam reports convergence and `bugbot_clean_at == current_head`.
209
+ Claude: [runs `gh pr ready <NUMBER>`, reports "PR converged: bugbot CLEAN at <SHA>, bugteam CLEAN at <SHA>; marked ready for review", omits ScheduleWakeup, terminates the /loop]
210
+ </example>
211
+
212
+ <example>
213
+ In BUGTEAM phase, /bugteam pushed a fix commit during its run.
214
+ Claude: [re-resolves HEAD, sets `bugbot_clean_at = null`, posts `bugbot run` in this same tick, transitions `phase = BUGBOT`, schedules next wakeup at 270s]
215
+ </example>
216
+
217
+ <example>
218
+ Tick fires in BUGBOT phase, bugbot review body says "found 3 potential issues" against HEAD but the inline-comments API returns zero matching comments for `current_head`.
219
+ Claude: [increments `inline_lag_streak` to 1, schedules next wakeup at 60s, returns; expects inline comments to appear by the next tick]
220
+ </example>