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.
@@ -36,9 +36,9 @@ import sys
36
36
  from dataclasses import dataclass, replace
37
37
 
38
38
  from _cctally_core import (
39
- _command_as_of, _normalize_week_boundary_dt, compute_week_bounds,
40
- eprint, get_week_start_name, make_week_ref, now_utc_iso,
41
- open_db, parse_iso_datetime,
39
+ WEEKDAY_MAP, _command_as_of, _normalize_week_boundary_dt,
40
+ compute_week_bounds, eprint, get_week_start_name, make_week_ref,
41
+ now_utc_iso, open_db, parse_iso_datetime,
42
42
  )
43
43
  # Non-kernel _cctally_core re-exports used by the moved code. Verified NOT
44
44
  # ns-patched (§5.A gate); honest-import permitted (the §8.1b gate allows any
@@ -46,7 +46,10 @@ from _cctally_core import (
46
46
  # kernel-invariant tests are unaffected). _BudgetConfigError is an exception
47
47
  # class — honest-import avoids the except-over-accessor foot-gun
48
48
  # (gotcha_except_over_callable_shim_typeerrors).
49
- from _cctally_core import _BudgetConfigError, _get_budget_config
49
+ from _cctally_core import (
50
+ _BudgetConfigError, _get_budget_config,
51
+ BUDGET_PERIODS as _CCTALLY_BUDGET_PERIODS,
52
+ )
50
53
 
51
54
 
52
55
  def _cctally():
@@ -1559,6 +1562,232 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1559
1562
 
1560
1563
  _BUDGET_JSON_SCHEMA_VERSION = 1
1561
1564
 
1565
+ # Calendar-period + Codex budgets (spec §2/§3): single-source the short→canonical
1566
+ # `--period` spellings so the parser choices and the handler normalizer never
1567
+ # drift. The SHORT aliases live here, keyed canonical → its short spelling;
1568
+ # `_BUDGET_PERIOD_ALIASES` (the normalizer's lookup) and `_BUDGET_PERIOD_CHOICES`
1569
+ # (the parser's `choices=`) are BOTH derived from this map + the canonical
1570
+ # `BUDGET_PERIODS` tuple in _cctally_core, so adding a period (or renaming a
1571
+ # spelling) touches exactly one place (code-review #5).
1572
+ _BUDGET_PERIOD_SHORT = {
1573
+ "subscription-week": "sub-week",
1574
+ "calendar-week": "week",
1575
+ "calendar-month": "month",
1576
+ }
1577
+
1578
+ # short → canonical (the handler normalizer's lookup), plus identity entries so
1579
+ # canonical spellings pass through unchanged.
1580
+ _BUDGET_PERIOD_ALIASES = {
1581
+ **{short: canonical for canonical, short in _BUDGET_PERIOD_SHORT.items()},
1582
+ **{canonical: canonical for canonical in _CCTALLY_BUDGET_PERIODS},
1583
+ }
1584
+
1585
+ # The parser's `choices=` — canonical-first within each period group, matching
1586
+ # the prior hardcoded order so `--help` stays byte-stable.
1587
+ _BUDGET_PERIOD_CHOICES = [
1588
+ spelling
1589
+ for canonical in _CCTALLY_BUDGET_PERIODS
1590
+ for spelling in (canonical, _BUDGET_PERIOD_SHORT[canonical])
1591
+ ]
1592
+
1593
+
1594
+ def _normalize_budget_period(raw):
1595
+ """Map a `--period` flag value (short or canonical) to the canonical enum.
1596
+
1597
+ ``None`` (flag omitted) passes through so the set/unset semantics can
1598
+ distinguish "preserve stored / per-vendor default" from an explicit choice.
1599
+ An unknown value passes through unchanged so the per-vendor config validator
1600
+ raises the canonical _BudgetConfigError (single error surface).
1601
+ """
1602
+ if raw is None:
1603
+ return None
1604
+ return _BUDGET_PERIOD_ALIASES.get(raw, raw)
1605
+
1606
+
1607
+ def _resolve_local_calendar_window(period, now_utc, week_start_idx=None):
1608
+ """DST-correct ``(start_utc, end_utc)`` for the ``display.tz=local`` case
1609
+ (issue #136).
1610
+
1611
+ The explicit-tz path feeds a real DST-aware ``ZoneInfo`` to the pure
1612
+ kernels, which is correct. But ``display.tz=local`` has no clean stdlib
1613
+ IANA-name handle — ``datetime.now().astimezone().tzinfo`` is only a
1614
+ *fixed-offset* ``datetime.timezone`` snapped at one instant. Feeding that to
1615
+ the kernel converts the period-start local midnight to UTC at the wrong
1616
+ offset whenever the period straddles a DST transition, so the same civil
1617
+ period resolves to two different ``period_start_at`` instants before vs
1618
+ after the boundary — shifting the ``[start, now]`` spend window AND drifting
1619
+ the ``UNIQUE(period_start_at, threshold)`` milestone key into a re-fire.
1620
+
1621
+ Instead, mirror ``_period_label_local``: build the NAIVE local civil
1622
+ boundaries, then convert each via a bare ``astimezone()`` so each boundary
1623
+ picks up the offset in effect at ITS OWN wall-clock instant — stable across
1624
+ an in-period transition and with NO dependency on the real wall clock.
1625
+ Period boundaries sit at 00:00 local, which is never inside a US/EU
1626
+ spring-forward gap (those land at 01:00–03:00), so the naive→aware
1627
+ conversion is unambiguous. Impure (it reads the process zone, like
1628
+ ``_period_label_local``); kept in the forecast layer so ``_lib_budget``
1629
+ stays a pure, dependency-injected kernel.
1630
+ """
1631
+ # internal fallback: host-local intentional (per-instant DST-correct)
1632
+ now_local = now_utc.astimezone()
1633
+ if period == "calendar-month":
1634
+ start_naive = now_local.replace(
1635
+ day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None
1636
+ )
1637
+ if start_naive.month == 12:
1638
+ end_naive = start_naive.replace(year=start_naive.year + 1, month=1)
1639
+ else:
1640
+ end_naive = start_naive.replace(month=start_naive.month + 1)
1641
+ else: # calendar-week
1642
+ midnight_naive = now_local.replace(
1643
+ hour=0, minute=0, second=0, microsecond=0, tzinfo=None
1644
+ )
1645
+ diff = (midnight_naive.weekday() - week_start_idx) % 7
1646
+ start_naive = midnight_naive - dt.timedelta(days=diff)
1647
+ end_naive = start_naive + dt.timedelta(days=7)
1648
+ return (
1649
+ start_naive.astimezone(dt.timezone.utc),
1650
+ end_naive.astimezone(dt.timezone.utc),
1651
+ )
1652
+
1653
+
1654
+ def _resolve_calendar_window(period, now_utc, config, tz):
1655
+ """Resolve a calendar period's ``(start_utc, end_utc)`` (spec §3). ``period``
1656
+ is canonical (calendar-week / calendar-month). Reuses the existing
1657
+ ``collector.week_start`` config for the week-start index — no new config key.
1658
+
1659
+ Two paths (issue #136). An explicit ``display.tz`` (``utc`` / IANA) resolves
1660
+ to a real DST-aware ``ZoneInfo``, so it goes straight to the pure kernels —
1661
+ already DST-correct (proven by ``test_budget_periods.py``). ``display.tz=
1662
+ local`` (``tz is None``) has no DST-aware stdlib handle, so it takes
1663
+ ``_resolve_local_calendar_window``'s per-instant path instead of collapsing
1664
+ the zone to a single fixed offset."""
1665
+ c = _cctally()
1666
+ if period == "calendar-month":
1667
+ if tz is None:
1668
+ return _resolve_local_calendar_window("calendar-month", now_utc)
1669
+ return c.calendar_month_window(now_utc, tz)
1670
+ # calendar-week
1671
+ week_start_idx = WEEKDAY_MAP[get_week_start_name(config)]
1672
+ if tz is None:
1673
+ return _resolve_local_calendar_window(
1674
+ "calendar-week", now_utc, week_start_idx
1675
+ )
1676
+ return c.calendar_week_window(now_utc, tz, week_start_idx)
1677
+
1678
+
1679
+ def _period_label_local(instant, tz):
1680
+ """Render ``instant`` (a UTC-aware datetime) into the DISPLAY-TZ civil
1681
+ wall-clock, PER INSTANT (code-review #4).
1682
+
1683
+ For an explicit ``tz`` (utc / IANA) the offset is uniform, so this is just
1684
+ ``instant.astimezone(tz)``. For the ``display.tz=local`` case (``tz is
1685
+ None``) it does a BARE ``instant.astimezone()`` so EACH instant picks up its
1686
+ OWN host-local offset — matching the prior ``format_display_dt(..., tz=None)``
1687
+ behavior. Window RESOLUTION (``_resolve_local_calendar_window``) applies the
1688
+ same per-instant conversion for ``display.tz=local`` (issue #136); a single
1689
+ fixed offset captured at ``now()`` would shift a boundary that straddles a
1690
+ DST transition by an hour (and so a day, at midnight)."""
1691
+ if tz is not None:
1692
+ return instant.astimezone(tz)
1693
+ # internal fallback: host-local intentional
1694
+ return instant.astimezone()
1695
+
1696
+
1697
+ def _civil_period_label(period, start_utc, end_utc, tz):
1698
+ """Build the terminal header period label from the DISPLAY-TZ civil
1699
+ boundary (NOT the UTC instant — spec §3). Returns e.g.
1700
+ ``subscription week 2026-05-26 → 2026-06-02`` /
1701
+ ``calendar month 2026-06`` / ``calendar week 2026-06-01 → 06-08``.
1702
+
1703
+ The start/end are converted PER INSTANT (code-review #4) so a window
1704
+ straddling a DST transition renders each boundary at its own local offset."""
1705
+ s_local = _period_label_local(start_utc, tz)
1706
+ e_local = _period_label_local(end_utc, tz)
1707
+ if period == "calendar-month":
1708
+ return f"calendar month {s_local.strftime('%Y-%m')}"
1709
+ if period == "calendar-week":
1710
+ return (
1711
+ f"calendar week {s_local.strftime('%Y-%m-%d')} → "
1712
+ f"{e_local.strftime('%m-%d')}"
1713
+ )
1714
+ # subscription-week
1715
+ return (
1716
+ f"subscription week {s_local.strftime('%Y-%m-%d')} → "
1717
+ f"{e_local.strftime('%Y-%m-%d')}"
1718
+ )
1719
+
1720
+
1721
+ def _build_vendor_budget_inputs(
1722
+ *, vendor, period, target_usd, alert_thresholds, now_utc, config, tz,
1723
+ skip_sync=False,
1724
+ ):
1725
+ """Resolve the window + live spend for one (vendor, period) budget and
1726
+ return a :class:`BudgetInputs` (or ``None`` only for the Claude /
1727
+ subscription-week case where no usage snapshot has landed yet).
1728
+
1729
+ Decoupled from ``weekly_usage_snapshots`` for every calendar period (spec
1730
+ §4 review #5): the calendar/Codex path resolves the window from the pure
1731
+ ``calendar_*_window`` functions and renders ``$0`` / ``0%`` when entries are
1732
+ empty — it NEVER short-circuits to the no-data note. Only the legacy
1733
+ Claude + subscription-week path can return ``None`` (no resolvable week
1734
+ window yet), preserving the existing byte-identical no-data behavior.
1735
+
1736
+ The stats DB is opened LAZILY and ONLY on the Claude + subscription-week
1737
+ branch (the sole reader of ``weekly_usage_snapshots`` via
1738
+ ``_resolve_current_budget_window``). Codex spend and Claude calendar-period
1739
+ spend come from the cache DB / pure window functions, so a Codex-only or
1740
+ calendar-period budget never opens a stats connection (code-review #2).
1741
+
1742
+ Spend: Claude → ``_sum_cost_for_range``; Codex → ``_sum_codex_cost_for_range``
1743
+ (cache DB; spec §4). ``recent_24h_usd`` is the same helper over the CLAMPED
1744
+ trailing-24h window ``[max(start, now-24h), now]`` so a heavy spend just
1745
+ before the window start can't leak into a fresh window's verdict. The
1746
+ returned dataclass keeps the ``week_*`` field names (a deliberate
1747
+ back-compat misnomer — they back documented ``--json`` fields, spec §9).
1748
+ """
1749
+ c = _cctally()
1750
+ if vendor == "claude" and period == "subscription-week":
1751
+ conn = open_db()
1752
+ try:
1753
+ window = _resolve_current_budget_window(conn, now_utc)
1754
+ finally:
1755
+ conn.close()
1756
+ if window is None:
1757
+ return None
1758
+ start_at, end_at = window
1759
+ else:
1760
+ start_at, end_at = _resolve_calendar_window(period, now_utc, config, tz)
1761
+
1762
+ recent_start = max(start_at, now_utc - dt.timedelta(hours=24))
1763
+ # The full-window sum (first call) honors the caller's ``skip_sync`` (render
1764
+ # callers default False; the Claude record/projected leg passes True because
1765
+ # other record-usage work already warmed the cache; the Codex projected leg
1766
+ # passes False — R5 — since Codex has no other record-path warmer). Either
1767
+ # way the recent-24h window is a SUBSET of the full window already fetched,
1768
+ # so ``skip_sync=True`` on the second call avoids a redundant JSONL walk.
1769
+ if vendor == "codex":
1770
+ spent = c._sum_codex_cost_for_range(start_at, now_utc, skip_sync=skip_sync)
1771
+ recent_24h = c._sum_codex_cost_for_range(
1772
+ recent_start, now_utc, skip_sync=True
1773
+ )
1774
+ else:
1775
+ spent = c._sum_cost_for_range(
1776
+ start_at, now_utc, mode="auto", skip_sync=skip_sync
1777
+ )
1778
+ recent_24h = c._sum_cost_for_range(
1779
+ recent_start, now_utc, mode="auto", skip_sync=True
1780
+ )
1781
+ return c.BudgetInputs(
1782
+ target_usd=float(target_usd),
1783
+ spent_usd=float(spent),
1784
+ recent_24h_usd=float(recent_24h),
1785
+ week_start_at=start_at,
1786
+ week_end_at=end_at,
1787
+ now=now_utc,
1788
+ alert_thresholds=tuple(alert_thresholds),
1789
+ )
1790
+
1562
1791
 
1563
1792
  def cmd_budget(args: argparse.Namespace) -> int:
1564
1793
  """Dispatch `cctally budget [set AMOUNT | unset]`. See docs/commands/budget.md."""
@@ -1577,13 +1806,40 @@ def cmd_budget(args: argparse.Namespace) -> int:
1577
1806
  eprint("cctally budget: --format is not valid with set/unset")
1578
1807
  return 2
1579
1808
 
1809
+ # Per-vendor calendar-period budgets (spec §2): `--vendor`/`--period`
1810
+ # normalize + validate in the handler so the error is a clean exit 2 with a
1811
+ # message (not an argparse usage error). `--project` is Claude/subscription-
1812
+ # week-only (spec Q5), so reject combining it with --vendor codex / --period.
1813
+ vendor = getattr(args, "vendor", "claude") or "claude"
1814
+ raw_period = getattr(args, "period", None)
1815
+ period = _normalize_budget_period(raw_period)
1816
+ if action in {"set", "unset"}:
1817
+ if getattr(args, "project", None) is not None and (
1818
+ vendor != "claude" or period is not None
1819
+ ):
1820
+ eprint(
1821
+ "cctally budget: --project budgets are Claude / subscription-week "
1822
+ "only; drop --vendor/--period"
1823
+ )
1824
+ return 2
1825
+ if vendor == "codex" and period in {"subscription-week"}:
1826
+ eprint(
1827
+ "cctally budget: Codex has no subscription week; use "
1828
+ "--period calendar-week or --period calendar-month"
1829
+ )
1830
+ return 2
1831
+
1580
1832
  if action == "set":
1581
1833
  if getattr(args, "project", None) is not None:
1582
1834
  return _cmd_budget_set_project(args)
1583
- return _cmd_budget_set(args)
1835
+ if vendor == "codex":
1836
+ return _cmd_budget_set_codex(args, period)
1837
+ return _cmd_budget_set(args, period)
1584
1838
  if action == "unset":
1585
1839
  if getattr(args, "project", None) is not None:
1586
1840
  return _cmd_budget_unset_project(args)
1841
+ if vendor == "codex":
1842
+ return _cmd_budget_unset_codex(args)
1587
1843
  return _cmd_budget_unset(args)
1588
1844
 
1589
1845
  # ── bare status ──
@@ -1592,6 +1848,7 @@ def cmd_budget(args: argparse.Namespace) -> int:
1592
1848
  c._share_validate_args(args)
1593
1849
  config = c._load_claude_config_for_args(args) # honors --config read-only
1594
1850
  args._resolved_tz = c.resolve_display_tz(args, config)
1851
+ tz = args._resolved_tz
1595
1852
  try:
1596
1853
  budget_cfg = _get_budget_config(config)
1597
1854
  except _BudgetConfigError as exc:
@@ -1602,10 +1859,20 @@ def cmd_budget(args: argparse.Namespace) -> int:
1602
1859
  # no-data, full status) — gated on budget.projects being non-empty. When
1603
1860
  # empty, NOTHING is appended → the existing global render paths stay
1604
1861
  # byte-identical (spec §7.3a). It needs the budget window, so the work
1605
- # happens after we have a conn / now_utc below.
1862
+ # happens after we have now_utc below (project rows open their own conn).
1606
1863
  has_projects = bool(budget_cfg["projects"])
1607
1864
 
1608
1865
  target = budget_cfg["weekly_usd"]
1866
+ claude_period = budget_cfg["period"]
1867
+ codex_cfg = budget_cfg["codex"]
1868
+ has_codex = codex_cfg is not None
1869
+ # Vendor labels / equivalent-$ vs actual-$ cues appear in the TERMINAL block
1870
+ # ONLY once a Codex budget exists OR the Claude period is non-default — so a
1871
+ # legacy Claude/subscription-week + no-Codex render stays byte-identical
1872
+ # (spec §5/§10.1). `coexists` gates the terminal header relabel; the share
1873
+ # artifact keys its period label off `claude_period` directly (code-review
1874
+ # #3), so it doesn't need `coexists`.
1875
+ coexists = has_codex or claude_period != "subscription-week"
1609
1876
 
1610
1877
  # Resolve the window-dependent per-project rows once (only when configured).
1611
1878
  # `project_rows` is None when no window resolves (no snapshot yet) — the
@@ -1623,28 +1890,82 @@ def cmd_budget(args: argparse.Namespace) -> int:
1623
1890
  pj_conn.close()
1624
1891
  project_window_resolved = project_rows is not None
1625
1892
 
1893
+ # Build the Codex sibling inputs/status once (when configured) so every
1894
+ # render path (terminal / --json / share) can reuse them. Decoupled from
1895
+ # weekly snapshots — always resolves a calendar window (spec §4 review #5).
1896
+ codex_inputs = None
1897
+ codex_status = None
1898
+ if has_codex:
1899
+ # Codex spend reads the cache DB — no stats connection needed here;
1900
+ # _build_vendor_budget_inputs opens one lazily only for the
1901
+ # Claude+subscription-week branch (code-review #2).
1902
+ codex_inputs = _build_vendor_budget_inputs(
1903
+ vendor="codex", period=codex_cfg["period"],
1904
+ target_usd=codex_cfg["amount_usd"],
1905
+ alert_thresholds=codex_cfg["alert_thresholds"],
1906
+ now_utc=now_utc, config=config, tz=tz,
1907
+ )
1908
+ codex_status = c.compute_budget_status(codex_inputs)
1909
+ # Opportunistic Codex-budget alert firing (spec §6 trigger 2) — the
1910
+ # INTERACTIVE backstop for spend that crosses a threshold BETWEEN
1911
+ # record-usage ticks. Gated to the plain terminal status render:
1912
+ # * never under `--config PATH` (documented read-only — firing would
1913
+ # write milestones into the DEFAULT stats.db + dispatch off an
1914
+ # alternate config), and
1915
+ # * never under the machine-readable `--json` / artifact `--format`
1916
+ # surfaces (a scripted/automated read must not pop a desktop
1917
+ # notification).
1918
+ # Routes through the SAME record-path helper as the automated trigger so
1919
+ # the dedup key is resolved in CONFIG tz (like record-usage), NOT the
1920
+ # display `--tz`: a `cctally budget --tz X` near a period boundary must
1921
+ # not fork `period_start_at` and double-fire. The helper pre-probes,
1922
+ # forward-only/fire-once via UNIQUE(period_start_at, threshold), and is
1923
+ # best-effort (it never raises into the status render).
1924
+ interactive_status = not (
1925
+ getattr(args, "config", None)
1926
+ or getattr(args, "json", False)
1927
+ or getattr(args, "format", None)
1928
+ )
1929
+ if interactive_status and codex_cfg.get("alerts_enabled"):
1930
+ c.maybe_record_codex_budget_milestone({})
1931
+ # #135: the opportunistic Codex PROJECTED-pace backstop, scoped to
1932
+ # the codex_budget_usd metric so it never pops a weekly_pct / Claude
1933
+ # budget_usd notification from a bare `cctally budget`. The projected
1934
+ # leg self-syncs (skip_sync=False); since codex_inputs was just
1935
+ # built above, that delta-sync is a no-op here — correct and cheap.
1936
+ if codex_cfg.get("projected_enabled"):
1937
+ c.maybe_record_projected_alert(
1938
+ {}, only_metrics={"codex_budget_usd"}
1939
+ )
1940
+
1626
1941
  if target is None:
1627
- # Global budget unset → friendly message, then (if configured) the
1628
- # per-project section. --json carries status:"unset" + projects[].
1942
+ # Global Claude budget unset → friendly message, then (if configured)
1943
+ # the per-project section + the Codex sibling. --json carries
1944
+ # status:"unset" + projects[] + the gated codex block.
1629
1945
  return _budget_render_unset(
1630
1946
  args,
1947
+ claude_period=claude_period,
1631
1948
  project_rows=project_rows,
1632
1949
  has_projects=has_projects,
1633
1950
  project_window_resolved=project_window_resolved,
1951
+ codex_cfg=codex_cfg, codex_inputs=codex_inputs,
1952
+ codex_status=codex_status, tz=tz,
1634
1953
  )
1635
1954
 
1636
- conn = open_db()
1637
- inputs = _build_budget_status_inputs(
1638
- conn,
1639
- target_usd=target,
1640
- now_utc=now_utc,
1641
- alert_thresholds=budget_cfg["alert_thresholds"],
1955
+ inputs = _build_vendor_budget_inputs(
1956
+ vendor="claude", period=claude_period,
1957
+ target_usd=target, alert_thresholds=budget_cfg["alert_thresholds"],
1958
+ now_utc=now_utc, config=config, tz=tz,
1642
1959
  )
1643
1960
  if inputs is None:
1644
- # No usage snapshot yet → no resolvable week window (spec §6 worst case).
1961
+ # Claude/subscription-week with no usage snapshot yet → no resolvable
1962
+ # week window (spec §6 worst case). Calendar periods never reach here.
1645
1963
  if getattr(args, "format", None):
1646
1964
  snap = _build_budget_no_data_snapshot(args, budget_cfg, now_utc)
1647
1965
  snap = _append_project_share_rows(snap, project_rows, has_projects)
1966
+ snap = _append_codex_share_rows(
1967
+ snap, codex_cfg, codex_inputs, codex_status, tz
1968
+ )
1648
1969
  c._share_render_and_emit(snap, args)
1649
1970
  return 0
1650
1971
  if getattr(args, "json", False):
@@ -1652,12 +1973,16 @@ def cmd_budget(args: argparse.Namespace) -> int:
1652
1973
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1653
1974
  "status": "no_data",
1654
1975
  "weekly_usd": target,
1976
+ "period": claude_period,
1655
1977
  }
1978
+ if has_codex:
1979
+ _append_codex_json(payload, codex_cfg, codex_inputs, codex_status)
1656
1980
  if has_projects:
1657
1981
  _append_project_json(payload, project_rows)
1658
1982
  print(json.dumps(payload))
1659
1983
  return 0
1660
1984
  print(f"Weekly budget: ${target:,.2f} — no usage data yet this week.")
1985
+ _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args)
1661
1986
  _print_project_section_or_note(
1662
1987
  project_rows, has_projects, project_window_resolved, args
1663
1988
  )
