cctally 1.102.0 → 1.104.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +1274 -1389
  2. package/README.md +3 -3
  3. package/bin/_cctally_alerts.py +65 -4
  4. package/bin/_cctally_cache.py +31 -0
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +11 -0
  8. package/bin/_cctally_dashboard_envelope.py +319 -14
  9. package/bin/_cctally_dashboard_share.py +56 -25
  10. package/bin/_cctally_doctor.py +63 -0
  11. package/bin/_cctally_forecast.py +917 -44
  12. package/bin/_cctally_journal.py +153 -5
  13. package/bin/_cctally_parser.py +66 -0
  14. package/bin/_cctally_project.py +535 -10
  15. package/bin/_cctally_quota.py +13 -0
  16. package/bin/_cctally_quota_calibration.py +146 -0
  17. package/bin/_cctally_quota_model.py +1616 -0
  18. package/bin/_cctally_record.py +114 -6
  19. package/bin/_cctally_share.py +16 -8
  20. package/bin/_cctally_statusline.py +34 -0
  21. package/bin/_cctally_tui.py +214 -45
  22. package/bin/_lib_dashboard_settings_contract.py +2 -0
  23. package/bin/_lib_doctor.py +159 -1
  24. package/bin/_lib_forecast.py +337 -43
  25. package/bin/_lib_jsonl.py +102 -5
  26. package/bin/_lib_meter_rate_change.py +294 -0
  27. package/bin/_lib_pricing.py +97 -21
  28. package/bin/_lib_quota_calibration.py +311 -0
  29. package/bin/_lib_quota_copy.py +131 -0
  30. package/bin/_lib_quota_model.py +2333 -0
  31. package/bin/_lib_rederive.py +10 -0
  32. package/bin/_lib_render.py +6 -0
  33. package/bin/_lib_share_templates.py +37 -5
  34. package/bin/_lib_statusline.py +226 -2
  35. package/bin/_lib_view_models.py +30 -12
  36. package/bin/cctally +32 -0
  37. package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
  38. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  39. package/dashboard/static/dashboard.html +2 -2
  40. package/package.json +7 -1
  41. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  42. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
package/README.md CHANGED
@@ -34,9 +34,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
34
34
 
35
35
  Highlights from the `v1.95.5` to `v1.101.0` stable upgrade:
36
36
 
