cctally 1.24.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 CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.25.0] - 2026-06-03
9
+
10
+ ### Added
11
+ - **Per-project weekly budgets with their own actual-spend alerts (a new `project_budget` alert axis).** Set a dollar budget for any repo with `cctally budget set 25 --project` (resolves the current directory's git-root) or `--project /abs/path`, clear it with `cctally budget unset --project`, and `cctally budget` now renders a per-project section below the global status (budget / spent / used% / verdict / `LOW CONF`, sorted by used% desc) — present even when no global `budget.weekly_usd` is set, additive in `--json` (a `projects[]` array, no schema bump) and in share-output (names anonymized unless `--reveal-projects`). Turn on push alerts (opt-in, default off) with `cctally config set budget.project_alerts_enabled true`: when a project crosses one of your `budget.alert_thresholds` of its own budget, `record-usage` fires one cross-platform notification per `(project, threshold)` per week with project-specific text (e.g. *"Project foo - $26.00 of $25.00 (104% of budget)"*), severity from the shared 3-tier model. Setting a project budget mid-week when you're already over a threshold records that crossing silently (no retroactive popup) and only later crossings fire, and a mid-week budget change never re-alerts an already-fired threshold. Preview the notification without any real config via `cctally alerts test --axis project-budget --threshold 100`. Thresholds reuse the global `budget.alert_thresholds`; projects are keyed by canonical git-root so same-basename repos stay distinct. In the local web dashboard, fired project alerts now show in the existing "Recent alerts" panel/modal with a distinct **PROJECT** chip (vs the global **BUDGET** chip) and the project basename + `$spent of $budget` context, the Settings overlay gains a **Per-project budget alerts** on/off toggle that persists `budget.project_alerts_enabled` (enabling it mid-week when already over latches the crossed thresholds without storming retroactive popups), and the Settings "Send test alert" picker can fire the synthetic project-budget example end-to-end; editing per-project budget *amounts* stays CLI-only.
12
+
8
13
  ## [1.24.0] - 2026-06-02
9
14
 
10
15
  ### Added
@@ -70,11 +70,13 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
70
70
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
71
71
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
72
72
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
73
+ _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
73
74
  _alert_text_projected = _lib_alerts_payload._alert_text_projected
74
75
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
75
76
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
76
77
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
77
78
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
79
+ _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
78
80
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
79
81
 
80
82
  # Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
@@ -171,6 +173,8 @@ def _dispatch_alert_notification(
171
173
  title, subtitle, body = _alert_text_five_hour(payload, tz)
172
174
  elif axis == "budget":
173
175
  title, subtitle, body = _alert_text_budget(payload, tz)
176
+ elif axis == "project_budget":
177
+ title, subtitle, body = _alert_text_project_budget(payload, tz)
174
178
  elif axis == "projected":
175
179
  title, subtitle, body = _alert_text_projected(payload, tz)
176
180
  else:
@@ -279,6 +283,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
279
283
  axis = "weekly"
280
284
  elif args.axis == "budget":
281
285
  axis = "budget"
286
+ elif args.axis == "project-budget":
287
+ axis = "project_budget"
282
288
  elif args.axis == "projected":
283
289
  axis = "projected"
284
290
  else:
@@ -313,6 +319,22 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
313
319
  spent_usd=300.0 * threshold / 100.0,
314
320
  consumption_pct=float(threshold),
315
321
  )
322
+ elif axis == "project_budget":
323
+ # Synthetic per-project budget payload — NO DB writes (test/real
324
+ # divergence contract), NO real budget.projects entry required. A small
325
+ # $25 budget at $26 spent (104%) reads plausibly regardless of the
326
+ # --threshold (the body line shows the at-crossing snapshot the dashboard
327
+ # would render).
328
+ payload = _build_alert_payload_project_budget(
329
+ threshold=threshold,
330
+ crossed_at_utc=now_utc_iso(),
331
+ week_start_at=dt.date.today().isoformat(),
332
+ project="example-project",
333
+ project_key="/example/repos/example-project",
334
+ budget_usd=25.0,
335
+ spent_usd=26.0,
336
+ consumption_pct=104.0,
337
+ )
316
338
  elif axis == "projected":
317
339
  # Synthetic projected-pace payload — NO DB writes (test/real divergence
318
340
  # contract). The metric discriminator picks the wiring; projected_value
@@ -310,6 +310,8 @@ ALLOWED_CONFIG_KEYS = (
310
310
  "budget.alerts_enabled",
311
311
  "budget.alert_thresholds",
312
312
  "budget.projected_enabled",
313
+ "budget.projects",
314
+ "budget.project_alerts_enabled",
313
315
  )
314
316
 
315
317
 
@@ -499,6 +501,8 @@ def _config_known_value(config: dict, key: str) -> "object":
499
501
  "budget.alerts_enabled",
500
502
  "budget.alert_thresholds",
501
503
  "budget.projected_enabled",
504
+ "budget.projects",
505
+ "budget.project_alerts_enabled",
502
506
  ):
