claude-dev-env 1.71.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -6,7 +6,7 @@
6
6
 
7
7
  When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
8
8
 
9
- The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Two more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the three gated slices.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Three more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the four gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -15,8 +15,9 @@ Read the body and the docstring side by side:
15
15
  - **Read-source / match-source unions.** A body that computes `read_names = a | b | c` (or any union of "what counts") names each union member in the prose enumeration. A union member the code applies but the prose omits is a gap.
16
16
  - **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
17
17
  - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
18
- - **Step order.** A docstring that says `A then B then C` matches the call order in the body.
18
+ - **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
19
19
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
20
+ - **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
20
21
  - **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
21
22
 
22
23
  When the body changes the set of behaviors it applies, the same edit updates the prose enumeration. The two move together in one commit.
package/scripts/CLAUDE.md CHANGED
@@ -18,6 +18,7 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
18
18
  | `Migrate-ShellPolicy.ps1` | Applies automated fixes for common shell-policy violations found by the audit script |
19
19
  | `Install-SweepEmptyDirs.ps1` | Registers `sweep_empty_dirs.py` as a scheduled task on Windows |
20
20
  | `check.ps1` | Runs the full code-quality check suite |
21
+ | `Show-Asset.ps1` | Opens files on screen, sizing each image window to the image's pixel dimensions (scaled to fit the screen); non-image files open in their default application |
21
22
 
22
23
  ## Subdirectories
23
24
 
@@ -0,0 +1,106 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Opens files on screen, sizing each image window to the image's own dimensions.
4
+
5
+ .DESCRIPTION
6
+ For every path given, an image opens in a window whose client area matches the
7
+ image's pixel size, scaled down to fit the primary screen's working area when the
8
+ image is larger than the screen. A small image gets a usable minimum window with
9
+ the picture centered at native size. Non-image files open in their registered
10
+ default application, and any file that cannot be loaded as an image falls back to
11
+ that default application too. Escape or the close button dismisses a window; the
12
+ process exits once every window is closed.
13
+
14
+ .PARAMETER Paths
15
+ One or more file paths to open.
16
+ #>
17
+ param(
18
+ [Parameter(ValueFromRemainingArguments = $true)]
19
+ [string[]]$Paths
20
+ )
21
+
22
+ Add-Type -AssemblyName System.Windows.Forms
23
+ Add-Type -AssemblyName System.Drawing
24
+
25
+ try {
26
+ [System.Windows.Forms.Application]::SetHighDpiMode([System.Windows.Forms.HighDpiMode]::PerMonitorV2) | Out-Null
27
+ }
28
+ catch {
29
+ $null = $_
30
+ }
31
+ [System.Windows.Forms.Application]::EnableVisualStyles()
32
+
33
+ $imageExtensions = @('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tif', '.tiff', '.ico')
34
+ $screenMargin = 80
35
+ $minimumClientWidth = 220
36
+ $minimumClientHeight = 160
37
+ $openWindowCount = 0
38
+
39
+ foreach ($path in $Paths) {
40
+ if (-not (Test-Path -LiteralPath $path)) { continue }
41
+ $fullPath = (Resolve-Path -LiteralPath $path).Path
42
+ $extension = [System.IO.Path]::GetExtension($fullPath).ToLowerInvariant()
43
+
44
+ if ($imageExtensions -notcontains $extension) {
45
+ Invoke-Item -LiteralPath $fullPath
46
+ continue
47
+ }
48
+
49
+ try {
50
+ $imageBytes = [System.IO.File]::ReadAllBytes($fullPath)
51
+ $imageStream = New-Object System.IO.MemoryStream(, $imageBytes)
52
+ $loadedImage = [System.Drawing.Image]::FromStream($imageStream)
53
+ $image = New-Object System.Drawing.Bitmap($loadedImage)
54
+ $loadedImage.Dispose()
55
+ $imageStream.Dispose()
56
+ }
57
+ catch {
58
+ Invoke-Item -LiteralPath $fullPath
59
+ continue
60
+ }
61
+
62
+ $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
63
+ $maximumWidth = $workingArea.Width - $screenMargin
64
+ $maximumHeight = $workingArea.Height - $screenMargin
65
+ $scale = [Math]::Min(1.0, [Math]::Min($maximumWidth / $image.Width, $maximumHeight / $image.Height))
66
+
67
+ $pictureBox = New-Object System.Windows.Forms.PictureBox
68
+ $pictureBox.Dock = [System.Windows.Forms.DockStyle]::Fill
69
+ $pictureBox.Image = $image
70
+
71
+ if ($scale -lt 1.0) {
72
+ $pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
73
+ $clientWidth = [int][Math]::Round($image.Width * $scale)
74
+ $clientHeight = [int][Math]::Round($image.Height * $scale)
75
+ }
76
+ else {
77
+ $pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
78
+ $clientWidth = [Math]::Max($minimumClientWidth, $image.Width)
79
+ $clientHeight = [Math]::Max($minimumClientHeight, $image.Height)
80
+ }
81
+
82
+ $form = New-Object System.Windows.Forms.Form
83
+ $form.Text = [System.IO.Path]::GetFileName($fullPath)
84
+ $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None
85
+ $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
86
+ $form.ClientSize = New-Object System.Drawing.Size($clientWidth, $clientHeight)
87
+ $form.KeyPreview = $true
88
+ $form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 24)
89
+ $form.Controls.Add($pictureBox)
90
+
91
+ $form.Add_KeyDown({
92
+ param($sender, $eventArguments)
93
+ if ($eventArguments.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { $sender.Close() }
94
+ })
95
+ $form.Add_FormClosed({
96
+ $script:openWindowCount--
97
+ if ($script:openWindowCount -le 0) { [System.Windows.Forms.Application]::Exit() }
98
+ })
99
+
100
+ $openWindowCount++
101
+ $form.Show()
102
+ }
103
+
104
+ if ($openWindowCount -gt 0) {
105
+ [System.Windows.Forms.Application]::Run()
106
+ }
@@ -23,6 +23,22 @@ the workflow journal.
23
23
  autoconverge runs it as a deterministic workflow. The two skills share the same