@@ -1665,16 +1990,29 @@ def cmd_budget(args: argparse.Namespace) -> int:
1665
1990
 
1666
1991
  status = c.compute_budget_status(inputs)
1667
1992
  if getattr(args, "format", None):
1668
- snap = _build_budget_snapshot(args, budget_cfg, inputs, status)
1993
+ snap = _build_budget_snapshot(
1994
+ args, budget_cfg, inputs, status,
1995
+ period=claude_period, tz=tz,
1996
+ )
1669
1997
  snap = _append_project_share_rows(snap, project_rows, has_projects)
1998
+ snap = _append_codex_share_rows(
1999
+ snap, codex_cfg, codex_inputs, codex_status, tz
2000
+ )
1670
2001
  c._share_render_and_emit(snap, args)
1671
2002
  return 0
1672
2003
  if getattr(args, "json", False):
1673
2004
  return _budget_emit_json(
1674
2005
  budget_cfg, inputs, status,
1675
2006
  project_rows=project_rows, has_projects=has_projects,
2007
+ period=claude_period,
2008
+ codex_cfg=codex_cfg, codex_inputs=codex_inputs,
2009
+ codex_status=codex_status,
1676
2010
  )
1677
- rc = _budget_render_terminal(args, budget_cfg, inputs, status)
2011
+ rc = _budget_render_terminal(
2012
+ args, budget_cfg, inputs, status,
2013
+ period=claude_period, coexists=coexists, tz=tz,
2014
+ )
2015
+ _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args)
1678
2016
  _print_project_section_or_note(
1679
2017
  project_rows, has_projects, project_window_resolved, args
1680
2018
  )