503
507
  inner = key.split(".", 1)[1]
504
508
  # Read the validated, defaults-filled block. A corrupt block falls
@@ -513,6 +517,8 @@ def _config_known_value(config: dict, key: str) -> "object":
513
517
  default = _BUDGET_DEFAULTS[inner]
514
518
  if isinstance(default, list):
515
519
  return list(default)
520
+ if isinstance(default, dict):
521
+ return dict(default)
516
522
  return default
517
523
  return None
518
524
 
@@ -522,11 +528,12 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
522
528
  if key is not None and key not in ALLOWED_CONFIG_KEYS:
523
529
  eprint(f"cctally config: unknown config key {key!r}")
524
530
  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.
531
+ # `alerts.command_template` is JSON-shaped (a list of strings or null), and
532
+ # `budget.projects` is JSON-shaped (an object), so their real values
533
+ # (including None) must survive into the render layer the generic
534
+ # None->"" coercion below would break the JSON shape / round-trip.
528
535
  def _coerce(k: str, v: "object") -> "object":
529
- if k == "alerts.command_template":
536
+ if k in ("alerts.command_template", "budget.projects"):
530
537
  return v
531
538
  return v if v is not None else ""
532
539
 
@@ -555,10 +562,11 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
555
562
  for k, v in pairs:
556
563
  # Preserve canonical bool stringification (true/false) so
557
564
  # round-trips via `config set alerts.enabled <plain-text>` work.
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).
565
+ if k in ("alerts.command_template", "budget.projects"):
566
+ # JSON-encoded so `config get` output round-trips through the
567
+ # matching `config set` branch (both JSON-parse their value).
568
+ # `alerts.command_template` is a list-of-strings|null;
569
+ # `budget.projects` is an object {git-root: usd}.
562
570
  rendered = json.dumps(v)
563
571
  elif isinstance(v, bool):
564
572
  rendered = "true" if v else "false"
@@ -869,6 +877,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
869
877
  "budget.alerts_enabled",
870
878
  "budget.alert_thresholds",
871
879
  "budget.projected_enabled",
880
+ "budget.projects",
881
+ "budget.project_alerts_enabled",
872
882
  ):
873
883
  inner_key = key.split(".", 1)[1]
874
884
  # Parse + normalize the raw value per key BEFORE acquiring the lock so
@@ -888,7 +898,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
888
898
  f"null, got {raw!r}"
889
899
  )
890
900
  return 2
891
- elif inner_key in ("alerts_enabled", "projected_enabled"):
901
+ elif inner_key in (
902
+ "alerts_enabled", "projected_enabled", "project_alerts_enabled"
903
+ ):
892
904
  lo = raw.strip().lower()
893
905
  if lo in ("true", "yes", "on", "1"):
894
906
  new_val = True
@@ -900,6 +912,42 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
900
912
  f"got {raw!r}"
901
913
  )
902
914
  return 2
