cctally 1.23.0 → 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 CHANGED
@@ -5,6 +5,15 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.24.0] - 2026-06-02
9
+
10
+ ### Added
11
+ - **Threshold alerts now dispatch cross-platform, not just on macOS.** Alongside the existing macOS `osascript` Notification Center popup, alerts can now fire via Linux `notify-send` (with the severity mapped to a `-u low|normal|critical` urgency token and a `--` end-of-options guard against option-injection) or via a fully custom command you configure — so Windows / WSL / headless setups can finally wire up a real popup. The backend is picked automatically per host (`auto`: a custom command if you've set one, else `osascript` on macOS, else `notify-send` on Linux, else no popup), and `cctally alerts test` now prints a `notifier: <resolved>` line so you can see exactly which backend will fire before relying on a real crossing. The dispatch decision lives in a new pure, fully-unit-tested kernel (`bin/_lib_alert_dispatch.py`) and every spawn is `shell=False` with the arg-list form, so alert text containing `$(...)`, `;`, or `&&` is passed as one literal argument and can never inject a shell command.
12
+ - **A new `alerts.command_template` config key lets you run any command on an alert.** Set it to a JSON argv list — e.g. `cctally config set alerts.command_template '["notify-send","-u","{urgency}","{title}","{body}"]'` — and cctally spawns that command (shell-free) on every crossing, substituting the documented `{title}`/`{subtitle}`/`{body}`/`{severity}`/`{urgency}`/`{axis}`/`{threshold}`/`{metric}` tokens (unmatched braces stay literal; a missing value substitutes as empty). This is trusted local execution (you own your config), and under the default `auto` notifier a set template takes over dispatch on every OS, so you can route alerts to a webhook, a logger, a phone push, or any tool you like. The companion `alerts.notifier` key (`auto` / `osascript` / `notify-send` / `command` / `none`) pins the backend explicitly when you don't want auto-detection, and both keys are editable from the dashboard Settings overlay (notifier dropdown) as well as `cctally config set`.
13
+
14
+ ### Changed
15
+ - **Alert severity is now a 3-tier model (info / warn / critical) instead of the previous two-color split.** A crossed threshold below 90% is `info` (indigo), 90–99% is `warn` (amber), and 100%+ is `critical` (red); this single mapping drives the dashboard toast color, the "Recent alerts" panel chip, and the Linux `notify-send` urgency token, and it is kept byte-identical between the Python authority (`bin/_lib_alert_axes.py::severity_for`) and the dashboard (`alertSeverity` in `dashboard/web/src/lib/alertAxis.ts`), with legacy `amber`/`red` tokens from an older backend normalizing to `warn`/`critical` on read. The `alerts.log` audit file gains a 7th tab-delimited column carrying this severity on every dispatch line. No action needed on upgrade; existing alert config and thresholds are unchanged.
16
+
8
17
  ## [1.23.0] - 2026-06-02
9
18
 
10
19
  ### Added
@@ -47,6 +47,7 @@ import datetime as dt
47
47
  import importlib.util as _ilu
48
48
  import os
49
49
  import pathlib
50
+ import shutil
50
51
  import subprocess
51
52
  import sys
52
53
 
@@ -76,6 +77,24 @@ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_h
76
77
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
77
78
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
78
79
 
80
+ # Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
81
+ # pure (parameterized on platform + which_on_path); this module is the I/O glue
82
+ # that injects the real sys.platform / shutil.which and spawns with shell=False.
83
+ _lib_alert_axes = _load_lib("_lib_alert_axes")
84
+ severity_for = _lib_alert_axes.severity_for
85
+ _lib_alert_dispatch = _load_lib("_lib_alert_dispatch")
86
+ resolve_notifier = _lib_alert_dispatch.resolve_notifier
87
+ build_command = _lib_alert_dispatch.build_command
88
+ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
89
+
90
+
91
+ # `load_config` STAYS a shim that bounces through cctally's namespace (mirrors
92
+ # bin/_cctally_record.py): production monkeypatches `cctally.load_config`, and
93
+ # the dispatch tests patch this module-level name directly. Its natural home is
94
+ # _cctally_config; a direct import would silently bypass those patches.
95
+ def load_config(*args, **kwargs):
96
+ return sys.modules["cctally"].load_config(*args, **kwargs)
97
+
79
98
 
80
99
  # === Honest imports from extracted homes ===================================
81
100
  # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
@@ -103,14 +122,26 @@ def _dispatch_alert_notification(
103
122
  popen_factory=subprocess.Popen,
104
123
  mode: str = "real",
105
124
  tz: "object | None" = None,
125
+ platform: "str | None" = None,
126
+ which_on_path=None,
106
127
  ) -> str:
107
- """Spawn osascript to display a macOS notification (non-blocking, best-effort).
128
+ """Dispatch a notification for a crossed threshold (non-blocking, best-effort).
129
+
130
+ Picks the active notifier (osascript / notify-send / a config-driven
131
+ command_template / none) via the pure ``_lib_alert_dispatch`` kernel, builds
132
+ its exact arg-list, and spawns it with ``shell=False``. Returns one of:
133
+ ``"queued"`` Popen succeeded
134
+ ``"no_notifier:none"`` auto/none resolved to no popup on this host
135
+ ``"no_notifier:unavailable"`` an explicit osascript/notify-send is missing
136
+ ``"spawn_error: <ExcType>: <msg>"`` Popen raised
137
+ Writes EXACTLY ONE line to ``alerts.log`` with the terminal status PLUS the
138
+ crossing's 3-tier severity as a trailing column. Never raises: the config
139
+ read, Popen-spawn failures, and log-write failures are all swallowed so the
140
+ dispatch contract stays independent of the OS / FS / user-config state.
108
141
 
109
- Returns ``"queued"`` on successful Popen, ``"spawn_error: <ExcType>: <msg>"``
110
- on failure. Writes EXACTLY ONE line to ``alerts.log`` with the terminal
111
- status (no contradictory pre-/post-Popen log pair). Never raises:
112
- Popen-spawn failures and log-write failures are both swallowed so the
113
- dispatch contract stays independent of the OS / FS state.
142
+ ``platform`` (sys.platform-style) and ``which_on_path`` (name -> bool) are
143
+ injectable so every OS branch + the no-notifier paths are testable from any
144
+ host; both default to the real ``sys.platform`` / ``shutil.which``.
114
145
 
115
146
  Production callers ignore the return value (fire-and-forget); test
116
147
  callers assert on it via an injected ``popen_factory``.
@@ -149,27 +180,64 @@ def _dispatch_alert_notification(
149
180
  f"axis={axis} threshold={payload.get('threshold')}",
150
181
  )
151
182
 
152
- script = (
153
- f'display notification "{_escape_applescript_string(body)}"'
154
- f' with title "{_escape_applescript_string(title)}"'
155
- f' subtitle "{_escape_applescript_string(subtitle)}"'
183
+ # Severity (3-tier) drives both the notify-send urgency token and the
184
+ # trailing log column. A missing threshold (defensive — shouldn't happen for
185
+ # a real crossing) floors at "info".
186
+ threshold = payload.get("threshold")
187
+ try:
188
+ severity = severity_for(int(threshold)) if threshold is not None else "info"
189
+ except (TypeError, ValueError):
190
+ severity = "info"
191
+ urgency = severity_to_urgency(severity)
192
+
193
+ if platform is None:
194
+ platform = sys.platform
195
+ if which_on_path is None:
196
+ which_on_path = lambda name: shutil.which(name) is not None
197
+
198
+ # Guarded so a malformed user config (or a load_config raise) never breaks
199
+ # the never-raise contract: fall back to auto-detect / no custom command.
200
+ try:
201
+ alerts_cfg = _cctally_core._get_alerts_config(load_config())
202
+ except Exception:
203
+ alerts_cfg = {"notifier": "auto", "command_template": None}
204
+
205
+ notifier = resolve_notifier(
206
+ alerts_cfg, platform=platform, which_on_path=which_on_path
207
+ )
208
+ args = build_command(
209
+ notifier,
210
+ title=title,
211
+ subtitle=subtitle,
212
+ body=body,
213
+ severity=severity,
214
+ urgency=urgency,
215
+ payload=payload,
216
+ command_template=alerts_cfg.get("command_template"),
156
217
  )
157
218
 
158
219
  status: str
159
- try:
160
- popen_factory(
161
- ["osascript", "-e", script],
162
- stdout=subprocess.DEVNULL,
163
- stderr=subprocess.DEVNULL,
164
- close_fds=True,
165
- start_new_session=True,
166
- )
167
- status = "queued"
168
- except (FileNotFoundError, PermissionError, OSError) as exc:
169
- status = f"spawn_error: {exc.__class__.__name__}: {exc}"
220
+ if args is None:
221
+ # 'none' (auto resolved to no popup, or notifier='none') vs an
222
+ # explicitly-selected native backend that is unavailable on this host.
223
+ selector = alerts_cfg.get("notifier", "auto")
224
+ reason = "unavailable" if selector in ("osascript", "notify-send") else "none"
225
+ status = f"no_notifier:{reason}"
226
+ else:
227
+ try:
228
+ popen_factory(
229
+ args,
230
+ stdout=subprocess.DEVNULL,
231
+ stderr=subprocess.DEVNULL,
232
+ close_fds=True,
233
+ start_new_session=True,
234
+ )
235
+ status = "queued"
236
+ except (FileNotFoundError, PermissionError, OSError) as exc:
237
+ status = f"spawn_error: {exc.__class__.__name__}: {exc}"
170
238
 
171
- # SINGLE log line per dispatch attempt (Codex P1#2 fix: no
172
- # contradictory "queued" + "spawn_error" pair for the same call).
239
+ # SINGLE log line per dispatch attempt (Codex P1#2 fix: no contradictory
240
+ # "queued" + "spawn_error" pair). Severity is appended as the 7th column.
173
241
  try:
174
242
  log_path = _alerts_log_path()
175
243
  ctx = payload.get("context") or {}
@@ -181,7 +249,7 @@ def _dispatch_alert_notification(
181
249
  )
182
250
  line = (
183
251
  f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
184
- f"\t{mode}\t{status}\n"
252
+ f"\t{mode}\t{status}\t{severity}\n"
185
253
  )
186
254
  with open(log_path, "a") as f:
187
255
  f.write(line)
@@ -275,6 +343,20 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
275
343
  block_cost_usd=1.23,
276
344
  primary_model="claude-sonnet-4-6",
277
345
  )
346
+ # Resolve and report the active notifier for display BEFORE dispatch — the
347
+ # config read is guarded the same way `_dispatch_alert_notification` guards
348
+ # its own (so a malformed config never crashes `alerts test`). This is
349
+ # purely informational; the dispatch path re-resolves independently.
350
+ try:
351
+ alerts_cfg = _cctally_core._get_alerts_config(load_config())
352
+ except Exception:
353
+ alerts_cfg = {"notifier": "auto", "command_template": None}
354
+ notifier = resolve_notifier(
355
+ alerts_cfg,
356
+ platform=sys.platform,
357
+ which_on_path=lambda name: shutil.which(name) is not None,
358
+ )
359
+ print(f"notifier: {notifier}")
278
360
  status = _dispatch_alert_notification(payload, mode="test")
279
361
  if status == "queued":
280
362
  print("Test alert dispatched (mode=test). Check Notification Center.")
@@ -298,6 +298,8 @@ ALLOWED_CONFIG_KEYS = (
298
298
  "display.tz",
299
299
  "alerts.enabled",
300
300
  "alerts.projected_enabled",
301
+ "alerts.notifier",
302
+ "alerts.command_template",
301
303
  "dashboard.bind",
302
304
  "update.check.enabled",
303
305
  "update.check.ttl_hours",
@@ -414,6 +416,21 @@ def _config_known_value(config: dict, key: str) -> "object":
414
416
  return bool(_get_alerts_config(config)["projected_enabled"])
415
417
  except c._AlertsConfigError:
416
418
  return False
419
+ if key == "alerts.notifier":
420
+ # Validated dispatch backend (defaults to 'auto' when unset). A corrupt
421
+ # alerts block surfaces the default — mirrors alerts.enabled.
422
+ try:
423
+ return _get_alerts_config(config)["notifier"]
424
+ except c._AlertsConfigError:
425
+ return "auto"
426
+ if key == "alerts.command_template":
427
+ # Validated argv list or None (defaults to None when unset). A corrupt
428
+ # alerts block surfaces the default. The plain-text render path JSON-
429
+ # encodes this so `config get` round-trips through `config set`.
430
+ try:
431
+ return _get_alerts_config(config)["command_template"]
432
+ except c._AlertsConfigError:
433
+ return None
417
434
  if key == "dashboard.bind":
418
435
  # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
419
436
  # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
@@ -505,14 +522,20 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
505
522
  if key is not None and key not in ALLOWED_CONFIG_KEYS:
506
523
  eprint(f"cctally config: unknown config key {key!r}")
507
524
  return 2
525
+ # `alerts.command_template` is JSON-shaped (a list of strings or null), so
526
+ # its real value (including None) must survive into the render layer — the
527
+ # generic None->"" coercion below would break the JSON shape / round-trip.
528
+ def _coerce(k: str, v: "object") -> "object":
529
+ if k == "alerts.command_template":
530
+ return v
531
+ return v if v is not None else ""
532
+
508
533
  pairs: "list[tuple[str, object]]" = []
509
534
  if key is None:
510
535
  for k in ALLOWED_CONFIG_KEYS:
511
- v = _config_known_value(config, k)
512
- pairs.append((k, v if v is not None else ""))
536
+ pairs.append((k, _coerce(k, _config_known_value(config, k))))
513
537
  else:
514
- v = _config_known_value(config, key)
515
- pairs.append((key, v if v is not None else ""))
538
+ pairs.append((key, _coerce(key, _config_known_value(config, key))))
516
539
 
517
540
  if getattr(args, "emit_json", False):
518
541
  # Walk every dot-delimited segment so keys deeper than two
@@ -532,7 +555,12 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
532
555
  for k, v in pairs:
533
556
  # Preserve canonical bool stringification (true/false) so
534
557
  # round-trips via `config set alerts.enabled <plain-text>` work.
535
- if isinstance(v, bool):
558
+ if k == "alerts.command_template":
559
+ # JSON-encoded (list of strings or null) so `config get` output
560
+ # round-trips through `config set alerts.command_template`
561
+ # (which JSON-parses its value).
562
+ rendered = json.dumps(v)
563
+ elif isinstance(v, bool):
536
564
  rendered = "true" if v else "false"
537
565
  elif isinstance(v, list):
538
566
  # Comma-joined so `config get budget.alert_thresholds` output
@@ -653,6 +681,76 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
653
681
  f"alerts.projected_enabled={'true' if normalized else 'false'}"
654
682
  )
655
683
  return 0
684
+ if key == "alerts.notifier":
685
+ # Dispatch backend (Phase B). Plain string; the enum constraint is
686
+ # enforced by the pre-persist _get_alerts_config validation (so we never
687
+ # write a config that fails subsequent reads). Same read-modify-write
688
+ # posture as alerts.enabled (preserves sibling alerts.* keys).
689
+ normalized = raw.strip()
690
+ with config_writer_lock():
691
+ config = _load_config_unlocked()
692
+ existing_alerts = config.get("alerts")
693
+ if existing_alerts is not None and not isinstance(
694
+ existing_alerts, dict
695
+ ):
696
+ print(
697
+ "cctally: alerts config error: alerts must be an object",
698
+ file=sys.stderr,
699
+ )
700
+ return 2
701
+ alerts_block = dict(existing_alerts or {})
702
+ alerts_block["notifier"] = normalized
703
+ try:
704
+ _get_alerts_config({**config, "alerts": alerts_block})
705
+ except _AlertsConfigError as exc:
706
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
707
+ return 2
708
+ config["alerts"] = alerts_block
709
+ save_config(config)
710
+ if getattr(args, "emit_json", False):
711
+ print(json.dumps({"alerts": {"notifier": normalized}}, indent=2))
712
+ else:
713
+ print(f"alerts.notifier={normalized}")
714
+ return 0
715
+ if key == "alerts.command_template":
716
+ # Dispatch argv template (Phase B). JSON-parsed value (a list of strings
717
+ # or null to clear it); the shape + cross-field constraints are enforced
718
+ # by the pre-persist _get_alerts_config validation. Same read-modify-
719
+ # write posture as alerts.enabled (preserves sibling alerts.* keys).
720
+ try:
721
+ parsed = json.loads(raw)
722
+ except (ValueError, TypeError) as exc:
723
+ print(
724
+ f"cctally: alerts.command_template must be JSON (a list of "
725
+ f"strings or null): {exc}",
726
+ file=sys.stderr,
727
+ )
728
+ return 2
729
+ with config_writer_lock():
730
+ config = _load_config_unlocked()
731
+ existing_alerts = config.get("alerts")
732
+ if existing_alerts is not None and not isinstance(
733
+ existing_alerts, dict
734
+ ):
735
+ print(
736
+ "cctally: alerts config error: alerts must be an object",
737
+ file=sys.stderr,
738
+ )
739
+ return 2
740
+ alerts_block = dict(existing_alerts or {})
741
+ alerts_block["command_template"] = parsed
742
+ try:
743
+ _get_alerts_config({**config, "alerts": alerts_block})
744
+ except _AlertsConfigError as exc:
745
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
746
+ return 2
747
+ config["alerts"] = alerts_block
748
+ save_config(config)
749
+ if getattr(args, "emit_json", False):
750
+ print(json.dumps({"alerts": {"command_template": parsed}}, indent=2))
751
+ else:
752
+ print(f"alerts.command_template={json.dumps(parsed)}")
753
+ return 0
656
754
  if key == "dashboard.bind":
657
755
  # Validation rejects whitespace / empty / non-string up front;
658
756
  # write proceeds under config_writer_lock with _load_config_unlocked
@@ -873,15 +971,25 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
873
971
  save_config(config)
874
972
  # idempotent: silent on missing key
875
973
  return 0
876
- if key in ("alerts.enabled", "alerts.projected_enabled"):
974
+ if key in (
975
+ "alerts.enabled",
976
+ "alerts.projected_enabled",
977
+ "alerts.notifier",
978
+ "alerts.command_template",
979
+ ):
877
980
  # Mirror the display.tz branch: writer-lock + _load_config_unlocked
878
981
  # (NOT load_config — fcntl.flock is per-fd so re-entry would
879
982
  # self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
880
983
  # named key preserves any user-customized threshold lists
881
984
  # (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
882
- # enabled/projected_enabled toggle; the read-time validator
883
- # (`_get_alerts_config`) re-applies the canonical default of `False`
884
- # for the missing key on next get.
985
+ # enabled/projected_enabled/notifier/command_template keys. For
986
+ # enabled/projected_enabled/notifier the read-time validator
987
+ # (`_get_alerts_config`) re-applies the canonical default
988
+ # (`False` / `"auto"`) for the missing key on next get. NOT so for
989
+ # command_template when notifier == "command": the cross-field
990
+ # constraint makes notifier="command" REQUIRE a template, so dropping
991
+ # the template would leave a config that _get_alerts_config REJECTS on
992
+ # the next read. The pre-persist guard below catches exactly that case.
885
993
  inner_key = key.split(".", 1)[1]
886
994
  with config_writer_lock():
887
995
  config = _load_config_unlocked()
@@ -890,6 +998,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
890
998
  del block[inner_key]
891
999
  if not block:
892
1000
  config.pop("alerts", None)
1001
+ # Pre-persist guard (mirrors the set branches): unsetting a key
1002
+ # that participates in a cross-field constraint
1003
+ # (alerts.command_template while alerts.notifier == "command")
1004
+ # would leave a config that _get_alerts_config rejects on the
1005
+ # next read. Validate the TOP-LEVEL config (so a pruned/empty
1006
+ # alerts block correctly validates to defaults) and refuse
1007
+ # rather than persist an unreadable config.
1008
+ try:
1009
+ _get_alerts_config(config)
1010
+ except _AlertsConfigError as exc:
1011
+ print(
1012
+ f"cctally: alerts config error: {exc}", file=sys.stderr
1013
+ )
1014
+ return 2
893
1015
  save_config(config)
894
1016
  # idempotent: silent on missing key
895
1017
  return 0
@@ -432,8 +432,14 @@ _ALERTS_CONFIG_VALID_KEYS = {
432
432
  "weekly_thresholds",
433
433
  "five_hour_thresholds",
434
434
  "projected_enabled",
435
+ "notifier",
436
+ "command_template",
435
437
  }
436
438
 
439
+ # Dispatch backends (Phase B). "auto" picks a platform default; "command"
440
+ # routes through alerts.command_template (which it then requires).
441
+ _ALERTS_VALID_NOTIFIERS = ("auto", "osascript", "notify-send", "command", "none")
442
+
437
443
 
438
444
  def _validate_threshold_list(name: str, value: object) -> "list[int]":
439
445
  """Validate one of the alerts threshold lists.
@@ -513,11 +519,47 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
513
519
  f"alerts.projected_enabled must be a JSON boolean, got "
514
520
  f"{type(projected_enabled).__name__}: {projected_enabled!r}"
515
521
  )
522
+ # Dispatch-global keys (Phase B). `notifier` selects the backend;
523
+ # `command_template` is an argv list for the `command` backend (and may be
524
+ # set ahead of switching the backend). The cross-field constraint
525
+ # (notifier='command' requires a template) is enforced last.
526
+ notifier = block.get("notifier", "auto")
527
+ if notifier not in _ALERTS_VALID_NOTIFIERS:
528
+ raise _AlertsConfigError(
529
+ f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
530
+ f"got {notifier!r}"
531
+ )
532
+ command_template = block.get("command_template", None)
533
+ if command_template is not None:
534
+ if not isinstance(command_template, list) or not command_template:
535
+ raise _AlertsConfigError(
536
+ "alerts.command_template must be null or a non-empty list of strings"
537
+ )
538
+ for el in command_template:
539
+ if not isinstance(el, str):
540
+ raise _AlertsConfigError(
541
+ f"alerts.command_template elements must be strings, "
542
+ f"got {type(el).__name__}: {el!r}"
543
+ )
544
+ if "\x00" in el:
545
+ raise _AlertsConfigError(
546
+ "alerts.command_template elements must not contain a NUL byte"
547
+ )
548
+ if not command_template[0].strip():
549
+ raise _AlertsConfigError(
550
+ "alerts.command_template[0] (the program) must not be empty/whitespace"
551
+ )
552
+ if notifier == "command" and command_template is None:
553
+ raise _AlertsConfigError(
554
+ "alerts.notifier='command' requires alerts.command_template to be set"
555
+ )
516
556
  return {
517
557
  "enabled": enabled,
518
558
  "weekly_thresholds": weekly,
519
559
  "five_hour_thresholds": five_hour,
520
560
  "projected_enabled": projected_enabled,
561
+ "notifier": notifier,
562
+ "command_template": command_template,
521
563
  }
522
564
 
523
565
 
@@ -4687,6 +4687,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4687
4687
  "weekly_thresholds": [],
4688
4688
  "five_hour_thresholds": [],
4689
4689
  "projected_enabled": False,
4690
+ # Mirror the dispatch keys so the new alerts_settings lines
4691
+ # (`notifier` / `command_configured`) don't KeyError on a
4692
+ # corrupt config. Safe defaults: no notifier override, no
4693
+ # configured command.
4694
+ "notifier": "auto",
4695
+ "command_template": None,
4690
4696
  }
4691
4697
  # Budget is its OWN config block (issue #19) — source budget fields
4692
4698
  # from ``_get_budget_config`` (the validated ``budget`` block), NOT
@@ -4708,6 +4714,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4708
4714
  # from the validated getters' ``projected_enabled`` (default False).
4709
4715
  "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
4710
4716
  "projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
4717
+ # Alert-dispatch notifier mirror (Phase B). `notifier` is the
4718
+ # validated backend selector ("auto"/"command"/etc.). The raw
4719
+ # `command_template` is NEVER mirrored — it routinely holds secrets
4720
+ # (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
4721
+ # every connected client. We expose only a boolean: is a custom
4722
+ # command configured? (the CLI/config remains the sole writer of the
4723
+ # template itself).
4724
+ "notifier": _alerts_cfg.get("notifier", "auto"),
4725
+ "command_configured": _alerts_cfg.get("command_template") is not None,
4711
4726
  }
4712
4727
 
4713
4728
  # Mirror update-state.json + update-suppress.json into the envelope
@@ -5347,7 +5362,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5347
5362
  sent) is the full computed block from ``_compute_display_block``
5348
5363
  (preserves ``tz`` / ``resolved_tz`` / ``offset_label`` /
5349
5364
  ``offset_seconds`` shape consumers rely on). ``alerts`` (when
5350
- sent) is the full validated block from ``_get_alerts_config``.
5365
+ sent) is the full validated block from ``_get_alerts_config``,
5366
+ except the raw ``command_template`` is redacted to the boolean
5367
+ ``command_configured`` (it routinely holds secrets — webhook URLs
5368
+ / bearer tokens — and the echo is returned to the client; the
5369
+ SSE ``alerts_settings`` mirror redacts identically). Do NOT
5370
+ re-add the raw template to the echo.
5351
5371
  ``saved_at`` is included for backward compat.
5352
5372
  """
5353
5373
  if not self._check_origin_csrf():
@@ -5487,6 +5507,27 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5487
5507
  {"error": "alerts.projected_enabled must be a JSON boolean"},
5488
5508
  )
