overkill 0.7.0__tar.gz → 0.8.2__tar.gz

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 (90) hide show
  1. {overkill-0.7.0 → overkill-0.8.2}/.overkillrc.example +14 -0
  2. {overkill-0.7.0 → overkill-0.8.2}/.refactorsuggestrc.example +6 -0
  3. {overkill-0.7.0 → overkill-0.8.2}/PKG-INFO +30 -1
  4. {overkill-0.7.0 → overkill-0.8.2}/README.md +29 -0
  5. {overkill-0.7.0 → overkill-0.8.2}/pyproject.toml +1 -1
  6. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/__main__.py +5 -2
  7. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/agents.py +14 -0
  8. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/__init__.py +53 -0
  9. overkill-0.8.2/src/mr_overkill/budget/codex.py +300 -0
  10. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget_report.py +25 -5
  11. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/cli.py +59 -1
  12. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/git_ops.py +43 -6
  13. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/loop_engine.py +50 -1
  14. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/models.py +11 -1
  15. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/refactor_suggest.py +10 -2
  16. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/review_loop.py +13 -0
  17. {overkill-0.7.0 → overkill-0.8.2}/tests/conftest.py +17 -0
  18. {overkill-0.7.0 → overkill-0.8.2}/tests/test_agents.py +39 -0
  19. overkill-0.8.2/tests/test_budget_codex.py +457 -0
  20. {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_policy.py +66 -1
  21. {overkill-0.7.0 → overkill-0.8.2}/tests/test_cli.py +186 -0
  22. {overkill-0.7.0 → overkill-0.8.2}/tests/test_git_ops.py +75 -0
  23. {overkill-0.7.0 → overkill-0.8.2}/tests/test_loop_engine.py +120 -0
  24. {overkill-0.7.0 → overkill-0.8.2}/tests/test_refactor_suggest.py +35 -0
  25. overkill-0.8.2/tests/test_review_loop.py +225 -0
  26. {overkill-0.7.0 → overkill-0.8.2}/uv.lock +1 -1
  27. overkill-0.7.0/src/mr_overkill/budget/codex.py +0 -118
  28. overkill-0.7.0/tests/test_budget_codex.py +0 -106
  29. overkill-0.7.0/tests/test_review_loop.py +0 -66
  30. {overkill-0.7.0 → overkill-0.8.2}/.github/workflows/publish.yml +0 -0
  31. {overkill-0.7.0 → overkill-0.8.2}/.github/workflows/test.yml +0 -0
  32. {overkill-0.7.0 → overkill-0.8.2}/.gitignore +0 -0
  33. {overkill-0.7.0 → overkill-0.8.2}/.python-version +0 -0
  34. {overkill-0.7.0 → overkill-0.8.2}/AGENTS.md +0 -0
  35. {overkill-0.7.0 → overkill-0.8.2}/CLAUDE.md +0 -0
  36. {overkill-0.7.0 → overkill-0.8.2}/GEMINI.md +0 -0
  37. {overkill-0.7.0 → overkill-0.8.2}/LICENSE +0 -0
  38. {overkill-0.7.0 → overkill-0.8.2}/install.sh +0 -0
  39. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-fix-execute.prompt.md +0 -0
  40. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-fix.prompt.md +0 -0
  41. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
  42. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-fix.prompt.md +0 -0
  43. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-full.prompt.md +0 -0
  44. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-layer.prompt.md +0 -0
  45. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-micro.prompt.md +0 -0
  46. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-module.prompt.md +0 -0
  47. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-review.prompt.md +0 -0
  48. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-self-review.prompt.md +0 -0
  49. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-full.prompt.md +0 -0
  50. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-layer.prompt.md +0 -0
  51. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-micro.prompt.md +0 -0
  52. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-module.prompt.md +0 -0
  53. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-review.prompt.md +0 -0
  54. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-full.prompt.md +0 -0
  55. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
  56. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
  57. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-module.prompt.md +0 -0
  58. {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-review.prompt.md +0 -0
  59. {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/claude-code-review-plugin.md +0 -0
  60. {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/claude-security-review.md +0 -0
  61. {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/codex-review-original.md +0 -0
  62. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/__init__.py +0 -0
  63. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/claude.py +0 -0
  64. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/gemini.py +0 -0
  65. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/classify.py +0 -0
  66. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/data/__init__.py +0 -0
  67. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/data/review.schema.json +0 -0
  68. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/init.py +0 -0
  69. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/json_extract.py +0 -0
  70. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/reporting.py +0 -0
  71. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/resume.py +0 -0
  72. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/retry.py +0 -0
  73. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/self_review.py +0 -0
  74. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/time_utils.py +0 -0
  75. {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/two_step_fix.py +0 -0
  76. {overkill-0.7.0 → overkill-0.8.2}/tests/__init__.py +0 -0
  77. {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_claude.py +0 -0
  78. {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_gemini.py +0 -0
  79. {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_report.py +0 -0
  80. {overkill-0.7.0 → overkill-0.8.2}/tests/test_classify.py +0 -0
  81. {overkill-0.7.0 → overkill-0.8.2}/tests/test_init.py +0 -0
  82. {overkill-0.7.0 → overkill-0.8.2}/tests/test_integration.py +0 -0
  83. {overkill-0.7.0 → overkill-0.8.2}/tests/test_json_extract.py +0 -0
  84. {overkill-0.7.0 → overkill-0.8.2}/tests/test_reporting.py +0 -0
  85. {overkill-0.7.0 → overkill-0.8.2}/tests/test_resume.py +0 -0
  86. {overkill-0.7.0 → overkill-0.8.2}/tests/test_retry.py +0 -0
  87. {overkill-0.7.0 → overkill-0.8.2}/tests/test_self_review.py +0 -0
  88. {overkill-0.7.0 → overkill-0.8.2}/tests/test_time_utils.py +0 -0
  89. {overkill-0.7.0 → overkill-0.8.2}/tests/test_two_step_fix.py +0 -0
  90. {overkill-0.7.0 → overkill-0.8.2}/uninstall.sh +0 -0
@@ -17,6 +17,14 @@
17
17
  # Enable auto-commit of fixes (default: true)
18
18
  # AUTO_COMMIT=true
19
19
 
20
+ # CI trigger policy for iteration commits (default: last-only)
21
+ # last-only — append "[skip ci]" to each iteration commit; push a single
22
+ # empty "chore: trigger CI" commit only when the loop ends
23
+ # with all_clear (saves CI cost on long iteration runs)
24
+ # every — each iteration commit triggers CI (pre-0.3 behaviour)
25
+ # none — append "[skip ci]"; no trigger commit (forks / CI-less repos)
26
+ # CI_TRIGGER_MODE="last-only"
27
+
20
28
  # Path to active prompt templates (default: <script_dir>/../prompts/active)
21
29
  # PROMPTS_DIR="./custom-prompts"
22
30
 
@@ -30,6 +38,12 @@
30
38
  # Pre-flight budget check scope: micro (<90%) or module (<75%) (default: module)
31
39
  # BUDGET_SCOPE="module"
32
40
 
41
+ # ── Budget Gate ─────────────────────────────────────────────────────
42
+ # Skip token-budget checks entirely. Use when local budget data is stale or
43
+ # wrong (e.g. Codex session logs left over from a previous login).
44
+ # Equivalent to --no-budget-gate / OVERKILL_SKIP_BUDGET=1. (default: false)
45
+ # NO_BUDGET_GATE=false
46
+
33
47
  # ── Diagnostic Logging ──────────────────────────────────────────────
34
48
  # Save full Claude event stream (tool calls, reasoning) to .stream.jsonl files.
35
49
  # Useful for debugging missed findings. (default: false)
@@ -44,6 +44,12 @@
44
44
  # Pre-flight budget check scope: micro (<90%) or module (<75%) (default: module)
45
45
  # BUDGET_SCOPE="module"
46
46
 
47
+ # ── Budget Gate ─────────────────────────────────────────────────────
48
+ # Skip token-budget checks entirely. Use when local budget data is stale or
49
+ # wrong (e.g. Codex session logs left over from a previous login).
50
+ # Equivalent to --no-budget-gate / OVERKILL_SKIP_BUDGET=1. (default: false)
51
+ # NO_BUDGET_GATE=false
52
+
47
53
  # ── Diagnostic Logging ──────────────────────────────────────────────
48
54
  # Save full Claude event stream (tool calls, reasoning) to .stream.jsonl files.
49
55
  # Useful for debugging missed findings. (default: false)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overkill
3
- Version: 0.7.0
3
+ Version: 0.8.2
4
4
  Summary: AI-powered code review loop — automates Codex/Gemini review + Claude fix cycles
5
5
  Project-URL: Repository, https://github.com/modocai/mr-overkill
6
6
  Project-URL: Issues, https://github.com/modocai/mr-overkill/issues
@@ -125,7 +125,14 @@ Options:
125
125
  --no-auto-commit Fix but do not commit/push (single iteration)
126
126
  --resume Resume from a previously interrupted run (reuses existing logs)
127
127
  --reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
128
+ --ci-trigger-mode <m> CI trigger policy: every|last-only|none (default: last-only).
129
+ 'last-only' tags each iteration commit with [skip ci]
130
+ and pushes a single empty trigger commit on PASS —
131
+ CI runs once instead of once per iteration.
132
+ Use 'every' to restore pre-0.3 per-commit CI.
128
133
  --diagnostic-log Save full Claude event stream to sidecar files
134
+ --no-budget-gate Skip token-budget checks and run regardless
135
+ (same as OVERKILL_SKIP_BUDGET=1)
129
136
 
130
137
  Examples:
131
138
  overkill review-loop -t main -n 3 # diff against main, max 3 loops
@@ -134,6 +141,7 @@ Examples:
134
141
  overkill review-loop -n 3 --no-self-review # disable self-review sub-loop
135
142
  overkill review-loop --resume # resume an interrupted run
136
143
  overkill review-loop -n 2 --reviewer-backend claude # use Claude as reviewer
144
+ overkill review-loop -n 10 --ci-trigger-mode last-only # CI fires once on PASS
137
145
  ```
138
146
 
139
147
  ## Usage: overkill refactor-suggest
@@ -158,6 +166,8 @@ Options:
158
166
  --with-review-loops <N> Set review-loop iteration count (implies --with-review)
159
167
  --reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
160
168
  --diagnostic-log Save full Claude event stream to sidecar files
169
+ --no-budget-gate Skip token-budget checks and run regardless
170
+ (same as OVERKILL_SKIP_BUDGET=1)
161
171
 
162
172
  Examples:
163
173
  overkill refactor-suggest -n 3 # auto scope (budget-aware)
@@ -304,6 +314,13 @@ All logs are git-ignored by default (inside `.overkill/`).
304
314
 
305
315
  The budget checker verifies Claude Code's 5-hour rate limit **before** starting expensive loops.
306
316
 
317
+ Codex is checked too, but only when it authenticates through a ChatGPT plan.
318
+ Auth mode is read from `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`),
319
+ falling back to the login method in `config.toml` when Codex keeps credentials
320
+ in the OS keyring instead; under API-key auth there are no plan rate-limit
321
+ windows, so the gate is skipped entirely and stale session logs from a previous
322
+ plan login are ignored.
323
+
307
324
  ### How it estimates usage
308
325
 
309
326
  | Mode | Data source | Accuracy |
@@ -324,6 +341,18 @@ Go/no-go decision based on current usage percentage:
324
341
  | `layer` | TBD | Cross-cutting changes |
325
342
  | `full` | TBD | Full architecture review |
326
343
 
344
+ ### Bypassing the gate
345
+
346
+ Budget data is an estimate read from local CLI logs, so it can be wrong — stale
347
+ logs, a changed auth mode, or a new rate-limit payload shape. To run anyway:
348
+
349
+ ```bash
350
+ overkill review-loop -n 3 --no-budget-gate # per run
351
+ OVERKILL_SKIP_BUDGET=1 overkill review-loop -n 3 # env var, covers every gate
352
+ ```
353
+
354
+ `NO_BUDGET_GATE=true` in `.overkillrc` / `.refactorsuggestrc` makes it the default.
355
+
327
356
  ## Customizing Prompts
328
357
 
329
358
  Edit the templates in `.overkill/prompts/active/`.
@@ -102,7 +102,14 @@ Options:
102
102
  --no-auto-commit Fix but do not commit/push (single iteration)
103
103
  --resume Resume from a previously interrupted run (reuses existing logs)
104
104
  --reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
105
+ --ci-trigger-mode <m> CI trigger policy: every|last-only|none (default: last-only).
106
+ 'last-only' tags each iteration commit with [skip ci]
107
+ and pushes a single empty trigger commit on PASS —
108
+ CI runs once instead of once per iteration.
109
+ Use 'every' to restore pre-0.3 per-commit CI.
105
110
  --diagnostic-log Save full Claude event stream to sidecar files
111
+ --no-budget-gate Skip token-budget checks and run regardless
112
+ (same as OVERKILL_SKIP_BUDGET=1)
106
113
 
107
114
  Examples:
108
115
  overkill review-loop -t main -n 3 # diff against main, max 3 loops
@@ -111,6 +118,7 @@ Examples:
111
118
  overkill review-loop -n 3 --no-self-review # disable self-review sub-loop
112
119
  overkill review-loop --resume # resume an interrupted run
113
120
  overkill review-loop -n 2 --reviewer-backend claude # use Claude as reviewer
121
+ overkill review-loop -n 10 --ci-trigger-mode last-only # CI fires once on PASS
114
122
  ```
115
123
 
116
124
  ## Usage: overkill refactor-suggest
@@ -135,6 +143,8 @@ Options:
135
143
  --with-review-loops <N> Set review-loop iteration count (implies --with-review)
136
144
  --reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
137
145
  --diagnostic-log Save full Claude event stream to sidecar files
146
+ --no-budget-gate Skip token-budget checks and run regardless
147
+ (same as OVERKILL_SKIP_BUDGET=1)
138
148
 
139
149
  Examples:
140
150
  overkill refactor-suggest -n 3 # auto scope (budget-aware)
@@ -281,6 +291,13 @@ All logs are git-ignored by default (inside `.overkill/`).
281
291
 
282
292
  The budget checker verifies Claude Code's 5-hour rate limit **before** starting expensive loops.
283
293
 
294
+ Codex is checked too, but only when it authenticates through a ChatGPT plan.
295
+ Auth mode is read from `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`),
296
+ falling back to the login method in `config.toml` when Codex keeps credentials
297
+ in the OS keyring instead; under API-key auth there are no plan rate-limit
298
+ windows, so the gate is skipped entirely and stale session logs from a previous
299
+ plan login are ignored.
300
+
284
301
  ### How it estimates usage
285
302
 
286
303
  | Mode | Data source | Accuracy |
@@ -301,6 +318,18 @@ Go/no-go decision based on current usage percentage:
301
318
  | `layer` | TBD | Cross-cutting changes |
302
319
  | `full` | TBD | Full architecture review |
303
320
 
321
+ ### Bypassing the gate
322
+
323
+ Budget data is an estimate read from local CLI logs, so it can be wrong — stale
324
+ logs, a changed auth mode, or a new rate-limit payload shape. To run anyway:
325
+
326
+ ```bash
327
+ overkill review-loop -n 3 --no-budget-gate # per run
328
+ OVERKILL_SKIP_BUDGET=1 overkill review-loop -n 3 # env var, covers every gate
329
+ ```
330
+
331
+ `NO_BUDGET_GATE=true` in `.overkillrc` / `.refactorsuggestrc` makes it the default.
332
+
304
333
  ## Customizing Prompts
305
334
 
306
335
  Edit the templates in `.overkill/prompts/active/`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "overkill"
3
- version = "0.7.0"
3
+ version = "0.8.2"
4
4
  description = "AI-powered code review loop — automates Codex/Gemini review + Claude fix cycles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -85,11 +85,14 @@ def main() -> None:
85
85
  from mr_overkill.cli import parse_review_loop_args
86
86
  from mr_overkill.review_loop import run as review_run
87
87
 
88
- review_config = parse_review_loop_args([
88
+ review_argv = [
89
89
  "-t", config.target_branch,
90
90
  "-n", str(extra.review_loops),
91
91
  "--reviewer-backend", config.reviewer_backend,
92
- ])
92
+ ]
93
+ if config.skip_budget_gate:
94
+ review_argv.append("--no-budget-gate")
95
+ review_config = parse_review_loop_args(review_argv)
93
96
  exit_code = review_run(review_config)
94
97
  sys.exit(exit_code)
95
98
  elif command == "check-budget":
@@ -16,6 +16,7 @@ from abc import ABC, abstractmethod
16
16
  from importlib.resources import as_file, files
17
17
  from pathlib import Path
18
18
 
19
+ from mr_overkill.budget import SKIP_BUDGET_ENV_VAR, budget_gate_disabled
19
20
  from mr_overkill.budget.claude import claude_budget_sufficient
20
21
  from mr_overkill.budget.codex import codex_budget_sufficient
21
22
  from mr_overkill.budget.gemini import gemini_budget_sufficient
@@ -88,6 +89,13 @@ def _budget_check(
88
89
  tool: str, scope: BudgetScope, max_wait: int
89
90
  ) -> bool:
90
91
  """Direct budget check without waiting."""
92
+ if budget_gate_disabled():
93
+ logger.info(
94
+ "Budget gate disabled via %s — skipping %s check.",
95
+ SKIP_BUDGET_ENV_VAR,
96
+ tool,
97
+ )
98
+ return True
91
99
  if tool == "claude":
92
100
  return claude_budget_sufficient(scope)
93
101
  if tool == "codex":
@@ -109,6 +117,12 @@ class _BudgetFn:
109
117
  def __call__(
110
118
  self, tool: str, scope: BudgetScope, max_wait: int
111
119
  ) -> bool:
120
+ if self._config.skip_budget_gate:
121
+ logger.info(
122
+ "Budget gate disabled (--no-budget-gate) — running %s anyway.",
123
+ tool,
124
+ )
125
+ return True
112
126
  actual = (
113
127
  max_wait if max_wait > 0 else self._config.retry_max_wait
114
128
  )
@@ -7,6 +7,7 @@ Ports ``_budget_sufficient`` from ``common.sh`` and
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
+ import os
10
11
 
11
12
  from mr_overkill.models import BudgetScope, BudgetStatus
12
13
  from mr_overkill.time_utils import codex_ts_to_iso
@@ -21,6 +22,28 @@ _THRESHOLDS: dict[BudgetScope, int | None] = {
21
22
  BudgetScope.FULL: None,
22
23
  }
23
24
 
25
+ # Rate-limit window kinds, keyed by the field they map onto in BudgetStatus.
26
+ FIVE_HOUR_WINDOW = "five_hour"
27
+ SEVEN_DAY_WINDOW = "seven_day"
28
+
29
+ # Any window declared as one day or shorter counts as the short ("5-hour")
30
+ # window; anything longer is the rolling weekly window.
31
+ _SHORT_WINDOW_MAX_MINUTES = 24 * 60
32
+
33
+ # Env escape hatch: set to 1/true/yes/on to bypass every budget gate.
34
+ SKIP_BUDGET_ENV_VAR = "OVERKILL_SKIP_BUDGET"
35
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
36
+
37
+
38
+ def budget_gate_disabled() -> bool:
39
+ """Return True if the user has disabled budget gating via the environment.
40
+
41
+ Local budget data is an estimate derived from CLI logs; when it is wrong
42
+ (stale logs, changed auth mode, new rate-limit shapes) the loop would
43
+ otherwise block with no way out.
44
+ """
45
+ return os.environ.get(SKIP_BUDGET_ENV_VAR, "").strip().lower() in _TRUTHY
46
+
24
47
 
25
48
  def has_threshold(scope: BudgetScope) -> bool:
26
49
  """Return True if *scope* has a defined go/no-go threshold."""
@@ -73,6 +96,36 @@ def budget_sufficient(scope: BudgetScope, status: BudgetStatus) -> bool:
73
96
  return False
74
97
 
75
98
 
99
+ def codex_window_kind(window: dict[str, object] | None) -> str | None:
100
+ """Classify a Codex ``rate_limits`` window by its declared length.
101
+
102
+ Codex does not guarantee that ``primary`` is the short window — on some
103
+ plans ``primary`` carries the weekly (``window_minutes: 10080``) limit.
104
+ Returns ``None`` when the window omits ``window_minutes``, leaving the
105
+ caller to fall back to positional assignment.
106
+ """
107
+ if not window:
108
+ return None
109
+
110
+ raw = window.get("window_minutes")
111
+ if raw is None:
112
+ return None
113
+
114
+ try:
115
+ minutes = float(str(raw))
116
+ except ValueError:
117
+ return None
118
+
119
+ if minutes <= 0:
120
+ return None
121
+
122
+ return (
123
+ FIVE_HOUR_WINDOW
124
+ if minutes <= _SHORT_WINDOW_MAX_MINUTES
125
+ else SEVEN_DAY_WINDOW
126
+ )
127
+
128
+
76
129
  def codex_parse_window(
77
130
  window: dict[str, object] | None,
78
131
  now_epoch: int,
@@ -0,0 +1,300 @@
1
+ """Codex CLI token-budget checker.
2
+
3
+ Ports the five functions from ``check-codex-limit.sh``:
4
+ find_latest_token_count, check_token_budget, codex_budget_sufficient.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import tomllib
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import Path
15
+
16
+ from mr_overkill.budget import (
17
+ FIVE_HOUR_WINDOW,
18
+ SEVEN_DAY_WINDOW,
19
+ budget_sufficient,
20
+ codex_parse_window,
21
+ codex_window_kind,
22
+ )
23
+ from mr_overkill.models import BudgetScope, BudgetStatus
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Codex auth modes, as written to ``auth.json`` by ``codex login``.
28
+ AUTH_MODE_APIKEY = "apikey"
29
+ AUTH_MODE_CHATGPT = "chatgpt"
30
+ AUTH_MODE_UNKNOWN = "unknown"
31
+
32
+ # ``config.toml`` keys that record the login method Codex was set up with.
33
+ # Only ``forced_login_method`` is read: the older ``preferred_auth_method`` is
34
+ # gone from current Codex, which silently ignores it, so a stale copy left in a
35
+ # config would misreport the login Codex actually uses.
36
+ _CONFIG_AUTH_KEYS = ("forced_login_method",)
37
+
38
+ # Login-method spellings seen across auth.json and config.toml, mapped to the
39
+ # canonical mode the budget gate compares against. ``api`` is the only value
40
+ # current Codex accepts for ``forced_login_method`` — it rejects the ``apikey``
41
+ # spellings outright — while auth.json writes ``apikey``.
42
+ _AUTH_MODE_ALIASES = {
43
+ "api": AUTH_MODE_APIKEY,
44
+ "apikey": AUTH_MODE_APIKEY,
45
+ "api_key": AUTH_MODE_APIKEY,
46
+ "api-key": AUTH_MODE_APIKEY,
47
+ "chatgpt": AUTH_MODE_CHATGPT,
48
+ }
49
+
50
+
51
+ def codex_home() -> Path:
52
+ """Return the Codex config directory (``$CODEX_HOME`` or ``~/.codex``)."""
53
+ raw = os.environ.get("CODEX_HOME", "").strip()
54
+ return Path(raw) if raw else Path.home() / ".codex"
55
+
56
+
57
+ def _config_auth_mode(home: Path) -> str | None:
58
+ """Return the login method declared in ``config.toml``, or ``None``.
59
+
60
+ Only recognised spellings are reported, so ``None`` means "the config
61
+ declares no login method we know" — which is what lets the caller tell an
62
+ explicit ChatGPT setup apart from a config that says nothing at all.
63
+ """
64
+ try:
65
+ with (home / "config.toml").open("rb") as handle:
66
+ config = tomllib.load(handle)
67
+ except (OSError, ValueError):
68
+ # ValueError covers both TOMLDecodeError and the UnicodeDecodeError
69
+ # tomllib raises when it decodes the bytes itself.
70
+ return None
71
+
72
+ for key in _CONFIG_AUTH_KEYS:
73
+ value = config.get(key)
74
+ if isinstance(value, str):
75
+ mode = _AUTH_MODE_ALIASES.get(value.strip().lower())
76
+ if mode is not None:
77
+ return mode
78
+
79
+ return None
80
+
81
+
82
+ def detect_auth_mode(home: Path | None = None) -> str:
83
+ """Detect how the Codex CLI authenticates.
84
+
85
+ ``CODEX_API_KEY`` wins outright: Codex sends it even when ``auth.json``
86
+ holds a ChatGPT login. Otherwise ``auth.json`` is authoritative, since it
87
+ records the mode chosen by the last ``codex login``. Falls back to the
88
+ login method declared in ``config.toml``, then to ``unknown``.
89
+
90
+ Only reads what is already on disk. Logins this cannot name — credentials
91
+ kept in the OS keyring, Bedrock, access tokens — come back ``unknown``,
92
+ which leaves the plan gate active. That is deliberate rather than a gap:
93
+ a login with no plan windows reports no usage either, so the gate finds no
94
+ data and lets the run through anyway. The only setup it actually blocks is
95
+ a switch away from a ChatGPT plan whose session logs are still unexpired,
96
+ and ``--no-budget-gate`` covers that.
97
+ """
98
+ if home is None:
99
+ home = codex_home()
100
+
101
+ # Checked before auth.json: Codex authenticates with this key regardless of
102
+ # what the last login stored.
103
+ if os.environ.get("CODEX_API_KEY", "").strip():
104
+ return AUTH_MODE_APIKEY
105
+
106
+ try:
107
+ auth = json.loads((home / "auth.json").read_text(encoding="utf-8"))
108
+ except (OSError, ValueError):
109
+ # ValueError covers both JSONDecodeError and the UnicodeDecodeError a
110
+ # non-UTF-8 auth.json raises: an unreadable file means "mode unknown",
111
+ # not a crashed review loop.
112
+ auth = None
113
+
114
+ if isinstance(auth, dict):
115
+ mode = auth.get("auth_mode")
116
+ if isinstance(mode, str) and mode.strip():
117
+ # Unrecognised spellings pass through as-is: they are not the
118
+ # API-key mode the gate bypasses, so the gate stays active.
119
+ spelling = mode.strip().lower()
120
+ return _AUTH_MODE_ALIASES.get(spelling, spelling)
121
+ # Older Codex versions omit auth_mode; infer from the stored payload.
122
+ if auth.get("tokens"):
123
+ return AUTH_MODE_CHATGPT
124
+ if auth.get("OPENAI_API_KEY"):
125
+ return AUTH_MODE_APIKEY
126
+
127
+ # auth.json can be missing entirely when Codex keeps credentials in the OS
128
+ # keyring (``cli_auth_credentials_store``); config.toml may still declare
129
+ # the login method, so consult it before giving up. An ambient
130
+ # OPENAI_API_KEY is deliberately not consulted: Codex does not authenticate
131
+ # with it, so a key left in the environment for other tools must not switch
132
+ # the gate off.
133
+ config_mode = _config_auth_mode(home)
134
+ if config_mode is not None:
135
+ return config_mode
136
+
137
+ return AUTH_MODE_UNKNOWN
138
+
139
+
140
+ def find_latest_token_count(
141
+ sessions_dir: Path | None = None,
142
+ ) -> dict[str, object] | None:
143
+ """Scan Codex session logs from last 7 days for the latest token_count event.
144
+
145
+ Returns the event dict, or ``None`` if no data found.
146
+ """
147
+ if sessions_dir is None:
148
+ sessions_dir = codex_home() / "sessions"
149
+
150
+ if not sessions_dir.is_dir():
151
+ return None
152
+
153
+ best_event: dict[str, object] | None = None
154
+ best_ts: object = None
155
+
156
+ now = datetime.now(tz=UTC)
157
+ for offset in range(7):
158
+ day = now - timedelta(days=offset)
159
+ day_dir = sessions_dir / day.strftime("%Y/%m/%d")
160
+ if not day_dir.is_dir():
161
+ continue
162
+
163
+ for jsonl_file in day_dir.glob("*.jsonl"):
164
+ try:
165
+ for line in jsonl_file.read_text(encoding="utf-8").splitlines():
166
+ if not line.strip():
167
+ continue
168
+ try:
169
+ entry = json.loads(line)
170
+ except json.JSONDecodeError:
171
+ continue
172
+
173
+ if (
174
+ entry.get("type") == "event_msg"
175
+ and isinstance(entry.get("payload"), dict)
176
+ and entry["payload"].get("type") == "token_count"
177
+ ):
178
+ ts = entry.get("timestamp")
179
+ if best_ts is None or (ts is not None and ts > best_ts):
180
+ best_ts = ts
181
+ best_event = entry
182
+ except OSError:
183
+ continue
184
+
185
+ return best_event
186
+
187
+
188
+ def check_token_budget(
189
+ sessions_dir: Path | None = None,
190
+ home: Path | None = None,
191
+ ) -> BudgetStatus:
192
+ """Get Codex budget status from session logs.
193
+
194
+ API-key auth is billed per token and carries no plan rate-limit windows,
195
+ so no budget data is reported for it — session logs written under a
196
+ previous ChatGPT login would otherwise gate the loop indefinitely.
197
+ """
198
+ if home is None:
199
+ # Session logs live at <codex_home>/sessions; callers that override
200
+ # only sessions_dir (tests, custom layouts) get the matching home.
201
+ home = sessions_dir.parent if sessions_dir is not None else codex_home()
202
+
203
+ auth_mode = detect_auth_mode(home)
204
+ if auth_mode == AUTH_MODE_APIKEY:
205
+ logger.info(
206
+ "Codex is on API-key auth — no plan rate limits to check.",
207
+ )
208
+ return BudgetStatus(
209
+ five_hour_used_pct=None,
210
+ seven_day_used_pct=None,
211
+ tokens_used=0,
212
+ mode=AUTH_MODE_APIKEY,
213
+ tier="",
214
+ resets_at=None,
215
+ seven_day_resets_at=None,
216
+ )
217
+
218
+ event = find_latest_token_count(sessions_dir)
219
+
220
+ if event is None:
221
+ return BudgetStatus(
222
+ five_hour_used_pct=None,
223
+ seven_day_used_pct=None,
224
+ tokens_used=0,
225
+ mode="no_data",
226
+ tier="",
227
+ resets_at=None,
228
+ seven_day_resets_at=None,
229
+ )
230
+
231
+ now_epoch = int(datetime.now(tz=UTC).timestamp())
232
+ payload = event.get("payload", {})
233
+ rate_limits = payload.get("rate_limits", {}) if isinstance(payload, dict) else {}
234
+
235
+ primary = rate_limits.get("primary") if isinstance(rate_limits, dict) else None
236
+ secondary = rate_limits.get("secondary") if isinstance(rate_limits, dict) else None
237
+
238
+ windows = _map_windows(
239
+ (
240
+ (primary, FIVE_HOUR_WINDOW),
241
+ (secondary, SEVEN_DAY_WINDOW),
242
+ ),
243
+ now_epoch,
244
+ )
245
+ five_pct, five_resets = windows.get(FIVE_HOUR_WINDOW, (None, None))
246
+ seven_pct, seven_resets = windows.get(SEVEN_DAY_WINDOW, (None, None))
247
+
248
+ return BudgetStatus(
249
+ five_hour_used_pct=five_pct,
250
+ seven_day_used_pct=seven_pct,
251
+ tokens_used=0,
252
+ mode="session_log",
253
+ tier="",
254
+ resets_at=five_resets,
255
+ seven_day_resets_at=seven_resets,
256
+ )
257
+
258
+
259
+ def _map_windows(
260
+ slots: tuple[tuple[object, str], ...],
261
+ now_epoch: int,
262
+ ) -> dict[str, tuple[int | None, str | None]]:
263
+ """Assign Codex rate-limit windows to their BudgetStatus fields.
264
+
265
+ Each ``(window, positional_kind)`` pair is classified by the window's own
266
+ ``window_minutes``; the positional kind is only a fallback for payloads
267
+ that omit it. When two windows land on the same kind, the higher usage
268
+ wins so the gate stays conservative.
269
+ """
270
+ mapped: dict[str, tuple[int | None, str | None]] = {}
271
+
272
+ for window, positional_kind in slots:
273
+ if not isinstance(window, dict) or not window:
274
+ continue
275
+
276
+ kind = codex_window_kind(window) or positional_kind
277
+ pct, resets = codex_parse_window(window, now_epoch)
278
+
279
+ previous = mapped.get(kind)
280
+ if previous is not None:
281
+ prev_pct = previous[0]
282
+ if pct is None or (prev_pct is not None and prev_pct >= pct):
283
+ continue
284
+
285
+ mapped[kind] = (pct, resets)
286
+
287
+ return mapped
288
+
289
+
290
+ def codex_budget_sufficient(
291
+ scope: BudgetScope,
292
+ status: BudgetStatus | None = None,
293
+ sessions_dir: Path | None = None,
294
+ ) -> bool:
295
+ """Go/no-go for Codex at the given scope."""
296
+ if status is None:
297
+ status = check_token_budget(sessions_dir)
298
+ if status.mode == AUTH_MODE_APIKEY:
299
+ return True
300
+ return budget_sufficient(scope, status)
@@ -6,8 +6,14 @@ import json
6
6
  import sys
7
7
  from dataclasses import asdict
8
8
 
9
- from mr_overkill.budget import budget_sufficient, has_threshold
9
+ from mr_overkill.budget import (
10
+ SKIP_BUDGET_ENV_VAR,
11
+ budget_gate_disabled,
12
+ budget_sufficient,
13
+ has_threshold,
14
+ )
10
15
  from mr_overkill.budget.claude import check_token_budget as claude_check
16
+ from mr_overkill.budget.codex import AUTH_MODE_APIKEY
11
17
  from mr_overkill.budget.codex import check_token_budget as codex_check
12
18
  from mr_overkill.models import BudgetScope, BudgetStatus
13
19
 
@@ -47,6 +53,10 @@ def _print_codex(status: BudgetStatus) -> None:
47
53
  print("Codex Token Budget")
48
54
  print(_HEADER)
49
55
  print(f" Mode: {status.mode}")
56
+ if status.mode == AUTH_MODE_APIKEY:
57
+ print(" No plan rate limits (API-key billing).")
58
+ print()
59
+ return
50
60
  print(
51
61
  f" 5h used: {_fmt_pct(status.five_hour_used_pct)}"
52
62
  f"{_fmt_reset(status.resets_at)}"
@@ -73,13 +83,23 @@ def _print_scope_table(claude_st: BudgetStatus, codex_st: BudgetStatus) -> None:
73
83
  BudgetScope.MICRO, BudgetScope.MODULE,
74
84
  BudgetScope.LAYER, BudgetScope.FULL,
75
85
  )
86
+ gate_off = budget_gate_disabled()
76
87
  for scope in scopes:
77
- if has_threshold(scope):
78
- c_label = "GO" if budget_sufficient(scope, claude_st) else "NOGO"
79
- x_label = "GO" if budget_sufficient(scope, codex_st) else "NOGO"
80
- else:
88
+ if not has_threshold(scope):
81
89
  c_label = x_label = "—"
90
+ else:
91
+ c_label = "GO" if gate_off or budget_sufficient(
92
+ scope, claude_st
93
+ ) else "NOGO"
94
+ codex_go = (
95
+ gate_off
96
+ or codex_st.mode == AUTH_MODE_APIKEY
97
+ or budget_sufficient(scope, codex_st)
98
+ )
99
+ x_label = "GO" if codex_go else "NOGO"
82
100
  print(f" {scope.value:<8} {c_label:<7} {x_label:<7} —")
101
+ if gate_off:
102
+ print(f" (all GO: {SKIP_BUDGET_ENV_VAR} is set)")
83
103
 
84
104
 
85
105
  def print_budget_report(*, json_mode: bool = False) -> int: