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.
@@ -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
 
@@ -533,12 +575,16 @@ _BUDGET_DEFAULTS = {
533
575
  "alerts_enabled": True, # "on when set"
534
576
  "alert_thresholds": [90, 100],
535
577
  "projected_enabled": False, # projected-pace opt-in (#121); default OFF
578
+ "projects": {}, # per-project weekly $ budgets, keyed by git-root
579
+ "project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
536
580
  }
537
581
  _BUDGET_CONFIG_VALID_KEYS = {
538
582
  "weekly_usd",
539
583
  "alerts_enabled",
540
584
  "alert_thresholds",
541
585
  "projected_enabled",
586
+ "projects",
587
+ "project_alerts_enabled",
542
588
  }
543
589
 
544
590
 
@@ -551,6 +597,7 @@ def _get_budget_config(cfg: dict) -> dict:
551
597
  """
552
598
  out = dict(_BUDGET_DEFAULTS)
553
599
  out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
600
+ out["projects"] = dict(_BUDGET_DEFAULTS["projects"])
554
601
  block = cfg.get("budget") if isinstance(cfg, dict) else None
555
602
  if block is None:
556
603
  return out
@@ -606,6 +653,41 @@ def _get_budget_config(cfg: dict) -> dict:
606
653
  raise _BudgetConfigError("budget.projected_enabled must be a boolean")
607
654
  out["projected_enabled"] = v
608
655
 
656
+ if "projects" in block:
657
+ v = block["projects"]
658
+ if not isinstance(v, dict):
659
+ raise _BudgetConfigError(
660
+ f"budget.projects must be an object, got {type(v).__name__}"
661
+ )
662
+ cleaned: "dict[str, float]" = {}
663
+ for proj_key, proj_val in v.items():
664
+ if not isinstance(proj_key, str):
665
+ raise _BudgetConfigError(
666
+ "budget.projects keys must be strings (canonical git-root paths)"
667
+ )
668
+ # Reuse the weekly_usd numeric rule per value: a non-bool finite
669
+ # number > 0 (bool is an int subclass, so reject it explicitly).
670
+ if isinstance(proj_val, bool) or not isinstance(proj_val, (int, float)):
671
+ raise _BudgetConfigError(
672
+ f"budget.projects values must be numbers, "
673
+ f"got {type(proj_val).__name__} for key {proj_key!r}"
674
+ )
675
+ if not math.isfinite(float(proj_val)) or float(proj_val) <= 0:
676
+ raise _BudgetConfigError(
677
+ f"budget.projects values must be finite numbers > 0, "
678
+ f"got {proj_val!r} for key {proj_key!r}"
679
+ )
680
+ cleaned[proj_key] = float(proj_val)
681
+ out["projects"] = cleaned
682
+
683
+ if "project_alerts_enabled" in block:
684
+ v = block["project_alerts_enabled"]
685
+ if not isinstance(v, bool):
686
+ raise _BudgetConfigError(
687
+ "budget.project_alerts_enabled must be a boolean"
688
+ )
689
+ out["project_alerts_enabled"] = v
690
+
609
691
  return out
610
692
 
611
693
 
@@ -1085,6 +1167,42 @@ def open_db() -> sqlite3.Connection:
1085
1167
  """
1086
1168
  )
1087
1169
 