@@ -1878,10 +2216,15 @@ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
1878
2216
  return 0
1879
2217
 
1880
2218
 
1881
- def _cmd_budget_set(args: argparse.Namespace) -> int:
1882
- """`cctally budget set AMOUNT` — write `budget.weekly_usd`, preserving the
1883
- other budget keys. Writes the DEFAULT config (F4). Task 3 appends the
1884
- forward-only milestone reconcile here."""
2219
+ def _cmd_budget_set(args: argparse.Namespace, period=None) -> int:
2220
+ """`cctally budget set AMOUNT [--period P]` — write `budget.weekly_usd`,
2221
+ preserving the other budget keys. Writes the DEFAULT config (F4). Task 3
2222
+ appends the forward-only milestone reconcile here.
2223
+
2224
+ ``period`` is the canonical-normalized ``--period`` (or ``None`` = omitted).
2225
+ When omitted, the stored period is preserved (a pre-existing budget keeps
2226
+ its chosen period; first-create defaults to ``subscription-week`` via the
2227
+ validator). When supplied, it's written as ``budget.period`` (spec §2)."""
1885
2228
  c = _cctally()
1886
2229
  raw = getattr(args, "amount", None)
1887
2230
  if raw is None:
@@ -1907,6 +2250,8 @@ def _cmd_budget_set(args: argparse.Namespace) -> int:
1907
2250
  return 2
