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.
- package/CHANGELOG.md +27 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +278 -6
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +230 -41
- package/bin/_cctally_dashboard.py +399 -37
- package/bin/_cctally_db.py +594 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +273 -28
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +328 -50
- package/bin/_cctally_setup.py +7 -3
- package/bin/_cctally_statusline.py +8 -0
- package/bin/_cctally_update.py +3 -3
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +8 -1
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +162 -0
- package/bin/_lib_conversation_query.py +524 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +40 -1
- package/dashboard/static/assets/{index-D34qf0LE.css → index-Bj5ckRUE.css} +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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,
|
|
40
|
-
eprint, get_week_start_name, make_week_ref,
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
1628
|
-
# per-project section. --json carries
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
#
|
|
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(
|
|
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(
|
|
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`,
|
|
1883
|
-
other budget keys. Writes the DEFAULT config (F4). Task 3
|
|
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
|
-
|
|
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
|
|
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.
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|
|
2016
|
-
|
|
2017
|
-
|
|
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(
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
|
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
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
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=
|
|
3009
|
+
title=title,
|
|
2367
3010
|
subtitle=subtitle,
|
|
2368
|
-
period=
|
|
3011
|
+
period=period_spec,
|
|
2369
3012
|
columns=columns,
|
|
2370
3013
|
rows=rows,
|
|
2371
3014
|
chart=None,
|