1170
+ # ── project_budget_milestones (per-project equiv-$ budget crossings) ──────
1171
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
1172
+ # same posture as `budget_milestones` / `projected_milestones` (write-once,
1173
+ # forward-only, framework-untracked). `project_key` is the NEW dimension in
1174
+ # the UNIQUE key: each project crosses each threshold once per week,
1175
+ # independently of every other project (issue #19 / #121, spec §5.1). It
1176
+ # stores the canonical git-root (`ProjectKey.bucket_path`), matched by string
1177
+ # equality against each session entry's resolved git-root. `budget_usd`
1178
+ # snapshots the project's target AT crossing time so the dashboard renders
1179
+ # "$26 of $25" from the ROW, not from live config that may have changed since
1180
+ # (the Codex P0-4 lesson, already baked into `budget_milestones` /
1181
+ # `projected_milestones`). A mid-week quota reset re-anchors `week_start_at`
1182
+ # (new window → fresh rows under the UNIQUE key) — budget-pattern reset
1183
+ # handling, hence NO `reset_event_id` segment column. `alerted_at` is stamped
1184
+ # BEFORE dispatch (set-then-dispatch invariant); NULL = "recorded without
1185
+ # dispatch" (forward-only-from-set reconcile) OR "not yet dispatched", never
1186
+ # "delivery failed". Lives BEFORE the migration dispatcher: a plain CREATE on
1187
+ # a framework-untracked table never touches `schema_migrations`, so the
1188
+ # dispatcher's fresh-install snapshot is unaffected.
1189
+ conn.execute(
1190
+ """
1191
+ CREATE TABLE IF NOT EXISTS project_budget_milestones (
1192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1193
+ week_start_at TEXT NOT NULL,
1194
+ project_key TEXT NOT NULL, -- canonical git-root (bucket_path)
1195
+ threshold INTEGER NOT NULL,
1196
+ budget_usd REAL NOT NULL, -- project's target snapshotted AT crossing
1197
+ spent_usd REAL NOT NULL,
1198
+ consumption_pct REAL NOT NULL,
1199
+ crossed_at_utc TEXT NOT NULL,
1200
+ alerted_at TEXT,
1201
+ UNIQUE(week_start_at, project_key, threshold)
1202
+ )
1203
+ """
1204
+ )
1205
+
1088
1206
  # Migration framework dispatcher. Replaces the prior inline gate stack
1089
1207
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1090
1208
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers
@@ -340,6 +340,12 @@ def _build_alert_payload_projected(*args, **kwargs):
340
340
  return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
341
341
 
342
342
 
343
+ def _build_alert_payload_project_budget(*args, **kwargs):
344
+ return sys.modules["cctally"]._build_alert_payload_project_budget(
345
+ *args, **kwargs
346
+ )
347
+
348
+
343
349
  def _dispatch_alert_notification(*args, **kwargs):
344
350
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
345
351
 
@@ -4252,6 +4258,72 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
4252
4258
  return out
4253
4259
 
4254
4260
 