1908
2251
  block = dict(existing or {})
1909
2252
  block["weekly_usd"] = amount
2253
+ if period is not None:
2254
+ block["period"] = period
1910
2255
  config["budget"] = block
1911
2256
  try:
1912
2257
  validated = _get_budget_config(config)
@@ -1914,12 +2259,14 @@ def _cmd_budget_set(args: argparse.Namespace) -> int:
1914
2259
  eprint(f"cctally budget: {exc}")
1915
2260
  return 2
1916
2261
  block["weekly_usd"] = validated["weekly_usd"]
2262
+ block["period"] = validated["period"]
1917
2263
  config["budget"] = block
1918
2264
  c.save_config(config)
1919
2265
 
1920
2266
  weekly_usd = validated["weekly_usd"]
1921
2267
  alerts_enabled = validated["alerts_enabled"]
1922
2268
  thresholds = validated["alert_thresholds"]
2269
+ stored_period = validated["period"]
1923
2270
 
1924
2271
  # Forward-only-from-set reconcile (Task 3, spec §5): record thresholds
1925
2272
  # ALREADY crossed with alerted_at set but WITHOUT dispatch, so setting a
@@ -1934,6 +2281,7 @@ def _cmd_budget_set(args: argparse.Namespace) -> int:
1934
2281
  print(json.dumps({
1935
2282
  "status": "set",
1936
2283
  "weekly_usd": weekly_usd,
2284
+ "period": stored_period,
1937
2285
  "alerts_enabled": alerts_enabled,
1938
2286
  "alert_thresholds": list(thresholds),
1939
2287
  }))