5489
5509
  return
5510
+ # The dispatch command template is CLI/config-only — never
5511
+ # settable via the dashboard (it routinely holds secrets and the
5512
+ # dashboard echoes settings to the client). Reject it explicitly
5513
+ # rather than silently dropping it.
5514
+ if "command_template" in alerts_block:
5515
+ self._respond_json(
5516
+ 400,
5517
+ {"error": "alerts.command_template is CLI/config-only "
5518
+ "(not settable via the dashboard)"},
5519
+ )
5520
+ return
5521
+ # `notifier` is settable (the backend selector). Structural type
5522
+ # check only; the enum + cross-field rule (command needs a stored
5523
+ # template) is enforced free by `_get_alerts_config(merged)` below.
5524
+ if "notifier" in alerts_block and not isinstance(
5525
+ alerts_block["notifier"], str
5526
+ ):
5527
+ self._respond_json(
5528
+ 400, {"error": "alerts.notifier must be a string"}
5529
+ )
5530
+ return
5490
5531
 
5491
5532
  # Pre-validate budget shape (the structural type check; full
5492
5533
  # cross-key validation runs inside the lock via
@@ -5602,6 +5643,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5602
5643
  merged_alerts["projected_enabled"] = (
5603
5644
  alerts_in["projected_enabled"]
5604
5645
  )
