cctally 1.100.0 → 1.101.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 +44 -0
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +713 -166
- package/bin/_cctally_dashboard_envelope.py +28 -38
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +304 -96
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_forecast.py +12 -4
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +10 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
- package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
package/bin/_cctally_diff.py
CHANGED
|
@@ -21,6 +21,8 @@ import sqlite3
|
|
|
21
21
|
import sys
|
|
22
22
|
|
|
23
23
|
import _lib_diff_kernel as dk
|
|
24
|
+
# #620 S1 D11: the one affordance shape every warning state renders.
|
|
25
|
+
import _lib_alert_scope
|
|
24
26
|
|
|
25
27
|
from _cctally_core import _command_as_of, eprint
|
|
26
28
|
|
|
@@ -279,4 +281,22 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
279
281
|
result, color=color, width=width, raw_aggregates=result.raw_totals,
|
|
280
282
|
tz=tz_obj, compact=args.compact,
|
|
281
283
|
))
|
|
284
|
+
# #620 S1 D11: when the two windows are of different lengths the absolute
|
|
285
|
+
# dollars on screen are per-day normalizations, so route to the command
|
|
286
|
+
# that reports window A's real, un-normalized total.
|
|
287
|
+
if result.mismatched_length and not result.auto_normalized:
|
|
288
|
+
source = getattr(args, "source", None) or "claude"
|
|
289
|
+
a = result.window_a
|
|
290
|
+
# Full UTC instants, not bare dates: `range-cost -s/-e` are parsed by
|
|
291
|
+
# `parse_iso_datetime`, which reads a naive value as host-local and
|
|
292
|
+
# never extends a date to end-of-day, so a date-only selector would
|
|
293
|
+
# drop every hour after midnight from the window the line states.
|
|
294
|
+
print(_lib_alert_scope.next_step_line(
|
|
295
|
+
f"cctally range-cost -s {a.start_utc:%Y-%m-%dT%H:%M:%SZ} "
|
|
296
|
+
f"-e {a.end_utc:%Y-%m-%dT%H:%M:%SZ} -b --source {source}",
|
|
297
|
+
provider=source,
|
|
298
|
+
window_start=a.start_utc,
|
|
299
|
+
window_end=a.end_utc,
|
|
300
|
+
tz=tz_obj,
|
|
301
|
+
))
|
|
282
302
|
return 0
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -91,6 +91,10 @@ from _lib_forecast import ForecastInputs, BudgetRow, ForecastOutput, _compute_fo
|
|
|
91
91
|
_ensure_sibling_loaded("_lib_json_envelope")
|
|
92
92
|
from _lib_json_envelope import _iso_z
|
|
93
93
|
|
|
94
|
+
# #620 S1 D11: the one affordance shape every warning state renders.
|
|
95
|
+
_ensure_sibling_loaded("_lib_alert_scope")
|
|
96
|
+
import _lib_alert_scope
|
|
97
|
+
|
|
94
98
|
|
|
95
99
|
def _cctally():
|
|
96
100
|
"""Resolve the current `cctally` module at call-time (§2)."""
|
|
@@ -368,9 +372,16 @@ def _select_dollars_per_percent(
|
|
|
368
372
|
skip_sync: bool = False,
|
|
369
373
|
use_weekref_cost_cache: bool = False,
|
|
370
374
|
account_key: "str | None" = None,
|
|
371
|
-
) -> tuple[float, str]:
|
|
375
|
+
) -> "tuple[float | None, str]":
|
|
372
376
|
"""Return (dollars_per_percent, source_label). See spec §1 selection rule.
|
|
373
377
|
|
|
378
|
+
#620 S1 D5: the rate is ``None`` when no usage has been observed. It used
|
|
379
|
+
to be ``0.0`` paired with the ``this_week_sparse`` label, which published
|
|
380
|
+
a rate of exactly $0.00 per percent and blamed a sparse week — a
|
|
381
|
+
fabricated answer with a false cause, on a week that may carry real
|
|
382
|
+
spend. Absence is now typed, and the cause travels on the existing
|
|
383
|
+
companion source field rather than a new key.
|
|
384
|
+
|
|
374
385
|
Eligible prior week: week_end_at < now_utc AND final_weekly_percent >= 1.
|
|
375
386
|
Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
|
|
376
387
|
via `get_entries`); `conn` is only used for snapshot queries.
|
|
@@ -466,8 +477,11 @@ def _select_dollars_per_percent(
|
|
|
466
477
|
# Path 3: fall back to current week even if sparse.
|
|
467
478
|
if p_now > 0:
|
|
468
479
|
return spent_usd / p_now, "this_week_sparse"
|
|
469
|
-
# p_now == 0: no signal
|
|
470
|
-
|
|
480
|
+
# p_now == 0: there is no signal to divide by. Withhold the rate and say
|
|
481
|
+
# why (#620 S1 D5). Every dollar figure derived from it becomes
|
|
482
|
+
# unavailable; percent projections are unaffected because they never
|
|
483
|
+
# depended on it.
|
|
484
|
+
return None, "no_usage_observed"
|
|
471
485
|
|
|
472
486
|
|
|
473
487
|
def _assess_forecast_confidence(
|
|
@@ -643,7 +657,10 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
643
657
|
"week_average_pct_per_hour": round(out.r_avg, 6),
|
|
644
658
|
"recent_24h_pct_per_hour": (None if out.r_recent is None
|
|
645
659
|
else round(out.r_recent, 6)),
|
|
646
|
-
"dollars_per_percent":
|
|
660
|
+
"dollars_per_percent": (
|
|
661
|
+
None if i.dollars_per_percent is None
|
|
662
|
+
else round(i.dollars_per_percent, 6)
|
|
663
|
+
),
|
|
647
664
|
"dollars_per_percent_source": i.dollars_per_percent_source,
|
|
648
665
|
},
|
|
649
666
|
"forecast": {
|
|
@@ -814,6 +831,62 @@ def _render_forecast_progress_bar(
|
|
|
814
831
|
return [label_str, axis_str, bar_line, cap_str]
|
|
815
832
|
|
|
816
833
|
|
|
834
|
+
# #620 S1 D10: the four reason codes `_assess_forecast_confidence` and
|
|
835
|
+
# `_load_forecast_inputs` emit are a wire contract that `--json` consumers
|
|
836
|
+
# read, so they are unchanged. What changed is that the terminal used to
|
|
837
|
+
# print them verbatim into a line a person reads. The mapping is a render
|
|
838
|
+
# step, and it stays in this I/O layer; moving cause classification beside
|
|
839
|
+
# the predicate in `_lib_forecast.py` buys nothing without a cause enum,
|
|
840
|
+
# which is S2's.
|
|
841
|
+
_FORECAST_CONFIDENCE_WORDING = {
|
|
842
|
+
"elapsed_hours<24": "less than 24 hours into the week",
|
|
843
|
+
"percent<2": "under 2% of quota used so far",
|
|
844
|
+
"snapshots<3": "fewer than 3 usage snapshots",
|
|
845
|
+
"no_sample_ge_24h": "no snapshot at least 24 hours old",
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _forecast_confidence_wording(reasons) -> str:
|
|
850
|
+
"""Render low-confidence reason codes as human wording.
|
|
851
|
+
|
|
852
|
+
An unrecognised code falls back to the code itself rather than being
|
|
853
|
+
dropped. Dropping it would render an empty parenthetical that states no
|
|
854
|
+
cause at all, which is worse than an unfamiliar token: a code nobody
|
|
855
|
+
recognises is still a fact about why the forecast is thin.
|
|
856
|
+
"""
|
|
857
|
+
return ", ".join(
|
|
858
|
+
_FORECAST_CONFIDENCE_WORDING.get(code, code) for code in reasons
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _wrap_panel_text(text: str, width: int, indent: str = " ") -> list[str]:
|
|
863
|
+
"""Break one statement into panel rows of at most ``width`` characters.
|
|
864
|
+
|
|
865
|
+
``_render_forecast_terminal``'s ``_row`` pads to a fixed width and never
|
|
866
|
+
truncates, so a statement longer than the row runs past the frame's right
|
|
867
|
+
border. Human confidence wording is longer than the machine codes it
|
|
868
|
+
replaced and up to four reasons can fire at once, so the statement is laid
|
|
869
|
+
across as many rows as it needs. Nothing is dropped and nothing is
|
|
870
|
+
truncated: a single word longer than the row still gets its own row and
|
|
871
|
+
overruns, which is the honest outcome for an unrecognised reason code.
|
|
872
|
+
"""
|
|
873
|
+
words = text.split(" ")
|
|
874
|
+
rows: list[str] = []
|
|
875
|
+
current = ""
|
|
876
|
+
prefix = ""
|
|
877
|
+
for word in words:
|
|
878
|
+
candidate = f"{current} {word}" if current else f"{prefix}{word}"
|
|
879
|
+
if current and len(candidate) > width:
|
|
880
|
+
rows.append(current)
|
|
881
|
+
prefix = indent
|
|
882
|
+
current = f"{prefix}{word}"
|
|
883
|
+
else:
|
|
884
|
+
current = candidate
|
|
885
|
+
if current:
|
|
886
|
+
rows.append(current)
|
|
887
|
+
return rows or [text]
|
|
888
|
+
|
|
889
|
+
|
|
817
890
|
def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
818
891
|
"""Full box-frame terminal render (spec §4)."""
|
|
819
892
|
c = _cctally()
|
|
@@ -865,14 +938,24 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
865
938
|
|
|
866
939
|
# ── Panel 2: used / forecast / bar
|
|
867
940
|
used_line = f"Used {i.p_now:.1f}% ${i.spent_usd:.2f}"
|
|
941
|
+
# Only the low-confidence branch can need continuation rows.
|
|
942
|
+
forecast_extra_lines: list[str] = []
|
|
868
943
|
if out.already_capped:
|
|
869
944
|
forecast_line = c._style_ansi(
|
|
870
945
|
f"\u26a0 CAPPED at {i.p_now:.1f}% \u2014 reset {fmt_dt(i.week_end_at)} "
|
|
871
946
|
f"({i.remaining_days:.1f}d)", "31", color)
|
|
872
947
|
elif i.confidence == "low":
|
|
873
|
-
reasons =
|
|
874
|
-
|
|
875
|
-
|
|
948
|
+
reasons = _forecast_confidence_wording(i.low_confidence_reasons)
|
|
949
|
+
# `_row` fits `inner_w - 1` characters; one short of that keeps the
|
|
950
|
+
# single trailing space every other row in the panel has.
|
|
951
|
+
conf_rows = _wrap_panel_text(
|
|
952
|
+
f"\u26a0 LOW CONF \u2014 insufficient data ({reasons})",
|
|
953
|
+
inner_w - 2,
|
|
954
|
+
)
|
|
955
|
+
forecast_line = c._style_ansi(conf_rows[0], "33", color)
|
|
956
|
+
forecast_extra_lines = [
|
|
957
|
+
c._style_ansi(row, "33", color) for row in conf_rows[1:]
|
|
958
|
+
]
|
|
876
959
|
else:
|
|
877
960
|
low, high = out.final_percent_low, out.final_percent_high
|
|
878
961
|
low_rnd = round(low)
|
|
@@ -901,6 +984,10 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
901
984
|
budget_rows = []
|
|
902
985
|
for b in out.budgets:
|
|
903
986
|
if b.dollars_per_day is None:
|
|
987
|
+
# `past target` is the only cause reachable here. The other cause
|
|
988
|
+
# of a `None` budget — no rate observed — cannot arrive: this
|
|
989
|
+
# panel renders only when `confidence != "low"`, and `p_now == 0`
|
|
990
|
+
# always sets `percent<2`, which is a low-confidence input.
|
|
904
991
|
budget_rows.append(f" to {b.target_percent:>3}% \u2014 past target")
|
|
905
992
|
else:
|
|
906
993
|
budget_rows.append(
|
|
@@ -927,6 +1014,8 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
927
1014
|
lines.append(_box_mid())
|
|
928
1015
|
lines.append(_row(used_line))
|
|
929
1016
|
lines.append(_row(forecast_line))
|
|
1017
|
+
for extra_line in forecast_extra_lines:
|
|
1018
|
+
lines.append(_row(extra_line))
|
|
930
1019
|
lines.append(_row(""))
|
|
931
1020
|
for bl in bar_lines:
|
|
932
1021
|
lines.append(_row(bl))
|
|
@@ -950,6 +1039,153 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
950
1039
|
return "\n".join(lines)
|
|
951
1040
|
|
|
952
1041
|
|
|
1042
|
+
# A trend row is marked partial when its span falls short of the nominal
|
|
1043
|
+
# subscription week by more than this. The tolerance absorbs the
|
|
1044
|
+
# hour-boundary normalisation applied to Anthropic's reset jitter, so an
|
|
1045
|
+
# ordinary week normalised by up to an hour is never marked (#620 S1 D9).
|
|
1046
|
+
_REPORT_PARTIAL_WEEK_TOLERANCE = dt.timedelta(hours=1)
|
|
1047
|
+
_REPORT_NOMINAL_WEEK = dt.timedelta(days=7)
|
|
1048
|
+
_REPORT_PARTIAL_MARK = "~"
|
|
1049
|
+
_REPORT_PARTIAL_LEGEND = (
|
|
1050
|
+
"~ marks a week shorter than the nominal 7 days, so its $ / 1% is not "
|
|
1051
|
+
"directly comparable with a full week's."
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _report_row_is_partial_week(row: "dict") -> bool:
|
|
1056
|
+
"""True when this trend row's `[start, end)` span is short of a full
|
|
1057
|
+
subscription week by more than the jitter tolerance (#620 S1 D9).
|
|
1058
|
+
|
|
1059
|
+
A row that does not carry both exact bounds is NOT marked: the span is
|
|
1060
|
+
unknown, and marking on a guess would assert something the data does not
|
|
1061
|
+
support. Absent weeks stay omitted, exactly as before.
|
|
1062
|
+
"""
|
|
1063
|
+
start_iso = row.get("weekStartAt")
|
|
1064
|
+
end_iso = row.get("weekEndAt")
|
|
1065
|
+
if not start_iso or not end_iso:
|
|
1066
|
+
return False
|
|
1067
|
+
try:
|
|
1068
|
+
start = parse_iso_datetime(start_iso, "report row weekStartAt")
|
|
1069
|
+
end = parse_iso_datetime(end_iso, "report row weekEndAt")
|
|
1070
|
+
except (TypeError, ValueError):
|
|
1071
|
+
return False
|
|
1072
|
+
return (end - start) < (_REPORT_NOMINAL_WEEK - _REPORT_PARTIAL_WEEK_TOLERANCE)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def render_report_terminal(payload: "dict", *, tz) -> str:
|
|
1076
|
+
"""Render the report's current-week and trend tables (#620 S1 D7).
|
|
1077
|
+
|
|
1078
|
+
A pure function over the report's own JSON payload — the exact object
|
|
1079
|
+
`cmd_report` emits under `--json`. It was inline in `cmd_report`, which
|
|
1080
|
+
meant the all-source path had no way to reach it: `_render_claude_terminal`
|
|
1081
|
+
fell back to `_legacy_claude_totals`, which reads a `totals` object this
|
|
1082
|
+
payload does not have, so `report --source all` printed two lines and the
|
|
1083
|
+
literal `Data available.` while `report` alone printed the whole report.
|
|
1084
|
+
|
|
1085
|
+
Teaching `_legacy_claude_totals` this payload's shape was rejected: it
|
|
1086
|
+
would reduce a rich two-table report to a misleading one-line total.
|
|
1087
|
+
"""
|
|
1088
|
+
c = _cctally()
|
|
1089
|
+
current_row = payload.get("current")
|
|
1090
|
+
trend = payload.get("trend") or []
|
|
1091
|
+
blocks: "list[str]" = []
|
|
1092
|
+
|
|
1093
|
+
if current_row is not None:
|
|
1094
|
+
week_window = c._format_week_window(
|
|
1095
|
+
current_row.get("weekStartDate"),
|
|
1096
|
+
current_row.get("weekEndDate"),
|
|
1097
|
+
current_row.get("weekStartAt"),
|
|
1098
|
+
current_row.get("weekEndAt"),
|
|
1099
|
+
tz=tz,
|
|
1100
|
+
)
|
|
1101
|
+
wp = current_row["weeklyPercent"]
|
|
1102
|
+
wc = current_row["weeklyCostUSD"]
|
|
1103
|
+
dpp = current_row["dollarsPerPercent"]
|
|
1104
|
+
blocks.append(
|
|
1105
|
+
c._boxed_table(
|
|
1106
|
+
["Week Window", "Usage %", "Cost USD", "$ / 1%"],
|
|
1107
|
+
[[
|
|
1108
|
+
week_window,
|
|
1109
|
+
f"{wp:.2f}%" if wp is not None else "n/a",
|
|
1110
|
+
f"${wc:.6f}" if wc is not None else "n/a",
|
|
1111
|
+
f"${dpp:.6f}" if dpp is not None else "n/a",
|
|
1112
|
+
]],
|
|
1113
|
+
["left", "right", "right", "right"],
|
|
1114
|
+
)
|
|
1115
|
+
)
|
|
1116
|
+
blocks.append("")
|
|
1117
|
+
|
|
1118
|
+
blocks.append("Trend:")
|
|
1119
|
+
headers = [
|
|
1120
|
+
"#",
|
|
1121
|
+
"Week Window",
|
|
1122
|
+
"Usage %",
|
|
1123
|
+
"Cost USD",
|
|
1124
|
+
"$ / 1%",
|
|
1125
|
+
"As Of",
|
|
1126
|
+
"Usage Captured",
|
|
1127
|
+
"Cost Captured",
|
|
1128
|
+
]
|
|
1129
|
+
display_trend = sorted(
|
|
1130
|
+
trend,
|
|
1131
|
+
key=c._trend_row_recency_seconds,
|
|
1132
|
+
reverse=True,
|
|
1133
|
+
)
|
|
1134
|
+
table_rows: list[list[str]] = []
|
|
1135
|
+
any_partial = False
|
|
1136
|
+
for idx, row in enumerate(display_trend, start=1):
|
|
1137
|
+
percent = "n/a" if row["weeklyPercent"] is None else f"{row['weeklyPercent']:.2f}%"
|
|
1138
|
+
cost = "n/a" if row["weeklyCostUSD"] is None else f"${row['weeklyCostUSD']:.6f}"
|
|
1139
|
+
dpp = "n/a" if row["dollarsPerPercent"] is None else f"${row['dollarsPerPercent']:.6f}"
|
|
1140
|
+
week_window = c._format_week_window(
|
|
1141
|
+
row.get("weekStartDate"),
|
|
1142
|
+
row.get("weekEndDate"),
|
|
1143
|
+
row.get("weekStartAt"),
|
|
1144
|
+
row.get("weekEndAt"),
|
|
1145
|
+
tz=tz,
|
|
1146
|
+
)
|
|
1147
|
+
# The marker rides in the `#` column so no new column is added and a
|
|
1148
|
+
# table of full weeks keeps its bytes apart from that column's width.
|
|
1149
|
+
partial = _report_row_is_partial_week(row)
|
|
1150
|
+
any_partial = any_partial or partial
|
|
1151
|
+
index_cell = f"{_REPORT_PARTIAL_MARK}{idx}" if partial else str(idx)
|
|
1152
|
+
table_rows.append(
|
|
1153
|
+
[
|
|
1154
|
+
index_cell,
|
|
1155
|
+
week_window,
|
|
1156
|
+
percent,
|
|
1157
|
+
cost,
|
|
1158
|
+
dpp,
|
|
1159
|
+
c._format_ts_compact(row.get("asOf"), tz=tz),
|
|
1160
|
+
c._format_ts_compact(row.get("usageCapturedAt"), tz=tz),
|
|
1161
|
+
c._format_ts_compact(row.get("costCapturedAt"), tz=tz),
|
|
1162
|
+
]
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
blocks.append(
|
|
1166
|
+
c._boxed_table(
|
|
1167
|
+
headers,
|
|
1168
|
+
table_rows,
|
|
1169
|
+
aligns=[
|
|
1170
|
+
"right",
|
|
1171
|
+
"left",
|
|
1172
|
+
"right",
|
|
1173
|
+
"right",
|
|
1174
|
+
"right",
|
|
1175
|
+
"left",
|
|
1176
|
+
"left",
|
|
1177
|
+
"left",
|
|
1178
|
+
],
|
|
1179
|
+
color_header=True,
|
|
1180
|
+
)
|
|
1181
|
+
)
|
|
1182
|
+
# The legend renders only when a marked row exists, so a full-week table
|
|
1183
|
+
# gains no line at all.
|
|
1184
|
+
if any_partial:
|
|
1185
|
+
blocks.append(_REPORT_PARTIAL_LEGEND)
|
|
1186
|
+
return "\n".join(blocks)
|
|
1187
|
+
|
|
1188
|
+
|
|
953
1189
|
def cmd_report(args: argparse.Namespace) -> int:
|
|
954
1190
|
c = _cctally()
|
|
955
1191
|
c._share_validate_args(args)
|
|
@@ -1266,89 +1502,7 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
1266
1502
|
print(json.dumps(payload, indent=2))
|
|
1267
1503
|
return 0
|
|
1268
1504
|
|
|
1269
|
-
|
|
1270
|
-
week_window = c._format_week_window(
|
|
1271
|
-
current_row.get("weekStartDate"),
|
|
1272
|
-
current_row.get("weekEndDate"),
|
|
1273
|
-
current_row.get("weekStartAt"),
|
|
1274
|
-
current_row.get("weekEndAt"),
|
|
1275
|
-
tz=tz,
|
|
1276
|
-
)
|
|
1277
|
-
wp = current_row["weeklyPercent"]
|
|
1278
|
-
wc = current_row["weeklyCostUSD"]
|
|
1279
|
-
dpp = current_row["dollarsPerPercent"]
|
|
1280
|
-
print(
|
|
1281
|
-
c._boxed_table(
|
|
1282
|
-
["Week Window", "Usage %", "Cost USD", "$ / 1%"],
|
|
1283
|
-
[[
|
|
1284
|
-
week_window,
|
|
1285
|
-
f"{wp:.2f}%" if wp is not None else "n/a",
|
|
1286
|
-
f"${wc:.6f}" if wc is not None else "n/a",
|
|
1287
|
-
f"${dpp:.6f}" if dpp is not None else "n/a",
|
|
1288
|
-
]],
|
|
1289
|
-
["left", "right", "right", "right"],
|
|
1290
|
-
)
|
|
1291
|
-
)
|
|
1292
|
-
print()
|
|
1293
|
-
|
|
1294
|
-
print("Trend:")
|
|
1295
|
-
headers = [
|
|
1296
|
-
"#",
|
|
1297
|
-
"Week Window",
|
|
1298
|
-
"Usage %",
|
|
1299
|
-
"Cost USD",
|
|
1300
|
-
"$ / 1%",
|
|
1301
|
-
"As Of",
|
|
1302
|
-
"Usage Captured",
|
|
1303
|
-
"Cost Captured",
|
|
1304
|
-
]
|
|
1305
|
-
display_trend = sorted(
|
|
1306
|
-
trend,
|
|
1307
|
-
key=c._trend_row_recency_seconds,
|
|
1308
|
-
reverse=True,
|
|
1309
|
-
)
|
|
1310
|
-
table_rows: list[list[str]] = []
|
|
1311
|
-
for idx, row in enumerate(display_trend, start=1):
|
|
1312
|
-
percent = "n/a" if row["weeklyPercent"] is None else f"{row['weeklyPercent']:.2f}%"
|
|
1313
|
-
cost = "n/a" if row["weeklyCostUSD"] is None else f"${row['weeklyCostUSD']:.6f}"
|
|
1314
|
-
dpp = "n/a" if row["dollarsPerPercent"] is None else f"${row['dollarsPerPercent']:.6f}"
|
|
1315
|
-
week_window = c._format_week_window(
|
|
1316
|
-
row.get("weekStartDate"),
|
|
1317
|
-
row.get("weekEndDate"),
|
|
1318
|
-
row.get("weekStartAt"),
|
|
1319
|
-
row.get("weekEndAt"),
|
|
1320
|
-
tz=tz,
|
|
1321
|
-
)
|
|
1322
|
-
table_rows.append(
|
|
1323
|
-
[
|
|
1324
|
-
str(idx),
|
|
1325
|
-
week_window,
|
|
1326
|
-
percent,
|
|
1327
|
-
cost,
|
|
1328
|
-
dpp,
|
|
1329
|
-
c._format_ts_compact(row.get("asOf"), tz=tz),
|
|
1330
|
-
c._format_ts_compact(row.get("usageCapturedAt"), tz=tz),
|
|
1331
|
-
c._format_ts_compact(row.get("costCapturedAt"), tz=tz),
|
|
1332
|
-
]
|
|
1333
|
-
)
|
|
1334
|
-
|
|
1335
|
-
print(
|
|
1336
|
-
c._boxed_table(
|
|
1337
|
-
headers,
|
|
1338
|
-
table_rows,
|
|
1339
|
-
aligns=[
|
|
1340
|
-
"right",
|
|
1341
|
-
"left",
|
|
1342
|
-
"right",
|
|
1343
|
-
"right",
|
|
1344
|
-
"right",
|
|
1345
|
-
"left",
|
|
1346
|
-
"left",
|
|
1347
|
-
"left",
|
|
1348
|
-
],
|
|
1349
|
-
color_header=True,
|
|
1350
|
-
)
|
|
1351
|
-
)
|
|
1505
|
+
print(render_report_terminal(output, tz=tz))
|
|
1352
1506
|
|
|
1353
1507
|
if args.detail:
|
|
1354
1508
|
milestone_rows = c.get_milestones_for_week(
|
|
@@ -1460,7 +1614,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1460
1614
|
projected_low_pct=0.0,
|
|
1461
1615
|
projected_high_pct=0.0,
|
|
1462
1616
|
days_remaining=0.0,
|
|
1463
|
-
|
|
1617
|
+
# #620 S1 D5/A5: with no snapshot recorded this week there is
|
|
1618
|
+
# no rate, and zero would render `$0.00` in the artifact's
|
|
1619
|
+
# `$ / 1% (this week)` row — a fabricated answer on the one
|
|
1620
|
+
# path whose whole message is that nothing was measured.
|
|
1621
|
+
# `_build_forecast_snapshot` renders `None` as `n/a`.
|
|
1622
|
+
dollars_per_percent=None,
|
|
1464
1623
|
dollars_per_percent_source="this_week",
|
|
1465
1624
|
low_conf=False,
|
|
1466
1625
|
notes=(
|
|
@@ -1564,7 +1723,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1564
1723
|
projected_low_pct=float(output.final_percent_low),
|
|
1565
1724
|
projected_high_pct=float(output.final_percent_high),
|
|
1566
1725
|
days_remaining=float(i.remaining_days),
|
|
1567
|
-
|
|
1726
|
+
# #620 S1 D5: no `float(...)` coercion — that would turn a
|
|
1727
|
+
# withheld rate back into $0.00 one layer below the fix.
|
|
1728
|
+
dollars_per_percent=(
|
|
1729
|
+
None if i.dollars_per_percent is None
|
|
1730
|
+
else float(i.dollars_per_percent)
|
|
1731
|
+
),
|
|
1568
1732
|
dollars_per_percent_source=i.dollars_per_percent_source,
|
|
1569
1733
|
low_conf=(i.confidence == "low"),
|
|
1570
1734
|
)
|
|
@@ -1586,6 +1750,19 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1586
1750
|
|
|
1587
1751
|
color = _forecast_color_enabled(args.color, sys.stdout)
|
|
1588
1752
|
print(_render_forecast_terminal(output, args, color))
|
|
1753
|
+
# #620 S1 D11: a capped or low-confidence forecast routes to the command
|
|
1754
|
+
# that explains the week it is about. The line sits under the panel rather
|
|
1755
|
+
# than inside it, because the panel is width-fixed and a wrapped command
|
|
1756
|
+
# is not one a reader can copy.
|
|
1757
|
+
if output.already_capped or output.inputs.confidence == "low":
|
|
1758
|
+
i = output.inputs
|
|
1759
|
+
print(_lib_alert_scope.next_step_line(
|
|
1760
|
+
f"cctally percent-breakdown --week-start {i.week_start_at:%Y-%m-%d}",
|
|
1761
|
+
provider="claude",
|
|
1762
|
+
window_start=i.week_start_at,
|
|
1763
|
+
window_end=i.week_end_at,
|
|
1764
|
+
tz=getattr(args, "_resolved_tz", None),
|
|
1765
|
+
))
|
|
1589
1766
|
return 0
|
|
1590
1767
|
|
|
1591
1768
|
|
|
@@ -2573,13 +2750,18 @@ def _budget_verdict_ansi_code(verdict: str) -> str:
|
|
|
2573
2750
|
|
|
2574
2751
|
|
|
2575
2752
|
def _budget_block_lines(
|
|
2576
|
-
inputs, status, *, header_label, alerts_line, color
|
|
2753
|
+
inputs, status, *, header_label, alerts_line, color, provider="claude",
|
|
2754
|
+
tz=None,
|
|
2577
2755
|
) -> list:
|
|
2578
2756
|
"""Render one budget block (header + spent/remaining/pace/projected + the
|
|
2579
2757
|
alerts footer) as a list of lines. Shared by the Claude top block and the
|
|
2580
2758
|
Codex sibling so their layout is identical (spec §5). ``header_label`` is
|
|
2581
2759
|
the fully-formed first line (already carries the period/equivalent-$ cue);
|
|
2582
|
-
``alerts_line`` is the pre-rendered footer.
|
|
2760
|
+
``alerts_line`` is the pre-rendered footer.
|
|
2761
|
+
|
|
2762
|
+
``tz`` is the resolved display zone, the same one ``header_label`` was
|
|
2763
|
+
built from. ``None`` means ``display.tz = local``, which is what
|
|
2764
|
+
``format_display_dt`` already treats it as."""
|
|
2583
2765
|
c = _cctally()
|
|
2584
2766
|
total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
|
|
2585
2767
|
elapsed_days = status.elapsed_fraction * total_seconds / 86400.0
|
|
@@ -2614,10 +2796,32 @@ def _budget_block_lines(
|
|
|
2614
2796
|
f"–${status.projected_eow_high_usd:,.0f} → {verdict_text}"
|
|
2615
2797
|
)
|
|
2616
2798
|
if status.low_confidence:
|
|
2617
|
-
|
|
2799
|
+
# #620 S1 D10: `low_confidence` fires when the period is barely
|
|
2800
|
+
# elapsed OR nothing has been spent. On a fully elapsed period with
|
|
2801
|
+
# zero spend the second disjunct fires and a claim about the first is
|
|
2802
|
+
# simply false. Cause-neutral rather than a cause enum: naming the
|
|
2803
|
+
# disjunct is new vocabulary and belongs to S2.
|
|
2804
|
+
proj_line += " (LOW CONF — limited evidence)"
|
|
2618
2805
|
lines.append(proj_line)
|
|
2619
2806
|
lines.append("")
|
|
2620
2807
|
lines.append(alerts_line)
|
|
2808
|
+
# #620 S1 D11: a warn or over verdict routes to the command that explains
|
|
2809
|
+
# where the spend went, over this block's OWN vendor and period. `--until`
|
|
2810
|
+
# is inclusive while the period is half-open, so it names the last day
|
|
2811
|
+
# inside the window.
|
|
2812
|
+
if status.verdict in ("warn", "over"):
|
|
2813
|
+
since = inputs.week_start_at
|
|
2814
|
+
until = _lib_alert_scope.inclusive_last_day(inputs.week_end_at)
|
|
2815
|
+
selector = "" if provider == "claude" else f" --source {provider}"
|
|
2816
|
+
lines.append("")
|
|
2817
|
+
lines.append(" " + _lib_alert_scope.next_step_line(
|
|
2818
|
+
f"cctally project{selector} --since {since:%Y-%m-%d} "
|
|
2819
|
+
f"--until {until:%Y-%m-%d}",
|
|
2820
|
+
provider=provider,
|
|
2821
|
+
window_start=inputs.week_start_at,
|
|
2822
|
+
window_end=inputs.week_end_at,
|
|
2823
|
+
tz=tz,
|
|
2824
|
+
))
|
|
2621
2825
|
return lines
|
|
2622
2826
|
|
|
2623
2827
|
|
|
@@ -2654,6 +2858,7 @@ def _budget_render_terminal(
|
|
|
2654
2858
|
header_label=header,
|
|
2655
2859
|
alerts_line=_budget_alerts_line(budget_cfg, status),
|
|
2656
2860
|
color=color,
|
|
2861
|
+
tz=tz,
|
|
2657
2862
|
)
|
|
2658
2863
|
print("\n".join(lines))
|
|
2659
2864
|
return 0
|
|
@@ -2687,6 +2892,7 @@ def _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args) -> Non
|
|
|
2687
2892
|
lines = _budget_block_lines(
|
|
2688
2893
|
codex_inputs, codex_status,
|
|
2689
2894
|
header_label=header, alerts_line=alerts_line, color=color,
|
|
2895
|
+
provider="codex", tz=tz,
|
|
2690
2896
|
)
|
|
2691
2897
|
print("\n" + "\n".join(lines))
|
|
2692
2898
|
|
|
@@ -3073,7 +3279,9 @@ def _build_budget_snapshot(
|
|
|
3073
3279
|
_lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (high)"),
|
|
3074
3280
|
"value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
|
|
3075
3281
|
)
|
|
3076
|
-
|
|
3282
|
+
# #620 S1 D10 — the same neutral string the terminal renders, because the
|
|
3283
|
+
# shared artifact and the terminal describe one status.
|
|
3284
|
+
notes = ("LOW CONF — limited evidence",) if status.low_confidence else ()
|
|
3077
3285
|
return _lib_share.ShareSnapshot(
|
|
3078
3286
|
cmd="budget",
|
|
3079
3287
|
title=title,
|
|
@@ -273,9 +273,15 @@ def _current_claude_week_key(conn: sqlite3.Connection) -> "str | None":
|
|
|
273
273
|
|
|
274
274
|
|
|
275
275
|
def _claude_cycle_key(ref) -> str:
|
|
276
|
+
# A milestone-only week whose rows predate `percent_milestones.week_start_at`
|
|
277
|
+
# carries no boundaries at all, and `_synthesize_milestone_only_ref` returns
|
|
278
|
+
# that ref rather than dropping the week. `dashboard_resource_key` rejects
|
|
279
|
+
# the empty string as an identity part but accepts `None` as its own typed
|
|
280
|
+
# part, so the absent boundary is passed as the absence it is. Every ref
|
|
281
|
+
# that does carry both boundaries keys exactly as before.
|
|
276
282
|
return dashboard_resource_key(
|
|
277
283
|
"milestone_cycle", "claude", ref.key,
|
|
278
|
-
ref.week_start_at or
|
|
284
|
+
ref.week_start_at or None, ref.week_end_at or None,
|
|
279
285
|
)
|
|
280
286
|
|
|
281
287
|
|
|
@@ -440,9 +446,11 @@ def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None
|
|
|
440
446
|
return None
|
|
441
447
|
entry = next(e for e in build_claude_week_index(conn) if e["key"] == key)
|
|
442
448
|
rows = _claude_cycle_rows(conn, ref)
|
|
449
|
+
# Same absent-boundary case `_claude_cycle_key` handles, and this call runs
|
|
450
|
+
# unconditionally, ahead of the `if rows` guard below.
|
|
443
451
|
segment_key = dashboard_resource_key(
|
|
444
452
|
"milestone_segment", "claude", ref.key,
|
|
445
|
-
ref.week_start_at or
|
|
453
|
+
ref.week_start_at or None, ref.week_end_at or None,
|
|
446
454
|
)
|
|
447
455
|
segments = ([{"key": segment_key,
|
|
448
456
|
"milestones": [_shape_weekly_milestone(r) for r in rows]}]
|