cctally 1.27.0 → 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.
@@ -301,6 +301,7 @@ ALLOWED_CONFIG_KEYS = (
301
301
  "alerts.notifier",
302
302
  "alerts.command_template",
303
303
  "dashboard.bind",
304
+ "dashboard.expose_transcripts",
304
305
  "update.check.enabled",
305
306
  "update.check.ttl_hours",
306
307
  "statusline.visual_burn_rate",
@@ -310,8 +311,10 @@ ALLOWED_CONFIG_KEYS = (
310
311
  "budget.alerts_enabled",
311
312
  "budget.alert_thresholds",
312
313
  "budget.projected_enabled",
314
+ "budget.period",
313
315
  "budget.projects",
314
316
  "budget.project_alerts_enabled",
317
+ "budget.codex",
315
318
  )
316
319
 
317
320
 
@@ -449,6 +452,32 @@ def _config_known_value(config: dict, key: str) -> "object":
449
452
  # Hand-edited junk: surface the default rather than the bad value;
450
453
  # `cmd_dashboard` warns at server-start when it hits the same path.
451
454
  return "loopback"
455
+ if key == "dashboard.expose_transcripts":
456
+ # Boolean opt-in (Plan 2, spec §5). Default False — transcript
457
+ # endpoints are served only over loopback unless this is true (LAN
458
+ # exposure). A hand-edited junk value surfaces the default, mirroring
459
+ # dashboard.bind.
460
+ block = config.get("dashboard") if isinstance(config, dict) else None
461
+ if not isinstance(block, dict):
462
+ block = {}
463
+ stored = block.get("expose_transcripts")
464
+ if stored is None:
465
+ return False
466
+ # Config stores a JSON bool; the shared string-normalizer
467
+ # (_normalize_alerts_enabled_value) only tolerates str spellings,
468
+ # so short-circuit a real bool here rather than re-forking it.
469
+ if isinstance(stored, bool):
470
+ return stored
471
+ # Only str spellings are normalizable. Any other JSON scalar/container
472
+ # (int/float/list/dict) must surface the default — NOT crash: the shared
473
+ # normalizer does ``(raw or "").strip()``, which raises AttributeError
474
+ # (uncaught by ``except ValueError``) on e.g. a hand-edited bare ``1``.
475
+ if isinstance(stored, str):
476
+ try:
477
+ return c._normalize_alerts_enabled_value(stored)
478
+ except ValueError:
479
+ return False
480
+ return False
452
481
  if key in ("update.check.enabled", "update.check.ttl_hours"):
453
482
  # Defaults mirror `_is_update_check_due` (True / 24 hours).
454
483
  # Hand-edited junk surfaces as the default — matches dashboard.bind.
@@ -501,8 +530,10 @@ def _config_known_value(config: dict, key: str) -> "object":
501
530
  "budget.alerts_enabled",
502
531
  "budget.alert_thresholds",
503
532
  "budget.projected_enabled",
533
+ "budget.period",
504
534
  "budget.projects",
505
535
  "budget.project_alerts_enabled",
536
+ "budget.codex",
506
537
  ):
507
538
  inner = key.split(".", 1)[1]
508
539
  # Read the validated, defaults-filled block. A corrupt block falls
@@ -533,7 +564,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
533
564
  # (including None) must survive into the render layer — the generic
534
565
  # None->"" coercion below would break the JSON shape / round-trip.
535
566
  def _coerce(k: str, v: "object") -> "object":
536
- if k in ("alerts.command_template", "budget.projects"):
567
+ if k in ("alerts.command_template", "budget.projects", "budget.codex"):
537
568
  return v
538
569
  return v if v is not None else ""
539
570
 
@@ -562,11 +593,14 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
562
593
  for k, v in pairs:
563
594
  # Preserve canonical bool stringification (true/false) so
564
595
  # round-trips via `config set alerts.enabled <plain-text>` work.
565
- if k in ("alerts.command_template", "budget.projects"):
596
+ if k in (
597
+ "alerts.command_template", "budget.projects", "budget.codex"
598
+ ):
566
599
  # JSON-encoded so `config get` output round-trips through the
567
600
  # matching `config set` branch (both JSON-parse their value).
568
601
  # `alerts.command_template` is a list-of-strings|null;
569
- # `budget.projects` is an object {git-root: usd}.
602
+ # `budget.projects` is an object {git-root: usd};
603
+ # `budget.codex` is an object|null (the no-budget sentinel).
570
604
  rendered = json.dumps(v)
571
605
  elif isinstance(v, bool):
572
606
  rendered = "true" if v else "false"
@@ -787,6 +821,49 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
787
821
  else:
788
822
  print(f"dashboard.bind={canonical}")
789
823
  return 0
824
+ if key == "dashboard.expose_transcripts":
825
+ # Same read-modify-write posture as dashboard.bind: validate first,
826
+ # then write under config_writer_lock with _load_config_unlocked
827
+ # (calling load_config inside the writer-lock self-deadlocks per the
828
+ # CLAUDE.md gotcha — fcntl.flock is per-fd, not per-process). Preserves
829
+ # a sibling dashboard.bind in the same parent block.
830
+ # Reuse the shared bool-normalizer (DRY with alerts.enabled); it
831
+ # hardcodes "alerts.enabled" in its ValueError text, so catch +
832
+ # re-message with the actual key name (mirrors alerts.projected_enabled).
833
+ try:
834
+ canonical = c._normalize_alerts_enabled_value(raw)
835
+ except ValueError:
836
+ print(
837
+ f"cctally: invalid boolean value for dashboard.expose_transcripts: "
838
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
839
+ file=sys.stderr,
840
+ )
841
+ return 2
842
+ with config_writer_lock():
843
+ config = _load_config_unlocked()
844
+ existing = config.get("dashboard")
845
+ if existing is not None and not isinstance(existing, dict):
846
+ print(
847
+ "cctally: dashboard config error: dashboard must be an object",
848
+ file=sys.stderr,
849
+ )
850
+ return 2
851
+ block = dict(existing or {})
852
+ block["expose_transcripts"] = canonical
853
+ config["dashboard"] = block
854
+ save_config(config)
855
+ if getattr(args, "emit_json", False):
856
+ print(
857
+ json.dumps(
858
+ {"dashboard": {"expose_transcripts": canonical}}, indent=2
859
+ )
860
+ )
861
+ else:
862
+ print(
863
+ f"dashboard.expose_transcripts="
864
+ f"{'true' if canonical else 'false'}"
865
+ )
866
+ return 0
790
867
  if key in (
791
868
  "statusline.visual_burn_rate",
792
869
  "statusline.cost_source",
@@ -877,8 +954,10 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
877
954
  "budget.alerts_enabled",
878
955
  "budget.alert_thresholds",
879
956
  "budget.projected_enabled",
957
+ "budget.period",
880
958
  "budget.projects",
881
959
  "budget.project_alerts_enabled",
960
+ "budget.codex",
882
961
  ):
883
962
  inner_key = key.split(".", 1)[1]
884
963
  # Parse + normalize the raw value per key BEFORE acquiring the lock so
@@ -912,6 +991,36 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
912
991
  f"got {raw!r}"
913
992
  )
914
993
  return 2
994
+ elif inner_key == "period":
995
+ # `budget.period` is a plain string leaf. The enum check lives in
996
+ # _get_budget_config under the lock below (so a bad value is a
997
+ # clean exit-2 with the canonical message); here we just pass the
998
+ # raw token through.
999
+ new_val = raw.strip()
1000
+ elif inner_key == "codex":
1001
+ # `budget.codex` is a nested object (or null = no Codex budget),
1002
+ # which the plain leaves can't round-trip — JSON-parse it (mirrors
1003
+ # the budget.projects branch). The shape/period/amount rules are
1004
+ # enforced by _get_budget_config under the lock below; here we only
1005
+ # reject non-JSON and coerce the null sentinel.
1006
+ if raw.strip().lower() in {"null", "none"}:
1007
+ new_val = None
1008
+ else:
1009
+ try:
1010
+ parsed_codex = json.loads(raw)
1011
+ except (json.JSONDecodeError, ValueError):
1012
+ eprint(
1013
+ "cctally config: budget.codex must be a JSON object or "
1014
+ f"null, got {raw!r}"
1015
+ )
1016
+ return 2
1017
+ if parsed_codex is not None and not isinstance(parsed_codex, dict):
1018
+ eprint(
1019
+ "cctally config: budget.codex must be a JSON object or "
1020
+ "null"
1021
+ )
1022
+ return 2
1023
+ new_val = parsed_codex
915
1024
  elif inner_key == "projects":