@@ -1943,13 +2291,19 @@ def _cmd_budget_set(args: argparse.Namespace) -> int:
1943
2291
  thr_part = " · thresholds " + " ".join(f"{t}%" for t in thresholds)
1944
2292
  else:
1945
2293
  thr_part = " · no thresholds"
1946
- print(f"Weekly budget set to ${weekly_usd:,.2f} · {alerts_part}{thr_part}")
2294
+ # Back-compat: the subscription-week confirmation stays byte-identical; a
2295
+ # non-default period appends a ` · <period>` segment (spec §5).
2296
+ period_part = "" if stored_period == "subscription-week" else f" · {stored_period}"
2297
+ print(
2298
+ f"Weekly budget set to ${weekly_usd:,.2f}{period_part} · "
2299
+ f"{alerts_part}{thr_part}"
2300
+ )
1947
2301
  return 0
1948
2302
 
1949
2303
 
1950
2304
  def _cmd_budget_unset(args: argparse.Namespace) -> int:
1951
2305
  """`cctally budget unset` — clear `budget.weekly_usd` (preserve
1952
- alerts_enabled / alert_thresholds). Idempotent."""
2306
+ alerts_enabled / alert_thresholds / period). Idempotent."""
1953
2307
  c = _cctally()
1954
2308
  with c.config_writer_lock():
1955
2309
  config = c._load_config_unlocked()
@@ -1969,25 +2323,151 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
1969
2323
  return 0
1970
2324
 
1971
2325
 
2326
+ def _cmd_budget_set_codex(args: argparse.Namespace, period=None) -> int:
2327
+ """`cctally budget set AMOUNT --vendor codex [--period P]` — write the
2328
+ nested ``budget.codex`` block (spec §2). ``period`` is the canonical-
2329
+ normalized ``--period`` (or ``None`` = omitted → preserve the stored period
2330
+ on an existing Codex budget, else the per-vendor default calendar-month)."""
2331
+ c = _cctally()
2332
+ raw = getattr(args, "amount", None)
2333
+ if raw is None:
2334
+ eprint(
2335
+ "cctally budget: `set --vendor codex` requires an amount, e.g. "
2336
+ "cctally budget set 200 --vendor codex --period month"
2337
+ )
2338
+ return 2
2339
+ try:
2340
+ amount = float(raw)
2341
+ except (TypeError, ValueError):
2342
+ eprint(f"cctally budget: amount must be a positive number, got {raw!r}")
2343
+ return 2
2344
+ if not math.isfinite(amount) or amount <= 0:
2345
+ eprint(f"cctally budget: amount must be a positive finite number, got {raw!r}")
2346
+ return 2
2347
+
2348
+ with c.config_writer_lock():
2349
+ config = c._load_config_unlocked()
2350
+ existing = config.get("budget")
2351
+ if existing is not None and not isinstance(existing, dict):
2352
+ eprint("cctally budget: budget config must be an object")
2353
+ return 2
2354
+ block = dict(existing or {})
2355
+ existing_codex = block.get("codex")
2356
+ if existing_codex is not None and not isinstance(existing_codex, dict):
2357
+ eprint("cctally budget: budget.codex must be an object")
2358
+ return 2
2359
+ codex_block = dict(existing_codex or {})
2360
+ codex_block["amount_usd"] = amount
2361
+ if period is not None:
2362
+ codex_block["period"] = period
2363
+ # First create with no period → the validator fills calendar-month.
2364
+ block["codex"] = codex_block
2365
+ config["budget"] = block
2366
+ try:
2367
+ validated = _get_budget_config(config)
2368
+ except _BudgetConfigError as exc:
2369
+ eprint(f"cctally budget: {exc}")
2370
+ return 2
2371
+ block["codex"] = dict(validated["codex"])
2372
+ config["budget"] = block
2373
+ c.save_config(config)
2374
+
2375
+ # Forward-only-from-set reconcile (spec §6): record Codex thresholds ALREADY
2376
+ # crossed this period with alerted_at set but WITHOUT dispatch, so setting a
2377
+ # Codex budget mid-period doesn't instant-popup; only LATER crossings fire.
2378
+ # Runs OUTSIDE the config_writer_lock (open_db has its own locking). Gated on
2379
+ # codex alerts_enabled + thresholds — a Codex budget with alerts off records
2380
+ # nothing.
2381
+ c._reconcile_codex_budget_on_config_write(validated)
2382
+
2383
+ codex = validated["codex"]
2384
+ amount_usd = codex["amount_usd"]
2385
+ stored_period = codex["period"]
2386
+ alerts_enabled = codex["alerts_enabled"]
2387
+ thresholds = codex["alert_thresholds"]
2388
+ if getattr(args, "json", False):
2389
+ print(json.dumps({
2390
+ "status": "set",
2391
+ "vendor": "codex",
2392
+ "amount_usd": amount_usd,
2393
+ "period": stored_period,
2394
+ "alerts_enabled": alerts_enabled,
2395
+ "alert_thresholds": list(thresholds),
2396
+ }))
2397
+ return 0
2398
+ alerts_part = "alerts on" if alerts_enabled else "alerts off"
2399
+ if thresholds:
2400
+ thr_part = " · thresholds " + " ".join(f"{t}%" for t in thresholds)
2401
+ else:
2402
+ thr_part = " · no thresholds"
2403
+ print(
2404
+ f"Codex budget set to ${amount_usd:,.2f} · {stored_period} · "
2405
+ f"{alerts_part}{thr_part}"
2406
+ )
2407
+ return 0
2408
+
2409
+
2410
+ def _cmd_budget_unset_codex(args: argparse.Namespace) -> int:
2411
+ """`cctally budget unset --vendor codex` — remove the ``budget.codex``
2412
+ block entirely (spec §2). Idempotent."""
2413
+ c = _cctally()
2414
+ removed = False
2415
+ with c.config_writer_lock():
2416
+ config = c._load_config_unlocked()
2417
+ existing = config.get("budget")
2418
+ if existing is not None and not isinstance(existing, dict):
2419
+ eprint("cctally budget: budget config must be an object")
2420
+ return 2
2421
+ block = dict(existing or {})
2422
+ if block.get("codex") is not None:
2423
+ removed = True
2424
+ block["codex"] = None
2425
+ config["budget"] = block
2426
+ c.save_config(config)
2427
+
2428
+ if getattr(args, "json", False):
2429
+ print(json.dumps({
2430
+ "status": "unset" if removed else "noop", "vendor": "codex",
2431
+ }))
2432
+ return 0
2433
+ if removed:
2434
+ print("Codex budget cleared")
2435
+ else:
2436
+ print("No Codex budget set")
2437
+ return 0
2438
+
2439
+
1972
2440
  def _budget_render_unset(
1973
2441
  args: argparse.Namespace,
1974
2442
  *,
2443
+ claude_period: str = "subscription-week",
1975
2444
  project_rows=None,
1976
2445
  has_projects: bool = False,
1977
2446
  project_window_resolved: bool = False,
2447
+ codex_cfg=None,
2448
+ codex_inputs=None,
2449
+ codex_status=None,
2450
+ tz=None,
1978
2451
  ) -> int:
1979
- """No GLOBAL budget set → friendly stdout message, exit 0 (NOT an error).
2452
+ """No GLOBAL Claude budget set → friendly stdout message, exit 0 (NOT an
2453
+ error).
1980
2454
 
1981
2455
  When per-project budgets ARE configured (``has_projects``), the
1982
2456
  per-project section is STILL rendered below the unset message (spec
1983
- §7.3a) — a project-only configuration is fully supported. When
1984
- ``has_projects`` is False, every output mode is byte-identical to the
1985
- pre-feature behavior (no ``projects`` key in --json, no extra lines).
2457
+ §7.3a) — a project-only configuration is fully supported. A configured
2458
+ Codex budget likewise renders as a sibling section (a Codex-only
2459
+ configuration is supported). When neither is configured, every output mode
2460
+ is byte-identical to the pre-feature behavior (no ``projects``/``codex`` key
2461
+ in --json, no extra lines).
1986
2462
  """
