cctally 1.27.1 → 1.29.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.
@@ -331,32 +331,45 @@ def insert_percent_milestone(
331
331
  def insert_budget_milestone(
332
332
  conn: sqlite3.Connection,
333
333
  *,
334
- week_start_at: str,
334
+ vendor: str,
335
+ period_start_at: str,
336
+ period: "str | None" = None,
335
337
  threshold: int,
336
338
  budget_usd: float,
337
339
  spent_usd: float,
338
340
  consumption_pct: float,
339
341
  commit: bool = True,
340
342
  ) -> int:
341
- """INSERT OR IGNORE a budget threshold crossing. Returns ``cur.rowcount``
342
- (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
343
- ``(week_start_at, threshold)`` row).
344
-
345
- Mirrors :func:`insert_percent_milestone`'s rowcount contract so the
346
- 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
343
+ """INSERT OR IGNORE a budget threshold crossing into the unified vendor-tagged
344
+ table (#143). Returns ``cur.rowcount`` (1 = genuinely new crossing, 0 =
345
+ INSERT OR IGNORE no-op on a pre-existing ``(vendor, period_start_at, period,
346
+ threshold)`` row).
347
+
348
+ The merged ``budget_milestones`` table (migration 012) carries a ``vendor``
349
+ column (``'claude'``|``'codex'``) and the renamed ``period_start_at`` key
350
+ (the Claude subscription-week start OR the Codex calendar-period start —
351
+ Codex has no Anthropic subscription week, spec §6). Mirrors
352
+ :func:`insert_percent_milestone`'s rowcount contract so the alert-fire
353
+ predicate (`if inserted == 1`) is race-safe without a follow-up SELECT.
354
+ ``period`` (#137) is the configured period noun at crossing
355
+ ('calendar-week'|'calendar-month'|'subscription-week'); it discriminates the
356
+ UNIQUE key so calendar-week and calendar-month windows that share a start
357
+ instant don't collide. A NULL ``period`` is the pre-011 "unknown" sentinel
358
+ (only seeded migration rows carry it). ``alerted_at`` is left NULL — the
359
+ caller stamps it in the SAME transaction BEFORE dispatching (set-then-dispatch
360
+ invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle
361
+ the INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
351
362
  crash between them can't strand ``alerted_at`` NULL forever.
352
363
  """
353
364
  cur = conn.execute(
354
365
  "INSERT OR IGNORE INTO budget_milestones "
355
- "(week_start_at, threshold, budget_usd, spent_usd, consumption_pct, "
356
- " crossed_at_utc) "
357
- "VALUES (?, ?, ?, ?, ?, ?)",
366
+ "(vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
367
+ " consumption_pct, crossed_at_utc) "
368
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
358
369
  (
359
- week_start_at,
370
+ str(vendor),
371
+ period_start_at,
372
+ period,
360
373
  int(threshold),
361
374
  float(budget_usd),
362
375
  float(spent_usd),
@@ -419,6 +432,7 @@ def insert_projected_milestone(
419
432
  conn: sqlite3.Connection,
420
433
  *,
421
434
  week_start_at: str,
435
+ period: "str | None" = None,
422
436
  metric: str,
423
437
  threshold: int,
424
438
  projected_value: float,
@@ -427,23 +441,27 @@ def insert_projected_milestone(
427
441
  ) -> int:
428
442
  """INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
429
443
  (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
430
- ``(week_start_at, metric, threshold)`` row).
444
+ ``(week_start_at, period, metric, threshold)`` row).
431
445
 
432
446
  Mirrors :func:`insert_budget_milestone`'s rowcount contract so the
433
447
  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.
448
+ SELECT. ``period`` (#137) is the configured period at crossing for the
449
+ ``weekly_pct`` leg it is 'subscription-week', for ``budget_usd`` the Claude
450
+ configured period, for ``codex_budget_usd`` the Codex configured period;
451
+ NULL is the pre-011 unknown sentinel. ``alerted_at`` is left NULL the
452
+ caller stamps it in the SAME transaction BEFORE dispatching (set-then-
453
+ dispatch invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the
454
+ caller bundle the INSERT with the follow-up ``alerted_at`` UPDATE in one
455
+ transaction so a crash between them can't strand ``alerted_at`` NULL forever.
439
456
  """
440
457
  cur = conn.execute(
441
458
  "INSERT OR IGNORE INTO projected_milestones "
442
- "(week_start_at, metric, threshold, projected_value, denominator, "
443
- " crossed_at_utc) "
444
- "VALUES (?, ?, ?, ?, ?, ?)",
459
+ "(week_start_at, period, metric, threshold, projected_value, "
460
+ " denominator, crossed_at_utc) "
461
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
445
462
  (
446
463
  week_start_at,
464
+ period,
447
465
  str(metric),
448
466
  int(threshold),
449
467
  float(projected_value),
@@ -460,69 +478,247 @@ def _projected_levels_already_latched(
460
478
  conn: sqlite3.Connection,
461
479
  *,
462
480
  week_start_at: str,
481
+ period: "str | None" = None,
463
482
  metric: str,
464
483
  levels: "tuple[int, ...]",
465
484
  ) -> bool:
466
485
  """True iff EVERY level in ``levels`` already has a row for
467
- ``(week_start_at, metric)``.
486
+ ``(week_start_at, period, metric)``.
468
487
 
469
488
  Cheap indexed SELECT used as the pre-probe gate BEFORE any projection math
470
489
  / cost work ([Pre-probe before sync_cache]). Empty ``levels`` → True
471
490
  (nothing owed). When False, at least one level is still un-recorded and the
472
491
  caller must do the projection. Mirrors the per-week pre-probe SELECT in
473
492
  :func:`maybe_record_budget_milestone`.
493
+
494
+ The ``period IS NULL`` arm (#137) means a pre-011 NULL-period row for the
495
+ current window counts as latched — so an upgrading user never re-fires a
496
+ spurious projected alert against a historical crossing.
474
497
  """
475
498
  if not levels:
476
499
  return True
477
500
  rows = conn.execute(
478
501
  "SELECT threshold FROM projected_milestones "
479
- "WHERE week_start_at = ? AND metric = ?",
480
- (week_start_at, str(metric)),
502
+ "WHERE week_start_at = ? AND (period = ? OR period IS NULL) "
503
+ " AND metric = ?",
504
+ (week_start_at, period, str(metric)),
481
505
  ).fetchall()
482
506
  have = {int(r[0]) for r in rows}
483
507
  return all(int(level) in have for level in levels)
484
508
 
485
509
 
486
- def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
487
- """Forward-only-from-set reconcile (spec §5): on `budget set`, every
488
- threshold ALREADY crossed for the current week is recorded with
489
- ``alerted_at`` SET but WITHOUT dispatch — so setting a budget when you're
490
- already at 95% does NOT instant-popup. Thresholds not yet crossed get NO
491
- row, so they fire later via :func:`maybe_record_budget_milestone`.
492
-
493
- 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).
496
- """
510
+ def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz):
511
+ """Resolve the Claude budget's ``(start_utc, end_utc)`` window for the
512
+ configured ``period`` (calendar-period-codex-budgets generalization, spec
513
+ §6). Subscription-week the existing ``_resolve_current_budget_window``
514
+ (snapshot-anchored; may return ``None`` when no usage snapshot has landed
515
+ yet). Calendar period the pure ``_resolve_calendar_window`` (derived purely
516
+ from ``now`` + the period; NEVER ``None``). The dedup key column is now
517
+ ``period_start_at`` (#143) it carries the resolved PERIOD-start instant
518
+ (subscription-week OR calendar period-start)."""
497
519
  c = _cctally()
498
- window = c._resolve_current_budget_window(conn, now_utc)
520
+ if period == "subscription-week":
521
+ return c._resolve_current_budget_window(conn, now_utc)
522
+ return c._resolve_calendar_window(period, now_utc, config, tz)
523
+
524
+
525
+ def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz):
526
+ """Resolve the budget period-start instant for ``vendor`` (#143). CHEAP — does
527
+ NO cost SUM, preserving the pre-probe-before-spend hot path (spec §4.2): the
528
+ firing paths resolve this cheap window, pre-probe which thresholds are already
529
+ latched, and skip the cost SUM entirely when nothing is pending.
530
+
531
+ Dispatches to the per-vendor window primitive:
532
+ * claude → :func:`_resolve_claude_budget_window` (snapshot-anchored for
533
+ subscription-week → may be ``None`` pre-snapshot; calendar period → the
534
+ pure calendar window, never ``None``).
535
+ * codex → :func:`_resolve_codex_budget_period_window` (pure calendar window;
536
+ never ``None``).
537
+
538
+ Returns the period-start ``datetime`` or ``None`` (claude subscription-week
539
+ pre-snapshot)."""
540
+ if vendor == "claude":
541
+ window = _resolve_claude_budget_window(
542
+ conn, now_utc, period=period, config=config, tz=tz
543
+ )
544
+ else:
545
+ window = _resolve_codex_budget_period_window(period, now_utc, config, tz)
499
546
  if window is None:
547
+ return None
548
+ start_at, _end_at = window
549
+ return start_at
550
+
551
+
552
+ def _budget_spend_for_vendor(conn, *, vendor, start_at, now_utc) -> float:
553
+ """Spend over ``[start_at, now]`` for ``vendor`` (#143) — the COSTLY leg,
554
+ called only after the pre-probe finds pending thresholds (spec §4.2). claude
555
+ routes through the Claude cost SUM (``mode="auto"``); codex through the Codex
556
+ cost SUM."""
557
+ c = _cctally()
558
+ if vendor == "claude":
559
+ return c._sum_cost_for_range(start_at, now_utc, mode="auto")
560
+ return c._sum_codex_cost_for_range(start_at, now_utc)
561
+
562
+
563
+ def _reconcile_budget_milestones_on_set(
564
+ conn, *, vendor, target, thresholds, now_utc, period, config=None, tz=None,
565
+ ):
566
+ """Forward-only-from-set reconcile for the budget axis (both vendors, #143):
567
+ on `budget set`, every threshold ALREADY crossed for the current
568
+ window/period is recorded with ``alerted_at`` SET but WITHOUT dispatch — so
569
+ setting a budget when you're already at 95% does NOT instant-popup. Thresholds
570
+ not yet crossed get NO row, so they fire later via the firing path
571
+ (:func:`maybe_record_budget_milestone` / :func:`maybe_record_codex_budget_milestone`).
572
+
573
+ A mid-window target change re-runs this; thresholds already alerted stay
574
+ deduped via UNIQUE(vendor, period_start_at, period, threshold) + the
575
+ ``alerted_at IS NULL`` guard on the UPDATE (so an existing alerted row is never
576
+ re-stamped).
577
+
578
+ Cold path — no pre-probe; resolves the cheap window then computes spend right
579
+ after (spec §4.2 ordering). ``vendor`` selects the window + spend dispatcher;
580
+ claude subscription-week may resolve ``None`` pre-snapshot (early return).
581
+ Keeps its own stamp-no-dispatch tail (distinct from :func:`_budget_crossings`,
582
+ which dispatches) — that asymmetry is intrinsic, not duplication.
583
+ """
584
+ start_at = _resolve_budget_window(
585
+ conn, vendor=vendor, now_utc=now_utc, period=period, config=config, tz=tz
586
+ )
587
+ if start_at is None:
500
588
  return
501
- week_start_at, _week_end_at = window
502
- week_key = week_start_at.isoformat(timespec="seconds")
503
- spent = c._sum_cost_for_range(week_start_at, now_utc, mode="auto")
504
- # target > 0 guaranteed by the caller (_cmd_budget_set passes the validated
505
- # weekly_usd); the else is belt-and-suspenders.
589
+ period_key = start_at.isoformat(timespec="seconds")
590
+ spent = _budget_spend_for_vendor(
591
+ conn, vendor=vendor, start_at=start_at, now_utc=now_utc
592
+ )
593
+ # target > 0 guaranteed by the caller (validated weekly_usd / amount_usd);
594
+ # the else is belt-and-suspenders.
506
595
  consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
507
596
  for t in sorted(thresholds):
508
597
  if consumption_pct + 1e-9 >= t:
509
598
  insert_budget_milestone(
510
599
  conn,
511
- week_start_at=week_key,
600
+ vendor=vendor,
601
+ period_start_at=period_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 (vendor, period) (not the
610
+ # wildcard): only the row we just inserted is stamped, never a
611
+ # pre-011 NULL-period sibling (#137) or another vendor's row (#143).
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 vendor = ? AND period_start_at = ? AND period = ? "
615
+ " AND threshold = ? AND alerted_at IS NULL",
616
+ (now_utc_iso(), vendor, period_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 _budget_crossings(
634
+ conn, *, vendor, period_key, period=None, thresholds, target, spent, now_utc
635
+ ):
636
+ """Shared INSERT-and-arm core for the budget axis (both vendors, #143): for
637
+ every STILL-pending threshold that's been crossed at ``spent``, ``INSERT OR
638
+ IGNORE`` a milestone (commit=False) into the unified vendor-tagged table and —
639
+ on the genuine-new-crossing winner (rowcount==1) — stamp ``alerted_at`` in the
640
+ SAME transaction (set-then-dispatch), returning the list of crossings the
641
+ caller must dispatch.
642
+
643
+ Pure of config/window resolution: both firing sites (record-usage +
644
+ opportunistic ``cctally budget``), for either vendor, feed the
645
+ already-resolved ``vendor`` / ``period_key`` / ``target`` / ``spent`` here, so
646
+ the crossing arithmetic + the set-then-dispatch invariant live in ONE place.
647
+ Does NOT commit — the caller owns the single durable commit that bundles every
648
+ INSERT with its ``alerted_at`` UPDATE. Applies the +1e-9 float-floor snap
649
+ (CLAUDE.md gotcha). Returns ``[(threshold, crossed_at, spent, target,
650
+ consumption_pct), ...]`` for the rowcount==1 winners only.
651
+
652
+ Forward-only / fire-once is enforced by INSERT OR IGNORE's rowcount on the
653
+ UNIQUE(vendor, period_start_at, period, threshold) key; a racing record-usage
654
+ instance OR an already-recorded threshold gets rowcount==0 and is skipped
655
+ ([Dedup mustn't gate side effects])."""
656
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
657
+ fired: "list" = []
658
+ for t in sorted(thresholds):
659
+ if consumption_pct + 1e-9 >= t:
660
+ inserted = insert_budget_milestone(
661
+ conn,
662
+ vendor=vendor,
663
+ period_start_at=period_key,
664
+ period=period,
665
+ threshold=t,
666
+ budget_usd=target,
667
+ spent_usd=spent,
668
+ consumption_pct=consumption_pct,
669
+ commit=False,
670
+ )
671
+ if inserted == 1:
672
+ crossed_at = now_utc_iso()
673
+ # alerted_at UPDATE keys on the CONCRETE (vendor, period) (#137 /
674
+ # #143): only the row just inserted is stamped, never a pre-011
675
+ # NULL-period sibling or another vendor's row.
676
+ conn.execute(
677
+ "UPDATE budget_milestones SET alerted_at = ? "
678
+ "WHERE vendor = ? AND period_start_at = ? AND period = ? "
679
+ " AND threshold = ? AND alerted_at IS NULL",
680
+ (crossed_at, vendor, period_key, period, t),
681
+ )
682
+ fired.append((t, crossed_at, spent, target, consumption_pct))
683
+ return fired
684
+
685
+
686
+ def _reconcile_codex_budget_on_config_write(validated_budget):
687
+ """Forward-only reconcile shared by the Codex-budget config write paths
688
+ (`budget set --vendor codex`, `config set budget.codex`). Gated +
689
+ best-effort: a Codex budget with alerts off or no thresholds records
690
+ nothing; a stats.db failure never fails the write. Runs OUTSIDE any
691
+ config_writer_lock (open_db has its own locking). Mirrors
692
+ :func:`_reconcile_budget_on_config_write`."""
693
+ codex = (validated_budget or {}).get("codex")
694
+ if not codex:
695
+ return
696
+ thresholds = codex.get("alert_thresholds") or []
697
+ if not (codex.get("alerts_enabled") and codex.get("amount_usd") and thresholds):
698
+ return
699
+ c = _cctally()
700
+ try:
701
+ import argparse
702
+ config = c.load_config()
703
+ tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
704
+ conn = open_db()
705
+ try:
706
+ _reconcile_budget_milestones_on_set(
707
+ conn,
708
+ vendor="codex",
709
+ target=codex["amount_usd"],
710
+ thresholds=thresholds,
711
+ now_utc=_command_as_of(),
712
+ period=codex["period"],
713
+ config=config,
714
+ tz=tz,
715
+ )
716
+ finally:
717
+ conn.close()
718
+ except Exception as exc: # best-effort; never fail the write
719
+ eprint(f"[codex-budget-milestone] reconcile on set failed: {exc}")
720
+
721
+
526
722
  def _reconcile_budget_on_config_write(validated_budget):
527
723
  """Forward-only reconcile shared by all three budget-config write
528
724
  paths (`budget set`, `config set budget.*`, dashboard POST
@@ -532,14 +728,23 @@ def _reconcile_budget_on_config_write(validated_budget):
532
728
  thresholds = validated_budget.get("alert_thresholds") or []
533
729
  if not (_budget_alerts_active(validated_budget) and thresholds):
534
730
  return
731
+ c = _cctally()
732
+ period = validated_budget.get("period", "subscription-week")
535
733
  try:
734
+ import argparse
735
+ config = c.load_config()
736
+ tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
536
737
  conn = open_db()
537
738
  try:
538
739
  _reconcile_budget_milestones_on_set(
539
740
  conn,
741
+ vendor="claude",
540
742
  target=validated_budget["weekly_usd"],
541
743
  thresholds=thresholds,
542
744
  now_utc=_command_as_of(),
745
+ period=period,
746
+ config=config,
747
+ tz=tz,
543
748
  )
544
749
  finally:
545
750
  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",