916
1025
  # `budget.projects` is a dict {git-root: usd}, which the plain
917
1026
  # number/bool/list leaves can't round-trip — JSON-parse it (mirrors
@@ -995,25 +1104,41 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
995
1104
  # on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
996
1105
  # dispatched threshold as already-alerted, permanently suppressing the
997
1106
  # next record-usage tick's dispatch. The global axis feeds on
998
- # weekly_usd/alerts_enabled/alert_thresholds; the per-project axis on
999
- # projects/project_alerts_enabled/alert_thresholds (alert_thresholds is
1000
- # shared; projected_enabled belongs to neither reconcile). Both run
1001
- # OUTSIDE config_writer_lock (each helper has its own open_db lock).
1002
- if inner_key in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
1107
+ # weekly_usd/alerts_enabled/alert_thresholds/period; the per-project axis
1108
+ # on projects/project_alerts_enabled/alert_thresholds (alert_thresholds
1109
+ # is shared; projected_enabled belongs to neither reconcile). `period` is
1110
+ # in the global set because changing it re-keys the milestone window
1111
+ # (calendar period-start instant vs subscription-week); without the
1112
+ # reconcile, switching period while already over a threshold would
1113
+ # instant-popup on the next record-usage tick — the exact case the
1114
+ # forward-only-from-set reconcile prevents (`budget set --period` already
1115
+ # reconciles via the same helper). Both run OUTSIDE config_writer_lock
1116
+ # (each helper has its own open_db lock).
1117
+ if inner_key in (
1118
+ "weekly_usd", "alerts_enabled", "alert_thresholds", "period"
1119
+ ):
1003
1120
  c._reconcile_budget_on_config_write(validated)
1004
1121
  if inner_key in (
1005
1122
  "projects", "project_alerts_enabled", "alert_thresholds"
1006
1123
  ):
1007
1124
  c._reconcile_project_budget_milestones_on_write(validated)
1125
+ # Codex budget axis (spec §6): the nested budget.codex block is set
1126
+ # wholesale via `config set budget.codex '<json>'`, so the only key that
1127
+ # touches it is `codex` itself. Gated on the codex block carrying
1128
+ # alerts_enabled + thresholds (the helper re-checks); records nothing
1129
+ # otherwise.
1130
+ if inner_key == "codex":
1131
+ c._reconcile_codex_budget_on_config_write(validated)
1008
1132
  out_val = validated[inner_key]
1009
1133
  if getattr(args, "emit_json", False):
1010
1134
  print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
1011
1135
  else:
1012
1136
  if isinstance(out_val, bool):
1013
1137
  rendered = "true" if out_val else "false"
1014
- elif inner_key == "projects":
1015
- # JSON so `config get budget.projects` round-trips back through
1016
- # this branch (str(dict) is not valid JSON).
1138
+ elif inner_key in ("projects", "codex"):
1139
+ # JSON so `config get budget.{projects,codex}` round-trips back
1140
+ # through this branch (str(dict)/None is not valid JSON; the
1141
+ # codex no-budget sentinel renders as `null`).
1017
1142
  rendered = json.dumps(out_val)
1018
1143
  else:
1019
1144
  rendered = str(out_val)
@@ -1096,6 +1221,21 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1096
1221
  save_config(config)
1097
1222
  # idempotent: silent on missing key
1098
1223
  return 0