915
+ elif inner_key == "projects":
916
+ # `budget.projects` is a dict {git-root: usd}, which the plain
917
+ # number/bool/list leaves can't round-trip — JSON-parse it (mirrors
918
+ # the alerts.command_template branch). The per-value numeric rule is
919
+ # enforced by _get_budget_config under the lock below; here we only
920
+ # reject non-JSON / non-object shape.
921
+ try:
922
+ parsed_obj = json.loads(raw)
923
+ except (json.JSONDecodeError, ValueError):
924
+ eprint(
925
+ "cctally config: budget.projects must be a JSON object, "
926
+ f"got {raw!r}"
927
+ )
928
+ return 2
929
+ if not isinstance(parsed_obj, dict):
930
+ eprint("cctally config: budget.projects must be a JSON object")
931
+ return 2
932
+ # Canonicalize each project key to its resolved git-root, mirroring
933
+ # the `budget set --project` CLI path (`_resolve_project_budget_-
934
+ # target`). `_sum_cost_by_project` buckets spend under the realpath'd
935
+ # `ProjectKey.bucket_path`, so a `~`/relative/sub-dir/trailing-slash
936
+ # key stored verbatim would NEVER match → a permanent $0 row that
937
+ # silently never alerts. Resolving here makes the JSON-object surface
938
+ # match the per-project CLI surface. Non-string keys (impossible from
939
+ # json.loads, defensive) and the `__CWD__`-non-git None case fall
940
+ # back to the raw key for `_get_budget_config` to handle.
941
+ c = _cctally()
942
+ new_val = {
943
+ (
944
+ c._resolve_project_budget_target(pk)
945
+ if isinstance(pk, str)
946
+ else pk
947
+ )
948
+ or pk: pv
949
+ for pk, pv in parsed_obj.items()
950
+ }
903
951
  else: # alert_thresholds — comma-separated int list (empty = silenced)
904
952
  stripped = raw.strip()
905
953
  parsed: "list[int]" = []
@@ -941,13 +989,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
941
989
  # helper opens stats.db and must not nest under the config lock
942
990
  # (fcntl.flock is per-fd; the helper has its own open_db locking).
943
991
  c = _cctally()
944
- c._reconcile_budget_on_config_write(validated)
992
+ # Gate each forward-only reconcile (spec §6.8) on the keys it actually
993
+ # consumes. Running unconditionally on an UNRELATED write — e.g. the
994
+ # global axis on `config set budget.projects`, or the per-project axis
995
+ # on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
996
+ # dispatched threshold as already-alerted, permanently suppressing the
997
+ # next record-usage tick's dispatch. The global axis feeds on
998
+ # weekly_usd/alerts_enabled/alert_thresholds; the per-project axis on
999
+ # projects/project_alerts_enabled/alert_thresholds (alert_thresholds is
1000
+ # shared; projected_enabled belongs to neither reconcile). Both run
1001
+ # OUTSIDE config_writer_lock (each helper has its own open_db lock).
1002
+ if inner_key in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
1003
+ c._reconcile_budget_on_config_write(validated)
1004
+ if inner_key in (
1005
+ "projects", "project_alerts_enabled", "alert_thresholds"
1006
+ ):
1007
+ c._reconcile_project_budget_milestones_on_write(validated)
945
1008
  out_val = validated[inner_key]
946
1009
  if getattr(args, "emit_json", False):
947
1010
  print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
948
1011
  else:
949
1012
  if isinstance(out_val, bool):
950
1013
  rendered = "true" if out_val else "false"
1014
+ elif inner_key == "projects":
1015
+ # JSON so `config get budget.projects` round-trips back through
1016
+ # this branch (str(dict) is not valid JSON).
1017
+ rendered = json.dumps(out_val)
951
1018
  else:
952
1019
  rendered = str(out_val)
953
1020
  print(f"{key}={rendered}")
@@ -1070,6 +1137,8 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1070
1137
  "budget.alerts_enabled",
1071
1138
  "budget.alert_thresholds",
1072
1139
  "budget.projected_enabled",
1140
+ "budget.projects",
1141
+ "budget.project_alerts_enabled",
1073
1142
  ):
1074
1143
  # Drop only the named leaf; preserve sibling budget.* keys (e.g.
1075
1144
  # unsetting weekly_usd keeps a customized alert_thresholds). If the
@@ -575,12 +575,16 @@ _BUDGET_DEFAULTS = {
575
575
  "alerts_enabled": True, # "on when set"
576
576
  "alert_thresholds": [90, 100],
577
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
578
580
  }
579
581
  _BUDGET_CONFIG_VALID_KEYS = {
580
582
  "weekly_usd",
581
583
  "alerts_enabled",
582
584
  "alert_thresholds",
583
585
  "projected_enabled",
586
+ "projects",
587
+ "project_alerts_enabled",
584
588
  }
585
589
 
586
590
 
