claude-dev-env 1.57.2 → 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 (77) 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 +317 -54
  10. package/bin/install.test.mjs +478 -3
  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/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
@@ -6,8 +6,6 @@ skill still runs teardown (revoke permissions, final report).
6
6
 
7
7
  ## Blockers (end the run short of ready)
8
8
 
9
- - **Copilot no-show** — Copilot surfaces no review on the current HEAD after
10
- three polls (360 seconds apart). `blocker` names the Copilot timeout.
11
9
  - **Iteration cap** — 20 loop iterations pass without a full convergence-check
12
10
  pass. The iteration counter increments on every pass through any phase, so a
13
11
  convergence-check gate that no round can clear (for example a `mergeable_state`
@@ -40,6 +38,13 @@ skill still runs teardown (revoke permissions, final report).
40
38
  run or review after the lens poll budget, the Bugbot lens returns `down: true`.
41
39
  The run continues, and the convergence check runs with `--bugbot-down` so its
42
40
  Bugbot gate is bypassed.
41
+ - **Copilot down or out of quota** — when Copilot posts an out-of-usage notice on
42
+ the current HEAD (the user who requested the review reached their quota limit)
43
+ rather than a code review, or surfaces no review at all after three polls, the
44
+ Copilot gate returns `down: true`. The run logs a notice, runs the convergence
45
+ check with `--copilot-down` (the Copilot review gate and the
46
+ pending-requested-reviews gate bypassed), and marks the PR ready. `copilotNote`
47
+ records the bypass for the final report.
43
48
  - **A lens agent dies** — when one parallel lens returns null (a terminal agent
44
49
  failure), the round proceeds on the surviving lenses. A real defect it would
45
50
  have caught surfaces in a later round or at the convergence check. A dead
@@ -0,0 +1,105 @@
1
+ """Named constants for render_report.py."""
2
+
3
+ STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput"
4
+
5
+ LABEL_RESOLVE_HEAD = "resolve-head"
6
+ LABEL_PREFIX_LENS = "lens:"
7
+ LABEL_PREFIX_FIX = "fix:"
8
+ LABEL_COPILOT_GATE = "copilot-gate"
9
+
10
+ JOURNAL_SIBLING_SUBAGENTS = "subagents"
11
+ JOURNAL_SIBLING_WORKFLOWS = "workflows"
12
+
13
+ THEME_PATH_SEGMENT_COUNT = 2
14
+ THEME_FALLBACK = "other"
15
+
16
+ SEVERITY_CRITICAL_BUCKET = "Critical"
17
+ SEVERITY_MINOR_BUCKET = "Minor"
18
+ SEVERITY_CRITICAL_LEVELS = frozenset({"P0", "P1"})
19
+ SEVERITY_BADGE_CLASS_BY_LEVEL = {
20
+ "P0": "b-p0",
21
+ "P1": "b-p1",
22
+ "P2": "b-p2",
23
+ }
24
+
25
+ BAR_COLOR_SEVERITY_CRITICAL = "#dc2626"
26
+ BAR_COLOR_SEVERITY_MINOR = "#eab308"
27
+ BAR_COLOR_ROUND = "#2563eb"
28
+ BAR_COLOR_TESTS = "#10b981"
29
+ BAR_COLOR_THEME = "#8b5cf6"
30
+
31
+ TEST_DEFINITION_PATTERN = r"^\+\s*(async\s+)?def\s+(test|should)"
32
+ TEST_PATH_GLOBS = ("*test*.py", "**/*test*.py")
33
+
34
+ BAR_FILL_MAX_PERCENT = 100
35
+
36
+ GITHUB_PR_URL_TEMPLATE = "https://github.com/{owner}/{repo}/pull/{number}"
37
+
38
+ HTML_DOCTYPE = "<!DOCTYPE html>"
39
+
40
+ HTML_HEAD_TEMPLATE = """\
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <title>PR #{pr_number} Convergence Insights</title>
44
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
45
+ {style_block}
46
+ </head>"""
47
+
48
+ HTML_STYLE_BLOCK = """\
49
+ <style>
50
+ * { box-sizing: border-box; margin: 0; padding: 0; }
51
+ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #334155; line-height: 1.65; padding: 48px 24px; }
52
+ .container { max-width: 800px; margin: 0 auto; }
53
+ h1 { font-size: 32px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
54
+ h2 { font-size: 20px; font-weight: 600; color: #0f172a; margin-top: 48px; margin-bottom: 16px; }
55
+ .subtitle { color: #64748b; font-size: 15px; margin-bottom: 32px; }
56
+ .nav-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 32px 0; padding: 16px; background: white; border-radius: 8px; border: 1px solid #e2e8f0; }
57
+ .nav-toc a { font-size: 12px; color: #64748b; text-decoration: none; padding: 6px 12px; border-radius: 6px; background: #f1f5f9; transition: all 0.15s; }
58
+ .nav-toc a:hover { background: #e2e8f0; color: #334155; }
59
+ .stats-row { display: flex; gap: 24px; margin-bottom: 40px; padding: 20px 0; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; }
60
+ .stat { text-align: center; }
61
+ .stat-value { font-size: 24px; font-weight: 700; color: #0f172a; }
62
+ .stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; }
63
+ .at-a-glance { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #f59e0b; border-radius: 12px; padding: 20px 24px; margin-bottom: 32px; }
64
+ .glance-title { font-size: 16px; font-weight: 700; color: #92400e; margin-bottom: 16px; }
65
+ .glance-sections { display: flex; flex-direction: column; gap: 12px; }
66
+ .glance-section { font-size: 14px; color: #78350f; line-height: 1.6; }
67
+ .glance-section strong { color: #92400e; }
68
+ .section-intro { font-size: 14px; color: #64748b; margin-bottom: 16px; }
69
+ .charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 24px 0; }
70
+ .chart-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
71
+ .chart-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 12px; }
72
+ .bar-row { display: flex; align-items: center; margin-bottom: 6px; }
73
+ .bar-label { width: 116px; font-size: 11px; color: #475569; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
+ .bar-track { flex: 1; height: 6px; background: #f1f5f9; border-radius: 3px; margin: 0 8px; }
75
+ .bar-fill { height: 100%; border-radius: 3px; }
76
+ .bar-value { width: 28px; font-size: 11px; font-weight: 500; color: #64748b; text-align: right; }
77
+ .bugs { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
78
+ .bug-card { border-radius: 8px; padding: 16px; }
79
+ .bug-card.crit { background: #fef2f2; border: 1px solid #fca5a5; }
80
+ .bug-card.minor { background: #fffbeb; border: 1px solid #fcd34d; }
81
+ .bug-head { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
82
+ .bug-num { font-size: 13px; font-weight: 700; color: #94a3b8; min-width: 28px; }
83
+ .bug-title { flex: 1 1 300px; font-weight: 600; font-size: 15px; }
84
+ .bug-card.crit .bug-title { color: #991b1b; }
85
+ .bug-card.minor .bug-title { color: #92400e; }
86
+ .badges { display: flex; gap: 6px; flex-wrap: wrap; }
87
+ .badge { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; padding: 3px 8px; border-radius: 20px; white-space: nowrap; }
88
+ .b-p0 { background: #fee2e2; color: #991b1b; }
89
+ .b-p1 { background: #fee2e2; color: #991b1b; }
90
+ .b-p2 { background: #fef3c7; color: #92400e; }
91
+ .b-fixed { background: #dcfce7; color: #166534; }
92
+ .bug-impact { font-size: 13px; color: #7f1d1d; margin-top: 10px; line-height: 1.55; }
93
+ .bug-card.minor .bug-impact { color: #78350f; }
94
+ .bug-fix { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 10px 12px; margin-top: 10px; font-size: 13px; color: #166534; line-height: 1.5; }
95
+ .bug-fix b { color: #15803d; }
96
+ .bug-meta { font-size: 11px; color: #94a3b8; margin-top: 10px; }
97
+ .bug-meta code { background: #f1f5f9; padding: 1px 6px; border-radius: 4px; font-family: monospace; color: #475569; }
98
+ .horizon-card { background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%); border: 1px solid #c4b5fd; border-radius: 8px; padding: 16px; }
99
+ .horizon-title { font-weight: 600; font-size: 15px; color: #5b21b6; margin-bottom: 8px; }
100
+ .horizon-possible { font-size: 14px; color: #334155; margin-bottom: 10px; line-height: 1.5; }
101
+ .horizon-tip { font-size: 13px; color: #6b21a8; background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: 4px; }
102
+ footer { margin-top: 40px; padding-top: 18px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 12px; }
103
+ footer code { background: #f1f5f9; padding: 1px 6px; border-radius: 4px; font-family: monospace; color: #475569; }
104
+ @media (max-width: 640px) { .charts-row { grid-template-columns: 1fr; } .stats-row { justify-content: center; } }
105
+ </style>"""
@@ -134,7 +134,9 @@ function finalizeRepairBranch() {
134
134
  assert.notEqual(repairCallIndex, -1, 'expected the FINALIZE repair call to exist');
135
135
  const transitionIndex = convergeSource.indexOf("phase = 'CONVERGE'", repairCallIndex);
136
136
  assert.notEqual(transitionIndex, -1, 'expected a CONVERGE transition after the repair call');
137
- const branchEnd = convergeSource.indexOf('continue', transitionIndex) + 'continue'.length;
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;
138
140
  return convergeSource.slice(repairCallIndex, branchEnd);
139
141
  }
140
142
 
@@ -146,6 +148,33 @@ test('the FINALIZE repair branch does not re-assign head from the repair before
146
148
  );
147
149
  });
