claude-dev-env 1.59.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 (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -1,206 +1,206 @@
1
- import { test } from 'node:test';
2
- import { strict as assert } from 'node:assert';
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
- import { dirname, join } from 'node:path';
6
-
7
- const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
- const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
- const gotchasSource = readFileSync(
10
- join(workflowDirectory, '..', 'reference', 'gotchas.md'),
11
- 'utf8',
12
- );
13
-
14
- function lensPromptBody(builderName) {
15
- const builderStart = convergeSource.indexOf(`function ${builderName}(`);
16
- assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
17
- const nextBuilderStart = convergeSource.indexOf('\nfunction ', builderStart + 1);
18
- const builderEnd = nextBuilderStart === -1 ? convergeSource.length : nextBuilderStart;
19
- return convergeSource.slice(builderStart, builderEnd);
20
- }
21
-
22
- test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
23
- assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
24
- });
25
-
26
- test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
27
- assert.doesNotMatch(lensPromptBody('runAuditLens'), /git fetch origin main/);
28
- });
29
-
30
- test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
31
- assert.match(convergeSource, /function prefetchMainForRound\(/);
32
- const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
33
- const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
34
- assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
35
- assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
36
- assert.ok(
37
- prefetchCallIndex < parallelLensIndex,
38
- 'expected the round prefetch to run before the parallel lenses spawn',
39
- );
40
- });
41
-
42
- test('bugbot lens preamble does not blanket-instruct passing --owner/--repo to every script', () => {
43
- const bugbotPrompt = lensPromptBody('runBugbotLens');
44
- assert.doesNotMatch(
45
- bugbotPrompt,
46
- /use the existing scripts; pass --owner/,
47
- 'the blanket clause breaks reviews_disabled.py, which accepts only --reviewer',
48
- );
49
- });
50
-
51
- test('bugbot lens invokes reviews_disabled.py with only --reviewer', () => {
52
- const bugbotPrompt = lensPromptBody('runBugbotLens');
53
- const reviewsDisabledIndex = bugbotPrompt.indexOf('reviews_disabled.py');
54
- assert.notEqual(reviewsDisabledIndex, -1, 'expected reviews_disabled.py invocation');
55
- const invocationLineEnd = bugbotPrompt.indexOf('\\n', reviewsDisabledIndex);
56
- const invocationLine = bugbotPrompt.slice(reviewsDisabledIndex, invocationLineEnd);
57
- assert.match(invocationLine, /--reviewer bugbot/);
58
- assert.doesNotMatch(
59
- invocationLine,
60
- /--owner|--repo/,
61
- 'reviews_disabled.py argparse rejects --owner/--repo with SystemExit(2)',
62
- );
63
- });
64
-
65
- test('gotchas doc states parallel lenses must avoid concurrent git operations', () => {
66
- assert.doesNotMatch(gotchasSource, /cannot race on git state/);
67
- assert.match(gotchasSource, /fetch.*once.*before/i);
68
- });
69
-
70
- test('repair-convergence filters unresolved threads to bot authors and skips human threads', () => {
71
- const repairPrompt = lensPromptBody('repairConvergence');
72
- assert.match(
73
- repairPrompt,
74
- /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
75
- 'expected the bot-author allowlist (Cursor/Claude/Copilot) to be named',
76
- );
77
- assert.match(
78
- repairPrompt,
79
- /skip.*human|human.*skip/is,
80
- 'expected an explicit instruction to skip human reviewer threads',
81
- );
82
- });
83
-
84
- test('repair-convergence no longer instructs resolving every unresolved thread without an author filter', () => {
85
- const repairPrompt = lensPromptBody('repairConvergence');
86
- assert.doesNotMatch(
87
- repairPrompt,
88
- /fetch every thread where isResolved is false/,
89
- 'the unfiltered instruction could resolve human reviewer threads',
90
- );
91
- });
92
-
93
- test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
94
- const bugbotPrompt = lensPromptBody('runBugbotLens');
95
- assert.match(bugbotPrompt, /sleep 60/, 'expected a shell-agnostic 60-second poll delay');
96
- assert.match(bugbotPrompt, /sleep 8/, 'expected a concrete 8-second delay command');
97
- assert.match(
98
- bugbotPrompt,
99
- /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
100
- 'expected PowerShell to be named only as an allowed alternative',
101
- );
102
- assert.doesNotMatch(
103
- bugbotPrompt,
104
- /wait 8 seconds(?!,)/,
105
- 'the vague "wait 8 seconds" phrasing must carry a concrete command',
106
- );
107
- });
108
-
109
- test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
110
- const copilotPrompt = lensPromptBody('runCopilotGate');
111
- assert.match(copilotPrompt, /sleep 360/, 'expected a shell-agnostic 360-second poll delay');
112
- assert.match(
113
- copilotPrompt,
114
- /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
115
- 'expected PowerShell to be named only as an allowed alternative',
116
- );
117
- });
118
-
119
- test('gotchas doc describes the reviewer wait as shell-agnostic', () => {
120
- assert.match(
121
- gotchasSource,
122
- /\bsleep\b/i,
123
- 'expected the wait guidance to name a shell-agnostic sleep',
124
- );
125
- assert.doesNotMatch(
126
- gotchasSource,
127
- /a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
128
- 'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
129
- );
130
- });
131
-
132
- function finalizeRepairBranch() {
133
- const repairCallIndex = convergeSource.indexOf('await repairConvergence(');
134
- assert.notEqual(repairCallIndex, -1, 'expected the FINALIZE repair call to exist');
135
- const transitionIndex = convergeSource.indexOf("phase = 'CONVERGE'", repairCallIndex);
136
- assert.notEqual(transitionIndex, -1, 'expected a CONVERGE transition after the repair call');
137
- const continueIndex = convergeSource.indexOf('continue', transitionIndex);
138
- assert.notEqual(continueIndex, -1, 'expected a continue statement to close the FINALIZE repair branch');
139
- const branchEnd = continueIndex + 'continue'.length;
140
- return convergeSource.slice(repairCallIndex, branchEnd);
141
- }
142
-
143
- test('the FINALIZE repair branch does not re-assign head from the repair before re-converging', () => {
144
- assert.doesNotMatch(
145
- finalizeRepairBranch(),
146
- /head\s*=\s*repair/,
147
- 'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the repair SHA here is dead',
148
- );
149
- });
150
-
151
- function fixBranchAfter(branchLabel) {
152
- const labelIndex = convergeSource.indexOf(branchLabel);
153
- assert.notEqual(labelIndex, -1, `expected the ${branchLabel} marker to exist`);
154
- const applyFixesIndex = convergeSource.indexOf('await applyFixes(', labelIndex);
155
- assert.notEqual(applyFixesIndex, -1, `expected an applyFixes call after ${branchLabel}`);
156
- const continueIndex = convergeSource.indexOf('continue', applyFixesIndex);
157
- assert.notEqual(continueIndex, -1, `expected a continue statement to close the ${branchLabel} branch`);
158
- const branchEnd = continueIndex + 'continue'.length;
159
- return convergeSource.slice(applyFixesIndex, branchEnd);
160
- }
161
-
162
- test('the CONVERGE fix branch does not re-assign head from the fix before re-converging', () => {
163
- assert.doesNotMatch(
164
- fixBranchAfter('${findings.length} finding(s) — applying fixes'),
165
- /head\s*=\s*fixProgress/,
166
- 'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the fix SHA here is dead',
167
- );
168
- });
169
-
170
- test('the COPILOT fix branch does not re-assign head from the fix before re-converging', () => {
171
- assert.doesNotMatch(
172
- fixBranchAfter('${copilotOutcome.findings.length} finding(s) — fixing and re-converging'),
173
- /head\s*=\s*fixProgress/,
174
- 'the CONVERGE pass it transitions to re-resolves HEAD from GitHub, so assigning the fix SHA here is dead',
175
- );
176
- });
177
-
178
- test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
179
- const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
180
- assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
181
- const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
182
- assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
183
- });
184
-
185
- test('fix prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
186
- const fixPrompt = lensPromptBody('applyFixes');
187
- assert.match(fixPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
188
- assert.match(
189
- fixPrompt,
190
- /databaseId/,
191
- 'expected the GraphQL lookup matching comment databaseId to be named',
192
- );
193
- assert.match(
194
- fixPrompt,
195
- /not the numeric comment id/,
196
- 'expected an explicit guard against passing the numeric comment id to resolve_thread',
197
- );
198
- });
199
-
200
- test('fix prompt does not pass the numeric comment id straight to resolve_thread', () => {
201
- assert.doesNotMatch(
202
- lensPromptBody('applyFixes'),
203
- /then resolve that thread \(use the github MCP pull_request_review_write/,
204
- 'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
205
- );
206
- });
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+ const gotchasSource = readFileSync(
10
+ join(workflowDirectory, '..', 'reference', 'gotchas.md'),
11
+ 'utf8',
12
+ );
13
+
14
+ function lensPromptBody(builderName) {
15
+ const builderStart = convergeSource.indexOf(`function ${builderName}(`);
16
+ assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
17
+ const nextBuilderStart = convergeSource.indexOf('\nfunction ', builderStart + 1);
18
+ const builderEnd = nextBuilderStart === -1 ? convergeSource.length : nextBuilderStart;
19
+ return convergeSource.slice(builderStart, builderEnd);
20
+ }
21
+
22
+ test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
23
+ assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
24
+ });
25
+
26
+ test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
27
+ assert.doesNotMatch(lensPromptBody('runAuditLens'), /git fetch origin main/);
28
+ });
29
+
30
+ test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
31
+ assert.match(convergeSource, /function prefetchMainForRound\(/);
32
+ const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
33
+ const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
34
+ assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
35
+ assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
36
+ assert.ok(
37
+ prefetchCallIndex < parallelLensIndex,
38
+ 'expected the round prefetch to run before the parallel lenses spawn',
39
+ );
40
+ });
41
+
42
+ test('bugbot lens preamble does not blanket-instruct passing --owner/--repo to every script', () => {
43
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
44
+ assert.doesNotMatch(
45
+ bugbotPrompt,
46
+ /use the existing scripts; pass --owner/,
47
+ 'the blanket clause breaks reviews_disabled.py, which accepts only --reviewer',
48
+ );
49
+ });
50
+
51
+ test('bugbot lens invokes reviews_disabled.py with only --reviewer', () => {
52
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
53
+ const reviewsDisabledIndex = bugbotPrompt.indexOf('reviews_disabled.py');
54
+ assert.notEqual(reviewsDisabledIndex, -1, 'expected reviews_disabled.py invocation');
55
+ const invocationLineEnd = bugbotPrompt.indexOf('\\n', reviewsDisabledIndex);
56
+ const invocationLine = bugbotPrompt.slice(reviewsDisabledIndex, invocationLineEnd);
57
+ assert.match(invocationLine, /--reviewer bugbot/);
58
+ assert.doesNotMatch(
59
+ invocationLine,
60
+ /--owner|--repo/,
61
+ 'reviews_disabled.py argparse rejects --owner/--repo with SystemExit(2)',
62
+ );
63
+ });
64
+
65
+ test('gotchas doc states parallel lenses must avoid concurrent git operations', () => {
66
+ assert.doesNotMatch(gotchasSource, /cannot race on git state/);
67
+ assert.match(gotchasSource, /fetch.*once.*before/i);
68
+ });
69
+
70
+ test('repair-convergence filters unresolved threads to bot authors and skips human threads', () => {
71
+ const repairPrompt = lensPromptBody('repairConvergence');
72
+ assert.match(
73
+ repairPrompt,
74
+ /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
75
+ 'expected the bot-author allowlist (Cursor/Claude/Copilot) to be named',
76
+ );
77
+ assert.match(
78
+ repairPrompt,
79
+ /skip.*human|human.*skip/is,
80
+ 'expected an explicit instruction to skip human reviewer threads',
81
+ );
82
+ });
83
+
84
+ test('repair-convergence no longer instructs resolving every unresolved thread without an author filter', () => {
85
+ const repairPrompt = lensPromptBody('repairConvergence');
86
+ assert.doesNotMatch(
87
+ repairPrompt,
88
+ /fetch every thread where isResolved is false/,
89
+ 'the unfiltered instruction could resolve human reviewer threads',
90
+ );
91
+ });
92
+
93
+ test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
94
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
95
+ assert.match(bugbotPrompt, /sleep 60/, 'expected a shell-agnostic 60-second poll delay');
96
+ assert.match(bugbotPrompt, /sleep 8/, 'expected a concrete 8-second delay command');
97
+ assert.match(
98
+ bugbotPrompt,
99
+ /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
100
+ 'expected PowerShell to be named only as an allowed alternative',
101
+ );
102
+ assert.doesNotMatch(
103
+ bugbotPrompt,
104
+ /wait 8 seconds(?!,)/,
105
+ 'the vague "wait 8 seconds" phrasing must carry a concrete command',
106
+ );
107
+ });
108
+
109
+ test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
110
+ const copilotPrompt = lensPromptBody('runCopilotGate');
111
+ assert.match(copilotPrompt, /sleep 360/, 'expected a shell-agnostic 360-second poll delay');
112
+ assert.match(
113
+ copilotPrompt,
114
+ /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
115
+ 'expected PowerShell to be named only as an allowed alternative',
116
+ );
117
+ });
118
+
119
+ test('gotchas doc describes the reviewer wait as shell-agnostic', () => {
120
+ assert.match(
121
+ gotchasSource,
122
+ /\bsleep\b/i,
123
+ 'expected the wait guidance to name a shell-agnostic sleep',
124
+ );
125
+ assert.doesNotMatch(
126
+ gotchasSource,
127
+ /a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
128
+ 'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
129
+ );
130
+ });
131
+
132
+ function finalizeRepairBranch() {
133
+ const repairCallIndex = convergeSource.indexOf('await repairConvergence(');
134
+ assert.notEqual(repairCallIndex, -1, 'expected the FINALIZE repair call to exist');
135
+ const transitionIndex = convergeSource.indexOf("phase = 'CONVERGE'", repairCallIndex);
136
+ assert.notEqual(transitionIndex, -1, 'expected a CONVERGE transition after the repair call');
137
+ const continueIndex = convergeSource.indexOf('continue', transitionIndex);
138
+ assert.notEqual(continueIndex, -1, 'expected a continue statement to close the FINALIZE repair branch');
139
+ const branchEnd = continueIndex + 'continue'.length;
140
+ return convergeSource.slice(repairCallIndex, branchEnd);
141
+ }
142
+
143
+ test('the FINALIZE repair branch does not re-assign head from the repair before re-converging', () => {
144
+ assert.doesNotMatch(
145
+ finalizeRepairBranch(),
146
+ /head\s*=\s*repair/,
147
+ 'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the repair SHA here is dead',
148
+ );
149
+ });
150
+
151
+ function fixBranchAfter(branchLabel) {
152
+ const labelIndex = convergeSource.indexOf(branchLabel);
153
+ assert.notEqual(labelIndex, -1, `expected the ${branchLabel} marker to exist`);
154
+ const applyFixesIndex = convergeSource.indexOf('await applyFixes(', labelIndex);
155
+ assert.notEqual(applyFixesIndex, -1, `expected an applyFixes call after ${branchLabel}`);
156
+ const continueIndex = convergeSource.indexOf('continue', applyFixesIndex);
157
+ assert.notEqual(continueIndex, -1, `expected a continue statement to close the ${branchLabel} branch`);
158
+ const branchEnd = continueIndex + 'continue'.length;
159
+ return convergeSource.slice(applyFixesIndex, branchEnd);
160
+ }
161
+
162
+ test('the CONVERGE fix branch does not re-assign head from the fix before re-converging', () => {
163
+ assert.doesNotMatch(
164
+ fixBranchAfter('${findings.length} finding(s) — applying fixes'),
165
+ /head\s*=\s*fixProgress/,
166
+ 'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the fix SHA here is dead',
167
+ );
168
+ });
169
+
170
+ test('the COPILOT fix branch does not re-assign head from the fix before re-converging', () => {
171
+ assert.doesNotMatch(
172
+ fixBranchAfter('${copilotOutcome.findings.length} finding(s) — fixing and re-converging'),
173
+ /head\s*=\s*fixProgress/,
174
+ 'the CONVERGE pass it transitions to re-resolves HEAD from GitHub, so assigning the fix SHA here is dead',
175
+ );
176
+ });
177
+
178
+ test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
179
+ const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
180
+ assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
181
+ const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
182
+ assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
183
+ });
184
+
185
+ test('fix prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
186
+ const fixPrompt = lensPromptBody('applyFixes');
187
+ assert.match(fixPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
188
+ assert.match(
189
+ fixPrompt,
190
+ /databaseId/,
191
+ 'expected the GraphQL lookup matching comment databaseId to be named',
192
+ );
193
+ assert.match(
194
+ fixPrompt,
195
+ /not the numeric comment id/,
196
+ 'expected an explicit guard against passing the numeric comment id to resolve_thread',
197
+ );
198
+ });
199
+
200
+ test('fix prompt does not pass the numeric comment id straight to resolve_thread', () => {
201
+ assert.doesNotMatch(
202
+ lensPromptBody('applyFixes'),
203
+ /then resolve that thread \(use the github MCP pull_request_review_write/,
204
+ 'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
205
+ );
206
+ });
@@ -105,6 +105,31 @@ const FIX_SCHEMA = {
105
105
  required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary'],
106
106
  }
107
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
+
108
133
  const CONVERGENCE_SCHEMA = {
109
134
  type: 'object',
110
135
  additionalProperties: false,
@@ -124,6 +149,17 @@ const READY_SCHEMA = {
124
149
  required: ['ready'],
125
150
  }
126
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
+
127
163
  const SEVERITY_RANK = { P0: 0, P1: 1, P2: 2 }
128
164
  const SHA_COMPARISON_PREFIX_LENGTH = 7
129
165
 
@@ -579,17 +615,41 @@ function applyFixes(head, findings, sourceLabel) {
579
615
 
580
616
  /**
581
617
  * Post the terminal CLEAN bugteam audit artifact so check_convergence.py sees
582
- * 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.
583
624
  * @param {string} head converged PR HEAD SHA
584
- * @returns {Promise<string>} agent transcript (unused)
625
+ * @returns {Promise<object>} CLEAN_AUDIT_SCHEMA result
585
626
  */
586
627
  function postCleanAudit(head) {
587
628
  return convergeAgent(
588
629
  `Post a CLEAN bugteam audit review on ${prCoordinates} at commit ${head}. All review lenses are clean on this HEAD.\n\n` +
589
630
  `Write an empty findings file: create a temp file containing exactly [] (an empty JSON array). Then run:\n` +
590
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` +
591
- `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.`,
592
- { 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.`
593
653
  )
594
654
  }
595
655
 
@@ -730,6 +790,51 @@ function spawnStandardsFollowUp(head, findings, sourceLabel) {
730
790
  )
731
791
  }
732
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
+
733
838
  let phase = 'CONVERGE'
734
839
  let head = null
735
840
  let rounds = 0
@@ -739,6 +844,8 @@ let bugbotDown = input.bugbotDisabled || false
739
844
  let copilotDown = false
740
845
  let copilotNote = null
741
846
  let standardsNote = null
847
+ const allRoundFindings = []
848
+ const fixSummaries = []
742
849
 
743
850
  while (iterations < CONFIG.maxIterations) {
744
851
  iterations += 1
@@ -765,15 +872,22 @@ while (iterations < CONFIG.maxIterations) {
765
872
  const findings = roundOutcome.findings
766
873
  if (isStandardsOnlyRound(findings)) {
767
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)
768
876
  await spawnStandardsFollowUp(head, findings, 'converge-round')
769
877
  standardsNote = `${findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
770
- await postCleanAudit(head)
878
+ const auditResult = await postCleanAudit(head)
879
+ if (!auditResult?.posted) {
880
+ blocker = cleanAuditBlocker(head, auditResult)
881
+ break
882
+ }
771
883
  phase = 'COPILOT'
772
884
  continue
773
885
  }
774
886
  if (findings.length > 0) {
775
887
  log(`Round ${rounds}: ${findings.length} finding(s) — applying fixes`)
888
+ allRoundFindings.push(...findings)
776
889
  const fixResult = await applyFixes(head, findings, 'converge-round')
890
+ if (fixResult?.summary) fixSummaries.push(fixResult.summary)
777
891
  const hadThreadBearingFinding = findings.some((each) => collectFindingThreadIds(each).length > 0)
778
892
  const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
779
893
  if (!fixProgress.progressed) {
@@ -789,7 +903,11 @@ while (iterations < CONFIG.maxIterations) {
789
903
  continue
790
904
  }
791
905
  log(`Round ${rounds}: all lenses clean on ${head?.slice(0, 7)} — posting clean audit artifact`)
792
- await postCleanAudit(head)
906
+ const auditResult = await postCleanAudit(head)
907
+ if (!auditResult?.posted) {
908
+ blocker = cleanAuditBlocker(head, auditResult)
909
+ break
910
+ }
793
911
  phase = 'COPILOT'
794
912
  continue
795
913
  }
@@ -813,6 +931,7 @@ while (iterations < CONFIG.maxIterations) {
813
931
  if (copilotOutcome.kind === 'fix') {
814
932
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
815
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)
816
935
  await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
817
936
  standardsNote = `${copilotOutcome.findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
818
937
  copilotDown = false
@@ -821,7 +940,9 @@ while (iterations < CONFIG.maxIterations) {
821
940
  continue
822
941
  }
823
942
  log(`Copilot raised ${copilotOutcome.findings.length} finding(s) — fixing and re-converging`)
943
+ allRoundFindings.push(...copilotOutcome.findings)
824
944
  const fixResult = await applyFixes(head, copilotOutcome.findings, 'copilot')
945
+ if (fixResult?.summary) fixSummaries.push(fixResult.summary)
825
946
  const hadThreadBearingFinding = copilotOutcome.findings.some((each) => collectFindingThreadIds(each).length > 0)
826
947
  const fixProgress = detectFixProgress(fixResult, head, hadThreadBearingFinding)
827
948
  if (!fixProgress.progressed) {
@@ -850,6 +971,7 @@ while (iterations < CONFIG.maxIterations) {
850
971
  const readyResult = await markReady(head, copilotDown)
851
972
  const readyOutcome = classifyReadyOutcome(readyResult)
852
973
  if (readyOutcome.converged) {
974
+ await spawnConvergenceSummary(dedupeFindings(allRoundFindings), fixSummaries, rounds, standardsNote, copilotNote)
853
975
  return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote }
854
976
  }
855
977
  blocker = readyOutcome.blocker