1987
2463
  c = _cctally()
2464
+ has_codex = codex_cfg is not None
1988
2465
  if getattr(args, "format", None):
1989
2466
  snap = _build_budget_no_budget_snapshot(args)
1990
2467
  snap = _append_project_share_rows(snap, project_rows, has_projects)
2468
+ snap = _append_codex_share_rows(
2469
+ snap, codex_cfg, codex_inputs, codex_status, tz
2470
+ )
1991
2471
  c._share_render_and_emit(snap, args)
1992
2472
  return 0
1993
2473
  if getattr(args, "json", False):
@@ -1995,12 +2475,19 @@ def _budget_render_unset(
1995
2475
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1996
2476
  "status": "unset",
1997
2477
  "weekly_usd": None,
2478
+ # `period` is ALWAYS present (spec §5/§10.8) — even with no global
2479
+ # Claude budget, the configured/default Claude period rides along so
2480
+ # consumers never have to special-case an absent key.
2481
+ "period": claude_period,
1998
2482
  }
2483
+ if has_codex:
2484
+ _append_codex_json(payload, codex_cfg, codex_inputs, codex_status)
1999
2485
  if has_projects:
2000
2486
  _append_project_json(payload, project_rows)
2001
2487
  print(json.dumps(payload))
2002
2488
  return 0
2003
2489
  print("No weekly budget set. Set one with: cctally budget set <amount>.")
2490
+ _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args)
2004
2491
  _print_project_section_or_note(
2005
2492
  project_rows, has_projects, project_window_resolved, args
2006
2493
  )
@@ -2012,28 +2499,21 @@ def _budget_verdict_ansi_code(verdict: str) -> str:
2012
2499
  return {"ok": "32", "warn": "33", "over": "31"}.get(verdict, "32")
2013
2500
 
2014
2501
 
2015
- def _budget_render_terminal(args, budget_cfg, inputs, status) -> int:
2016
- """Render the §4 status block to stdout. Datetimes via format_display_dt
2017
- (honors display.tz). Verdict color ok→green / warn→amber / over→red."""
2502
+ def _budget_block_lines(
2503
+ inputs, status, *, header_label, alerts_line, color
2504
+ ) -> list:
2505
+ """Render one budget block (header + spent/remaining/pace/projected + the
2506
+ alerts footer) as a list of lines. Shared by the Claude top block and the
2507
+ Codex sibling so their layout is identical (spec §5). ``header_label`` is
2508
+ the fully-formed first line (already carries the period/equivalent-$ cue);
2509
+ ``alerts_line`` is the pre-rendered footer."""
2018
2510
  c = _cctally()
2019
- color = c._supports_color_stdout()
2020
- tz_render = getattr(args, "_resolved_tz", None)
2021
-
2022
2511
  total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
2023
2512
  elapsed_days = status.elapsed_fraction * total_seconds / 86400.0
2024
2513
  remaining_days = max(
2025
2514
  0.0, total_seconds * (1.0 - status.elapsed_fraction) / 86400.0
2026
2515
  )
