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.
Files changed (44) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  3. package/hooks/blocking/code_rules_shared.py +4 -2
  4. package/hooks/blocking/pii_payload_scan.py +78 -20
  5. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  6. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  7. package/hooks/validators/ruff_integration.py +2 -1
  8. package/hooks/validators/run_all_validators.py +460 -24
  9. package/hooks/validators/test_ruff_integration.py +21 -0
  10. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  11. package/package.json +1 -1
  12. package/rules/CLAUDE.md +1 -0
  13. package/rules/cleanup-command-forms.md +23 -0
  14. package/scripts/check.ps1 +32 -6
  15. package/skills/_shared/pr-loop/CLAUDE.md +18 -13
  16. package/skills/_shared/pr-loop/portable-driver.md +150 -0
  17. package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
  18. package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
  19. package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
  20. package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
  21. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
  22. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
  23. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
  24. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
  25. package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
  26. package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
  27. package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
  28. package/skills/autoconverge/CLAUDE.md +5 -3
  29. package/skills/autoconverge/SKILL.md +185 -74
  30. package/skills/autoconverge/reference/convergence.md +5 -5
  31. package/skills/autoconverge/reference/multi-pr.md +33 -2
  32. package/skills/autoconverge/reference/stop-conditions.md +9 -6
  33. package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
  34. package/skills/copilot-finding-triage/SKILL.md +16 -4
  35. package/skills/pr-converge/CLAUDE.md +4 -3
  36. package/skills/pr-converge/SKILL.md +236 -44
  37. package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
  38. package/skills/pr-converge/reference/per-tick.md +51 -24
  39. package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
  40. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
  41. package/skills/usage-pause/SKILL.md +5 -4
  42. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  43. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  44. 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 main, run_validators_entrypoint_subprocess
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(["--pre-tool-use"], stdin_text=json.dumps(payload))
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(self, tmp_path: Path) -> None:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 for the hooks package — runs ruff, mypy, and the
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). Each tool runs sequentially; the first non-zero
12
- exit code is preserved as the script's exit code so CI/pre-commit can
13
- short-circuit on the first failure.
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 the mypy run.
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` and `pr-converge`. Provides the XML prompt template, Python runtime scripts, and named constants that both skills invoke during each loop tick.
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
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/build_audit_prompt.py` | Assembles the audit agent prompt from loop state and the constants module. |
18
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/build_fix_prompt.py` | Assembles the fix agent prompt from loop state and findings. |
19
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/init_loop_state.py` | Initializes the per-PR loop state JSON file. |
20
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/write_audit_outcomes.py` | Writes the per-loop audit outcome XML into the workspace. |
21
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/write_fix_outcomes.py` | Writes the per-loop fix outcome XML into the workspace. |
22
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/preflight_worktree.py` | Verifies the working directory is a healthy worktree for the target PR's repo. |
23
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py` | Removes loop worktrees on clean exit. |
24
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/write_handoff.py` | Writes durable resume-handoff files under `~/.claude/runtime/pr-loop/<run-name>/` at each converge checkpoint. |
25
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_path_resolver.py` | Resolves workspace and worktree paths from PR metadata. |
26
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_cli_utils.py` | Shared CLI argument parsing helpers. |
27
- | `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_xml_utils.py` | XML serialization helpers. |
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. |