claude-dev-env 1.95.0 → 2.0.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 (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. package/skills/team-advisor/SKILL.md +7 -7
@@ -7,22 +7,43 @@
7
7
  exit 1 flag: a gate failed (FAIL lines print to stdout)
8
8
  exit 2 flag: gh CLI error
9
9
 
10
+ check_convergence.py --owner O --repo R --pr-number N --fixture PATH [flags]
11
+ Same stdout and exit codes as the live path, reading a frozen GitHub
12
+ API snapshot from PATH instead of calling ``gh`` against current state.
13
+
10
14
  Each bypass flag closes one reviewer gate when that reviewer is unavailable.
11
15
  ``--bugbot-down`` skips the bugbot check-run gate. ``--copilot-down`` skips
12
16
  the Copilot review and pending-review gates. ``--bugteam-post-blocked`` skips
13
- the bugteam CLEAN-review gate.
17
+ the bugteam CLEAN-review gate. ``--codex-down`` skips the Codex review gate.
14
18
 
15
19
  Each flag also honors its own reviews-disabled token: "bugbot", "copilot",
16
- or "bugteam". A caller that cannot pass the flag reaches the same bypass by
17
- exporting the token. The mark-ready blocker hook re-runs this script with no
18
- flags, so its convergence re-check reads the bypass from the exported token
19
- instead.
20
+ "bugteam", or "codex". A caller that cannot pass the flag reaches the same
21
+ bypass by exporting the token. The mark-ready blocker hook re-runs this script
22
+ with no flags, so its convergence re-check reads the bypass from the exported
23
+ token instead. For Codex, sticky job-dir state ``codex_down: true`` is a third
24
+ bypass source when the original run could not re-pass ``--codex-down``.
25
+
26
+ Copilot and Bugbot waivers are disk-authoritative.
27
+ When ``~/.claude/settings.json`` is readable, its env block is the single source.
28
+ The frozen process env is only a fallback when that disk read fails (logged once).
29
+ Bugteam's opt-out is env-only via ``_resolve_bugteam_post_blocked``.
30
+ A probe error does not waive the gate; the live GitHub checks still run.
31
+
32
+ The Codex gate is conditional-required: it demands
33
+ ``codex_clean_at == current_head`` only when the weekly usage probe reports
34
+ more than the probe's threshold percent left. At or below that threshold, null
35
+ usage, the codex token, or ``codex_down`` never blocks ready.
20
36
  """
21
37
 
22
38
  from __future__ import annotations
23
39
 
24
40
  import argparse
41
+ import json
42
+ import os
43
+ import subprocess
25
44
  import sys
45
+ from collections.abc import Callable
46
+ from pathlib import Path
26
47
  from typing import NamedTuple
27
48
 
28
49
  import _pr_converge_path_setup # noqa: F401
@@ -35,11 +56,42 @@ from check_convergence_gates import (
35
56
  _get_pr_head_sha,
36
57
  _gh_api_paginated,
37
58
  _short_sha,
59
+ _sort_reviews_newest_first,
60
+ _evaluate_mergeable_from_pr_object,
38
61
  )
39
62
  from check_convergence_thread_gates import (
40
63
  _check_no_pending_reviews,
41
64
  _count_unresolved_bot_threads,
42
65
  )
66
+ from codex_review_scripts_constants.codex_usage_probe_constants import (
67
+ USAGE_REPORT_KEY_PERCENT_LEFT,
68
+ )
69
+ from codex_usage_probe import is_codex_review_required, probe_weekly_usage_via_subprocess
70
+ from pr_converge_scripts_constants.convergence_gate_constants import (
71
+ BUGBOT_DOWN_BYPASS_NOTE,
72
+ CLAUDE_JOB_DIR_ENV_VAR_NAME,
73
+ CODEX_BYPASS_DETAIL,
74
+ CODEX_CLEAN_AT_STATE_KEY,
75
+ CODEX_CLEAN_DETAIL_TEMPLATE,
76
+ CODEX_DOWN_STATE_KEY,
77
+ CODEX_GATE_LABEL,
78
+ CODEX_MISSING_CLEAN_DETAIL_TEMPLATE,
79
+ CODEX_SKIPPED_USAGE_DETAIL,
80
+ COPILOT_DOWN_BYPASS_NOTE,
81
+ FIXTURE_DEFAULT_PENDING_DETAIL,
82
+ FIXTURE_DEFAULT_THREADS_DETAIL,
83
+ FIXTURE_KEY_CODEX_CLEAN_AT,
84
+ FIXTURE_KEY_CODEX_PERCENT_LEFT,
85
+ FIXTURE_KEY_HEAD_SHA,
86
+ FIXTURE_KEY_PENDING_REVIEWS_DETAIL,
87
+ FIXTURE_KEY_PENDING_REVIEWS_PASSED,
88
+ FIXTURE_KEY_PR_OBJECT,
89
+ FIXTURE_KEY_REVIEWS,
90
+ FIXTURE_KEY_UNRESOLVED_BOT_THREADS_DETAIL,
91
+ FIXTURE_KEY_UNRESOLVED_BOT_THREADS_PASSED,
92
+ MINIMUM_ABBREVIATED_SHA_LENGTH,
93
+ PR_CONVERGE_STATE_FILENAME,
94
+ )
43
95
  from pr_converge_skill_constants.constants import (
44
96
  ALL_COPILOT_CLEAN_REVIEW_STATES,
45
97
  ALL_COPILOT_DIRTY_REVIEW_STATES,
@@ -54,13 +106,33 @@ from pr_converge_skill_constants.constants import (
54
106
  from reviews_disabled import (
55
107
  is_bugbot_disabled_via_env,
56
108
  is_bugteam_disabled_via_env,
109
+ is_codex_disabled_via_env,
57
110
  is_copilot_disabled_via_env,
58
111
  )
112
+ from check_convergence_availability import (
113
+ _resolve_bugbot_waiver,
114
+ _resolve_copilot_waiver,
115
+ _waiver_from_cli_flag,
116
+ )
59
117
 
60
118
  JsonObject = dict[str, object]
61
119
  GateCondition = tuple[str, tuple[bool, str]]
62
120
 
63
121
 
122
+ class ConvergenceFixture(NamedTuple):
123
+ """Frozen GitHub API snapshot one fixture gate run evaluates."""
124
+
125
+ head_sha: str
126
+ pr_object: JsonObject
127
+ reviews: list[JsonObject]
128
+ is_zero_unresolved_bot_threads: bool
129
+ unresolved_bot_threads_detail: str
130
+ is_no_pending_reviews: bool
131
+ pending_reviews_detail: str
132
+ codex_percent_left: float | None
133
+ codex_clean_at: str | None
134
+
135
+
64
136
  class GateContext(NamedTuple):
65
137
  """The PR coordinates and per-reviewer bypass flags one gate run evaluates."""
66
138
 
@@ -71,6 +143,11 @@ class GateContext(NamedTuple):
71
143
  is_bugbot_down: bool
72
144
  is_copilot_down: bool
73
145
  is_bugteam_post_blocked: bool
146
+ is_codex_down: bool
147
+ bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE
148
+ copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE
149
+ live_codex_clean_at: str | None = None
150
+ fixture: ConvergenceFixture | None = None
74
151
 
75
152
 
76
153
  def _is_bugteam_review(review_body: str) -> bool:
@@ -105,6 +182,25 @@ def _bugteam_review_detail(review: JsonObject, head_sha: str) -> tuple[bool, str
105
182
  return False, f"review {review_id}, dirty bugteam audit, commit {short_commit}"
106
183
 
107
184
 
185
+ def _evaluate_bugteam_clean_from_reviews(
186
+ all_reviews: list[JsonObject], head_sha: str
187
+ ) -> tuple[bool, str]:
188
+ """Return whether the newest bugteam audit on HEAD is clean (pure, no network).
189
+
190
+ Args:
191
+ all_reviews: Review objects newest-first as returned by the GitHub reviews API.
192
+ head_sha: Current PR HEAD commit SHA.
193
+
194
+ Returns:
195
+ (passed, detail) for the first bugteam review on HEAD, or a missing-review failure.
196
+ """
197
+ for each_review in all_reviews:
198
+ detail = _bugteam_review_detail(each_review, head_sha)
199
+ if detail is not None:
200
+ return detail
201
+ return False, f"no bugteam review found on {_short_sha(head_sha)}"
202
+
203
+
108
204
  def _check_bugteam_clean(*, owner: str, repo: str, number: int, head_sha: str) -> tuple[bool, str]:
109
205
  """Return whether the newest bugteam audit review on HEAD declares a clean pass."""
110
206
  endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
@@ -114,17 +210,30 @@ def _check_bugteam_clean(*, owner: str, repo: str, number: int, head_sha: str) -
114
210
  all_flat = _flatten_paginated_reviews(stdout)
115
211
  if all_flat is None:
116
212
  return False, "unexpected gh api response shape (expected list)"
117
- for each_review in all_flat:
118
- detail = _bugteam_review_detail(each_review, head_sha)
119
- if detail is not None:
120
- return detail
121
- return False, f"no bugteam review found on {_short_sha(head_sha)}"
213
+ return _evaluate_bugteam_clean_from_reviews(all_flat, head_sha)
214
+
215
+
216
+ def _bypassed_note(bypass_note: str) -> str:
217
+ """Format the PASS detail line for a gate bypassed for the given reason."""
218
+ return f"bypassed ({bypass_note})"
122
219
 
123
220
 
124
221
  def _bugbot_conditions(context: GateContext) -> list[GateCondition]:
125
222
  """Build the Bugbot gate conditions, bypassed when Bugbot is down."""
126
223
  if context.is_bugbot_down:
127
- return [("bugbot_clean_at == current_head", (True, "bypassed (bugbot_down)"))]
224
+ return [
225
+ (
226
+ "bugbot_clean_at == current_head",
227
+ (True, _bypassed_note(context.bugbot_bypass_note)),
228
+ )
229
+ ]
230
+ if context.fixture is not None:
231
+ return [
232
+ (
233
+ "bugbot_clean_at == current_head",
234
+ (False, "fixture has no bugbot check-runs; pass --bugbot-down"),
235
+ )
236
+ ]
128
237
  conditions: list[GateCondition] = [
129
238
  (
130
239
  "bugbot_clean_at == current_head",
@@ -136,7 +245,10 @@ def _bugbot_conditions(context: GateContext) -> list[GateCondition]:
136
245
  (
137
246
  "bugbot review body clean",
138
247
  _check_bugbot_not_dirty(
139
- owner=context.owner, repo=context.repo, number=context.number, head_sha=context.head_sha
248
+ owner=context.owner,
249
+ repo=context.repo,
250
+ number=context.number,
251
+ head_sha=context.head_sha,
140
252
  ),
141
253
  )
142
254
  )
@@ -147,10 +259,18 @@ def _bugteam_condition(context: GateContext) -> GateCondition:
147
259
  """Build the bugteam CLEAN-review condition, skipped when the post was blocked."""
148
260
  if context.is_bugteam_post_blocked:
149
261
  return ("bugteam_clean_at == current_head", (True, "bypassed (bugteam_post_blocked)"))
262
+ if context.fixture is not None:
263
+ return (
264
+ "bugteam_clean_at == current_head",
265
+ _evaluate_bugteam_clean_from_reviews(context.fixture.reviews, context.head_sha),
266
+ )
150
267
  return (
151
268
  "bugteam_clean_at == current_head",
152
269
  _check_bugteam_clean(
153
- owner=context.owner, repo=context.repo, number=context.number, head_sha=context.head_sha
270
+ owner=context.owner,
271
+ repo=context.repo,
272
+ number=context.number,
273
+ head_sha=context.head_sha,
154
274
  ),
155
275
  )
156
276
 
@@ -158,7 +278,15 @@ def _bugteam_condition(context: GateContext) -> GateCondition:
158
278
  def _copilot_review_condition(context: GateContext) -> GateCondition:
159
279
  """Build the Copilot review gate condition, bypassed when Copilot is down."""
160
280
  if context.is_copilot_down:
161
- return ("copilot_clean_at == current_head", (True, "bypassed (copilot_down)"))
281
+ return (
282
+ "copilot_clean_at == current_head",
283
+ (True, _bypassed_note(context.copilot_bypass_note)),
284
+ )
285
+ if context.fixture is not None:
286
+ return (
287
+ "copilot_clean_at == current_head",
288
+ (False, "fixture has no copilot review; pass --copilot-down"),
289
+ )
162
290
  return (
163
291
  "copilot_clean_at == current_head",
164
292
  _check_bot_review(
@@ -174,24 +302,249 @@ def _copilot_review_condition(context: GateContext) -> GateCondition:
174
302
  )
175
303
 
176
304
 
305
+ def _sha_matches_head(stamp_sha: str, head_sha: str) -> bool:
306
+ """Return True when a clean-at stamp matches the PR HEAD SHA (full or prefix).
307
+
308
+ ::
309
+
310
+ stamp 86454e3d..., head 86454e3d... -> True (full match)
311
+ stamp 86454e3, head 86454e3d... -> True (abbreviated stamp)
312
+ stamp 8, head 86454e3d... -> False (below the abbreviation floor)
313
+
314
+ A prefix shorter than an abbreviated SHA matches many commits, so a stamp
315
+ that short never passes the gate.
316
+
317
+ Args:
318
+ stamp_sha: The ``codex_clean_at`` stamp under test.
319
+ head_sha: Current PR HEAD commit SHA.
320
+
321
+ Returns:
322
+ True when the two name the same commit.
323
+ """
324
+ if len(stamp_sha) < MINIMUM_ABBREVIATED_SHA_LENGTH:
325
+ return False
326
+ if len(head_sha) < MINIMUM_ABBREVIATED_SHA_LENGTH:
327
+ return False
328
+ lowered_stamp_sha = stamp_sha.lower()
329
+ lowered_head_sha = head_sha.lower()
330
+ return lowered_head_sha.startswith(lowered_stamp_sha) or lowered_stamp_sha.startswith(
331
+ lowered_head_sha
332
+ )
333
+
334
+
335
+ def _evaluate_codex_clean(
336
+ *,
337
+ read_percent_left: Callable[[], float | None],
338
+ codex_clean_at: str | None,
339
+ head_sha: str,
340
+ ) -> tuple[bool, str]:
341
+ """Return whether the conditional Codex gate passes for the given stamp and usage.
342
+
343
+ ::
344
+
345
+ clean stamp on head -> pass (usage never read)
346
+ no stamp, percent None or <= limit -> skip (never blocks)
347
+ no stamp, percent > limit -> fail
348
+
349
+ A stamp on the current HEAD settles the gate on its own, so the usage read
350
+ stays behind it: the weekly probe spawns a Codex subprocess, and the common
351
+ already-clean tick has no reason to pay for one.
352
+
353
+ Args:
354
+ read_percent_left: Reads weekly Codex percent remaining, or None when unknown.
355
+ codex_clean_at: HEAD SHA where Codex last reported clean, or None.
356
+ head_sha: Current PR HEAD commit SHA.
357
+
358
+ Returns:
359
+ (passed, detail) for the Codex gate condition line.
360
+ """
361
+ if codex_clean_at is not None and _sha_matches_head(codex_clean_at, head_sha):
362
+ return True, CODEX_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
363
+ if not is_codex_review_required(read_percent_left()):
364
+ return True, CODEX_SKIPPED_USAGE_DETAIL
365
+ return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
366
+
367
+
368
+ def _probe_codex_percent_left() -> float | None:
369
+ """Probe weekly Codex usage and return percent remaining, or None when unknown."""
370
+ try:
371
+ usage_report = probe_weekly_usage_via_subprocess()
372
+ except (
373
+ FileNotFoundError,
374
+ OSError,
375
+ subprocess.TimeoutExpired,
376
+ subprocess.SubprocessError,
377
+ json.JSONDecodeError,
378
+ ValueError,
379
+ TypeError,
380
+ KeyError,
381
+ ):
382
+ return None
383
+ raw_percent = usage_report.get(USAGE_REPORT_KEY_PERCENT_LEFT)
384
+ if isinstance(raw_percent, bool):
385
+ return None
386
+ if isinstance(raw_percent, (int, float)):
387
+ return float(raw_percent)
388
+ return None
389
+
390
+
391
+ def _read_job_state() -> dict[str, object]:
392
+ """Read the single-PR job-dir state file, or an empty mapping when unreadable.
393
+
394
+ ::
395
+
396
+ CLAUDE_JOB_DIR unset -> {}
397
+ state file absent -> {}
398
+ corrupt or non-object -> {}
399
+ {"codex_down": true} -> {"codex_down": True}
400
+
401
+ Args:
402
+ None.
403
+
404
+ Returns:
405
+ The parsed state object, or an empty dict when no state is readable.
406
+ """
407
+ job_directory = os.environ.get(CLAUDE_JOB_DIR_ENV_VAR_NAME)
408
+ if not job_directory:
409
+ return {}
410
+ state_path = Path(job_directory) / PR_CONVERGE_STATE_FILENAME
411
+ if not state_path.is_file():
412
+ return {}
413
+ try:
414
+ payload = json.loads(state_path.read_text(encoding="utf-8"))
415
+ except (OSError, json.JSONDecodeError):
416
+ return {}
417
+ if not isinstance(payload, dict):
418
+ return {}
419
+ return payload
420
+
421
+
422
+ def _read_codex_clean_at_from_job_state(all_job_state_fields: dict[str, object]) -> str | None:
423
+ """Read ``codex_clean_at`` from an already-read job-dir state mapping."""
424
+ clean_at = all_job_state_fields.get(CODEX_CLEAN_AT_STATE_KEY)
425
+ if isinstance(clean_at, str) and clean_at:
426
+ return clean_at
427
+ return None
428
+
429
+
430
+ def _read_codex_down_from_job_state(all_job_state_fields: dict[str, object]) -> bool:
431
+ """Return True when job-dir state records sticky ``codex_down`` as JSON true.
432
+
433
+ ::
434
+
435
+ {"codex_down": true} -> True
436
+ {"codex_down": false} -> False
437
+ missing key / corrupt -> False
438
+
439
+ Only exact boolean true counts; strings, numbers, and missing values do not.
440
+
441
+ Args:
442
+ all_job_state_fields: Parsed job-dir state, empty when none is readable.
443
+
444
+ Returns:
445
+ True when the state records ``codex_down`` as JSON true.
446
+ """
447
+ return all_job_state_fields.get(CODEX_DOWN_STATE_KEY) is True
448
+
449
+
450
+ def _resolve_live_codex_clean_at(
451
+ cli_codex_clean_at: str | None, all_job_state_fields: dict[str, object]
452
+ ) -> str | None:
453
+ """Prefer the CLI stamp, then the job-dir state stamp, for the live Codex gate.
454
+
455
+ Args:
456
+ cli_codex_clean_at: Stamp passed through ``--codex-clean-at``, or None.
457
+ all_job_state_fields: Parsed job-dir state, empty when none is readable.
458
+
459
+ Returns:
460
+ The stamp the live Codex gate checks against HEAD, or None.
461
+ """
462
+ if cli_codex_clean_at:
463
+ return cli_codex_clean_at
464
+ return _read_codex_clean_at_from_job_state(all_job_state_fields)
465
+
466
+
467
+ def _codex_condition(context: GateContext) -> GateCondition:
468
+ """Build the conditional Codex gate, bypassed when Codex is down."""
469
+ if context.is_codex_down:
470
+ return (CODEX_GATE_LABEL, (True, CODEX_BYPASS_DETAIL))
471
+ if context.fixture is not None:
472
+ fixture_percent_left = context.fixture.codex_percent_left
473
+ return (
474
+ CODEX_GATE_LABEL,
475
+ _evaluate_codex_clean(
476
+ read_percent_left=lambda: fixture_percent_left,
477
+ codex_clean_at=context.fixture.codex_clean_at,
478
+ head_sha=context.head_sha,
479
+ ),
480
+ )
481
+ return (
482
+ CODEX_GATE_LABEL,
483
+ _evaluate_codex_clean(
484
+ read_percent_left=_probe_codex_percent_left,
485
+ codex_clean_at=context.live_codex_clean_at,
486
+ head_sha=context.head_sha,
487
+ ),
488
+ )
489
+
490
+
177
491
  def _pending_reviews_condition(context: GateContext) -> GateCondition:
178
492
  """Build the pending-requested-reviews condition, bypassed when Copilot is down."""
179
493
  if context.is_copilot_down:
180
- return ("no pending requested reviews", (True, "bypassed (copilot_down)"))
494
+ return (
495
+ "no pending requested reviews",
496
+ (True, _bypassed_note(context.copilot_bypass_note)),
497
+ )
498
+ if context.fixture is not None:
499
+ return (
500
+ "no pending requested reviews",
501
+ (
502
+ context.fixture.is_no_pending_reviews,
503
+ context.fixture.pending_reviews_detail,
504
+ ),
505
+ )
181
506
  return (
182
507
  "no pending requested reviews",
183
508
  _check_no_pending_reviews(owner=context.owner, repo=context.repo, number=context.number),
184
509
  )
185
510
 
186
511
 
512
+ def _mergeable_condition(context: GateContext) -> GateCondition:
513
+ """Build the mergeable condition from live gh or the frozen fixture PR object."""
514
+ if context.fixture is not None:
515
+ return (
516
+ "PR is mergeable",
517
+ _evaluate_mergeable_from_pr_object(context.fixture.pr_object),
518
+ )
519
+ return (
520
+ "PR is mergeable",
521
+ _get_mergeable(owner=context.owner, repo=context.repo, number=context.number),
522
+ )
523
+
524
+
525
+ def _threads_condition(context: GateContext) -> GateCondition:
526
+ """Build the unresolved-bot-threads condition from live gh or the fixture."""
527
+ if context.fixture is not None:
528
+ return (
529
+ "zero unresolved bot threads",
530
+ (
531
+ context.fixture.is_zero_unresolved_bot_threads,
532
+ context.fixture.unresolved_bot_threads_detail,
533
+ ),
534
+ )
535
+ return (
536
+ "zero unresolved bot threads",
537
+ _count_unresolved_bot_threads(
538
+ owner=context.owner, repo=context.repo, number=context.number
539
+ ),
540
+ )
541
+
542
+
187
543
  def _state_conditions(context: GateContext) -> list[GateCondition]:
188
544
  """Build the thread, mergeable, and pending-review conditions in gate order."""
189
545
  return [
190
- (
191
- "zero unresolved bot threads",
192
- _count_unresolved_bot_threads(owner=context.owner, repo=context.repo, number=context.number),
193
- ),
194
- ("PR is mergeable", _get_mergeable(owner=context.owner, repo=context.repo, number=context.number)),
546
+ _threads_condition(context),
547
+ _mergeable_condition(context),
195
548
  _pending_reviews_condition(context),
196
549
  ]
197
550
 
@@ -201,6 +554,7 @@ def _build_all_conditions(context: GateContext) -> list[GateCondition]:
201
554
  conditions = list(_bugbot_conditions(context))
202
555
  conditions.append(_bugteam_condition(context))
203
556
  conditions.append(_copilot_review_condition(context))
557
+ conditions.append(_codex_condition(context))
204
558
  conditions.extend(_state_conditions(context))
205
559
  return conditions
206
560
 
@@ -227,15 +581,88 @@ def _evaluate_convergence(context: GateContext) -> int:
227
581
  return _print_conditions(_build_all_conditions(context))
228
582
 
229
583
 
584
+ def _load_convergence_fixture(from_path: Path) -> ConvergenceFixture:
585
+ """Load a frozen GitHub API snapshot from JSON for fixture replay.
586
+
587
+ Args:
588
+ from_path: Path to a JSON fixture file.
589
+
590
+ Returns:
591
+ ConvergenceFixture with head, reviews, mergeable PR object, and thread/pending facts.
592
+
593
+ Raises:
594
+ ValueError: When required keys are missing or mistyped.
595
+ """
596
+ payload = json.loads(from_path.read_text(encoding="utf-8"))
597
+ if not isinstance(payload, dict):
598
+ raise ValueError("fixture root must be a JSON object")
599
+ head_sha = payload.get(FIXTURE_KEY_HEAD_SHA)
600
+ pr_object = payload.get(FIXTURE_KEY_PR_OBJECT)
601
+ reviews = payload.get(FIXTURE_KEY_REVIEWS)
602
+ if not isinstance(head_sha, str) or not head_sha:
603
+ raise ValueError("fixture head_sha must be a non-empty string")
604
+ if not isinstance(pr_object, dict):
605
+ raise ValueError("fixture pr_object must be an object")
606
+ if not isinstance(reviews, list):
607
+ raise ValueError("fixture reviews must be a list")
608
+ typed_reviews = _sort_reviews_newest_first(
609
+ [each for each in reviews if isinstance(each, dict)]
610
+ )
611
+ is_threads_ok = payload.get(FIXTURE_KEY_UNRESOLVED_BOT_THREADS_PASSED)
612
+ threads_detail = payload.get(
613
+ FIXTURE_KEY_UNRESOLVED_BOT_THREADS_DETAIL, FIXTURE_DEFAULT_THREADS_DETAIL
614
+ )
615
+ is_pending_ok = payload.get(FIXTURE_KEY_PENDING_REVIEWS_PASSED)
616
+ pending_detail = payload.get(
617
+ FIXTURE_KEY_PENDING_REVIEWS_DETAIL, FIXTURE_DEFAULT_PENDING_DETAIL
618
+ )
619
+ if not isinstance(is_threads_ok, bool):
620
+ raise ValueError("fixture unresolved_bot_threads_passed must be a bool")
621
+ if not isinstance(threads_detail, str):
622
+ raise ValueError("fixture unresolved_bot_threads_detail must be a string")
623
+ if not isinstance(is_pending_ok, bool):
624
+ raise ValueError("fixture pending_reviews_passed must be a bool")
625
+ if not isinstance(pending_detail, str):
626
+ raise ValueError("fixture pending_reviews_detail must be a string")
627
+ codex_percent_left = payload.get(FIXTURE_KEY_CODEX_PERCENT_LEFT)
628
+ codex_clean_at = payload.get(FIXTURE_KEY_CODEX_CLEAN_AT)
629
+ if isinstance(codex_percent_left, bool):
630
+ raise ValueError("fixture codex_percent_left must be a number or null")
631
+ if codex_percent_left is not None and not isinstance(codex_percent_left, (int, float)):
632
+ raise ValueError("fixture codex_percent_left must be a number or null")
633
+ if codex_clean_at is not None and not isinstance(codex_clean_at, str):
634
+ raise ValueError("fixture codex_clean_at must be a string or null")
635
+ typed_percent_left: float | None = (
636
+ float(codex_percent_left) if isinstance(codex_percent_left, (int, float)) else None
637
+ )
638
+ typed_codex_clean_at: str | None = codex_clean_at if isinstance(codex_clean_at, str) else None
639
+ return ConvergenceFixture(
640
+ head_sha=head_sha,
641
+ pr_object=pr_object,
642
+ reviews=typed_reviews,
643
+ is_zero_unresolved_bot_threads=is_threads_ok,
644
+ unresolved_bot_threads_detail=threads_detail,
645
+ is_no_pending_reviews=is_pending_ok,
646
+ pending_reviews_detail=pending_detail,
647
+ codex_percent_left=typed_percent_left,
648
+ codex_clean_at=typed_codex_clean_at,
649
+ )
650
+
651
+
230
652
  def check_all(
231
653
  owner: str,
232
654
  repo: str,
233
655
  number: int,
234
656
  is_bugbot_down: bool,
235
657
  is_copilot_down: bool,
236
- is_bugteam_post_blocked: bool = False,
658
+ is_bugteam_post_blocked: bool,
659
+ is_codex_down: bool,
660
+ live_codex_clean_at: str | None,
661
+ bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
662
+ copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
237
663
  ) -> int:
238
664
  """Run every convergence gate and print one PASS/FAIL line per condition.
665
+
239
666
  Args:
240
667
  owner: GitHub repository owner login.
241
668
  repo: GitHub repository name.
@@ -243,6 +670,11 @@ def check_all(
243
670
  is_bugbot_down: True bypasses the bugbot check-run and review-body gates.
244
671
  is_copilot_down: True bypasses the Copilot review and pending gates.
245
672
  is_bugteam_post_blocked: True skips the bugteam CLEAN-review gate.
673
+ is_codex_down: True bypasses the conditional Codex review gate.
674
+ live_codex_clean_at: Optional SHA stamp for the live Codex clean-at check.
675
+ bugbot_bypass_note: Detail note printed when the bugbot gate is bypassed.
676
+ copilot_bypass_note: Detail note printed when the Copilot gates are bypassed.
677
+
246
678
  Returns:
247
679
  0 when every gate passes, 1 when at least one gate fails.
248
680
  """
@@ -255,6 +687,57 @@ def check_all(
255
687
  is_bugbot_down=is_bugbot_down,
256
688
  is_copilot_down=is_copilot_down,
257
689
  is_bugteam_post_blocked=is_bugteam_post_blocked,
690
+ is_codex_down=is_codex_down,
691
+ bugbot_bypass_note=bugbot_bypass_note,
692
+ copilot_bypass_note=copilot_bypass_note,
693
+ live_codex_clean_at=live_codex_clean_at,
694
+ fixture=None,
695
+ )
696
+ return _evaluate_convergence(context)
697
+
698
+
699
+ def _check_all_from_fixture(
700
+ owner: str,
701
+ repo: str,
702
+ number: int,
703
+ fixture: ConvergenceFixture,
704
+ is_bugbot_down: bool,
705
+ is_copilot_down: bool,
706
+ is_bugteam_post_blocked: bool,
707
+ is_codex_down: bool,
708
+ bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
709
+ copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
710
+ ) -> int:
711
+ """Run every convergence gate against a frozen API snapshot.
712
+
713
+ Args:
714
+ owner: GitHub repository owner login.
715
+ repo: GitHub repository name.
716
+ number: Pull request number recorded in the snapshot.
717
+ fixture: Frozen GitHub API snapshot (no live `gh` calls).
718
+ is_bugbot_down: True bypasses the bugbot check-run and review-body gates.
719
+ is_copilot_down: True bypasses the Copilot review and pending gates.
720
+ is_bugteam_post_blocked: True skips the bugteam CLEAN-review gate.
721
+ is_codex_down: True bypasses the conditional Codex review gate.
722
+ bugbot_bypass_note: Detail note printed when the bugbot gate is bypassed.
723
+ copilot_bypass_note: Detail note printed when the Copilot gates are bypassed.
724
+
725
+ Returns:
726
+ 0 when every gate passes, 1 when at least one gate fails.
727
+ """
728
+ context = GateContext(
729
+ owner=owner,
730
+ repo=repo,
731
+ number=number,
732
+ head_sha=fixture.head_sha,
733
+ is_bugbot_down=is_bugbot_down,
734
+ is_copilot_down=is_copilot_down,
735
+ is_bugteam_post_blocked=is_bugteam_post_blocked,
736
+ is_codex_down=is_codex_down,
737
+ bugbot_bypass_note=bugbot_bypass_note,
738
+ copilot_bypass_note=copilot_bypass_note,
739
+ live_codex_clean_at=None,
740
+ fixture=fixture,
258
741
  )
259
742
  return _evaluate_convergence(context)
260
743
 
@@ -265,6 +748,11 @@ def _build_argument_parser() -> argparse.ArgumentParser:
265
748
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
266
749
  parser.add_argument("--repo", required=True, help="GitHub repository name")
267
750
  parser.add_argument("--pr-number", required=True, type=int, help="Pull request number")
751
+ parser.add_argument(
752
+ "--fixture",
753
+ default=None,
754
+ help="Path to a frozen GitHub API JSON snapshot for offline replay.",
755
+ )
268
756
  parser.add_argument(
269
757
  "--bugbot-down",
270
758
  action="store_true",
@@ -280,6 +768,16 @@ def _build_argument_parser() -> argparse.ArgumentParser:
280
768
  action="store_true",
281
769
  help="Skip the bugteam CLEAN-review gate when the environment refused the CLEAN post on HEAD.",
282
770
  )
771
+ parser.add_argument(
772
+ "--codex-down",
773
+ action="store_true",
774
+ help="Bypass the conditional Codex review gate when Codex is down or opted out.",
775
+ )
776
+ parser.add_argument(
777
+ "--codex-clean-at",
778
+ default=None,
779
+ help="HEAD SHA where Codex last reported clean (live path; optional when job-dir state holds it).",
780
+ )
283
781
  return parser
284
782
 
285
783
 
@@ -290,19 +788,29 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
290
788
  all_argv: Argument list excluding the program name.
291
789
 
292
790
  Returns:
293
- Namespace exposing owner, repo, pr_number, bugbot_down, copilot_down,
294
- and bugteam_post_blocked attributes.
791
+ Namespace exposing owner, repo, pr_number, fixture, bugbot_down, copilot_down,
792
+ bugteam_post_blocked, codex_down, and codex_clean_at attributes.
295
793
  """
296
794
  return _build_argument_parser().parse_args(all_argv)
297
795
 
298
796
 
299
797
  def _resolve_bugbot_down(is_bugbot_down_flag: bool) -> bool:
300
- """Combine the --bugbot-down flag with the env availability gate for Bugbot."""
798
+ """Pinned contract/unit-test helper for the bugbot flag and env bypass.
799
+
800
+ Production ``main()`` resolves Bugbot waivers through ``_resolve_bugbot_waiver``.
801
+ This function serves only pinned contract and unit tests; issue #120 tracks
802
+ its removal.
803
+ """
301
804
  return is_bugbot_down_flag or is_bugbot_disabled_via_env()
302
805
 
303
806
 
304
807
  def _resolve_copilot_down(is_copilot_down_flag: bool) -> bool:
305
- """Combine the --copilot-down flag with the CLAUDE_REVIEWS_DISABLED env opt-out."""
808
+ """Pinned contract/unit-test helper for the copilot flag and env bypass.
809
+
810
+ Production ``main()`` resolves Copilot waivers through ``_resolve_copilot_waiver``.
811
+ This function serves only pinned contract and unit tests; issue #120 tracks
812
+ its removal.
813
+ """
306
814
  return is_copilot_down_flag or is_copilot_disabled_via_env()
307
815
 
308
816
 
@@ -321,23 +829,91 @@ def _resolve_bugteam_post_blocked(is_bugteam_post_blocked_flag: bool) -> bool:
321
829
  return is_bugteam_post_blocked_flag or is_bugteam_disabled_via_env()
322
830
 
323
831
 
324
- def main(all_arguments: list[str]) -> int:
832
+ def _resolve_codex_down(
833
+ is_codex_down_flag: bool, all_job_state_fields: dict[str, object]
834
+ ) -> bool:
835
+ """Combine --codex-down, env opt-out, and sticky job-state ``codex_down``.
836
+
837
+ ::
838
+
839
+ flag True, env unset, job unset -> True (caller passed the flag)
840
+ flag False, reviews-disabled lists codex -> True (exported token)
841
+ flag False, env unset, job codex_down true -> True (sticky job-state)
842
+ flag False, env unset, job unset or false -> False (gate may run)
843
+
844
+ The mark-ready blocker hook re-runs this script with no flags, so the env
845
+ token and sticky job-state ``codex_down`` both carry the bypass into that
846
+ re-check when the original run cannot re-pass ``--codex-down``.
847
+
848
+ Args:
849
+ is_codex_down_flag: True when the caller passed ``--codex-down``.
850
+ all_job_state_fields: Parsed job-dir state, empty when none is readable.
851
+
852
+ Returns:
853
+ True when any of the three bypass sources is in force.
854
+ """
855
+ return (
856
+ is_codex_down_flag
857
+ or is_codex_disabled_via_env()
858
+ or _read_codex_down_from_job_state(all_job_state_fields)
859
+ )
860
+
861
+
862
+ def main(
863
+ all_arguments: list[str],
864
+ bugbot_bypass_note: str = BUGBOT_DOWN_BYPASS_NOTE,
865
+ copilot_bypass_note: str = COPILOT_DOWN_BYPASS_NOTE,
866
+ ) -> int:
325
867
  """Run the script end-to-end against parsed CLI arguments.
326
868
 
327
869
  Args:
328
870
  all_arguments: Argument list excluding the program name.
871
+ bugbot_bypass_note: Note printed when the bugbot gate is bypassed.
872
+ copilot_bypass_note: Note printed when the Copilot gates are bypassed.
329
873
 
330
874
  Returns:
331
875
  0 on full convergence, 1 on one or more gate failures.
332
876
  """
333
877
  arguments = parse_arguments(all_arguments)
878
+ all_job_state_fields = _read_job_state()
879
+ is_bugteam_post_blocked = _resolve_bugteam_post_blocked(arguments.bugteam_post_blocked)
880
+ is_codex_down = _resolve_codex_down(arguments.codex_down, all_job_state_fields)
881
+ fixture_path = getattr(arguments, "fixture", None)
882
+ if fixture_path:
883
+ bugbot_waiver = _waiver_from_cli_flag(
884
+ arguments.bugbot_down, bugbot_bypass_note
885
+ )
886
+ copilot_waiver = _waiver_from_cli_flag(
887
+ arguments.copilot_down, copilot_bypass_note
888
+ )
889
+ fixture = _load_convergence_fixture(Path(fixture_path))
890
+ return _check_all_from_fixture(
891
+ owner=arguments.owner,
892
+ repo=arguments.repo,
893
+ number=getattr(arguments, "pr_number"),
894
+ fixture=fixture,
895
+ is_bugbot_down=bugbot_waiver.is_waived,
896
+ is_copilot_down=copilot_waiver.is_waived,
897
+ is_bugteam_post_blocked=is_bugteam_post_blocked,
898
+ is_codex_down=is_codex_down,
899
+ bugbot_bypass_note=bugbot_waiver.bypass_note or bugbot_bypass_note,
900
+ copilot_bypass_note=copilot_waiver.bypass_note or copilot_bypass_note,
901
+ )
902
+ bugbot_waiver = _resolve_bugbot_waiver(arguments.bugbot_down)
903
+ copilot_waiver = _resolve_copilot_waiver(arguments.copilot_down)
334
904
  return check_all(
335
905
  owner=arguments.owner,
336
906
  repo=arguments.repo,
337
907
  number=getattr(arguments, "pr_number"),
338
- is_bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
339
- is_copilot_down=_resolve_copilot_down(arguments.copilot_down),
340
- is_bugteam_post_blocked=_resolve_bugteam_post_blocked(arguments.bugteam_post_blocked),
908
+ is_bugbot_down=bugbot_waiver.is_waived,
909
+ is_copilot_down=copilot_waiver.is_waived,
910
+ is_bugteam_post_blocked=is_bugteam_post_blocked,
911
+ is_codex_down=is_codex_down,
912
+ live_codex_clean_at=_resolve_live_codex_clean_at(
913
+ arguments.codex_clean_at, all_job_state_fields
914
+ ),
915
+ bugbot_bypass_note=bugbot_waiver.bypass_note or bugbot_bypass_note,
916
+ copilot_bypass_note=copilot_waiver.bypass_note or copilot_bypass_note,
341
917
  )
342
918
 
343
919