24
24
  helper scripts and the same convergence gate.
25
25
 
26
+ ## Run scope: one PR or several
27
+
28
+ Decide the scope from how many PRs the user named, then follow that path:
29
+
30
+ 1. **One PR** → the single-PR run described below (`workflow/converge.mjs`): one
31
+ worktree, one workflow launch, one teardown.
32
+ 2. **Several PRs** → the [Multiple PRs](#multiple-prs) run
33
+ (`workflow/converge_multi.mjs`): one worktree per PR and a single workflow
34
+ launch that drives every PR's converge run in parallel, then one teardown per
35
+ PR.
36
+
37
+ The single-PR sections (Requirements, Pre-flight, Run the workflow, Teardown)
38
+ each describe one converge run. The Multiple PRs section reuses them once per PR
39
+ and adds only what fanning out needs: a per-PR worktree and a per-PR teardown
40
+ loop.
41
+
26
42
  ## Requirements
27
43
 
28
44
  Scan the tool list at the top of this conversation for the literal string
@@ -101,7 +117,7 @@ own. The workflow runs in the background and notifies this session on
101
117
  completion. Watch live progress with `/workflows`.
102
118
 
103
119
  The workflow returns
104
- `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote }`.
120
+ `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
105
121
 
106
122
  ## Budget-aware round boundaries
107
123
 
@@ -207,8 +223,31 @@ round records nothing resumable and replays dirty.
207
223
  Blocker: <blocker> # only when blocked
208
224
  Standards: <standardsNote> # only when a round deferred code-standard findings
209
225
  Copilot: <copilotNote> # only when Copilot was down or out of quota
226
+ Reuse: <reuseNote> # only when the reuse pass identified an improvement
210
227
  ```
211
228
 
229
+ ## Reuse pass (before convergence)
230
+
231
+ Before the first round, one reuse lens (`code-quality-agent`) scans the full
232
+ `origin/main...HEAD` diff for places the PR re-implements behavior the codebase
233
+ already provides. It reports a reuse improvement only when all three criteria
234
+ hold, and drops any case where even one is in doubt:
235
+
236
+ - **Certain** — an existing symbol or module unquestionably covers the new
237
+ code's behavior, cited at `file:line`.
238
+ - **Behaviorally identical** — swapping the new code for the existing one
239
+ changes no observable behavior: same inputs, outputs, side effects, and error
240
+ handling.
241
+ - **Autonomously implementable** — the replacement is a mechanical edit (import
242
+ and call the existing symbol, delete the duplicate) needing no product
243
+ decision and no human judgment.
244
+
245
+ The reuse lens reports without editing. Qualifying improvements then run through
246
+ the same edit → verify → commit fix flow the rounds use, so they land in one
247
+ verified commit before convergence starts. The pass is best-effort: when no case
248
+ clears all three criteria, the run proceeds straight to convergence, and
249
+ `reuseNote` records what landed.
250
+
212
251
  ## What the workflow does each round
213
252
 
214
253
  See [`reference/convergence.md`](reference/convergence.md) for the full round
@@ -227,8 +266,12 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
227
266
  - **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
228
267
  the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
229
268
  `clean-coder` applies all fixes in a single commit, pushes, replies to and
230
- resolves any bot threads; re-verify next round on the new HEAD. When all
231
- three are clean on a stable HEAD, post the CLEAN bugteam audit artifact.
269
+ resolves any bot threads; re-verify next round on the new HEAD. Every edit
270
+ step ends with a pre-commit gate check: before its turn ends, the fixer
271
+ dry-runs the CODE_RULES commit gate (`code_rules_gate.py --staged`) and keeps
272
+ fixing until that gate would accept the commit — it makes no commit itself.
273
+ When all three are clean on a stable HEAD, post the CLEAN bugteam audit
274
+ artifact.
232
275
  A round whose findings are ALL code-standard violations (pure CODE_RULES/style,
233
276
  no behavioral impact) passes for convergence purposes: the workflow files a
234
277
  follow-up issue listing the findings, opens a draft environment-hardening PR
@@ -243,10 +286,87 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
243
286
  - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
244
287
  full pass the workflow marks `draft=false`.
245
288
 
289
+ ## Multiple PRs
290
+
291
+ The multi-PR run drives several draft PRs to ready in one launch:
292
+ `workflow/converge_multi.mjs` fans out one `converge.mjs` child run per PR with
293
+ `parallel()`, and every child is pinned to its own PR's worktree through the
294
+ `repoPath` it receives, so the children never share a checkout. Each child run is
295
+ the exact single-PR convergence loop — same rounds, same reuse pass, same Copilot
296
+ gate, same convergence check — one per PR at once. The children share the run's
297
+ concurrency cap, so the fan-out self-throttles rather than spawning every PR's
298
+ lenses at the same instant.
299
+
300
+ ### Multi-PR pre-flight (main session)
301
+
302
+ `EnterWorktree` puts the session on one branch only, so the multi-PR path gives
303
+ each PR its own checkout with `git worktree add`. For each PR the user named:
304
+
305
+ 1. **Resolve PR scope** as the single-PR pre-flight step 2 does: capture `owner`,
306
+ `repo`, `prNumber`, and `headRefName`; confirm the PR is a draft, and mark it
307
+ draft (`gh pr ready <n> --repo <o>/<r> --undo`) when it is already ready so the
308
+ loop owns the ready transition.
309
+ 2. **Create a worktree on the PR's head ref** and capture its absolute path. From
310
+ inside the PR's repository checkout:
311
+ `git worktree add <abs worktree path> <headRefName>` (run `git fetch origin
312
+ <headRefName>` first when the ref is not local). Put each PR's worktree under a
313
+ path carrying its PR number so the fan-out keeps them distinct. Confirm
314
+ `git -C <abs worktree path> rev-parse --abbrev-ref HEAD` equals the head ref
315
+ and its `HEAD` equals the PR head SHA.
316
+ 3. **Verify each worktree is the PR's repo (strict pre-flight):**
317
+ `python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode strict`,
318
+ run with that worktree as the working directory. A non-zero exit prints a
319
+ `PREFLIGHT_OUTCOME` line and an `ABORT` line: report it and drop that PR from
320
+ the run rather than aborting every PR.
321
+ 4. **Grant project permissions once per repository** — the single-PR pre-flight
322
+ step 4 grant covers every worktree of the same repo, so run it one time for
323
+ the repo the PRs live in.
324
+
325
+ ### Launch the multi-PR workflow
326
+
327
+ Call the `Workflow` tool against the fan-out script, passing the absolute path of
328
+ `converge.mjs` and one entry per PR:
329
+
330
+ ```
331
+ Workflow({
332
+ scriptPath: "<this skill dir>/workflow/converge_multi.mjs",
333
+ args: {
334
+ convergeScriptPath: "<this skill dir>/workflow/converge.mjs",
335
+ prs: [
336
+ { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false },
337
+ { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false }
338
+ ]
339
+ }
340
+ })
341
+ ```
342
+
343
+ `convergeScriptPath` is the absolute path to `workflow/converge.mjs` in this same
344
+ skill directory; each `repoPath` is the absolute path of the worktree that PR is
345
+ checked out in. The workflow runs in the background and notifies this session on
346
+ completion; watch live progress with `/workflows`, where each PR's child run
347
+ appears under its own group.
348
+
349
+ The workflow returns `{ converged, prCount, convergedCount, results, blocker }`,
350
+ where `results` is one record per PR carrying
351
+ `{ owner, repo, prNumber, converged, rounds, finalSha, blocker }`. The top-level
352
+ `converged` is true only when every PR converged.
353
+
354
+ ### Multi-PR teardown (on workflow completion)
355
+
356
+ Run the single-PR [Teardown](#teardown-on-workflow-completion) once per entry in
357
+ `results`, using that PR's `owner`, `repo`, `prNumber`, and `finalSha`, and its
358
+ own worktree as the working directory. Build and publish a PR's closing report
359
+ only for a PR whose `converged` is true; for a PR that returned a blocker, skip
360
+ its report and carry the blocker into the final summary. Revoke project
361
+ permissions once per repository after every PR's teardown. Then print one summary
362
+ report — a line per PR as
363
+ `#<prNumber>: <converged | blocked> — rounds <N>, final <finalSha>[, blocker <blocker>]`.
364
+
246
365
  ## Folder map
247
366
 
248
367
  - `SKILL.md` — this hub.
249
368
  - `workflow/converge.mjs` — the convergence workflow script.
369
+ - `workflow/converge_multi.mjs` — the multi-PR fan-out driver: one `converge.mjs` child run per PR in parallel, each pinned to its PR worktree via `repoPath`.
250
370
  - `workflow/aggregate_runs.py` — merges every autoconverge journal for a PR into one journal and returns its deduped findings, fix summaries, round count, and final SHA.
251
371
  - `workflow/convergence_summary.py` — builds the convergence-summary agent prompt over a PR's merged findings.
252
372
  - `workflow/render_report.py` — builds the closing convergence insights HTML report, taking the summary from `--summary-file`.
@@ -1,5 +1,42 @@
1
1
  # Convergence — round shape and the ready definition
2
2
 
3
+ ## Pre-flight: clear merge conflicts
4
+
5
+ Before the first round, the workflow checks once whether the PR branch conflicts
6
+ with `origin/main`. When GitHub reports a conflict (`mergeable` false or
7
+ `mergeable_state` dirty), one `clean-coder` rebases the branch onto `origin/main`
8
+ and resolves every conflict — gated the same way as every other code change: the
9
+ edit leaves the rebase in the working tree, a `code-verifier` binds a verdict to
10
+ it, and the commit step force-pushes with lease. The bug checks then run on a
11
+ conflict-free diff.
12
+
13
+ A PR that merges cleanly skips the rebase. A conflict that surfaces mid-run, when
14
+ `origin/main` advances during a later round, is caught by the convergence repair
15
+ at the end of the loop, which also rebases.
16
+
17
+ ## Reuse pass (runs after the conflict pre-flight, before convergence)
18
+
19
+ One reuse lens (`code-quality-agent`) reviews the full `origin/main...HEAD` diff
20
+ for code that re-implements behavior the repository already provides. It reports a
21
+ reuse improvement only when all three criteria hold together, and omits any case
22
+ where even one is in doubt:
23
+
24
+ 1. **Certain** — an existing symbol or module unquestionably covers the new
25
+ code's behavior, cited at `file:line`.
26
+ 2. **Behaviorally the same** — swapping the new code for the existing one
27
+ changes no observable behavior: same inputs, outputs, side effects, and
28
+ error handling.
29
+ 3. **Autonomously implementable** — the replacement is a mechanical edit (import
30
+ and call the existing symbol, drop the duplicate) needing no product
31
+ decision and no human judgment.
32
+
33
+ The lens reports without editing. Each qualifying improvement runs through the
34
+ same edit → verify → commit fix flow the rounds use, landing in one verified
35
+ commit before convergence begins. The pass is best-effort: when no case clears
36
+ all three criteria the run proceeds straight to convergence. Whatever the reuse
37
+ pass surfaces also joins the round findings, so the code-review lens re-checks
38
+ any improvement that did not land.
39
+
3
40
  ## The round loop
4
41
 
5
42
  The workflow holds three states and moves between them until the PR is ready or
@@ -26,7 +63,10 @@ tracks CONVERGE passes only and is never the cap.
26
63
  colliding threads.
27
64
  4. **Any findings** → one `clean-coder` applies every fix in a single test-first
28
65
  commit, pushes, then replies to and resolves each finding that carries a
29
- GitHub review thread. A round progresses when the fix lens lands a push that
66
+ GitHub review thread. Before its turn ends, the edit step dry-runs the
67
+ CODE_RULES commit gate (`code_rules_gate.py --staged`) over its staged
68
+ changes and keeps fixing until that gate would accept the commit, so the
69
+ later commit step never hits a gate rejection. A round progresses when the fix lens lands a push that
30
70
  moves HEAD, or when every finding was already addressed so no code change is
31
71
  needed yet each finding thread is still resolved (the fix lens reports
32
72
  `resolvedWithoutCommit` and the run re-converges on the unchanged HEAD). A
@@ -457,3 +457,93 @@ test('both standards-deferral call sites build standardsNote from the spawnStand
457
457
  'expected no unconditional hardening-PR claim in standardsNote',
458
458
  );
459
459
  });
