cctally 1.100.0 → 1.102.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 +73 -0
- package/README.md +8 -2
- 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 +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- 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_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- 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 +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -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
|
@@ -82,7 +82,11 @@ def _ensure_sibling_loaded(name: str) -> None:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
_ensure_sibling_loaded("_lib_forecast")
|
|
85
|
-
from _lib_forecast import
|
|
85
|
+
from _lib_forecast import (
|
|
86
|
+
ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast,
|
|
87
|
+
ForecastConfidenceAssessment, ForecastConfidenceCause,
|
|
88
|
+
assess_forecast_confidence,
|
|
89
|
+
)
|
|
86
90
|
|
|
87
91
|
# #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
|
|
88
92
|
# _iso_z (dt-only, no None guard) collapses to this single definition; the union
|
|
@@ -91,6 +95,10 @@ from _lib_forecast import ForecastInputs, BudgetRow, ForecastOutput, _compute_fo
|
|
|
91
95
|
_ensure_sibling_loaded("_lib_json_envelope")
|
|
92
96
|
from _lib_json_envelope import _iso_z
|
|
93
97
|
|
|
98
|
+
# #620 S1 D11: the one affordance shape every warning state renders.
|
|
99
|
+
_ensure_sibling_loaded("_lib_alert_scope")
|
|
100
|
+
import _lib_alert_scope
|
|
101
|
+
|
|
94
102
|
|
|
95
103
|
def _cctally():
|
|
96
104
|
"""Resolve the current `cctally` module at call-time (§2)."""
|
|
@@ -368,9 +376,16 @@ def _select_dollars_per_percent(
|
|
|
368
376
|
skip_sync: bool = False,
|
|
369
377
|
use_weekref_cost_cache: bool = False,
|
|
370
378
|
account_key: "str | None" = None,
|
|
371
|
-
) -> tuple[float, str]:
|
|
379
|
+
) -> "tuple[float | None, str]":
|
|
372
380
|
"""Return (dollars_per_percent, source_label). See spec §1 selection rule.
|
|
373
381
|
|
|
382
|
+
#620 S1 D5: the rate is ``None`` when no usage has been observed. It used
|
|
383
|
+
to be ``0.0`` paired with the ``this_week_sparse`` label, which published
|
|
384
|
+
a rate of exactly $0.00 per percent and blamed a sparse week — a
|
|
385
|
+
fabricated answer with a false cause, on a week that may carry real
|
|
386
|
+
spend. Absence is now typed, and the cause travels on the existing
|
|
387
|
+
companion source field rather than a new key.
|
|
388
|
+
|
|
374
389
|
Eligible prior week: week_end_at < now_utc AND final_weekly_percent >= 1.
|
|
375
390
|
Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
|
|
376
391
|
via `get_entries`); `conn` is only used for snapshot queries.
|
|
@@ -466,22 +481,31 @@ def _select_dollars_per_percent(
|
|
|
466
481
|
# Path 3: fall back to current week even if sparse.
|
|
467
482
|
if p_now > 0:
|
|
468
483
|
return spent_usd / p_now, "this_week_sparse"
|
|
469
|
-
# p_now == 0: no signal
|
|
470
|
-
|
|
484
|
+
# p_now == 0: there is no signal to divide by. Withhold the rate and say
|
|
485
|
+
# why (#620 S1 D5). Every dollar figure derived from it becomes
|
|
486
|
+
# unavailable; percent projections are unaffected because they never
|
|
487
|
+
# depended on it.
|
|
488
|
+
return None, "no_usage_observed"
|
|
471
489
|
|
|
472
490
|
|
|
473
491
|
def _assess_forecast_confidence(
|
|
474
|
-
elapsed_hours: float, p_now: float, snapshot_count: int
|
|
492
|
+
elapsed_hours: float, p_now: float, snapshot_count: int,
|
|
493
|
+
*, has_sample_ge_24h: bool = True,
|
|
475
494
|
) -> tuple[str, list[str]]:
|
|
476
|
-
"""
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
495
|
+
"""Alias onto the pure predicate in `_lib_forecast` (#620 S2 E3).
|
|
496
|
+
|
|
497
|
+
Four import paths reach this name — `bin/cctally:1600`,
|
|
498
|
+
`bin/_cctally_record.py:427-428`, `bin/_cctally_record.py:1539` and
|
|
499
|
+
`tests/test_620_confidence_wording.py:141` — so it keeps its three
|
|
500
|
+
positional parameters and its `(confidence, list_of_reasons)` return
|
|
501
|
+
shape. `has_sample_ge_24h` defaults to True, which is what makes a
|
|
502
|
+
three-argument call emit exactly the three reasons it always did.
|
|
503
|
+
"""
|
|
504
|
+
assessment = assess_forecast_confidence(
|
|
505
|
+
elapsed_hours, p_now, snapshot_count,
|
|
506
|
+
has_sample_ge_24h=has_sample_ge_24h,
|
|
507
|
+
)
|
|
508
|
+
return assessment.confidence, list(assessment.reasons)
|
|
485
509
|
|
|
486
510
|
|
|
487
511
|
def _pick_p_24h_ago(
|
|
@@ -562,12 +586,12 @@ def _load_forecast_inputs(
|
|
|
562
586
|
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
563
587
|
account_key=account_key,
|
|
564
588
|
)
|
|
565
|
-
confidence, reasons = _assess_forecast_confidence(elapsed_hours, p_now, len(samples))
|
|
566
589
|
target_24h = now_utc - dt.timedelta(hours=24)
|
|
567
590
|
has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
591
|
+
confidence, reasons = _assess_forecast_confidence(
|
|
592
|
+
elapsed_hours, p_now, len(samples),
|
|
593
|
+
has_sample_ge_24h=has_sample_ge_24h,
|
|
594
|
+
)
|
|
571
595
|
|
|
572
596
|
return ForecastInputs(
|
|
573
597
|
now_utc=now_utc,
|
|
@@ -643,7 +667,10 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
643
667
|
"week_average_pct_per_hour": round(out.r_avg, 6),
|
|
644
668
|
"recent_24h_pct_per_hour": (None if out.r_recent is None
|
|
645
669
|
else round(out.r_recent, 6)),
|
|
646
|
-
"dollars_per_percent":
|
|
670
|
+
"dollars_per_percent": (
|
|
671
|
+
None if i.dollars_per_percent is None
|
|
672
|
+
else round(i.dollars_per_percent, 6)
|
|
673
|
+
),
|
|
647
674
|
"dollars_per_percent_source": i.dollars_per_percent_source,
|
|
648
675
|
},
|
|
649
676
|
"forecast": {
|
|
@@ -814,6 +841,62 @@ def _render_forecast_progress_bar(
|
|
|
814
841
|
return [label_str, axis_str, bar_line, cap_str]
|
|
815
842
|
|
|
816
843
|
|
|
844
|
+
# #620 S1 D10: the four reason codes `_assess_forecast_confidence` and
|
|
845
|
+
# `_load_forecast_inputs` emit are a wire contract that `--json` consumers
|
|
846
|
+
# read, so they are unchanged. What changed is that the terminal used to
|
|
847
|
+
# print them verbatim into a line a person reads. The mapping is a render
|
|
848
|
+
# step, and it stays in this I/O layer; moving cause classification beside
|
|
849
|
+
# the predicate in `_lib_forecast.py` buys nothing without a cause enum,
|
|
850
|
+
# which is S2's.
|
|
851
|
+
_FORECAST_CONFIDENCE_WORDING = {
|
|
852
|
+
"elapsed_hours<24": "less than 24 hours into the week",
|
|
853
|
+
"percent<2": "under 2% of quota used so far",
|
|
854
|
+
"snapshots<3": "fewer than 3 usage snapshots",
|
|
855
|
+
"no_sample_ge_24h": "no snapshot at least 24 hours old",
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _forecast_confidence_wording(reasons) -> str:
|
|
860
|
+
"""Render low-confidence reason codes as human wording.
|
|
861
|
+
|
|
862
|
+
An unrecognised code falls back to the code itself rather than being
|
|
863
|
+
dropped. Dropping it would render an empty parenthetical that states no
|
|
864
|
+
cause at all, which is worse than an unfamiliar token: a code nobody
|
|
865
|
+
recognises is still a fact about why the forecast is thin.
|
|
866
|
+
"""
|
|
867
|
+
return ", ".join(
|
|
868
|
+
_FORECAST_CONFIDENCE_WORDING.get(code, code) for code in reasons
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _wrap_panel_text(text: str, width: int, indent: str = " ") -> list[str]:
|
|
873
|
+
"""Break one statement into panel rows of at most ``width`` characters.
|
|
874
|
+
|
|
875
|
+
``_render_forecast_terminal``'s ``_row`` pads to a fixed width and never
|
|
876
|
+
truncates, so a statement longer than the row runs past the frame's right
|
|
877
|
+
border. Human confidence wording is longer than the machine codes it
|
|
878
|
+
replaced and up to four reasons can fire at once, so the statement is laid
|
|
879
|
+
across as many rows as it needs. Nothing is dropped and nothing is
|
|
880
|
+
truncated: a single word longer than the row still gets its own row and
|
|
881
|
+
overruns, which is the honest outcome for an unrecognised reason code.
|
|
882
|
+
"""
|
|
883
|
+
words = text.split(" ")
|
|
884
|
+
rows: list[str] = []
|
|
885
|
+
current = ""
|
|
886
|
+
prefix = ""
|
|
887
|
+
for word in words:
|
|
888
|
+
candidate = f"{current} {word}" if current else f"{prefix}{word}"
|
|
889
|
+
if current and len(candidate) > width:
|
|
890
|
+
rows.append(current)
|
|
891
|
+
prefix = indent
|
|
892
|
+
current = f"{prefix}{word}"
|
|
893
|
+
else:
|
|
894
|
+
current = candidate
|
|
895
|
+
if current:
|
|
896
|
+
rows.append(current)
|
|
897
|
+
return rows or [text]
|
|
898
|
+
|
|
899
|
+
|
|
817
900
|
def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
818
901
|
"""Full box-frame terminal render (spec §4)."""
|
|
819
902
|
c = _cctally()
|
|
@@ -865,14 +948,24 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
865
948
|
|
|
866
949
|
# ── Panel 2: used / forecast / bar
|
|
867
950
|
used_line = f"Used {i.p_now:.1f}% ${i.spent_usd:.2f}"
|
|
951
|
+
# Only the low-confidence branch can need continuation rows.
|
|
952
|
+
forecast_extra_lines: list[str] = []
|
|
868
953
|
if out.already_capped:
|
|
869
954
|
forecast_line = c._style_ansi(
|
|
870
955
|
f"\u26a0 CAPPED at {i.p_now:.1f}% \u2014 reset {fmt_dt(i.week_end_at)} "
|
|
871
956
|
f"({i.remaining_days:.1f}d)", "31", color)
|
|
872
957
|
elif i.confidence == "low":
|
|
873
|
-
reasons =
|
|
874
|
-
|
|
875
|
-
|
|
958
|
+
reasons = _forecast_confidence_wording(i.low_confidence_reasons)
|
|
959
|
+
# `_row` fits `inner_w - 1` characters; one short of that keeps the
|
|
960
|
+
# single trailing space every other row in the panel has.
|
|
961
|
+
conf_rows = _wrap_panel_text(
|
|
962
|
+
f"\u26a0 LOW CONF \u2014 insufficient data ({reasons})",
|
|
963
|
+
inner_w - 2,
|
|
964
|
+
)
|
|
965
|
+
forecast_line = c._style_ansi(conf_rows[0], "33", color)
|
|
966
|
+
forecast_extra_lines = [
|
|
967
|
+
c._style_ansi(row, "33", color) for row in conf_rows[1:]
|
|
968
|
+
]
|
|
876
969
|
else:
|
|
877
970
|
low, high = out.final_percent_low, out.final_percent_high
|
|
878
971
|
low_rnd = round(low)
|
|
@@ -901,6 +994,10 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
901
994
|
budget_rows = []
|
|
902
995
|
for b in out.budgets:
|
|
903
996
|
if b.dollars_per_day is None:
|
|
997
|
+
# `past target` is the only cause reachable here. The other cause
|
|
998
|
+
# of a `None` budget — no rate observed — cannot arrive: this
|
|
999
|
+
# panel renders only when `confidence != "low"`, and `p_now == 0`
|
|
1000
|
+
# always sets `percent<2`, which is a low-confidence input.
|
|
904
1001
|
budget_rows.append(f" to {b.target_percent:>3}% \u2014 past target")
|
|
905
1002
|
else:
|
|
906
1003
|
budget_rows.append(
|
|
@@ -927,6 +1024,8 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
927
1024
|
lines.append(_box_mid())
|
|
928
1025
|
lines.append(_row(used_line))
|
|
929
1026
|
lines.append(_row(forecast_line))
|
|
1027
|
+
for extra_line in forecast_extra_lines:
|
|
1028
|
+
lines.append(_row(extra_line))
|
|
930
1029
|
lines.append(_row(""))
|
|
931
1030
|
for bl in bar_lines:
|
|
932
1031
|
lines.append(_row(bl))
|
|
@@ -950,6 +1049,153 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
950
1049
|
return "\n".join(lines)
|
|
951
1050
|
|
|
952
1051
|
|
|
1052
|
+
# A trend row is marked partial when its span falls short of the nominal
|
|
1053
|
+
# subscription week by more than this. The tolerance absorbs the
|
|
1054
|
+
# hour-boundary normalisation applied to Anthropic's reset jitter, so an
|
|
1055
|
+
# ordinary week normalised by up to an hour is never marked (#620 S1 D9).
|
|
1056
|
+
_REPORT_PARTIAL_WEEK_TOLERANCE = dt.timedelta(hours=1)
|
|
1057
|
+
_REPORT_NOMINAL_WEEK = dt.timedelta(days=7)
|
|
1058
|
+
_REPORT_PARTIAL_MARK = "~"
|
|
1059
|
+
_REPORT_PARTIAL_LEGEND = (
|
|
1060
|
+
"~ marks a week shorter than the nominal 7 days, so its $ / 1% is not "
|
|
1061
|
+
"directly comparable with a full week's."
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _report_row_is_partial_week(row: "dict") -> bool:
|
|
1066
|
+
"""True when this trend row's `[start, end)` span is short of a full
|
|
1067
|
+
subscription week by more than the jitter tolerance (#620 S1 D9).
|
|
1068
|
+
|
|
1069
|
+
A row that does not carry both exact bounds is NOT marked: the span is
|
|
1070
|
+
unknown, and marking on a guess would assert something the data does not
|
|
1071
|
+
support. Absent weeks stay omitted, exactly as before.
|
|
1072
|
+
"""
|
|
1073
|
+
start_iso = row.get("weekStartAt")
|
|
1074
|
+
end_iso = row.get("weekEndAt")
|
|
1075
|
+
if not start_iso or not end_iso:
|
|
1076
|
+
return False
|
|
1077
|
+
try:
|
|
1078
|
+
start = parse_iso_datetime(start_iso, "report row weekStartAt")
|
|
1079
|
+
end = parse_iso_datetime(end_iso, "report row weekEndAt")
|
|
1080
|
+
except (TypeError, ValueError):
|
|
1081
|
+
return False
|
|
1082
|
+
return (end - start) < (_REPORT_NOMINAL_WEEK - _REPORT_PARTIAL_WEEK_TOLERANCE)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def render_report_terminal(payload: "dict", *, tz) -> str:
|
|
1086
|
+
"""Render the report's current-week and trend tables (#620 S1 D7).
|
|
1087
|
+
|
|
1088
|
+
A pure function over the report's own JSON payload — the exact object
|
|
1089
|
+
`cmd_report` emits under `--json`. It was inline in `cmd_report`, which
|
|
1090
|
+
meant the all-source path had no way to reach it: `_render_claude_terminal`
|
|
1091
|
+
fell back to `_legacy_claude_totals`, which reads a `totals` object this
|
|
1092
|
+
payload does not have, so `report --source all` printed two lines and the
|
|
1093
|
+
literal `Data available.` while `report` alone printed the whole report.
|
|
1094
|
+
|
|
1095
|
+
Teaching `_legacy_claude_totals` this payload's shape was rejected: it
|
|
1096
|
+
would reduce a rich two-table report to a misleading one-line total.
|
|
1097
|
+
"""
|
|
1098
|
+
c = _cctally()
|
|
1099
|
+
current_row = payload.get("current")
|
|
1100
|
+
trend = payload.get("trend") or []
|
|
1101
|
+
blocks: "list[str]" = []
|
|
1102
|
+
|
|
1103
|
+
if current_row is not None:
|
|
1104
|
+
week_window = c._format_week_window(
|
|
1105
|
+
current_row.get("weekStartDate"),
|
|
1106
|
+
current_row.get("weekEndDate"),
|
|
1107
|
+
current_row.get("weekStartAt"),
|
|
1108
|
+
current_row.get("weekEndAt"),
|
|
1109
|
+
tz=tz,
|
|
1110
|
+
)
|
|
1111
|
+
wp = current_row["weeklyPercent"]
|
|
1112
|
+
wc = current_row["weeklyCostUSD"]
|
|
1113
|
+
dpp = current_row["dollarsPerPercent"]
|
|
1114
|
+
blocks.append(
|
|
1115
|
+
c._boxed_table(
|
|
1116
|
+
["Week Window", "Usage %", "Cost USD", "$ / 1%"],
|
|
1117
|
+
[[
|
|
1118
|
+
week_window,
|
|
1119
|
+
f"{wp:.2f}%" if wp is not None else "n/a",
|
|
1120
|
+
f"${wc:.6f}" if wc is not None else "n/a",
|
|
1121
|
+
f"${dpp:.6f}" if dpp is not None else "n/a",
|
|
1122
|
+
]],
|
|
1123
|
+
["left", "right", "right", "right"],
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
blocks.append("")
|
|
1127
|
+
|
|
1128
|
+
blocks.append("Trend:")
|
|
1129
|
+
headers = [
|
|
1130
|
+
"#",
|
|
1131
|
+
"Week Window",
|
|
1132
|
+
"Usage %",
|
|
1133
|
+
"Cost USD",
|
|
1134
|
+
"$ / 1%",
|
|
1135
|
+
"As Of",
|
|
1136
|
+
"Usage Captured",
|
|
1137
|
+
"Cost Captured",
|
|
1138
|
+
]
|
|
1139
|
+
display_trend = sorted(
|
|
1140
|
+
trend,
|
|
1141
|
+
key=c._trend_row_recency_seconds,
|
|
1142
|
+
reverse=True,
|
|
1143
|
+
)
|
|
1144
|
+
table_rows: list[list[str]] = []
|
|
1145
|
+
any_partial = False
|
|
1146
|
+
for idx, row in enumerate(display_trend, start=1):
|
|
1147
|
+
percent = "n/a" if row["weeklyPercent"] is None else f"{row['weeklyPercent']:.2f}%"
|
|
1148
|
+
cost = "n/a" if row["weeklyCostUSD"] is None else f"${row['weeklyCostUSD']:.6f}"
|
|
1149
|
+
dpp = "n/a" if row["dollarsPerPercent"] is None else f"${row['dollarsPerPercent']:.6f}"
|
|
1150
|
+
week_window = c._format_week_window(
|
|
1151
|
+
row.get("weekStartDate"),
|
|
1152
|
+
row.get("weekEndDate"),
|
|
1153
|
+
row.get("weekStartAt"),
|
|
1154
|
+
row.get("weekEndAt"),
|
|
1155
|
+
tz=tz,
|
|
1156
|
+
)
|
|
1157
|
+
# The marker rides in the `#` column so no new column is added and a
|
|
1158
|
+
# table of full weeks keeps its bytes apart from that column's width.
|
|
1159
|
+
partial = _report_row_is_partial_week(row)
|
|
1160
|
+
any_partial = any_partial or partial
|
|
1161
|
+
index_cell = f"{_REPORT_PARTIAL_MARK}{idx}" if partial else str(idx)
|
|
1162
|
+
table_rows.append(
|
|
1163
|
+
[
|
|
1164
|
+
index_cell,
|
|
1165
|
+
week_window,
|
|
1166
|
+
percent,
|
|
1167
|
+
cost,
|
|
1168
|
+
dpp,
|
|
1169
|
+
c._format_ts_compact(row.get("asOf"), tz=tz),
|
|
1170
|
+
c._format_ts_compact(row.get("usageCapturedAt"), tz=tz),
|
|
1171
|
+
c._format_ts_compact(row.get("costCapturedAt"), tz=tz),
|
|
1172
|
+
]
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
blocks.append(
|
|
1176
|
+
c._boxed_table(
|
|
1177
|
+
headers,
|
|
1178
|
+
table_rows,
|
|
1179
|
+
aligns=[
|
|
1180
|
+
"right",
|
|
1181
|
+
"left",
|
|
1182
|
+
"right",
|
|
1183
|
+
"right",
|
|
1184
|
+
"right",
|
|
1185
|
+
"left",
|
|
1186
|
+
"left",
|
|
1187
|
+
"left",
|
|
1188
|
+
],
|
|
1189
|
+
color_header=True,
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
# The legend renders only when a marked row exists, so a full-week table
|
|
1193
|
+
# gains no line at all.
|
|
1194
|
+
if any_partial:
|
|
1195
|
+
blocks.append(_REPORT_PARTIAL_LEGEND)
|
|
1196
|
+
return "\n".join(blocks)
|
|
1197
|
+
|
|
1198
|
+
|
|
953
1199
|
def cmd_report(args: argparse.Namespace) -> int:
|
|
954
1200
|
c = _cctally()
|
|
955
1201
|
c._share_validate_args(args)
|
|
@@ -1266,89 +1512,7 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
1266
1512
|
print(json.dumps(payload, indent=2))
|
|
1267
1513
|
return 0
|
|
1268
1514
|
|
|
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
|
-
)
|
|
1515
|
+
print(render_report_terminal(output, tz=tz))
|
|
1352
1516
|
|
|
1353
1517
|
if args.detail:
|
|
1354
1518
|
milestone_rows = c.get_milestones_for_week(
|
|
@@ -1460,7 +1624,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1460
1624
|
projected_low_pct=0.0,
|
|
1461
1625
|
projected_high_pct=0.0,
|
|
1462
1626
|
days_remaining=0.0,
|
|
1463
|
-
|
|
1627
|
+
# #620 S1 D5/A5: with no snapshot recorded this week there is
|
|
1628
|
+
# no rate, and zero would render `$0.00` in the artifact's
|
|
1629
|
+
# `$ / 1% (this week)` row — a fabricated answer on the one
|
|
1630
|
+
# path whose whole message is that nothing was measured.
|
|
1631
|
+
# `_build_forecast_snapshot` renders `None` as `n/a`.
|
|
1632
|
+
dollars_per_percent=None,
|
|
1464
1633
|
dollars_per_percent_source="this_week",
|
|
1465
1634
|
low_conf=False,
|
|
1466
1635
|
notes=(
|
|
@@ -1564,7 +1733,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1564
1733
|
projected_low_pct=float(output.final_percent_low),
|
|
1565
1734
|
projected_high_pct=float(output.final_percent_high),
|
|
1566
1735
|
days_remaining=float(i.remaining_days),
|
|
1567
|
-
|
|
1736
|
+
# #620 S1 D5: no `float(...)` coercion — that would turn a
|
|
1737
|
+
# withheld rate back into $0.00 one layer below the fix.
|
|
1738
|
+
dollars_per_percent=(
|
|
1739
|
+
None if i.dollars_per_percent is None
|
|
1740
|
+
else float(i.dollars_per_percent)
|
|
1741
|
+
),
|
|
1568
1742
|
dollars_per_percent_source=i.dollars_per_percent_source,
|
|
1569
1743
|
low_conf=(i.confidence == "low"),
|
|
1570
1744
|
)
|
|
@@ -1586,6 +1760,19 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1586
1760
|
|
|
1587
1761
|
color = _forecast_color_enabled(args.color, sys.stdout)
|
|
1588
1762
|
print(_render_forecast_terminal(output, args, color))
|
|
1763
|
+
# #620 S1 D11: a capped or low-confidence forecast routes to the command
|
|
1764
|
+
# that explains the week it is about. The line sits under the panel rather
|
|
1765
|
+
# than inside it, because the panel is width-fixed and a wrapped command
|
|
1766
|
+
# is not one a reader can copy.
|
|
1767
|
+
if output.already_capped or output.inputs.confidence == "low":
|
|
1768
|
+
i = output.inputs
|
|
1769
|
+
print(_lib_alert_scope.next_step_line(
|
|
1770
|
+
f"cctally percent-breakdown --week-start {i.week_start_at:%Y-%m-%d}",
|
|
1771
|
+
provider="claude",
|
|
1772
|
+
window_start=i.week_start_at,
|
|
1773
|
+
window_end=i.week_end_at,
|
|
1774
|
+
tz=getattr(args, "_resolved_tz", None),
|
|
1775
|
+
))
|
|
1589
1776
|
return 0
|
|
1590
1777
|
|
|
1591
1778
|
|
|
@@ -2573,13 +2760,18 @@ def _budget_verdict_ansi_code(verdict: str) -> str:
|
|
|
2573
2760
|
|
|
2574
2761
|
|
|
2575
2762
|
def _budget_block_lines(
|
|
2576
|
-
inputs, status, *, header_label, alerts_line, color
|
|
2763
|
+
inputs, status, *, header_label, alerts_line, color, provider="claude",
|
|
2764
|
+
tz=None,
|
|
2577
2765
|
) -> list:
|
|
2578
2766
|
"""Render one budget block (header + spent/remaining/pace/projected + the
|
|
2579
2767
|
alerts footer) as a list of lines. Shared by the Claude top block and the
|
|
2580
2768
|
Codex sibling so their layout is identical (spec §5). ``header_label`` is
|
|
2581
2769
|
the fully-formed first line (already carries the period/equivalent-$ cue);
|
|
2582
|
-
``alerts_line`` is the pre-rendered footer.
|
|
2770
|
+
``alerts_line`` is the pre-rendered footer.
|
|
2771
|
+
|
|
2772
|
+
``tz`` is the resolved display zone, the same one ``header_label`` was
|
|
2773
|
+
built from. ``None`` means ``display.tz = local``, which is what
|
|
2774
|
+
``format_display_dt`` already treats it as."""
|
|
2583
2775
|
c = _cctally()
|
|
2584
2776
|
total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
|
|
2585
2777
|
elapsed_days = status.elapsed_fraction * total_seconds / 86400.0
|
|
@@ -2614,10 +2806,32 @@ def _budget_block_lines(
|
|
|
2614
2806
|
f"–${status.projected_eow_high_usd:,.0f} → {verdict_text}"
|
|
2615
2807
|
)
|
|
2616
2808
|
if status.low_confidence:
|
|
2617
|
-
|
|
2809
|
+
# #620 S1 D10: `low_confidence` fires when the period is barely
|
|
2810
|
+
# elapsed OR nothing has been spent. On a fully elapsed period with
|
|
2811
|
+
# zero spend the second disjunct fires and a claim about the first is
|
|
2812
|
+
# simply false. Cause-neutral rather than a cause enum: naming the
|
|
2813
|
+
# disjunct is new vocabulary and belongs to S2.
|
|
2814
|
+
proj_line += " (LOW CONF — limited evidence)"
|
|
2618
2815
|
lines.append(proj_line)
|
|
2619
2816
|
lines.append("")
|
|
2620
2817
|
lines.append(alerts_line)
|
|
2818
|
+
# #620 S1 D11: a warn or over verdict routes to the command that explains
|
|
2819
|
+
# where the spend went, over this block's OWN vendor and period. `--until`
|
|
2820
|
+
# is inclusive while the period is half-open, so it names the last day
|
|
2821
|
+
# inside the window.
|
|
2822
|
+
if status.verdict in ("warn", "over"):
|
|
2823
|
+
since = inputs.week_start_at
|
|
2824
|
+
until = _lib_alert_scope.inclusive_last_day(inputs.week_end_at)
|
|
2825
|
+
selector = "" if provider == "claude" else f" --source {provider}"
|
|
2826
|
+
lines.append("")
|
|
2827
|
+
lines.append(" " + _lib_alert_scope.next_step_line(
|
|
2828
|
+
f"cctally project{selector} --since {since:%Y-%m-%d} "
|
|
2829
|
+
f"--until {until:%Y-%m-%d}",
|
|
2830
|
+
provider=provider,
|
|
2831
|
+
window_start=inputs.week_start_at,
|
|
2832
|
+
window_end=inputs.week_end_at,
|
|
2833
|
+
tz=tz,
|
|
2834
|
+
))
|
|
2621
2835
|
return lines
|
|
2622
2836
|
|
|
2623
2837
|
|
|
@@ -2654,6 +2868,7 @@ def _budget_render_terminal(
|
|
|
2654
2868
|
header_label=header,
|
|
2655
2869
|
alerts_line=_budget_alerts_line(budget_cfg, status),
|
|
2656
2870
|
color=color,
|
|
2871
|
+
tz=tz,
|
|
2657
2872
|
)
|
|
2658
2873
|
print("\n".join(lines))
|
|
2659
2874
|
return 0
|
|
@@ -2687,6 +2902,7 @@ def _print_codex_section(codex_cfg, codex_inputs, codex_status, tz, args) -> Non
|
|
|
2687
2902
|
lines = _budget_block_lines(
|
|
2688
2903
|
codex_inputs, codex_status,
|
|
2689
2904
|
header_label=header, alerts_line=alerts_line, color=color,
|
|
2905
|
+
provider="codex", tz=tz,
|
|
2690
2906
|
)
|
|
2691
2907
|
print("\n" + "\n".join(lines))
|
|
2692
2908
|
|
|
@@ -3073,7 +3289,9 @@ def _build_budget_snapshot(
|
|
|
3073
3289
|
_lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (high)"),
|
|
3074
3290
|
"value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
|
|
3075
3291
|
)
|
|
3076
|
-
|
|
3292
|
+
# #620 S1 D10 — the same neutral string the terminal renders, because the
|
|
3293
|
+
# shared artifact and the terminal describe one status.
|
|
3294
|
+
notes = ("LOW CONF — limited evidence",) if status.low_confidence else ()
|
|
3077
3295
|
return _lib_share.ShareSnapshot(
|
|
3078
3296
|
cmd="budget",
|
|
3079
3297
|
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]}]
|