1224
+ if key == "dashboard.expose_transcripts":
1225
+ # Mirror the dashboard.bind unset branch: drop only the
1226
+ # expose_transcripts leaf; if the dashboard block ends up empty, drop
1227
+ # the parent too so config.json stays tidy. A sibling dashboard.bind
1228
+ # survives.
1229
+ with config_writer_lock():
1230
+ config = _load_config_unlocked()
1231
+ block = config.get("dashboard")
1232
+ if isinstance(block, dict) and "expose_transcripts" in block:
1233
+ del block["expose_transcripts"]
1234
+ if not block:
1235
+ config.pop("dashboard", None)
1236
+ save_config(config)
1237
+ # idempotent: silent on missing key
1238
+ return 0
1099
1239
  if key in (
1100
1240
  "statusline.visual_burn_rate",
1101
1241
  "statusline.cost_source",
@@ -1137,8 +1277,10 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1137
1277
  "budget.alerts_enabled",
1138
1278
  "budget.alert_thresholds",
1139
1279
  "budget.projected_enabled",
1280
+ "budget.period",
1140
1281
  "budget.projects",
1141
1282
  "budget.project_alerts_enabled",
1283
+ "budget.codex",
1142
1284
  ):
1143
1285
  # Drop only the named leaf; preserve sibling budget.* keys (e.g.
1144
1286
  # unsetting weekly_usd keeps a customized alert_thresholds). If the
@@ -160,6 +160,24 @@ def _is_dev_checkout() -> bool:
160
160
  return (_repo_root() / ".git").exists()
161
161
 
162
162
 
163
+ def _real_prod_data_dir() -> pathlib.Path:
164
+ """The REAL user's prod data dir (~/.local/share/cctally), resolved from
165
+ the password database rather than $HOME so it is immune to a faked HOME.
166
+
167
+ The prod-migration guard (bin/_cctally_db.py, issue #142) compares the
168
+ connection's DB directory against this to tell a fake-HOME test 'prod'
169
+ (e.g. a golden harness's /tmp/scratch/.local/share/cctally) apart from
170
+ the actual prod dir. Monkeypatchable seam: tests point it at a tmp dir to
171
+ exercise the guard's fire path without touching real prod. Falls back to
172
+ Path.home() only if `pwd` is unavailable (cctally targets Unix only)."""
173
+ try:
174
+ import pwd
175
+ home = pathlib.Path(pwd.getpwuid(os.getuid()).pw_dir)
176
+ except Exception:
177
+ home = pathlib.Path.home()
178
+ return home / ".local" / "share" / "cctally"
179
+
180
+
163
181
  _init_paths_from_env()
164
182
 
165
183
 
@@ -570,21 +588,49 @@ class _BudgetConfigError(ValueError):
570
588
  """Raised by _get_budget_config on an invalid budget block."""
571
589
 
572
590
 
591
+ def _validate_positive_budget_amount(v: object, label: str) -> float:
592
+ """Validate a budget *amount* value: a non-bool finite number > 0.
593
+
594
+ Single-sources the rule shared by ``budget.weekly_usd``,
595
+ ``budget.codex.amount_usd``, and each ``budget.projects`` value (code-review
596
+ #5). ``bool`` is an ``int`` subclass, so it's rejected explicitly. ``label``
597
+ is the human field name used in the raised message (e.g.
598
+ ``"budget.weekly_usd"``). Null handling stays at the call site — this helper
599
+ only validates a value the caller has already decided must be a number.
600
+ """
601
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
602
+ raise _BudgetConfigError(f"{label} must be a number")
603
+ if not math.isfinite(float(v)) or float(v) <= 0:
604
+ raise _BudgetConfigError(f"{label} must be a finite number > 0")
605
+ return float(v)
606
+
607
+
608
+ # Per-vendor budget period enums (calendar-period + Codex budgets feature).
609
+ # Claude budgets may use any of the three (default subscription-week, the
610
+ # existing reset-aware behavior); Codex budgets may NOT use subscription-week
611
+ # (it's an Anthropic-only concept), so Codex defaults to calendar-month. These
612
+ # are reused by the parser (`--period` choices) and the config layer.
613
+ BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
614
+ CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
573
615
  _BUDGET_DEFAULTS = {
574
616
  "weekly_usd": None, # None = no budget (default)
575
617
  "alerts_enabled": True, # "on when set"
576
618
  "alert_thresholds": [90, 100],
577
619
  "projected_enabled": False, # projected-pace opt-in (#121); default OFF
620
+ "period": "subscription-week", # Claude period; default = existing behavior
578
621
  "projects": {}, # per-project weekly $ budgets, keyed by git-root
579
622
  "project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
623
+ "codex": None, # None = no Codex budget (nested block when set)
580
624
  }