@@ -593,6 +597,7 @@ def _get_budget_config(cfg: dict) -> dict:
593
597
  """
594
598
  out = dict(_BUDGET_DEFAULTS)
595
599
  out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
600
+ out["projects"] = dict(_BUDGET_DEFAULTS["projects"])
596
601
  block = cfg.get("budget") if isinstance(cfg, dict) else None
597
602
  if block is None:
598
603
  return out
@@ -648,6 +653,41 @@ def _get_budget_config(cfg: dict) -> dict:
648
653
  raise _BudgetConfigError("budget.projected_enabled must be a boolean")
649
654
  out["projected_enabled"] = v
650
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
+
651
691
  return out
652
692
 
653
693
 
@@ -1127,6 +1167,42 @@ def open_db() -> sqlite3.Connection:
1127
1167
  """
1128
1168
  )
1129
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
+
1130
1206
  # Migration framework dispatcher. Replaces the prior inline gate stack
1131
1207
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1132
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
@@ -4702,7 +4776,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4702
4776
  _budget_cfg = _get_budget_config(_cfg_for_alerts)
4703
4777
  except _BudgetConfigError:
4704
4778
  _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
4705
- "alert_thresholds": [], "projected_enabled": False}
4779
+ "alert_thresholds": [], "projected_enabled": False,
4780
+ "projects": {}, "project_alerts_enabled": False}
4706
4781
  alerts_settings = {
4707
4782
  "enabled": _alerts_cfg["enabled"],
4708
4783
  "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
@@ -4714,6 +4789,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4714
4789
  # from the validated getters' ``projected_enabled`` (default False).
4715
4790
  "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
4716
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")),
4717
4798
  # Alert-dispatch notifier mirror (Phase B). `notifier` is the
4718
4799
  # validated backend selector ("auto"/"command"/etc.). The raw
4719
4800
  # `command_template` is NEVER mirrored — it routinely holds secrets
@@ -5673,7 +5754,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5673
5754
  budget_in = payload["budget"]
5674
5755
  for leaf in (
5675
5756
  "weekly_usd", "alerts_enabled", "alert_thresholds",
5676
- "projected_enabled",
5757
+ "projected_enabled", "project_alerts_enabled",
5677
5758
  ):
5678
5759
  if leaf in budget_in:
5679
5760
  merged_budget[leaf] = budget_in[leaf]
@@ -5766,7 +5847,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5766
5847
  # Runs AFTER save_config (config persisted first); best-effort —
5767
5848
  # never breaks the 200 response. Config write already left the
5768
5849
  # config_writer_lock, so the helper's open_db never nests.
5769
- _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
+ )
5770
5867
  if update_check_validated is not None:
5771
5868
  # Echo the full merged check block (cooked defaults included)
5772
5869
  # so the SettingsOverlay can repaint without a follow-up GET.
@@ -5818,11 +5915,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5818
5915
  dispatch status string in the JSON response.
5819
5916
 
5820
5917
  Body (all fields optional): ``{"axis":
5821
- "weekly"|"five_hour"|"budget"|"projected", "threshold": 1..100,
5822
- "metric": "weekly_pct"|"budget_usd"}``. Defaults: axis="weekly",
5823
- threshold=90, metric="weekly_pct". ``metric`` is only consulted for
5824
- the ``projected`` axis (mirrors the CLI ``alerts test --axis
5825
- 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.
5826
5925
 
5827
5926
  IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
5828
5927
  in the JSON API to match the dispatch payload's internal axis
@@ -5861,12 +5960,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5861
5960
  return
5862
5961
 
5863
5962
  axis = body.get("axis", "weekly")
5864
- if axis not in ("weekly", "five_hour", "budget", "projected"):
5963
+ if axis not in (
5964
+ "weekly", "five_hour", "budget", "projected", "project_budget",
5965
+ ):
5865
5966
  self._respond_json(
5866
5967
  400,
5867
5968
  {"error": (
5868
- "axis must be 'weekly', 'five_hour', 'budget' or "
5869
- f"'projected', got {axis!r}"
5969
+ "axis must be 'weekly', 'five_hour', 'budget', "
5970
+ f"'projected' or 'project_budget', got {axis!r}"
5870
5971
  )},
5871
5972
  )
5872
5973
  return
@@ -5917,6 +6018,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5917
6018
  spent_usd=300.0 * threshold / 100.0,
5918
6019
  consumption_pct=float(threshold),
5919
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
+ )
5920
6037
  elif axis == "projected":
5921
6038
  # Synthetic projected-pace payload — mirrors the CLI
5922
6039
  # cmd_alerts_test projected branch (NO DB writes, test/real