2027
-
2028
- ws = c.format_display_dt(inputs.week_start_at, tz_render, fmt="%Y-%m-%d", suffix=False)
2029
- we = c.format_display_dt(inputs.week_end_at, tz_render, fmt="%Y-%m-%d", suffix=False)
2030
-
2031
- lines = []
2032
- lines.append(
2033
- f"Weekly budget: ${inputs.target_usd:,.2f} "
2034
- f"(subscription week {ws} → {we})"
2035
- )
2036
- lines.append("")
2516
+ lines = [header_label, ""]
2037
2517
  lines.append(
2038
2518
  f" Spent so far ${status.spent_usd:,.2f} "
2039
2519
  f"{status.consumption_pct:.1f}% of budget"
@@ -2064,12 +2544,80 @@ def _budget_render_terminal(args, budget_cfg, inputs, status) -> int:
2064
2544
  proj_line += " (LOW CONF — early in week)"
2065
2545
  lines.append(proj_line)
2066
2546
  lines.append("")
2067
- lines.append(_budget_alerts_line(budget_cfg, status))
2547
+ lines.append(alerts_line)
2548
+ return lines
2549
+
2550
+
2551
+ def _claude_budget_header(inputs, period, coexists, tz):
2552
+ """The Claude block's header line. Byte-identical to the legacy
2553
+ ``Weekly budget: $X (subscription week WS → WE)`` for the
2554
+ subscription-week + no-Codex case; switches to the civil-period label (and
2555
+ an `equivalent-$` cue) once a Codex budget coexists or the period is
2556
+ non-default (spec §5)."""
2557
+ period_label = _civil_period_label(
2558
+ period, inputs.week_start_at, inputs.week_end_at, tz
2559
+ )
2560
+ if not coexists:
2561
+ # Legacy byte-identical path (subscription-week, no Codex).
2562
+ return f"Weekly budget: ${inputs.target_usd:,.2f} ({period_label})"
2563
+ return (
2564
+ f"Claude budget: ${inputs.target_usd:,.2f} ({period_label})"
2565
+ f" — equivalent-$"
2566
+ )
2567
+
2068
2568
 
2569
+ def _budget_render_terminal(
2570
+ args, budget_cfg, inputs, status, *,
2571
+ period="subscription-week", coexists=False, tz=None,
2572
+ ) -> int:
2573
+ """Render the §4 Claude status block to stdout. The period header is derived
2574
+ from the DISPLAY-TZ civil boundary (spec §3); ``coexists`` switches in the
2575
+ vendor label + equivalent-$ cue only when a Codex budget exists or the
2576
+ period is non-default (byte-identical legacy render otherwise)."""
2577
+ color = _cctally()._supports_color_stdout()
2578
+ header = _claude_budget_header(inputs, period, coexists, tz)
2579
+ lines = _budget_block_lines(
2580
+ inputs, status,
2581
+ header_label=header,
2582
+ alerts_line=_budget_alerts_line(budget_cfg, status),
2583
+ color=color,
2584
+ )
2069
2585
  print("\n".join(lines))
2070
2586
  return 0
2071
2587
 
2072
2588
 
2589
+ def _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args) -> None:
2590
+ """Print the Codex budget sibling section below the Claude block (spec §5).
2591
+ No-op when no Codex budget is configured so the existing terminal output
2592
+ stays byte-identical. Layout mirrors the Claude block; the header carries an
2593
+ `actual API $` cue (vs Claude's equivalent-$)."""
2594
+ if codex_cfg is None or codex_inputs is None:
2595
+ return
2596
+ c = _cctally()
2597
+ color = c._supports_color_stdout()
2598
+ period_label = _civil_period_label(
2599
+ codex_cfg["period"], codex_inputs.week_start_at,
2600
+ codex_inputs.week_end_at, tz,
2601
+ )
2602
+ header = (
2603
+ f"Codex budget: ${codex_inputs.target_usd:,.2f} ({period_label})"
2604
+ f" — actual API $"
2605
+ )
2606
+ # The Codex alerts footer reads the codex block's own enabled/thresholds.
2607
+ alerts_line = _budget_alerts_line(
2608
+ {
2609
+ "alerts_enabled": codex_cfg["alerts_enabled"],
2610
+ "alert_thresholds": codex_cfg["alert_thresholds"],
2611
+ },
2612
+ codex_status,
2613
+ )
2614
+ lines = _budget_block_lines(
2615
+ codex_inputs, codex_status,
2616
+ header_label=header, alerts_line=alerts_line, color=color,
2617
+ )
2618
+ print("\n" + "\n".join(lines))
2619
+
2620
+
2073
2621
  def _budget_alerts_line(budget_cfg, status) -> str:
2074
2622
  """Render the "Alerts: ..." footer line: on/off + thresholds + crossed."""
2075
2623
  enabled = budget_cfg["alerts_enabled"]
@@ -2089,19 +2637,24 @@ def _budget_alerts_line(budget_cfg, status) -> str:
2089
2637
 
2090
2638
 
2091
2639
  def _budget_emit_json(
2092
- budget_cfg, inputs, status, *, project_rows=None, has_projects=False
2640
+ budget_cfg, inputs, status, *, project_rows=None, has_projects=False,
2641
+ period="subscription-week", codex_cfg=None, codex_inputs=None,
2642
+ codex_status=None,
2093
2643
  ) -> int:
2094
2644
  """Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
2095
2645
  Window timestamps are `…Z`, ignoring display.tz (every --json is UTC).
2096
2646
 
2097
- When per-project budgets are configured (``has_projects``), an additive
2098
- ``projects: [...]`` array is appended ADDITIVE only, NO schemaVersion
2099
- bump (spec §7.4). Absent when projects are empty so the global --json stays
2100
- byte-identical."""
2647
+ Additive (no schemaVersion bump spec §5/§10.8): a ``period`` string
2648
+ (ALWAYS present) + a gated ``codex`` sibling object (only when a Codex
2649
+ budget is configured, like ``projects``). When per-project budgets are
2650
+ configured (``has_projects``), an additive ``projects: [...]`` array is
2651
+ appended. The terminal output stays byte-identical for the legacy case;
2652
+ the --json golden is regenerated to carry ``period``."""
2101
2653
  payload = {
2102
2654
  "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2103
2655
  "status": "ok",
2104
2656
  "weekly_usd": inputs.target_usd,
2657
+ "period": period,
2105
2658
  "alerts_enabled": budget_cfg["alerts_enabled"],
2106
2659
  "alert_thresholds": list(budget_cfg["alert_thresholds"]),
2107
2660
  "week_start_at": _iso_z(inputs.week_start_at),
@@ -2120,12 +2673,43 @@ def _budget_emit_json(
2120
2673
  "low_confidence": status.low_confidence,
2121
2674
  "crossed_thresholds": list(status.crossed_thresholds),
2122
2675
  }
2676
+ if codex_cfg is not None:
2677
+ _append_codex_json(payload, codex_cfg, codex_inputs, codex_status)
2123
2678
  if has_projects:
2124
2679
  _append_project_json(payload, project_rows)
2125
2680
  print(json.dumps(payload))
2126
2681
  return 0
2127
2682
 
2128
2683
 
2684
+ def _append_codex_json(payload, codex_cfg, codex_inputs, codex_status) -> None:
2685
+ """Attach the additive, gated ``codex`` sibling object to a budget --json
2686
+ payload (spec §5). Gated exactly like ``projects`` — only emitted when a
2687
+ Codex budget is configured, so unconfigured users keep the smaller payload.
2688
+ The amount key is ``amount_usd`` (NOT ``weekly_usd`` — a misnomer inside a
2689
+ monthly Codex block); the status fields mirror the Claude top level."""
2690
+ payload["codex"] = {
2691
+ "amount_usd": codex_inputs.target_usd,
2692
+ "period": codex_cfg["period"],
2693
+ "alerts_enabled": codex_cfg["alerts_enabled"],
2694
+ "alert_thresholds": list(codex_cfg["alert_thresholds"]),
2695
+ "period_start_at": _iso_z(codex_inputs.week_start_at),
2696
+ "period_end_at": _iso_z(codex_inputs.week_end_at),
2697
+ "as_of": _iso_z(codex_inputs.now),
2698
+ "spent_usd": codex_status.spent_usd,
2699
+ "remaining_usd": codex_status.remaining_usd,
2700
+ "consumption_pct": codex_status.consumption_pct,
2701
+ "elapsed_fraction": codex_status.elapsed_fraction,
2702
+ "projected_eow_low_usd": codex_status.projected_eow_low_usd,
2703
+ "projected_eow_high_usd": codex_status.projected_eow_high_usd,
2704
+ "week_avg_projection_usd": codex_status.week_avg_projection_usd,
2705
+ "daily_pace_usd": codex_status.daily_pace_usd,
2706
+ "daily_budget_remaining_usd": codex_status.daily_budget_remaining_usd,
2707
+ "verdict": codex_status.verdict,
2708
+ "low_confidence": codex_status.low_confidence,
2709
+ "crossed_thresholds": list(codex_status.crossed_thresholds),
2710
+ }
2711
+
2712
+
2129
2713
  def _build_project_budget_rows(conn, budget_cfg, now_utc):
2130
2714
  """Build per-project budget status dicts for the configured projects.
