claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,507 +1,507 @@
1
- """Fixture-driven tests for the conditional Codex convergence gate.
2
-
3
- Covers: required-and-clean, required-and-dirty, skipped-by-threshold,
4
- skipped-by-token, skipped-by-down. Threshold comes from the probe constant
5
- via ``is_codex_review_required`` — this module never inlines the percent.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import importlib.util
11
- import json
12
- import sys
13
- from pathlib import Path
14
- from types import ModuleType
15
-
16
- import pytest
17
-
18
- import _pr_converge_path_setup # noqa: F401
19
- from codex_review_scripts_constants.codex_usage_probe_constants import (
20
- WEEKLY_USAGE_GATE_THRESHOLD_PERCENT,
21
- )
22
- from pr_converge_scripts_constants.convergence_gate_constants import (
23
- MINIMUM_ABBREVIATED_SHA_LENGTH,
24
- )
25
-
26
- _SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
27
- _PR_CONVERGE_DIRECTORY = _SCRIPTS_DIRECTORY.parent
28
-
29
- CLEAN_LABEL = "Clean \u2014 no findings"
30
- CLEAN_BODY = (
31
- f"**Bugteam audit completed** \u2014 {CLEAN_LABEL}\n\n"
32
- "No findings.\n"
33
- )
34
- HEAD_SHA = "ae8005aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
35
- OTHER_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
36
- PERCENT_ABOVE_THRESHOLD = float(WEEKLY_USAGE_GATE_THRESHOLD_PERCENT) + 1.0
37
- PERCENT_AT_THRESHOLD = float(WEEKLY_USAGE_GATE_THRESHOLD_PERCENT)
38
-
39
-
40
- def _load_module() -> ModuleType:
41
- for each_cached_name in [
42
- each_key
43
- for each_key in list(sys.modules)
44
- if each_key == "config" or each_key.startswith("config.")
45
- ]:
46
- sys.modules.pop(each_cached_name, None)
47
- if str(_PR_CONVERGE_DIRECTORY) in sys.path:
48
- sys.path.remove(str(_PR_CONVERGE_DIRECTORY))
49
- sys.path.insert(0, str(_PR_CONVERGE_DIRECTORY))
50
- module_path = _SCRIPTS_DIRECTORY / "check_convergence.py"
51
- spec = importlib.util.spec_from_file_location(
52
- "check_convergence_codex_under_test", module_path
53
- )
54
- assert spec is not None
55
- assert spec.loader is not None
56
- module = importlib.util.module_from_spec(spec)
57
- spec.loader.exec_module(module)
58
- return module
59
-
60
-
61
- check_convergence = _load_module()
62
-
63
-
64
- def _write_fixture(
65
- tmp_path: Path,
66
- *,
67
- codex_percent_left: float | None,
68
- codex_clean_at: str | None,
69
- filename: str = "codex-gate.json",
70
- ) -> Path:
71
- fixture_path = tmp_path / filename
72
- payload: dict[str, object] = {
73
- "head_sha": HEAD_SHA,
74
- "pr_object": {"mergeable": True, "mergeable_state": "clean"},
75
- "reviews": [
76
- {
77
- "id": 1,
78
- "body": CLEAN_BODY,
79
- "commit_id": HEAD_SHA,
80
- }
81
- ],
82
- "unresolved_bot_threads_passed": True,
83
- "unresolved_bot_threads_detail": "0 unresolved",
84
- "pending_reviews_passed": True,
85
- "pending_reviews_detail": "none pending",
86
- "codex_percent_left": codex_percent_left,
87
- "codex_clean_at": codex_clean_at,
88
- }
89
- fixture_path.write_text(json.dumps(payload), encoding="utf-8")
90
- return fixture_path
91
-
92
-
93
- def should_pass_when_codex_required_and_clean(
94
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
95
- ) -> None:
96
- fixture_path = _write_fixture(
97
- tmp_path,
98
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
99
- codex_clean_at=HEAD_SHA,
100
- filename="required-clean.json",
101
- )
102
- exit_code = check_convergence.main(
103
- [
104
- "--owner",
105
- "jl-cmd",
106
- "--repo",
107
- "claude-dev-env",
108
- "--pr-number",
109
- "111",
110
- "--fixture",
111
- str(fixture_path),
112
- "--bugbot-down",
113
- "--copilot-down",
114
- ]
115
- )
116
- captured = capsys.readouterr().out
117
- assert exit_code == 0
118
- assert "codex_clean_at == current_head: PASS" in captured
119
- assert "clean at" in captured
120
- assert "All pre-conditions met" in captured
121
-
122
-
123
- def should_pass_without_reading_usage_when_the_clean_stamp_is_on_head(
124
- monkeypatch: pytest.MonkeyPatch,
125
- ) -> None:
126
- def _probe_that_must_not_run() -> float | None:
127
- raise AssertionError(
128
- "the weekly usage probe must not run when codex_clean_at is on HEAD"
129
- )
130
-
131
- monkeypatch.setattr(
132
- check_convergence, "_probe_codex_percent_left", _probe_that_must_not_run
133
- )
134
-
135
- is_passed, detail = check_convergence._evaluate_codex_clean(
136
- read_percent_left=check_convergence._probe_codex_percent_left,
137
- codex_clean_at=HEAD_SHA,
138
- head_sha=HEAD_SHA,
139
- )
140
-
141
- assert is_passed is True
142
- assert "clean at" in detail
143
-
144
-
145
- def should_match_a_clean_stamp_whose_hex_case_differs_from_the_head_sha() -> None:
146
- assert check_convergence._sha_matches_head(HEAD_SHA.upper(), HEAD_SHA) is True
147
-
148
-
149
- def should_fail_when_codex_clean_stamp_is_shorter_than_an_abbreviated_sha(
150
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
151
- ) -> None:
152
- fixture_path = _write_fixture(
153
- tmp_path,
154
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
155
- codex_clean_at=HEAD_SHA[:1],
156
- filename="required-short-stamp.json",
157
- )
158
- exit_code = check_convergence.main(
159
- [
160
- "--owner",
161
- "jl-cmd",
162
- "--repo",
163
- "claude-dev-env",
164
- "--pr-number",
165
- "111",
166
- "--fixture",
167
- str(fixture_path),
168
- "--bugbot-down",
169
- "--copilot-down",
170
- ]
171
- )
172
- captured = capsys.readouterr().out
173
- assert exit_code == 1
174
- assert "codex_clean_at == current_head: FAIL" in captured
175
-
176
-
177
- def should_pass_when_codex_clean_stamp_is_an_abbreviated_head_sha(
178
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
179
- ) -> None:
180
- fixture_path = _write_fixture(
181
- tmp_path,
182
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
183
- codex_clean_at=HEAD_SHA[:MINIMUM_ABBREVIATED_SHA_LENGTH],
184
- filename="required-abbreviated-stamp.json",
185
- )
186
- exit_code = check_convergence.main(
187
- [
188
- "--owner",
189
- "jl-cmd",
190
- "--repo",
191
- "claude-dev-env",
192
- "--pr-number",
193
- "111",
194
- "--fixture",
195
- str(fixture_path),
196
- "--bugbot-down",
197
- "--copilot-down",
198
- ]
199
- )
200
- captured = capsys.readouterr().out
201
- assert exit_code == 0
202
- assert "codex_clean_at == current_head: PASS" in captured
203
-
204
-
205
- def should_fail_when_codex_required_and_dirty(
206
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
207
- ) -> None:
208
- fixture_path = _write_fixture(
209
- tmp_path,
210
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
211
- codex_clean_at=OTHER_SHA,
212
- filename="required-dirty.json",
213
- )
214
- exit_code = check_convergence.main(
215
- [
216
- "--owner",
217
- "jl-cmd",
218
- "--repo",
219
- "claude-dev-env",
220
- "--pr-number",
221
- "111",
222
- "--fixture",
223
- str(fixture_path),
224
- "--bugbot-down",
225
- "--copilot-down",
226
- ]
227
- )
228
- captured = capsys.readouterr().out
229
- assert exit_code == 1
230
- assert "codex_clean_at == current_head: FAIL" in captured
231
- assert "no codex clean on" in captured
232
- assert "do not mark ready" in captured
233
-
234
-
235
- def should_skip_when_usage_at_or_below_threshold(
236
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
237
- ) -> None:
238
- fixture_path = _write_fixture(
239
- tmp_path,
240
- codex_percent_left=PERCENT_AT_THRESHOLD,
241
- codex_clean_at=None,
242
- filename="skipped-threshold.json",
243
- )
244
- exit_code = check_convergence.main(
245
- [
246
- "--owner",
247
- "jl-cmd",
248
- "--repo",
249
- "claude-dev-env",
250
- "--pr-number",
251
- "111",
252
- "--fixture",
253
- str(fixture_path),
254
- "--bugbot-down",
255
- "--copilot-down",
256
- ]
257
- )
258
- captured = capsys.readouterr().out
259
- assert exit_code == 0
260
- assert "codex_clean_at == current_head: PASS" in captured
261
- assert "skipped (codex review not required)" in captured
262
-
263
-
264
- def should_skip_when_codex_percent_left_is_null(
265
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
266
- ) -> None:
267
- fixture_path = _write_fixture(
268
- tmp_path,
269
- codex_percent_left=None,
270
- codex_clean_at=None,
271
- filename="skipped-null.json",
272
- )
273
- exit_code = check_convergence.main(
274
- [
275
- "--owner",
276
- "jl-cmd",
277
- "--repo",
278
- "claude-dev-env",
279
- "--pr-number",
280
- "111",
281
- "--fixture",
282
- str(fixture_path),
283
- "--bugbot-down",
284
- "--copilot-down",
285
- ]
286
- )
287
- captured = capsys.readouterr().out
288
- assert exit_code == 0
289
- assert "skipped (codex review not required)" in captured
290
-
291
-
292
- def should_bypass_when_codex_token_disables_reviewer(
293
- tmp_path: Path,
294
- monkeypatch: pytest.MonkeyPatch,
295
- capsys: pytest.CaptureFixture[str],
296
- ) -> None:
297
- monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "codex")
298
- fixture_path = _write_fixture(
299
- tmp_path,
300
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
301
- codex_clean_at=None,
302
- filename="skipped-token.json",
303
- )
304
- exit_code = check_convergence.main(
305
- [
306
- "--owner",
307
- "jl-cmd",
308
- "--repo",
309
- "claude-dev-env",
310
- "--pr-number",
311
- "111",
312
- "--fixture",
313
- str(fixture_path),
314
- "--bugbot-down",
315
- "--copilot-down",
316
- ]
317
- )
318
- captured = capsys.readouterr().out
319
- assert exit_code == 0
320
- assert "bypassed (codex_down)" in captured
321
- assert "no codex clean on" not in captured
322
-
323
-
324
- def should_bypass_when_codex_down_flag_set(
325
- tmp_path: Path, capsys: pytest.CaptureFixture[str]
326
- ) -> None:
327
- fixture_path = _write_fixture(
328
- tmp_path,
329
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
330
- codex_clean_at=None,
331
- filename="skipped-down.json",
332
- )
333
- exit_code = check_convergence.main(
334
- [
335
- "--owner",
336
- "jl-cmd",
337
- "--repo",
338
- "claude-dev-env",
339
- "--pr-number",
340
- "111",
341
- "--fixture",
342
- str(fixture_path),
343
- "--bugbot-down",
344
- "--copilot-down",
345
- "--codex-down",
346
- ]
347
- )
348
- captured = capsys.readouterr().out
349
- assert exit_code == 0
350
- assert "bypassed (codex_down)" in captured
351
-
352
-
353
- def should_resolve_codex_down_true_when_flag_set(
354
- monkeypatch: pytest.MonkeyPatch,
355
- ) -> None:
356
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
357
- monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
358
- assert check_convergence._resolve_codex_down(
359
- True, check_convergence._read_job_state()
360
- ) is True
361
-
362
-
363
- def should_resolve_codex_down_true_when_env_disables_codex(
364
- monkeypatch: pytest.MonkeyPatch,
365
- ) -> None:
366
- monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
367
- monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "codex")
368
- assert check_convergence._resolve_codex_down(
369
- False, check_convergence._read_job_state()
370
- ) is True
371
-
372
-
373
- def should_resolve_codex_down_false_when_flag_unset_and_env_empty(
374
- monkeypatch: pytest.MonkeyPatch,
375
- ) -> None:
376
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
377
- monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
378
- assert check_convergence._resolve_codex_down(
379
- False, check_convergence._read_job_state()
380
- ) is False
381
-
382
-
383
- def should_resolve_codex_down_true_when_job_state_sticky_down(
384
- tmp_path: Path,
385
- monkeypatch: pytest.MonkeyPatch,
386
- ) -> None:
387
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
388
- monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
389
- (tmp_path / "pr-converge-state.json").write_text(
390
- json.dumps({"codex_down": True}),
391
- encoding="utf-8",
392
- )
393
- assert check_convergence._resolve_codex_down(
394
- False, check_convergence._read_job_state()
395
- ) is True
396
-
397
-
398
- def should_resolve_codex_down_false_when_job_state_codex_down_is_false(
399
- tmp_path: Path,
400
- monkeypatch: pytest.MonkeyPatch,
401
- ) -> None:
402
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
403
- monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
404
- (tmp_path / "pr-converge-state.json").write_text(
405
- json.dumps({"codex_down": False}),
406
- encoding="utf-8",
407
- )
408
- assert check_convergence._resolve_codex_down(
409
- False, check_convergence._read_job_state()
410
- ) is False
411
-
412
-
413
- def should_bypass_when_job_state_codex_down_is_true(
414
- tmp_path: Path,
415
- monkeypatch: pytest.MonkeyPatch,
416
- capsys: pytest.CaptureFixture[str],
417
- ) -> None:
418
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
419
- monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
420
- (tmp_path / "pr-converge-state.json").write_text(
421
- json.dumps({"codex_down": True}),
422
- encoding="utf-8",
423
- )
424
- fixture_path = _write_fixture(
425
- tmp_path,
426
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
427
- codex_clean_at=None,
428
- filename="job-state-codex-down.json",
429
- )
430
- exit_code = check_convergence.main(
431
- [
432
- "--owner",
433
- "jl-cmd",
434
- "--repo",
435
- "claude-dev-env",
436
- "--pr-number",
437
- "111",
438
- "--fixture",
439
- str(fixture_path),
440
- "--bugbot-down",
441
- "--copilot-down",
442
- ]
443
- )
444
- captured = capsys.readouterr().out
445
- assert exit_code == 0
446
- assert "bypassed (codex_down)" in captured
447
- assert "no codex clean on" not in captured
448
-
449
-
450
- def should_fail_when_job_state_lacks_codex_down_and_clean_stamp(
451
- tmp_path: Path,
452
- monkeypatch: pytest.MonkeyPatch,
453
- capsys: pytest.CaptureFixture[str],
454
- ) -> None:
455
- monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
456
- monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
457
- (tmp_path / "pr-converge-state.json").write_text(
458
- json.dumps({}),
459
- encoding="utf-8",
460
- )
461
- fixture_path = _write_fixture(
462
- tmp_path,
463
- codex_percent_left=PERCENT_ABOVE_THRESHOLD,
464
- codex_clean_at=None,
465
- filename="job-state-no-down.json",
466
- )
467
- exit_code = check_convergence.main(
468
- [
469
- "--owner",
470
- "jl-cmd",
471
- "--repo",
472
- "claude-dev-env",
473
- "--pr-number",
474
- "111",
475
- "--fixture",
476
- str(fixture_path),
477
- "--bugbot-down",
478
- "--copilot-down",
479
- ]
480
- )
481
- captured = capsys.readouterr().out
482
- assert exit_code == 1
483
- assert "codex_clean_at == current_head: FAIL" in captured
484
- assert "no codex clean on" in captured
485
-
486
-
487
- def should_accept_codex_down_flag_in_parsed_arguments() -> None:
488
- arguments = check_convergence.parse_arguments(
489
- ["--owner", "o", "--repo", "r", "--pr-number", "1", "--codex-down"]
490
- )
491
- assert arguments.codex_down is True
492
-
493
-
494
- def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
495
- arguments = check_convergence.parse_arguments(
496
- [
497
- "--owner",
498
- "o",
499
- "--repo",
500
- "r",
501
- "--pr-number",
502
- "1",
503
- "--codex-clean-at",
504
- HEAD_SHA,
505
- ]
506
- )
507
- assert arguments.codex_clean_at == HEAD_SHA
1
+ """Fixture-driven tests for the conditional Codex convergence gate.
2
+
3
+ Covers: required-and-clean, required-and-dirty, skipped-by-threshold,
4
+ skipped-by-token, skipped-by-down. Threshold comes from the probe constant
5
+ via ``is_codex_review_required`` — this module never inlines the percent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+ import _pr_converge_path_setup # noqa: F401
19
+ from codex_review_scripts_constants.codex_usage_probe_constants import (
20
+ WEEKLY_USAGE_GATE_THRESHOLD_PERCENT,
21
+ )
22
+ from pr_converge_scripts_constants.convergence_gate_constants import (
23
+ MINIMUM_ABBREVIATED_SHA_LENGTH,
24
+ )
25
+
26
+ _SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
27
+ _PR_CONVERGE_DIRECTORY = _SCRIPTS_DIRECTORY.parent
28
+
29
+ CLEAN_LABEL = "Clean \u2014 no findings"
30
+ CLEAN_BODY = (
31
+ f"**Bugteam audit completed** \u2014 {CLEAN_LABEL}\n\n"
32
+ "No findings.\n"
33
+ )
34
+ HEAD_SHA = "ae8005aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
35
+ OTHER_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
36
+ PERCENT_ABOVE_THRESHOLD = float(WEEKLY_USAGE_GATE_THRESHOLD_PERCENT) + 1.0
37
+ PERCENT_AT_THRESHOLD = float(WEEKLY_USAGE_GATE_THRESHOLD_PERCENT)
38
+
39
+
40
+ def _load_module() -> ModuleType:
41
+ for each_cached_name in [
42
+ each_key
43
+ for each_key in list(sys.modules)
44
+ if each_key == "config" or each_key.startswith("config.")
45
+ ]:
46
+ sys.modules.pop(each_cached_name, None)
47
+ if str(_PR_CONVERGE_DIRECTORY) in sys.path:
48
+ sys.path.remove(str(_PR_CONVERGE_DIRECTORY))
49
+ sys.path.insert(0, str(_PR_CONVERGE_DIRECTORY))
50
+ module_path = _SCRIPTS_DIRECTORY / "check_convergence.py"
51
+ spec = importlib.util.spec_from_file_location(
52
+ "check_convergence_codex_under_test", module_path
53
+ )
54
+ assert spec is not None
55
+ assert spec.loader is not None
56
+ module = importlib.util.module_from_spec(spec)
57
+ spec.loader.exec_module(module)
58
+ return module
59
+
60
+
61
+ check_convergence = _load_module()
62
+
63
+
64
+ def _write_fixture(
65
+ tmp_path: Path,
66
+ *,
67
+ codex_percent_left: float | None,
68
+ codex_clean_at: str | None,
69
+ filename: str = "codex-gate.json",
70
+ ) -> Path:
71
+ fixture_path = tmp_path / filename
72
+ payload: dict[str, object] = {
73
+ "head_sha": HEAD_SHA,
74
+ "pr_object": {"mergeable": True, "mergeable_state": "clean"},
75
+ "reviews": [
76
+ {
77
+ "id": 1,
78
+ "body": CLEAN_BODY,
79
+ "commit_id": HEAD_SHA,
80
+ }
81
+ ],
82
+ "unresolved_bot_threads_passed": True,
83
+ "unresolved_bot_threads_detail": "0 unresolved",
84
+ "pending_reviews_passed": True,
85
+ "pending_reviews_detail": "none pending",
86
+ "codex_percent_left": codex_percent_left,
87
+ "codex_clean_at": codex_clean_at,
88
+ }
89
+ fixture_path.write_text(json.dumps(payload), encoding="utf-8")
90
+ return fixture_path
91
+
92
+
93
+ def should_pass_when_codex_required_and_clean(
94
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
95
+ ) -> None:
96
+ fixture_path = _write_fixture(
97
+ tmp_path,
98
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
99
+ codex_clean_at=HEAD_SHA,
100
+ filename="required-clean.json",
101
+ )
102
+ exit_code = check_convergence.main(
103
+ [
104
+ "--owner",
105
+ "jl-cmd",
106
+ "--repo",
107
+ "claude-dev-env",
108
+ "--pr-number",
109
+ "111",
110
+ "--fixture",
111
+ str(fixture_path),
112
+ "--bugbot-down",
113
+ "--copilot-down",
114
+ ]
115
+ )
116
+ captured = capsys.readouterr().out
117
+ assert exit_code == 0
118
+ assert "codex_clean_at == current_head: PASS" in captured
119
+ assert "clean at" in captured
120
+ assert "All pre-conditions met" in captured
121
+
122
+
123
+ def should_pass_without_reading_usage_when_the_clean_stamp_is_on_head(
124
+ monkeypatch: pytest.MonkeyPatch,
125
+ ) -> None:
126
+ def _probe_that_must_not_run() -> float | None:
127
+ raise AssertionError(
128
+ "the weekly usage probe must not run when codex_clean_at is on HEAD"
129
+ )
130
+
131
+ monkeypatch.setattr(
132
+ check_convergence, "_probe_codex_percent_left", _probe_that_must_not_run
133
+ )
134
+
135
+ is_passed, detail = check_convergence._evaluate_codex_clean(
136
+ read_percent_left=check_convergence._probe_codex_percent_left,
137
+ codex_clean_at=HEAD_SHA,
138
+ head_sha=HEAD_SHA,
139
+ )
140
+
141
+ assert is_passed is True
142
+ assert "clean at" in detail
143
+
144
+
145
+ def should_match_a_clean_stamp_whose_hex_case_differs_from_the_head_sha() -> None:
146
+ assert check_convergence._sha_matches_head(HEAD_SHA.upper(), HEAD_SHA) is True
147
+
148
+
149
+ def should_fail_when_codex_clean_stamp_is_shorter_than_an_abbreviated_sha(
150
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
151
+ ) -> None:
152
+ fixture_path = _write_fixture(
153
+ tmp_path,
154
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
155
+ codex_clean_at=HEAD_SHA[:1],
156
+ filename="required-short-stamp.json",
157
+ )
158
+ exit_code = check_convergence.main(
159
+ [
160
+ "--owner",
161
+ "jl-cmd",
162
+ "--repo",
163
+ "claude-dev-env",
164
+ "--pr-number",
165
+ "111",
166
+ "--fixture",
167
+ str(fixture_path),
168
+ "--bugbot-down",
169
+ "--copilot-down",
170
+ ]
171
+ )
172
+ captured = capsys.readouterr().out
173
+ assert exit_code == 1
174
+ assert "codex_clean_at == current_head: FAIL" in captured
175
+
176
+
177
+ def should_pass_when_codex_clean_stamp_is_an_abbreviated_head_sha(
178
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
179
+ ) -> None:
180
+ fixture_path = _write_fixture(
181
+ tmp_path,
182
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
183
+ codex_clean_at=HEAD_SHA[:MINIMUM_ABBREVIATED_SHA_LENGTH],
184
+ filename="required-abbreviated-stamp.json",
185
+ )
186
+ exit_code = check_convergence.main(
187
+ [
188
+ "--owner",
189
+ "jl-cmd",
190
+ "--repo",
191
+ "claude-dev-env",
192
+ "--pr-number",
193
+ "111",
194
+ "--fixture",
195
+ str(fixture_path),
196
+ "--bugbot-down",
197
+ "--copilot-down",
198
+ ]
199
+ )
200
+ captured = capsys.readouterr().out
201
+ assert exit_code == 0
202
+ assert "codex_clean_at == current_head: PASS" in captured
203
+
204
+
205
+ def should_fail_when_codex_required_and_dirty(
206
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
207
+ ) -> None:
208
+ fixture_path = _write_fixture(
209
+ tmp_path,
210
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
211
+ codex_clean_at=OTHER_SHA,
212
+ filename="required-dirty.json",
213
+ )
214
+ exit_code = check_convergence.main(
215
+ [
216
+ "--owner",
217
+ "jl-cmd",
218
+ "--repo",
219
+ "claude-dev-env",
220
+ "--pr-number",
221
+ "111",
222
+ "--fixture",
223
+ str(fixture_path),
224
+ "--bugbot-down",
225
+ "--copilot-down",
226
+ ]
227
+ )
228
+ captured = capsys.readouterr().out
229
+ assert exit_code == 1
230
+ assert "codex_clean_at == current_head: FAIL" in captured
231
+ assert "no codex clean on" in captured
232
+ assert "do not mark ready" in captured
233
+
234
+
235
+ def should_skip_when_usage_at_or_below_threshold(
236
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
237
+ ) -> None:
238
+ fixture_path = _write_fixture(
239
+ tmp_path,
240
+ codex_percent_left=PERCENT_AT_THRESHOLD,
241
+ codex_clean_at=None,
242
+ filename="skipped-threshold.json",
243
+ )
244
+ exit_code = check_convergence.main(
245
+ [
246
+ "--owner",
247
+ "jl-cmd",
248
+ "--repo",
249
+ "claude-dev-env",
250
+ "--pr-number",
251
+ "111",
252
+ "--fixture",
253
+ str(fixture_path),
254
+ "--bugbot-down",
255
+ "--copilot-down",
256
+ ]
257
+ )
258
+ captured = capsys.readouterr().out
259
+ assert exit_code == 0
260
+ assert "codex_clean_at == current_head: PASS" in captured
261
+ assert "skipped (codex review not required)" in captured
262
+
263
+
264
+ def should_skip_when_codex_percent_left_is_null(
265
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
266
+ ) -> None:
267
+ fixture_path = _write_fixture(
268
+ tmp_path,
269
+ codex_percent_left=None,
270
+ codex_clean_at=None,
271
+ filename="skipped-null.json",
272
+ )
273
+ exit_code = check_convergence.main(
274
+ [
275
+ "--owner",
276
+ "jl-cmd",
277
+ "--repo",
278
+ "claude-dev-env",
279
+ "--pr-number",
280
+ "111",
281
+ "--fixture",
282
+ str(fixture_path),
283
+ "--bugbot-down",
284
+ "--copilot-down",
285
+ ]
286
+ )
287
+ captured = capsys.readouterr().out
288
+ assert exit_code == 0
289
+ assert "skipped (codex review not required)" in captured
290
+
291
+
292
+ def should_bypass_when_codex_token_disables_reviewer(
293
+ tmp_path: Path,
294
+ monkeypatch: pytest.MonkeyPatch,
295
+ capsys: pytest.CaptureFixture[str],
296
+ ) -> None:
297
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "codex")
298
+ fixture_path = _write_fixture(
299
+ tmp_path,
300
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
301
+ codex_clean_at=None,
302
+ filename="skipped-token.json",
303
+ )
304
+ exit_code = check_convergence.main(
305
+ [
306
+ "--owner",
307
+ "jl-cmd",
308
+ "--repo",
309
+ "claude-dev-env",
310
+ "--pr-number",
311
+ "111",
312
+ "--fixture",
313
+ str(fixture_path),
314
+ "--bugbot-down",
315
+ "--copilot-down",
316
+ ]
317
+ )
318
+ captured = capsys.readouterr().out
319
+ assert exit_code == 0
320
+ assert "bypassed (codex_down)" in captured
321
+ assert "no codex clean on" not in captured
322
+
323
+
324
+ def should_bypass_when_codex_down_flag_set(
325
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
326
+ ) -> None:
327
+ fixture_path = _write_fixture(
328
+ tmp_path,
329
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
330
+ codex_clean_at=None,
331
+ filename="skipped-down.json",
332
+ )
333
+ exit_code = check_convergence.main(
334
+ [
335
+ "--owner",
336
+ "jl-cmd",
337
+ "--repo",
338
+ "claude-dev-env",
339
+ "--pr-number",
340
+ "111",
341
+ "--fixture",
342
+ str(fixture_path),
343
+ "--bugbot-down",
344
+ "--copilot-down",
345
+ "--codex-down",
346
+ ]
347
+ )
348
+ captured = capsys.readouterr().out
349
+ assert exit_code == 0
350
+ assert "bypassed (codex_down)" in captured
351
+
352
+
353
+ def should_resolve_codex_down_true_when_flag_set(
354
+ monkeypatch: pytest.MonkeyPatch,
355
+ ) -> None:
356
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
357
+ monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
358
+ assert check_convergence._resolve_codex_down(
359
+ True, check_convergence._read_job_state()
360
+ ) is True
361
+
362
+
363
+ def should_resolve_codex_down_true_when_env_disables_codex(
364
+ monkeypatch: pytest.MonkeyPatch,
365
+ ) -> None:
366
+ monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
367
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "codex")
368
+ assert check_convergence._resolve_codex_down(
369
+ False, check_convergence._read_job_state()
370
+ ) is True
371
+
372
+
373
+ def should_resolve_codex_down_false_when_flag_unset_and_env_empty(
374
+ monkeypatch: pytest.MonkeyPatch,
375
+ ) -> None:
376
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
377
+ monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
378
+ assert check_convergence._resolve_codex_down(
379
+ False, check_convergence._read_job_state()
380
+ ) is False
381
+
382
+
383
+ def should_resolve_codex_down_true_when_job_state_sticky_down(
384
+ tmp_path: Path,
385
+ monkeypatch: pytest.MonkeyPatch,
386
+ ) -> None:
387
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
388
+ monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
389
+ (tmp_path / "pr-converge-state.json").write_text(
390
+ json.dumps({"codex_down": True}),
391
+ encoding="utf-8",
392
+ )
393
+ assert check_convergence._resolve_codex_down(
394
+ False, check_convergence._read_job_state()
395
+ ) is True
396
+
397
+
398
+ def should_resolve_codex_down_false_when_job_state_codex_down_is_false(
399
+ tmp_path: Path,
400
+ monkeypatch: pytest.MonkeyPatch,
401
+ ) -> None:
402
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
403
+ monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
404
+ (tmp_path / "pr-converge-state.json").write_text(
405
+ json.dumps({"codex_down": False}),
406
+ encoding="utf-8",
407
+ )
408
+ assert check_convergence._resolve_codex_down(
409
+ False, check_convergence._read_job_state()
410
+ ) is False
411
+
412
+
413
+ def should_bypass_when_job_state_codex_down_is_true(
414
+ tmp_path: Path,
415
+ monkeypatch: pytest.MonkeyPatch,
416
+ capsys: pytest.CaptureFixture[str],
417
+ ) -> None:
418
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
419
+ monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
420
+ (tmp_path / "pr-converge-state.json").write_text(
421
+ json.dumps({"codex_down": True}),
422
+ encoding="utf-8",
423
+ )
424
+ fixture_path = _write_fixture(
425
+ tmp_path,
426
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
427
+ codex_clean_at=None,
428
+ filename="job-state-codex-down.json",
429
+ )
430
+ exit_code = check_convergence.main(
431
+ [
432
+ "--owner",
433
+ "jl-cmd",
434
+ "--repo",
435
+ "claude-dev-env",
436
+ "--pr-number",
437
+ "111",
438
+ "--fixture",
439
+ str(fixture_path),
440
+ "--bugbot-down",
441
+ "--copilot-down",
442
+ ]
443
+ )
444
+ captured = capsys.readouterr().out
445
+ assert exit_code == 0
446
+ assert "bypassed (codex_down)" in captured
447
+ assert "no codex clean on" not in captured
448
+
449
+
450
+ def should_fail_when_job_state_lacks_codex_down_and_clean_stamp(
451
+ tmp_path: Path,
452
+ monkeypatch: pytest.MonkeyPatch,
453
+ capsys: pytest.CaptureFixture[str],
454
+ ) -> None:
455
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
456
+ monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
457
+ (tmp_path / "pr-converge-state.json").write_text(
458
+ json.dumps({}),
459
+ encoding="utf-8",
460
+ )
461
+ fixture_path = _write_fixture(
462
+ tmp_path,
463
+ codex_percent_left=PERCENT_ABOVE_THRESHOLD,
464
+ codex_clean_at=None,
465
+ filename="job-state-no-down.json",
466
+ )
467
+ exit_code = check_convergence.main(
468
+ [
469
+ "--owner",
470
+ "jl-cmd",
471
+ "--repo",
472
+ "claude-dev-env",
473
+ "--pr-number",
474
+ "111",
475
+ "--fixture",
476
+ str(fixture_path),
477
+ "--bugbot-down",
478
+ "--copilot-down",
479
+ ]
480
+ )
481
+ captured = capsys.readouterr().out
482
+ assert exit_code == 1
483
+ assert "codex_clean_at == current_head: FAIL" in captured
484
+ assert "no codex clean on" in captured
485
+
486
+
487
+ def should_accept_codex_down_flag_in_parsed_arguments() -> None:
488
+ arguments = check_convergence.parse_arguments(
489
+ ["--owner", "o", "--repo", "r", "--pr-number", "1", "--codex-down"]
490
+ )
491
+ assert arguments.codex_down is True
492
+
493
+
494
+ def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
495
+ arguments = check_convergence.parse_arguments(
496
+ [
497
+ "--owner",
498
+ "o",
499
+ "--repo",
500
+ "r",
501
+ "--pr-number",
502
+ "1",
503
+ "--codex-clean-at",
504
+ HEAD_SHA,
505
+ ]
506
+ )
507
+ assert arguments.codex_clean_at == HEAD_SHA