cctally 1.22.4 → 1.23.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.
@@ -0,0 +1,44 @@
1
+ """Pure kernel: alert-axis descriptors + registry + shared severity policy.
2
+
3
+ Single source of truth for axis *metadata* — id, chip/title labels (kept
4
+ byte-identical with dashboard/web/src/lib/alertAxis.ts), the milestone-table
5
+ name used by the dashboard envelope, and the axis-uniform severity policy
6
+ (amber <95 / red >=95). This kernel does NOT own the write/transaction path:
7
+ each axis keeps its own detect-and-arm code in bin/_cctally_record.py. The
8
+ descriptor is the metadata/render contract, not the write engine.
9
+
10
+ Stdlib-only, no I/O at import time. bin/cctally re-exports the public symbols.
11
+ Spec: docs/superpowers/specs/2026-06-01-alerts-axis-registry-projected-pace-design.md
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ # Severity boundary: thresholds at or above this render red, below it amber.
18
+ # Mirrors the legacy hardcoded amber<95 / red>=95 split (axis-uniform v1).
19
+ _SEVERITY_RED_FLOOR = 95
20
+
21
+
22
+ def severity_for(threshold: int) -> str:
23
+ """Map a crossed integer threshold to a severity color ('amber' | 'red')."""
24
+ return "red" if int(threshold) >= _SEVERITY_RED_FLOOR else "amber"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AlertAxisDescriptor:
29
+ """Axis-agnostic metadata shared by the record path + dashboard envelope."""
30
+
31
+ id: str # 'weekly' | 'five_hour' | 'budget' | 'projected'
32
+ chip_label: str # SHOUT form, byte-identical with alertAxis.ts AXIS_CHIP_LABEL
33
+ title_label: str # sentence-case form, byte-identical with AXIS_TITLE_LABEL
34
+ milestone_table: str # SQLite table the dashboard envelope SELECTs from
35
+
36
+
37
+ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
38
+ AlertAxisDescriptor("weekly", "WEEKLY", "Weekly", "percent_milestones"),
39
+ AlertAxisDescriptor("five_hour", "5H-BLOCK", "5h-block", "five_hour_milestones"),
40
+ AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones"),
41
+ AlertAxisDescriptor("projected", "PROJECTED", "Projected", "projected_milestones"),
42
+ )
43
+
44
+ AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}
@@ -242,3 +242,70 @@ def _build_alert_payload_budget(
242
242
  "consumption_pct": float(consumption_pct),
243
243
  },
244
244
  }
245
+
246
+
247
+ def _alert_text_projected(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
248
+ """Build (title, subtitle, body) for a projected-pace alert (#121).
249
+
250
+ These fire on the WEEK-AVERAGE projection — what you're tracking toward at
251
+ the current week-average pace — NOT on an actual crossing. The text carries
252
+ an explicit "(projection)" / "on current pace" cue so a user never confuses
253
+ a projected alert with an actual-crossing one (which the weekly/budget
254
+ builders render). The rendered numbers come from the payload (the values
255
+ snapshotted at crossing), never from live config (Codex P0-4). ``tz`` is
256
+ accepted for signature parity with peer ``_alert_text_*`` builders and
257
+ intentionally unused (no instant is rendered, same as ``_alert_text_budget``).
258
+ """
259
+ metric = payload["metric"]
260
+ t = int(payload["threshold"])
261
+ proj = float(payload["projected_value"])
262
+ denom = float(payload["denominator"])
263
+ if metric == "weekly_pct":
264
+ title = f"cctally - projected to reach {t}% this week"
265
+ subtitle = "On current pace (projection)"
266
+ body = f"Projected ~{proj:.0f}% of cap by reset (week-average pace)"
267
+ else: # budget_usd
268
+ title = "cctally - projected to exceed budget"
269
+ subtitle = f"On current pace (projection) - {t}% of budget"
270
+ body = (
271
+ f"Projected ${proj:,.2f} of ${denom:,.2f} budget "
272
+ f"(week-average pace)"
273
+ )
274
+ return title, subtitle, body
275
+
276
+
277
+ def _build_alert_payload_projected(
278
+ *,
279
+ metric: str,
280
+ threshold: int,
281
+ projected_value: float,
282
+ denominator: float,
283
+ week_start_at: str,
284
+ ) -> dict:
285
+ """Build the alert payload for a projected-pace threshold crossing (#121).
286
+
287
+ ``axis: "projected"`` is the fourth alert axis; ``metric`` discriminates
288
+ ``weekly_pct`` (denominator 100.0, "% of cap") from ``budget_usd``
289
+ (denominator = target_usd, "$ of budget"). The frontend renders context
290
+ FROM these row-sourced fields (``metric`` / ``projected_value`` /
291
+ ``denominator``), not from live config that may have changed since crossing
292
+ (Codex P0-4). No ``crossed_at``/``alerted_at`` keys here: the projected
293
+ detector stamps ``alerted_at`` on the DB row itself in the same txn before
294
+ dispatch (set-then-dispatch), and the dashboard envelope reads it from the
295
+ row — mirroring ``_build_alert_payload_budget``'s context-only shape minus
296
+ the redundant timestamp echo.
297
+ """
298
+ return {
299
+ "id": f"projected:{week_start_at}:{metric}:{int(threshold)}",
300
+ "axis": "projected",
301
+ "metric": str(metric),
302
+ "threshold": int(threshold),
303
+ "projected_value": float(projected_value),
304
+ "denominator": float(denominator),
305
+ "context": {
306
+ "week_start_at": week_start_at,
307
+ "metric": str(metric),
308
+ "projected_value": float(projected_value),
309
+ "denominator": float(denominator),
310
+ },
311
+ }
@@ -52,6 +52,7 @@ class BudgetStatus:
52
52
  elapsed_fraction: float # [0, 1]
53
53
  projected_eow_low_usd: float
54
54
  projected_eow_high_usd: float
55
+ week_avg_projection_usd: float # spent + rate_avg*remaining (smooth estimator)
55
56
  verdict: str # "ok" | "warn" | "over"
56
57
  daily_budget_remaining_usd: float # remaining / remaining-days
57
58
  daily_pace_usd: float # current burn $/day (week-average)
@@ -93,6 +94,12 @@ def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
93
94
  spent, remaining_hours, rate_low, rate_high
94
95
  )
95
96
 
97
+ # Smooth week-average end-of-week projection (additive surface field).
98
+ # Distinct from the displayed band (which keys off the high end): this is
99
+ # the conservative week-average value the projected-pace alert axis fires
100
+ # on. spent + rate_avg*remaining (== project_linear collapsed to one rate).
101
+ week_avg_projection_usd = spent + rate_avg * remaining_hours
102
+
96
103
  daily_pace_usd = rate_avg * 24.0
97
104
  daily_budget_remaining_usd = (
98
105
  (remaining_usd / remaining_days) if remaining_days > 0 else remaining_usd
@@ -125,6 +132,7 @@ def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
125
132
  elapsed_fraction=elapsed_fraction,
126
133
  projected_eow_low_usd=projected_low,
127
134
  projected_eow_high_usd=projected_high,
135
+ week_avg_projection_usd=week_avg_projection_usd,
128
136
  verdict=verdict,
129
137
  daily_budget_remaining_usd=daily_budget_remaining_usd,
130
138
  daily_pace_usd=daily_pace_usd,
package/bin/cctally CHANGED
@@ -366,14 +366,22 @@ _build_codex_weekly_parser = _cctally_parser._build_codex_weekly_parser
366
366
  _build_codex_session_parser = _cctally_parser._build_codex_session_parser
367
367
 
368
368
 
369
+ _lib_alert_axes = _load_sibling("_lib_alert_axes")
370
+ severity_for = _lib_alert_axes.severity_for
371
+ AlertAxisDescriptor = _lib_alert_axes.AlertAxisDescriptor
372
+ AXIS_REGISTRY = _lib_alert_axes.AXIS_REGISTRY
373
+ AXIS_BY_ID = _lib_alert_axes.AXIS_BY_ID
374
+
369
375
  _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
370
376
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
371
377
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
372
378
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
379
+ _alert_text_projected = _lib_alerts_payload._alert_text_projected
373
380
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
374
381
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
375
382
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
376
383
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
384
+ _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
377
385
 
378
386
  _lib_five_hour = _load_sibling("_lib_five_hour")
379
387
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
@@ -799,6 +807,8 @@ _PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
799
807
  _normalize_percent = _cctally_record._normalize_percent
800
808
  maybe_record_milestone = _cctally_record.maybe_record_milestone
801
809
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
810
+ maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
811
+ _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
802
812
  _compute_block_totals = _cctally_record._compute_block_totals
803
813
  maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
804
814
  cmd_record_usage = _cctally_record.cmd_record_usage
@@ -2053,6 +2063,8 @@ get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for
2053
2063
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2054
2064
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2055
2065
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2066
+ insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2067
+ _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2056
2068
  _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2057
2069
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2058
2070