claude-dev-env 1.58.0 → 1.60.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 (106) 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-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -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 = {
@@ -87,6 +105,31 @@ const FIX_SCHEMA = {
87
105
  required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary'],
88
106
  }
89
107
 
108
+ const CONVERGENCE_SUMMARY_SCHEMA = {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ properties: {
112
+ verdictLine: { type: 'string', description: 'one factual BLUF sentence: converged?, distinct issue-class count, all fixed or deferred. No hedging words.' },
113
+ issueClasses: {
114
+ type: 'array',
115
+ items: {
116
+ type: 'object',
117
+ additionalProperties: false,
118
+ properties: {
119
+ plainName: { type: 'string', description: 'everyday-language name of the issue class — no tool tokens, rule ids, file paths, line numbers, severity codes (P0/P1/P2), or bot names' },
120
+ count: { type: 'integer', description: 'number of raw findings grouped into this class' },
121
+ severity: { type: 'string', enum: ['P0', 'P1', 'P2'], description: 'most severe among the class' },
122
+ category: { type: 'string', enum: ['bug', 'code-standard'] },
123
+ status: { type: 'string', enum: ['fixed', 'deferred'] },
124
+ whatItWas: { type: 'string', description: 'at most 2 sentences, plain language, what the problem was' },
125
+ },
126
+ required: ['plainName', 'count', 'severity', 'category', 'status', 'whatItWas'],
127
+ },
128
+ },
129
+ },
130
+ required: ['verdictLine', 'issueClasses'],
131
+ }
132
+
90
133
  const CONVERGENCE_SCHEMA = {
91
134
  type: 'object',
92
135
  additionalProperties: false,
@@ -106,6 +149,17 @@ const READY_SCHEMA = {
106
149
  required: ['ready'],
107
150
  }
108
151
 
152
+ const CLEAN_AUDIT_SCHEMA = {
153
+ type: 'object',
154
+ additionalProperties: false,
155
+ properties: {
156
+ posted: { type: 'boolean', description: 'true only when post_audit_thread.py printed the review URL confirming the CLEAN bugteam review landed on HEAD' },
157
+ reviewUrl: { type: 'string', description: 'the posted review URL when posted is true, otherwise an empty string' },
158
+ reason: { type: 'string', description: 'when posted is false, the one-line reason the post did not land (a permission denial, a classifier block, or a script error)' },
159
+ },
160
+ required: ['posted', 'reviewUrl', 'reason'],
161
+ }
162
+
109
163
  const SEVERITY_RANK = { P0: 0, P1: 1, P2: 2 }
110
164
  const SHA_COMPARISON_PREFIX_LENGTH = 7
111
165
 
@@ -324,22 +378,44 @@ function classifyReadyOutcome(readyResult) {
324
378
  * Classify a Copilot gate result into the loop's next action. A dead gate agent
325
379
  * (null result) is a retry rather than an approval, mirroring the converge
326
380
  * 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.
381
+ * Copilot review. A down result Copilot out of quota or unreachable, so it
382
+ * posts an out-of-usage notice or never surfaces a review after the poll cap
383
+ * routes to the 'down' kind, which logs a notice and proceeds to mark-ready with
384
+ * the Copilot gate bypassed, the same way a down Bugbot lens is bypassed; this is
385
+ * checked first so an outage proceeds rather than waiting on a review that will
386
+ * not arrive. Findings route to a fix step. The gate otherwise approves only when
387
+ * it explicitly reports clean:true with no findings — a clean:false result with
388
+ * zero findings is an unreliable or malformed gate response and retries rather
389
+ * than advancing to Finalize, so a PR never goes ready on a HEAD Copilot did not
390
+ * call clean.
332
391
  * @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
392
+ * @returns {{kind: string, findings?: Array<object>}} the next action
334
393
  */
335
394
  function classifyCopilotOutcome(copilot) {
336
395
  if (copilot == null) return { kind: 'retry' }
337
- if (copilot.blocker) return { kind: 'blocker', blocker: copilot.blocker }
396
+ if (copilot.down === true) return { kind: 'down' }
338
397
  if (copilot.findings.length > 0) return { kind: 'fix', findings: copilot.findings }
339
398
  if (copilot.clean === true) return { kind: 'approved' }
340
399
  return { kind: 'retry' }
341
400
  }
342
401
 
402
+ /**
403
+ * Decide whether the Copilot review gate is bypassed for this COPILOT pass from
404
+ * the gate outcome, mirroring resolveBugbotDown so the flag is recomputed every
405
+ * pass rather than left sticky. Only a 'down' outcome (Copilot out of quota or
406
+ * unreachable after the poll cap) bypasses the convergence Copilot gate; an
407
+ * 'approved', 'fix', or 'retry' outcome means Copilot answered this pass, so the
408
+ * gate must be evaluated against its review and is never bypassed. Recomputing
409
+ * from the current outcome is what lets a recovered Copilot — one that returns
410
+ * standards-only findings after an earlier down pass — reach FINALIZE without
411
+ * the stale bypass that would skip its non-clean review.
412
+ * @param {{kind: string}} copilotOutcome a classifyCopilotOutcome result
413
+ * @returns {boolean} true only when this pass's Copilot gate is bypassed
414
+ */
415
+ function resolveCopilotDown(copilotOutcome) {
416
+ return copilotOutcome.kind === 'down'
417
+ }
418
+
343
419
  /**
344
420
  * Classify a convergence-check result into the loop's next action. A dead check
345
421
  * agent (null/undefined result) is a retry rather than a failure: with no FAIL
@@ -412,7 +488,7 @@ const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNum
412
488
  * @returns {Promise<string>} the 40-char HEAD SHA
413
489
  */
414
490
  async function resolveHead() {
415
- const head = await agent(
491
+ const head = await convergeAgent(
416
492
  `Print the current HEAD SHA of ${prCoordinates}. Run exactly:\n` +
417
493
  `gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .head.sha\n` +
418
494
  `Return the full 40-character SHA in the sha field. Do not modify any files.`,
@@ -430,7 +506,7 @@ async function resolveHead() {
430
506
  * @returns {Promise<string>} agent transcript (unused)
431
507
  */
432
508
  function prefetchMainForRound() {
433
- return agent(
509
+ return convergeAgent(
434
510
  `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
511
  `git fetch origin main\n` +
436
512
  `Do not edit, commit, push, rebase, or modify any files — fetch only.`,
@@ -448,7 +524,7 @@ function runBugbotLens(head) {
448
524
  if (input.bugbotDisabled) {
449
525
  return Promise.resolve({ sha: head, clean: true, down: true, findings: [] })
450
526
  }
451
- return agent(
527
+ return convergeAgent(
452
528
  `You are the Cursor Bugbot lens for ${prCoordinates}, HEAD ${head}. Cursor Bugbot participates this run.\n\n` +
453
529
  `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
530
  `Procedure (use the existing scripts; each step below shows the exact flags that script accepts):\n` +
@@ -474,7 +550,7 @@ function runBugbotLens(head) {
474
550
  * @returns {Promise<object>} LENS_SCHEMA result
475
551
  */
476
552
  function runCodeReviewLens(head) {
477
- return agent(
553
+ return convergeAgent(
478
554
  `You are the code-review lens for ${prCoordinates}, HEAD ${head}.\n\n` +
479
555
  `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
556
  `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 +566,7 @@ function runCodeReviewLens(head) {
490
566
  * @returns {Promise<object>} LENS_SCHEMA result
491
567
  */
492
568
  function runAuditLens(head) {
493
- return agent(
569
+ return convergeAgent(
494
570
  `You are the second-opinion bug-audit lens for ${prCoordinates}, HEAD ${head}.\n\n` +
495
571
  `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
572
  `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 +596,7 @@ function applyFixes(head, findings, sourceLabel) {
520
596
  const threadIds = findings
521
597
  .flatMap((each) => collectFindingThreadIds(each))
522
598
  .filter((each) => typeof each === 'number')
523
- return agent(
599
+ return convergeAgent(
524
600
  `You are fixing ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}.\n\n` +
525
601
  `Findings:\n${findingsBlock}\n\n` +
526
602
  `Rules:\n` +
@@ -539,35 +615,65 @@ function applyFixes(head, findings, sourceLabel) {
539
615
 
540
616
  /**
541
617
  * Post the terminal CLEAN bugteam audit artifact so check_convergence.py sees
542
- * a clean bugteam review on the converged HEAD.
618
+ * a clean bugteam review on the converged HEAD. The post is load-bearing: the
619
+ * convergence gate's bugteam-review check can never pass until this review
620
+ * lands, so the result reports whether the post succeeded rather than
621
+ * discarding it. A blocked post (a permission or auto-mode-classifier denial)
622
+ * or a script error returns posted:false with the reason so the caller can
623
+ * surface a blocker instead of re-converging into the iteration cap.
543
624
  * @param {string} head converged PR HEAD SHA
544
- * @returns {Promise<string>} agent transcript (unused)
625
+ * @returns {Promise<object>} CLEAN_AUDIT_SCHEMA result
545
626
  */
546
627
  function postCleanAudit(head) {
547
- return agent(
628
+ return convergeAgent(
548
629
  `Post a CLEAN bugteam audit review on ${prCoordinates} at commit ${head}. All review lenses are clean on this HEAD.\n\n` +
549
630
  `Write an empty findings file: create a temp file containing exactly [] (an empty JSON array). Then run:\n` +
550
631
  `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` +
551
- `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.`,
552
- { label: 'post-clean-audit', phase: 'Converge', agentType: 'general-purpose' },
632
+ `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.\n\n` +
633
+ `Report whether the review landed. When the script prints a review URL, return {posted:true, reviewUrl:<that URL>, reason:""}. When the script is denied (a permission prompt or auto-mode-classifier block), errors, or prints anything other than a review URL, return {posted:false, reviewUrl:"", reason:<the denial message or error as one line>}. Do not retry a denied post.`,
634
+ { label: 'post-clean-audit', phase: 'Converge', schema: CLEAN_AUDIT_SCHEMA, agentType: 'general-purpose' },
635
+ )
636
+ }
637
+
638
+ /**
639
+ * Blocker message for a CLEAN bugteam audit that did not land. The convergence
640
+ * gate's bugteam-review check can never pass without this review, so a blocked
641
+ * post stops the run with an actionable message rather than re-converging until
642
+ * the iteration cap. Handles a dead post agent (a null result) as not posted.
643
+ * @param {string} head converged PR HEAD SHA
644
+ * @param {object} auditResult CLEAN_AUDIT_SCHEMA result from postCleanAudit, or null when the agent died
645
+ * @returns {string} the blocker message naming the post failure and the unblock path
646
+ */
647
+ function cleanAuditBlocker(head, auditResult) {
648
+ const reason = auditResult?.reason || 'the post agent returned no result'
649
+ return (
650
+ `clean-audit post blocked: the CLEAN bugteam review could not be posted on HEAD ${head} (${reason}) — ` +
651
+ `the convergence gate's bugteam-review check can never pass without it, so the run stops rather than re-converge to the iteration cap. ` +
652
+ `Allow post_audit_thread.py for this run with a Bash permission rule, or post the CLEAN review by hand, then re-run.`
553
653
  )
554
654
  }
555
655
 
556
656
  /**
557
657
  * 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.
658
+ * poll cap is hit; return Copilot's findings or a down signal. Copilot is down
659
+ * when it posts an out-of-usage notice (the requester hit their quota) rather
660
+ * than a review, or surfaces no review at all after the poll cap; the gate
661
+ * reports either as down so the run logs a notice and proceeds to mark-ready with
662
+ * the gate bypassed rather than waiting on a review that will not arrive.
559
663
  * @param {string} head converged PR HEAD SHA
560
664
  * @returns {Promise<object>} COPILOT_SCHEMA result
561
665
  */
562
666
  function runCopilotGate(head) {
563
- return agent(
667
+ return convergeAgent(
564
668
  `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` +
669
+ `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` +
670
+ `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
671
  ` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
567
672
  `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` +
673
+ ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
674
+ ` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
675
+ ` - 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` +
676
+ ` - 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
677
  `Return strictly the schema.`,
572
678
  { label: 'copilot-gate', phase: 'Copilot gate', schema: COPILOT_SCHEMA },
573
679
  )
@@ -576,13 +682,15 @@ function runCopilotGate(head) {
576
682
  /**
577
683
  * Run the authoritative convergence gate.
578
684
  * @param {boolean} bugbotDown pass --bugbot-down when Bugbot is opted out or proved unreachable this run
685
+ * @param {boolean} copilotDown pass --copilot-down when Copilot is down or out of quota this run
579
686
  * @returns {Promise<object>} CONVERGENCE_SCHEMA result
580
687
  */
581
- function checkConvergence(bugbotDown) {
688
+ function checkConvergence(bugbotDown, copilotDown) {
582
689
  const bugbotDownFlag = bugbotDown ? ' --bugbot-down' : ''
583
- return agent(
690
+ const copilotDownFlag = copilotDown ? ' --copilot-down' : ''
691
+ return convergeAgent(
584
692
  `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` +
693
+ `Run: python "${CONFIG.sharedScripts}/check_convergence.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}${bugbotDownFlag}${copilotDownFlag}\n\n` +
586
694
  `Exit 0 -> every gate passed: return {pass:true, failures:[]}.\n` +
587
695
  `Exit 1 -> return {pass:false, failures:[<each printed FAIL line verbatim>]}.\n` +
588
696
  `Exit 2 -> retry once; if it still errors, return {pass:false, failures:["check_convergence gh error"]}.`,
@@ -592,12 +700,22 @@ function checkConvergence(bugbotDown) {
592
700
 
593
701
  /**
594
702
  * Mark the PR ready for review (draft=false) and confirm the transition landed.
703
+ * When Copilot is down this run, the mark-ready agent first opts the
704
+ * independent mark-ready blocker hook out of the Copilot gate by exporting
705
+ * the Copilot token into CLAUDE_REVIEWS_DISABLED: that hook re-runs
706
+ * check_convergence.py without --copilot-down, so the env token is the only
707
+ * channel a genuine Copilot outage has to pass its Copilot review gate.
595
708
  * @param {string} head converged PR HEAD SHA
709
+ * @param {boolean} copilotDown true when the Copilot gate was bypassed for an outage this run
596
710
  * @returns {Promise<object>} READY_SCHEMA result
597
711
  */
598
- function markReady(head) {
599
- return agent(
712
+ function markReady(head, copilotDown) {
713
+ const copilotOptOut = copilotDown
714
+ ? `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`
715
+ : ''
716
+ return convergeAgent(
600
717
  `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` +
718
+ copilotOptOut +
601
719
  `1. Run: gh pr ready ${input.prNumber} --repo ${input.owner}/${input.repo}\n` +
602
720
  `2. Re-query the draft state: gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .draft\n` +
603
721
  `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 +735,7 @@ function repairConvergence(head, failures) {
617
735
  const failureBlock = failures.length
618
736
  ? failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
619
737
  : 'none reported'
620
- return agent(
738
+ return convergeAgent(
621
739
  `The convergence check for ${prCoordinates} failed these gates on HEAD ${head}:\n${failureBlock}\n\n` +
622
740
  `Address only the failing gates:\n` +
623
741
  `- 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 +779,7 @@ function spawnStandardsFollowUp(head, findings, sourceLabel) {
661
779
  return `${position + 1}. [${each.severity}] ${each.file}:${each.line} — ${each.title}\n ${each.detail}${threadNote}`
662
780
  })
663
781
  .join('\n')
664
- return agent(
782
+ return convergeAgent(
665
783
  `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
784
  `Findings:\n${findingsBlock}\n\n` +
667
785
  `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` +
@@ -672,13 +790,62 @@ function spawnStandardsFollowUp(head, findings, sourceLabel) {
672
790
  )
673
791
  }
674
792
 
793
+ /**
794
+ * Spawn the convergence-summary agent at finalize so its StructuredOutput is
795
+ * recorded into the run journal for the closing report to read. The agent groups
796
+ * the deduped findings into plain-language issue classes, translates reviewer
797
+ * jargon to everyday English, and writes one BLUF verdict line. The side effect
798
+ * is the journal record; the return value is discarded by the caller.
799
+ * @param {Array<object>} distinctFindings deduped findings across every round
800
+ * @param {Array<string>} fixSummaries per-round fix-lens one-line summaries
801
+ * @param {number} roundCount the number of converge rounds the run took
802
+ * @param {string|null} standardsNote deferral note when a round was code-standard-only
803
+ * @param {string|null} copilotNote outage note when the Copilot gate was bypassed
804
+ * @returns {Promise<object>} CONVERGENCE_SUMMARY_SCHEMA result (journal side effect)
805
+ */
806
+ function spawnConvergenceSummary(distinctFindings, fixSummaries, roundCount, standardsNote, copilotNote) {
807
+ const findingsBlock = distinctFindings.length
808
+ ? distinctFindings
809
+ .map((each, position) => {
810
+ const truncatedDetail = (each.detail || '').slice(0, 400)
811
+ return `${position + 1}. [${each.severity}/${each.category}] ${each.file}:${each.line} — ${each.title} :: ${truncatedDetail}`
812
+ })
813
+ .join('\n')
814
+ : 'none — every lens was clean on a stable HEAD'
815
+ const fixSummariesBlock = fixSummaries.length
816
+ ? fixSummaries.map((each, position) => `${position + 1}. ${each}`).join('\n')
817
+ : 'none'
818
+ const standardsBlock = standardsNote ? `\nDeferred code-standard note: ${standardsNote}\n` : ''
819
+ const copilotBlock = copilotNote ? `\nCopilot gate note: ${copilotNote}\n` : ''
820
+ return convergeAgent(
821
+ `You write the plain-language convergence summary for ${prCoordinates}. The autoconverge run reached convergence in ${roundCount} round(s). Use ONLY the findings and fix summaries below; invent nothing not present.\n\n` +
822
+ `Distinct findings caught across the run (already deduped):\n${findingsBlock}\n\n` +
823
+ `Per-round fix summaries:\n${fixSummariesBlock}\n${standardsBlock}${copilotBlock}\n` +
824
+ `Produce a summary an everyday reader understands:\n` +
825
+ `- GROUP near-duplicate findings into issue CLASSES: the same KIND of problem across different files or lines becomes ONE class with a count. Example: seven "Missing return type annotation on test function" findings become one class with count 7.\n` +
826
+ `- TRANSLATE reviewer jargon into plain everyday English. Examples: "CodingGuidelineID 1000000 / Repository guideline (Types)" -> "a typing rule the project enforces"; "missing return type annotation / Add -> None" -> "a test did not declare what it returns"; "Banned identifier result" -> "a vague variable name the project bans"; a magic-value finding -> "a raw number or string that should be a named value".\n` +
827
+ `- plainName must carry NO tool token, rule id, file path, line number, severity code (P0/P1/P2), or bot name.\n` +
828
+ `- Lead with category 'bug' classes, then 'code-standard'. Cap to about 5 classes. whatItWas is at most 2 sentences. No paragraphs.\n` +
829
+ `- status is 'fixed' unless the fix summaries or the deferred code-standard note mark the class deferred, in which case status is 'deferred'.\n` +
830
+ `- Use NO hedging words anywhere (likely, probably, should, appears, seems, may, might, could, possibly). State facts ("caught and fixed").\n` +
831
+ `- When there are zero findings, return issueClasses: [] and a verdictLine stating the run converged with no issues caught.\n` +
832
+ `- verdictLine is one factual sentence naming the round count and that all classes are fixed or deferred.\n\n` +
833
+ `Return strictly the schema.`,
834
+ { label: 'convergence-summary', phase: 'Finalize', schema: CONVERGENCE_SUMMARY_SCHEMA, agentType: 'general-purpose' },
835
+ )
836
+ }
837
+
675
838
  let phase = 'CONVERGE'
676
839
  let head = null
677
840
  let rounds = 0
678
841
  let iterations = 0
679
842
  let blocker = null
680
843
  let bugbotDown = input.bugbotDisabled || false
844
+ let copilotDown = false
845
+ let copilotNote = null
681
846
  let standardsNote = null
847
+ const allRoundFindings = []
848
+ const fixSummaries = []
682
849
 
683
850
  while (iterations < CONFIG.maxIterations) {
684
851
  iterations += 1
@@ -705,15 +872,22 @@ while (iterations < CONFIG.maxIterations) {
705
872
  const findings = roundOutcome.findings
706
873
  if (isStandardsOnlyRound(findings)) {
707
874
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
875
+ allRoundFindings.push(...findings)
708
876
  await spawnStandardsFollowUp(head, findings, 'converge-round')
709
877
  standardsNote = `${findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
710
- await postCleanAudit(head)
878
+ const auditResult = await postCleanAudit(head)
879
+ if (!auditResult?.posted) {
880
+ blocker = cleanAuditBlocker(head, auditResult)
881
+ break
882
+ }
711
883
  phase = 'COPILOT'
712
884
  continue
713
885
  }
714
886
  if (findings.length > 0) {
715
887
  log(`Round ${rounds}: ${findings.length} finding(s) — applying fixes`)
888
+ allRoundFindings.push(...findings)
716
889
  const fixResult = await applyFixes(head, findings, 'converge-round')
890
+ if (fixResult?.summary) fixSummaries.push(fixResult.summary)
717
891
  const hadThreadBearingFinding = findings.some((each) => collectFindingThreadIds(each).length > 0)
718
892
  const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
719
893
  if (!fixProgress.progressed) {
@@ -729,7 +903,11 @@ while (iterations < CONFIG.maxIterations) {
729
903
  continue
730
904
  }
731
905
  log(`Round ${rounds}: all lenses clean on ${head?.slice(0, 7)} — posting clean audit artifact`)
732
- await postCleanAudit(head)
906
+ const auditResult = await postCleanAudit(head)
907
+ if (!auditResult?.posted) {
908
+ blocker = cleanAuditBlocker(head, auditResult)
909
+ break
910
+ }
733
911
  phase = 'COPILOT'
734
912
  continue
735
913
  }
@@ -737,24 +915,34 @@ while (iterations < CONFIG.maxIterations) {
737
915
  if (phase === 'COPILOT') {
738
916
  const copilot = await runCopilotGate(head)
739
917
  const copilotOutcome = classifyCopilotOutcome(copilot)
918
+ copilotDown = resolveCopilotDown(copilotOutcome)
919
+ copilotNote = null
740
920
  if (copilotOutcome.kind === 'retry') {
741
921
  log('Copilot gate agent died or returned an unreliable not-clean result with no findings — re-running the gate on the same HEAD')
742
922
  continue
743
923
  }
744
- if (copilotOutcome.kind === 'blocker') {
745
- blocker = copilotOutcome.blocker
746
- break
924
+ if (copilotOutcome.kind === 'down') {
925
+ 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.')
926
+ copilotDown = true
927
+ copilotNote = 'Copilot was down or out of quota — the Copilot gate was bypassed and the PR was marked ready without a Copilot review'
928
+ phase = 'FINALIZE'
929
+ continue
747
930
  }
748
931
  if (copilotOutcome.kind === 'fix') {
749
932
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
750
933
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
934
+ allRoundFindings.push(...copilotOutcome.findings)
751
935
  await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
752
936
  standardsNote = `${copilotOutcome.findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
937
+ copilotDown = false
938
+ copilotNote = null
753
939
  phase = 'FINALIZE'
754
940
  continue
755
941
  }
756
942
  log(`Copilot raised ${copilotOutcome.findings.length} finding(s) — fixing and re-converging`)
943
+ allRoundFindings.push(...copilotOutcome.findings)
757
944
  const fixResult = await applyFixes(head, copilotOutcome.findings, 'copilot')
945
+ if (fixResult?.summary) fixSummaries.push(fixResult.summary)
758
946
  const hadThreadBearingFinding = copilotOutcome.findings.some((each) => collectFindingThreadIds(each).length > 0)
759
947
  const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
760
948
  if (!fixProgress.progressed) {
@@ -766,22 +954,25 @@ while (iterations < CONFIG.maxIterations) {
766
954
  phase = 'CONVERGE'
767
955
  continue
768
956
  }
957
+ copilotDown = false
958
+ copilotNote = null
769
959
  phase = 'FINALIZE'
770
960
  continue
771
961
  }
772
962
 
773
963
  if (phase === 'FINALIZE') {
774
- const convergence = await checkConvergence(bugbotDown)
964
+ const convergence = await checkConvergence(bugbotDown, copilotDown)
775
965
  const convergenceOutcome = classifyConvergenceOutcome(convergence)
776
966
  if (convergenceOutcome.kind === 'retry') {
777
967
  log('Convergence check agent died or returned no FAIL lines — re-running the check on the same HEAD')
778
968
  continue
779
969
  }
780
970
  if (convergenceOutcome.kind === 'ready') {
781
- const readyResult = await markReady(head)
971
+ const readyResult = await markReady(head, copilotDown)
782
972
  const readyOutcome = classifyReadyOutcome(readyResult)
783
973
  if (readyOutcome.converged) {
784
- return { converged: true, rounds, finalSha: head, blocker: null, standardsNote }
974
+ await spawnConvergenceSummary(dedupeFindings(allRoundFindings), fixSummaries, rounds, standardsNote, copilotNote)
975
+ return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote }
785
976
  }
786
977
  blocker = readyOutcome.blocker
787
978
  break
@@ -799,4 +990,5 @@ return {
799
990
  finalSha: head,
800
991
  blocker: blocker || `iteration cap reached (${CONFIG.maxIterations})`,
801
992
  standardsNote,
993
+ copilotNote,
802
994
  }
@@ -0,0 +1,110 @@
1
+ """Build the convergence-summary agent prompt over a PR's aggregated findings."""
2
+
3
+ from autoconverge_report_constants.render_report_constants import (
4
+ GITHUB_PR_URL_TEMPLATE,
5
+ SUMMARY_COPILOT_NOTE_TEMPLATE,
6
+ SUMMARY_DETAIL_MAX_CHARS,
7
+ SUMMARY_FINDING_LINE_TEMPLATE,
8
+ SUMMARY_FINDINGS_EMPTY_TEXT,
9
+ SUMMARY_FIX_EMPTY_TEXT,
10
+ SUMMARY_FIX_LINE_TEMPLATE,
11
+ SUMMARY_PR_COORDINATES_TEMPLATE,
12
+ SUMMARY_PROMPT_TEMPLATE,
13
+ SUMMARY_STANDARDS_NOTE_TEMPLATE,
14
+ )
15
+
16
+
17
+ def _format_findings_block(findings: list[dict]) -> str:
18
+ """Return the numbered findings block, or a clean-run sentence when empty.
19
+
20
+ Args:
21
+ findings: Aggregated distinct findings, each carrying severity, category,
22
+ file, line, title, and detail keys.
23
+
24
+ Returns:
25
+ A newline-joined numbered list, or a sentence stating every lens was clean.
26
+ """
27
+ if not findings:
28
+ return SUMMARY_FINDINGS_EMPTY_TEXT
29
+ numbered_lines: list[str] = []
30
+ for position, each_finding in enumerate(findings):
31
+ detail = str(each_finding.get("detail", ""))[:SUMMARY_DETAIL_MAX_CHARS]
32
+ numbered_lines.append(
33
+ SUMMARY_FINDING_LINE_TEMPLATE.format(
34
+ number=position + 1,
35
+ severity=each_finding.get("severity", ""),
36
+ category=each_finding.get("category", ""),
37
+ file=each_finding.get("file", ""),
38
+ line=each_finding.get("line", 0),
39
+ title=each_finding.get("title", ""),
40
+ detail=detail,
41
+ )
42
+ )
43
+ return "\n".join(numbered_lines)
44
+
45
+
46
+ def _format_fix_block(fix_summaries: list[str]) -> str:
47
+ """Return the numbered per-round fix-summary block, or 'none' when empty.
48
+
49
+ Args:
50
+ fix_summaries: One-line fix summaries collected across every round.
51
+
52
+ Returns:
53
+ A newline-joined numbered list, or the empty-state literal.
54
+ """
55
+ if not fix_summaries:
56
+ return SUMMARY_FIX_EMPTY_TEXT
57
+ return "\n".join(
58
+ SUMMARY_FIX_LINE_TEMPLATE.format(number=position + 1, summary=each_summary)
59
+ for position, each_summary in enumerate(fix_summaries)
60
+ )
61
+
62
+
63
+ def build_summary_prompt(
64
+ owner: str,
65
+ repo: str,
66
+ pr_number: int,
67
+ round_count: int,
68
+ findings: list[dict],
69
+ fix_summaries: list[str],
70
+ standards_note: str | None,
71
+ copilot_note: str | None,
72
+ ) -> str:
73
+ """Return the convergence-summary agent prompt for a PR's aggregated findings.
74
+
75
+ Args:
76
+ owner: The PR's repository owner.
77
+ repo: The PR's repository name.
78
+ pr_number: The PR number.
79
+ round_count: Total converge rounds across every run aggregated.
80
+ findings: Aggregated distinct findings across every run.
81
+ fix_summaries: One-line fix summaries collected across every run.
82
+ standards_note: Deferral note when a round was code-standard-only, else None.
83
+ copilot_note: Outage note when the Copilot gate was bypassed, else None.
84
+
85
+ Returns:
86
+ The full agent prompt instructing a StructuredOutput convergence summary.
87
+ """
88
+ pr_url = GITHUB_PR_URL_TEMPLATE.format(owner=owner, repo=repo, number=pr_number)
89
+ pr_coordinates = SUMMARY_PR_COORDINATES_TEMPLATE.format(
90
+ owner=owner, repo=repo, pr_number=pr_number, url=pr_url
91
+ )
92
+ standards_block = (
93
+ SUMMARY_STANDARDS_NOTE_TEMPLATE.format(note=standards_note)
94
+ if standards_note
95
+ else ""
96
+ )
97
+ copilot_block = (
98
+ SUMMARY_COPILOT_NOTE_TEMPLATE.format(note=copilot_note) if copilot_note else ""
99
+ )
100
+ return SUMMARY_PROMPT_TEMPLATE.format(
101
+ pr_coordinates=pr_coordinates,
102
+ owner=owner,
103
+ repo=repo,
104
+ pr_number=pr_number,
105
+ round_count=round_count,
106
+ findings_block=_format_findings_block(findings),
107
+ fix_block=_format_fix_block(fix_summaries),
108
+ standards_block=standards_block,
109
+ copilot_block=copilot_block,
110
+ )
@@ -0,0 +1,2 @@
1
+ {"type": "user", "uuid": "ab1c2d3e4f5a6b7c8-u", "message": {"role": "user", "content": "Return the convergence summary."}}
2
+ {"type": "assistant", "uuid": "ab1c2d3e4f5a6b7c8-a", "message": {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_ab1c2d3e4f5a6b7c8", "name": "StructuredOutput", "input": {"prProblem": "DataBridge, the service that exports your records in batches, restarted an interrupted export from the beginning instead of continuing.", "prFix": "An interrupted export now resumes from the last finished batch.", "problemScenes": [{"trigger": "export stops at batch 90 of 100", "condition": "you restart it", "result": "starts again at batch 1", "caption": "A halted export threw away the 90 batches it had already finished and began again."}], "fixScenes": [{"trigger": "export stops at batch 90 of 100", "condition": "you restart it", "result": "continues at batch 91", "caption": "A restarted export now picks up at the next unfinished batch."}], "verdictLine": "Converged in 4 rounds; 3 distinct issue classes were caught and fixed.", "issueClasses": [{"plainName": "Tests did not declare their return type", "count": 7, "severity": "P2", "category": "code-standard", "status": "fixed", "cause": "Several new test functions did not state that they return nothing, which the project's type checker wants.", "medium": "code", "beforeLines": ["def test_resume_skip():", " ..."], "afterLines": ["def test_resume_skip() -> None:", " ..."]}, {"plainName": "A vague banned variable name", "count": 2, "severity": "P2", "category": "code-standard", "status": "fixed", "cause": "Two variables used a generic placeholder name the project bans.", "medium": "code", "beforeLines": ["result = fetch()"], "afterLines": ["exported_rows = fetch()"]}, {"plainName": "Hardcoded message strings", "count": 6, "severity": "P2", "category": "code-standard", "status": "fixed", "cause": "Warning text was written inline in code instead of shared configuration.", "medium": "text", "beforeLines": ["inline warning text"], "afterLines": ["shared config message"]}]}}]}}