148
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
+
149
178
  test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
150
179
  const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
151
180
  assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
@@ -0,0 +1,265 @@
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
+
10
+ function functionBody(functionName) {
11
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
12
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
13
+ const nextFunctionStart = convergeSource.indexOf('\nfunction ', functionStart + 1);
14
+ const functionEnd = nextFunctionStart === -1 ? convergeSource.length : nextFunctionStart;
15
+ return convergeSource.slice(functionStart, functionEnd);
16
+ }
17
+
18
+ const productionModule = new Function(
19
+ `${functionBody('classifyCopilotOutcome')}\n` +
20
+ `${functionBody('resolveCopilotDown')}\n` +
21
+ 'return { classifyCopilotOutcome, resolveCopilotDown };',
22
+ )();
23
+ const { classifyCopilotOutcome, resolveCopilotDown } = productionModule;
24
+
25
+ function copilotResult(overrides) {
26
+ return {
27
+ sha: 'abcdef0',
28
+ clean: false,
29
+ down: false,
30
+ findings: [],
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ test('an out-of-usage Copilot result (down) routes to the down kind', () => {
36
+ const outcome = classifyCopilotOutcome(copilotResult({ clean: true, down: true }));
37
+ assert.equal(outcome.kind, 'down');
38
+ });
39
+
40
+ test('a down Copilot result routes to down even when clean is false', () => {
41
+ const outcome = classifyCopilotOutcome(copilotResult({ clean: false, down: true }));
42
+ assert.equal(outcome.kind, 'down');
43
+ });
44
+
45
+ test('a dead Copilot gate agent retries rather than passing', () => {
46
+ assert.equal(classifyCopilotOutcome(null).kind, 'retry');
47
+ });
48
+
49
+ test('a reachable Copilot gate with no findings and no clean verdict retries', () => {
50
+ const outcome = classifyCopilotOutcome(copilotResult({ clean: false, down: false }));
51
+ assert.equal(outcome.kind, 'retry');
52
+ });
53
+
54
+ test('Copilot findings route to a fix when Copilot is reachable and not down', () => {
55
+ const outcome = classifyCopilotOutcome(
56
+ copilotResult({
57
+ findings: [
58
+ {
59
+ file: 'a.py',
60
+ line: 1,
61
+ severity: 'P1',
62
+ category: 'bug',
63
+ title: 't',
64
+ detail: 'd',
65
+ replyToCommentId: null,
66
+ },
67
+ ],
68
+ }),
69
+ );
70
+ assert.equal(outcome.kind, 'fix');
71
+ });
72
+
73
+ test('COPILOT_SCHEMA carries a required down field', () => {
74
+ const schemaStart = convergeSource.indexOf('const COPILOT_SCHEMA =');
75
+ const schemaEnd = convergeSource.indexOf('const HEAD_SCHEMA =');
76
+ assert.notEqual(schemaStart, -1, 'expected COPILOT_SCHEMA to exist');
77
+ const schemaSource = convergeSource.slice(schemaStart, schemaEnd);
78
+ assert.match(schemaSource, /down:\s*\{\s*type:\s*'boolean'/);
79
+ assert.match(schemaSource, /required:\s*\[[^\]]*'down'[^\]]*\]/);
80
+ assert.doesNotMatch(
81
+ schemaSource,
82
+ /blocker:/,
83
+ 'the Copilot gate no longer surfaces a blocker; a down result carries the outage',
84
+ );
85
+ });
86
+
87
+ test('the Copilot gate prompt detects an out-of-usage notice and returns a down result', () => {
88
+ const copilotPrompt = functionBody('runCopilotGate');
89
+ assert.match(
90
+ copilotPrompt,
91
+ /quota|out of usage|out-of-usage/i,
92
+ 'expected the gate to name the out-of-usage / quota signal',
93
+ );
94
+ assert.match(
95
+ copilotPrompt,
96
+ /down:\s*true/,
97
+ 'expected the gate to return down:true on an out-of-usage notice',
98
+ );
99
+ });
100
+
101
+ test('the step-1 out-of-usage down-detection requires the notice commit_id to start with HEAD', () => {
102
+ const copilotPrompt = functionBody('runCopilotGate');
103
+ const stepOneStart = copilotPrompt.indexOf('`1.');
104
+ assert.notEqual(stepOneStart, -1, 'expected a step-1 instruction in the gate prompt');
105
+ const stepTwoStart = copilotPrompt.indexOf('`2.', stepOneStart);
106
+ assert.notEqual(stepTwoStart, -1, 'expected a step-2 instruction in the gate prompt');
107
+ const stepOneText = copilotPrompt.slice(stepOneStart, stepTwoStart);
108
+ assert.match(
109
+ stepOneText,
110
+ /commit_id starts with \$\{head\}/,
111
+ 'expected step 1 to scope the out-of-usage notice to reviews whose commit_id starts with HEAD, matching step 2 and the convergence gate',
112
+ );
113
+ });
114
+
115
+ test('a Copilot no-show after the poll cap returns a down result rather than a blocker', () => {
116
+ const copilotPrompt = functionBody('runCopilotGate');
117
+ const noReviewStart = copilotPrompt.indexOf('No review after');
118
+ assert.notEqual(noReviewStart, -1, 'expected a no-show branch in the gate prompt');
119
+ const noReviewBranch = copilotPrompt.slice(noReviewStart, noReviewStart + 200);
120
+ assert.match(
121
+ noReviewBranch,
122
+ /down:\s*true/,
123
+ 'expected a Copilot no-show after the poll cap to return down:true',
124
+ );
125
+ assert.doesNotMatch(
126
+ noReviewBranch,
127
+ /blocker:/,
128
+ 'expected the no-show branch to carry a down result, not a blocker',
129
+ );
130
+ });
131
+
132
+ test('checkConvergence wires the --copilot-down flag from a copilotDown argument', () => {
133
+ const checkConvergenceBody = functionBody('checkConvergence');
134
+ assert.match(
135
+ checkConvergenceBody,
136
+ /copilotDown \? ' --copilot-down' : ''/,
137
+ 'expected checkConvergence to append --copilot-down when copilotDown is set',
138
+ );
139
+ assert.match(
140
+ checkConvergenceBody,
141
+ /\$\{copilotDownFlag\}/,
142
+ 'expected the --copilot-down flag to be interpolated into the script invocation',
143
+ );
144
+ });
145
+
146
+ test('the COPILOT phase routes a down outcome to FINALIZE with the gate bypassed', () => {
147
+ const copilotPhaseStart = convergeSource.indexOf("if (phase === 'COPILOT') {");
148
+ assert.notEqual(copilotPhaseStart, -1, 'expected a COPILOT phase block');
149
+ const downBranchStart = convergeSource.indexOf("copilotOutcome.kind === 'down'", copilotPhaseStart);
150
+ assert.notEqual(downBranchStart, -1, 'expected the COPILOT phase to handle a down outcome');
151
+ const downBranch = convergeSource.slice(downBranchStart, downBranchStart + 400);
152
+ assert.match(downBranch, /copilotDown = true/);
153
+ assert.match(downBranch, /copilotNote =/);
154
+ assert.match(downBranch, /phase = 'FINALIZE'/);
155
+ });
156
+
157
+ test('resolveCopilotDown reports down only for a down outcome', () => {
158
+ assert.equal(resolveCopilotDown({ kind: 'down' }), true);
159
+ });
160
+
161
+ test('resolveCopilotDown clears the bypass for an approved outcome', () => {
162
+ assert.equal(resolveCopilotDown({ kind: 'approved' }), false);
163
+ });
164
+
165
+ test('resolveCopilotDown clears the bypass for a fix outcome carrying findings', () => {
166
+ assert.equal(
167
+ resolveCopilotDown({
168
+ kind: 'fix',
169
+ findings: [
170
+ {
171
+ file: 'a.py',
172
+ line: 1,
173
+ severity: 'P1',
174
+ category: 'bug',
175
+ title: 't',
176
+ detail: 'd',
177
+ replyToCommentId: null,
178
+ },
179
+ ],
180
+ }),
181
+ false,
182
+ );
183
+ });
184
+
185
+ test('resolveCopilotDown clears the bypass for a retry outcome', () => {
186
+ assert.equal(resolveCopilotDown({ kind: 'retry' }), false);
187
+ });
188
+
189
+ test('the standards-only Copilot sub-path resets copilotDown before FINALIZE', () => {
190
+ const standardsBranchStart = convergeSource.indexOf(
191
+ 'isStandardsOnlyRound(copilotOutcome.findings)',
192
+ );
193
+ assert.notEqual(
194
+ standardsBranchStart,
195
+ -1,
196
+ 'expected the COPILOT phase to handle a standards-only Copilot fix outcome',
197
+ );
198
+ const standardsBranch = convergeSource.slice(standardsBranchStart, standardsBranchStart + 600);
199
+ const resetIndex = standardsBranch.indexOf('copilotDown = false');
200
+ const finalizeIndex = standardsBranch.indexOf("phase = 'FINALIZE'");
201
+ assert.notEqual(
202
+ resetIndex,
203
+ -1,
204
+ 'expected the standards-only sub-path to reset copilotDown so a recovered Copilot is not bypassed',
205
+ );
206
+ assert.notEqual(finalizeIndex, -1, 'expected the standards-only sub-path to reach FINALIZE');
207
+ assert.ok(
208
+ resetIndex < finalizeIndex,
209
+ 'expected copilotDown to be cleared before the transition to FINALIZE',
210
+ );
211
+ assert.match(
212
+ standardsBranch.slice(0, finalizeIndex),
213
+ /copilotNote = null/,
214
+ 'expected the standards-only sub-path to clear the stale copilotNote alongside copilotDown',
215
+ );
216
+ });
217
+
218
+ test('the COPILOT phase recomputes copilotDown from each gate outcome via resolveCopilotDown', () => {
219
+ const copilotPhaseStart = convergeSource.indexOf("if (phase === 'COPILOT') {");
220
+ assert.notEqual(copilotPhaseStart, -1, 'expected a COPILOT phase block');
221
+ const finalizePhaseStart = convergeSource.indexOf(
222
+ "if (phase === 'FINALIZE') {",
223
+ copilotPhaseStart,
224
+ );
225
+ assert.notEqual(finalizePhaseStart, -1, 'expected a FINALIZE phase block after COPILOT');
226
+ const copilotPhase = convergeSource.slice(copilotPhaseStart, finalizePhaseStart);
227
+ assert.match(
228
+ copilotPhase,
229
+ /copilotDown = resolveCopilotDown\(copilotOutcome\)/,
230
+ 'expected the COPILOT phase to recompute copilotDown from the current outcome so a recovered Copilot is never bypassed',
231
+ );
232
+ });
233
+
234
+ test('markReady receives copilotDown so it can opt the unflagged hook out of the Copilot gate', () => {
235
+ const finalizeStart = convergeSource.indexOf("if (phase === 'FINALIZE') {");
236
+ assert.notEqual(finalizeStart, -1, 'expected a FINALIZE phase block');
237
+ const markReadyCall = convergeSource.indexOf('await markReady(', finalizeStart);
238
+ assert.notEqual(markReadyCall, -1, 'expected the FINALIZE phase to call markReady');
239
+ const callSlice = convergeSource.slice(markReadyCall, markReadyCall + 40);
240
+ assert.match(
241
+ callSlice,
242
+ /markReady\(head,\s*copilotDown\)/,
243
+ 'expected markReady to receive copilotDown so the mark-ready agent can opt the unflagged hook out of the Copilot gate',
244
+ );
245
+ });
246
+
247
+ test('the markReady prompt opts the unflagged convergence hook out of Copilot when copilotDown', () => {
248
+ const markReadyBody = functionBody('markReady');
249
+ assert.match(
250
+ markReadyBody,
251
+ /copilotDown/,
252
+ 'expected markReady to branch on copilotDown',
253
+ );
254
+ assert.match(
255
+ markReadyBody,
256
+ /CLAUDE_REVIEWS_DISABLED/,
257
+ 'expected the markReady prompt to set CLAUDE_REVIEWS_DISABLED so the unflagged hook re-derives the Copilot bypass',
258
+ );
259
+ assert.match(
260
+ markReadyBody,
261
+ /copilot/,
262
+ 'expected the markReady opt-out to name the copilot token',
263
+ );
264
+ });
265
+