37
- - Dashboard refreshes now release their coherent `cache.db` snapshot before constructing provider views, cutting the measured median read hold from 3.40 seconds to 1.04 seconds while preserving the exact published payload (#617).
38
- - Every dashboard warning state now offers the explanation for its own window. A row in Recent alerts, an alert toast, a warn or over budget block, and a capped forecast each carry a button that opens the surface explaining that warning: a weekly crossing opens the current week, a five-hour crossing opens that block, a calendar-month budget opens that month, a project-budget crossing opens that project's drill-down, and a projected-pace crossing opens the forecast. The window is checked before anything opens, a warning about a week the dashboard is no longer showing states that its window has closed and opens nothing at all, rather than quietly showing you the current week instead. A five-hour block still opens however old it is, as long as the alert recorded which block it was; an alert that recorded no block start says so and opens nothing. On Codex the button says `Open this cycle` rather than `Open this week`, because a Codex quota cycle is what it opens. A project-budget crossing on a machine with more than one account is recorded across all of them, so it states that it cannot be narrowed to one account's projects rather than picking one. Every button is an ordinary control reachable by Tab and activated by Enter or Space, and no keyboard shortcut is claimed (#620).
39
- - The dashboard's Projects table now writes out what its two percentages mean, under the week selector rather than only in a tooltip, a tooltip never appears on a phone. The `Used pp` column is relabelled `Used pp (sum)` and the caption states that it adds each week's attributed usage percentage across the selected window, so it is a sum of percentage points rather than a share of any one week; the caption also names the denominator of the `Cost share` column as total project spend in that same window. No column is added or removed. On an installation with more than one Claude account, both the Projects panel and the Projects table now also state that accounts are folded into one ranking and that their weekly quota percentages are never added together. That sentence appears on the Claude and All views only, because the Codex view ranks Codex projects and folds no Claude accounts at all (#620).
37
+ - Dashboard refreshes release their read lock on `cache.db` sooner, cutting the measured median hold from 3.40 seconds to 1.04. The published data is unchanged.
38
+ - Every dashboard warning carries a button that opens the surface explaining it, the week, the five-hour block, the month, the project, or the forecast. A warning whose window has closed says so and opens nothing.
39
+ - The dashboard's Projects table writes out what its two percentages mean under the week selector, where a phone can read it. `Used pp` is relabelled `Used pp (sum)`, and the caption names the denominator of `Cost share`.
40
40
 
41
41
  [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.101.0)
42
42
  <!-- cctally:latest-stable:end -->
@@ -140,6 +140,56 @@ def _alert_text_quota(payload: dict, _tz) -> tuple[str, str, str]:
140
140
  # dashboard alert envelope, so the CLI line is the only routing it gets.
141
141
  return title, subtitle, _with_next_step(body, payload, _tz)
142
142
 
143
+
144
+ def _alert_text_meter_rate_change(payload: dict, tz) -> tuple[str, str, str]:
145
+ """#661 S2 §6.1: the non-threshold family's own copy.
146
+
147
+ It states the direction and the size of the change, and it states them as
148
+ a rate the provider sets rather than as anything cctally can act on. There
149
+ is no threshold to name, which is the whole reason this family is not an
150
+ `AXIS_REGISTRY` member.
151
+
152
+ Two DELIBERATE divergences from the threshold builders, both recorded in
153
+ `docs/alerts-gotchas.md` (#661 S2 Stage C review, F7b):
154
+
155
+ * The body does not end with `_with_next_step`. That helper derives its
156
+ affordance through `alert_next_step_command`, which branches on
157
+ `AXIS_REGISTRY` axes; this family is deliberately not one, so it has no
158
+ window to scope and no branch to reach. The affordance is stated
159
+ directly instead.
160
+ * This builder and `_alert_text_quota` above it live in this glue module
161
+ rather than in the pure `_lib_alerts_payload` kernel, which holds the
162
+ other five. That is the placement both non-registry families use.
163
+
164
+ `effective_from` IS routed through `format_display_dt`. It is a clock
165
+ instant, not a calendar day, so the carve-out `_alert_text_weekly`
166
+ documents for `week_start_date` does not apply: slicing its first ten
167
+ characters showed a user at UTC+13 or UTC-8 a date one day away from
168
+ their own.
169
+ """
170
+ previous = float(payload.get("previous_units_per_point") or 0.0)
171
+ new = float(payload.get("new_units_per_point") or 0.0)
172
+ provider = str(payload.get("provider") or "claude").capitalize()
173
+ if previous > 0.0 and new > 0.0:
174
+ change = (new - previous) / previous * 100.0
175
+ direction = "less" if change < 0 else "more"
176
+ subtitle = (
177
+ f"each meter point now covers {abs(change):.0f}% {direction} usage")
178
+ else:
179
+ subtitle = "the rate this account is metered at changed"
180
+ raw = str(payload.get("effective_from") or "")
181
+ try:
182
+ effective = _lib_alerts_payload.format_display_dt(
183
+ raw, tz, fmt="%Y-%m-%d", suffix=False) if raw else ""
184
+ except ValueError:
185
+ effective = ""
186
+ body = (
187
+ f"Effective {effective}. Run `cctally quota` for the fitted budget "
188
+ f"and its evidence." if effective else
189
+ "Run `cctally quota` for the fitted budget and its evidence.")
190
+ return f"cctally - {provider} metering rate changed", subtitle, body
191
+
192
+
143
193
  # Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
144
194
  # pure (parameterized on platform + which_on_path); this module is the I/O glue
145
195
  # that injects the real sys.platform / shutil.which and spawns with shell=False.
@@ -293,6 +343,8 @@ def _dispatch_alert_notification(
293
343
  title, subtitle, body = _alert_text_projected(payload, tz)
294
344
  elif axis == "quota":
295
345
  title, subtitle, body = _alert_text_quota(payload, tz)
346
+ elif axis == "meter_rate_change":
347
+ title, subtitle, body = _alert_text_meter_rate_change(payload, tz)
296
348
  else:
297
349
  title, subtitle, body = (
298
350
  "cctally - alert",
@@ -310,10 +362,19 @@ def _dispatch_alert_notification(
310
362
  # trailing log column. A missing threshold (defensive — shouldn't happen for
311
363
  # a real crossing) floors at "info".
312
364
  threshold = payload.get("threshold")
313
- try:
314
- severity = severity_for(int(threshold)) if threshold is not None else "info"
315
- except (TypeError, ValueError):
316
- severity = "info"
365
+ if axis == "meter_rate_change":
366
+ # #661 S2 §6.1: this family carries an EXPLICIT severity, because a
367
+ # rate transition has no percentage threshold for `severity_for` to
368
+ # map. Reading it verbatim is what keeps the three tiers meaningful
369
+ # here; falling through would floor every transition at `info`.
370
+ severity = str(payload.get("severity") or "info")
371
+ if severity not in ("info", "warn", "alarm"):
372
+ severity = "info"
373
+ else:
374
+ try:
375
+ severity = severity_for(int(threshold)) if threshold is not None else "info"
376
+ except (TypeError, ValueError):
377
+ severity = "info"
317
378
  urgency = severity_to_urgency(severity)
318
379
 
319
380
  if platform is None:
@@ -7327,6 +7327,20 @@ def sync_codex_cache(
7327
7327
  model=initial_model,
7328
7328
  total_tokens=initial_total_tokens,
7329
7329
  )
7330
+ if start_offset > 0 and not truncated and not requalified:
7331
+ prior_accounting = conn.execute(
7332
+ "SELECT MAX(timestamp_utc),MAX(line_offset) "
7333
+ "FROM codex_session_entries "
7334
+ "WHERE source_path=? AND line_offset<? ",
7335
+ (path_str, start_offset),
7336
+ ).fetchone()
7337
+ if prior_accounting is not None and prior_accounting[0] is not None:
7338
+ iter_state.last_accounting_timestamp = _parse_anchor_iso(
7339
+ prior_accounting[0])
7340
+ if prior_accounting is not None and prior_accounting[1] is not None:
7341
+ iter_state.generation_transition_pending = (
7342
+ _lib_jsonl._codex_generation_transition_between(
7343
+ path_str, int(prior_accounting[1]), start_offset))
7330
7344
  if (
7331
7345
  prev is not None and not truncated and not requalified
7332
7346
  and prev_native_thread_id is not None
@@ -11223,6 +11237,23 @@ def sync_codex_conversations(
11223
11237
  model=initial_model,
11224
11238
  total_tokens=initial_total_tokens,
11225
11239
  )
11240
+ if start_offset > 0 and not reset_file:
11241
+ try:
11242
+ prior_accounting = conn.execute(
11243
+ "SELECT MAX(timestamp_utc),MAX(line_offset) FROM "
11244
+ "cache_db.codex_session_entries "
11245
+ "WHERE source_path=? AND line_offset<? ",
11246
+ (path_str, start_offset),
11247
+ ).fetchone()
11248
+ except sqlite3.OperationalError:
11249
+ prior_accounting = None
11250
+ if prior_accounting is not None and prior_accounting[0] is not None:
11251
+ state.last_accounting_timestamp = _parse_anchor_iso(
11252
+ prior_accounting[0])
11253
+ if prior_accounting is not None and prior_accounting[1] is not None:
11254
+ state.generation_transition_pending = (
11255
+ _lib_jsonl._codex_generation_transition_between(
11256
+ path_str, int(prior_accounting[1]), start_offset))
11226
11257
  if initial_native and initial_root:
11227
11258
  state.thread = _lib_jsonl.CodexThreadMetadata(
11228
11259
  source_root_key=discovered.source_root_key,
@@ -300,6 +300,7 @@ ALLOWED_CONFIG_KEYS = (
300
300
  "display.tz",
301
301
  "alerts.enabled",
302
302
  "alerts.projected_enabled",
303
+ "alerts.rate_change_enabled",
303
304
  "alerts.notifier",
304
305
  "alerts.command_template",
305
306
  "alerts.quota",
@@ -1006,6 +1007,16 @@ def _config_known_value(config: dict, key: str) -> "object":
1006
1007
  return bool(_get_alerts_config(config)["projected_enabled"])
1007
1008
  except c._AlertsConfigError:
1008
1009
  return False
1010
+ if key == "alerts.rate_change_enabled":
1011
+ # #661 S2 section 6.2's PUSH gate. Recording a metering-rate change is
1012
+ # unconditional; this key gates only the notification. Same posture as
1013
+ # alerts.projected_enabled — validated boolean, default False, and a
1014
+ # corrupt alerts block surfaces the default rather than erroring out of
1015
+ # a plain `config get`.
1016
+ try:
1017
+ return bool(_get_alerts_config(config)["rate_change_enabled"])
1018
+ except c._AlertsConfigError:
1019
+ return False
1009
1020
  if key == "alerts.notifier":
1010
1021
  # Validated dispatch backend (defaults to 'auto' when unset). A corrupt
1011
1022
  # alerts block surfaces the default — mirrors alerts.enabled.
@@ -1468,6 +1479,53 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1468
1479
  f"alerts.projected_enabled={'true' if normalized else 'false'}"
1469
1480
  )
1470
1481
  return 0
1482
+ if key == "alerts.rate_change_enabled":
1483
+ # #661 S2 section 6.2. Identical posture to alerts.projected_enabled
1484
+ # above, including the re-message: _normalize_alerts_enabled_value
1485
+ # hardcodes "alerts.enabled" in its ValueError text, so the branch
1486
+ # names the key the user actually typed.
1487
+ try:
1488
+ normalized = c._normalize_alerts_enabled_value(raw)
1489
+ except ValueError:
1490
+ print(
1491
+ f"cctally: invalid boolean value for "
1492
+ f"alerts.rate_change_enabled: "
1493
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
1494
+ file=sys.stderr,
1495
+ )
1496
+ return 2
1497
+ with config_writer_lock():
1498
+ config = _load_config_unlocked()
1499
+ existing_alerts = config.get("alerts")
1500
+ if existing_alerts is not None and not isinstance(
1501
+ existing_alerts, dict
1502
+ ):
1503
+ print(
1504
+ "cctally: alerts config error: alerts must be an object",
1505
+ file=sys.stderr,
1506
+ )
1507
+ return 2
1508
+ alerts_block = dict(existing_alerts or {})
1509
+ alerts_block["rate_change_enabled"] = normalized
1510
+ try:
1511
+ _get_alerts_config({**config, "alerts": alerts_block})
1512
+ except _AlertsConfigError as exc:
1513
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
1514
+ return 2
1515
+ config["alerts"] = alerts_block
1516
+ save_config(config)
1517
+ if getattr(args, "emit_json", False):
1518
+ print(
1519
+ json.dumps(
1520
+ {"alerts": {"rate_change_enabled": normalized}}, indent=2
1521
+ )
1522
+ )
1523
+ else:
1524
+ print(
1525
+ f"alerts.rate_change_enabled="
1526
+ f"{'true' if normalized else 'false'}"
1527
+ )
1528
+ return 0
1471
1529
  if key == "alerts.notifier":
1472
1530
  # Dispatch backend (Phase B). Plain string; the enum constraint is
1473
1531
  # enforced by the pre-persist _get_alerts_config validation (so we never
@@ -2296,6 +2354,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2296
2354
  if key in (
2297
2355
  "alerts.enabled",
2298
2356
  "alerts.projected_enabled",
2357
+ "alerts.rate_change_enabled",
2299
2358
  "alerts.notifier",
2300
2359
  "alerts.command_template",
2301
2360
  "alerts.quota",
@@ -2305,8 +2364,9 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2305
2364
  # self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
2306
2365
  # named key preserves any user-customized threshold lists
2307
2366
  # (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
2308
- # enabled/projected_enabled/notifier/command_template keys. For
2309
- # enabled/projected_enabled/notifier the read-time validator
2367
+ # enabled/projected_enabled/rate_change_enabled/notifier/
2368
+ # command_template keys. For enabled/projected_enabled/
2369
+ # rate_change_enabled/notifier the read-time validator
2310
2370
  # (`_get_alerts_config`) re-applies the canonical default
2311
2371
  # (`False` / `"auto"`) for the missing key on next get. NOT so for
2312
2372
  # command_template when notifier == "command": the cross-field
@@ -400,7 +400,17 @@ _init_paths_from_env()
400
400
  # removing the shared-memory WAL page map implicated by the retained corruption
401
401
  # bundles. The one-time rebuild is the mode transition; no stats migration is
402
402
  # added and cache/conversations remain WAL/NORMAL.
403
- STATS_INDEX_EPOCH = 1010
403
+ # 1010 -> 1011 (#661 S2 §6.4): the meter-rate-change event family. Adds
404
+ # `meter_rate_change_events`, a durable forward-only record that one provider
405
+ # metering-rate transition was observed, keyed
406
+ # `(provider, account_key, effective_from)`. It is an epoch bump and NOT a
407
+ # stats migration for the two reasons every bump since 1005 carries: the
408
+ # 13-entry registry is frozen, AND an epoch-current open returns before any
409
+ # schema work, so a `@stats_migration` handler would never run on an upgraded
410
+ # install and the table would simply never appear. `quota_alert_arming` is the
411
+ # precedent the family follows — a journaled STATE record whose boundary
412
+ # survives a rebuild, so history cannot re-fire.
413
+ STATS_INDEX_EPOCH = 1011
404
414
  LEGACY_STATS_HEAD = 13
405
415
 
406
416
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
@@ -965,6 +975,12 @@ _ALERTS_CONFIG_VALID_KEYS = {
965
975
  "weekly_thresholds",
966
976
  "five_hour_thresholds",
967
977
  "projected_enabled",
978
+ # #661 S2 §6.2. The metering-rate-change family's PUSH toggle, and only
979
+ # its push toggle: events are recorded from the first upgrade whatever
980
+ # this says, so every pull surface shows the state with no configuration.
981
+ # It defaults OFF like every other alert toggle, so an upgrade never
982
+ # produces a surprise notification.
983
+ "rate_change_enabled",
968
984
  "notifier",
969
985
  "command_template",
970
986
  }
@@ -1062,6 +1078,17 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
1062
1078
  f"{type(projected_enabled).__name__}: {projected_enabled!r}",
1063
1079
  field="alerts.projected_enabled",
1064
1080
  )
1081
+ # #661 S2 §6.2: the rate-change PUSH toggle, default OFF. Recording is
1082
+ # unconditional and does not consult this — the split is what lets a user
1083
+ # learn that the rate changed without opting in while nothing arrives
1084
+ # unbidden. Bool-validated rather than coerced, like `projected_enabled`.
1085
+ rate_change_enabled = block.get("rate_change_enabled", False)
1086
+ if not isinstance(rate_change_enabled, bool):
1087
+ raise _AlertsConfigError(
1088
+ f"alerts.rate_change_enabled must be a JSON boolean, got "
1089
+ f"{type(rate_change_enabled).__name__}: {rate_change_enabled!r}",
1090
+ field="alerts.rate_change_enabled",
1091
+ )
1065
1092
  # Dispatch-global keys (Phase B). `notifier` selects the backend;
1066
1093
  # `command_template` is an argv list for the `command` backend (and may be
1067
1094
  # set ahead of switching the backend). The cross-field constraint
@@ -1109,6 +1136,7 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
1109
1136
  "weekly_thresholds": weekly,
1110
1137
  "five_hour_thresholds": five_hour,
1111
1138
  "projected_enabled": projected_enabled,
1139
+ "rate_change_enabled": rate_change_enabled,
1112
1140
  "notifier": notifier,
1113
1141
  "command_template": command_template,
1114
1142
  }
@@ -1637,6 +1665,39 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1637
1665
  PRIMARY KEY(source)
1638
1666
  );
1639
1667
 
1668
+ -- #661 S2 §6: a provider metering-rate transition, recorded from the
1669
+ -- first upgrade whether or not notification is enabled (§6.2). The
1670
+ -- row is the durable forward-only latch: its presence is what stops
1671
+ -- the same transition alerting twice, and it survives a stats.db
1672
+ -- rebuild because the journal event that created it carries a
1673
+ -- self-sufficient payload (§6.5).
1674
+ --
1675
+ -- The identity is `(provider, account_key, effective_from)` and the
1676
+ -- promise is stated at exactly that precision — once per exact key,
1677
+ -- not "once per provider regime". `reduce_state` closes and
1678
+ -- re-creates regimes on a fingerprint change and a revised detector
1679
+ -- can select a different `effective_from` for the same underlying
1680
+ -- transition; separately, R8 can turn a formerly merged identity into
1681
+ -- a real account key. Both produce a new key for a transition a human
1682
+ -- would call the same one. The FINGERPRINT stays out of the key
1683
+ -- deliberately: including it would re-alert on our own algorithm
1684
+ -- revisions.
1685
+ CREATE TABLE IF NOT EXISTS meter_rate_change_events (
1686
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1687
+ provider TEXT NOT NULL,
1688
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1689
+ effective_from TEXT NOT NULL,
1690
+ previous_units_per_point REAL NOT NULL,
1691
+ new_units_per_point REAL NOT NULL,
1692
+ severity TEXT NOT NULL,
1693
+ detected_at_utc TEXT NOT NULL,
1694
+ created_at_utc TEXT NOT NULL,
1695
+ notified_at TEXT,
1696
+ UNIQUE(provider, account_key, effective_from)
1697
+ );
1698
+ CREATE INDEX IF NOT EXISTS idx_meter_rate_change_events_key
1699
+ ON meter_rate_change_events(provider, account_key, effective_from);
1700
+
1640
1701
  CREATE TABLE IF NOT EXISTS quota_alert_arming (
1641
1702
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1642
1703
  source TEXT NOT NULL,
@@ -581,6 +581,7 @@ from _cctally_dashboard_envelope import (
581
581
  _envelope_rows_project_budget,
582
582
  _ENVELOPE_AXIS_MAPPERS,
583
583
  _build_alerts_envelope_array,
584
+ _build_meter_rate_change_array,
584
585
  _model_breakdowns_to_models,
585
586
  )
586
587
 
@@ -8282,6 +8283,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8282
8283
  merged_alerts["projected_enabled"] = (
8283
8284
  alerts_in["projected_enabled"]
8284
8285
  )
8286
+ # #661 S2 section 6.2's PUSH gate. Assigned VERBATIM, like
8287
+ # every sibling here: the boolean rule lives in
8288
+ # `_get_alerts_config`, which runs against the merged block
8289
+ # below, so a `bool()` here would coerce "yes" to True and
8290
+ # destroy the evidence before the canonical validator could
8291
+ # refuse it.
8292
+ if "rate_change_enabled" in alerts_in:
8293
+ merged_alerts["rate_change_enabled"] = (
8294
+ alerts_in["rate_change_enabled"]
8295
+ )
8285
8296
  if "notifier" in alerts_in:
8286
8297
  merged_alerts["notifier"] = alerts_in["notifier"]
8287
8298
  merged["alerts"] = merged_alerts