4261
+ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4262
+ # Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
4263
+ # crossings. Like the global budget axis, project-budget alerts re-anchor
4264
+ # ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
4265
+ # segment — the new window gets fresh rows under
4266
+ # ``UNIQUE(week_start_at, project_key, threshold)``. ``project_key`` is the
4267
+ # canonical git-root (``ProjectKey.bucket_path``); the human-readable chip
4268
+ # context carries the project BASENAME, resolved through the production
4269
+ # ``_resolve_project_key`` (git-root mode) so a moved/deleted repo still
4270
+ # renders its basename from the snapshotted path (no FS dependency on a
4271
+ # live ``.git``). ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
4272
+ # rendered FROM THE ROW (snapshotted at crossing), never live config that
4273
+ # may have changed since (Codex P0-4). The envelope id mirrors the dispatch
4274
+ # payload's ``project_budget:<week_start_at>:<project_key>:<threshold>``
4275
+ # shape (``_build_alert_payload_project_budget``).
4276
+ rows = conn.execute(
4277
+ f"""
4278
+ SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
4279
+ consumption_pct, crossed_at_utc, alerted_at
4280
+ FROM {descriptor.milestone_table}
4281
+ WHERE alerted_at IS NOT NULL
4282
+ ORDER BY alerted_at DESC
4283
+ LIMIT ?
4284
+ """,
4285
+ (limit,),
4286
+ ).fetchall()
4287
+ c = _cctally()
4288
+ resolve = c._resolve_project_key
4289
+ resolver_cache: dict = {}
4290
+ # Collision-aware labels ACROSS the distinct project_keys in these rows so
4291
+ # two same-basename roots (/work/app + /personal/app) don't both render
4292
+ # "app" — byte-matching the live notification + budget table via the shared
4293
+ # `_project_disambiguate_labels`. Disambiguated across the ROWS (not live
4294
+ # config) to preserve the render-from-the-snapshotted-row invariant above.
4295
+ distinct_keys = sorted({r["project_key"] for r in rows})
4296
+ pkeys = [resolve(k, "git-root", resolver_cache) for k in distinct_keys]
4297
+ disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
4298
+ label_by_key = {
4299
+ distinct_keys[i]: disambig.get(i, pkeys[i].display_key)
4300
+ for i in range(len(distinct_keys))
4301
+ }
4302
+ out: list[dict] = []
4303
+ for r in rows:
4304
+ threshold = int(r["threshold"])
4305
+ project_key = r["project_key"]
4306
+ out.append({
4307
+ "id": (
4308
+ f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
4309
+ ),
4310
+ "axis": descriptor.id,
4311
+ "threshold": threshold,
4312
+ "severity": severity_for(threshold),
4313
+ "crossed_at": r["crossed_at_utc"],
4314
+ "alerted_at": r["alerted_at"],
4315
+ "context": {
4316
+ "week_start_at": r["week_start_at"],
4317
+ "project": label_by_key.get(project_key, project_key),
4318
+ "project_key": project_key,
4319
+ "budget_usd": float(r["budget_usd"]),
4320
+ "spent_usd": float(r["spent_usd"]),
4321
+ "consumption_pct": float(r["consumption_pct"]),
4322
+ },
4323
+ })
4324
+ return out
4325
+
4326
+
4255
4327
  # Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
4256
4328
  # in what order; this table supplies the bespoke heterogeneous row-mapper.
