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.
Files changed (36) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/bin/_cctally_alerts.py +13 -2
  3. package/bin/_cctally_cache.py +3 -1
  4. package/bin/_cctally_cache_report.py +103 -6
  5. package/bin/_cctally_dashboard.py +713 -166
  6. package/bin/_cctally_dashboard_envelope.py +28 -38
  7. package/bin/_cctally_dashboard_share.py +101 -29
  8. package/bin/_cctally_dashboard_sources.py +663 -192
  9. package/bin/_cctally_diff.py +20 -0
  10. package/bin/_cctally_forecast.py +304 -96
  11. package/bin/_cctally_milestone_history.py +10 -2
  12. package/bin/_cctally_project.py +155 -47
  13. package/bin/_cctally_record.py +7 -1
  14. package/bin/_cctally_share.py +9 -2
  15. package/bin/_cctally_source_analytics.py +40 -4
  16. package/bin/_cctally_tui.py +425 -234
  17. package/bin/_lib_alert_scope.py +685 -0
  18. package/bin/_lib_alerts_payload.py +112 -7
  19. package/bin/_lib_cache_report.py +110 -1
  20. package/bin/_lib_codex_pools.py +20 -8
  21. package/bin/_lib_dashboard_sources.py +212 -24
  22. package/bin/_lib_forecast.py +12 -4
  23. package/bin/_lib_pricing.py +8 -7
  24. package/bin/_lib_render.py +31 -3
  25. package/bin/_lib_share_templates.py +150 -55
  26. package/bin/_lib_snapshot_cache.py +71 -13
  27. package/bin/_lib_source_identity.py +50 -2
  28. package/bin/_lib_subscription_weeks.py +65 -0
  29. package/bin/cctally +10 -0
  30. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  31. package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
  32. package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
  33. package/dashboard/static/dashboard.html +2 -2
  34. package/package.json +2 -1
  35. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  36. 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
@@ -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. Return 0; math layer guards against div-by-zero.
470
- return 0.0, "this_week_sparse"
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": round(i.dollars_per_percent, 6),
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 = ", ".join(i.low_confidence_reasons)
874
- forecast_line = c._style_ansi(
875
- f"\u26a0 LOW CONF \u2014 insufficient data ({reasons})", "33", color)
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
- 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
- )
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
- dollars_per_percent=0.0,
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
- dollars_per_percent=float(i.dollars_per_percent),
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
- proj_line += " (LOW CONF — early in week)"
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
- notes = ("LOW CONF — early in week",) if status.low_confidence else ()
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 "", 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]}]