claude-dev-env 1.52.1 → 1.54.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/agents/clean-coder.md +1 -1
- package/package.json +2 -2
- package/skills/autoconverge/SKILL.md +112 -0
- package/skills/autoconverge/reference/convergence.md +84 -0
- package/skills/autoconverge/reference/gotchas.md +47 -0
- package/skills/autoconverge/reference/stop-conditions.md +59 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +177 -0
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +107 -0
- package/skills/autoconverge/workflow/converge.mjs +742 -0
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +81 -0
- package/skills/bugteam/SKILL.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/team-setup.md +1 -1
- package/skills/qbug/SKILL.md +1 -1
- package/skills/qbug/test_qbug_skill_audit_schema.py +3 -3
package/agents/clean-coder.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: clean-coder
|
|
3
3
|
description: "Use PROACTIVELY for ALL code generation — feature development, bug fixes, refactoring, hook creation, automation scripts, and any task that produces code. Internalizes CODE_RULES.md and the 8-dimension readability standard so thoroughly that /check finds zero issues. The definitive code-writing agent."
|
|
4
4
|
tools: Read, Write, Edit, Bash, Grep, Glob, Task, Skill
|
|
5
|
-
model:
|
|
5
|
+
model: fable
|
|
6
6
|
color: green
|
|
7
7
|
---
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.54.0",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-dev-env": "bin/install.mjs"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test bin/*.test.mjs"
|
|
10
|
+
"test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\""
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin/",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: autoconverge
|
|
3
|
+
description: >-
|
|
4
|
+
Drives one draft PR to convergence in a single autonomous workflow run.
|
|
5
|
+
Each round runs Cursor Bugbot, a code-review pass, and a bug-audit in
|
|
6
|
+
parallel on the same HEAD, dedups findings, applies every fix in one
|
|
7
|
+
commit, re-verifies, then clears a Copilot wait-gate and a closing
|
|
8
|
+
convergence check before marking the PR ready. Use when the user says
|
|
9
|
+
'/autoconverge', 'autoconverge this PR', 'converge this PR in one run',
|
|
10
|
+
'run the converge workflow', or 'drive the PR to ready autonomously'.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Autoconverge
|
|
14
|
+
|
|
15
|
+
One launch drives the whole loop to convergence. The `/autoconverge` skill
|
|
16
|
+
resolves PR scope, enters a worktree, grants project permissions, then hands
|
|
17
|
+
the loop to the **`converge.mjs` workflow**, which runs every round and every
|
|
18
|
+
reviewer wait inside one background pass — no ticks, no `ScheduleWakeup`, no
|
|
19
|
+
state file. State lives in the workflow's own variables; resume is handled by
|
|
20
|
+
the workflow journal.
|
|
21
|
+
|
|
22
|
+
`pr-converge` paces the same four-reviewer loop across `ScheduleWakeup` ticks;
|
|
23
|
+
autoconverge runs it as a deterministic workflow. The two skills share the same
|
|
24
|
+
helper scripts and the same convergence gate.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
Scan the tool list at the top of this conversation for the literal string
|
|
29
|
+
`Workflow`. If it is absent, report `autoconverge requires the Workflow tool;
|
|
30
|
+
aborting` and stop. The workflow also needs the `gh` CLI authenticated for the
|
|
31
|
+
PR's owner.
|
|
32
|
+
|
|
33
|
+
## Pre-flight (main session)
|
|
34
|
+
|
|
35
|
+
1. **Enter a worktree.** Call `EnterWorktree` with no arguments before any
|
|
36
|
+
`gh`, `git`, file read, or edit. `gh`/`git` Bash calls do not auto-isolate,
|
|
37
|
+
so this is mandatory. If it fails, report and stop.
|
|
38
|
+
|
|
39
|
+
2. **Resolve PR scope.** When the user passed a PR URL or number, parse owner,
|
|
40
|
+
repo, and number from it. Otherwise read the current branch's PR:
|
|
41
|
+
`gh pr view --json number,headRefName,url,isDraft,baseRefName`. Capture
|
|
42
|
+
`owner`, `repo`, `prNumber`. Confirm the PR is a draft; if it is already
|
|
43
|
+
ready, mark it draft first (`gh pr ready <n> --repo <o>/<r> --undo`) so the
|
|
44
|
+
loop owns the ready transition.
|
|
45
|
+
|
|
46
|
+
3. **Grant project permissions.**
|
|
47
|
+
`python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`
|
|
48
|
+
|
|
49
|
+
## Run the workflow
|
|
50
|
+
|
|
51
|
+
Call the `Workflow` tool against the colocated script:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Workflow({
|
|
55
|
+
scriptPath: "<this skill dir>/workflow/converge.mjs",
|
|
56
|
+
args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false }
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`scriptPath` is the absolute path to `workflow/converge.mjs` inside this skill's
|
|
61
|
+
own directory (on this install,
|
|
62
|
+
`<home>/.claude/skills/autoconverge/workflow/converge.mjs`). Set
|
|
63
|
+
`bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
|
|
64
|
+
run; otherwise the workflow detects an opt-out or an unreachable Bugbot on its
|
|
65
|
+
own. The workflow runs in the background and notifies this session on
|
|
66
|
+
completion. Watch live progress with `/workflows`.
|
|
67
|
+
|
|
68
|
+
The workflow returns
|
|
69
|
+
`{ converged, rounds, finalSha, blocker }`.
|
|
70
|
+
|
|
71
|
+
## Teardown (on workflow completion)
|
|
72
|
+
|
|
73
|
+
1. **When `converged` is true:** rewrite the PR description and clean the
|
|
74
|
+
working tree — see
|
|
75
|
+
[`bugteam/reference/teardown-publish-permissions.md` § Step 4 and § Step 4.5](../bugteam/reference/teardown-publish-permissions.md).
|
|
76
|
+
The workflow already marked the PR ready.
|
|
77
|
+
|
|
78
|
+
2. **Always revoke project permissions** (including on a blocker exit):
|
|
79
|
+
`python "$HOME/.claude/skills/bugteam/scripts/revoke_project_claude_permissions.py"`
|
|
80
|
+
|
|
81
|
+
3. **Print the final report:**
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
/autoconverge exit: <converged | blocked>
|
|
85
|
+
Rounds: <N>
|
|
86
|
+
Final commit: <finalSha>
|
|
87
|
+
Blocker: <blocker> # only when blocked
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## What the workflow does each round
|
|
91
|
+
|
|
92
|
+
See [`reference/convergence.md`](reference/convergence.md) for the full round
|
|
93
|
+
shape and the exact convergence definition, and
|
|
94
|
+
[`reference/stop-conditions.md`](reference/stop-conditions.md) for every way the
|
|
95
|
+
run ends short of ready. Hard-won failure lessons live in
|
|
96
|
+
[`reference/gotchas.md`](reference/gotchas.md).
|
|
97
|
+
|
|
98
|
+
- **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
|
|
99
|
+
the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
|
|
100
|
+
`clean-coder` applies all fixes in a single commit, pushes, replies to and
|
|
101
|
+
resolves any bot threads; re-verify next round on the new HEAD. When all
|
|
102
|
+
three are clean on a stable HEAD, post the CLEAN bugteam audit artifact.
|
|
103
|
+
- **Copilot gate:** request a Copilot review, poll up to three times; findings
|
|
104
|
+
route back into Converge, a no-show after the cap is a blocker.
|
|
105
|
+
- **Convergence check:** `check_convergence.py` is the authoritative gate; on a
|
|
106
|
+
full pass the workflow marks `draft=false`.
|
|
107
|
+
|
|
108
|
+
## Folder map
|
|
109
|
+
|
|
110
|
+
- `SKILL.md` — this hub.
|
|
111
|
+
- `workflow/converge.mjs` — the convergence workflow script.
|
|
112
|
+
- `reference/` — convergence definition, stop conditions, gotchas.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Convergence — round shape and the ready definition
|
|
2
|
+
|
|
3
|
+
## The round loop
|
|
4
|
+
|
|
5
|
+
The workflow holds three states and moves between them until the PR is ready or
|
|
6
|
+
a blocker ends the run. A single iteration counter increments on every pass
|
|
7
|
+
through any phase and caps the whole run at 20 loop iterations; the round counter
|
|
8
|
+
tracks CONVERGE passes only and is never the cap.
|
|
9
|
+
|
|
10
|
+
**CONVERGE** (one round = one parallel sweep):
|
|
11
|
+
|
|
12
|
+
1. Resolve the current PR HEAD SHA.
|
|
13
|
+
2. Run three lenses in parallel on that HEAD, each over the full
|
|
14
|
+
`origin/main...HEAD` diff:
|
|
15
|
+
- **Bugbot lens** — drive Cursor Bugbot to a verdict on HEAD (trigger and
|
|
16
|
+
poll its CI check run when needed) and return its findings, or mark itself
|
|
17
|
+
down when Bugbot is opted out or unreachable.
|
|
18
|
+
- **Code-review lens** — a correctness-focused review pass (`code-quality-agent`)
|
|
19
|
+
that reports findings without editing.
|
|
20
|
+
- **Bug-audit lens** — the bug-audit (`code-quality-agent`) applying the
|
|
21
|
+
shared A–P rubric from `bugteam/reference/audit-contract.md`, reporting
|
|
22
|
+
findings without editing.
|
|
23
|
+
3. Dedup findings across the three lenses by file, line, and title. A collision
|
|
24
|
+
keeps the most severe duplicate's severity (P0 > P1 > P2), unions the detail
|
|
25
|
+
text, and collects every distinct bot thread id so the fix lens resolves all
|
|
26
|
+
colliding threads.
|
|
27
|
+
4. **Any findings** → one `clean-coder` applies every fix in a single test-first
|
|
28
|
+
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
|
|
30
|
+
moves HEAD, or when every finding was already addressed so no code change is
|
|
31
|
+
needed yet each finding thread is still resolved (the fix lens reports
|
|
32
|
+
`resolvedWithoutCommit` and the run re-converges on the unchanged HEAD). A
|
|
33
|
+
round whose fix lens reports neither a moved-HEAD push nor a full
|
|
34
|
+
thread-resolution ends the run with a fix-stalled blocker. The next round
|
|
35
|
+
re-verifies on the current HEAD.
|
|
36
|
+
5. **Zero findings on a stable HEAD** → post the CLEAN bugteam audit artifact
|
|
37
|
+
for that HEAD, then move to the Copilot gate.
|
|
38
|
+
|
|
39
|
+
**COPILOT** gate:
|
|
40
|
+
|
|
41
|
+
- Request a Copilot review on HEAD (skipping a duplicate request), then poll up
|
|
42
|
+
to three times, 360 seconds apart.
|
|
43
|
+
- Copilot findings → fix them and return to CONVERGE on the new HEAD.
|
|
44
|
+
- Copilot clean or approved → move to the convergence check.
|
|
45
|
+
- No review after three polls → blocker.
|
|
46
|
+
|
|
47
|
+
**Convergence check**:
|
|
48
|
+
|
|
49
|
+
- Run `check_convergence.py`. A full pass marks the PR ready (`draft=false`) and
|
|
50
|
+
ends the run. A failure returns to CONVERGE so the next round addresses the
|
|
51
|
+
failing gate.
|
|
52
|
+
|
|
53
|
+
## Full-diff rule
|
|
54
|
+
|
|
55
|
+
Every lens, every round, reviews the full `origin/main...HEAD` diff — every file
|
|
56
|
+
the PR touches. A lens that scopes to recent commits, a single file, or a
|
|
57
|
+
bugbot-flagged path does not satisfy the round; its clean verdict is not a clean.
|
|
58
|
+
|
|
59
|
+
## The ready definition
|
|
60
|
+
|
|
61
|
+
`check_convergence.py` is the single source of truth for readiness. It re-derives
|
|
62
|
+
every condition from GitHub and marks the PR ready only when all of these hold on
|
|
63
|
+
the current HEAD:
|
|
64
|
+
|
|
65
|
+
1. Bugbot CI check run is completed with a success or neutral conclusion
|
|
66
|
+
(bypassed when Bugbot is opted out or proved unreachable this run).
|
|
67
|
+
2. The Bugbot review body on HEAD reports no findings (checked when a Bugbot
|
|
68
|
+
review is present).
|
|
69
|
+
3. A CLEAN bugteam audit review sits on HEAD.
|
|
70
|
+
4. The Copilot review on HEAD is clean or approved.
|
|
71
|
+
5. Zero unresolved bot review threads anywhere on the PR — counting Cursor,
|
|
72
|
+
Claude, and Copilot authored threads where `isResolved` is false (`isOutdated`
|
|
73
|
+
threads are excluded by the gate, but the fix lens still verifies and resolves
|
|
74
|
+
them during the round).
|
|
75
|
+
6. The PR is mergeable (`mergeable` true and `mergeable_state` clean).
|
|
76
|
+
7. No requested reviewers are still pending.
|
|
77
|
+
|
|
78
|
+
## Audit-trail design
|
|
79
|
+
|
|
80
|
+
Bugbot and Copilot post their own review threads, which the fix lens replies to
|
|
81
|
+
and resolves. The bug-audit lens keeps its findings in memory across the round
|
|
82
|
+
and posts only the terminal CLEAN bugteam review once every lens is clean on a
|
|
83
|
+
stable HEAD — that single artifact is what gate 3 reads. This keeps thread churn
|
|
84
|
+
to the threads the bots raise themselves.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Gotchas
|
|
2
|
+
|
|
3
|
+
Hard-won lessons for the autoconverge workflow. Append a bullet each time a run
|
|
4
|
+
fails in a new way.
|
|
5
|
+
|
|
6
|
+
- **The workflow script cannot sleep.** `Date.now`, `Math.random`, and timers
|
|
7
|
+
are unavailable in the script body, and a foreground sleep is blocked. Every
|
|
8
|
+
reviewer wait lives inside an `agent`'s own poll loop — a shell-agnostic `sleep`
|
|
9
|
+
loop (PowerShell `Start-Sleep` is an allowed alternative), or `gh` check
|
|
10
|
+
polling. Never try to wait in the script itself.
|
|
11
|
+
|
|
12
|
+
- **Workflow agents start blank.** Spawned agents do not inherit CLAUDE.md,
|
|
13
|
+
rules, or this skill's context. Each agent prompt is self-contained: it names
|
|
14
|
+
the exact scripts and flags, the full `origin/main...HEAD` diff scope, and the
|
|
15
|
+
return contract.
|
|
16
|
+
|
|
17
|
+
- **Only the fix lens writes.** The three converge lenses read and report; they
|
|
18
|
+
never edit, commit, or push. Because only the serial fix step pushes, the
|
|
19
|
+
parallel sweep needs no worktree isolation.
|
|
20
|
+
|
|
21
|
+
- **Fetch origin/main once before the parallel lenses.** The code-review and
|
|
22
|
+
bug-audit lenses both diff against `origin/main`. Concurrent `git fetch` calls
|
|
23
|
+
contend on the worktree `.git` lock and fail intermittently, so the workflow
|
|
24
|
+
runs a single serial `git fetch origin main` (the `prefetch-main` step) at the
|
|
25
|
+
start of each round and the parallel lenses run no git fetch of their own —
|
|
26
|
+
they diff against the already-current ref.
|
|
27
|
+
|
|
28
|
+
- **The CLEAN bugteam artifact is HEAD-specific.** `check_convergence.py` reads
|
|
29
|
+
the bugteam review on the current HEAD. Any push moves HEAD and invalidates a
|
|
30
|
+
prior artifact, so post it only when a round is fully clean, and post a fresh
|
|
31
|
+
one after any later fix round.
|
|
32
|
+
|
|
33
|
+
- **Bot login fields differ by endpoint.** `get_reviews` returns `.user.login`
|
|
34
|
+
(an object), while `get_review_comments` returns `.author` (a string). Match
|
|
35
|
+
bot logins with case-insensitive substring tests, not strict equality.
|
|
36
|
+
|
|
37
|
+
- **`scriptPath` takes a real path, not a shell variable.** `$HOME` expands in a
|
|
38
|
+
Bash command but not in the `Workflow` tool's `scriptPath` argument. Pass the
|
|
39
|
+
expanded absolute path to `workflow/converge.mjs`.
|
|
40
|
+
|
|
41
|
+
- **Tilde paths fail on Windows Git Bash.** Inside shell calls, use `$HOME/.claude/...`,
|
|
42
|
+
not `~/.claude/...`; a tilde resolves to the wrong home and gives a
|
|
43
|
+
file-not-found error that looks like a script failure.
|
|
44
|
+
|
|
45
|
+
- **`gh` token drift across accounts.** When a run touches more than one GitHub
|
|
46
|
+
account, pin the token with `--user <login>`; `gh auth token` alone can return
|
|
47
|
+
another account's token after a switch.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Stop conditions
|
|
2
|
+
|
|
3
|
+
The workflow ends one of two ways: converged (PR marked ready) or blocked. A
|
|
4
|
+
blocker exit returns `{ converged: false, rounds, finalSha, blocker }`, and the
|
|
5
|
+
skill still runs teardown (revoke permissions, final report).
|
|
6
|
+
|
|
7
|
+
## Blockers (end the run short of ready)
|
|
8
|
+
|
|
9
|
+
- **Copilot no-show** — Copilot surfaces no review on the current HEAD after
|
|
10
|
+
three polls (360 seconds apart). `blocker` names the Copilot timeout.
|
|
11
|
+
- **Iteration cap** — 20 loop iterations pass without a full convergence-check
|
|
12
|
+
pass. The iteration counter increments on every pass through any phase, so a
|
|
13
|
+
convergence-check gate that no round can clear (for example a `mergeable_state`
|
|
14
|
+
stuck at `blocked`, `behind`, or `unknown` that a rebase does not fix) and a
|
|
15
|
+
Copilot gate agent that keeps dying and retrying on the same HEAD both reach
|
|
16
|
+
the cap this way. `blocker` reports `iteration cap reached`.
|
|
17
|
+
- **Fix stalled** — the fix lens reports no push (`pushed: false`) without
|
|
18
|
+
resolving every finding thread, returns a SHA equal to the prior HEAD on a
|
|
19
|
+
case-folded common prefix (a full or abbreviated SHA of the unchanged commit
|
|
20
|
+
both count), or returns null for a round's findings. HEAD did not move and the
|
|
21
|
+
threads were not all resolved, so the next round would re-raise the same
|
|
22
|
+
findings. The run ends with a `blocker` that names the finding count and the
|
|
23
|
+
stalled HEAD. A round whose every finding carries no GitHub thread
|
|
24
|
+
(`replyToCommentId: null` on each) and whose fix reports
|
|
25
|
+
`resolvedWithoutCommit: true` is also a stall: it moves no code and resolves no
|
|
26
|
+
thread, so re-converging on the unchanged HEAD would loop the same finding to
|
|
27
|
+
the iteration cap. The `blocker` names the in-memory finding count and the
|
|
28
|
+
stalled HEAD. An all-stale round that makes no commit but resolves every
|
|
29
|
+
finding thread (`resolvedWithoutCommit: true` with at least one thread-bearing
|
|
30
|
+
finding) is not a stall — the run re-converges on the unchanged HEAD and
|
|
31
|
+
reaches the Copilot and convergence gates.
|
|
32
|
+
- **Mark-ready failed** — the convergence check passes but the mark-ready step
|
|
33
|
+
cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
|
|
34
|
+
re-query still reports true). The workflow does not report `converged: true`;
|
|
35
|
+
the run ends with a `blocker` naming the failed ready transition.
|
|
36
|
+
|
|
37
|
+
## Not a blocker (the run continues)
|
|
38
|
+
|
|
39
|
+
- **Bugbot down** — when Cursor Bugbot is opted out, or never produces a check
|
|
40
|
+
run or review after the lens poll budget, the Bugbot lens returns `down: true`.
|
|
41
|
+
The run continues, and the convergence check runs with `--bugbot-down` so its
|
|
42
|
+
Bugbot gate is bypassed.
|
|
43
|
+
- **A lens agent dies** — when one parallel lens returns null (a terminal agent
|
|
44
|
+
failure), the round proceeds on the surviving lenses. A real defect it would
|
|
45
|
+
have caught surfaces in a later round or at the convergence check. A dead
|
|
46
|
+
Bugbot lens (null result) counts as down for that HEAD, so the convergence
|
|
47
|
+
check runs with `--bugbot-down` rather than demanding a Bugbot verdict the
|
|
48
|
+
dead agent never produced.
|
|
49
|
+
- **Every lens agent dies** — when all three parallel lenses return null in the
|
|
50
|
+
same round, the round is a failure, not a clean: the workflow posts no CLEAN
|
|
51
|
+
bugteam artifact and does not advance to the Copilot gate. It re-resolves HEAD
|
|
52
|
+
and retries on the next round, still bounded by the iteration cap.
|
|
53
|
+
|
|
54
|
+
## User stop
|
|
55
|
+
|
|
56
|
+
Stopping the background workflow (`TaskStop`, or the user halting the run) ends
|
|
57
|
+
it where it stands. Re-launching `/autoconverge` starts a fresh run; the
|
|
58
|
+
workflow journal allows resuming the prior run from its last completed step with
|
|
59
|
+
`Workflow({ scriptPath, resumeFromRunId })`.
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
const gotchasSource = readFileSync(
|
|
10
|
+
join(workflowDirectory, '..', 'reference', 'gotchas.md'),
|
|
11
|
+
'utf8',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function lensPromptBody(builderName) {
|
|
15
|
+
const builderStart = convergeSource.indexOf(`function ${builderName}(`);
|
|
16
|
+
assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
|
|
17
|
+
const nextBuilderStart = convergeSource.indexOf('\nfunction ', builderStart + 1);
|
|
18
|
+
const builderEnd = nextBuilderStart === -1 ? convergeSource.length : nextBuilderStart;
|
|
19
|
+
return convergeSource.slice(builderStart, builderEnd);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
|
|
23
|
+
assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
|
|
27
|
+
assert.doesNotMatch(lensPromptBody('runAuditLens'), /git fetch origin main/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
|
|
31
|
+
assert.match(convergeSource, /function prefetchMainForRound\(/);
|
|
32
|
+
const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
|
|
33
|
+
const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
|
|
34
|
+
assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
|
|
35
|
+
assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
|
|
36
|
+
assert.ok(
|
|
37
|
+
prefetchCallIndex < parallelLensIndex,
|
|
38
|
+
'expected the round prefetch to run before the parallel lenses spawn',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('bugbot lens preamble does not blanket-instruct passing --owner/--repo to every script', () => {
|
|
43
|
+
const bugbotPrompt = lensPromptBody('runBugbotLens');
|
|
44
|
+
assert.doesNotMatch(
|
|
45
|
+
bugbotPrompt,
|
|
46
|
+
/use the existing scripts; pass --owner/,
|
|
47
|
+
'the blanket clause breaks reviews_disabled.py, which accepts only --reviewer',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('bugbot lens invokes reviews_disabled.py with only --reviewer', () => {
|
|
52
|
+
const bugbotPrompt = lensPromptBody('runBugbotLens');
|
|
53
|
+
const reviewsDisabledIndex = bugbotPrompt.indexOf('reviews_disabled.py');
|
|
54
|
+
assert.notEqual(reviewsDisabledIndex, -1, 'expected reviews_disabled.py invocation');
|
|
55
|
+
const invocationLineEnd = bugbotPrompt.indexOf('\\n', reviewsDisabledIndex);
|
|
56
|
+
const invocationLine = bugbotPrompt.slice(reviewsDisabledIndex, invocationLineEnd);
|
|
57
|
+
assert.match(invocationLine, /--reviewer bugbot/);
|
|
58
|
+
assert.doesNotMatch(
|
|
59
|
+
invocationLine,
|
|
60
|
+
/--owner|--repo/,
|
|
61
|
+
'reviews_disabled.py argparse rejects --owner/--repo with SystemExit(2)',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('gotchas doc states parallel lenses must avoid concurrent git operations', () => {
|
|
66
|
+
assert.doesNotMatch(gotchasSource, /cannot race on git state/);
|
|
67
|
+
assert.match(gotchasSource, /fetch.*once.*before/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('repair-convergence filters unresolved threads to bot authors and skips human threads', () => {
|
|
71
|
+
const repairPrompt = lensPromptBody('repairConvergence');
|
|
72
|
+
assert.match(
|
|
73
|
+
repairPrompt,
|
|
74
|
+
/cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
|
|
75
|
+
'expected the bot-author allowlist (Cursor/Claude/Copilot) to be named',
|
|
76
|
+
);
|
|
77
|
+
assert.match(
|
|
78
|
+
repairPrompt,
|
|
79
|
+
/skip.*human|human.*skip/is,
|
|
80
|
+
'expected an explicit instruction to skip human reviewer threads',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('repair-convergence no longer instructs resolving every unresolved thread without an author filter', () => {
|
|
85
|
+
const repairPrompt = lensPromptBody('repairConvergence');
|
|
86
|
+
assert.doesNotMatch(
|
|
87
|
+
repairPrompt,
|
|
88
|
+
/fetch every thread where isResolved is false/,
|
|
89
|
+
'the unfiltered instruction could resolve human reviewer threads',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
|
|
94
|
+
const bugbotPrompt = lensPromptBody('runBugbotLens');
|
|
95
|
+
assert.match(bugbotPrompt, /sleep 60/, 'expected a shell-agnostic 60-second poll delay');
|
|
96
|
+
assert.match(bugbotPrompt, /sleep 8/, 'expected a concrete 8-second delay command');
|
|
97
|
+
assert.match(
|
|
98
|
+
bugbotPrompt,
|
|
99
|
+
/Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
|
|
100
|
+
'expected PowerShell to be named only as an allowed alternative',
|
|
101
|
+
);
|
|
102
|
+
assert.doesNotMatch(
|
|
103
|
+
bugbotPrompt,
|
|
104
|
+
/wait 8 seconds(?!,)/,
|
|
105
|
+
'the vague "wait 8 seconds" phrasing must carry a concrete command',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
|
|
110
|
+
const copilotPrompt = lensPromptBody('runCopilotGate');
|
|
111
|
+
assert.match(copilotPrompt, /sleep 360/, 'expected a shell-agnostic 360-second poll delay');
|
|
112
|
+
assert.match(
|
|
113
|
+
copilotPrompt,
|
|
114
|
+
/Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
|
|
115
|
+
'expected PowerShell to be named only as an allowed alternative',
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('gotchas doc describes the reviewer wait as shell-agnostic', () => {
|
|
120
|
+
assert.match(
|
|
121
|
+
gotchasSource,
|
|
122
|
+
/\bsleep\b/i,
|
|
123
|
+
'expected the wait guidance to name a shell-agnostic sleep',
|
|
124
|
+
);
|
|
125
|
+
assert.doesNotMatch(
|
|
126
|
+
gotchasSource,
|
|
127
|
+
/a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
|
|
128
|
+
'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function finalizeRepairBranch() {
|
|
133
|
+
const repairCallIndex = convergeSource.indexOf('await repairConvergence(');
|
|
134
|
+
assert.notEqual(repairCallIndex, -1, 'expected the FINALIZE repair call to exist');
|
|
135
|
+
const transitionIndex = convergeSource.indexOf("phase = 'CONVERGE'", repairCallIndex);
|
|
136
|
+
assert.notEqual(transitionIndex, -1, 'expected a CONVERGE transition after the repair call');
|
|
137
|
+
const branchEnd = convergeSource.indexOf('continue', transitionIndex) + 'continue'.length;
|
|
138
|
+
return convergeSource.slice(repairCallIndex, branchEnd);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test('the FINALIZE repair branch does not re-assign head from the repair before re-converging', () => {
|
|
142
|
+
assert.doesNotMatch(
|
|
143
|
+
finalizeRepairBranch(),
|
|
144
|
+
/head\s*=\s*repair/,
|
|
145
|
+
'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the repair SHA here is dead',
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
|
|
150
|
+
const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
|
|
151
|
+
assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
|
|
152
|
+
const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
|
|
153
|
+
assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('fix prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
|
|
157
|
+
const fixPrompt = lensPromptBody('applyFixes');
|
|
158
|
+
assert.match(fixPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
|
|
159
|
+
assert.match(
|
|
160
|
+
fixPrompt,
|
|
161
|
+
/databaseId/,
|
|
162
|
+
'expected the GraphQL lookup matching comment databaseId to be named',
|
|
163
|
+
);
|
|
164
|
+
assert.match(
|
|
165
|
+
fixPrompt,
|
|
166
|
+
/not the numeric comment id/,
|
|
167
|
+
'expected an explicit guard against passing the numeric comment id to resolve_thread',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('fix prompt does not pass the numeric comment id straight to resolve_thread', () => {
|
|
172
|
+
assert.doesNotMatch(
|
|
173
|
+
lensPromptBody('applyFixes'),
|
|
174
|
+
/then resolve that thread \(use the github MCP pull_request_review_write/,
|
|
175
|
+
'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
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 nextFunctionStart = convergeSource.indexOf('\nfunction ', functionStart + 1);
|
|
14
|
+
const functionEnd = nextFunctionStart === -1 ? convergeSource.length : nextFunctionStart;
|
|
15
|
+
return convergeSource.slice(functionStart, functionEnd);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function constantLine(constantName) {
|
|
19
|
+
const constantSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
|
|
20
|
+
const matchedLine = constantSource
|
|
21
|
+
.split('\n')
|
|
22
|
+
.find((eachLine) => eachLine.trimStart().startsWith(`const ${constantName} =`));
|
|
23
|
+
assert.ok(matchedLine, `expected ${constantName} to be declared`);
|
|
24
|
+
return matchedLine;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const productionModule = new Function(
|
|
28
|
+
`${constantLine('SHA_COMPARISON_PREFIX_LENGTH')}\n` +
|
|
29
|
+
`${functionBody('normalizeShaForComparison')}\n` +
|
|
30
|
+
`${functionBody('collectFindingThreadIds')}\n` +
|
|
31
|
+
`${functionBody('detectFixProgress')}\n` +
|
|
32
|
+
'return { detectFixProgress, collectFindingThreadIds };',
|
|
33
|
+
)();
|
|
34
|
+
|
|
35
|
+
const { detectFixProgress, collectFindingThreadIds } = productionModule;
|
|
36
|
+
|
|
37
|
+
const PRIOR_HEAD = 'abcdef0123456789abcdef0123456789abcdef01';
|
|
38
|
+
const MOVED_HEAD = 'fedcba9876543210fedcba9876543210fedcba98';
|
|
39
|
+
|
|
40
|
+
function nullThreadFinding() {
|
|
41
|
+
return { file: 'converge.mjs', line: 278, severity: 'P2', title: 'stale', detail: 'x', replyToCommentId: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function threadBearingFinding() {
|
|
45
|
+
return { file: 'converge.mjs', line: 278, severity: 'P2', title: 'stale', detail: 'x', replyToCommentId: 4242 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('a resolvedWithoutCommit round whose findings carry no thread id does not count as progress', () => {
|
|
49
|
+
const allNullThreadFindings = [nullThreadFinding(), nullThreadFinding()];
|
|
50
|
+
const hadThreadBearingFinding = allNullThreadFindings.some(
|
|
51
|
+
(eachFinding) => collectFindingThreadIds(eachFinding).length > 0,
|
|
52
|
+
);
|
|
53
|
+
const fixProgress = detectFixProgress(
|
|
54
|
+
{ newSha: PRIOR_HEAD, pushed: false, resolvedWithoutCommit: true, summary: 'judged stale' },
|
|
55
|
+
PRIOR_HEAD,
|
|
56
|
+
hadThreadBearingFinding,
|
|
57
|
+
);
|
|
58
|
+
assert.equal(fixProgress.progressed, false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a resolvedWithoutCommit round with at least one thread-bearing finding still counts as progress', () => {
|
|
62
|
+
const mixedFindings = [nullThreadFinding(), threadBearingFinding()];
|
|
63
|
+
const hadThreadBearingFinding = mixedFindings.some(
|
|
64
|
+
(eachFinding) => collectFindingThreadIds(eachFinding).length > 0,
|
|
65
|
+
);
|
|
66
|
+
const fixProgress = detectFixProgress(
|
|
67
|
+
{ newSha: PRIOR_HEAD, pushed: false, resolvedWithoutCommit: true, summary: 'resolved threads' },
|
|
68
|
+
PRIOR_HEAD,
|
|
69
|
+
hadThreadBearingFinding,
|
|
70
|
+
);
|
|
71
|
+
assert.equal(fixProgress.progressed, true);
|
|
72
|
+
assert.equal(fixProgress.newSha, PRIOR_HEAD);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a pushed fix that moved HEAD still progresses regardless of thread-bearing findings', () => {
|
|
76
|
+
const fixProgress = detectFixProgress(
|
|
77
|
+
{ newSha: MOVED_HEAD, pushed: true, resolvedWithoutCommit: false, summary: 'committed' },
|
|
78
|
+
PRIOR_HEAD,
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
assert.equal(fixProgress.progressed, true);
|
|
82
|
+
assert.equal(fixProgress.newSha, MOVED_HEAD);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a dead fix agent never progresses', () => {
|
|
86
|
+
const fixProgress = detectFixProgress(null, PRIOR_HEAD, true);
|
|
87
|
+
assert.equal(fixProgress.progressed, false);
|
|
88
|
+
assert.equal(fixProgress.newSha, PRIOR_HEAD);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the converge call site sets a fix-stalled blocker when an all-null-thread resolvedWithoutCommit round cannot progress', () => {
|
|
92
|
+
const convergeBranch = convergeSource.slice(
|
|
93
|
+
convergeSource.indexOf("const fixResult = await applyFixes(head, findings, 'converge-round')"),
|
|
94
|
+
convergeSource.indexOf("if (!roundOutcome.roundClean)"),
|
|
95
|
+
);
|
|
96
|
+
assert.match(convergeBranch, /collectFindingThreadIds/);
|
|
97
|
+
assert.match(convergeBranch, /fix stalled/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the copilot call site sets a fix-stalled blocker when an all-null-thread resolvedWithoutCommit round cannot progress', () => {
|
|
101
|
+
const copilotBranch = convergeSource.slice(
|
|
102
|
+
convergeSource.indexOf("const fixResult = await applyFixes(head, copilotOutcome.findings, 'copilot')"),
|
|
103
|
+
convergeSource.indexOf("phase = 'CONVERGE'\n continue\n }\n phase = 'FINALIZE'"),
|
|
104
|
+
);
|
|
105
|
+
assert.match(copilotBranch, /collectFindingThreadIds/);
|
|
106
|
+
assert.match(copilotBranch, /fix stalled/i);
|
|
107
|
+
});
|