claude-dev-env 1.58.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +100 -27
  10. package/bin/install.test.mjs +133 -1
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  21. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  22. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  24. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  25. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  26. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  28. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  29. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  30. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  31. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  32. package/hooks/hooks.json +15 -0
  33. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  34. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  35. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  36. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  37. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  38. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  39. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  40. package/package.json +1 -1
  41. package/rules/docstring-prose-matches-implementation.md +43 -0
  42. package/rules/hook-prose-matches-detector.md +26 -0
  43. package/rules/no-inline-destructive-literals.md +11 -0
  44. package/rules/workflow-substitution-slots.md +7 -0
  45. package/skills/autoconverge/SKILL.md +13 -2
  46. package/skills/autoconverge/reference/convergence.md +7 -3
  47. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  49. package/skills/autoconverge/workflow/converge.mjs +106 -36
  50. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  51. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  52. package/skills/update/SKILL.md +37 -5
@@ -15,7 +15,7 @@ export const meta = {
15
15
  whenToUse: 'Launched by the /autoconverge skill after it resolves PR scope, enters a worktree, and grants project .claude permissions.',
16
16
  phases: [
17
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' },
18
+ { title: 'Copilot gate', detail: 'Request Copilot review and poll up to three times; route findings back into Converge; when Copilot is down or out of quota, log a notice and mark the PR ready with the gate bypassed' },
19
19
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
20
20
  ],
21
21
  }
@@ -28,6 +28,24 @@ const CONFIG = {
28
28
  bugteamRubric: '$HOME/.claude/skills/bugteam/reference/audit-contract.md',
29
29
  }
30
30
 