460
+
461
+ test('a reuse-audit lens builder exists', () => {
462
+ assert.match(convergeSource, /function runReuseAuditPass\(/);
463
+ });
464
+
465
+ test('the reuse pass runs once before the convergence loop', () => {
466
+ const reuseCallIndex = convergeSource.indexOf('await runReuseAuditPass(');
467
+ const loopIndex = convergeSource.indexOf('while (iterations < CONFIG.maxIterations)');
468
+ assert.notEqual(reuseCallIndex, -1, 'expected the reuse pass to be invoked');
469
+ assert.notEqual(loopIndex, -1, 'expected the convergence loop to exist');
470
+ assert.ok(
471
+ reuseCallIndex < loopIndex,
472
+ 'expected the reuse pass to run before the convergence loop starts',
473
+ );
474
+ });
475
+
476
+ test('the reuse lens prompt enumerates all three qualifying criteria and an omit rule', () => {
477
+ const reusePrompt = lensPromptBody('runReuseAuditPass');
478
+ assert.match(reusePrompt, /CERTAIN/);
479
+ assert.match(reusePrompt, /BEHAVIORALLY IDENTICAL/);
480
+ assert.match(reusePrompt, /AUTONOMOUSLY IMPLEMENTABLE/);
481
+ assert.match(
482
+ reusePrompt,
483
+ /when any one is in doubt, omit the finding/i,
484
+ 'expected the reuse lens to drop any finding that fails a criterion',
485
+ );
486
+ });
487
+
488
+ test('the reuse lens reviews the full diff and does not edit', () => {
489
+ const reusePrompt = lensPromptBody('runReuseAuditPass');
490
+ assert.match(reusePrompt, /origin\/main\.\.\.HEAD/);
491
+ assert.match(
492
+ reusePrompt,
493
+ /Do NOT edit, commit, or push/,
494
+ 'expected the reuse lens to report findings without editing',
495
+ );
496
+ });
497
+
498
+ test('the reuse pass applies its findings through applyFixes, not the standards-deferral path', () => {
499
+ const reuseCallIndex = convergeSource.indexOf('await runReuseAuditPass(');
500
+ const loopIndex = convergeSource.indexOf('while (iterations < CONFIG.maxIterations)');
501
+ const reuseBlock = convergeSource.slice(reuseCallIndex, loopIndex);
502
+ assert.match(
503
+ reuseBlock,
504
+ /applyFixes\(reuseHead, reuseFindings, 'reuse-pass'\)/,
505
+ 'expected the reuse pass to apply its findings via applyFixes',
506
+ );
507
+ assert.doesNotMatch(
508
+ reuseBlock,
509
+ /spawnStandardsFollowUp/,
510
+ 'expected the reuse pass to apply improvements, not defer them',
511
+ );
512
+ });
513
+
514
+ test('the reuse lens runs under the Reuse phase', () => {
515
+ const reusePrompt = lensPromptBody('runReuseAuditPass');
516
+ assert.match(reusePrompt, /phase: 'Reuse'/);
517
+ });
518
+
519
+ test('the pre-commit gate step is a shared constant that dry-runs the CODE_RULES commit gate', () => {
520
+ assert.match(convergeSource, /const PRE_COMMIT_GATE_STEP =/);
521
+ const stepStart = convergeSource.indexOf('const PRE_COMMIT_GATE_STEP =');
522
+ const stepEnd = convergeSource.indexOf('\n\n', stepStart);
523
+ const stepBody = convergeSource.slice(stepStart, stepEnd);
524
+ assert.match(stepBody, /code_rules_gate\.py/);
525
+ assert.match(stepBody, /--staged/);
526
+ assert.match(
527
+ stepBody,
528
+ /do NOT commit/i,
529
+ 'expected the gate step to forbid committing — it is a dry committability check',
530
+ );
531
+ });
532
+
533
+ const editStepBuilders = [
534
+ 'applyFixesEdit',
535
+ 'recoverCommitBlockEdit',
536
+ 'recoverVerifyFailEdit',
537
+ 'repairConvergenceEdit',
538
+ 'standardsFollowUpEdit',
539
+ ];
540
+
541
+ for (const builderName of editStepBuilders) {
542
+ test(`${builderName} appends the pre-commit gate step to its edit prompt`, () => {
543
+ assert.match(
544
+ lensPromptBody(builderName),
545
+ /\+\s*PRE_COMMIT_GATE_STEP/,
546
+ `expected ${builderName} to append PRE_COMMIT_GATE_STEP`,
547
+ );
548
+ });
549
+ }
@@ -0,0 +1,98 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+
10
+ function functionBody(functionName) {
11
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
12
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
13
+ const nextFunctionMatch = /\n(?:async )?function /.exec(convergeSource.slice(functionStart + 1));
14
+ const functionEnd =
15
+ nextFunctionMatch === null ? convergeSource.length : functionStart + 1 + nextFunctionMatch.index;
16
+ return convergeSource.slice(functionStart, functionEnd);
17
+ }
18
+
19
+ const helperModule = new Function(
20
+ `${functionBody('isMergeConflicting')}\nreturn { isMergeConflicting };`,
21
+ )();
22
+ const { isMergeConflicting } = helperModule;
23
+
24
+ test('isMergeConflicting treats a dead check agent (null/undefined) as not conflicting', () => {
25
+ assert.equal(isMergeConflicting(null), false);
26
+ assert.equal(isMergeConflicting(undefined), false);
27
+ });
28
+
29
+ test('isMergeConflicting reports a conflict only when the check returned conflicting:true', () => {
30
+ assert.equal(isMergeConflicting({ conflicting: true }), true);
31
+ assert.equal(isMergeConflicting({ conflicting: false }), false);
32
+ });
33
+
34
+ test('checkMergeConflicts is a read-only mergeability probe that polls until GitHub computes it', () => {
35
+ const body = functionBody('checkMergeConflicts');
36
+ assert.match(body, /mergeable/, 'expected the probe to read the PR mergeable field');
37
+ assert.match(
38
+ body,
39
+ /do not edit, commit, push, or rebase|read only/i,
40
+ 'expected the probe to be read-only',
41
+ );
42
+ assert.match(body, /agentType:\s*'Explore'/, 'expected the probe to use the read-only Explore agent');
43
+ assert.match(body, /schema:\s*MERGE_CONFLICT_SCHEMA/, 'expected the probe to return MERGE_CONFLICT_SCHEMA');
44
+ assert.match(body, /null/, 'expected the probe to handle GitHub returning mergeable:null while it computes');
45
+ assert.match(body, /sleep 5|Start-Sleep/, 'expected a shell-agnostic poll delay');
46
+ });
47
+
48
+ test('resolveConflictsEdit rebases onto origin/main and makes no push', () => {
49
+ const body = functionBody('resolveConflictsEdit');
50
+ assert.match(body, /git rebase origin\/main/, 'expected the edit step to rebase onto origin/main');
51
+ assert.match(
52
+ body,
53
+ /do not push|no push|not push/i,
54
+ 'expected the edit step to leave the push to the commit step',
55
+ );
56
+ assert.match(body, /agentType:\s*'clean-coder'/, 'expected the edit step to use clean-coder');
57
+ });
58
+
59
+ test('resolveMergeConflicts runs check -> edit -> verify -> commit and gates the push on the verdict', () => {
60
+ const body = functionBody('resolveMergeConflicts');
61
+ const checkIndex = body.indexOf('checkMergeConflicts(');
62
+ const editIndex = body.indexOf('resolveConflictsEdit(');
63
+ const verifyIndex = body.indexOf('verifyRepairChanges(');
64
+ const commitIndex = body.indexOf('commitRepairFixes(');
65
+ assert.notEqual(checkIndex, -1, 'expected the conflict check to run');
66
+ assert.notEqual(editIndex, -1, 'expected the rebase edit step to run');
67
+ assert.notEqual(verifyIndex, -1, 'expected the verify step to run');
68
+ assert.notEqual(commitIndex, -1, 'expected the commit step to run');
69
+ assert.ok(
70
+ checkIndex < editIndex && editIndex < verifyIndex && verifyIndex < commitIndex,
71
+ 'expected the order check -> edit -> verify -> commit',
72
+ );
73
+ assert.match(body, /verdictPassed\(/, 'expected the verifier verdict to gate the force-push');
74
+ assert.match(
75
+ body,
76
+ /commitRepairFixes\(head,\s*true\)/,
77
+ 'expected the commit to force-with-lease (wasRebased=true) after a rebase',
78
+ );
79
+ });
80
+
81
+ test('resolveMergeConflicts rebases only when the check reports a conflict', () => {
82
+ const body = functionBody('resolveMergeConflicts');
83
+ assert.match(body, /isMergeConflicting\(/, 'expected the orchestrator to branch on the conflict decision');
84
+ assert.match(
85
+ body,
86
+ /if \(!isMergeConflicting\([^)]*\)\) return head/,
87
+ 'expected a clean PR to return the unchanged HEAD without rebasing',
88
+ );
89
+ });
90
+
91
+ test('the merge-conflict pre-flight runs once before the round loop, ahead of the parallel bug-check lenses', () => {
92
+ const preflightCall = convergeSource.indexOf('await resolveMergeConflicts(');
93
+ const whileLoop = convergeSource.indexOf('while (iterations < CONFIG.maxIterations)');
94
+ const firstLens = convergeSource.indexOf('const lenses = await parallel(');
95
+ assert.notEqual(preflightCall, -1, 'expected the pre-flight resolveMergeConflicts call site');
96
+ assert.ok(preflightCall < whileLoop, 'expected the pre-flight to run before the round loop');
97
+ assert.ok(preflightCall < firstLens, 'expected the pre-flight to run before the first bug-check lenses');
98
+ });