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
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autoconverge convergence-loop workflow driver.
|
|
3
|
+
*
|
|
4
|
+
* SINGLE-FILE CONTRACT — keep this file self-contained. The Workflow runtime
|
|
5
|
+
* wraps this body in a function (so top-level await and return work) and rejects
|
|
6
|
+
* static import statements, and `export const meta` must be the first statement.
|
|
7
|
+
* Every decision and prompt helper lives here as a local function; a sibling
|
|
8
|
+
* import makes the workflow unlaunchable, so keep helpers inline even when file
|
|
9
|
+
* length suggests a split.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
name: 'autoconverge',
|
|
14
|
+
description: 'Drive one draft PR to convergence in a single autonomous run: parallel Bugbot + code-review + bug-audit lenses on the same HEAD each round, dedup findings, fix once, re-verify, then a Copilot wait-gate and a final convergence check that marks the PR ready.',
|
|
15
|
+
whenToUse: 'Launched by the /autoconverge skill after it resolves PR scope, enters a worktree, and grants project .claude permissions.',
|
|
16
|
+
phases: [
|
|
17
|
+
{ title: 'Converge', detail: 'Bugbot + code-review + bug-audit in parallel each round; one clean-coder applies all fixes; loop until all three are clean on a stable HEAD' },
|
|
18
|
+
{ title: 'Copilot gate', detail: 'Request Copilot review and poll up to three times; route findings back into Converge' },
|
|
19
|
+
{ title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CONFIG = {
|
|
24
|
+
maxIterations: 20,
|
|
25
|
+
copilotMaxPolls: 3,
|
|
26
|
+
sharedScripts: '$HOME/.claude/skills/pr-converge/scripts',
|
|
27
|
+
prLoopScripts: '$HOME/.claude/_shared/pr-loop/scripts',
|
|
28
|
+
bugteamRubric: '$HOME/.claude/skills/bugteam/reference/audit-contract.md',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LENS_SCHEMA = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
properties: {
|
|
35
|
+
sha: { type: 'string', description: 'PR HEAD SHA this lens evaluated' },
|
|
36
|
+
clean: { type: 'boolean', description: 'true when this lens found no findings on sha' },
|
|
37
|
+
down: { type: 'boolean', description: 'true when the reviewer is opted out or unreachable and is bypassed' },
|
|
38
|
+
findings: {
|
|
39
|
+
type: 'array',
|
|
40
|
+
items: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
properties: {
|
|
44
|
+
file: { type: 'string' },
|
|
45
|
+
line: { type: 'integer' },
|
|
46
|
+
severity: { type: 'string', enum: ['P0', 'P1', 'P2'] },
|
|
47
|
+
title: { type: 'string' },
|
|
48
|
+
detail: { type: 'string' },
|
|
49
|
+
replyToCommentId: { type: ['integer', 'null'], description: 'GitHub review comment id to reply to and resolve, or null when the finding has no thread' },
|
|
50
|
+
},
|
|
51
|
+
required: ['file', 'line', 'severity', 'title', 'detail', 'replyToCommentId'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ['sha', 'clean', 'down', 'findings'],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const COPILOT_SCHEMA = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: {
|
|
62
|
+
sha: { type: 'string' },
|
|
63
|
+
clean: { type: 'boolean' },
|
|
64
|
+
findings: LENS_SCHEMA.properties.findings,
|
|
65
|
+
blocker: { type: ['string', 'null'], description: 'non-null when Copilot never surfaced a review after the poll cap' },
|
|
66
|
+
},
|
|
67
|
+
required: ['sha', 'clean', 'findings', 'blocker'],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HEAD_SCHEMA = {
|
|
71
|
+
type: 'object',
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
properties: { sha: { type: 'string' } },
|
|
74
|
+
required: ['sha'],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FIX_SCHEMA = {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
properties: {
|
|
81
|
+
newSha: { type: 'string', description: 'HEAD SHA after the fix commit was pushed, or the unchanged HEAD when no commit was needed' },
|
|
82
|
+
pushed: { type: 'boolean' },
|
|
83
|
+
resolvedWithoutCommit: { type: 'boolean', description: 'true when every finding was already addressed so no code change was made, yet each finding thread was still resolved — the round advances rather than stalling' },
|
|
84
|
+
summary: { type: 'string' },
|
|
85
|
+
},
|
|
86
|
+
required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary'],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CONVERGENCE_SCHEMA = {
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
properties: {
|
|
93
|
+
pass: { type: 'boolean', description: 'true only when check_convergence.py exits 0' },
|
|
94
|
+
failures: { type: 'array', items: { type: 'string' }, description: 'FAIL lines from check_convergence.py when pass is false' },
|
|
95
|
+
},
|
|
96
|
+
required: ['pass', 'failures'],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const READY_SCHEMA = {
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
properties: {
|
|
103
|
+
ready: { type: 'boolean', description: 'true only when isDraft is confirmed false after gh pr ready' },
|
|
104
|
+
},
|
|
105
|
+
required: ['ready'],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SEVERITY_RANK = { P0: 0, P1: 1, P2: 2 }
|
|
109
|
+
const SHA_COMPARISON_PREFIX_LENGTH = 7
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Dedup findings across lenses by file + line + lowercased title, reconciling
|
|
113
|
+
* severity to the most severe duplicate, unioning detail text, and collecting
|
|
114
|
+
* every distinct bot thread id so a collision never strands a review thread or
|
|
115
|
+
* understates severity.
|
|
116
|
+
* @param {Array<object>} allFindings concatenated lens findings
|
|
117
|
+
* @returns {Array<object>} unique findings keyed by file:line:title
|
|
118
|
+
*/
|
|
119
|
+
function dedupeFindings(allFindings) {
|
|
120
|
+
const keptByFingerprint = new Map()
|
|
121
|
+
const orderedFingerprints = []
|
|
122
|
+
for (const eachFinding of allFindings) {
|
|
123
|
+
const fingerprint = `${eachFinding.file}:${eachFinding.line}:${(eachFinding.title || '').toLowerCase()}`
|
|
124
|
+
const alreadyKept = keptByFingerprint.get(fingerprint)
|
|
125
|
+
if (alreadyKept === undefined) {
|
|
126
|
+
keptByFingerprint.set(fingerprint, seedKeptFinding(eachFinding))
|
|
127
|
+
orderedFingerprints.push(fingerprint)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
mergeDuplicateInto(alreadyKept, eachFinding)
|
|
131
|
+
}
|
|
132
|
+
return orderedFingerprints.map((eachFingerprint) => keptByFingerprint.get(eachFingerprint))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the first-seen finding for a fingerprint, seeding a thread-id list that
|
|
137
|
+
* later duplicates extend.
|
|
138
|
+
* @param {object} firstFinding the earliest finding at this fingerprint
|
|
139
|
+
* @returns {object} a kept finding carrying a replyToCommentIds array
|
|
140
|
+
*/
|
|
141
|
+
function seedKeptFinding(firstFinding) {
|
|
142
|
+
const seededThreadIds =
|
|
143
|
+
firstFinding.replyToCommentId == null ? [] : [firstFinding.replyToCommentId]
|
|
144
|
+
return { ...firstFinding, replyToCommentIds: seededThreadIds }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reconcile a dropped duplicate into the kept finding: raise severity to the
|
|
149
|
+
* more severe of the two, union detail text, preserve the earliest thread id,
|
|
150
|
+
* and append every distinct thread id for resolution.
|
|
151
|
+
* @param {object} keptFinding the finding retained for this fingerprint
|
|
152
|
+
* @param {object} droppedFinding the later duplicate being collapsed
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
function mergeDuplicateInto(keptFinding, droppedFinding) {
|
|
156
|
+
if (isMoreSevere(droppedFinding.severity, keptFinding.severity)) {
|
|
157
|
+
keptFinding.severity = droppedFinding.severity
|
|
158
|
+
}
|
|
159
|
+
if (keptFinding.replyToCommentId == null && droppedFinding.replyToCommentId != null) {
|
|
160
|
+
keptFinding.replyToCommentId = droppedFinding.replyToCommentId
|
|
161
|
+
}
|
|
162
|
+
if (droppedFinding.replyToCommentId != null && !keptFinding.replyToCommentIds.includes(droppedFinding.replyToCommentId)) {
|
|
163
|
+
keptFinding.replyToCommentIds.push(droppedFinding.replyToCommentId)
|
|
164
|
+
}
|
|
165
|
+
const droppedDetail = droppedFinding.detail || ''
|
|
166
|
+
const keptDetail = keptFinding.detail || ''
|
|
167
|
+
if (droppedDetail && !keptDetail.includes(droppedDetail)) {
|
|
168
|
+
keptFinding.detail = keptDetail ? `${keptDetail}\n${droppedDetail}` : droppedDetail
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Collect every distinct GitHub review thread id a finding carries, preferring
|
|
174
|
+
* the deduped replyToCommentIds list and falling back to the scalar
|
|
175
|
+
* replyToCommentId for findings that never passed through dedupeFindings.
|
|
176
|
+
* @param {object} finding a single finding
|
|
177
|
+
* @returns {Array<number>} distinct non-null thread ids to reply to and resolve
|
|
178
|
+
*/
|
|
179
|
+
function collectFindingThreadIds(finding) {
|
|
180
|
+
if (Array.isArray(finding.replyToCommentIds)) {
|
|
181
|
+
return finding.replyToCommentIds.filter((eachId) => eachId != null)
|
|
182
|
+
}
|
|
183
|
+
return finding.replyToCommentId == null ? [] : [finding.replyToCommentId]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decide whether a candidate severity outranks the current one (P0 > P1 > P2).
|
|
188
|
+
* @param {string} candidateSeverity the duplicate's severity
|
|
189
|
+
* @param {string} currentSeverity the kept finding's severity
|
|
190
|
+
* @returns {boolean} true when the candidate is strictly more severe
|
|
191
|
+
*/
|
|
192
|
+
function isMoreSevere(candidateSeverity, currentSeverity) {
|
|
193
|
+
const candidateRank = SEVERITY_RANK[candidateSeverity]
|
|
194
|
+
const currentRank = SEVERITY_RANK[currentSeverity]
|
|
195
|
+
if (candidateRank === undefined) return false
|
|
196
|
+
if (currentRank === undefined) return true
|
|
197
|
+
return candidateRank < currentRank
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Decide whether the convergence check should bypass the Bugbot gate this round,
|
|
202
|
+
* recomputed from the current Bugbot lens result rather than latched across the
|
|
203
|
+
* run, so a recovered Bugbot re-arms the gate. A dead lens agent (null/undefined
|
|
204
|
+
* result) produces no Bugbot verdict on this HEAD, so it is treated as down to
|
|
205
|
+
* keep the convergence gate from demanding a verdict that cannot exist.
|
|
206
|
+
* @param {object|null|undefined} bugbotLens the current round's Bugbot lens result
|
|
207
|
+
* @param {boolean} bugbotDisabled true when Bugbot is opted out for the whole run
|
|
208
|
+
* @returns {boolean} true when the Bugbot gate is bypassed for the current HEAD
|
|
209
|
+
*/
|
|
210
|
+
function resolveBugbotDown(bugbotLens, bugbotDisabled) {
|
|
211
|
+
if (bugbotDisabled) return true
|
|
212
|
+
if (bugbotLens == null) return true
|
|
213
|
+
return bugbotLens.down === true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Decide whether a single surviving lens calls the HEAD clean. A lens is clean
|
|
218
|
+
* when it explicitly reports clean:true, or when it is bypassed (down:true) so it
|
|
219
|
+
* has no verdict to withhold. A lens reporting clean:false with no findings — a
|
|
220
|
+
* Bugbot lens awaiting a pending CI verdict, or a reviewer that reports 'not
|
|
221
|
+
* clean' without pinning a file:line — keeps the round in the converge phase.
|
|
222
|
+
* @param {object} lens a surviving LENS_SCHEMA result
|
|
223
|
+
* @returns {boolean} true when this lens does not hold the round back
|
|
224
|
+
*/
|
|
225
|
+
function lensCallsHeadClean(lens) {
|
|
226
|
+
return lens.clean === true || lens.down === true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Decide the outcome of a converge round from its raw parallel lens results:
|
|
231
|
+
* whether every lens agent died (a failed round that must not post a clean
|
|
232
|
+
* artifact), the deduped findings from the surviving lenses, and whether the
|
|
233
|
+
* round is clean. A round is clean only when at least one lens survived, every
|
|
234
|
+
* surviving lens calls the HEAD clean, and the deduped findings are empty — so a
|
|
235
|
+
* clean:false lens with zero findings keeps the round converging rather than
|
|
236
|
+
* advancing to the Copilot gate on a HEAD a lens did not call clean.
|
|
237
|
+
* @param {Array<object|null>} lensResults raw parallel results, null per dead lens
|
|
238
|
+
* @returns {{allLensesDead: boolean, findings: Array<object>, roundClean: boolean}} round outcome
|
|
239
|
+
*/
|
|
240
|
+
function resolveRoundOutcome(lensResults) {
|
|
241
|
+
const liveLenses = lensResults.filter(Boolean)
|
|
242
|
+
const findings = dedupeFindings(liveLenses.flatMap((eachLens) => eachLens.findings || []))
|
|
243
|
+
const allLensesDead = liveLenses.length === 0
|
|
244
|
+
const everyLensClean = liveLenses.every(lensCallsHeadClean)
|
|
245
|
+
const roundClean = !allLensesDead && everyLensClean && findings.length === 0
|
|
246
|
+
return { allLensesDead, findings, roundClean }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Reduce a SHA to a case-folded common prefix so a full 40-char HEAD and an
|
|
251
|
+
* abbreviated SHA reported by a fix agent (git rev-parse --short) for the same
|
|
252
|
+
* commit compare equal. A non-string SHA folds to the empty string.
|
|
253
|
+
* @param {string} sha a full or abbreviated commit SHA
|
|
254
|
+
* @returns {string} the lowercased leading prefix used for comparison
|
|
255
|
+
*/
|
|
256
|
+
function normalizeShaForComparison(sha) {
|
|
257
|
+
if (typeof sha !== 'string') return ''
|
|
258
|
+
return sha.slice(0, SHA_COMPARISON_PREFIX_LENGTH).toLowerCase()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Decide whether a fix lens actually advanced the round: a pushed fix that moved
|
|
263
|
+
* HEAD progressed, and so did an all-stale round whose findings were every one
|
|
264
|
+
* already addressed — the fix lens makes no commit but resolves each thread and
|
|
265
|
+
* reports resolvedWithoutCommit:true, leaving HEAD unchanged on purpose. That
|
|
266
|
+
* unchanged-HEAD resolve counts as progress only when the round carried at least
|
|
267
|
+
* one thread-bearing finding to resolve; an all-null-thread round whose fix
|
|
268
|
+
* reports resolvedWithoutCommit:true moved nothing and bounded nothing — its
|
|
269
|
+
* vacuously-satisfied resolve would otherwise re-converge on the same HEAD until
|
|
270
|
+
* the iteration cap — so it does not progress and surfaces a fix-stalled blocker.
|
|
271
|
+
* A null result, a no-push round that did not resolve every thread, or a SHA
|
|
272
|
+
* equal to the prior HEAD on a case-folded common prefix likewise did not
|
|
273
|
+
* progress. Comparing on a normalized prefix keeps a no-op fix that reports an
|
|
274
|
+
* abbreviated SHA of the unchanged HEAD from masquerading as a moved-HEAD push.
|
|
275
|
+
* @param {object|null} fixResult the FIX_SCHEMA result, or null on agent failure
|
|
276
|
+
* @param {string} priorHead the HEAD the fix was applied against
|
|
277
|
+
* @param {boolean} hadThreadBearingFinding true when at least one finding in the round carried a GitHub thread id
|
|
278
|
+
* @returns {{progressed: boolean, newSha: string}} progress decision and resulting HEAD
|
|
279
|
+
*/
|
|
280
|
+
function detectFixProgress(fixResult, priorHead, hadThreadBearingFinding) {
|
|
281
|
+
if (fixResult == null) return { progressed: false, newSha: priorHead }
|
|
282
|
+
const newSha = fixResult.newSha || priorHead
|
|
283
|
+
if (fixResult.resolvedWithoutCommit === true) {
|
|
284
|
+
return { progressed: hadThreadBearingFinding === true, newSha: priorHead }
|
|
285
|
+
}
|
|
286
|
+
const movedHead = normalizeShaForComparison(newSha) !== normalizeShaForComparison(priorHead)
|
|
287
|
+
const progressed = fixResult.pushed === true && movedHead
|
|
288
|
+
return { progressed, newSha }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decide whether a resolved HEAD SHA is safe to spawn lenses against. A dead
|
|
293
|
+
* resolve-head agent or a malformed result yields a falsy SHA; spawning lenses
|
|
294
|
+
* against it interpolates the literal string 'HEAD undefined' into their prompts
|
|
295
|
+
* and produces a spurious clean verdict on a non-existent commit.
|
|
296
|
+
* @param {string|null|undefined} resolvedHead the SHA from resolveHead()
|
|
297
|
+
* @returns {boolean} true only when the SHA is a non-empty string
|
|
298
|
+
*/
|
|
299
|
+
function isResolvedHeadUsable(resolvedHead) {
|
|
300
|
+
return typeof resolvedHead === 'string' && resolvedHead.length > 0
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Decide whether the mark-ready step actually cleared the draft state. The run
|
|
305
|
+
* reports converged only when the mark-ready agent confirms ready:true; a dead
|
|
306
|
+
* agent (null result) or a ready:false report means `gh pr ready` did not land
|
|
307
|
+
* (auth or token drift, a transient gh failure), so the PR is still a draft and
|
|
308
|
+
* the run must surface a blocker rather than claim success.
|
|
309
|
+
* @param {object|null|undefined} readyResult the READY_SCHEMA result, or null on agent failure
|
|
310
|
+
* @returns {{converged: boolean, blocker: string|null}} convergence decision
|
|
311
|
+
*/
|
|
312
|
+
function classifyReadyOutcome(readyResult) {
|
|
313
|
+
if (readyResult != null && readyResult.ready === true) {
|
|
314
|
+
return { converged: true, blocker: null }
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
converged: false,
|
|
318
|
+
blocker: 'mark-ready step did not confirm the PR left draft state (gh pr ready failed or the agent died)',
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Classify a Copilot gate result into the loop's next action. A dead gate agent
|
|
324
|
+
* (null result) is a retry rather than an approval, mirroring the converge
|
|
325
|
+
* lenses' dead-agent convention so a failed gate is never mistaken for a clean
|
|
326
|
+
* Copilot review. A non-null blocker ends the run; findings route to a fix step.
|
|
327
|
+
* The gate approves only when it explicitly reports clean:true with no findings —
|
|
328
|
+
* a clean:false result with zero findings is an unreliable or malformed gate
|
|
329
|
+
* response and retries rather than advancing to Finalize, so a PR never goes
|
|
330
|
+
* ready on a HEAD Copilot did not call clean.
|
|
331
|
+
* @param {object|null|undefined} copilot the Copilot gate result, or null on agent failure
|
|
332
|
+
* @returns {{kind: string, blocker?: string, findings?: Array<object>}} the next action
|
|
333
|
+
*/
|
|
334
|
+
function classifyCopilotOutcome(copilot) {
|
|
335
|
+
if (copilot == null) return { kind: 'retry' }
|
|
336
|
+
if (copilot.blocker) return { kind: 'blocker', blocker: copilot.blocker }
|
|
337
|
+
if (copilot.findings.length > 0) return { kind: 'fix', findings: copilot.findings }
|
|
338
|
+
if (copilot.clean === true) return { kind: 'approved' }
|
|
339
|
+
return { kind: 'retry' }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Classify a convergence-check result into the loop's next action. A dead check
|
|
344
|
+
* agent (null/undefined result) is a retry rather than a failure: with no FAIL
|
|
345
|
+
* lines to act on, running the convergence repair (which may rebase and
|
|
346
|
+
* force-push) would be a destructive response to a transient agent death. A
|
|
347
|
+
* genuine pass marks the PR ready; a real failure carrying FAIL lines routes to
|
|
348
|
+
* repair; a pass:false report with no failure lines is an unreliable check and
|
|
349
|
+
* retries rather than triggering a repair with nothing concrete to fix.
|
|
350
|
+
* @param {object|null|undefined} convergence the convergence-check result, or null on agent failure
|
|
351
|
+
* @returns {{kind: string, failures?: Array<string>}} the next action
|
|
352
|
+
*/
|
|
353
|
+
function classifyConvergenceOutcome(convergence) {
|
|
354
|
+
if (convergence == null) return { kind: 'retry' }
|
|
355
|
+
if (convergence.pass === true) return { kind: 'ready' }
|
|
356
|
+
const failures = convergence.failures || []
|
|
357
|
+
if (failures.length === 0) return { kind: 'retry' }
|
|
358
|
+
return { kind: 'repair', failures }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Normalize the workflow's raw args global into a run-coordinates object. The
|
|
363
|
+
* Workflow runtime delivers args as a JSON-encoded string, so a string payload
|
|
364
|
+
* is parsed; an object payload passes through unchanged. A non-JSON or empty
|
|
365
|
+
* string yields null rather than throwing, so a malformed payload becomes a
|
|
366
|
+
* structured blocker instead of aborting the run. Reading args.owner off an
|
|
367
|
+
* unparsed string yields undefined and strands every GitHub call on invalid
|
|
368
|
+
* coordinates, so every entry point reads coordinates through this function.
|
|
369
|
+
* @param {string|object} rawArgs the workflow args global (JSON string or object)
|
|
370
|
+
* @returns {object|null} the run coordinates, or null when a string payload fails to parse
|
|
371
|
+
*/
|
|
372
|
+
function normalizeRunInput(rawArgs) {
|
|
373
|
+
if (typeof rawArgs !== 'string') return rawArgs
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(rawArgs)
|
|
376
|
+
} catch {
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Validate the normalized run input into either usable coordinates or a
|
|
383
|
+
* structured blocker. The run cannot build a single GitHub call without owner,
|
|
384
|
+
* repo, and prNumber, so a null payload (failed parse or missing args) or a
|
|
385
|
+
* payload missing any coordinate yields a blocker the top-level run reports as
|
|
386
|
+
* {converged:false, blocker} rather than throwing on input.owner at startup.
|
|
387
|
+
* @param {string|object} rawArgs the workflow args global (JSON string or object)
|
|
388
|
+
* @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
|
|
389
|
+
*/
|
|
390
|
+
function classifyRunInput(rawArgs) {
|
|
391
|
+
const candidate = normalizeRunInput(rawArgs)
|
|
392
|
+
const hasUsableCoordinates =
|
|
393
|
+
candidate != null && candidate.owner && candidate.repo && candidate.prNumber
|
|
394
|
+
if (hasUsableCoordinates) return { input: candidate, blocker: null }
|
|
395
|
+
return {
|
|
396
|
+
input: null,
|
|
397
|
+
blocker:
|
|
398
|
+
'invalid run coordinates: the workflow args did not parse into an object carrying owner, repo, and prNumber',
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const runInput = classifyRunInput(args)
|
|
403
|
+
if (runInput.blocker) {
|
|
404
|
+
return { converged: false, rounds: 0, finalSha: null, blocker: runInput.blocker }
|
|
405
|
+
}
|
|
406
|
+
const input = runInput.input
|
|
407
|
+
const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNumber} (https://github.com/${input.owner}/${input.repo}/pull/${input.prNumber})`
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the current PR HEAD SHA from GitHub.
|
|
411
|
+
* @returns {Promise<string>} the 40-char HEAD SHA
|
|
412
|
+
*/
|
|
413
|
+
async function resolveHead() {
|
|
414
|
+
const head = await agent(
|
|
415
|
+
`Print the current HEAD SHA of ${prCoordinates}. Run exactly:\n` +
|
|
416
|
+
`gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .head.sha\n` +
|
|
417
|
+
`Return the full 40-character SHA in the sha field. Do not modify any files.`,
|
|
418
|
+
{ label: 'resolve-head', phase: 'Converge', schema: HEAD_SCHEMA, agentType: 'Explore' },
|
|
419
|
+
)
|
|
420
|
+
return head?.sha
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Fetch origin/main once per round before the parallel lenses spawn. The
|
|
425
|
+
* code-review and bug-audit lenses both diff against origin/main; running their
|
|
426
|
+
* own git fetch in parallel contends on the worktree .git lock and fails
|
|
427
|
+
* intermittently, so a single serial fetch here keeps the ref current and the
|
|
428
|
+
* parallel lenses do no git fetch of their own.
|
|
429
|
+
* @returns {Promise<string>} agent transcript (unused)
|
|
430
|
+
*/
|
|
431
|
+
function prefetchMainForRound() {
|
|
432
|
+
return agent(
|
|
433
|
+
`Refresh the base ref for ${prCoordinates} so the parallel review lenses can diff against an up-to-date origin/main without each running its own fetch. Run exactly:\n` +
|
|
434
|
+
`git fetch origin main\n` +
|
|
435
|
+
`Do not edit, commit, push, rebase, or modify any files — fetch only.`,
|
|
436
|
+
{ label: 'prefetch-main', phase: 'Converge', agentType: 'Explore' },
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Bugbot lens: ensure Cursor Bugbot has rendered a verdict on the given HEAD,
|
|
442
|
+
* triggering and polling its CI check run when needed, and return its findings.
|
|
443
|
+
* @param {string} head PR HEAD SHA to evaluate
|
|
444
|
+
* @returns {Promise<object>} LENS_SCHEMA result
|
|
445
|
+
*/
|
|
446
|
+
function runBugbotLens(head) {
|
|
447
|
+
if (input.bugbotDisabled) {
|
|
448
|
+
return Promise.resolve({ sha: head, clean: true, down: true, findings: [] })
|
|
449
|
+
}
|
|
450
|
+
return agent(
|
|
451
|
+
`You are the Cursor Bugbot lens for ${prCoordinates}, HEAD ${head}. Cursor Bugbot participates this run.\n\n` +
|
|
452
|
+
`Goal: return Bugbot's verdict on HEAD ${head}. Do not edit code, commit, or push. You may post the literal trigger comment described below.\n\n` +
|
|
453
|
+
`Procedure (use the existing scripts; each step below shows the exact flags that script accepts):\n` +
|
|
454
|
+
`1. Opt-out: python "${CONFIG.prLoopScripts}/reviews_disabled.py" --reviewer bugbot. Exit 0 means disabled -> return {sha, clean:true, down:true, findings:[]}.\n` +
|
|
455
|
+
`2. Silent pass: python "${CONFIG.sharedScripts}/check_bugbot_ci.py" --owner ${input.owner} --repo ${input.repo} --sha ${head} --check-clean. Exit 0 means the CI check completed clean with no review -> return clean with no findings.\n` +
|
|
456
|
+
`3. Fetch any Bugbot review + inline comments on HEAD ${head} with gh api (Bugbot's GitHub login contains "cursor", case-insensitive). Use --paginate --slurp piped to external jq:\n` +
|
|
457
|
+
` gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/reviews" --paginate --slurp (top-level review body + state)\n` +
|
|
458
|
+
` gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp (inline review comments + their ids)\n` +
|
|
459
|
+
` Only count entries whose commit_id starts with ${head}.\n` +
|
|
460
|
+
` - If findings exist on HEAD -> return them (each with its inline comment id in replyToCommentId when present, else null).\n` +
|
|
461
|
+
` - If a clean review exists on HEAD -> return clean.\n` +
|
|
462
|
+
`4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (delay each iteration with "sleep 60", or the PowerShell alternative "Start-Sleep -Seconds 60") for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", delay 8 seconds with "sleep 8" (PowerShell alternative "Start-Sleep -Seconds 8"), then poll as above.\n` +
|
|
463
|
+
`5. If after the full poll budget Bugbot has neither a check run nor a review on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} (treat as down).\n\n` +
|
|
464
|
+
`Scope is the whole PR; you are only reading Bugbot's own output here. Return strictly the schema.`,
|
|
465
|
+
{ label: 'lens:bugbot', phase: 'Converge', schema: LENS_SCHEMA },
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Code-review lens: a full-diff /code-review-style pass that reports findings
|
|
471
|
+
* without applying any fix.
|
|
472
|
+
* @param {string} head PR HEAD SHA to evaluate
|
|
473
|
+
* @returns {Promise<object>} LENS_SCHEMA result
|
|
474
|
+
*/
|
|
475
|
+
function runCodeReviewLens(head) {
|
|
476
|
+
return agent(
|
|
477
|
+
`You are the code-review lens for ${prCoordinates}, HEAD ${head}.\n\n` +
|
|
478
|
+
`Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT delta-scope to recent commits or to a single file. The workflow already fetched origin/main this round, so do NOT run git fetch; run git diff --name-only origin/main...HEAD to enumerate the changed files, then review the complete diff of each.\n\n` +
|
|
479
|
+
`Apply correctness-focused review: real bugs, broken logic, incorrect error handling, data-loss or security risks, contract mismatches, and reuse/simplification problems. Report only defensible findings with concrete file:line evidence.\n\n` +
|
|
480
|
+
`Do NOT edit, commit, or push — reporting only. Return strictly the schema: clean=true with empty findings when the diff is sound, otherwise one entry per finding (severity P0/P1/P2, replyToCommentId=null since these are not yet GitHub threads). Set sha=${'`'}${head}${'`'}, down=false.`,
|
|
481
|
+
{ label: 'lens:code-review', phase: 'Converge', schema: LENS_SCHEMA, agentType: 'code-quality-agent' },
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Bug-audit lens: the bugteam-class second-opinion audit over the full diff,
|
|
487
|
+
* applying the shared A–P audit rubric. Reports findings without fixing.
|
|
488
|
+
* @param {string} head PR HEAD SHA to evaluate
|
|
489
|
+
* @returns {Promise<object>} LENS_SCHEMA result
|
|
490
|
+
*/
|
|
491
|
+
function runAuditLens(head) {
|
|
492
|
+
return agent(
|
|
493
|
+
`You are the second-opinion bug-audit lens for ${prCoordinates}, HEAD ${head}.\n\n` +
|
|
494
|
+
`Read the audit rubric at ${CONFIG.bugteamRubric} and apply its categories (A through P) against the FULL origin/main...HEAD diff — every file the PR touches, never a delta cut. The workflow already fetched origin/main this round, so do NOT run git fetch; run git diff --name-only origin/main...HEAD first to enumerate scope.\n\n` +
|
|
495
|
+
`This is a clean-room audit: assume nothing from other lenses. Report only findings backed by concrete file:line evidence. Do NOT edit, commit, or push.\n\n` +
|
|
496
|
+
`Return strictly the schema: clean=true with empty findings when the diff passes every category, otherwise one entry per finding (severity P0/P1/P2, replyToCommentId=null). Set sha=${'`'}${head}${'`'}, down=false.`,
|
|
497
|
+
{ label: 'lens:bug-audit', phase: 'Converge', schema: LENS_SCHEMA, agentType: 'code-quality-agent' },
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Fix lens: one clean-coder applies every finding in a single TDD commit,
|
|
503
|
+
* pushes, then replies to and resolves any real GitHub review threads.
|
|
504
|
+
* @param {string} head PR HEAD SHA the findings were raised against
|
|
505
|
+
* @param {Array<object>} findings deduped findings across all lenses
|
|
506
|
+
* @param {string} sourceLabel short description of where the findings came from
|
|
507
|
+
* @returns {Promise<object>} FIX_SCHEMA result
|
|
508
|
+
*/
|
|
509
|
+
function applyFixes(head, findings, sourceLabel) {
|
|
510
|
+
const findingsBlock = findings
|
|
511
|
+
.map((each, position) => {
|
|
512
|
+
const eachThreadIds = collectFindingThreadIds(each)
|
|
513
|
+
const threadNote = eachThreadIds.length
|
|
514
|
+
? `\n (GitHub review comment ids: ${eachThreadIds.join(', ')})`
|
|
515
|
+
: ''
|
|
516
|
+
return `${position + 1}. [${each.severity}] ${each.file}:${each.line} — ${each.title}\n ${each.detail}${threadNote}`
|
|
517
|
+
})
|
|
518
|
+
.join('\n')
|
|
519
|
+
const threadIds = findings
|
|
520
|
+
.flatMap((each) => collectFindingThreadIds(each))
|
|
521
|
+
.filter((each) => typeof each === 'number')
|
|
522
|
+
return agent(
|
|
523
|
+
`You are fixing ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}.\n\n` +
|
|
524
|
+
`Findings:\n${findingsBlock}\n\n` +
|
|
525
|
+
`Rules:\n` +
|
|
526
|
+
`- Confirm the working tree is on the PR branch at HEAD ${head} with no unrelated edits before you start.\n` +
|
|
527
|
+
`- Fix every finding test-first (failing test, then minimum code to pass) per CODE_RULES. Verify each concern against current code; a finding whose concern no longer applies needs no code change but still needs its thread resolved.\n` +
|
|
528
|
+
`- Make ONE commit for all fixes, then push to the PR branch.\n` +
|
|
529
|
+
`- For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply with python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "<what changed>". Then resolve the PR review thread by thread node id (PRRT_...): look up the thread id for that comment via GraphQL (match on comment databaseId == <id> in the pull request's reviewThreads), then call the github MCP pull_request_review_write method=resolve_thread with threadId=<PRRT_...> (not the numeric comment id), or run the resolveReviewThread GraphQL mutation with the same threadId.\n` +
|
|
530
|
+
`- Findings with replyToCommentId null are in-memory audit findings: fix them, no reply needed.\n\n` +
|
|
531
|
+
`Return values:\n` +
|
|
532
|
+
`- When you commit and push a fix: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false.\n` +
|
|
533
|
+
`- When every finding was already addressed so no code change is needed — yet you still resolved each GitHub review thread above: newSha=${head} (the unchanged HEAD), pushed=false, resolvedWithoutCommit=true. Only set this when every thread that carries a comment id is resolved; otherwise the round is treated as stalled.\n` +
|
|
534
|
+
`Always include a one-line summary.`,
|
|
535
|
+
{ label: `fix:${sourceLabel}`, phase: 'Converge', schema: FIX_SCHEMA, agentType: 'clean-coder' },
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Post the terminal CLEAN bugteam audit artifact so check_convergence.py sees
|
|
541
|
+
* a clean bugteam review on the converged HEAD.
|
|
542
|
+
* @param {string} head converged PR HEAD SHA
|
|
543
|
+
* @returns {Promise<string>} agent transcript (unused)
|
|
544
|
+
*/
|
|
545
|
+
function postCleanAudit(head) {
|
|
546
|
+
return agent(
|
|
547
|
+
`Post a CLEAN bugteam audit review on ${prCoordinates} at commit ${head}. All review lenses are clean on this HEAD.\n\n` +
|
|
548
|
+
`Write an empty findings file: create a temp file containing exactly [] (an empty JSON array). Then run:\n` +
|
|
549
|
+
`python "${CONFIG.prLoopScripts}/post_audit_thread.py" --skill bugteam --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --commit ${head} --state CLEAN --findings-json <temp-file>\n` +
|
|
550
|
+
`Run the script with --help first if any flag name differs. This posts the APPROVE review body that check_convergence.py reads for the bugteam gate. Do not edit code, commit, or push.`,
|
|
551
|
+
{ label: 'post-clean-audit', phase: 'Converge', agentType: 'general-purpose' },
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Copilot gate: request a Copilot review on HEAD and poll until it lands or the
|
|
557
|
+
* poll cap is hit; return Copilot's findings or a blocker.
|
|
558
|
+
* @param {string} head converged PR HEAD SHA
|
|
559
|
+
* @returns {Promise<object>} COPILOT_SCHEMA result
|
|
560
|
+
*/
|
|
561
|
+
function runCopilotGate(head) {
|
|
562
|
+
return agent(
|
|
563
|
+
`You are the Copilot gate for ${prCoordinates}, HEAD ${head}. Do not edit code, commit, or push.\n\n` +
|
|
564
|
+
`1. Skip a duplicate request: python "${CONFIG.sharedScripts}/check_pending_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --user copilot. Exit 0 means a request is already pending; otherwise request one:\n` +
|
|
565
|
+
` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
|
|
566
|
+
`2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (delay each attempt with "sleep 360", or the PowerShell alternative "Start-Sleep -Seconds 360"). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
|
|
567
|
+
` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, findings:[], blocker:null}.\n` +
|
|
568
|
+
` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId), clean:false, blocker:null.\n` +
|
|
569
|
+
` - No review after ${CONFIG.copilotMaxPolls} attempts -> return {sha:${'`'}${head}${'`'}, clean:false, findings:[], blocker:"Copilot did not surface a review on HEAD after ${CONFIG.copilotMaxPolls} polls"}.\n\n` +
|
|
570
|
+
`Return strictly the schema.`,
|
|
571
|
+
{ label: 'copilot-gate', phase: 'Copilot gate', schema: COPILOT_SCHEMA },
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Run the authoritative convergence gate.
|
|
577
|
+
* @param {boolean} bugbotDown pass --bugbot-down when Bugbot is opted out or proved unreachable this run
|
|
578
|
+
* @returns {Promise<object>} CONVERGENCE_SCHEMA result
|
|
579
|
+
*/
|
|
580
|
+
function checkConvergence(bugbotDown) {
|
|
581
|
+
const bugbotDownFlag = bugbotDown ? ' --bugbot-down' : ''
|
|
582
|
+
return agent(
|
|
583
|
+
`Run the convergence gate for ${prCoordinates} and report the result. Do not edit code.\n\n` +
|
|
584
|
+
`Run: python "${CONFIG.sharedScripts}/check_convergence.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}${bugbotDownFlag}\n\n` +
|
|
585
|
+
`Exit 0 -> every gate passed: return {pass:true, failures:[]}.\n` +
|
|
586
|
+
`Exit 1 -> return {pass:false, failures:[<each printed FAIL line verbatim>]}.\n` +
|
|
587
|
+
`Exit 2 -> retry once; if it still errors, return {pass:false, failures:["check_convergence gh error"]}.`,
|
|
588
|
+
{ label: 'check-convergence', phase: 'Finalize', schema: CONVERGENCE_SCHEMA, agentType: 'Explore' },
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Mark the PR ready for review (draft=false) and confirm the transition landed.
|
|
594
|
+
* @param {string} head converged PR HEAD SHA
|
|
595
|
+
* @returns {Promise<object>} READY_SCHEMA result
|
|
596
|
+
*/
|
|
597
|
+
function markReady(head) {
|
|
598
|
+
return agent(
|
|
599
|
+
`All convergence gates pass for ${prCoordinates} on HEAD ${head}. Mark the PR ready, then confirm it left draft state. Do not edit code.\n\n` +
|
|
600
|
+
`1. Run: gh pr ready ${input.prNumber} --repo ${input.owner}/${input.repo}\n` +
|
|
601
|
+
`2. Re-query the draft state: gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .draft\n` +
|
|
602
|
+
`Return {ready:true} only when step 2 prints false (the PR is no longer a draft). If step 1 errors or step 2 still prints true, return {ready:false}.`,
|
|
603
|
+
{ label: 'mark-ready', phase: 'Finalize', schema: READY_SCHEMA, agentType: 'general-purpose' },
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Address the gates a convergence check reported as failing, then hand control
|
|
609
|
+
* back to the converge phase. Resolves lingering bot threads and rebases when
|
|
610
|
+
* the PR is not mergeable.
|
|
611
|
+
* @param {string} head current PR HEAD SHA
|
|
612
|
+
* @param {Array<string>} failures FAIL lines from the convergence check
|
|
613
|
+
* @returns {Promise<object>} FIX_SCHEMA result
|
|
614
|
+
*/
|
|
615
|
+
function repairConvergence(head, failures) {
|
|
616
|
+
const failureBlock = failures.length
|
|
617
|
+
? failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
|
|
618
|
+
: 'none reported'
|
|
619
|
+
return agent(
|
|
620
|
+
`The convergence check for ${prCoordinates} failed these gates on HEAD ${head}:\n${failureBlock}\n\n` +
|
|
621
|
+
`Address only the failing gates:\n` +
|
|
622
|
+
`- Unresolved bot review threads: fetch the threads where isResolved is false (gh api graphql, or the github MCP pull_request_read get_review_comments), then keep only the bot-authored ones — a thread whose root comment author login contains "cursor", "claude", or "copilot" (case-insensitive substring). Explicitly skip every human reviewer thread; the convergence gate counts only unresolved bot threads, so touching a human thread is out of scope. For each bot thread, verify the concern against current code; if it still applies, fix it test-first; either way post an inline reply and resolve the thread.\n` +
|
|
623
|
+
`- PR not mergeable: rebase onto origin/main and force-push (git fetch origin main; git rebase origin/main; resolve conflicts; git push --force-with-lease).\n` +
|
|
624
|
+
`- A dirty bot review or a still-pending requested reviewer: leave it; the next round re-runs that reviewer.\n` +
|
|
625
|
+
`Make at most one commit for any code fix. Return the HEAD SHA after any push in newSha (the unchanged ${head} when nothing was pushed), pushed true/false, resolvedWithoutCommit=false (this gate already accepts an unchanged HEAD), and a one-line summary.`,
|
|
626
|
+
{ label: 'repair-convergence', phase: 'Finalize', schema: FIX_SCHEMA, agentType: 'clean-coder' },
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let phase = 'CONVERGE'
|
|
631
|
+
let head = null
|
|
632
|
+
let rounds = 0
|
|
633
|
+
let iterations = 0
|
|
634
|
+
let blocker = null
|
|
635
|
+
let bugbotDown = input.bugbotDisabled || false
|
|
636
|
+
|
|
637
|
+
while (iterations < CONFIG.maxIterations) {
|
|
638
|
+
iterations += 1
|
|
639
|
+
if (phase === 'CONVERGE') {
|
|
640
|
+
rounds += 1
|
|
641
|
+
head = await resolveHead()
|
|
642
|
+
if (!isResolvedHeadUsable(head)) {
|
|
643
|
+
log(`Round ${rounds}: resolve-head agent returned no SHA — retrying without spawning lenses`)
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
await prefetchMainForRound()
|
|
647
|
+
log(`Round ${rounds}: parallel Bugbot + code-review + bug-audit on ${head?.slice(0, 7)}`)
|
|
648
|
+
const lenses = await parallel([
|
|
649
|
+
() => runBugbotLens(head),
|
|
650
|
+
() => runCodeReviewLens(head),
|
|
651
|
+
() => runAuditLens(head),
|
|
652
|
+
])
|
|
653
|
+
bugbotDown = resolveBugbotDown(lenses[0], input.bugbotDisabled || false)
|
|
654
|
+
const roundOutcome = resolveRoundOutcome(lenses)
|
|
655
|
+
if (roundOutcome.allLensesDead) {
|
|
656
|
+
log(`Round ${rounds}: every lens agent died — retrying without posting a clean artifact`)
|
|
657
|
+
continue
|
|
658
|
+
}
|
|
659
|
+
const findings = roundOutcome.findings
|
|
660
|
+
if (findings.length > 0) {
|
|
661
|
+
log(`Round ${rounds}: ${findings.length} finding(s) — applying fixes`)
|
|
662
|
+
const fixResult = await applyFixes(head, findings, 'converge-round')
|
|
663
|
+
const hadThreadBearingFinding = findings.some((each) => collectFindingThreadIds(each).length > 0)
|
|
664
|
+
const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
|
|
665
|
+
if (!fixProgress.progressed) {
|
|
666
|
+
blocker = fixResult?.resolvedWithoutCommit === true && !hadThreadBearingFinding
|
|
667
|
+
? `fix stalled: converge round raised ${findings.length} in-memory finding(s) with no GitHub thread, the fix judged them all stale (resolvedWithoutCommit) and moved no code on HEAD ${head} — re-raising would loop to the iteration cap`
|
|
668
|
+
: `fix lens landed no push for ${findings.length} finding(s) on HEAD ${head}`
|
|
669
|
+
break
|
|
670
|
+
}
|
|
671
|
+
head = fixProgress.newSha
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
if (!roundOutcome.roundClean) {
|
|
675
|
+
log(`Round ${rounds}: a lens reported not-clean with no findings on ${head?.slice(0, 7)} — re-converging without a clean artifact`)
|
|
676
|
+
continue
|
|
677
|
+
}
|
|
678
|
+
log(`Round ${rounds}: all lenses clean on ${head?.slice(0, 7)} — posting clean audit artifact`)
|
|
679
|
+
await postCleanAudit(head)
|
|
680
|
+
phase = 'COPILOT'
|
|
681
|
+
continue
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (phase === 'COPILOT') {
|
|
685
|
+
const copilot = await runCopilotGate(head)
|
|
686
|
+
const copilotOutcome = classifyCopilotOutcome(copilot)
|
|
687
|
+
if (copilotOutcome.kind === 'retry') {
|
|
688
|
+
log('Copilot gate agent died or returned an unreliable not-clean result with no findings — re-running the gate on the same HEAD')
|
|
689
|
+
continue
|
|
690
|
+
}
|
|
691
|
+
if (copilotOutcome.kind === 'blocker') {
|
|
692
|
+
blocker = copilotOutcome.blocker
|
|
693
|
+
break
|
|
694
|
+
}
|
|
695
|
+
if (copilotOutcome.kind === 'fix') {
|
|
696
|
+
log(`Copilot raised ${copilotOutcome.findings.length} finding(s) — fixing and re-converging`)
|
|
697
|
+
const fixResult = await applyFixes(head, copilotOutcome.findings, 'copilot')
|
|
698
|
+
const hadThreadBearingFinding = copilotOutcome.findings.some((each) => collectFindingThreadIds(each).length > 0)
|
|
699
|
+
const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
|
|
700
|
+
if (!fixProgress.progressed) {
|
|
701
|
+
blocker = fixResult?.resolvedWithoutCommit === true && !hadThreadBearingFinding
|
|
702
|
+
? `fix stalled: copilot round raised ${copilotOutcome.findings.length} in-memory finding(s) with no GitHub thread, the fix judged them all stale (resolvedWithoutCommit) and moved no code on HEAD ${head} — re-raising would loop to the iteration cap`
|
|
703
|
+
: `copilot fix lens landed no push for ${copilotOutcome.findings.length} finding(s) on HEAD ${head}`
|
|
704
|
+
break
|
|
705
|
+
}
|
|
706
|
+
head = fixProgress.newSha
|
|
707
|
+
phase = 'CONVERGE'
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
phase = 'FINALIZE'
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (phase === 'FINALIZE') {
|
|
715
|
+
const convergence = await checkConvergence(bugbotDown)
|
|
716
|
+
const convergenceOutcome = classifyConvergenceOutcome(convergence)
|
|
717
|
+
if (convergenceOutcome.kind === 'retry') {
|
|
718
|
+
log('Convergence check agent died or returned no FAIL lines — re-running the check on the same HEAD')
|
|
719
|
+
continue
|
|
720
|
+
}
|
|
721
|
+
if (convergenceOutcome.kind === 'ready') {
|
|
722
|
+
const readyResult = await markReady(head)
|
|
723
|
+
const readyOutcome = classifyReadyOutcome(readyResult)
|
|
724
|
+
if (readyOutcome.converged) {
|
|
725
|
+
return { converged: true, rounds, finalSha: head, blocker: null }
|
|
726
|
+
}
|
|
727
|
+
blocker = readyOutcome.blocker
|
|
728
|
+
break
|
|
729
|
+
}
|
|
730
|
+
log(`Convergence check failed: ${convergenceOutcome.failures.join('; ')} — repairing then re-converging`)
|
|
731
|
+
await repairConvergence(head, convergenceOutcome.failures)
|
|
732
|
+
phase = 'CONVERGE'
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
converged: false,
|
|
739
|
+
rounds,
|
|
740
|
+
finalSha: head,
|
|
741
|
+
blocker: blocker || `iteration cap reached (${CONFIG.maxIterations})`,
|
|
742
|
+
}
|