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
@@ -0,0 +1,685 @@
1
+ """Alert scope derivation — the exact window, provider and account an alert
2
+ describes, read from the fields that alert already retains (#620 S1, D11).
3
+
4
+ Pure leaf: stdlib only, no database access, no clock read, no I/O. Model-pool
5
+ classification delegates to ``_lib_codex_pools`` so that classification keeps
6
+ one home.
7
+
8
+ Two rules shape the whole module.
9
+
10
+ **The window END is derived, never stored.** These rows live in stats.db,
11
+ which returns early at ``STATS_INDEX_EPOCH`` before ``add_column_if_missing``
12
+ is reached, so a ``window_end_at`` column would cost an epoch bump plus
13
+ journal, replay, cutover, rebuild and rederive parity — disproportionate to a
14
+ value the retained fields already determine. Recorded caveat: a future change
15
+ to the ``BUDGET_PERIODS`` definitions would make derivation disagree with fire
16
+ time, and would then require the stamp.
17
+
18
+ **Scope is never read from the alert id.** The client contract states the id
19
+ is opaque and never parsed, the journal contract independently forbids parsing
20
+ event ids, and Claude alert ids omit the account entirely — so the id could not
21
+ carry the scope even if parsing it were permitted. ``derive_alert_scope`` takes
22
+ no id, and ``tests/test_620_alert_scope.py`` asserts structurally that no code
23
+ path here reads one.
24
+
25
+ Returning a bare ``None`` for "no window" was rejected: it cannot carry the
26
+ cause, and a caller cannot distinguish "no window" from "window withheld
27
+ because the account is vendor-wide". ``AlertScope`` follows the #556 typed-
28
+ withholding shape instead — it always returns, and it always says which state
29
+ it is in.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import datetime as dt
34
+ import pathlib
35
+ import sys
36
+ from dataclasses import dataclass
37
+
38
+
39
+ def _load_lib(name):
40
+ cached = sys.modules.get(name)
41
+ if cached is not None:
42
+ return cached
43
+ import importlib.util as _ilu
44
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
45
+ spec = _ilu.spec_from_file_location(name, p)
46
+ mod = _ilu.module_from_spec(spec)
47
+ sys.modules[name] = mod
48
+ spec.loader.exec_module(mod)
49
+ return mod
50
+
51
+
52
+ _lib_codex_pools = _load_lib("_lib_codex_pools")
53
+ # Rendering a derived window for a reader is a display concern, so it goes
54
+ # through the datetime chokepoint like every other user-facing instant.
55
+ _format_display_dt = _load_lib("_lib_display_tz").format_display_dt
56
+
57
+ # Both durable sentinels. `*` marks a vendor-wide row (budget-family and the
58
+ # account-blind project-budget table); `unattributed` is a real account bucket,
59
+ # not a vendor-wide stamp, so it stays account-scoped.
60
+ VENDOR_WIDE_KEY = "*"
61
+
62
+ SUBSCRIPTION_WEEK = dt.timedelta(days=7)
63
+ CALENDAR_WEEK = dt.timedelta(days=7)
64
+ FIVE_HOUR_WINDOW = dt.timedelta(hours=5)
65
+
66
+ ACCOUNT_SCOPE = "account"
67
+ VENDOR_WIDE_SCOPE = "vendor_wide"
68
+
69
+ # Window precision. Every axis but `weekly`'s fallback retains a real instant.
70
+ WINDOW_INSTANT = "instant"
71
+ WINDOW_DAY = "day"
72
+
73
+ # Every axis this kernel knows. `quota` is CLI-only: it is absent from the
74
+ # dashboard alert envelope, so six of the seven reach the client.
75
+ ALERT_AXES = (
76
+ "weekly",
77
+ "five_hour",
78
+ "budget",
79
+ "codex_budget",
80
+ "project_budget",
81
+ "projected",
82
+ "quota",
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AlertScope:
88
+ """What one alert is about. ``available`` is the discriminator.
89
+
90
+ When ``available`` is ``True``, ``withheld_reason`` is ``None`` and both
91
+ window bounds are set. When it is ``False``, ``withheld_reason`` is a
92
+ non-empty sentence a surface can render verbatim, and the bounds are
93
+ ``None``. The other fields are populated as far as the retained evidence
94
+ reaches, so a withheld window can still name its provider.
95
+ """
96
+
97
+ available: bool
98
+ withheld_reason: "str | None"
99
+ provider: "str | None"
100
+ account_key: "str | None"
101
+ account_scope: "str | None"
102
+ model_pool: "str | None"
103
+ cost_basis: "str | None"
104
+ window_start: "dt.datetime | None"
105
+ window_end: "dt.datetime | None"
106
+ # How precisely the retained fields fix the bounds. ``instant`` means the
107
+ # row retained a real clock reading. ``day`` means it retained only a
108
+ # calendar date, so the bounds are that date at UTC midnight and a surface
109
+ # must not state a time or a zone it does not have. Meaningful only when
110
+ # ``available``.
111
+ window_granularity: str = WINDOW_INSTANT
112
+
113
+
114
+ def _withheld(reason, *, provider=None, account_key=None, account_scope=None,
115
+ model_pool=None, cost_basis=None) -> AlertScope:
116
+ return AlertScope(
117
+ available=False,
118
+ withheld_reason=reason,
119
+ provider=provider,
120
+ account_key=account_key,
121
+ account_scope=account_scope,
122
+ model_pool=model_pool,
123
+ cost_basis=cost_basis,
124
+ window_start=None,
125
+ window_end=None,
126
+ )
127
+
128
+
129
+ def _parse_instant(value) -> "dt.datetime | None":
130
+ """Read a retained ISO-8601 instant. A naive value means UTC."""
131
+ if isinstance(value, dt.datetime):
132
+ parsed = value
133
+ elif isinstance(value, str) and value.strip():
134
+ try:
135
+ parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
136
+ except ValueError:
137
+ return None
138
+ else:
139
+ return None
140
+ if parsed.tzinfo is None:
141
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
142
+ return parsed.astimezone(dt.timezone.utc)
143
+
144
+
145
+ def _parse_calendar_day(value) -> "dt.datetime | None":
146
+ """Read a retained ``YYYY-MM-DD`` calendar day as its UTC midnight."""
147
+ if not isinstance(value, str) or not value.strip():
148
+ return None
149
+ try:
150
+ day = dt.date.fromisoformat(value.strip())
151
+ except ValueError:
152
+ return None
153
+ return dt.datetime(day.year, day.month, day.day, tzinfo=dt.timezone.utc)
154
+
155
+
156
+ def _parse_epoch_seconds(value) -> "dt.datetime | None":
157
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
158
+ return None
159
+ try:
160
+ return dt.datetime.fromtimestamp(int(value), tz=dt.timezone.utc)
161
+ except (OverflowError, OSError, ValueError):
162
+ return None
163
+
164
+
165
+ def _add_calendar_month(instant: dt.datetime) -> dt.datetime:
166
+ """Advance one civil month on the instant's own fields.
167
+
168
+ A ``calendar-month`` window start is the local first-of-month converted to
169
+ UTC, so advancing the month field reproduces the next window start exactly
170
+ whenever the zone's offset is unchanged across the boundary. A period that
171
+ straddles a DST transition derives an end one hour away from the instant
172
+ the next window actually starts at; the caveat is recorded in
173
+ ``docs/alerts-gotchas.md``.
174
+ """
175
+ year = instant.year + (1 if instant.month == 12 else 0)
176
+ month = 1 if instant.month == 12 else instant.month + 1
177
+ day = instant.day
178
+ while day > 1:
179
+ try:
180
+ return instant.replace(year=year, month=month, day=day)
181
+ except ValueError:
182
+ day -= 1
183
+ return instant.replace(year=year, month=month, day=1)
184
+
185
+
186
+ def period_window_end(period, window_start) -> "dt.datetime | None":
187
+ """End of the half-open ``[start, end)`` window a budget period names."""
188
+ if window_start is None:
189
+ return None
190
+ if period == "subscription-week":
191
+ return window_start + SUBSCRIPTION_WEEK
192
+ if period == "calendar-week":
193
+ return window_start + CALENDAR_WEEK
194
+ if period == "calendar-month":
195
+ return _add_calendar_month(window_start)
196
+ return None
197
+
198
+
199
+ def _account_scope_for(account_key) -> str:
200
+ return VENDOR_WIDE_SCOPE if account_key == VENDOR_WIDE_KEY else ACCOUNT_SCOPE
201
+
202
+
203
+ def _normalize_account_key(account_key) -> "str | None":
204
+ if account_key is None:
205
+ return None
206
+ text = str(account_key).strip()
207
+ return text or None
208
+
209
+
210
+ def derive_alert_scope(axis, context, account_key=None) -> AlertScope:
211
+ """Derive one alert's scope from ``context`` plus its stamped account.
212
+
213
+ ``axis`` is the alert axis; ``context`` is the alert's retained context
214
+ mapping; ``account_key`` is the top-level stamp the row wrote under. No
215
+ argument names or carries an alert identifier.
216
+ """
217
+ ctx = context if isinstance(context, dict) else {}
218
+ key = _normalize_account_key(account_key)
219
+ handler = _AXIS_HANDLERS.get(axis)
220
+ if handler is None:
221
+ return _withheld(f"unknown alert axis: {axis!r}")
222
+ return handler(ctx, key)
223
+
224
+
225
+ def _scope_weekly(ctx, key) -> AlertScope:
226
+ # A subscription week is anchored to a real reset instant, which is what
227
+ # `percent_milestones.week_start_at` retains and what the envelope and the
228
+ # dispatch payload both publish. `week_start_date` is the same week's
229
+ # calendar label and is all a pre-`week_start_at` row has; falling back to
230
+ # it recovers the week but not the reset hour, so the granularity drops
231
+ # with it rather than a midnight instant being invented.
232
+ start = _parse_instant(ctx.get("week_start_at"))
233
+ granularity = WINDOW_INSTANT
234
+ if start is None:
235
+ start = _parse_calendar_day(ctx.get("week_start_date"))
236
+ granularity = WINDOW_DAY
237
+ common = {
238
+ "provider": "claude",
239
+ "account_key": key,
240
+ "account_scope": _account_scope_for(key),
241
+ "cost_basis": "cumulative_cost_usd",
242
+ }
243
+ if start is None:
244
+ return _withheld(
245
+ "the weekly alert retains no week start, so its window cannot be "
246
+ "derived",
247
+ **common,
248
+ )
249
+ return AlertScope(
250
+ available=True,
251
+ withheld_reason=None,
252
+ model_pool=None,
253
+ window_start=start,
254
+ window_end=start + SUBSCRIPTION_WEEK,
255
+ window_granularity=granularity,
256
+ **common,
257
+ )
258
+
259
+
260
+ def _scope_five_hour(ctx, key) -> AlertScope:
261
+ common = {
262
+ "provider": "claude",
263
+ "account_key": key,
264
+ "account_scope": _account_scope_for(key),
265
+ "cost_basis": "block_cost_usd",
266
+ }
267
+ start = _parse_instant(ctx.get("block_start_at"))
268
+ if start is not None:
269
+ return AlertScope(
270
+ available=True,
271
+ withheld_reason=None,
272
+ model_pool=None,
273
+ window_start=start,
274
+ window_end=start + FIVE_HOUR_WINDOW,
275
+ **common,
276
+ )
277
+ # `five_hour_window_key` is the RESET epoch floored to ten minutes
278
+ # (`_canonical_5h_window_key`), so it names the window's END. Reading it as
279
+ # a start would place the window five hours early. The floor also means the
280
+ # start recovered this way is within ten minutes of the recorded block
281
+ # start, which is why the block join is preferred when it resolves.
282
+ end = _parse_epoch_seconds(ctx.get("five_hour_window_key"))
283
+ if end is None:
284
+ return _withheld(
285
+ "the five-hour alert retains no block start, so its window cannot "
286
+ "be derived",
287
+ **common,
288
+ )
289
+ return AlertScope(
290
+ available=True,
291
+ withheld_reason=None,
292
+ model_pool=None,
293
+ window_start=end - FIVE_HOUR_WINDOW,
294
+ window_end=end,
295
+ **common,
296
+ )
297
+
298
+
299
+ def _scope_budget_family(ctx, key, *, provider, default_period) -> AlertScope:
300
+ period = ctx.get("period") or default_period
301
+ start = _parse_instant(ctx.get("period_start_at")) or _parse_instant(
302
+ ctx.get("week_start_at")
303
+ )
304
+ common = {
305
+ "provider": provider,
306
+ "account_key": key,
307
+ "account_scope": _account_scope_for(key),
308
+ "cost_basis": "spent_usd",
309
+ }
310
+ if start is None:
311
+ return _withheld(
312
+ "the budget alert retains no period start, so its window cannot "
313
+ "be derived",
314
+ **common,
315
+ )
316
+ end = period_window_end(period, start)
317
+ if end is None:
318
+ return _withheld(
319
+ f"the budget alert names no derivable period (got {period!r})",
320
+ **common,
321
+ )
322
+ return AlertScope(
323
+ available=True,
324
+ withheld_reason=None,
325
+ model_pool=None,
326
+ window_start=start,
327
+ window_end=end,
328
+ **common,
329
+ )
330
+
331
+
332
+ def _scope_budget(ctx, key) -> AlertScope:
333
+ return _scope_budget_family(
334
+ ctx, key, provider="claude", default_period="subscription-week"
335
+ )
336
+
337
+
338
+ def _scope_codex_budget(ctx, key) -> AlertScope:
339
+ # No default: Codex has no subscription week, and guessing between the two
340
+ # calendar periods would double or halve the window.
341
+ return _scope_budget_family(
342
+ ctx, key, provider="codex", default_period=None
343
+ )
344
+
345
+
346
+ def _scope_project_budget(ctx, key) -> AlertScope:
347
+ # `project_budget_milestones` is deliberately account-blind and stamped
348
+ # `*`, so the scope is vendor-wide whatever the caller passes. Narrowing it
349
+ # to an account would invent an attribution the row never recorded.
350
+ period = ctx.get("period") or "subscription-week"
351
+ start = _parse_instant(ctx.get("week_start_at")) or _parse_instant(
352
+ ctx.get("period_start_at")
353
+ )
354
+ common = {
355
+ "provider": "claude",
356
+ "account_key": key,
357
+ "account_scope": VENDOR_WIDE_SCOPE,
358
+ "cost_basis": "spent_usd",
359
+ }
360
+ if start is None:
361
+ return _withheld(
362
+ "the project-budget alert retains no window start, so its window "
363
+ "cannot be derived",
364
+ **common,
365
+ )
366
+ end = period_window_end(period, start)
367
+ if end is None:
368
+ return _withheld(
369
+ f"the project-budget alert names no derivable period (got "
370
+ f"{period!r})",
371
+ **common,
372
+ )
373
+ return AlertScope(
374
+ available=True,
375
+ withheld_reason=None,
376
+ model_pool=None,
377
+ window_start=start,
378
+ window_end=end,
379
+ **common,
380
+ )
381
+
382
+
383
+ def _scope_projected(ctx, key) -> AlertScope:
384
+ metric = ctx.get("metric")
385
+ provider = "codex" if metric == "codex_budget_usd" else "claude"
386
+ # `weekly_pct` projects against the percent cap, which retains no dollar
387
+ # basis; the two budget metrics project a dollar figure.
388
+ cost_basis = None if metric == "weekly_pct" else "projected_value"
389
+ common = {
390
+ "provider": provider,
391
+ "account_key": key,
392
+ "account_scope": _account_scope_for(key),
393
+ "cost_basis": cost_basis,
394
+ }
395
+ start = _parse_instant(ctx.get("week_start_at")) or _parse_instant(
396
+ ctx.get("period_start_at")
397
+ )
398
+ if start is None:
399
+ return _withheld(
400
+ "the projected alert retains no period start, so its window "
401
+ "cannot be derived",
402
+ **common,
403
+ )
404
+ # The metric fixes the period for `weekly_pct` — a weekly-percent
405
+ # projection is a subscription week by construction. The budget metrics do
406
+ # not: a Claude budget runs on any of three periods and a Codex budget on
407
+ # either calendar period, so an absent `period` is withheld rather than
408
+ # guessed.
409
+ period = ctx.get("period")
410
+ if period is None and metric == "weekly_pct":
411
+ period = "subscription-week"
412
+ end = period_window_end(period, start)
413
+ if end is None:
414
+ return _withheld(
415
+ f"the projected alert retains no period for metric {metric!r}, so "
416
+ "its window length is unknown",
417
+ **common,
418
+ )
419
+ return AlertScope(
420
+ available=True,
421
+ withheld_reason=None,
422
+ model_pool=None,
423
+ window_start=start,
424
+ window_end=end,
425
+ **common,
426
+ )
427
+
428
+
429
+ def _scope_quota(ctx, key) -> AlertScope:
430
+ source = ctx.get("source")
431
+ provider = str(source) if isinstance(source, str) and source.strip() else None
432
+ pool = _lib_codex_pools.codex_key_model_pool(ctx.get("logical_limit_key"))
433
+ common = {
434
+ "provider": provider,
435
+ "account_key": key,
436
+ "account_scope": _account_scope_for(key),
437
+ # `quota_threshold_events` retains source, root, account, logical
438
+ # limit, slot, duration and reset — and no dollar cost basis. Where a
439
+ # cost is needed it is recomputed, never assumed retained.
440
+ "cost_basis": None,
441
+ }
442
+ end = _parse_instant(ctx.get("resets_at_utc"))
443
+ minutes = ctx.get("window_minutes")
444
+ if end is None:
445
+ return _withheld(
446
+ "the quota alert retains no reset instant, so its window cannot "
447
+ "be derived",
448
+ model_pool=pool,
449
+ **common,
450
+ )
451
+ if isinstance(minutes, bool) or not isinstance(minutes, (int, float)):
452
+ return _withheld(
453
+ "the quota alert retains no window duration, so its window start "
454
+ "cannot be derived",
455
+ model_pool=pool,
456
+ **common,
457
+ )
458
+ if int(minutes) <= 0:
459
+ return _withheld(
460
+ f"the quota alert names a non-positive window duration "
461
+ f"({int(minutes)} minutes)",
462
+ model_pool=pool,
463
+ **common,
464
+ )
465
+ return AlertScope(
466
+ available=True,
467
+ withheld_reason=None,
468
+ model_pool=pool,
469
+ window_start=end - dt.timedelta(minutes=int(minutes)),
470
+ window_end=end,
471
+ **common,
472
+ )
473
+
474
+
475
+ _AXIS_HANDLERS = {
476
+ "weekly": _scope_weekly,
477
+ "five_hour": _scope_five_hour,
478
+ "budget": _scope_budget,
479
+ "codex_budget": _scope_codex_budget,
480
+ "project_budget": _scope_project_budget,
481
+ "projected": _scope_projected,
482
+ "quota": _scope_quota,
483
+ }
484
+
485
+
486
+ # ─────────────────────────── the remediation idiom ──────────────────────────
487
+ # One affordance shape for every warning state in the product, alert bodies
488
+ # and CLI reports alike. `doctor` already renders `→ <remediation>`; this keeps
489
+ # the arrow and fixes the verb so a reader learns exactly one form.
490
+
491
+ def inclusive_last_day(window_end: dt.datetime) -> "dt.date":
492
+ """The last calendar day a half-open ``[start, end)`` window touches.
493
+
494
+ `--since` / `--until` selectors are inclusive whole days, so a window
495
+ ending at 2026-06-02 14:00 still contains fourteen hours of 2026-06-02 and
496
+ must name it. Subtracting a whole day is only correct when the end lands
497
+ exactly on midnight, which a subscription week anchored to a real reset
498
+ instant almost never does.
499
+ """
500
+ return (window_end - dt.timedelta(microseconds=1)).date()
501
+
502
+
503
+ def format_scope_detail(provider, window_start, window_end, tz,
504
+ granularity=WINDOW_INSTANT) -> "str | None":
505
+ """The scope statement a next-step line ends with.
506
+
507
+ Both instants go through ``format_display_dt`` (chokepoint rule); only the
508
+ end carries the zone label, because both bounds are in the same zone. A
509
+ command's own arguments are NOT rendered here — those stay in UTC, which
510
+ is what the selectors they feed accept.
511
+
512
+ ``WINDOW_DAY`` renders bare calendar dates and no zone label. The bounds
513
+ then came from a retained ``YYYY-MM-DD``, so a clock reading would be
514
+ invented; and converting that day's UTC midnight into a non-UTC display
515
+ zone would move the printed date off the calendar day the row recorded
516
+ (the same shift ``_alert_text_weekly`` avoids for ``week_start_date``).
517
+ """
518
+ parts = []
519
+ if provider:
520
+ parts.append(str(provider))
521
+ if window_start is not None and window_end is not None:
522
+ if granularity == WINDOW_DAY:
523
+ parts.append(f"{window_start:%Y-%m-%d} → {window_end:%Y-%m-%d}")
524
+ else:
525
+ start = _format_display_dt(
526
+ window_start, tz, fmt="%Y-%m-%d %H:%M", suffix=False
527
+ )
528
+ end = _format_display_dt(
529
+ window_end, tz, fmt="%Y-%m-%d %H:%M", suffix=True
530
+ )
531
+ parts.append(f"{start} → {end}")
532
+ return " · ".join(parts) or None
533
+
534
+
535
+ def next_step_line(command: "str | None", *, provider=None, window_start=None,
536
+ window_end=None, tz=None, granularity=WINDOW_INSTANT,
537
+ unavailable_reason: "str | None" = None) -> str:
538
+ """Render the affordance for one warning state, scope statement included.
539
+
540
+ This is the single entry point every warning state uses — CLI reports and
541
+ alert bodies alike — so a reader learns the form once.
542
+ """
543
+ return format_next_step(
544
+ command,
545
+ detail=format_scope_detail(
546
+ provider, window_start, window_end, tz, granularity
547
+ ),
548
+ unavailable_reason=unavailable_reason,
549
+ )
550
+
551
+
552
+ def format_next_step(command: "str | None", *, detail: "str | None" = None,
553
+ unavailable_reason: "str | None" = None) -> str:
554
+ """Render one next-step line.
555
+
556
+ With a ``command``, the line offers it and appends ``detail`` (the scope
557
+ the command addresses) after an em dash. Without one, the line states why
558
+ no scoped explanation is available — a closed or underivable window says
559
+ so and offers nothing, rather than silently substituting a window the
560
+ alert never described.
561
+ """
562
+ if command:
563
+ line = f"→ Run `{command}`"
564
+ return f"{line} — {detail}" if detail else line
565
+ reason = unavailable_reason or "the window this alert describes is not addressable"
566
+ return f"→ No scoped explanation: {reason}"
567
+
568
+
569
+ UNRETAINED_FIVE_HOUR_BLOCK = (
570
+ "the block this alert names is no longer retained, so its five-hour "
571
+ "detail cannot be re-opened"
572
+ )
573
+
574
+ # `cctally budget` bare status resolves `--vendor` and `--period` and then
575
+ # consumes them only in its `set` / `unset` branches: the read path takes the
576
+ # period from config (`budget_cfg["period"]`), and the command carries no
577
+ # historical selector at all. Naming those flags would state a scope the
578
+ # command cannot honour, so the whole budget family offers the bare form.
579
+ LIVE_PERIOD_ONLY_COMMAND = "cctally budget"
580
+
581
+ # `cctally forecast` is the same shape for the same reason: its only time knob
582
+ # is `--as-of`, registered `argparse.SUPPRESS` as a test hook rather than a
583
+ # user-facing selector, so the command reports whichever subscription week is
584
+ # live when it runs.
585
+ LIVE_WEEK_ONLY_COMMAND = "cctally forecast --explain"
586
+
587
+ CLOSED_BUDGET_PERIOD = (
588
+ "the budget period this alert names has already closed, and `cctally "
589
+ "budget` reports only the live period"
590
+ )
591
+
592
+ CLOSED_FORECAST_WEEK = (
593
+ "the week this alert names has already closed, and `cctally forecast` "
594
+ "reports only the live week"
595
+ )
596
+
597
+ # Which command reports the live window only, and what to say once the
598
+ # alert's own window has closed. One table so a new live-window command
599
+ # cannot be classified without also stating its cause.
600
+ _LIVE_WINDOW_ONLY_COMMANDS = {
601
+ LIVE_PERIOD_ONLY_COMMAND: CLOSED_BUDGET_PERIOD,
602
+ LIVE_WEEK_ONLY_COMMAND: CLOSED_FORECAST_WEEK,
603
+ }
604
+
605
+
606
+ def command_reports_the_live_period_only(command) -> bool:
607
+ """True when ``command`` addresses whichever period is live when it runs.
608
+
609
+ Following such a command from an alert whose period has closed would
610
+ report the current window under a line that names the closed one — the
611
+ silent substitution the as-of contract forbids. Deciding that a period HAS
612
+ closed needs a clock, which this module never reads, so the caller does
613
+ the comparison; this states only which commands the question applies to.
614
+ """
615
+ return command in _LIVE_WINDOW_ONLY_COMMANDS
616
+
617
+
618
+ def closed_window_reason(command) -> str:
619
+ """Why a live-window command is withheld once its window has closed."""
620
+ return _LIVE_WINDOW_ONLY_COMMANDS.get(command, CLOSED_BUDGET_PERIOD)
621
+
622
+
623
+ def alert_target_unavailable_reason(axis, context, scope: AlertScope) -> str:
624
+ """Why no command is offered. One place, so every surface says the same.
625
+
626
+ A withheld scope states its own cause. An available scope with no command
627
+ means the window is known but its target is gone, which today is only the
628
+ purged five-hour block.
629
+ """
630
+ if scope.withheld_reason:
631
+ return scope.withheld_reason
632
+ if axis == "five_hour":
633
+ return UNRETAINED_FIVE_HOUR_BLOCK
634
+ return "the window this alert describes is not addressable"
635
+
636
+
637
+ def alert_next_step_command(axis, context, scope: AlertScope) -> "str | None":
638
+ """The command that explains one alert axis, scoped to its own window.
639
+
640
+ Returns ``None`` when the scope is withheld, because every command below
641
+ needs the window the scope failed to derive — except the projected axis,
642
+ whose targets address the live window by construction and so carry no
643
+ window selector.
644
+ """
645
+ ctx = context if isinstance(context, dict) else {}
646
+ if axis == "five_hour" and not _parse_instant(ctx.get("block_start_at")):
647
+ # The window still derives from the retained reset key, but the block
648
+ # row it names has been purged, so `--block-start` would select
649
+ # nothing. Knowing the window and being able to re-open its detail are
650
+ # different claims, and only the second one is what a command asserts.
651
+ return None
652
+ if axis == "projected":
653
+ if ctx.get("metric") in ("budget_usd", "codex_budget_usd"):
654
+ return LIVE_PERIOD_ONLY_COMMAND
655
+ return LIVE_WEEK_ONLY_COMMAND
656
+ if not scope.available:
657
+ return None
658
+ start = scope.window_start
659
+ end = scope.window_end
660
+ if axis == "weekly":
661
+ return f"cctally percent-breakdown --week-start {start:%Y-%m-%d}"
662
+ if axis == "five_hour":
663
+ return f"cctally five-hour-breakdown --block-start {start:%Y-%m-%dT%H:%M}"
664
+ if axis in ("budget", "codex_budget"):
665
+ return LIVE_PERIOD_ONLY_COMMAND
666
+ if axis == "project_budget":
667
+ # `--until` is inclusive, so the half-open end renders as the last
668
+ # calendar day the window touches.
669
+ last_day = inclusive_last_day(end)
670
+ project = ctx.get("project")
671
+ selector = f" --project {project}" if project else ""
672
+ return (
673
+ f"cctally project --since {start:%Y-%m-%d} "
674
+ f"--until {last_day:%Y-%m-%d}{selector}"
675
+ )
676
+ if axis == "quota":
677
+ root = ctx.get("source_root_key")
678
+ limit = ctx.get("logical_limit_key")
679
+ parts = [f"cctally codex quota breakdown --reset-at {end:%Y-%m-%dT%H:%M:%SZ}"]
680
+ if root:
681
+ parts.append(f"--root-key {root}")
682
+ if limit:
683
+ parts.append(f"--limit-key {limit}")
684
+ return " ".join(parts)
685
+ return None