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.
Files changed (94) hide show
  1. {overkill-0.9.0 → overkill-0.9.1}/PKG-INFO +1 -1
  2. {overkill-0.9.0 → overkill-0.9.1}/pyproject.toml +1 -1
  3. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/claude.py +93 -22
  4. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/cli.py +42 -40
  5. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/commit_scope.py +25 -4
  6. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/git_ops.py +2 -1
  7. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/init.py +18 -8
  8. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/loop_engine.py +3 -29
  9. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/models.py +5 -2
  10. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/refactor_suggest.py +8 -20
  11. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/self_review.py +2 -1
  12. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/wip_scope.py +3 -3
  13. overkill-0.9.1/src/mr_overkill/workspace_policy.py +103 -0
  14. overkill-0.9.1/tests/test_budget_claude.py +282 -0
  15. {overkill-0.9.0 → overkill-0.9.1}/tests/test_commit_scope.py +58 -0
  16. overkill-0.9.1/tests/test_workspace_policy.py +306 -0
  17. {overkill-0.9.0 → overkill-0.9.1}/uv.lock +1 -1
  18. overkill-0.9.0/tests/test_budget_claude.py +0 -91
  19. {overkill-0.9.0 → overkill-0.9.1}/.github/workflows/publish.yml +0 -0
  20. {overkill-0.9.0 → overkill-0.9.1}/.github/workflows/test.yml +0 -0
  21. {overkill-0.9.0 → overkill-0.9.1}/.gitignore +0 -0
  22. {overkill-0.9.0 → overkill-0.9.1}/.overkillrc.example +0 -0
  23. {overkill-0.9.0 → overkill-0.9.1}/.python-version +0 -0
  24. {overkill-0.9.0 → overkill-0.9.1}/.refactorsuggestrc.example +0 -0
  25. {overkill-0.9.0 → overkill-0.9.1}/AGENTS.md +0 -0
  26. {overkill-0.9.0 → overkill-0.9.1}/CLAUDE.md +0 -0
  27. {overkill-0.9.0 → overkill-0.9.1}/GEMINI.md +0 -0
  28. {overkill-0.9.0 → overkill-0.9.1}/LICENSE +0 -0
  29. {overkill-0.9.0 → overkill-0.9.1}/README.md +0 -0
  30. {overkill-0.9.0 → overkill-0.9.1}/install.sh +0 -0
  31. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-fix-execute.prompt.md +0 -0
  32. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-fix.prompt.md +0 -0
  33. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
  34. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-fix.prompt.md +0 -0
  35. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-full.prompt.md +0 -0
  36. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-layer.prompt.md +0 -0
  37. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-micro.prompt.md +0 -0
  38. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-refactor-module.prompt.md +0 -0
  39. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-review.prompt.md +0 -0
  40. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/claude-self-review.prompt.md +0 -0
  41. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-full.prompt.md +0 -0
  42. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-layer.prompt.md +0 -0
  43. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-micro.prompt.md +0 -0
  44. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-refactor-module.prompt.md +0 -0
  45. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/codex-review.prompt.md +0 -0
  46. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-full.prompt.md +0 -0
  47. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
  48. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
  49. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-refactor-module.prompt.md +0 -0
  50. {overkill-0.9.0 → overkill-0.9.1}/prompts/active/gemini-review.prompt.md +0 -0
  51. {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/claude-code-review-plugin.md +0 -0
  52. {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/claude-security-review.md +0 -0
  53. {overkill-0.9.0 → overkill-0.9.1}/prompts/reference/codex-review-original.md +0 -0
  54. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/__init__.py +0 -0
  55. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/__main__.py +0 -0
  56. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/agents.py +0 -0
  57. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/__init__.py +0 -0
  58. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/codex.py +0 -0
  59. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget/gemini.py +0 -0
  60. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/budget_report.py +0 -0
  61. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/classify.py +0 -0
  62. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/data/__init__.py +0 -0
  63. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/data/review.schema.json +0 -0
  64. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/json_extract.py +0 -0
  65. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/reporting.py +0 -0
  66. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/resume.py +0 -0
  67. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/retry.py +0 -0
  68. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/review_loop.py +0 -0
  69. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/time_utils.py +0 -0
  70. {overkill-0.9.0 → overkill-0.9.1}/src/mr_overkill/two_step_fix.py +0 -0
  71. {overkill-0.9.0 → overkill-0.9.1}/tests/__init__.py +0 -0
  72. {overkill-0.9.0 → overkill-0.9.1}/tests/conftest.py +0 -0
  73. {overkill-0.9.0 → overkill-0.9.1}/tests/test_agents.py +0 -0
  74. {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_codex.py +0 -0
  75. {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_gemini.py +0 -0
  76. {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_policy.py +0 -0
  77. {overkill-0.9.0 → overkill-0.9.1}/tests/test_budget_report.py +0 -0
  78. {overkill-0.9.0 → overkill-0.9.1}/tests/test_classify.py +0 -0
  79. {overkill-0.9.0 → overkill-0.9.1}/tests/test_cli.py +0 -0
  80. {overkill-0.9.0 → overkill-0.9.1}/tests/test_git_ops.py +0 -0
  81. {overkill-0.9.0 → overkill-0.9.1}/tests/test_init.py +0 -0
  82. {overkill-0.9.0 → overkill-0.9.1}/tests/test_integration.py +0 -0
  83. {overkill-0.9.0 → overkill-0.9.1}/tests/test_json_extract.py +0 -0
  84. {overkill-0.9.0 → overkill-0.9.1}/tests/test_loop_engine.py +0 -0
  85. {overkill-0.9.0 → overkill-0.9.1}/tests/test_refactor_suggest.py +0 -0
  86. {overkill-0.9.0 → overkill-0.9.1}/tests/test_reporting.py +0 -0
  87. {overkill-0.9.0 → overkill-0.9.1}/tests/test_resume.py +0 -0
  88. {overkill-0.9.0 → overkill-0.9.1}/tests/test_retry.py +0 -0
  89. {overkill-0.9.0 → overkill-0.9.1}/tests/test_review_loop.py +0 -0
  90. {overkill-0.9.0 → overkill-0.9.1}/tests/test_self_review.py +0 -0
  91. {overkill-0.9.0 → overkill-0.9.1}/tests/test_time_utils.py +0 -0
  92. {overkill-0.9.0 → overkill-0.9.1}/tests/test_two_step_fix.py +0 -0
  93. {overkill-0.9.0 → overkill-0.9.1}/tests/test_wip_scope.py +0 -0
  94. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "overkill"
3
- version = "0.9.0"
3
+ version = "0.9.1"
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" }
@@ -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
- def detect_tier(telemetry_dir: Path | None = None) -> str:
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'``, or ``'max20'``.
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
- return "pro"
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
- return _TIER_MAP.get(best_tier, "pro")
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
- tier = detect_tier()
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, _TIER_LIMITS["pro"])
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 = (int(total_tokens) * 100 // limit) if total_tokens > 0 and limit > 0 else 0
264
-
265
- logger.info(
266
- "Local estimate: %d%% (%d weighted tokens / %d limit, "
267
- "tier=%s, %d deduplicated messages)",
268
- pct,
269
- int(total_tokens),
270
- limit,
271
- tier,
272
- len(seen_ids),
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
- # Use the higher 5h value between cached and local to avoid
369
- # underreporting when cache is stale.
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
- cached = dataclasses.replace(
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("Using cached OAuth budget data.")
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 / ".overkill" / rc_name
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
- # Fall back to the legacy .review-loop/ location
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
- git_root / ".review-loop" / ".reviewlooprc"
94
- if rc_name == ".overkillrc" else None
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 = ".reviewlooprc" if rc_name == ".overkillrc" else rc_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
- if not resolved.is_dir() and ".overkill/" in prompts_dir:
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
- ".overkill/", ".review-loop/", 1
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 ".review-loop/" in prompts_dir:
198
+ if not resolved.is_dir() and legacy_prefix in prompts_dir:
193
199
  migrated = git_root / prompts_dir.replace(
194
- ".review-loop/", ".overkill/", 1
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(".overkillrc")
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", ".overkill/prompts/active")
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 / ".overkill" / "logs"
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(".refactorsuggestrc")
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", ".overkill/prompts/active")
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 / ".overkill" / "logs" / "refactor"
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
- # ``git hash-object -t tree /dev/null`` — the canonical empty tree. Diffing
24
- # against it turns a root commit (which has no parent to diff against) into a
25
- # plain "everything was added" patch.
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 EMPTY_TREE_SHA
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=(".overkill/logs/", ".review-loop/logs/"),
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 = ".overkill"
15
- _LEGACY_DIR = ".review-loop"
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
- (".overkillrc.example", ".overkillrc", ".reviewlooprc"),
73
- (".refactorsuggestrc.example", ".refactorsuggestrc", None),
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 = ".overkill/"
102
- legacy_marker = ".review-loop/"
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 / ".reviewlooprc"
171
- new_rc = dest / ".overkillrc"
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
- allowlisted = {
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
- mode: str # "oauth" | "local" | "session_log" | "apikey" | "no_data"
84
- tier: str # "pro" | "max5" | "max20"
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 git_all_dirty, stash_allowlisted, unstash_allowlisted
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
- allowlisted = set(_ALLOWLISTED_FILES)
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=(".overkill/logs/", ".review-loop/logs/"),
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. Same list as
32
- # ``git_ops.commit_and_push``'s ``exclude_prefixes``.
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(