581
625
  _BUDGET_CONFIG_VALID_KEYS = {
582
626
  "weekly_usd",
583
627
  "alerts_enabled",
584
628
  "alert_thresholds",
585
629
  "projected_enabled",
630
+ "period",
586
631
  "projects",
587
632
  "project_alerts_enabled",
633
+ "codex",
588
634
  }
589
635
 
590
636
 
@@ -631,21 +677,18 @@ def _get_budget_config(cfg: dict) -> dict:
631
677
  out["alerts_enabled"] = v
632
678
 
633
679
  if "alert_thresholds" in block:
634
- v = block["alert_thresholds"]
635
- if not isinstance(v, list):
636
- raise _BudgetConfigError("budget.alert_thresholds must be a list of ints")
637
- cleaned = []
638
- for t in v:
639
- if isinstance(t, bool) or not isinstance(t, int):
640
- raise _BudgetConfigError(
641
- "budget.alert_thresholds entries must be integers"
642
- )
643
- if t < 1 or t > 100:
644
- raise _BudgetConfigError(
645
- "budget.alert_thresholds entries must be in [1, 100]"
646
- )
647
- cleaned.append(t)
648
- out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
680
+ out["alert_thresholds"] = _validate_budget_thresholds(
681
+ block["alert_thresholds"], "budget.alert_thresholds"
682
+ )
683
+
684
+ if "period" in block:
685
+ v = block["period"]
686
+ if not isinstance(v, str) or v not in BUDGET_PERIODS:
687
+ raise _BudgetConfigError(
688
+ "budget.period must be one of "
689
+ f"{', '.join(BUDGET_PERIODS)}, got {v!r}"
690
+ )
691
+ out["period"] = v
649
692
 
650
693
  if "projected_enabled" in block:
651
694
  v = block["projected_enabled"]
@@ -688,6 +731,107 @@ def _get_budget_config(cfg: dict) -> dict:
688
731
  )
689
732
  out["project_alerts_enabled"] = v
690
733
 
