overkill 0.9.0__tar.gz → 0.9.1__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.9.0 → overkill-0.9.1}/PKG-INFO +1 -1
- {overkill-0.9.0 → overkill-0.9.1}/pyproject.toml +1 -1
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/claude.py +93 -22
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/cli.py +42 -40
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/commit_scope.py +25 -4
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/git_ops.py +2 -1
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/init.py +18 -8
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/loop_engine.py +3 -29
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/models.py +5 -2
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/refactor_suggest.py +8 -20
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/self_review.py +2 -1
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/wip_scope.py +3 -3
- overkill-0.9.1/src/mr_overkill/workspace_policy.py +103 -0
- overkill-0.9.1/tests/test_budget_claude.py +282 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_commit_scope.py +58 -0
- overkill-0.9.1/tests/test_workspace_policy.py +306 -0
- {overkill-0.9.0 → overkill-0.9.1}/uv.lock +1 -1
- overkill-0.9.0/tests/test_budget_claude.py +0 -91
- {overkill-0.9.0 → overkill-0.9.1}/.github/workflows/publish.yml +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/.github/workflows/test.yml +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/.gitignore +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/.overkillrc.example +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/.python-version +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/.refactorsuggestrc.example +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/AGENTS.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/CLAUDE.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/GEMINI.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/LICENSE +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/README.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/install.sh +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-fix-execute.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-fix.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-fix.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-full.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-layer.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-micro.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-module.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-review.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-self-review.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-full.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-layer.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-micro.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-module.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-review.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-full.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-module.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-review.prompt.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/claude-code-review-plugin.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/claude-security-review.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/codex-review-original.md +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/__init__.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/__main__.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/agents.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/__init__.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/codex.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/gemini.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget_report.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/classify.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/data/__init__.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/data/review.schema.json +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/json_extract.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/reporting.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/resume.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/retry.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/review_loop.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/time_utils.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/two_step_fix.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/__init__.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/conftest.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_agents.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_codex.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_gemini.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_policy.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_report.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_classify.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_cli.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_git_ops.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_init.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_integration.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_json_extract.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_loop_engine.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_refactor_suggest.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_reporting.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_resume.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_retry.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_review_loop.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_self_review.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_time_utils.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_two_step_fix.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/tests/test_wip_scope.py +0 -0
- {overkill-0.9.0 → overkill-0.9.1}/uninstall.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: overkill
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
|
@@ -45,21 +45,42 @@ _TIER_LIMITS: dict[str, int] = {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
_TIER_MAP: dict[str, str] = {
|
|
48
|
+
"default": "pro",
|
|
48
49
|
"default_claude_max_20x": "max20",
|
|
49
50
|
"default_claude_max_5x": "max5",
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
#: Reported in place of a tier that could not be determined.
|
|
55
|
+
UNKNOWN_TIER = "unknown"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def detect_tier(
|
|
59
|
+
telemetry_dir: Path | None = None, *, warn: bool = True
|
|
60
|
+
) -> str | None:
|
|
54
61
|
"""Read rateLimitTier from Claude Code telemetry files.
|
|
55
62
|
|
|
56
|
-
Returns ``'pro'``, ``'max5'``,
|
|
63
|
+
Returns ``'pro'``, ``'max5'``, ``'max20'``, or ``None`` when the tier
|
|
64
|
+
cannot be determined.
|
|
65
|
+
|
|
66
|
+
Pass ``warn=False`` from callers that only use the tier as a display
|
|
67
|
+
label. The warning below says the budget cannot be sized, which is only
|
|
68
|
+
true where the tier is a denominator; on the OAuth path it is noise, and
|
|
69
|
+
the budget gate re-checks per agent invocation, so it repeats.
|
|
70
|
+
|
|
71
|
+
``None`` rather than a default on purpose. This value is only ever used
|
|
72
|
+
as the denominator of a usage percentage, so guessing it does not degrade
|
|
73
|
+
gracefully: guessing ``pro`` for a max20 account reports 995% where the
|
|
74
|
+
truth is 49%, and the run blocks on a budget it has not used. Telemetry
|
|
75
|
+
formats change — ``user_attributes`` disappeared from the files once
|
|
76
|
+
already — so not knowing has to be sayable.
|
|
57
77
|
"""
|
|
58
78
|
if telemetry_dir is None:
|
|
59
79
|
telemetry_dir = Path.home() / ".claude" / "telemetry"
|
|
60
80
|
|
|
61
81
|
if not telemetry_dir.is_dir():
|
|
62
|
-
|
|
82
|
+
logger.debug("No telemetry directory at %s — tier unknown.", telemetry_dir)
|
|
83
|
+
return None
|
|
63
84
|
|
|
64
85
|
best_ts = ""
|
|
65
86
|
best_tier = ""
|
|
@@ -113,7 +134,22 @@ def detect_tier(telemetry_dir: Path | None = None) -> str:
|
|
|
113
134
|
best_ts = ts
|
|
114
135
|
best_tier = str(tier_raw)
|
|
115
136
|
|
|
116
|
-
|
|
137
|
+
if not best_tier:
|
|
138
|
+
# Debug, not a warning: the only caller that sizes a limit with this
|
|
139
|
+
# is check_local(), which logs its own accurate message when the
|
|
140
|
+
# missing tier actually costs it a percentage.
|
|
141
|
+
logger.debug(
|
|
142
|
+
"No rateLimitTier found in %s — cannot size the token limit.",
|
|
143
|
+
telemetry_dir,
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
tier = _TIER_MAP.get(best_tier)
|
|
147
|
+
if tier is None and warn:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"Unrecognised rateLimitTier %r — cannot size the token limit.",
|
|
150
|
+
best_tier,
|
|
151
|
+
)
|
|
152
|
+
return tier
|
|
117
153
|
|
|
118
154
|
|
|
119
155
|
def check_oauth() -> BudgetStatus | None:
|
|
@@ -179,7 +215,9 @@ def check_oauth() -> BudgetStatus | None:
|
|
|
179
215
|
logger.debug("OAuth: no utilization in response")
|
|
180
216
|
return None
|
|
181
217
|
|
|
182
|
-
|
|
218
|
+
# Only a label here: OAuth reports utilization directly, so an
|
|
219
|
+
# unknown tier costs nothing on this path — hence warn=False.
|
|
220
|
+
tier = detect_tier(warn=False) or UNKNOWN_TIER
|
|
183
221
|
seven_day = data.get("seven_day")
|
|
184
222
|
|
|
185
223
|
return BudgetStatus(
|
|
@@ -207,9 +245,15 @@ def check_local(projects_dir: Path | None = None) -> BudgetStatus:
|
|
|
207
245
|
|
|
208
246
|
Scans files modified in the last 5 hours, sums token usage
|
|
209
247
|
(deduplicating by message.id), and calculates percentage.
|
|
248
|
+
|
|
249
|
+
``five_hour_used_pct`` is ``None`` when tokens were used but the tier —
|
|
250
|
+
and so the limit to measure them against — could not be determined. A
|
|
251
|
+
percentage from a guessed limit is worse than no percentage at all:
|
|
252
|
+
``budget_sufficient`` treats ``None`` as "assume OK", where a wrong number
|
|
253
|
+
blocks the run outright.
|
|
210
254
|
"""
|
|
211
255
|
tier = detect_tier()
|
|
212
|
-
limit = _TIER_LIMITS.get(tier
|
|
256
|
+
limit = _TIER_LIMITS.get(tier) if tier else None
|
|
213
257
|
|
|
214
258
|
if projects_dir is None:
|
|
215
259
|
projects_dir = Path.home() / ".claude" / "projects"
|
|
@@ -260,24 +304,39 @@ def check_local(projects_dir: Path | None = None) -> BudgetStatus:
|
|
|
260
304
|
for usage in seen_ids.values():
|
|
261
305
|
total_tokens += _sum_usage(usage)
|
|
262
306
|
|
|
263
|
-
pct
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
pct
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
307
|
+
pct: int | None
|
|
308
|
+
if total_tokens <= 0:
|
|
309
|
+
# Nothing used needs no denominator.
|
|
310
|
+
pct = 0
|
|
311
|
+
elif limit:
|
|
312
|
+
pct = int(total_tokens) * 100 // limit
|
|
313
|
+
else:
|
|
314
|
+
pct = None
|
|
315
|
+
|
|
316
|
+
if pct is None:
|
|
317
|
+
logger.warning(
|
|
318
|
+
"Local estimate unavailable: %d weighted tokens over %d "
|
|
319
|
+
"deduplicated messages, but no tier to size the limit against.",
|
|
320
|
+
int(total_tokens),
|
|
321
|
+
len(seen_ids),
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
logger.info(
|
|
325
|
+
"Local estimate: %d%% (%d weighted tokens / %s limit, "
|
|
326
|
+
"tier=%s, %d deduplicated messages)",
|
|
327
|
+
pct,
|
|
328
|
+
int(total_tokens),
|
|
329
|
+
limit if limit else "unknown",
|
|
330
|
+
tier or UNKNOWN_TIER,
|
|
331
|
+
len(seen_ids),
|
|
332
|
+
)
|
|
274
333
|
|
|
275
334
|
return BudgetStatus(
|
|
276
335
|
five_hour_used_pct=pct,
|
|
277
336
|
seven_day_used_pct=None,
|
|
278
337
|
tokens_used=int(total_tokens),
|
|
279
338
|
mode="local",
|
|
280
|
-
tier=tier,
|
|
339
|
+
tier=tier or UNKNOWN_TIER,
|
|
281
340
|
resets_at=None,
|
|
282
341
|
seven_day_resets_at=None,
|
|
283
342
|
estimated=True,
|
|
@@ -365,16 +424,28 @@ def check_token_budget() -> BudgetStatus:
|
|
|
365
424
|
|
|
366
425
|
if cached is not None:
|
|
367
426
|
if cached.five_hour_used_pct is not None:
|
|
368
|
-
#
|
|
369
|
-
#
|
|
427
|
+
# Prefer the higher of the two, so a stale cache cannot
|
|
428
|
+
# under-report. Only when the local estimate has a percentage at
|
|
429
|
+
# all: without a known tier it has no denominator, and letting it
|
|
430
|
+
# win then would replace a real reading with a guess — which is
|
|
431
|
+
# how a 44% cache once became a 995% refusal.
|
|
370
432
|
if (
|
|
371
433
|
local.five_hour_used_pct is not None
|
|
372
434
|
and local.five_hour_used_pct > cached.five_hour_used_pct
|
|
373
435
|
):
|
|
374
|
-
|
|
436
|
+
logger.info(
|
|
437
|
+
"Using cached OAuth budget data, raised from %d%% to the "
|
|
438
|
+
"higher local estimate of %d%%.",
|
|
439
|
+
cached.five_hour_used_pct,
|
|
440
|
+
local.five_hour_used_pct,
|
|
441
|
+
)
|
|
442
|
+
return dataclasses.replace(
|
|
375
443
|
cached, five_hour_used_pct=local.five_hour_used_pct,
|
|
376
444
|
)
|
|
377
|
-
logger.info(
|
|
445
|
+
logger.info(
|
|
446
|
+
"Using cached OAuth budget data (%d%% used).",
|
|
447
|
+
cached.five_hour_used_pct,
|
|
448
|
+
)
|
|
378
449
|
return cached
|
|
379
450
|
# 5-hour expired but 7-day still valid — merge 7-day into local.
|
|
380
451
|
logger.info(
|
|
@@ -13,7 +13,7 @@ import subprocess
|
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
from mr_overkill import __version__
|
|
16
|
+
from mr_overkill import __version__, workspace_policy
|
|
17
17
|
from mr_overkill.commit_scope import resolve_commit
|
|
18
18
|
from mr_overkill.models import BudgetScope, LoopConfig
|
|
19
19
|
|
|
@@ -84,17 +84,21 @@ def _load_rc_file(rc_name: str) -> dict[str, str]:
|
|
|
84
84
|
return {}
|
|
85
85
|
|
|
86
86
|
git_root = Path(result.stdout.strip())
|
|
87
|
-
rc_path = git_root
|
|
87
|
+
rc_path = workspace_policy.workspace_path(git_root, rc_name)
|
|
88
|
+
legacy_dir = workspace_policy.legacy_workspace_path(git_root, rc_name)
|
|
88
89
|
if not rc_path.is_file():
|
|
89
|
-
#
|
|
90
|
-
legacy_dir = git_root / ".review-loop" / rc_name
|
|
91
|
-
# Also check for legacy .reviewlooprc name in .review-loop/
|
|
90
|
+
# Also check for the legacy rc name in the legacy directory
|
|
92
91
|
legacy_old_name = (
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
workspace_policy.legacy_workspace_path(
|
|
93
|
+
git_root, workspace_policy.LEGACY_RC_NAME
|
|
94
|
+
)
|
|
95
|
+
if rc_name == workspace_policy.RC_NAME else None
|
|
95
96
|
)
|
|
96
97
|
# Legacy repo-root name: .overkillrc was .reviewlooprc
|
|
97
|
-
legacy_root_name =
|
|
98
|
+
legacy_root_name = (
|
|
99
|
+
workspace_policy.LEGACY_RC_NAME
|
|
100
|
+
if rc_name == workspace_policy.RC_NAME else rc_name
|
|
101
|
+
)
|
|
98
102
|
legacy_root = git_root / legacy_root_name
|
|
99
103
|
if legacy_dir.is_file():
|
|
100
104
|
logging.getLogger(__name__).warning(
|
|
@@ -177,9 +181,11 @@ def _resolve_prompts_dir(prompts_dir: str) -> Path:
|
|
|
177
181
|
git_root = Path(result.stdout.strip())
|
|
178
182
|
resolved = git_root / prompts_dir
|
|
179
183
|
# Legacy fallback: .overkill/... → .review-loop/...
|
|
180
|
-
|
|
184
|
+
current_prefix = f"{workspace_policy.WORKSPACE_DIR}/"
|
|
185
|
+
legacy_prefix = f"{workspace_policy.LEGACY_WORKSPACE_DIR}/"
|
|
186
|
+
if not resolved.is_dir() and current_prefix in prompts_dir:
|
|
181
187
|
legacy = git_root / prompts_dir.replace(
|
|
182
|
-
|
|
188
|
+
current_prefix, legacy_prefix, 1
|
|
183
189
|
)
|
|
184
190
|
if legacy.is_dir():
|
|
185
191
|
logging.getLogger(__name__).warning(
|
|
@@ -189,9 +195,9 @@ def _resolve_prompts_dir(prompts_dir: str) -> Path:
|
|
|
189
195
|
)
|
|
190
196
|
return legacy
|
|
191
197
|
# Inverse fallback: .review-loop/... → .overkill/...
|
|
192
|
-
if not resolved.is_dir() and
|
|
198
|
+
if not resolved.is_dir() and legacy_prefix in prompts_dir:
|
|
193
199
|
migrated = git_root / prompts_dir.replace(
|
|
194
|
-
|
|
200
|
+
legacy_prefix, current_prefix, 1
|
|
195
201
|
)
|
|
196
202
|
if migrated.is_dir():
|
|
197
203
|
logging.getLogger(__name__).warning(
|
|
@@ -204,6 +210,24 @@ def _resolve_prompts_dir(prompts_dir: str) -> Path:
|
|
|
204
210
|
return p
|
|
205
211
|
|
|
206
212
|
|
|
213
|
+
def _resolve_log_dir(git_root: Path, *parts: str, resume: bool) -> Path:
|
|
214
|
+
"""Log directory for this run, preferring the current workspace layout.
|
|
215
|
+
|
|
216
|
+
A repo half-migrated between the two layouts can hold both, so a resume
|
|
217
|
+
additionally prefers whichever directory actually has the run metadata —
|
|
218
|
+
picking the empty one would report there is nothing to resume.
|
|
219
|
+
"""
|
|
220
|
+
candidates = workspace_policy.workspace_paths(git_root, "logs", *parts)
|
|
221
|
+
log_dir = next((c for c in candidates if c.is_dir()), candidates[0])
|
|
222
|
+
if resume and not (log_dir / "max-loop.txt").is_file():
|
|
223
|
+
with_metadata = next(
|
|
224
|
+
(c for c in candidates if (c / "max-loop.txt").is_file()), None
|
|
225
|
+
)
|
|
226
|
+
if with_metadata is not None:
|
|
227
|
+
log_dir = with_metadata
|
|
228
|
+
return log_dir
|
|
229
|
+
|
|
230
|
+
|
|
207
231
|
def _int_from_rc(
|
|
208
232
|
rc: dict[str, str],
|
|
209
233
|
key: str,
|
|
@@ -403,7 +427,7 @@ def parse_review_loop_args(
|
|
|
403
427
|
)
|
|
404
428
|
|
|
405
429
|
# Load rc file defaults
|
|
406
|
-
rc = _load_rc_file(
|
|
430
|
+
rc = _load_rc_file(workspace_policy.RC_NAME)
|
|
407
431
|
|
|
408
432
|
# Resolve values with precedence: CLI > rc file > defaults
|
|
409
433
|
target = args.target or rc.get("TARGET_BRANCH", "develop")
|
|
@@ -449,7 +473,7 @@ def parse_review_loop_args(
|
|
|
449
473
|
budget_scope_str = rc.get("BUDGET_SCOPE", "micro")
|
|
450
474
|
|
|
451
475
|
prompts_dir = _resolve_prompts_dir(
|
|
452
|
-
rc.get("PROMPTS_DIR",
|
|
476
|
+
rc.get("PROMPTS_DIR", workspace_policy.DEFAULT_PROMPTS_DIR)
|
|
453
477
|
)
|
|
454
478
|
|
|
455
479
|
current_branch = _detect_current_branch()
|
|
@@ -462,18 +486,7 @@ def parse_review_loop_args(
|
|
|
462
486
|
check=False,
|
|
463
487
|
)
|
|
464
488
|
git_root = Path(result.stdout.strip()) if result.returncode == 0 else Path(".")
|
|
465
|
-
log_dir = git_root
|
|
466
|
-
if not log_dir.is_dir():
|
|
467
|
-
legacy_log = git_root / ".review-loop" / "logs"
|
|
468
|
-
if legacy_log.is_dir():
|
|
469
|
-
log_dir = legacy_log
|
|
470
|
-
|
|
471
|
-
# In mixed-state repos, fall back to legacy logs when resume metadata
|
|
472
|
-
# is missing from the preferred directory.
|
|
473
|
-
if args.resume and not (log_dir / "max-loop.txt").is_file():
|
|
474
|
-
legacy_log = git_root / ".review-loop" / "logs"
|
|
475
|
-
if (legacy_log / "max-loop.txt").is_file():
|
|
476
|
-
log_dir = legacy_log
|
|
489
|
+
log_dir = _resolve_log_dir(git_root, resume=args.resume)
|
|
477
490
|
|
|
478
491
|
# Restore saved values on resume when not explicitly given
|
|
479
492
|
restored_target = None
|
|
@@ -818,7 +831,7 @@ def parse_refactor_suggest_args(
|
|
|
818
831
|
args = parser.parse_args(argv)
|
|
819
832
|
|
|
820
833
|
# Load rc file defaults
|
|
821
|
-
rc = _load_rc_file(
|
|
834
|
+
rc = _load_rc_file(workspace_policy.REFACTOR_RC_NAME)
|
|
822
835
|
|
|
823
836
|
# Resolve values: CLI > rc > defaults
|
|
824
837
|
scope = args.scope or rc.get("SCOPE", "auto")
|
|
@@ -880,7 +893,7 @@ def parse_refactor_suggest_args(
|
|
|
880
893
|
budget_scope_str = rc.get("BUDGET_SCOPE", "module")
|
|
881
894
|
|
|
882
895
|
prompts_dir = _resolve_prompts_dir(
|
|
883
|
-
rc.get("PROMPTS_DIR",
|
|
896
|
+
rc.get("PROMPTS_DIR", workspace_policy.DEFAULT_PROMPTS_DIR)
|
|
884
897
|
)
|
|
885
898
|
|
|
886
899
|
current_branch = _detect_current_branch()
|
|
@@ -896,18 +909,7 @@ def parse_refactor_suggest_args(
|
|
|
896
909
|
Path(result.stdout.strip()) if result.returncode == 0
|
|
897
910
|
else Path(".")
|
|
898
911
|
)
|
|
899
|
-
log_dir = git_root
|
|
900
|
-
if not log_dir.is_dir():
|
|
901
|
-
legacy_log = git_root / ".review-loop" / "logs" / "refactor"
|
|
902
|
-
if legacy_log.is_dir():
|
|
903
|
-
log_dir = legacy_log
|
|
904
|
-
|
|
905
|
-
# In mixed-state repos, fall back to legacy logs when resume metadata
|
|
906
|
-
# is missing from the preferred directory.
|
|
907
|
-
if args.resume and not (log_dir / "max-loop.txt").is_file():
|
|
908
|
-
legacy_log = git_root / ".review-loop" / "logs" / "refactor"
|
|
909
|
-
if (legacy_log / "max-loop.txt").is_file():
|
|
910
|
-
log_dir = legacy_log
|
|
912
|
+
log_dir = _resolve_log_dir(git_root, "refactor", resume=args.resume)
|
|
911
913
|
|
|
912
914
|
# Restore saved values on resume when not explicitly given
|
|
913
915
|
if args.resume:
|
|
@@ -20,9 +20,10 @@ from pathlib import Path
|
|
|
20
20
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
#: The empty tree under SHA-1, git's default object format. Only a fallback
|
|
24
|
+
#: for when git cannot be asked — :func:`empty_tree_sha` is the real answer,
|
|
25
|
+
#: because a repository created with ``--object-format=sha256`` has a
|
|
26
|
+
#: different one and this id does not resolve there at all.
|
|
26
27
|
EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
27
28
|
|
|
28
29
|
|
|
@@ -30,6 +31,26 @@ def _run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess
|
|
|
30
31
|
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def empty_tree_sha(cwd: Path | None = None) -> str:
|
|
35
|
+
"""The empty tree's object id in *this* repository's object format.
|
|
36
|
+
|
|
37
|
+
Diffing against it turns a root commit — which has no parent to diff
|
|
38
|
+
against — into a plain "everything was added" patch. Asking git rather
|
|
39
|
+
than hardcoding the SHA-1 value is what makes that work in a repository
|
|
40
|
+
using SHA-256, where the hardcoded id resolves to nothing and the diff
|
|
41
|
+
fails with a message about an empty commit instead.
|
|
42
|
+
"""
|
|
43
|
+
result = _run(["git", "hash-object", "-t", "tree", "/dev/null"], cwd)
|
|
44
|
+
sha = result.stdout.strip()
|
|
45
|
+
if result.returncode != 0 or not sha:
|
|
46
|
+
logger.debug(
|
|
47
|
+
"Could not compute the empty tree id (%s); falling back to SHA-1.",
|
|
48
|
+
result.stderr.strip() or f"exit {result.returncode}",
|
|
49
|
+
)
|
|
50
|
+
return EMPTY_TREE_SHA
|
|
51
|
+
return sha
|
|
52
|
+
|
|
53
|
+
|
|
33
54
|
def resolve_commit(rev: str, cwd: Path | None = None) -> str | None:
|
|
34
55
|
"""Resolve *rev* to a full commit SHA, or ``None`` if it is not a commit.
|
|
35
56
|
|
|
@@ -57,7 +78,7 @@ def commit_base(sha: str, cwd: Path | None = None) -> str:
|
|
|
57
78
|
result = _run(["git", "rev-list", "--parents", "-n", "1", sha], cwd)
|
|
58
79
|
fields = result.stdout.split()
|
|
59
80
|
if len(fields) < 2:
|
|
60
|
-
return
|
|
81
|
+
return empty_tree_sha(cwd)
|
|
61
82
|
return fields[1]
|
|
62
83
|
|
|
63
84
|
|
|
@@ -16,6 +16,7 @@ import subprocess
|
|
|
16
16
|
import uuid
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
+
from mr_overkill import workspace_policy
|
|
19
20
|
from mr_overkill.models import WorktreeSnapshot
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
@@ -222,7 +223,7 @@ def commit_and_push(
|
|
|
222
223
|
"""
|
|
223
224
|
changed = changed_files_since_snapshot(
|
|
224
225
|
snapshot, cwd=cwd,
|
|
225
|
-
exclude_prefixes=
|
|
226
|
+
exclude_prefixes=workspace_policy.LOG_PREFIXES,
|
|
226
227
|
)
|
|
227
228
|
if not changed:
|
|
228
229
|
logger.info("No file changes after fix — nothing to commit.")
|
|
@@ -9,10 +9,12 @@ from contextlib import contextmanager
|
|
|
9
9
|
from importlib.resources import as_file, files
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from mr_overkill import workspace_policy
|
|
13
|
+
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
14
|
-
_OVERKILL_DIR =
|
|
15
|
-
_LEGACY_DIR =
|
|
16
|
+
_OVERKILL_DIR = workspace_policy.WORKSPACE_DIR
|
|
17
|
+
_LEGACY_DIR = workspace_policy.LEGACY_WORKSPACE_DIR
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@contextmanager
|
|
@@ -69,8 +71,16 @@ def _copy_rc_files(data: Path, dest: Path) -> list[str]:
|
|
|
69
71
|
manifest: list[str] = []
|
|
70
72
|
project_root = dest.parent
|
|
71
73
|
rc_pairs = [
|
|
72
|
-
(
|
|
73
|
-
|
|
74
|
+
(
|
|
75
|
+
f"{workspace_policy.RC_NAME}.example",
|
|
76
|
+
workspace_policy.RC_NAME,
|
|
77
|
+
workspace_policy.LEGACY_RC_NAME,
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
f"{workspace_policy.REFACTOR_RC_NAME}.example",
|
|
81
|
+
workspace_policy.REFACTOR_RC_NAME,
|
|
82
|
+
None,
|
|
83
|
+
),
|
|
74
84
|
]
|
|
75
85
|
for example_name, live_name, legacy_name in rc_pairs:
|
|
76
86
|
live_path = dest / live_name
|
|
@@ -98,8 +108,8 @@ def _copy_rc_files(data: Path, dest: Path) -> list[str]:
|
|
|
98
108
|
def _ensure_gitignore(project_root: Path) -> None:
|
|
99
109
|
"""Add ``.overkill/`` to ``.gitignore`` if absent."""
|
|
100
110
|
gitignore = project_root / ".gitignore"
|
|
101
|
-
marker = "
|
|
102
|
-
legacy_marker = "
|
|
111
|
+
marker = f"{_OVERKILL_DIR}/"
|
|
112
|
+
legacy_marker = f"{_LEGACY_DIR}/"
|
|
103
113
|
|
|
104
114
|
if gitignore.is_file():
|
|
105
115
|
content = gitignore.read_text(encoding="utf-8")
|
|
@@ -167,8 +177,8 @@ def _migrate_legacy_dir(target_dir: Path) -> None:
|
|
|
167
177
|
logger.info("Migrated %s/ → %s/", _LEGACY_DIR, _OVERKILL_DIR)
|
|
168
178
|
|
|
169
179
|
# Rename legacy RC file inside the migrated directory
|
|
170
|
-
old_rc = dest /
|
|
171
|
-
new_rc = dest /
|
|
180
|
+
old_rc = dest / workspace_policy.LEGACY_RC_NAME
|
|
181
|
+
new_rc = dest / workspace_policy.RC_NAME
|
|
172
182
|
if old_rc.is_file() and not new_rc.is_file():
|
|
173
183
|
old_rc.rename(new_rc)
|
|
174
184
|
logger.info("Renamed .reviewlooprc → .overkillrc")
|
|
@@ -12,7 +12,7 @@ import subprocess
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Protocol
|
|
14
14
|
|
|
15
|
-
from mr_overkill import wip_scope
|
|
15
|
+
from mr_overkill import wip_scope, workspace_policy
|
|
16
16
|
from mr_overkill.git_ops import (
|
|
17
17
|
commit_and_push,
|
|
18
18
|
diff_hash,
|
|
@@ -513,16 +513,7 @@ def review_fix_loop(
|
|
|
513
513
|
# f. Stash allowlisted files
|
|
514
514
|
try:
|
|
515
515
|
allowed_stashed = stash_allowlisted(
|
|
516
|
-
|
|
517
|
-
".gitignore",
|
|
518
|
-
".overkill/.overkillrc",
|
|
519
|
-
".overkill/.refactorsuggestrc",
|
|
520
|
-
# Legacy paths for pre-migration users
|
|
521
|
-
".reviewlooprc",
|
|
522
|
-
".refactorsuggestrc",
|
|
523
|
-
".review-loop/.reviewlooprc",
|
|
524
|
-
".review-loop/.refactorsuggestrc",
|
|
525
|
-
],
|
|
516
|
+
sorted(workspace_policy.ALLOWLISTED_FILES),
|
|
526
517
|
cwd=cwd,
|
|
527
518
|
)
|
|
528
519
|
except RuntimeError:
|
|
@@ -697,24 +688,7 @@ def _validate_target_branch(target: str, cwd: Path | None) -> bool:
|
|
|
697
688
|
|
|
698
689
|
def _reject_dirty_worktree(cwd: Path | None) -> list[str]:
|
|
699
690
|
"""Return non-allowlisted dirty files, or empty list if clean."""
|
|
700
|
-
|
|
701
|
-
".gitignore",
|
|
702
|
-
".overkill/.overkillrc",
|
|
703
|
-
".overkill/.refactorsuggestrc",
|
|
704
|
-
# Legacy paths for pre-migration users
|
|
705
|
-
".overkillrc",
|
|
706
|
-
".reviewlooprc",
|
|
707
|
-
".refactorsuggestrc",
|
|
708
|
-
".review-loop/.overkillrc",
|
|
709
|
-
".review-loop/.reviewlooprc",
|
|
710
|
-
".review-loop/.refactorsuggestrc",
|
|
711
|
-
}
|
|
712
|
-
return [
|
|
713
|
-
f for f in git_all_dirty(cwd)
|
|
714
|
-
if f not in allowlisted
|
|
715
|
-
and not f.startswith(".overkill/logs/")
|
|
716
|
-
and not f.startswith(".review-loop/logs/")
|
|
717
|
-
]
|
|
691
|
+
return workspace_policy.non_allowlisted(git_all_dirty(cwd))
|
|
718
692
|
|
|
719
693
|
|
|
720
694
|
def _no_diff(target: str, current: str, cwd: Path | None) -> bool:
|
|
@@ -80,8 +80,11 @@ class BudgetStatus:
|
|
|
80
80
|
five_hour_used_pct: int | None
|
|
81
81
|
seven_day_used_pct: int | None
|
|
82
82
|
tokens_used: int # weighted sum for local estimation (not raw count)
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
# "oauth" | "local" | "cached" | "session_log"
|
|
84
|
+
# | "apikey" | "no_data" | "synthetic" | "unknown"
|
|
85
|
+
mode: str
|
|
86
|
+
# "" means the checker has no tier concept; budget_report relies on it.
|
|
87
|
+
tier: str # "pro" | "max5" | "max20" | "unknown" | ""
|
|
85
88
|
resets_at: str | None
|
|
86
89
|
seven_day_resets_at: str | None = None
|
|
87
90
|
estimated: bool = False
|
|
@@ -10,6 +10,7 @@ import logging
|
|
|
10
10
|
import subprocess
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
|
+
from mr_overkill import workspace_policy
|
|
13
14
|
from mr_overkill.agents import (
|
|
14
15
|
create_fix_agent,
|
|
15
16
|
create_review_agent,
|
|
@@ -18,7 +19,11 @@ from mr_overkill.agents import (
|
|
|
18
19
|
from mr_overkill.budget import budget_gate_disabled, budget_sufficient
|
|
19
20
|
from mr_overkill.budget.claude import check_token_budget as claude_budget
|
|
20
21
|
from mr_overkill.budget.codex import check_token_budget as codex_budget
|
|
21
|
-
from mr_overkill.git_ops import
|
|
22
|
+
from mr_overkill.git_ops import (
|
|
23
|
+
git_all_dirty,
|
|
24
|
+
stash_allowlisted,
|
|
25
|
+
unstash_allowlisted,
|
|
26
|
+
)
|
|
22
27
|
from mr_overkill.loop_engine import PreFixConfirmFn, review_fix_loop
|
|
23
28
|
from mr_overkill.models import (
|
|
24
29
|
BudgetScope,
|
|
@@ -108,18 +113,7 @@ def _get_budget_status(tool: str) -> BudgetStatus:
|
|
|
108
113
|
# ── Branch management ────────────────────────────────────────────────
|
|
109
114
|
|
|
110
115
|
|
|
111
|
-
_ALLOWLISTED_FILES =
|
|
112
|
-
".gitignore",
|
|
113
|
-
".overkill/.overkillrc",
|
|
114
|
-
".overkill/.refactorsuggestrc",
|
|
115
|
-
# Legacy paths for pre-migration users
|
|
116
|
-
".overkillrc",
|
|
117
|
-
".reviewlooprc",
|
|
118
|
-
".refactorsuggestrc",
|
|
119
|
-
".review-loop/.overkillrc",
|
|
120
|
-
".review-loop/.reviewlooprc",
|
|
121
|
-
".review-loop/.refactorsuggestrc",
|
|
122
|
-
]
|
|
116
|
+
_ALLOWLISTED_FILES = sorted(workspace_policy.ALLOWLISTED_FILES)
|
|
123
117
|
|
|
124
118
|
|
|
125
119
|
def create_refactor_branch(
|
|
@@ -348,13 +342,7 @@ def run(config: LoopConfig, scope: str, *, create_pr: bool = False) -> int:
|
|
|
348
342
|
|
|
349
343
|
# Reject non-allowlisted dirty files before creating a branch
|
|
350
344
|
if not config.dry_run and not config.resume:
|
|
351
|
-
|
|
352
|
-
non_allowed = [
|
|
353
|
-
f for f in git_all_dirty(None)
|
|
354
|
-
if f not in allowlisted
|
|
355
|
-
and not f.startswith(".overkill/logs/")
|
|
356
|
-
and not f.startswith(".review-loop/logs/")
|
|
357
|
-
]
|
|
345
|
+
non_allowed = workspace_policy.non_allowlisted(git_all_dirty(None))
|
|
358
346
|
if non_allowed:
|
|
359
347
|
logger.error(
|
|
360
348
|
"Working tree is not clean. Commit or stash your changes "
|
|
@@ -11,6 +11,7 @@ import string
|
|
|
11
11
|
import subprocess
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
from mr_overkill import workspace_policy
|
|
14
15
|
from mr_overkill.git_ops import changed_files_since_snapshot
|
|
15
16
|
from mr_overkill.json_extract import (
|
|
16
17
|
inject_refactoring_plan,
|
|
@@ -104,7 +105,7 @@ def self_review_subloop(
|
|
|
104
105
|
# Check if fix produced changes
|
|
105
106
|
changed = changed_files_since_snapshot(
|
|
106
107
|
pre_fix_snapshot, cwd=cwd,
|
|
107
|
-
exclude_prefixes=
|
|
108
|
+
exclude_prefixes=workspace_policy.LOG_PREFIXES,
|
|
108
109
|
)
|
|
109
110
|
if not changed:
|
|
110
111
|
logger.info("No working tree changes from fix — skipping self-review.")
|
|
@@ -22,15 +22,15 @@ import logging
|
|
|
22
22
|
import subprocess
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
|
+
from mr_overkill import workspace_policy
|
|
25
26
|
from mr_overkill.git_ops import git_all_dirty
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
29
30
|
SCAFFOLD_MESSAGE = "wip: review-loop scaffolding [skip ci]"
|
|
30
31
|
|
|
31
|
-
# Log artefacts are not part of anyone's work in progress.
|
|
32
|
-
|
|
33
|
-
_LOG_PREFIXES = (".overkill/logs/", ".review-loop/logs/")
|
|
32
|
+
# Log artefacts are not part of anyone's work in progress.
|
|
33
|
+
_LOG_PREFIXES = workspace_policy.LOG_PREFIXES
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _run(
|