2131
2715
 
@@ -2318,21 +2902,80 @@ def _project_rows_json(rows) -> list:
2318
2902
  ]
2319
2903
 
2320
2904
 
2321
- def _build_budget_snapshot(args, budget_cfg, inputs, status):
2905
+ def _append_codex_share_rows(snap, codex_cfg, codex_inputs, codex_status, tz):
2906
+ """Append the Codex budget section to a budget ShareSnapshot (spec §5). The
2907
+ Codex figures are vendor-level (no project names) → nothing new to
2908
+ anonymize. No-op when no Codex budget is configured so existing share
2909
+ goldens stay byte-identical."""
2910
+ if codex_cfg is None or codex_inputs is None:
2911
+ return snap
2912
+ c = _cctally()
2913
+ _lib_share = c._share_load_lib()
2914
+ extra_rows = [
2915
+ _lib_share.Row(cells={
2916
+ "metric": _lib_share.TextCell("— Codex budget (actual API $) —"),
2917
+ "value": _lib_share.TextCell(""),
2918
+ }),
2919
+ _lib_share.Row(cells={
2920
+ "metric": _lib_share.TextCell("Codex budget"),
2921
+ "value": _lib_share.MoneyCell(codex_inputs.target_usd),
2922
+ }),
2923
+ _lib_share.Row(cells={
2924
+ "metric": _lib_share.TextCell("Codex spent so far"),
2925
+ "value": _lib_share.MoneyCell(codex_status.spent_usd),
2926
+ }),
2927
+ _lib_share.Row(cells={
2928
+ "metric": _lib_share.TextCell("Codex consumption"),
2929
+ "value": _lib_share.PercentCell(codex_status.consumption_pct),
2930
+ }),
2931
+ _lib_share.Row(cells={
2932
+ "metric": _lib_share.TextCell("Codex remaining"),
2933
+ "value": _lib_share.MoneyCell(codex_status.remaining_usd),
2934
+ }),
2935
+ _lib_share.Row(cells={
2936
+ "metric": _lib_share.TextCell("Codex verdict"),
2937
+ "value": _lib_share.TextCell(codex_status.verdict.upper()),
2938
+ }),
2939
+ ]
2940
+ return _replace_snapshot_rows(snap, tuple(snap.rows) + tuple(extra_rows))
2941
+
2942
+
2943
+ def _build_budget_snapshot(
2944
+ args, budget_cfg, inputs, status, *,
2945
+ period="subscription-week", tz=None,
2946
+ ):
2322
2947
  """Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
2323
2948
 
2324
2949
  This builds the GLOBAL budget rows only; when per-project budgets are
2325
2950
  configured, `_append_project_share_rows` appends ProjectCell rows so
2326
2951
  `--reveal-projects` reveals (or `_scrub` anonymizes) the per-project
2327
2952
  basenames via the share chokepoint. No parallel renderer; the gate calls
2328
- `_share_render_and_emit(snap, args)`."""
2953
+ `_share_render_and_emit(snap, args)`.
2954
+
2955
+ ``period`` selects the artifact's period label + title (code-review #3):
2956
+ a Claude *calendar* period (calendar-week / calendar-month) renders the
2957
+ DISPLAY-TZ civil label (e.g. ``calendar month 2026-06``) — matching the
2958
+ terminal header — instead of the UTC-instant ``week of <month-1st>``
2959
+ date-range. The legacy subscription-week artifact stays byte-identical."""
2329
2960
  c = _cctally()
2330
2961
  _lib_share = c._share_load_lib()
2331
2962
  tz_label = c._share_display_tz_label(getattr(args, "_resolved_tz", None))
2332
- period_label = c._share_period_label(
2333
- inputs.week_start_at, inputs.week_end_at, tz_label
2334
- )
2335
- period = _lib_share.PeriodSpec(
2963
+ if period in {"calendar-week", "calendar-month"}:
2964
+ # Civil period label off the display-tz boundary (NOT the UTC instant),
2965
+ # so a month/week artifact reads "calendar month 2026-06" / "calendar
2966
+ # week 2026-06-08 → 06-15" — the same label the terminal header uses.
2967
+ period_label = _civil_period_label(
2968
+ period, inputs.week_start_at, inputs.week_end_at, tz
2969
+ )
2970
+ title = f"Budget — {period_label}"
2971
+ else:
2972
+ # subscription-week: legacy date-range label + "week of …" title
2973
+ # (byte-identical to the pre-feature artifact).
2974
+ period_label = c._share_period_label(
2975
+ inputs.week_start_at, inputs.week_end_at, tz_label
2976
+ )
2977
+ title = f"Budget — week of {inputs.week_start_at.strftime('%b %d')}"
2978
+ period_spec = _lib_share.PeriodSpec(
2336
2979
  start=inputs.week_start_at, end=inputs.week_end_at,
2337
2980
  display_tz=tz_label, label=period_label,
2338
2981
  )
@@ -2363,9 +3006,9 @@ def _build_budget_snapshot(args, budget_cfg, inputs, status):
2363
3006
  ])
2364
3007
  return _lib_share.ShareSnapshot(
2365
3008
  cmd="budget",
2366
- title=f"Budget — week of {inputs.week_start_at.strftime('%b %d')}",
3009
+ title=title,
2367
3010
  subtitle=subtitle,
2368
- period=period,
3011
+ period=period_spec,
2369
3012
  columns=columns,
2370
3013
  rows=rows,
2371
3014
  chart=None,