cctally 1.22.4 → 1.24.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,53 @@
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
+ (info <90 / warn 90-99 / critical >=100). 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 bands (Phase B): info < warn floor <= warn < critical floor <= critical.
18
+ # The top tier means "hit the ceiling" (100% weekly = rate-limited, budget 100% =
19
+ # over). Maps onto notify-send's low/normal/critical urgency levels.
20
+ _SEVERITY_WARN_FLOOR = 90
21
+ _SEVERITY_CRITICAL_FLOOR = 100
22
+
23
+
24
+ def severity_for(threshold: int) -> str:
25
+ """Map a crossed integer threshold to a 3-tier severity
26
+ ('info' | 'warn' | 'critical'). Axis-uniform; the single authority kept
27
+ byte-identical with dashboard/web/src/lib/alertAxis.ts::alertSeverity."""
28
+ t = int(threshold)
29
+ if t >= _SEVERITY_CRITICAL_FLOOR:
30
+ return "critical"
31
+ if t >= _SEVERITY_WARN_FLOOR:
32
+ return "warn"
33
+ return "info"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AlertAxisDescriptor:
38
+ """Axis-agnostic metadata shared by the record path + dashboard envelope."""
39
+
40
+ id: str # 'weekly' | 'five_hour' | 'budget' | 'projected'
41
+ chip_label: str # SHOUT form, byte-identical with alertAxis.ts AXIS_CHIP_LABEL
42
+ title_label: str # sentence-case form, byte-identical with AXIS_TITLE_LABEL
43
+ milestone_table: str # SQLite table the dashboard envelope SELECTs from
44
+
45
+
46
+ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
47
+ AlertAxisDescriptor("weekly", "WEEKLY", "Weekly", "percent_milestones"),
48
+ AlertAxisDescriptor("five_hour", "5H-BLOCK", "5h-block", "five_hour_milestones"),
49
+ AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones"),
50
+ AlertAxisDescriptor("projected", "PROJECTED", "Projected", "projected_milestones"),
51
+ )
52
+
53
+ AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}
@@ -0,0 +1,141 @@
1
+ """Pure kernel: cross-platform alert dispatch decisions + severity->urgency.
2
+
3
+ resolve_notifier() picks the active notifier; build_command() returns the exact
4
+ arg-list to spawn (osascript / notify-send / a config-driven command_template),
5
+ or None ("no popup; log + dashboard only"). NO I/O at import time, and the
6
+ decision is parameterized on `platform` + `which_on_path` so every OS branch is
7
+ unit-testable from any host. bin/_cctally_alerts.py is the I/O glue: it injects
8
+ the real sys.platform / shutil.which and spawns the result with shell=False.
9
+
10
+ Trust model (spec Q3): alerts.command_template is *trusted local command
11
+ execution* (the user owns config.json). shell=False + the arg-list form block
12
+ alert-text shell-injection (a week label with $(...) or ; is one literal arg);
13
+ the native notify-send path adds a `--` end-of-options delimiter against
14
+ option-injection.
15
+
16
+ Stdlib-only. bin/cctally re-exports the public symbols.
17
+ Spec: docs/superpowers/specs/2026-06-02-alerts-dispatch-severity-seams-design.md
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import importlib.util as _ilu
22
+ import pathlib
23
+ import sys
24
+
25
+ # notify-send urgency tokens keyed by the 3-tier severity from
26
+ # _lib_alert_axes.severity_for.
27
+ _SEVERITY_URGENCY = {"info": "low", "warn": "normal", "critical": "critical"}
28
+
29
+ # The documented command_template substitution tokens (one-pass, no re-scan).
30
+ _TEMPLATE_TOKENS = frozenset(
31
+ {"title", "subtitle", "body", "severity", "urgency", "axis", "threshold", "metric"}
32
+ )
33
+
34
+
35
+ def _load_lib(name: str):
36
+ cached = sys.modules.get(name)
37
+ if cached is not None:
38
+ return cached
39
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
40
+ spec = _ilu.spec_from_file_location(name, p)
41
+ mod = _ilu.module_from_spec(spec)
42
+ sys.modules[name] = mod
43
+ spec.loader.exec_module(mod)
44
+ return mod
45
+
46
+
47
+ def severity_to_urgency(severity: str) -> str:
48
+ """Map a 3-tier severity to a notify-send urgency token."""
49
+ return _SEVERITY_URGENCY.get(severity, "normal")
50
+
51
+
52
+ def resolve_notifier(cfg: dict, *, platform: str, which_on_path) -> str:
53
+ """Return the effective notifier id for this host + validated alerts cfg.
54
+
55
+ `platform` is a sys.platform-style string; `which_on_path` is a callable
56
+ name -> bool. An explicitly-selected native notifier that is unavailable on
57
+ this host downgrades to 'none' (rather than being spawned and failing).
58
+ """
59
+ selector = cfg.get("notifier", "auto")
60
+ template = cfg.get("command_template")
61
+ is_darwin = platform == "darwin"
62
+ is_linux = platform.startswith("linux")
63
+
64
+ if selector == "osascript":
65
+ return "osascript" if is_darwin else "none"
66
+ if selector == "notify-send":
67
+ return "notify-send" if (is_linux and which_on_path("notify-send")) else "none"
68
+ if selector == "command":
69
+ return "command" if template else "none"
70
+ if selector == "none":
71
+ return "none"
72
+ # auto
73
+ if template:
74
+ return "command"
75
+ if is_darwin:
76
+ return "osascript"
77
+ if is_linux and which_on_path("notify-send"):
78
+ return "notify-send"
79
+ return "none"
80
+
81
+
82
+ def _substitute_tokens(arg: str, values: dict) -> str:
83
+ """One-pass left-to-right substitution: replace only the documented
84
+ {tokens}; leave every other character (including stray/unmatched braces)
85
+ literal; substituted values are NOT re-scanned. Missing keys -> ""."""
86
+ out = []
87
+ i, n = 0, len(arg)
88
+ while i < n:
89
+ ch = arg[i]
90
+ if ch == "{":
91
+ close = arg.find("}", i + 1)
92
+ if close != -1 and arg[i + 1:close] in _TEMPLATE_TOKENS:
93
+ out.append(str(values.get(arg[i + 1:close], "")))
94
+ i = close + 1
95
+ continue
96
+ out.append(ch)
97
+ i += 1
98
+ return "".join(out)
99
+
100
+
101
+ def build_command(
102
+ notifier: str,
103
+ *,
104
+ title: str,
105
+ subtitle: str,
106
+ body: str,
107
+ severity: str,
108
+ urgency: str,
109
+ payload: dict,
110
+ command_template,
111
+ ):
112
+ """Return the arg-list to spawn for `notifier`, or None ('no popup')."""
113
+ if notifier == "osascript":
114
+ esc = _load_lib("_lib_alerts_payload")._escape_applescript_string
115
+ script = (
116
+ f'display notification "{esc(body)}"'
117
+ f' with title "{esc(title)}"'
118
+ f' subtitle "{esc(subtitle)}"'
119
+ )
120
+ return ["osascript", "-e", script]
121
+ if notifier == "notify-send":
122
+ folded = f"{subtitle}\n{body}" if subtitle.strip() else body
123
+ return ["notify-send", "-u", urgency, "--", title, folded]
124
+ if notifier == "command":
125
+ if not command_template:
126
+ return None
127
+ # A payload key present-but-None (e.g. a weekly payload's metric=None)
128
+ # substitutes as "" — same surface as an absent key.
129
+ def _pv(key):
130
+ v = payload.get(key)
131
+ return "" if v is None else v
132
+
133
+ values = {
134
+ "title": title, "subtitle": subtitle, "body": body,
135
+ "severity": severity, "urgency": urgency,
136
+ "axis": _pv("axis"),
137
+ "threshold": _pv("threshold"),
138
+ "metric": _pv("metric"),
139
+ }
140
+ return [_substitute_tokens(a, values) for a in command_template]
141
+ return None # "none" or unknown -> no popup
@@ -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,27 @@ _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
385
+
386
+ _lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
387
+ resolve_notifier = _lib_alert_dispatch.resolve_notifier
388
+ build_command = _lib_alert_dispatch.build_command
389
+ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
377
390
 
378
391
  _lib_five_hour = _load_sibling("_lib_five_hour")
379
392
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
@@ -799,6 +812,8 @@ _PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
799
812
  _normalize_percent = _cctally_record._normalize_percent
800
813
  maybe_record_milestone = _cctally_record.maybe_record_milestone
801
814
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
815
+ maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
816
+ _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
802
817
  _compute_block_totals = _cctally_record._compute_block_totals
803
818
  maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
804
819
  cmd_record_usage = _cctally_record.cmd_record_usage
@@ -2053,6 +2068,8 @@ get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for
2053
2068
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2054
2069
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2055
2070
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2071
+ insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2072
+ _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2056
2073
  _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2057
2074
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2058
2075