734
+ if "codex" in block:
735
+ out["codex"] = _validate_codex_budget_block(block["codex"])
736
+
737
+ return out
738
+
739
+
740
+ def _validate_budget_thresholds(v: object, label: str) -> "list[int]":
741
+ """Validate + canonicalize a budget alert-thresholds list.
742
+
743
+ Shared by the top-level ``budget.alert_thresholds`` and the nested
744
+ ``budget.codex.alert_thresholds`` leaves. Entries must be ints in [1, 100]
745
+ (bool is an int subclass and is rejected). Returns a sorted, deduped list;
746
+ an empty list is allowed (alerts silenced).
747
+ """
748
+ if not isinstance(v, list):
749
+ raise _BudgetConfigError(f"{label} must be a list of ints")
750
+ cleaned: "list[int]" = []
751
+ for t in v:
752
+ if isinstance(t, bool) or not isinstance(t, int):
753
+ raise _BudgetConfigError(f"{label} entries must be integers")
754
+ if t < 1 or t > 100:
755
+ raise _BudgetConfigError(f"{label} entries must be in [1, 100]")
756
+ cleaned.append(t)
757
+ return sorted(set(cleaned)) # empty list allowed (silenced)
758
+
759
+
760
+ def _validate_codex_budget_block(v: object) -> "dict | None":
761
+ """Validate the nested ``budget.codex`` block (Codex per-vendor budget).
762
+
763
+ ``None`` is the no-Codex-budget sentinel. When set, it's an object with a
764
+ finite ``amount_usd`` > 0, a ``period`` in CODEX_BUDGET_PERIODS (NOT
765
+ subscription-week — Anthropic-only), ``alerts_enabled`` bool (default
766
+ False — opt-in, like every alert axis), ``alert_thresholds`` validated like
767
+ the top-level budget thresholds (default [90, 100]), and
768
+ ``projected_enabled`` bool (default False). Returns a defaults-filled copy.
769
+ """
770
+ if v is None:
771
+ return None
772
+ if not isinstance(v, dict):
773
+ raise _BudgetConfigError(
774
+ f"budget.codex must be an object or null, got {type(v).__name__}"
775
+ )
776
+ # warn-and-ignore unknown sub-keys (forward compat, like the parent block)
777
+ _codex_valid = {
778
+ "amount_usd", "period", "alerts_enabled", "alert_thresholds",
779
+ "projected_enabled",
780
+ }
781
+ for k in v.keys():
782
+ if k not in _codex_valid:
783
+ print(
784
+ f"warning: ignoring unknown budget.codex config key: {k}",
785
+ file=sys.stderr,
786
+ )
787
+ out: "dict" = {
788
+ "amount_usd": None,
789
+ "period": "calendar-month", # Codex default (NO subscription-week)
790
+ "alerts_enabled": False, # opt-in, like every alert axis
791
+ "alert_thresholds": [90, 100],
792
+ "projected_enabled": False,
793
+ }
794
+ # amount_usd — required (a Codex block must define a budget) finite > 0.
795
+ # Shares the positive-amount rule with weekly_usd / projects via the helper;
796
+ # the message form ("must be a number" / "must be a finite number > 0") is
797
+ # byte-identical to the prior inline checks (code-review #5).
798
+ if "amount_usd" not in v:
799
+ raise _BudgetConfigError("budget.codex.amount_usd is required")
800
+ out["amount_usd"] = _validate_positive_budget_amount(
801
+ v["amount_usd"], "budget.codex.amount_usd"
802
+ )
803
+
804
+ if "period" in v:
805
+ p = v["period"]
806
+ if not isinstance(p, str) or p not in CODEX_BUDGET_PERIODS:
807
+ raise _BudgetConfigError(
808
+ "budget.codex.period must be one of "
809
+ f"{', '.join(CODEX_BUDGET_PERIODS)} (NOT subscription-week), "
810
+ f"got {p!r}"
811
+ )
812
+ out["period"] = p
813
+
814
+ if "alerts_enabled" in v:
815
+ ae = v["alerts_enabled"]
816
+ if not isinstance(ae, bool):
817
+ raise _BudgetConfigError(
818
+ "budget.codex.alerts_enabled must be a boolean"
819
+ )
820
+ out["alerts_enabled"] = ae
821
+
822
+ if "alert_thresholds" in v:
823
+ out["alert_thresholds"] = _validate_budget_thresholds(
824
+ v["alert_thresholds"], "budget.codex.alert_thresholds"
825
+ )
826
+
827
+ if "projected_enabled" in v:
828
+ pe = v["projected_enabled"]
829
+ if not isinstance(pe, bool):
830
+ raise _BudgetConfigError(
831
+ "budget.codex.projected_enabled must be a boolean"
832
+ )
833
+ out["projected_enabled"] = pe
834
+
691
835
  return out
692
836
 
693
837
 
@@ -1101,44 +1245,47 @@ def open_db() -> sqlite3.Connection:
1101
1245
  )
1102
1246
 
1103
1247
  # ── budget_milestones (equiv-$ budget threshold crossings — issue #19) ──
1104
- # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill the
1105
- # exact posture of `five_hour_milestones` (write-once, forward-only). A
1248
+ # Write-once, forward-only (the exact posture of `five_hour_milestones`). A
1106
1249
  # mid-week quota reset re-anchors `week_start_at` (see
1107
1250
  # `_resolve_current_budget_window`), so the new window naturally gets