4257
4329
  _ENVELOPE_AXIS_MAPPERS = {
@@ -4259,6 +4331,7 @@ _ENVELOPE_AXIS_MAPPERS = {
4259
4331
  "five_hour": _envelope_rows_five_hour,
4260
4332
  "budget": _envelope_rows_budget,
4261
4333
  "projected": _envelope_rows_projected,
4334
+ "project_budget": _envelope_rows_project_budget,
4262
4335
  }
4263
4336
 
4264
4337
 
@@ -4269,19 +4342,20 @@ def _build_alerts_envelope_array(
4269
4342
  """Return the ``alerts`` array for the SSE snapshot envelope.
4270
4343
 
4271
4344
  Union of ``percent_milestones``, ``five_hour_milestones``,
4272
- ``budget_milestones``, and ``projected_milestones`` rows with
4273
- ``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``,
4274
- capped at ``limit`` (default 100). Single source of truth for both the
4275
- dashboard panel (slices to 10 client-side) and the modal (renders all
4276
- 100). Forward-only semantics: only rows the alert-dispatch path stamped
4277
- get included; pre-deploy crossings stay NULL and are intentionally
4278
- invisible (spec §4.3).
4279
-
4280
- All four axes share the same envelope schema; the ``axis`` field
4281
- (``weekly`` / ``five_hour`` / ``budget`` / ``projected``) discriminates.
4282
- The ``projected`` axis additionally carries a top-level ``metric``
4283
- (``weekly_pct`` | ``budget_usd``) so the frontend can pick its
4284
- metric-aware context renderer.
4345
+ ``budget_milestones``, ``projected_milestones``, and
4346
+ ``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
4347
+ ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4348
+ Single source of truth for both the dashboard panel (slices to 10
4349
+ client-side) and the modal (renders all 100). Forward-only semantics: only
4350
+ rows the alert-dispatch path stamped get included; pre-deploy crossings
4351
+ stay NULL and are intentionally invisible (spec §4.3).
4352
+
4353
+ All five axes share the same envelope schema; the ``axis`` field
4354
+ (``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
4355
+ ``project_budget``) discriminates. The ``projected`` axis additionally
4356
+ carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
4357
+ frontend can pick its metric-aware context renderer; ``project_budget``
4358
+ carries the project basename + ``$spent of $budget`` in its context.
4285
4359
 
4286
4360
  Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4287
4361
  up to ``limit``) and the union is re-sorted + sliced — important for
@@ -4687,6 +4761,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4687
4761
  "weekly_thresholds": [],
4688
4762
  "five_hour_thresholds": [],
4689
4763
  "projected_enabled": False,
4764
+ # Mirror the dispatch keys so the new alerts_settings lines
4765
+ # (`notifier` / `command_configured`) don't KeyError on a
4766
+ # corrupt config. Safe defaults: no notifier override, no
4767
+ # configured command.
4768
+ "notifier": "auto",
4769
+ "command_template": None,
4690
4770
  }
4691
4771
  # Budget is its OWN config block (issue #19) — source budget fields
4692
4772
  # from ``_get_budget_config`` (the validated ``budget`` block), NOT
@@ -4696,7 +4776,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4696
4776
  _budget_cfg = _get_budget_config(_cfg_for_alerts)
4697
4777
  except _BudgetConfigError:
4698
4778
  _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
4699
- "alert_thresholds": [], "projected_enabled": False}
4779
+ "alert_thresholds": [], "projected_enabled": False,
4780
+ "projects": {}, "project_alerts_enabled": False}
4700
4781
  alerts_settings = {
4701
4782
  "enabled": _alerts_cfg["enabled"],
4702
4783
  "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
@@ -4708,6 +4789,21 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4708
4789
  # from the validated getters' ``projected_enabled`` (default False).
4709
4790
  "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
4710
4791
  "projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
4792
+ # Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
4793
+ # ``project_budget`` axis dispatch only (the display section always
4794
+ # renders configured projects). Sourced from the validated budget
4795
+ # getter's ``project_alerts_enabled`` (default False) — the frontend
4796
+ # SettingsOverlay seeds a single on/off toggle from it.
4797
+ "project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
4798
+ # Alert-dispatch notifier mirror (Phase B). `notifier` is the
4799
+ # validated backend selector ("auto"/"command"/etc.). The raw
4800
+ # `command_template` is NEVER mirrored — it routinely holds secrets
4801
+ # (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
4802
+ # every connected client. We expose only a boolean: is a custom
4803
+ # command configured? (the CLI/config remains the sole writer of the
4804
+ # template itself).
4805
+ "notifier": _alerts_cfg.get("notifier", "auto"),
4806
+ "command_configured": _alerts_cfg.get("command_template") is not None,
4711
4807
  }
4712
4808
 
4713
4809
  # Mirror update-state.json + update-suppress.json into the envelope
@@ -5347,7 +5443,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5347
5443
  sent) is the full computed block from ``_compute_display_block``
5348
5444
  (preserves ``tz`` / ``resolved_tz`` / ``offset_label`` /
5349
5445
  ``offset_seconds`` shape consumers rely on). ``alerts`` (when
5350
- sent) is the full validated block from ``_get_alerts_config``.
5446
+ sent) is the full validated block from ``_get_alerts_config``,
5447
+ except the raw ``command_template`` is redacted to the boolean
5448
+ ``command_configured`` (it routinely holds secrets — webhook URLs
5449
+ / bearer tokens — and the echo is returned to the client; the
5450
+ SSE ``alerts_settings`` mirror redacts identically). Do NOT
5451
+ re-add the raw template to the echo.
5351
5452
  ``saved_at`` is included for backward compat.
5352
5453
  """
5353
5454
  if not self._check_origin_csrf():
@@ -5487,6 +5588,27 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5487
5588
  {"error": "alerts.projected_enabled must be a JSON boolean"},
5488
5589
  )
5489
5590
  return
5591
+ # The dispatch command template is CLI/config-only — never
5592
+ # settable via the dashboard (it routinely holds secrets and the
5593
+ # dashboard echoes settings to the client). Reject it explicitly
5594
+ # rather than silently dropping it.
5595
+ if "command_template" in alerts_block:
5596
+ self._respond_json(
5597
+ 400,
5598
+ {"error": "alerts.command_template is CLI/config-only "
5599
+ "(not settable via the dashboard)"},
5600
+ )
5601
+ return
5602
+ # `notifier` is settable (the backend selector). Structural type
5603
+ # check only; the enum + cross-field rule (command needs a stored
5604
+ # template) is enforced free by `_get_alerts_config(merged)` below.
5605
+ if "notifier" in alerts_block and not isinstance(
5606
+ alerts_block["notifier"], str
5607
+ ):
5608
+ self._respond_json(
5609
+ 400, {"error": "alerts.notifier must be a string"}
5610
+ )
5611
+ return
5490
5612
 
5491
5613
  # Pre-validate budget shape (the structural type check; full
5492
5614
  # cross-key validation runs inside the lock via
@@ -5602,6 +5724,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5602
5724
  merged_alerts["projected_enabled"] = (
5603
5725
  alerts_in["projected_enabled"]
5604
5726
  )
5727
+ if "notifier" in alerts_in:
5728
+ merged_alerts["notifier"] = alerts_in["notifier"]
5605
5729
  merged["alerts"] = merged_alerts
5606
5730
  # Final cross-field validation against the merged block.
5607
5731
  # _AlertsConfigError → 400 (no partial write since
@@ -5630,7 +5754,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5630
5754
  budget_in = payload["budget"]
5631
5755
  for leaf in (
5632
5756
  "weekly_usd", "alerts_enabled", "alert_thresholds",
5633
- "projected_enabled",
5757
+ "projected_enabled", "project_alerts_enabled",
5634
5758
  ):
5635
5759
  if leaf in budget_in:
5636
5760
  merged_budget[leaf] = budget_in[leaf]
@@ -5703,7 +5827,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5703
5827
  merged, dt.datetime.now(dt.timezone.utc)
5704
5828
  )