31
+ const HEADLESS_SAFETY_PREAMBLE =
32
+ 'HEADLESS RUN — you run unattended: no human can answer a permission or confirmation prompt, and any such prompt stalls the entire convergence run. The destructive_command_blocker hook matches dangerous patterns (rm -rf, git reset --hard, dd, mkfs, chmod -R, fork bombs) as raw text anywhere in a Bash command, with no quote-awareness — so a destructive string stalls you even when it is only data you never execute. Therefore:\n' +
33
+ '- Never place a destructive-command literal inside a Bash command — not in echo, not in a heredoc, and not as an argument to python -c, node -e, or awk. To exercise or verify destructive_command_blocker (or any hook) behavior, run the committed test suite, e.g. python -m pytest <test_file>, which passes the command strings as in-language data rather than as a shell command.\n' +
34
+ '- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
35
+ '- Keep scratch files and cleanup inside the OS temp dir or $CLAUDE_JOB_DIR/tmp (auto-allowed as ephemeral); never target a repository or worktree path with rm -rf.\n' +
36
+ '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
37
+
38
+ /**
39
+ * Spawn a workflow agent with the headless-safety preamble prepended to its
40
+ * prompt. Every agent in this convergence loop runs unattended, so each one is
41
+ * routed through here to inherit the same no-confirmation-prompt guidance.
42
+ * @param {string} prompt the agent's role-specific instruction body
43
+ * @param {object} options the agent() options (label, phase, schema, agentType, model)
44
+ * @returns {Promise<*>} the agent() result
45
+ */
46
+ const convergeAgent = (prompt, options) =>
47
+ agent(`${HEADLESS_SAFETY_PREAMBLE}${prompt}`, options)
48
+
31
49
  const LENS_SCHEMA = {
32
50
  type: 'object',
33
51
  additionalProperties: false,
@@ -62,10 +80,10 @@ const COPILOT_SCHEMA = {
62
80
  properties: {
63
81
  sha: { type: 'string' },
64
82
  clean: { type: 'boolean' },
83
+ down: { type: 'boolean', description: 'true when Copilot is down or out of quota — it posts an out-of-usage notice or never surfaces a review on HEAD after the poll cap; the gate is bypassed and the run proceeds to mark-ready' },
65
84
  findings: LENS_SCHEMA.properties.findings,
66
- blocker: { type: ['string', 'null'], description: 'non-null when Copilot never surfaced a review after the poll cap' },
67
85
  },
68
- required: ['sha', 'clean', 'findings', 'blocker'],
86
+ required: ['sha', 'clean', 'down', 'findings'],
69
87
  }
70
88
 
71
89
  const HEAD_SCHEMA = {
@@ -324,22 +342,44 @@ function classifyReadyOutcome(readyResult) {
324
342
  * Classify a Copilot gate result into the loop's next action. A dead gate agent
325
343
  * (null result) is a retry rather than an approval, mirroring the converge
326
344
  * lenses' dead-agent convention so a failed gate is never mistaken for a clean
327
- * Copilot review. A non-null blocker ends the run; findings route to a fix step.
328
- * The gate approves only when it explicitly reports clean:true with no findings
329
- * a clean:false result with zero findings is an unreliable or malformed gate
330
- * response and retries rather than advancing to Finalize, so a PR never goes
331
- * ready on a HEAD Copilot did not call clean.
345
+ * Copilot review. A down result Copilot out of quota or unreachable, so it
346
+ * posts an out-of-usage notice or never surfaces a review after the poll cap
347
+ * routes to the 'down' kind, which logs a notice and proceeds to mark-ready with
348
+ * the Copilot gate bypassed, the same way a down Bugbot lens is bypassed; this is
349
+ * checked first so an outage proceeds rather than waiting on a review that will
350
+ * not arrive. Findings route to a fix step. The gate otherwise approves only when
351
+ * it explicitly reports clean:true with no findings — a clean:false result with
352
+ * zero findings is an unreliable or malformed gate response and retries rather
353
+ * than advancing to Finalize, so a PR never goes ready on a HEAD Copilot did not
354
+ * call clean.
332
355
  * @param {object|null|undefined} copilot the Copilot gate result, or null on agent failure
333
- * @returns {{kind: string, blocker?: string, findings?: Array<object>}} the next action
356
+ * @returns {{kind: string, findings?: Array<object>}} the next action
334
357
  */
335
358
  function classifyCopilotOutcome(copilot) {
336
359
  if (copilot == null) return { kind: 'retry' }
337
- if (copilot.blocker) return { kind: 'blocker', blocker: copilot.blocker }
360
+ if (copilot.down === true) return { kind: 'down' }
338
361
  if (copilot.findings.length > 0) return { kind: 'fix', findings: copilot.findings }
339
362
  if (copilot.clean === true) return { kind: 'approved' }
340
363
  return { kind: 'retry' }
341
364
  }
342
365
 
366
+ /**
367
+ * Decide whether the Copilot review gate is bypassed for this COPILOT pass from
368
+ * the gate outcome, mirroring resolveBugbotDown so the flag is recomputed every
369
+ * pass rather than left sticky. Only a 'down' outcome (Copilot out of quota or
370
+ * unreachable after the poll cap) bypasses the convergence Copilot gate; an
371
+ * 'approved', 'fix', or 'retry' outcome means Copilot answered this pass, so the
372
+ * gate must be evaluated against its review and is never bypassed. Recomputing
373
+ * from the current outcome is what lets a recovered Copilot — one that returns
374
+ * standards-only findings after an earlier down pass — reach FINALIZE without
375
+ * the stale bypass that would skip its non-clean review.
376
+ * @param {{kind: string}} copilotOutcome a classifyCopilotOutcome result
377
+ * @returns {boolean} true only when this pass's Copilot gate is bypassed
378
+ */
379
+ function resolveCopilotDown(copilotOutcome) {
380
+ return copilotOutcome.kind === 'down'
381
+ }
382
+
343
383
  /**
344
384
  * Classify a convergence-check result into the loop's next action. A dead check
345
385
  * agent (null/undefined result) is a retry rather than a failure: with no FAIL
@@ -412,7 +452,7 @@ const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNum
412
452
  * @returns {Promise<string>} the 40-char HEAD SHA
413
453
  */
414
454
  async function resolveHead() {
415
- const head = await agent(
455
+ const head = await convergeAgent(
416
456
  `Print the current HEAD SHA of ${prCoordinates}. Run exactly:\n` +
417
457
  `gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .head.sha\n` +
418
458
  `Return the full 40-character SHA in the sha field. Do not modify any files.`,
@@ -430,7 +470,7 @@ async function resolveHead() {
430
470
  * @returns {Promise<string>} agent transcript (unused)
431
471
  */
432
472
  function prefetchMainForRound() {
433
- return agent(
473
+ return convergeAgent(
434
474
  `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` +
435
475
  `git fetch origin main\n` +
436
476
  `Do not edit, commit, push, rebase, or modify any files — fetch only.`,
@@ -448,7 +488,7 @@ function runBugbotLens(head) {
448
488
  if (input.bugbotDisabled) {
449
489
  return Promise.resolve({ sha: head, clean: true, down: true, findings: [] })
450
490
  }
451
- return agent(
491
+ return convergeAgent(
452
492
  `You are the Cursor Bugbot lens for ${prCoordinates}, HEAD ${head}. Cursor Bugbot participates this run.\n\n` +
453
493
  `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` +
454
494
  `Procedure (use the existing scripts; each step below shows the exact flags that script accepts):\n` +
@@ -474,7 +514,7 @@ function runBugbotLens(head) {
474
514
  * @returns {Promise<object>} LENS_SCHEMA result
475
515
  */
476
516
  function runCodeReviewLens(head) {
477
- return agent(
517
+ return convergeAgent(
478
518
  `You are the code-review lens for ${prCoordinates}, HEAD ${head}.\n\n` +
479
519
  `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` +
480
520
  `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` +
@@ -490,7 +530,7 @@ function runCodeReviewLens(head) {
490
530
  * @returns {Promise<object>} LENS_SCHEMA result
491
531
  */
492
532
  function runAuditLens(head) {
493
- return agent(
533
+ return convergeAgent(
494
534
  `You are the second-opinion bug-audit lens for ${prCoordinates}, HEAD ${head}.\n\n` +
495
535
  `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` +
496
536
  `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` +
@@ -520,7 +560,7 @@ function applyFixes(head, findings, sourceLabel) {
520
560
  const threadIds = findings
521
561
  .flatMap((each) => collectFindingThreadIds(each))
522
562
  .filter((each) => typeof each === 'number')
523
- return agent(
563
+ return convergeAgent(
524
564
  `You are fixing ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}.\n\n` +
525
565
  `Findings:\n${findingsBlock}\n\n` +
526
566
  `Rules:\n` +
@@ -544,7 +584,7 @@ function applyFixes(head, findings, sourceLabel) {
544
584
  * @returns {Promise<string>} agent transcript (unused)
545
585
  */
546
586
  function postCleanAudit(head) {
547
- return agent(
587
+ return convergeAgent(
548
588
  `Post a CLEAN bugteam audit review on ${prCoordinates} at commit ${head}. All review lenses are clean on this HEAD.\n\n` +
549
589
  `Write an empty findings file: create a temp file containing exactly [] (an empty JSON array). Then run:\n` +
550
590
  `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` +
@@ -555,19 +595,25 @@ function postCleanAudit(head) {
555
595
 
556
596
  /**
557
597
  * Copilot gate: request a Copilot review on HEAD and poll until it lands or the
558
- * poll cap is hit; return Copilot's findings or a blocker.
598
+ * poll cap is hit; return Copilot's findings or a down signal. Copilot is down
599
+ * when it posts an out-of-usage notice (the requester hit their quota) rather
600
+ * than a review, or surfaces no review at all after the poll cap; the gate
601
+ * reports either as down so the run logs a notice and proceeds to mark-ready with
602
+ * the gate bypassed rather than waiting on a review that will not arrive.
559
603
  * @param {string} head converged PR HEAD SHA
560
604
  * @returns {Promise<object>} COPILOT_SCHEMA result
561
605
  */
562
606
  function runCopilotGate(head) {
563
- return agent(
607
+ return convergeAgent(
564
608
  `You are the Copilot gate for ${prCoordinates}, HEAD ${head}. Do not edit code, commit, or push.\n\n` +
565
- `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` +
609
+ `Copilot can run out of usage. When the newest Copilot review on HEAD carries an out-of-usage notice — a body stating Copilot was unable to review because the user who requested the review has reached their quota limit, or any equivalent quota / premium-request / usage-limit exhaustion message rather than an actual code review — Copilot is down for this run: return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} and stop. Do NOT re-request a review, do NOT keep polling, and do NOT treat the notice as a finding.\n\n` +
610
+ `1. Read any existing Copilot review on HEAD first: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}. This lists every Copilot review across all commits newest-first; only count entries whose commit_id starts with ${head}. If the newest such HEAD-scoped Copilot review is the out-of-usage notice above -> return the down result and stop. A notice on any earlier commit is NOT down: ignore it and continue. With no Copilot review on HEAD, 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` +
566
611
  ` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
567
612
  `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` +
568
- ` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, findings:[], blocker:null}.\n` +
569
- ` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, blocker:null.\n` +
570
- ` - 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` +
613
+ ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
614
+ ` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
615
+ ` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, down:false.\n` +
616
+ ` - No review after ${CONFIG.copilotMaxPolls} attempts -> Copilot is down for this run (unreachable, or silently out of quota with no notice): return {sha:${'`'}${head}${'`'}, clean:false, down:true, findings:[]}.\n\n` +
571
617
  `Return strictly the schema.`,
572
618
  { label: 'copilot-gate', phase: 'Copilot gate', schema: COPILOT_SCHEMA },
573
619
  )
@@ -576,13 +622,15 @@ function runCopilotGate(head) {
576
622
  /**
577
623
  * Run the authoritative convergence gate.
578
624
  * @param {boolean} bugbotDown pass --bugbot-down when Bugbot is opted out or proved unreachable this run
625
+ * @param {boolean} copilotDown pass --copilot-down when Copilot is down or out of quota this run
579
626
  * @returns {Promise<object>} CONVERGENCE_SCHEMA result
580
627
  */
581
- function checkConvergence(bugbotDown) {
628
+ function checkConvergence(bugbotDown, copilotDown) {
582
629
  const bugbotDownFlag = bugbotDown ? ' --bugbot-down' : ''
583
- return agent(
630
+ const copilotDownFlag = copilotDown ? ' --copilot-down' : ''
631
+ return convergeAgent(
584
632
  `Run the convergence gate for ${prCoordinates} and report the result. Do not edit code.\n\n` +
585
- `Run: python "${CONFIG.sharedScripts}/check_convergence.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}${bugbotDownFlag}\n\n` +
633
+ `Run: python "${CONFIG.sharedScripts}/check_convergence.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}${bugbotDownFlag}${copilotDownFlag}\n\n` +
586
634
  `Exit 0 -> every gate passed: return {pass:true, failures:[]}.\n` +
587
635
  `Exit 1 -> return {pass:false, failures:[<each printed FAIL line verbatim>]}.\n` +
588
636
  `Exit 2 -> retry once; if it still errors, return {pass:false, failures:["check_convergence gh error"]}.`,
@@ -592,12 +640,22 @@ function checkConvergence(bugbotDown) {
592
640
 
593
641
  /**
594
642
  * Mark the PR ready for review (draft=false) and confirm the transition landed.
643
+ * When Copilot is down this run, the mark-ready agent first opts the
644
+ * independent mark-ready blocker hook out of the Copilot gate by exporting
645
+ * the Copilot token into CLAUDE_REVIEWS_DISABLED: that hook re-runs
646
+ * check_convergence.py without --copilot-down, so the env token is the only
647
+ * channel a genuine Copilot outage has to pass its Copilot review gate.
595
648
  * @param {string} head converged PR HEAD SHA
649
+ * @param {boolean} copilotDown true when the Copilot gate was bypassed for an outage this run
596
650
  * @returns {Promise<object>} READY_SCHEMA result
597
651
  */
598
- function markReady(head) {
599
- return agent(
652
+ function markReady(head, copilotDown) {
653
+ const copilotOptOut = copilotDown
654
+ ? `0. Copilot is down this run, so opt the independent mark-ready blocker hook out of the Copilot gate before step 1. Export the token in the same shell session as step 1 so the hook's convergence re-check inherits it:\n bash: export CLAUDE_REVIEWS_DISABLED="copilot" (PowerShell: $env:CLAUDE_REVIEWS_DISABLED = "copilot")\n`
655
+ : ''
656
+ return convergeAgent(
600
657
  `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` +
658
+ copilotOptOut +
601
659
  `1. Run: gh pr ready ${input.prNumber} --repo ${input.owner}/${input.repo}\n` +
602
660
  `2. Re-query the draft state: gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .draft\n` +
603
661
  `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}.`,
@@ -617,7 +675,7 @@ function repairConvergence(head, failures) {
617
675
  const failureBlock = failures.length
618
676
  ? failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
619
677
  : 'none reported'
620
- return agent(
678
+ return convergeAgent(
621
679
  `The convergence check for ${prCoordinates} failed these gates on HEAD ${head}:\n${failureBlock}\n\n` +
622
680
  `Address only the failing gates:\n` +
623
681
  `- 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` +
@@ -661,7 +719,7 @@ function spawnStandardsFollowUp(head, findings, sourceLabel) {
661
719
  return `${position + 1}. [${each.severity}] ${each.file}:${each.line} — ${each.title}\n ${each.detail}${threadNote}`
662
720
  })
663
721
  .join('\n')
664
- return agent(
722
+ return convergeAgent(
665
723
  `A review round on ${prCoordinates}, HEAD ${head}, surfaced ONLY code-standard violations (CODE_RULES/style, no behavioral impact). The convergence run treats the round as passed and defers these to follow-up work, which you now create. Do NOT commit or push to the PR's own branch.\n\n` +
666
724
  `Findings:\n${findingsBlock}\n\n` +
667
725
  `1. Follow-up fix issue: file a GitHub issue on ${input.owner}/${input.repo} (gh issue create --body-file with a temp file) titled "Deferred code-standard fixes from PR #${input.prNumber}". The body references the PR and lists each finding with its file:line, severity, and detail. The issue carries the fix work; do not open a fix PR.\n` +
@@ -678,6 +736,8 @@ let rounds = 0
678
736
  let iterations = 0
679
737
  let blocker = null
680
738
  let bugbotDown = input.bugbotDisabled || false
739
+ let copilotDown = false
740
+ let copilotNote = null
681
741
  let standardsNote = null
682
742
 
683
743
  while (iterations < CONFIG.maxIterations) {
@@ -737,19 +797,26 @@ while (iterations < CONFIG.maxIterations) {
737
797
  if (phase === 'COPILOT') {
738
798
  const copilot = await runCopilotGate(head)
739
799
  const copilotOutcome = classifyCopilotOutcome(copilot)
800
+ copilotDown = resolveCopilotDown(copilotOutcome)
801
+ copilotNote = null
740
802
  if (copilotOutcome.kind === 'retry') {
741
803
  log('Copilot gate agent died or returned an unreliable not-clean result with no findings — re-running the gate on the same HEAD')
742
804
  continue
743
805
  }
744
- if (copilotOutcome.kind === 'blocker') {
745
- blocker = copilotOutcome.blocker
746
- break
806
+ if (copilotOutcome.kind === 'down') {
807
+ log('Copilot gate: Copilot is down or out of quota — no review on HEAD after the poll cap. Logging a notice and proceeding to mark-ready with the Copilot gate bypassed.')
808
+ copilotDown = true
809
+ copilotNote = 'Copilot was down or out of quota — the Copilot gate was bypassed and the PR was marked ready without a Copilot review'
810
+ phase = 'FINALIZE'
811
+ continue
747
812
  }
748
813
  if (copilotOutcome.kind === 'fix') {
749
814
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
750
815
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
751
816
  await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
752
817
  standardsNote = `${copilotOutcome.findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
818
+ copilotDown = false
819
+ copilotNote = null
753
820
  phase = 'FINALIZE'
754
821
  continue
755
822
  }
@@ -766,22 +833,24 @@ while (iterations < CONFIG.maxIterations) {
766
833
  phase = 'CONVERGE'
767
834
  continue
768
835
  }
836
+ copilotDown = false
837
+ copilotNote = null
769
838
  phase = 'FINALIZE'
770
839
  continue
771
840
  }
772
841
 
773
842
  if (phase === 'FINALIZE') {
774
- const convergence = await checkConvergence(bugbotDown)
843
+ const convergence = await checkConvergence(bugbotDown, copilotDown)
775
844
  const convergenceOutcome = classifyConvergenceOutcome(convergence)
776
845
  if (convergenceOutcome.kind === 'retry') {
777
846
  log('Convergence check agent died or returned no FAIL lines — re-running the check on the same HEAD')
778
847
  continue
779
848
  }
780
849
  if (convergenceOutcome.kind === 'ready') {
781
- const readyResult = await markReady(head)
850
+ const readyResult = await markReady(head, copilotDown)
782
851
  const readyOutcome = classifyReadyOutcome(readyResult)
783
852
  if (readyOutcome.converged) {
784
- return { converged: true, rounds, finalSha: head, blocker: null, standardsNote }
853
+ return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote }
785
854
  }
786
855
  blocker = readyOutcome.blocker
787
856
  break
@@ -799,4 +868,5 @@ return {
799
868
  finalSha: head,
800
869
  blocker: blocker || `iteration cap reached (${CONFIG.maxIterations})`,
801
870
  standardsNote,
871
+ copilotNote,
802
872
  }