1108
- # fresh rows under UNIQUE(week_start_at, threshold) — no `reset_event_id`
1109
- # segment column needed (unlike the percent/5h tables). `week_start_at`
1110
- # stores the effective/re-anchored ISO string from the resolver
1111
- # (`isoformat(timespec="seconds")`); the resolver's `parse_iso_datetime`
1112
- # returns a HOST-LOCAL tz-aware datetime, so this dedup key carries the
1113
- # host's UTC offset (e.g. `…T07:00:00-07:00`) — host-consistent, NOT
1114
- # portable across hosts, same posture as `five_hour_blocks.block_start_at`.
1115
- # Firing + reconcile + the dashboard envelope all read/write the identical
1116
- # string on a given host, so the UNIQUE dedup is exact. `alerted_at` is stamped BEFORE the
1117
- # osascript Popen (set-then-dispatch invariant); NULL = "recorded without
1118
- # dispatch" (the forward-only-from-set reconcile path) OR "not yet
1119
- # dispatched", never "delivery failed". Lives BEFORE the migration
1120
- # dispatcher: a plain CREATE on a framework-untracked table never touches
1121
- # `schema_migrations`, so the dispatcher's fresh-install snapshot is
1122
- # unaffected.
1251
+ # fresh rows under UNIQUE(week_start_at, period, threshold) — no
1252
+ # `reset_event_id` segment column needed (unlike the percent/5h tables).
1253
+ # `week_start_at` stores the effective/re-anchored ISO string from the
1254
+ # resolver (`isoformat(timespec="seconds")`); the resolver's
1255
+ # `parse_iso_datetime` returns a HOST-LOCAL tz-aware datetime, so this
1256
+ # dedup key carries the host's UTC offset (e.g. `…T07:00:00-07:00`) —
1257
+ # host-consistent, NOT portable across hosts, same posture as
1258
+ # `five_hour_blocks.block_start_at`. Firing + reconcile + the dashboard
1259
+ # envelope all read/write the identical string on a given host, so the
1260
+ # UNIQUE dedup is exact. `alerted_at` is stamped BEFORE the osascript Popen
1261
+ # (set-then-dispatch invariant); NULL = "recorded without dispatch" (the
1262
+ # forward-only-from-set reconcile path) OR "not yet dispatched", never
1263
+ # "delivery failed".
1264
+ # Schema owned by migration 011_budget_milestone_period_keys (the `period`
1265
+ # column + the period-inclusive UNIQUE; see _cctally_db.py). The live CREATE
1266
+ # below makes the new shape on fresh installs (dispatcher fast-stamps 011);
1267
+ # pre-011 DBs trip the migration's rename-recreate-copy. `period` is the
1268
+ # configured period noun at crossing ('calendar-week'|'calendar-month'|
1269
+ # 'subscription-week'); NULL = pre-011 unknown.
1123
1270
  conn.execute(
1124
1271
  """
1125
1272
  CREATE TABLE IF NOT EXISTS budget_milestones (
1126
1273
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1127
1274
  week_start_at TEXT NOT NULL,
1275
+ period TEXT, -- configured period at crossing; NULL = pre-011 unknown (migration 011)
1128
1276
  threshold INTEGER NOT NULL,
1129
1277
  budget_usd REAL NOT NULL,
1130
1278
  spent_usd REAL NOT NULL,
1131
1279
  consumption_pct REAL NOT NULL,
1132
1280
  crossed_at_utc TEXT NOT NULL,
1133
1281
  alerted_at TEXT,
1134
- UNIQUE(week_start_at, threshold)
1282
+ UNIQUE(week_start_at, period, threshold)
1135
1283
  )
1136
1284
  """
1137
1285
  )
1138
1286
 
1139
1287
  # ── projected_milestones (week-average-pace projection crossings — #121) ──
1140
- # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill same
1141
- # posture as `budget_milestones` (write-once, forward-only, no
1288
+ # Write-once, forward-only same posture as `budget_milestones` (no
1142
1289
  # `reset_event_id` segment column). Two metrics share the table, keyed by
1143
1290
  # `metric` ('weekly_pct' | 'budget_usd'); a level fires once the
1144
1291
  # WEEK-AVERAGE projection (not the displayed high-end verdict) crosses
@@ -1149,20 +1296,22 @@ def open_db() -> sqlite3.Connection:
1149
1296
  # `week_start_at` (new window → fresh rows under the UNIQUE key), the
1150
1297
  # budget-pattern reset handling — hence NO `reset_event_id` column.
1151
1298
  # `alerted_at` is stamped BEFORE the osascript Popen (set-then-dispatch).
