claude-dev-env 2.1.0 → 2.2.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/check.ps1 +32 -6
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/autoconverge/CLAUDE.md +5 -3
- package/skills/autoconverge/SKILL.md +185 -74
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/copilot-finding-triage/SKILL.md +16 -4
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +236 -44
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -8,12 +8,19 @@ when that content violates a validator, rather than grading the whole branch.
|
|
|
8
8
|
import json
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
|
+
from collections import Counter
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from unittest.mock import patch
|
|
13
14
|
|
|
14
15
|
import pytest
|
|
15
16
|
|
|
16
|
-
from .run_all_validators import
|
|
17
|
+
from .run_all_validators import (
|
|
18
|
+
ValidatorResult,
|
|
19
|
+
_scope_new_and_preexisting,
|
|
20
|
+
_violation_line_number,
|
|
21
|
+
main,
|
|
22
|
+
run_validators_entrypoint_subprocess,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
CLEAN_PYTHON_SOURCE = (
|
|
19
26
|
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
@@ -26,7 +33,9 @@ VIOLATING_PYTHON_SOURCE = (
|
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
|
|
29
|
-
return run_validators_entrypoint_subprocess(
|
|
36
|
+
return run_validators_entrypoint_subprocess(
|
|
37
|
+
["--pre-tool-use"], stdin_text=json.dumps(payload)
|
|
38
|
+
)
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
class TestPreToolUseGate:
|
|
@@ -73,7 +82,9 @@ class TestPreToolUseGate:
|
|
|
73
82
|
assert completed.returncode == 0, completed.stderr
|
|
74
83
|
assert "deny" not in completed.stdout
|
|
75
84
|
|
|
76
|
-
def test_edit_validates_reconstructed_post_edit_content(
|
|
85
|
+
def test_edit_validates_reconstructed_post_edit_content(
|
|
86
|
+
self, tmp_path: Path
|
|
87
|
+
) -> None:
|
|
77
88
|
target_file = tmp_path / "calculate.py"
|
|
78
89
|
target_file.write_text(CLEAN_PYTHON_SOURCE, encoding="utf-8")
|
|
79
90
|
completed = run_gate(
|
|
@@ -116,3 +127,212 @@ class TestCliModeRegression:
|
|
|
116
127
|
captured = capsys.readouterr()
|
|
117
128
|
assert exit_code == 1
|
|
118
129
|
assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _over_long_function_source(function_name: str) -> str:
|
|
133
|
+
"""Return a syntactically clean function whose only defect is exceeding 30 lines."""
|
|
134
|
+
body_lines = "".join(" running_total = running_total + 1\n" for _ in range(35))
|
|
135
|
+
return (
|
|
136
|
+
f"def {function_name}(running_total: int) -> int:\n"
|
|
137
|
+
f"{body_lines}"
|
|
138
|
+
" return running_total\n"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
CLEAN_MARKER_FUNCTION = "def clean_marker() -> int:\n return 1\n"
|
|
143
|
+
CLEAN_MARKER_EDITED = (
|
|
144
|
+
"def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
|
|
145
|
+
)
|
|
146
|
+
MAGIC_VALUE_MARKER_FUNCTION = "def clean_marker() -> int:\n return 199\n"
|
|
147
|
+
PRE_EXISTING_VIOLATION_SOURCE = (
|
|
148
|
+
_over_long_function_source("over_long_function") + "\n" + CLEAN_MARKER_FUNCTION
|
|
149
|
+
)
|
|
150
|
+
MAGIC_VALUE_FUNCTION_SOURCE = "def compute_total() -> int:\n return 199\n"
|
|
151
|
+
RUFF_DIRTY_FUNCTION_SOURCE = (
|
|
152
|
+
"def compute_total() -> int:\n unused_intermediate = 1\n return 1\n"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestBaselineScopedGate:
|
|
157
|
+
def test_clean_edit_to_violating_file_is_allowed_with_warning(
|
|
158
|
+
self, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
target_file = tmp_path / "legacy_module.py"
|
|
161
|
+
target_file.write_text(PRE_EXISTING_VIOLATION_SOURCE, encoding="utf-8")
|
|
162
|
+
completed = run_gate(
|
|
163
|
+
{
|
|
164
|
+
"tool_name": "Edit",
|
|
165
|
+
"tool_input": {
|
|
166
|
+
"file_path": str(target_file),
|
|
167
|
+
"old_string": CLEAN_MARKER_FUNCTION,
|
|
168
|
+
"new_string": CLEAN_MARKER_EDITED,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
assert completed.returncode == 0, completed.stderr
|
|
173
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
174
|
+
assert "Code Quality" in completed.stderr
|
|
175
|
+
|
|
176
|
+
def test_edit_introducing_new_violation_denies_naming_only_the_new_one(
|
|
177
|
+
self, tmp_path: Path
|
|
178
|
+
) -> None:
|
|
179
|
+
target_file = tmp_path / "legacy_module.py"
|
|
180
|
+
target_file.write_text(PRE_EXISTING_VIOLATION_SOURCE, encoding="utf-8")
|
|
181
|
+
completed = run_gate(
|
|
182
|
+
{
|
|
183
|
+
"tool_name": "Edit",
|
|
184
|
+
"tool_input": {
|
|
185
|
+
"file_path": str(target_file),
|
|
186
|
+
"old_string": CLEAN_MARKER_FUNCTION,
|
|
187
|
+
"new_string": MAGIC_VALUE_MARKER_FUNCTION,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
assert completed.returncode == 0, completed.stderr
|
|
192
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
193
|
+
assert "Magic Values" in completed.stdout
|
|
194
|
+
assert "Code Quality" not in completed.stdout
|
|
195
|
+
|
|
196
|
+
def test_new_violation_in_clean_file_denies(self, tmp_path: Path) -> None:
|
|
197
|
+
target_file = tmp_path / "clean_module.py"
|
|
198
|
+
target_file.write_text(CLEAN_MARKER_FUNCTION, encoding="utf-8")
|
|
199
|
+
completed = run_gate(
|
|
200
|
+
{
|
|
201
|
+
"tool_name": "Edit",
|
|
202
|
+
"tool_input": {
|
|
203
|
+
"file_path": str(target_file),
|
|
204
|
+
"old_string": " return 1\n",
|
|
205
|
+
"new_string": " return 199\n",
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
assert completed.returncode == 0, completed.stderr
|
|
210
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
211
|
+
assert "Magic Values" in completed.stdout
|
|
212
|
+
|
|
213
|
+
def test_clean_edit_to_clean_file_is_allowed(self, tmp_path: Path) -> None:
|
|
214
|
+
target_file = tmp_path / "clean_module.py"
|
|
215
|
+
target_file.write_text(CLEAN_MARKER_FUNCTION, encoding="utf-8")
|
|
216
|
+
completed = run_gate(
|
|
217
|
+
{
|
|
218
|
+
"tool_name": "Edit",
|
|
219
|
+
"tool_input": {
|
|
220
|
+
"file_path": str(target_file),
|
|
221
|
+
"old_string": " return 1\n",
|
|
222
|
+
"new_string": " computed_value = 1\n return computed_value\n",
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
assert completed.returncode == 0, completed.stderr
|
|
227
|
+
assert "deny" not in completed.stdout
|
|
228
|
+
|
|
229
|
+
def test_growing_a_function_with_existing_length_violation_is_allowed_with_warning(
|
|
230
|
+
self, tmp_path: Path
|
|
231
|
+
) -> None:
|
|
232
|
+
target_file = tmp_path / "legacy_module.py"
|
|
233
|
+
target_file.write_text(
|
|
234
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
235
|
+
)
|
|
236
|
+
completed = run_gate(
|
|
237
|
+
{
|
|
238
|
+
"tool_name": "Edit",
|
|
239
|
+
"tool_input": {
|
|
240
|
+
"file_path": str(target_file),
|
|
241
|
+
"old_string": " return running_total\n",
|
|
242
|
+
"new_string": (
|
|
243
|
+
" running_total = running_total + 1\n"
|
|
244
|
+
" running_total = running_total + 1\n"
|
|
245
|
+
" return running_total\n"
|
|
246
|
+
),
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
assert completed.returncode == 0, completed.stderr
|
|
251
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
252
|
+
assert "Code Quality" in completed.stderr
|
|
253
|
+
|
|
254
|
+
def test_new_same_validator_violation_in_dirty_function_denies(
|
|
255
|
+
self, tmp_path: Path
|
|
256
|
+
) -> None:
|
|
257
|
+
target_file = tmp_path / "legacy_module.py"
|
|
258
|
+
target_file.write_text(MAGIC_VALUE_FUNCTION_SOURCE, encoding="utf-8")
|
|
259
|
+
completed = run_gate(
|
|
260
|
+
{
|
|
261
|
+
"tool_name": "Edit",
|
|
262
|
+
"tool_input": {
|
|
263
|
+
"file_path": str(target_file),
|
|
264
|
+
"old_string": " return 199\n",
|
|
265
|
+
"new_string": " threshold = 42\n return 199 + threshold\n",
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
assert completed.returncode == 0, completed.stderr
|
|
270
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
271
|
+
assert "Magic Values" in completed.stdout
|
|
272
|
+
|
|
273
|
+
def test_new_module_scope_ruff_violation_with_dirty_function_denies(
|
|
274
|
+
self, tmp_path: Path
|
|
275
|
+
) -> None:
|
|
276
|
+
target_file = tmp_path / "legacy_module.py"
|
|
277
|
+
target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
|
|
278
|
+
completed = run_gate(
|
|
279
|
+
{
|
|
280
|
+
"tool_name": "Edit",
|
|
281
|
+
"tool_input": {
|
|
282
|
+
"file_path": str(target_file),
|
|
283
|
+
"old_string": "def compute_total",
|
|
284
|
+
"new_string": "import os\n\n\ndef compute_total",
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
assert completed.returncode == 0, completed.stderr
|
|
289
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
290
|
+
assert "F401" in completed.stdout
|
|
291
|
+
assert "F841" not in completed.stdout
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TestScopeNewAndPreexisting:
|
|
295
|
+
def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
|
|
296
|
+
summary_only_result = ValidatorResult(
|
|
297
|
+
name="Ruff", checks="37", passed=False, output="Found 1 error."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
new_results, preexisting_results = _scope_new_and_preexisting(
|
|
301
|
+
[summary_only_result], "import os\n", Counter()
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
assert [each_result.name for each_result in new_results] == ["Ruff"]
|
|
305
|
+
assert preexisting_results == []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class TestViolationLineNumber:
|
|
309
|
+
def test_ruff_line_col_prefix_returns_line_not_column(self) -> None:
|
|
310
|
+
ruff_shaped_line = "/pkg/legacy_module.py:37:5: F401 `os` imported but unused"
|
|
311
|
+
|
|
312
|
+
assert _violation_line_number(ruff_shaped_line) == 37
|
|
313
|
+
|
|
314
|
+
def test_windows_drive_prefix_returns_line(self) -> None:
|
|
315
|
+
windows_shaped_line = r"C:\repo\tmp\legacy_module.py:37:5: F401 unused import"
|
|
316
|
+
|
|
317
|
+
assert _violation_line_number(windows_shaped_line) == 37
|
|
318
|
+
|
|
319
|
+
def test_check_module_line_prefix_returns_line(self) -> None:
|
|
320
|
+
check_module_line = "/pkg/legacy_module.py:37: magic number 199"
|
|
321
|
+
|
|
322
|
+
assert _violation_line_number(check_module_line) == 37
|
|
323
|
+
|
|
324
|
+
def test_summary_line_without_location_returns_zero(self) -> None:
|
|
325
|
+
assert _violation_line_number("Found 3 errors.") == 0
|
|
326
|
+
|
|
327
|
+
def test_code_frame_line_quoting_colon_digits_returns_zero(self) -> None:
|
|
328
|
+
frame_line = '4 | message = "err:37: bad"'
|
|
329
|
+
|
|
330
|
+
assert _violation_line_number(frame_line) == 0
|
|
331
|
+
|
|
332
|
+
def test_code_frame_source_line_returns_zero(self) -> None:
|
|
333
|
+
assert _violation_line_number("17 | import os") == 0
|
|
334
|
+
|
|
335
|
+
def test_path_with_spaces_returns_line(self) -> None:
|
|
336
|
+
spaced_path_line = "/tmp/my dir/legacy module.py:37: magic number 199"
|
|
337
|
+
|
|
338
|
+
assert _violation_line_number(spaced_path_line) == 37
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -16,6 +16,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. A rule withou
|
|
|
16
16
|
| `ask-user-question-required.md` | Every user-directed question goes through the `AskUserQuestion` tool — no plain-text questions |
|
|
17
17
|
| `bdd.md` | BDD discovery-driven development workflow and Example Mapping reference |
|
|
18
18
|
| `claude-md-orphan-file.md` | Every backticked bare filename in a per-directory `CLAUDE.md` table's first column names a file in that directory's subtree |
|
|
19
|
+
| `cleanup-command-forms.md` | Never use bash `rm` to clean up; use the PowerShell `Remove-Item` and `git worktree remove --force` forms the `destructive_command_blocker` hook never prompts on, and carry the rule into every subagent prompt |
|
|
19
20
|
| `cleanup-temp-files.md` | Remove temporary files created during a task when the task is complete |
|
|
20
21
|
| `code-reviews.md` | Mandatory protocol for responding to GitHub PR review feedback |
|
|
21
22
|
| `code-standards.md` | Pointer to `CODE_RULES.md` as the single source of truth |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Cleanup Command Forms
|
|
2
|
+
|
|
3
|
+
Never use bash `rm` in any form to clean up. The `destructive_command_blocker` hook watches every Bash-tool command and matches `rm -rf` (and the rest of the destructive patterns) as raw text. It allows an `rm` without a prompt only for a narrow set of shapes it can prove safe: an `rm` whose every target is an absolute path under the OS temp root, `/tmp`, `/temp`, or a worktrees directory — standalone, or in a chain whose other segments are plain reporting commands such as `echo` or `cat`; an `rm` run from an ephemeral working directory; and an `rm` whose every target sits inside `~/.claude`. It falls through to a permission prompt on anything outside that set: a `$`, `$(...)`, or backtick expansion whose value it cannot resolve, a target it cannot place in a safe directory, a glob basename, or a string-executing wrapper (`bash -c 'rm -rf …'`). In a background or auto-mode run no human can answer that prompt, so the call stalls.
|
|
4
|
+
|
|
5
|
+
Remove files with these forms, which the hook never prompts on:
|
|
6
|
+
|
|
7
|
+
- **Scratch and probe files:** the PowerShell tool — `Remove-Item -Recurse -Force -Confirm:$false <absolute path>`. The hook watches only the Bash tool, so a PowerShell removal never reaches it. A file left in the OS temp dir or `$CLAUDE_JOB_DIR/tmp` is ephemeral and needs no explicit removal.
|
|
8
|
+
- **Worktrees:** `git worktree remove --force <path>`. This matches no destructive pattern.
|
|
9
|
+
- **When bash `rm` is unavoidable:** one standalone `rm` command, an absolute literal path under the OS temp root or a worktrees directory, no chaining, no variables, no globs. The hook auto-allows this shape without a prompt.
|
|
10
|
+
|
|
11
|
+
## Every subagent prompt carries the rule
|
|
12
|
+
|
|
13
|
+
A prompt-delivered directive reaches only the agent that gets it. An agent that spawns its own workers — review lenses, fix agents, verifiers — copies this line into every subagent prompt it issues, so a grandchild cleaning up its own probe file uses an allowed form:
|
|
14
|
+
|
|
15
|
+
> Never use bash rm in any form. Delete scratch/probe files with the PowerShell tool (Remove-Item -Recurse -Force -Confirm:$false <absolute path>), or leave them in the OS temp dir; remove worktrees only via git worktree remove --force.
|
|
16
|
+
|
|
17
|
+
Prefer that a child leaves its scratch files in place for the parent to remove at teardown with `Remove-Item`.
|
|
18
|
+
|
|
19
|
+
## Sibling rules
|
|
20
|
+
|
|
21
|
+
- [`no-inline-destructive-literals`](no-inline-destructive-literals.md) — keep a destructive literal out of the Bash command string even when it rides only as data.
|
|
22
|
+
- [`cleanup-temp-files`](cleanup-temp-files.md) — remove the scratch files a task created once the task is done.
|
|
23
|
+
- [`windows-filesystem-safe`](windows-filesystem-safe.md) — the safe `rmtree` / `force_rmtree` patterns for read-only Windows files.
|
package/scripts/check.ps1
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env pwsh
|
|
2
2
|
<#
|
|
3
3
|
.SYNOPSIS
|
|
4
|
-
One-shot quality gate
|
|
4
|
+
One-shot quality gate — runs ruff, hooks mypy, pr-loop mypy, and the
|
|
5
5
|
blocking pytest suite from a single entry point.
|
|
6
6
|
|
|
7
7
|
.DESCRIPTION
|
|
8
8
|
Resolves paths relative to $PSScriptRoot so the script works from any CWD
|
|
9
9
|
and from both the worktree (packages/claude-dev-env/scripts/check.ps1)
|
|
10
10
|
and the installed runtime (~/.claude/scripts/check.ps1, after install.mjs
|
|
11
|
-
propagates this file).
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
propagates this file). Tools: ruff over hooks/, mypy over hooks blocking
|
|
12
|
+
and validators, mypy-pr-loop over _shared/pr-loop/scripts production
|
|
13
|
+
modules, and optional pytest over the blocking enforcer suite. Each tool
|
|
14
|
+
runs sequentially; the first non-zero exit code is preserved as the
|
|
15
|
+
script's exit code so CI/pre-commit can short-circuit on the first failure.
|
|
14
16
|
|
|
15
17
|
.PARAMETER SkipTests
|
|
16
18
|
Skip the pytest run. Useful during local iteration when you want only the
|
|
17
19
|
static-analysis gates.
|
|
18
20
|
|
|
19
21
|
.PARAMETER SkipMypy
|
|
20
|
-
Skip
|
|
22
|
+
Skip both mypy runs (hooks mypy and mypy-pr-loop).
|
|
21
23
|
|
|
22
24
|
.PARAMETER SkipRuff
|
|
23
25
|
Skip the ruff run.
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
.OUTPUTS
|
|
26
28
|
Per-tool status lines on stdout. Final summary line:
|
|
27
29
|
CHECK: OK
|
|
28
|
-
CHECK: FAILED tools=ruff,mypy,pytest
|
|
30
|
+
CHECK: FAILED tools=ruff,mypy,mypy-pr-loop,pytest
|
|
29
31
|
#>
|
|
30
32
|
[CmdletBinding()]
|
|
31
33
|
param(
|
|
@@ -38,6 +40,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
38
40
|
|
|
39
41
|
$hooksRoot = Resolve-Path (Join-Path $PSScriptRoot '..' 'hooks')
|
|
40
42
|
$blockingRoot = Join-Path $hooksRoot 'blocking'
|
|
43
|
+
$prLoopScriptsRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '_shared' 'pr-loop' 'scripts')
|
|
41
44
|
|
|
42
45
|
$failedTools = @()
|
|
43
46
|
$firstNonZeroExitCode = 0
|
|
@@ -82,6 +85,29 @@ if (-not $SkipMypy) {
|
|
|
82
85
|
Pop-Location
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
|
|
89
|
+
Invoke-Tool -Label 'mypy-pr-loop' -Action {
|
|
90
|
+
Push-Location $prLoopScriptsRoot
|
|
91
|
+
try {
|
|
92
|
+
mypy --config-file (Join-Path $prLoopScriptsRoot 'pyproject.toml') `
|
|
93
|
+
_claude_permissions_common.py `
|
|
94
|
+
code_rules_gate.py `
|
|
95
|
+
copilot_quota.py `
|
|
96
|
+
fix_hookspath.py `
|
|
97
|
+
grant_project_claude_permissions.py `
|
|
98
|
+
post_audit_thread.py `
|
|
99
|
+
preflight_self_heal.py `
|
|
100
|
+
preflight.py `
|
|
101
|
+
reviewer_availability.py `
|
|
102
|
+
reviews_disabled.py `
|
|
103
|
+
revoke_project_claude_permissions.py `
|
|
104
|
+
terminology_sweep.py `
|
|
105
|
+
code_rules_gate_parts `
|
|
106
|
+
pr_loop_shared_constants
|
|
107
|
+
} finally {
|
|
108
|
+
Pop-Location
|
|
109
|
+
}
|
|
110
|
+
}
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
if (-not $SkipTests) {
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
# pr-loop
|
|
2
2
|
|
|
3
|
-
Shared infrastructure for the PR audit-fix loop used by `bugteam
|
|
3
|
+
Shared infrastructure for the PR audit-fix loop used by `bugteam`,
|
|
4
|
+
`pr-converge`, and `autoconverge`. Provides the XML prompt template, Python
|
|
5
|
+
runtime scripts, the portable converge driver protocol, and named constants
|
|
6
|
+
those skills invoke during each loop tick.
|
|
4
7
|
|
|
5
8
|
## Subdirectories
|
|
6
9
|
|
|
7
10
|
| Directory | Role |
|
|
8
11
|
|---|---|
|
|
9
12
|
| `prompts/` | XML agent prompt templates. |
|
|
10
|
-
| `scripts/` | Python scripts for loop state management, prompt building, outcome recording, path resolution, and preflight checks. |
|
|
13
|
+
| `scripts/` | Python scripts for loop state management, prompt building, outcome recording, path resolution, pacer selection, and preflight checks. |
|
|
11
14
|
|
|
12
15
|
## Key files
|
|
13
16
|
|
|
14
17
|
| File | Role |
|
|
15
18
|
|---|---|
|
|
19
|
+
| `portable-driver.md` | Continuous in-session pacer when Workflow / ScheduleWakeup are absent. |
|
|
16
20
|
| `prompts/pr-consistency-audit.xml` | Structured prompt artifact for the cross-file consistency audit agent. |
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
21
|
+
| `scripts/select_converge_pacer.py` | Maps entry skill + host tool flags to `workflow`, `schedule_wakeup`, or `portable`. |
|
|
22
|
+
| `scripts/build_audit_prompt.py` | Assembles the audit agent prompt from loop state and the constants module. |
|
|
23
|
+
| `scripts/build_fix_prompt.py` | Assembles the fix agent prompt from loop state and findings. |
|
|
24
|
+
| `scripts/init_loop_state.py` | Initializes the per-PR loop state JSON file. |
|
|
25
|
+
| `scripts/write_audit_outcomes.py` | Writes the per-loop audit outcome XML into the workspace. |
|
|
26
|
+
| `scripts/write_fix_outcomes.py` | Writes the per-loop fix outcome XML into the workspace. |
|
|
27
|
+
| `scripts/preflight_worktree.py` | Verifies the working directory is a healthy worktree for the target PR's repo. |
|
|
28
|
+
| `scripts/teardown_worktrees.py` | Removes loop worktrees on clean exit. |
|
|
29
|
+
| `scripts/write_handoff.py` | Writes durable resume-handoff files under `~/.claude/runtime/pr-loop/<run-name>/` at each converge checkpoint. |
|
|
30
|
+
| `scripts/_path_resolver.py` | Resolves workspace and worktree paths from PR metadata. |
|
|
31
|
+
| `scripts/_cli_utils.py` | Shared CLI argument parsing helpers. |
|
|
32
|
+
| `scripts/_xml_utils.py` | XML serialization helpers. |
|
|
28
33
|
| `scripts/skills_pr_loop_constants/` | Named constants package imported by the scripts above. |
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Portable converge driver
|
|
2
|
+
|
|
3
|
+
**Rule: deterministic control is script-only.** Phase transitions, wait delays,
|
|
4
|
+
clean stamps, ready decisions, task lists, and “what next” never live as prose
|
|
5
|
+
for the agent to invent. The agent runs scripts, reads JSON, and only performs
|
|
6
|
+
judgment steps the JSON names.
|
|
7
|
+
|
|
8
|
+
## Step 1 — task list (every autoconverge / portable run)
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/build_converge_task_list.py" \
|
|
12
|
+
[--bugbot-down 0|1] [--copilot-down 0|1] \
|
|
13
|
+
[--codex-down 0|1] [--codex-required 0|1]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Register every `tasks[]` entry on the session task list. **Final task id is
|
|
17
|
+
always** `all_runnable_reviews_clean_same_head`. The run is complete only when
|
|
18
|
+
that final task is completed: every runnable code review is CLEAN on one
|
|
19
|
+
shared HEAD. Do not invent tasks in prose.
|
|
20
|
+
|
|
21
|
+
`open-run` embeds the same list (`tasks`, `runnable_review_ids`,
|
|
22
|
+
`final_task_id`, `done_when`).
|
|
23
|
+
|
|
24
|
+
## Pacer selection
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/select_converge_pacer.py" \
|
|
28
|
+
--skill <pr-converge|autoconverge> \
|
|
29
|
+
--has-workflow <0|1> \
|
|
30
|
+
--has-schedule-wakeup <0|1>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When `pacer` is not `portable`, use the skill’s native Workflow or
|
|
34
|
+
ScheduleWakeup path. When `pacer=portable`, use the control script below.
|
|
35
|
+
|
|
36
|
+
## Isolation and worktree
|
|
37
|
+
|
|
38
|
+
1. When the tool list includes `EnterWorktree`, call it (same contract as the
|
|
39
|
+
Claude-host skills).
|
|
40
|
+
2. When `EnterWorktree` is absent, isolate with git worktree machinery:
|
|
41
|
+
- Prefer an existing worktree already on the PR head ref under
|
|
42
|
+
`.claude/worktrees/` (or another dedicated worktree path).
|
|
43
|
+
- Otherwise `git fetch origin <headRefName>` and
|
|
44
|
+
`git worktree add <path> <headRefName>` (or `gh pr checkout <N>` into a
|
|
45
|
+
dedicated directory), then `cd` into that checkout.
|
|
46
|
+
3. Confirm the working directory is the PR’s own repo on the PR head SHA:
|
|
47
|
+
`python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <O> --repo <R> --mode strict`.
|
|
48
|
+
Non-zero exit → report the `ABORT` line and stop.
|
|
49
|
+
4. Cross-repo routing follows pr-converge Step 1.5: every local review and edit
|
|
50
|
+
runs with cwd set to the **PR worktree**.
|
|
51
|
+
|
|
52
|
+
`open-run` runs the same strict preflight before seeding state.
|
|
53
|
+
|
|
54
|
+
## Control script
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/portable_converge_driver.py" <command> ...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Stdout JSON: `status`, `next`, `phase`, `state_file`, optional `commands`,
|
|
61
|
+
`wait_seconds`, `blocker`, and on `open-run` the task-list fields. Exit `0` =
|
|
62
|
+
ok; `1` = contract failure; `2` = usage error.
|
|
63
|
+
|
|
64
|
+
| Command | Deterministic effect |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `open-run` | Require `portable`; preflight; seed state + task list; when `--codex-down` is off, set `codex_required` from CLI force-on or the weekly usage probe (same rule as `check_convergence`); next=`run_code_review` |
|
|
67
|
+
| `after-code-review` | From returncode / dirty_tree / served_command |
|
|
68
|
+
| `after-bugteam` | From pushed / converged |
|
|
69
|
+
| `after-bugbot` | From classification / inline lag |
|
|
70
|
+
| `after-codex` | From classification clean / dirty / down |
|
|
71
|
+
| `after-copilot-wait` | From review surfaced / wait cap |
|
|
72
|
+
| `after-ready-check` | From check_convergence exit |
|
|
73
|
+
| `show-state` | Echo state; rehydrate `commands` / `wait_seconds` for pending next |
|
|
74
|
+
|
|
75
|
+
## Continuous tick loop
|
|
76
|
+
|
|
77
|
+
After transport check, PR scope, isolation, permission grant, and the once-per-run
|
|
78
|
+
Copilot quota pre-check:
|
|
79
|
+
|
|
80
|
+
1. Seed or restore state via `open-run` or `show-state` (and handoff
|
|
81
|
+
`state-copy.json` when resuming).
|
|
82
|
+
2. Run the driver command for the current step; read JSON `next` / `commands` /
|
|
83
|
+
`wait_seconds`.
|
|
84
|
+
3. On non-terminal `next`:
|
|
85
|
+
- Write state and handoff when the after-* payload says so.
|
|
86
|
+
- If `next` is `poll_wait`, sleep `wait_seconds` only, then re-poll and call
|
|
87
|
+
the matching after-*.
|
|
88
|
+
- If `next` is immediate work, continue in the same turn without sleeping.
|
|
89
|
+
4. On `mark_ready` / `stop_blocked` / named stop: run lifecycle Close, print the
|
|
90
|
+
entry skill’s exit block, omit further pacing.
|
|
91
|
+
5. External reviewers remain skippable the same way as Claude-host runs when
|
|
92
|
+
opted out or down. Push and head-change reset push-invalidated markers
|
|
93
|
+
(`*_clean_at`, `merge_state_status`, `bugbot_down`, `codex_down`).
|
|
94
|
+
|
|
95
|
+
## Agent loop (judgment only)
|
|
96
|
+
|
|
97
|
+
1. Run the driver command for the current step.
|
|
98
|
+
2. If JSON `commands` is non-empty, run that argv (scripted helper).
|
|
99
|
+
3. Map JSON `next` to the single judgment action; report back via the matching
|
|
100
|
+
`after-*` command.
|
|
101
|
+
4. Mark session tasks complete only when the scripted stamps say so; complete
|
|
102
|
+
the final task only when every runnable review is clean on the same HEAD.
|
|
103
|
+
|
|
104
|
+
| `next` | Agent does | Report with |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `run_code_review` | Run `commands` | `after-code-review` |
|
|
107
|
+
| `apply_fixes_and_push` | Fix protocol; commit; push | re-review then `after-code-review` |
|
|
108
|
+
| `run_bugteam` | resolve_worker_spawn / bugteam body | `after-bugteam` |
|
|
109
|
+
| `run_bugbot_gate` | Bugbot helper scripts | `after-bugbot` |
|
|
110
|
+
| `run_codex_review` | Run Codex review step | `after-codex` |
|
|
111
|
+
| `request_copilot_review` | Request Copilot | `after-copilot-wait` |
|
|
112
|
+
| `poll_wait` | Sleep `wait_seconds` only | re-poll then after-* |
|
|
113
|
+
| `check_ready` | Run `commands` (check_convergence) | `after-ready-check` |
|
|
114
|
+
| `mark_ready` | Run `commands` (`gh pr ready`) | teardown |
|
|
115
|
+
| `stop_blocked` | Teardown; print blocker | stop |
|
|
116
|
+
|
|
117
|
+
## Autoconverge entry on portable pacer
|
|
118
|
+
|
|
119
|
+
When `/autoconverge` selects `pacer=portable`:
|
|
120
|
+
|
|
121
|
+
- Complete autoconverge pre-flight (scope, draft ownership, strict worktree,
|
|
122
|
+
grant, Copilot quota).
|
|
123
|
+
- Drive the continuous tick loop above (scripted phase machine) until ready
|
|
124
|
+
or a documented blocker.
|
|
125
|
+
- Skip `Workflow({ scriptPath: converge.mjs })` — that path requires the
|
|
126
|
+
Workflow tool.
|
|
127
|
+
- Teardown uses the lifecycle Close path; the Workflow-only closing HTML
|
|
128
|
+
journal report is optional and skipped when no workflow run id exists.
|
|
129
|
+
- Resume command on handoff: `/autoconverge <PR URL>`.
|
|
130
|
+
|
|
131
|
+
When `/autoconverge` selects `pacer=workflow`, follow the skill’s Workflow
|
|
132
|
+
sections unchanged.
|
|
133
|
+
|
|
134
|
+
## Helpers (scripted)
|
|
135
|
+
|
|
136
|
+
| Concern | Script |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Task list | `build_converge_task_list.py` |
|
|
139
|
+
| Pacer | `select_converge_pacer.py` |
|
|
140
|
+
| Worktree | `preflight_worktree.py` |
|
|
141
|
+
| Code review | `$HOME/.claude/scripts/invoke_code_review.py` |
|
|
142
|
+
| Workers | `$HOME/.claude/scripts/resolve_worker_spawn.py` |
|
|
143
|
+
| Ready | `pr-converge/scripts/check_convergence.py` |
|
|
144
|
+
| Handoff | `write_handoff.py` |
|
|
145
|
+
|
|
146
|
+
## Fail closed
|
|
147
|
+
|
|
148
|
+
Never abort solely because Workflow or ScheduleWakeup is missing once
|
|
149
|
+
pacer=`portable`. Fail closed on contract failures only: auth, worktree,
|
|
150
|
+
unresolvable gates, and wait caps.
|
|
@@ -14,6 +14,9 @@ Python scripts that run the PR audit-fix loop at runtime. Both `bugteam` and `pr
|
|
|
14
14
|
| `preflight_worktree.py` | Verifies the working directory is a healthy git worktree for the target PR's repo. Supports `--mode strict` to abort when the repo does not match. |
|
|
15
15
|
| `teardown_worktrees.py` | Removes per-PR worktrees after a clean loop exit. |
|
|
16
16
|
| `write_handoff.py` | Writes durable resume-handoff files under the run's `~/.claude/runtime/pr-loop` directory at each converge checkpoint. |
|
|
17
|
+
| `select_converge_pacer.py` | Selects `workflow`, `schedule_wakeup`, or `portable` for pr-converge / autoconverge from host tool flags. |
|
|
18
|
+
| `build_converge_task_list.py` | Step-1 task list: runnable review gates + final all_runnable_reviews_clean_same_head. |
|
|
19
|
+
| `portable_converge_driver.py` | portable_converge_driver phase machine: open-run and post-step transitions emit JSON next/commands only. |
|
|
17
20
|
| `_path_resolver.py` | Resolves workspace and worktree paths from PR owner, repo, and number. |
|
|
18
21
|
| `_cli_utils.py` | Shared CLI argument parsing helpers (argparse wrappers). |
|
|
19
22
|
| `_xml_utils.py` | XML serialization helpers for outcome files. |
|