cctally 1.27.1 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -332,6 +332,7 @@ def insert_budget_milestone(
332
332
  conn: sqlite3.Connection,
333
333
  *,
334
334
  week_start_at: str,
335
+ period: "str | None" = None,
335
336
  threshold: int,
336
337
  budget_usd: float,
337
338
  spent_usd: float,
@@ -340,23 +341,77 @@ def insert_budget_milestone(
340
341
  ) -> int:
341
342
  """INSERT OR IGNORE a budget threshold crossing. Returns ``cur.rowcount``
342
343
  (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
343
- ``(week_start_at, threshold)`` row).
344
+ ``(week_start_at, period, threshold)`` row).
344
345
 
345
346
  Mirrors :func:`insert_percent_milestone`'s rowcount contract so the
346
347
  alert-fire predicate (`if inserted == 1`) is race-safe without a
347
- follow-up SELECT. ``alerted_at`` is left NULL — the caller stamps it in
348
- the SAME transaction BEFORE dispatching (set-then-dispatch invariant,
349
- CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle the
350
- INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
351
- crash between them can't strand ``alerted_at`` NULL forever.
348
+ follow-up SELECT. ``period`` (#137) is the configured period noun at
349
+ crossing ('calendar-week'|'calendar-month'|'subscription-week'); it
350
+ discriminates the UNIQUE key so calendar-week and calendar-month windows
351
+ that share a start instant don't collide. A NULL ``period`` is the pre-011
352
+ "unknown" sentinel (only seeded migration rows carry it). ``alerted_at`` is
353
+ left NULL — the caller stamps it in the SAME transaction BEFORE dispatching
354
+ (set-then-dispatch invariant, CLAUDE.md Alerts gotcha). ``commit=False``
355
+ lets the caller bundle the INSERT with the follow-up ``alerted_at`` UPDATE
356
+ in one transaction so a crash between them can't strand ``alerted_at`` NULL
357
+ forever.
352
358
  """
353
359
  cur = conn.execute(
354
360
  "INSERT OR IGNORE INTO budget_milestones "
355
- "(week_start_at, threshold, budget_usd, spent_usd, consumption_pct, "
356
- " crossed_at_utc) "
357
- "VALUES (?, ?, ?, ?, ?, ?)",
361
+ "(week_start_at, period, threshold, budget_usd, spent_usd, "
362
+ " consumption_pct, crossed_at_utc) "
363
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
358
364
  (
359
365
  week_start_at,
366
+ period,
367
+ int(threshold),
368
+ float(budget_usd),
369
+ float(spent_usd),
370
+ float(consumption_pct),
371
+ now_utc_iso(),
372
+ ),
373
+ )
374
+ if commit:
375
+ conn.commit()
376
+ return int(cur.rowcount)
377
+
378
+
379
+ def insert_codex_budget_milestone(
380
+ conn: sqlite3.Connection,
381
+ *,
382
+ period_start_at: str,
383
+ period: "str | None" = None,
384
+ threshold: int,
385
+ budget_usd: float,
386
+ spent_usd: float,
387
+ consumption_pct: float,
388
+ commit: bool = True,
389
+ ) -> int:
390
+ """INSERT OR IGNORE a Codex budget threshold crossing. Returns
391
+ ``cur.rowcount`` (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a
392
+ pre-existing ``(period_start_at, period, threshold)`` row).
393
+
394
+ Mirrors :func:`insert_budget_milestone` byte-for-byte but keyed on
395
+ ``period_start_at`` (the resolved CALENDAR-period window start instant) in
396
+ place of ``week_start_at`` — Codex has no Anthropic subscription week, so the
397
+ budget runs over a calendar period (spec §6). ``period`` (#137) is the
398
+ configured Codex period noun at crossing ('calendar-week'|'calendar-month');
399
+ NULL is the pre-011 unknown sentinel. Same rowcount contract so the
400
+ alert-fire predicate (`if inserted == 1`) is race-safe without a follow-up
401
+ SELECT. ``alerted_at`` is left NULL — the caller stamps it in the SAME
402
+ transaction BEFORE dispatching (set-then-dispatch invariant, CLAUDE.md
403
+ Alerts gotcha). ``commit=False`` lets the caller bundle the INSERT with the
404
+ follow-up ``alerted_at`` UPDATE in one transaction so a crash between them
405
+ can't strand ``alerted_at`` NULL forever.
406
+ """
407
+ cur = conn.execute(
408
+ "INSERT OR IGNORE INTO codex_budget_milestones "
409
+ "(period_start_at, period, threshold, budget_usd, spent_usd, "
410
+ " consumption_pct, crossed_at_utc) "
411
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
412
+ (
413
+ period_start_at,
414
+ period,
360
415
  int(threshold),
361
416
  float(budget_usd),
362
417
  float(spent_usd),
@@ -419,6 +474,7 @@ def insert_projected_milestone(
419
474
  conn: sqlite3.Connection,
420
475
  *,
421
476
  week_start_at: str,
477
+ period: "str | None" = None,
422
478
  metric: str,
423
479
  threshold: int,
424
480
  projected_value: float,
@@ -427,23 +483,27 @@ def insert_projected_milestone(
427
483
  ) -> int:
428
484
  """INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
429
485
  (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
430
- ``(week_start_at, metric, threshold)`` row).
486
+ ``(week_start_at, period, metric, threshold)`` row).
431
487
 
432
488
  Mirrors :func:`insert_budget_milestone`'s rowcount contract so the
433
489
  alert-fire predicate (`if inserted == 1`) is race-safe without a follow-up
434
- SELECT. ``alerted_at`` is left NULL the caller stamps it in the SAME
435
- transaction BEFORE dispatching (set-then-dispatch invariant, CLAUDE.md
436
- Alerts gotcha). ``commit=False`` lets the caller bundle the INSERT with the
437
- follow-up ``alerted_at`` UPDATE in one transaction so a crash between them
438
- can't strand ``alerted_at`` NULL forever.
490
+ SELECT. ``period`` (#137) is the configured period at crossing for the
491
+ ``weekly_pct`` leg it is 'subscription-week', for ``budget_usd`` the Claude
492
+ configured period, for ``codex_budget_usd`` the Codex configured period;
493
+ NULL is the pre-011 unknown sentinel. ``alerted_at`` is left NULL the
494
+ caller stamps it in the SAME transaction BEFORE dispatching (set-then-
495
+ dispatch invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the
496
+ caller bundle the INSERT with the follow-up ``alerted_at`` UPDATE in one
497
+ transaction so a crash between them can't strand ``alerted_at`` NULL forever.
439
498
  """
440
499
  cur = conn.execute(
441
500
  "INSERT OR IGNORE INTO projected_milestones "
442
- "(week_start_at, metric, threshold, projected_value, denominator, "
443
- " crossed_at_utc) "
444
- "VALUES (?, ?, ?, ?, ?, ?)",
501
+ "(week_start_at, period, metric, threshold, projected_value, "
502
+ " denominator, crossed_at_utc) "
503
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
445
504
  (
446
505
  week_start_at,
506
+ period,
447
507
  str(metric),
448
508
  int(threshold),
449
509
  float(projected_value),
@@ -460,42 +520,72 @@ def _projected_levels_already_latched(
460
520
  conn: sqlite3.Connection,
461
521
  *,
462
522
  week_start_at: str,
523
+ period: "str | None" = None,
463
524
  metric: str,
464
525
  levels: "tuple[int, ...]",
465
526
  ) -> bool:
466
527
  """True iff EVERY level in ``levels`` already has a row for
467
- ``(week_start_at, metric)``.
528
+ ``(week_start_at, period, metric)``.
468
529
 
469
530
  Cheap indexed SELECT used as the pre-probe gate BEFORE any projection math
470
531
  / cost work ([Pre-probe before sync_cache]). Empty ``levels`` → True
471
532
  (nothing owed). When False, at least one level is still un-recorded and the
472
533
  caller must do the projection. Mirrors the per-week pre-probe SELECT in
473
534
  :func:`maybe_record_budget_milestone`.
535
+
536
+ The ``period IS NULL`` arm (#137) means a pre-011 NULL-period row for the
537
+ current window counts as latched — so an upgrading user never re-fires a
538
+ spurious projected alert against a historical crossing.
474
539
  """
475
540
  if not levels:
476
541
  return True
477
542
  rows = conn.execute(
478
543
  "SELECT threshold FROM projected_milestones "
479
- "WHERE week_start_at = ? AND metric = ?",
480
- (week_start_at, str(metric)),
544
+ "WHERE week_start_at = ? AND (period = ? OR period IS NULL) "
545
+ " AND metric = ?",
546
+ (week_start_at, period, str(metric)),
481
547
  ).fetchall()
482
548
  have = {int(r[0]) for r in rows}
483
549
  return all(int(level) in have for level in levels)
484
550
 
485
551
 
486
- def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
552
+ def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz):
553
+ """Resolve the Claude budget's ``(start_utc, end_utc)`` window for the
554
+ configured ``period`` (calendar-period-codex-budgets generalization, spec
555
+ §6). Subscription-week → the existing ``_resolve_current_budget_window``
556
+ (snapshot-anchored; may return ``None`` when no usage snapshot has landed
557
+ yet). Calendar period → the pure ``_resolve_calendar_window`` (derived purely
558
+ from ``now`` + the period; NEVER ``None``). The dedup key column stays
559
+ ``week_start_at`` — for a calendar period it carries the resolved PERIOD-start
560
+ instant (a back-compat misnomer)."""
561
+ c = _cctally()
562
+ if period == "subscription-week":
563
+ return c._resolve_current_budget_window(conn, now_utc)
564
+ return c._resolve_calendar_window(period, now_utc, config, tz)
565
+
566
+
567
+ def _reconcile_budget_milestones_on_set(
568
+ conn, *, target, thresholds, now_utc, period="subscription-week",
569
+ config=None, tz=None,
570
+ ):
487
571
  """Forward-only-from-set reconcile (spec §5): on `budget set`, every
488
- threshold ALREADY crossed for the current week is recorded with
572
+ threshold ALREADY crossed for the current week/period is recorded with
489
573
  ``alerted_at`` SET but WITHOUT dispatch — so setting a budget when you're
490
574
  already at 95% does NOT instant-popup. Thresholds not yet crossed get NO
491
575
  row, so they fire later via :func:`maybe_record_budget_milestone`.
492
576
 
493
577
  A mid-week target change re-runs this; thresholds already alerted stay
494
- deduped via UNIQUE(week_start_at, threshold) + the ``alerted_at IS NULL``
495
- guard on the UPDATE (so an existing alerted row is never re-stamped).
578
+ deduped via UNIQUE(week_start_at, period, threshold) + the ``alerted_at IS
579
+ NULL`` guard on the UPDATE (so an existing alerted row is never re-stamped).
580
+
581
+ ``period`` defaults to subscription-week (byte-stable legacy behavior); a
582
+ calendar period resolves the window from ``now`` + the period instead of the
583
+ snapshot anchor (calendar-period-codex-budgets generalization, spec §6).
496
584
  """
497
585
  c = _cctally()
498
- window = c._resolve_current_budget_window(conn, now_utc)
586
+ window = _resolve_claude_budget_window(
587
+ conn, now_utc, period=period, config=config, tz=tz
588
+ )
499
589
  if window is None:
500
590
  return
501
591
  week_start_at, _week_end_at = window
@@ -509,20 +599,167 @@ def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
509
599
  insert_budget_milestone(
510
600
  conn,
511
601
  week_start_at=week_key,
602
+ period=period,
512
603
  threshold=t,
513
604
  budget_usd=target,
514
605
  spent_usd=spent,
515
606
  consumption_pct=consumption_pct,
516
607
  commit=False,
517
608
  )
609
+ # alerted_at UPDATE keys on the CONCRETE period (not the wildcard):
610
+ # only the row we just inserted under `period` is stamped, never a
611
+ # pre-011 NULL-period sibling (#137).
518
612
  conn.execute(
519
613
  "UPDATE budget_milestones SET alerted_at = ? "
520
- "WHERE week_start_at = ? AND threshold = ? AND alerted_at IS NULL",
521
- (now_utc_iso(), week_key, t),
614
+ "WHERE week_start_at = ? AND period = ? AND threshold = ? "
615
+ " AND alerted_at IS NULL",
616
+ (now_utc_iso(), week_key, period, t),
522
617
  )
523
618
  conn.commit()
524
619
 
525
620
 
621
+ def _resolve_codex_budget_period_window(period, now_utc, config, tz):
622
+ """Resolve the Codex budget's ``(start_utc, end_utc)`` calendar window via
623
+ the forecast layer's ``_resolve_calendar_window`` (which routes to the pure
624
+ ``calendar_month_window`` / ``calendar_week_window`` kernel functions). The
625
+ Codex axis NEVER touches ``weekly_usage_snapshots`` (no Anthropic week), so
626
+ the window comes purely from ``now`` + the configured calendar period.
627
+ ``period`` is canonical (calendar-week / calendar-month — the validator
628
+ forbids subscription-week for Codex)."""
629
+ c = _cctally()
630
+ return c._resolve_calendar_window(period, now_utc, config, tz)
631
+
632
+
633
+ def _codex_budget_crossings(
634
+ conn, *, period_key, period=None, thresholds, target, spent, now_utc
635
+ ):
636
+ """Shared INSERT-and-arm core for the Codex budget axis: for every
637
+ STILL-pending threshold that's been crossed at ``spent``, ``INSERT OR
638
+ IGNORE`` a milestone (commit=False) and — on the genuine-new-crossing winner
639
+ (rowcount==1) — stamp ``alerted_at`` in the SAME transaction (set-then-
640
+ dispatch), returning the list of crossings the caller must dispatch.
641
+
642
+ Pure of config/window resolution: both firing sites (record-usage +
643
+ opportunistic ``cctally budget``) feed the already-resolved ``period_key`` /
644
+ ``target`` / ``spent`` here, so the crossing arithmetic + the set-then-
645
+ dispatch invariant live in ONE place (plan §3.6 "one shared helper"). Does
646
+ NOT commit — the caller owns the single durable commit that bundles every
647
+ INSERT with its ``alerted_at`` UPDATE. Applies the +1e-9 float-floor snap
648
+ (CLAUDE.md gotcha). Returns ``[(threshold, crossed_at, spent, target,
649
+ consumption_pct), ...]`` for the rowcount==1 winners only.
650
+
651
+ Forward-only / fire-once is enforced by INSERT OR IGNORE's rowcount on the
652
+ UNIQUE(period_start_at, period, threshold) key; a racing record-usage
653
+ instance OR an already-recorded threshold gets rowcount==0 and is skipped
654
+ ([Dedup mustn't gate side effects])."""
655
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
656
+ fired: "list" = []
657
+ for t in sorted(thresholds):
658
+ if consumption_pct + 1e-9 >= t:
659
+ inserted = insert_codex_budget_milestone(
660
+ conn,
661
+ period_start_at=period_key,
662
+ period=period,
663
+ threshold=t,
664
+ budget_usd=target,
665
+ spent_usd=spent,
666
+ consumption_pct=consumption_pct,
667
+ commit=False,
668
+ )
669
+ if inserted == 1:
670
+ crossed_at = now_utc_iso()
671
+ # alerted_at UPDATE keys on the CONCRETE period (#137): only the
672
+ # row just inserted under `period` is stamped, never a pre-011
673
+ # NULL-period sibling.
674
+ conn.execute(
675
+ "UPDATE codex_budget_milestones SET alerted_at = ? "
676
+ "WHERE period_start_at = ? AND period = ? AND threshold = ? "
677
+ " AND alerted_at IS NULL",
678
+ (crossed_at, period_key, period, t),
679
+ )
680
+ fired.append((t, crossed_at, spent, target, consumption_pct))
681
+ return fired
682
+
683
+
684
+ def _reconcile_codex_budget_milestones_on_set(
685
+ conn, *, target, thresholds, now_utc, period, config, tz
686
+ ):
687
+ """Forward-only-from-set reconcile for the Codex budget axis (spec §6),
688
+ mirroring :func:`_reconcile_budget_milestones_on_set` but keyed on the
689
+ resolved CALENDAR period window instead of the subscription week.
690
+
691
+ On a Codex `budget set` (or `config set budget.codex`), every threshold
692
+ ALREADY crossed for the current period is recorded with ``alerted_at`` SET
693
+ but WITHOUT dispatch — so setting a Codex budget mid-month while already over
694
+ does NOT instant-popup; a mid-period amount change never re-alerts an
695
+ already-fired threshold (deduped via UNIQUE(period_start_at, period,
696
+ threshold) + the ``alerted_at IS NULL`` UPDATE guard). Thresholds not yet
697
+ crossed get NO row, so they fire later via
698
+ :func:`maybe_record_codex_budget_milestone`."""
699
+ c = _cctally()
700
+ start_at, _end_at = _resolve_codex_budget_period_window(
701
+ period, now_utc, config, tz
702
+ )
703
+ period_key = start_at.isoformat(timespec="seconds")
704
+ spent = c._sum_codex_cost_for_range(start_at, now_utc)
705
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
706
+ for t in sorted(thresholds):
707
+ if consumption_pct + 1e-9 >= t:
708
+ insert_codex_budget_milestone(
709
+ conn,
710
+ period_start_at=period_key,
711
+ period=period,
712
+ threshold=t,
713
+ budget_usd=target,
714
+ spent_usd=spent,
715
+ consumption_pct=consumption_pct,
716
+ commit=False,
717
+ )
718
+ # alerted_at UPDATE keys on the CONCRETE period (#137).
719
+ conn.execute(
720
+ "UPDATE codex_budget_milestones SET alerted_at = ? "
721
+ "WHERE period_start_at = ? AND period = ? AND threshold = ? "
722
+ " AND alerted_at IS NULL",
723
+ (now_utc_iso(), period_key, period, t),
724
+ )
725
+ conn.commit()
726
+
727
+
728
+ def _reconcile_codex_budget_on_config_write(validated_budget):
729
+ """Forward-only reconcile shared by the Codex-budget config write paths
730
+ (`budget set --vendor codex`, `config set budget.codex`). Gated +
731
+ best-effort: a Codex budget with alerts off or no thresholds records
732
+ nothing; a stats.db failure never fails the write. Runs OUTSIDE any
733
+ config_writer_lock (open_db has its own locking). Mirrors
734
+ :func:`_reconcile_budget_on_config_write`."""
735
+ codex = (validated_budget or {}).get("codex")
736
+ if not codex:
737
+ return
738
+ thresholds = codex.get("alert_thresholds") or []
739
+ if not (codex.get("alerts_enabled") and codex.get("amount_usd") and thresholds):
740
+ return
741
+ c = _cctally()
742
+ try:
743
+ import argparse
744
+ config = c.load_config()
745
+ tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
746
+ conn = open_db()
747
+ try:
748
+ _reconcile_codex_budget_milestones_on_set(
749
+ conn,
750
+ target=codex["amount_usd"],
751
+ thresholds=thresholds,
752
+ now_utc=_command_as_of(),
753
+ period=codex["period"],
754
+ config=config,
755
+ tz=tz,
756
+ )
757
+ finally:
758
+ conn.close()
759
+ except Exception as exc: # best-effort; never fail the write
760
+ eprint(f"[codex-budget-milestone] reconcile on set failed: {exc}")
761
+
762
+
526
763
  def _reconcile_budget_on_config_write(validated_budget):
527
764
  """Forward-only reconcile shared by all three budget-config write
528
765
  paths (`budget set`, `config set budget.*`, dashboard POST
@@ -532,7 +769,12 @@ def _reconcile_budget_on_config_write(validated_budget):
532
769
  thresholds = validated_budget.get("alert_thresholds") or []
533
770
  if not (_budget_alerts_active(validated_budget) and thresholds):
534
771
  return
772
+ c = _cctally()
773
+ period = validated_budget.get("period", "subscription-week")
535
774
  try:
775
+ import argparse
776
+ config = c.load_config()
777
+ tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
536
778
  conn = open_db()
537
779
  try:
538
780
  _reconcile_budget_milestones_on_set(
@@ -540,6 +782,9 @@ def _reconcile_budget_on_config_write(validated_budget):
540
782
  target=validated_budget["weekly_usd"],
541
783
  thresholds=thresholds,
542
784
  now_utc=_command_as_of(),
785
+ period=period,
786
+ config=config,
787
+ tz=tz,
543
788
  )
544
789
  finally:
545
790
  conn.close()
@@ -1290,6 +1290,24 @@ def build_parser() -> argparse.ArgumentParser:
1290
1290
  "--project", nargs="?", const="__CWD__", default=None,
1291
1291
  help="Set/unset a per-project budget for this git repo "
1292
1292
  "(bare = current directory's git-root; or pass a path).")
1293
+ bg.add_argument(
1294
+ "--vendor", choices=["claude", "codex"], default="claude",
1295
+ help="Which vendor budget to set/unset (default claude). Codex "
1296
+ "budgets are calendar-period only.")
1297
+ bg.add_argument(
1298
+ "--period",
1299
+ # Accept both canonical and short spellings; the command handler
1300
+ # normalizes short->canonical and rejects `--vendor codex
1301
+ # --period subscription-week` (Codex has no Anthropic week). The choices
1302
+ # are single-sourced from `_BUDGET_PERIOD_CHOICES` (derived from the same
1303
+ # short→canonical map the normalizer uses), so they can't drift from the
1304
+ # handler (code-review #5).
1305
+ choices=c._BUDGET_PERIOD_CHOICES,
1306
+ default=None,
1307
+ help="Budget period: subscription-week (claude only) / calendar-week "
1308
+ "/ calendar-month. Default: preserve the stored period, else the "
1309
+ "per-vendor default (claude=subscription-week, codex="
1310
+ "calendar-month).")
1293
1311
  bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1294
1312
  help="Show real project basenames in the per-project section "
1295
1313
  "of --format output (default anonymizes to project-1/…).")
@@ -2192,16 +2210,21 @@ def build_parser() -> argparse.ArgumentParser:
2192
2210
  cctally alerts test --axis five-hour --threshold 95
2193
2211
  cctally alerts test --axis budget --threshold 100
2194
2212
  cctally alerts test --axis project-budget --threshold 100
2213
+ cctally alerts test --axis codex-budget --threshold 100
2195
2214
  cctally alerts test --axis projected --metric budget_usd
2215
+ cctally alerts test --axis projected --metric codex_budget_usd
2196
2216
  """),
2197
2217
  )
2198
2218
  p_alerts_test.add_argument(
2199
2219
  "--axis",
2200
- choices=["weekly", "five-hour", "budget", "project-budget", "projected"],
2220
+ choices=[
2221
+ "weekly", "five-hour", "budget", "project-budget", "codex-budget",
2222
+ "projected",
2223
+ ],
2201
2224
  default="weekly",
2202
2225
  help="Alert axis to simulate: weekly subscription window, 5h block, "
2203
- "equiv-$ budget, per-project equiv-$ budget, or projected-pace "
2204
- "(default: weekly).",
2226
+ "equiv-$ budget, per-project equiv-$ budget, Codex budget, or "
2227
+ "projected-pace (default: weekly).",
2205
2228
  )
2206
2229
  p_alerts_test.add_argument(
2207
2230
  "--threshold",
@@ -2211,7 +2234,7 @@ def build_parser() -> argparse.ArgumentParser:
2211
2234
  )
2212
2235
  p_alerts_test.add_argument(
2213
2236
  "--metric",
2214
- choices=["weekly_pct", "budget_usd"],
2237
+ choices=["weekly_pct", "budget_usd", "codex_budget_usd"],
2215
2238
  default="weekly_pct",
2216
2239
  help="For --axis projected: which projected metric to preview "
2217
2240
  "(default: weekly_pct).",
@@ -2334,6 +2357,23 @@ def build_parser() -> argparse.ArgumentParser:
2334
2357
  )
2335
2358
  db_unskip.set_defaults(func=c.cmd_db_unskip)
2336
2359
 
2360
+ db_recover = db_sub.add_parser(
2361
+ "recover",
2362
+ help="Revert a version-ahead DB to the known schema head (#145)",
2363
+ )
2364
+ db_recover.add_argument(
2365
+ "--db",
2366
+ required=True,
2367
+ choices=("cache", "stats"),
2368
+ help="Which DB to recover",
2369
+ )
2370
+ db_recover.add_argument(
2371
+ "--yes",
2372
+ action="store_true",
2373
+ help="Required for --db stats (non-re-derivable; may need a re-record)",
2374
+ )
2375
+ db_recover.set_defaults(func=c.cmd_db_recover)
2376
+
2337
2377
  # ─── doctor (Diagnostics) ───────────────────────────────────────────
2338
2378
  doctor_p = sub.add_parser(
2339
2379
  "doctor",