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.
@@ -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
+ }