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.
- {overkill-0.7.0 → overkill-0.8.2}/.overkillrc.example +14 -0
- {overkill-0.7.0 → overkill-0.8.2}/.refactorsuggestrc.example +6 -0
- {overkill-0.7.0 → overkill-0.8.2}/PKG-INFO +30 -1
- {overkill-0.7.0 → overkill-0.8.2}/README.md +29 -0
- {overkill-0.7.0 → overkill-0.8.2}/pyproject.toml +1 -1
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/__main__.py +5 -2
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/agents.py +14 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/__init__.py +53 -0
- overkill-0.8.2/src/mr_overkill/budget/codex.py +300 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget_report.py +25 -5
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/cli.py +59 -1
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/git_ops.py +43 -6
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/loop_engine.py +50 -1
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/models.py +11 -1
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/refactor_suggest.py +10 -2
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/review_loop.py +13 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/conftest.py +17 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_agents.py +39 -0
- overkill-0.8.2/tests/test_budget_codex.py +457 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_policy.py +66 -1
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_cli.py +186 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_git_ops.py +75 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_loop_engine.py +120 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_refactor_suggest.py +35 -0
- overkill-0.8.2/tests/test_review_loop.py +225 -0
- {overkill-0.7.0 → overkill-0.8.2}/uv.lock +1 -1
- overkill-0.7.0/src/mr_overkill/budget/codex.py +0 -118
- overkill-0.7.0/tests/test_budget_codex.py +0 -106
- overkill-0.7.0/tests/test_review_loop.py +0 -66
- {overkill-0.7.0 → overkill-0.8.2}/.github/workflows/publish.yml +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/.github/workflows/test.yml +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/.gitignore +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/.python-version +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/AGENTS.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/CLAUDE.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/GEMINI.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/LICENSE +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/install.sh +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-fix-execute.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-fix.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-fix.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/claude-self-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/codex-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/active/gemini-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/claude-code-review-plugin.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/claude-security-review.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/prompts/reference/codex-review-original.md +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/claude.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/budget/gemini.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/classify.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/data/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/data/review.schema.json +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/init.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/json_extract.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/reporting.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/resume.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/retry.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/self_review.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/time_utils.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/src/mr_overkill/two_step_fix.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_claude.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_gemini.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_budget_report.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_classify.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_init.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_integration.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_json_extract.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_reporting.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_resume.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_retry.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_self_review.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_time_utils.py +0 -0
- {overkill-0.7.0 → overkill-0.8.2}/tests/test_two_step_fix.py +0 -0
- {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.
|
|
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/`.
|
|
@@ -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
|
-
|
|
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
|
|
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:
|