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.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -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
@@ -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 ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast
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. Return 0; math layer guards against div-by-zero.
470
- return 0.0, "this_week_sparse"
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
- """Binary confidence (spec §2)."""
477
- reasons: list[str] = []
478
- if elapsed_hours < 24:
479
- reasons.append("elapsed_hours<24")
480
- if p_now < 2:
481
- reasons.append("percent<2")
482
- if snapshot_count < 3:
483
- reasons.append("snapshots<3")
484
- return ("low", reasons) if reasons else ("high", [])
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
- if not has_sample_ge_24h:
569
- reasons = list(reasons) + ["no_sample_ge_24h"]
570
- confidence = "low"
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": round(i.dollars_per_percent, 6),
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 = ", ".join(i.low_confidence_reasons)
874
- forecast_line = c._style_ansi(
875
- f"\u26a0 LOW CONF \u2014 insufficient data ({reasons})", "33", color)
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
- if current_row is not None:
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
- dollars_per_percent=0.0,
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
- dollars_per_percent=float(i.dollars_per_percent),
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
- proj_line += " (LOW CONF — early in week)"
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
- notes = ("LOW CONF — early in week",) if status.low_confidence else ()
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 "", ref.week_end_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 "", ref.week_end_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]}]