1152
- # Lives BEFORE the migration dispatcher: a plain CREATE on a
1153
- # framework-untracked table never touches `schema_migrations`.
1299
+ # Schema owned by migration 011_budget_milestone_period_keys (the `period`
1300
+ # column + the period-inclusive UNIQUE; see _cctally_db.py). `period` is
1301
+ # NULL for pre-011 rows.
1154
1302
  conn.execute(
1155
1303
  """
1156
1304
  CREATE TABLE IF NOT EXISTS projected_milestones (
1157
1305
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1158
- week_start_at TEXT NOT NULL,
1159
- metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd'
1306
+ week_start_at TEXT NOT NULL, -- period-start instant (subscription-week OR calendar period-start; back-compat name)
1307
+ period TEXT, -- configured period at crossing; NULL = pre-011 unknown (migration 011)
1308
+ metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd' | 'codex_budget_usd'
1160
1309
  threshold INTEGER NOT NULL, -- 90 | 100
1161
1310
  projected_value REAL NOT NULL,
1162
- denominator REAL NOT NULL, -- target_usd (budget) | 100.0 (weekly)
1311
+ denominator REAL NOT NULL, -- target_usd (budget / codex_budget) | 100.0 (weekly)
1163
1312
  crossed_at_utc TEXT NOT NULL,
1164
1313
  alerted_at TEXT,
1165
- UNIQUE(week_start_at, metric, threshold)
1314
+ UNIQUE(week_start_at, period, metric, threshold)
1166
1315
  )
1167
1316
  """
1168
1317
  )
@@ -1203,6 +1352,46 @@ def open_db() -> sqlite3.Connection:
1203
1352
  """
1204
1353
  )
1205
1354
 
1355
+ # ── codex_budget_milestones (per-vendor Codex budget crossings) ──────────
1356
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
1357
+ # same posture as `budget_milestones` / `projected_milestones` /
1358
+ # `project_budget_milestones` (write-once, forward-only, framework-untracked;
1359
+ # calendar-period-codex-budgets feature, spec §6). The dedup key is keyed on
1360
+ # `period_start_at` — the resolved period-window START instant stored as the
1361
+ # `isoformat(timespec="seconds")` `+00:00` offset form (NOT a `Z` suffix),
1362
+ # e.g. calendar-month June → `2026-06-01T00:00:00+00:00` — NOT a subscription
1363
+ # week:
1364
+ # Codex has no Anthropic week, so the budget runs over a calendar period
1365
+ # (calendar-week / calendar-month). Rolling to the next period yields a fresh
1366
+ # `period_start_at` → fresh crossings under UNIQUE(period_start_at, period,
1367
+ # threshold) (the budget-pattern reset handling — hence NO `reset_event_id`
1368
+ # segment column). `budget_usd` snapshots the Codex target AT crossing so the
1369
+ # dashboard renders "$210 of $200" from the ROW, not from live config that
1370
+ # may have changed since (the Codex P0-4 lesson, baked into the sibling
1371
+ # tables). `alerted_at` is stamped BEFORE the osascript Popen (set-then-
1372
+ # dispatch invariant); NULL = "recorded without dispatch" (the forward-only-
1373
+ # from-set reconcile path) OR "not yet dispatched", never "delivery failed".
1374
+ # Schema owned by migration 011_budget_milestone_period_keys (the `period`
1375
+ # column + the period-inclusive UNIQUE; see _cctally_db.py). `period` is the
1376
+ # configured Codex period noun at crossing ('calendar-week'|'calendar-
1377
+ # month'); NULL = pre-011 unknown.
1378
+ conn.execute(
1379
+ """
1380
+ CREATE TABLE IF NOT EXISTS codex_budget_milestones (
1381
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1382
+ period_start_at TEXT NOT NULL, -- resolved period-window start instant (+00:00 offset form, NOT Z)
1383
+ period TEXT, -- configured period at crossing; NULL = pre-011 unknown (migration 011)
1384
+ threshold INTEGER NOT NULL,
1385
+ budget_usd REAL NOT NULL, -- Codex target snapshotted AT crossing
1386
+ spent_usd REAL NOT NULL,
1387
+ consumption_pct REAL NOT NULL,
1388
+ crossed_at_utc TEXT NOT NULL,
1389
+ alerted_at TEXT,
1390
+ UNIQUE(period_start_at, period, threshold)
1391
+ )
1392
+ """
1393
+ )
1394
+
1206
1395
  # Migration framework dispatcher. Replaces the prior inline gate stack
1207
1396
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1208
1397
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers