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.
- package/CHANGELOG.md +20 -0
- package/bin/_cctally_alerts.py +133 -24
- package/bin/_cctally_config.py +195 -14
- package/bin/_cctally_core.py +102 -2
- package/bin/_cctally_dashboard.py +277 -62
- package/bin/_cctally_forecast.py +25 -3
- package/bin/_cctally_milestones.py +68 -0
- package/bin/_cctally_parser.py +10 -2
- package/bin/_cctally_record.py +470 -137
- package/bin/_cctally_tui.py +1 -0
- package/bin/_lib_alert_axes.py +53 -0
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/cctally +17 -0
- package/dashboard/static/assets/{index-BxmaYT1y.css → index-CsqqtRBB.css} +1 -1
- package/dashboard/static/assets/index-DwuW39Tv.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CLcd-Tnm.js +0 -18
|
@@ -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
|
+
}
|
package/bin/_lib_budget.py
CHANGED
|
@@ -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
|
|