cctally 1.23.0 → 1.25.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 +14 -0
- package/bin/_cctally_alerts.py +128 -24
- package/bin/_cctally_config.py +202 -11
- package/bin/_cctally_core.py +118 -0
- package/bin/_cctally_dashboard.py +193 -26
- package/bin/_cctally_forecast.py +480 -16
- package/bin/_cctally_milestones.py +146 -0
- package/bin/_cctally_parser.py +11 -4
- package/bin/_cctally_project.py +51 -0
- package/bin/_cctally_record.py +227 -1
- package/bin/_lib_alert_axes.py +21 -7
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +19 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
|
@@ -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
|
|
@@ -244,6 +244,76 @@ def _build_alert_payload_budget(
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def _alert_text_project_budget(
|
|
248
|
+
payload: dict, tz: "ZoneInfo | None"
|
|
249
|
+
) -> tuple[str, str, str]:
|
|
250
|
+
"""Build (title, subtitle, body) for a PER-PROJECT equiv-$ budget threshold
|
|
251
|
+
alert (axis ``project_budget``, spec §5.3).
|
|
252
|
+
|
|
253
|
+
Mirrors :func:`_alert_text_budget` but prefixed with the project's basename
|
|
254
|
+
so a user reading the notification knows WHICH project crossed (e.g.
|
|
255
|
+
*"Project foo - $26.00 of $25.00 (104% of budget)"*). The rendered numbers
|
|
256
|
+
come from the payload (snapshotted at crossing), never live config that may
|
|
257
|
+
have changed since (Codex P0-4). ``week_start_at`` is an instant but the
|
|
258
|
+
text doesn't render it, so no ``format_display_dt`` call is needed; ``tz`` is
|
|
259
|
+
accepted for signature parity with peer ``_alert_text_*`` builders and
|
|
260
|
+
intentionally unused (same as ``_alert_text_budget``).
|
|
261
|
+
"""
|
|
262
|
+
threshold = int(payload["threshold"])
|
|
263
|
+
ctx = payload.get("context") or {}
|
|
264
|
+
project = ctx.get("project") or "(project)"
|
|
265
|
+
title = "cctally - project budget"
|
|
266
|
+
subtitle = f"{project} - {threshold}% of budget"
|
|
267
|
+
spent = float(ctx.get("spent_usd") or 0.0)
|
|
268
|
+
budget = float(ctx.get("budget_usd") or 0.0)
|
|
269
|
+
consumption = float(ctx.get("consumption_pct") or 0.0)
|
|
270
|
+
body = (
|
|
271
|
+
f"Project {project} - ${spent:,.2f} of ${budget:,.2f} "
|
|
272
|
+
f"({consumption:.0f}% of budget)"
|
|
273
|
+
)
|
|
274
|
+
return title, subtitle, body
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_alert_payload_project_budget(
|
|
278
|
+
*,
|
|
279
|
+
threshold: int,
|
|
280
|
+
crossed_at_utc: str,
|
|
281
|
+
week_start_at: str,
|
|
282
|
+
project: str,
|
|
283
|
+
project_key: str,
|
|
284
|
+
budget_usd: float,
|
|
285
|
+
spent_usd: float,
|
|
286
|
+
consumption_pct: float,
|
|
287
|
+
) -> dict:
|
|
288
|
+
"""Build the alert payload for a PER-PROJECT equiv-$ budget threshold
|
|
289
|
+
crossing (axis ``project_budget``, the fifth alert axis; spec §5.3).
|
|
290
|
+
|
|
291
|
+
Mirrors :func:`_build_alert_payload_budget` with the project dimension
|
|
292
|
+
added: ``project`` is the collision-disambiguated basename
|
|
293
|
+
(``ProjectKey.display_key``, for human-readable notification text) and
|
|
294
|
+
``project_key`` is the canonical git-root (``ProjectKey.bucket_path``, the
|
|
295
|
+
stable identity dimension of the UNIQUE dedup key). See
|
|
296
|
+
:func:`_build_alert_payload_weekly` for the ``alerted_at == crossed_at``
|
|
297
|
+
rationale (set-then-dispatch invariant). The dashboard envelope (Task 4)
|
|
298
|
+
surfaces this axis in the Recent-alerts panel from the row-sourced context.
|
|
299
|
+
"""
|
|
300
|
+
return {
|
|
301
|
+
"id": f"project_budget:{week_start_at}:{project_key}:{int(threshold)}",
|
|
302
|
+
"axis": "project_budget",
|
|
303
|
+
"threshold": int(threshold),
|
|
304
|
+
"crossed_at": crossed_at_utc,
|
|
305
|
+
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
306
|
+
"context": {
|
|
307
|
+
"week_start_at": week_start_at,
|
|
308
|
+
"project": project,
|
|
309
|
+
"project_key": project_key,
|
|
310
|
+
"budget_usd": float(budget_usd),
|
|
311
|
+
"spent_usd": float(spent_usd),
|
|
312
|
+
"consumption_pct": float(consumption_pct),
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
247
317
|
def _alert_text_projected(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
248
318
|
"""Build (title, subtitle, body) for a projected-pace alert (#121).
|
|
249
319
|
|
package/bin/cctally
CHANGED
|
@@ -376,13 +376,20 @@ _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
|
|
|
376
376
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
377
377
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
378
378
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
379
|
+
_alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
379
380
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
380
381
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
381
382
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
382
383
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
383
384
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
385
|
+
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
384
386
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
385
387
|
|
|
388
|
+
_lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
|
|
389
|
+
resolve_notifier = _lib_alert_dispatch.resolve_notifier
|
|
390
|
+
build_command = _lib_alert_dispatch.build_command
|
|
391
|
+
severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
392
|
+
|
|
386
393
|
_lib_five_hour = _load_sibling("_lib_five_hour")
|
|
387
394
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
388
395
|
_floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
|
|
@@ -623,6 +630,10 @@ cmd_sync_week = _cctally_sync_week.cmd_sync_week
|
|
|
623
630
|
# module-private (no external caller).
|
|
624
631
|
_cctally_project = _load_sibling("_cctally_project")
|
|
625
632
|
cmd_project = _cctally_project.cmd_project
|
|
633
|
+
# Shared per-project cost compute (#19/#121, spec §7.1). Re-exported so the
|
|
634
|
+
# per-project budget display (cmd_budget) and the Task 3 firing path reach it
|
|
635
|
+
# via the cctally namespace.
|
|
636
|
+
_sum_cost_by_project = _cctally_project._sum_cost_by_project
|
|
626
637
|
|
|
627
638
|
# Eager re-export of bin/_cctally_pricing_check.py — `cmd_pricing_check`
|
|
628
639
|
# is invoked via the parser's `set_defaults(func=c.cmd_pricing_check)`,
|
|
@@ -807,6 +818,7 @@ _PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
|
|
|
807
818
|
_normalize_percent = _cctally_record._normalize_percent
|
|
808
819
|
maybe_record_milestone = _cctally_record.maybe_record_milestone
|
|
809
820
|
maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
|
|
821
|
+
maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
|
|
810
822
|
maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
|
|
811
823
|
_weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
|
|
812
824
|
_compute_block_totals = _cctally_record._compute_block_totals
|
|
@@ -1102,6 +1114,11 @@ _render_forecast_terminal = _cctally_forecast._render_forecast_terminal
|
|
|
1102
1114
|
_BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
|
|
1103
1115
|
_cmd_budget_set = _cctally_forecast._cmd_budget_set
|
|
1104
1116
|
_cmd_budget_unset = _cctally_forecast._cmd_budget_unset
|
|
1117
|
+
# Per-project budget set/unset (#19/#121, spec §4.3 / §7).
|
|
1118
|
+
_resolve_project_budget_target = _cctally_forecast._resolve_project_budget_target
|
|
1119
|
+
_cmd_budget_set_project = _cctally_forecast._cmd_budget_set_project
|
|
1120
|
+
_cmd_budget_unset_project = _cctally_forecast._cmd_budget_unset_project
|
|
1121
|
+
_build_project_budget_rows = _cctally_forecast._build_project_budget_rows
|
|
1105
1122
|
_budget_render_unset = _cctally_forecast._budget_render_unset
|
|
1106
1123
|
_budget_verdict_ansi_code = _cctally_forecast._budget_verdict_ansi_code
|
|
1107
1124
|
_budget_render_terminal = _cctally_forecast._budget_render_terminal
|
|
@@ -2063,10 +2080,12 @@ get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for
|
|
|
2063
2080
|
get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
|
|
2064
2081
|
insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
|
|
2065
2082
|
insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
|
|
2083
|
+
insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
|
|
2066
2084
|
insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
|
|
2067
2085
|
_projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
|
|
2068
2086
|
_reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
|
|
2069
2087
|
_reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
|
|
2088
|
+
_reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write # forecast/config/dashboard c. (forward-only project-budget reconcile)
|
|
2070
2089
|
|
|
2071
2090
|
|
|
2072
2091
|
# === Update-banner predicate (spec §4.2) extracted to
|