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.
- package/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +676 -0
- package/hooks/blocking/code_rules_enforcer.py +26 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +75 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +123 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +203 -8
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- 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.
|
|
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.
|
|
231
|
-
|
|
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.
|
|
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
|
+
});
|