5646
+ if "notifier" in alerts_in:
5647
+ merged_alerts["notifier"] = alerts_in["notifier"]
5605
5648
  merged["alerts"] = merged_alerts
5606
5649
  # Final cross-field validation against the merged block.
5607
5650
  # _AlertsConfigError → 400 (no partial write since
@@ -5703,7 +5746,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5703
5746
  merged, dt.datetime.now(dt.timezone.utc)
5704
5747
  )
5705
5748
  if "alerts" in payload:
5706
- out["alerts"] = _get_alerts_config(merged)
5749
+ # Echo the full validated alerts block (defaults filled) so the
5750
+ # SettingsOverlay can repaint without a follow-up GET — but
5751
+ # redact the raw `command_template` (secrets) the same way the
5752
+ # SSE snapshot mirror does: replace it with a boolean
5753
+ # `command_configured`.
5754
+ _a = dict(_get_alerts_config(merged))
5755
+ _a["command_configured"] = _a.pop("command_template", None) is not None
5756
+ out["alerts"] = _a
5707
5757
  if "budget" in payload:
5708
5758
  # Echo the full validated budget block (defaults filled) so the
5709
5759
  # SettingsOverlay can repaint without a follow-up GET.
@@ -3,7 +3,7 @@
3
3
  Single source of truth for axis *metadata* — id, chip/title labels (kept
4
4
  byte-identical with dashboard/web/src/lib/alertAxis.ts), the milestone-table
5
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:
6
+ (info <90 / warn 90-99 / critical >=100). This kernel does NOT own the write/transaction path:
7
7
  each axis keeps its own detect-and-arm code in bin/_cctally_record.py. The
8
8
  descriptor is the metadata/render contract, not the write engine.
9
9
 
@@ -14,14 +14,23 @@ from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
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
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
20
22
 
21
23
 
22
24
  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
+ """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"
25
34
 
26
35
 
27
36
  @dataclass(frozen=True)