5705
5829
  if "alerts" in payload:
5706
- out["alerts"] = _get_alerts_config(merged)
5830
+ # Echo the full validated alerts block (defaults filled) so the
5831
+ # SettingsOverlay can repaint without a follow-up GET — but
5832
+ # redact the raw `command_template` (secrets) the same way the
5833
+ # SSE snapshot mirror does: replace it with a boolean
5834
+ # `command_configured`.
5835
+ _a = dict(_get_alerts_config(merged))
5836
+ _a["command_configured"] = _a.pop("command_template", None) is not None
5837
+ out["alerts"] = _a
5707
5838
  if "budget" in payload:
5708
5839
  # Echo the full validated budget block (defaults filled) so the
5709
5840
  # SettingsOverlay can repaint without a follow-up GET.
@@ -5716,7 +5847,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5716
5847
  # Runs AFTER save_config (config persisted first); best-effort —
5717
5848
  # never breaks the 200 response. Config write already left the
5718
5849
  # config_writer_lock, so the helper's open_db never nests.
5719
- _cctally()._reconcile_budget_on_config_write(validated_budget)
5850
+ #
5851
+ # Gate each axis on the touched leaves (parity with `config set`):
5852
+ # running on an unrelated leaf would latch a currently-over-but-
5853
+ # not-yet-dispatched threshold, permanently suppressing the next
5854
+ # tick's dispatch. The dashboard accepts no `projects` leaf (the
5855
+ # map is CLI-only), so the per-project axis keys on
5856
+ # project_alerts_enabled/alert_thresholds.
5857
+ budget_in = payload["budget"]
5858
+ touched = (
5859
+ set(budget_in.keys()) if isinstance(budget_in, dict) else set()
5860
+ )
5861
+ if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds"}:
5862
+ _cctally()._reconcile_budget_on_config_write(validated_budget)
5863
+ if touched & {"project_alerts_enabled", "alert_thresholds"}:
5864
+ _cctally()._reconcile_project_budget_milestones_on_write(
5865
+ validated_budget
5866
+ )
5720
5867
  if update_check_validated is not None:
5721
5868
  # Echo the full merged check block (cooked defaults included)
5722
5869
  # so the SettingsOverlay can repaint without a follow-up GET.
@@ -5768,11 +5915,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5768
5915
  dispatch status string in the JSON response.
5769
5916
 
5770
5917
  Body (all fields optional): ``{"axis":
5771
- "weekly"|"five_hour"|"budget"|"projected", "threshold": 1..100,
5772
- "metric": "weekly_pct"|"budget_usd"}``. Defaults: axis="weekly",
5773
- threshold=90, metric="weekly_pct". ``metric`` is only consulted for
5774
- the ``projected`` axis (mirrors the CLI ``alerts test --axis
5775
- projected --metric`` surface); it is ignored for the other axes.
5918
+ "weekly"|"five_hour"|"budget"|"projected"|"project_budget",
5919
+ "threshold": 1..100, "metric": "weekly_pct"|"budget_usd"}``. Defaults:
5920
+ axis="weekly", threshold=90, metric="weekly_pct". ``metric`` is only
5921
+ consulted for the ``projected`` axis (mirrors the CLI ``alerts test
5922
+ --axis projected --metric`` surface); it is ignored for the other
5923
+ axes. The ``project_budget`` axis dispatches a synthetic example
5924
+ project ($26 of $25) — no real ``budget.projects`` entry required.
5776
5925
 
5777
5926
  IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
5778
5927
  in the JSON API to match the dispatch payload's internal axis
@@ -5811,12 +5960,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5811
5960
  return
5812
5961
 
5813
5962
  axis = body.get("axis", "weekly")
5814
- if axis not in ("weekly", "five_hour", "budget", "projected"):
5963
+ if axis not in (
5964
+ "weekly", "five_hour", "budget", "projected", "project_budget",
5965
+ ):
5815
5966
  self._respond_json(
5816
5967
  400,
5817
5968
  {"error": (
5818
- "axis must be 'weekly', 'five_hour', 'budget' or "
5819
- f"'projected', got {axis!r}"
5969
+ "axis must be 'weekly', 'five_hour', 'budget', "
5970
+ f"'projected' or 'project_budget', got {axis!r}"
5820
5971
  )},
5821
5972
  )
5822
5973
  return
@@ -5867,6 +6018,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5867
6018
  spent_usd=300.0 * threshold / 100.0,
5868
6019
  consumption_pct=float(threshold),
5869
6020
  )
6021
+ elif axis == "project_budget":
6022
+ # Synthetic per-project budget payload — mirrors the CLI
6023
+ # ``alerts test --axis project_budget`` branch (NO DB writes,
6024
+ # test/real divergence contract). Uses a fixed example project
6025
+ # ($26 of $25 = 104%) so no real ``budget.projects`` entry is
6026
+ # required; ``project_key`` is a placeholder canonical path.
6027
+ payload = _build_alert_payload_project_budget(
6028
+ threshold=threshold,
6029
+ crossed_at_utc=now_utc_iso(),
6030
+ week_start_at=dt.date.today().isoformat(),
6031
+ project="example-project",
6032
+ project_key="/example/repos/example-project",
6033
+ budget_usd=25.0,
6034
+ spent_usd=26.0,
6035
+ consumption_pct=104.0,
6036
+ )
5870
6037
  elif axis == "projected":
5871
6038
  # Synthetic projected-pace payload — mirrors the CLI
5872
6039
  # cmd_alerts_test projected branch (NO DB writes, test/real