cctally 1.24.0 → 1.26.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,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.26.0] - 2026-06-03
9
+
10
+ ### Added
11
+ - **`cctally budget set --project` now hints the correct argument order when you put the amount after the flag.** Writing the flag-first form `cctally budget set --project 25` binds `25` to the `--project` value (argparse `nargs='?'`) and leaves the amount unset; instead of the generic "`set --project` requires an amount" error, cctally now points you at the supported `cctally budget set 25 --project` ordering. A bare numeric value that names a real directory (e.g. a repo literally called `2025`) is treated as a project path rather than a misplaced amount, so the hint never misfires on a numeric-named repo. The exit code is unchanged (2, a safe no-write failure) and valid invocations are unaffected.
12
+
13
+ ### Changed
14
+ - **Internal refactor (no user-facing change): consolidated the per-project budget label-disambiguation and threshold-crossing arithmetic onto two shared helpers (`_project_budget_labels`, `_project_crossings`), used by the budget table, the alert firing path, the config-write reconcile, and the dashboard SSE envelope, and replaced the share-ranking hidden-`MoneyCell` hack with an explicit `ProjectCell.rank_cost` field.** The firing-path label resolution is now lazy (resolved only when a crossing actually dispatches, off the common no-dispatch tick). Output is byte-identical — the per-project budget reconcile invariants (61/61), the budget/share goldens (26/26), and the full pytest suite are unchanged; nothing to do on upgrade.
15
+
16
+ ## [1.25.0] - 2026-06-03
17
+
18
+ ### Added
19
+ - **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.
20
+
8
21
  ## [1.24.0] - 2026-06-02
9
22
 
10
23
  ### 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,66 @@ 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
+ # Collision-aware labels via the shared primitive (#130), disambiguated
4289
+ # across the alerted ROWS (NOT live config) to preserve the
4290
+ # render-from-the-snapshotted-row invariant above — so a deleted/renamed
4291
+ # config key still renders. This is intentionally a different feed than the
4292
+ # table/notification (full config); see spec §1 Goals (Codex F1).
4293
+ label_by_key = c._project_budget_labels(
4294
+ sorted({r["project_key"] for r in rows})
4295
+ )
4296
+ out: list[dict] = []
4297
+ for r in rows:
4298
+ threshold = int(r["threshold"])
4299
+ project_key = r["project_key"]
4300
+ out.append({
4301
+ "id": (
4302
+ f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
4303
+ ),
4304
+ "axis": descriptor.id,
4305
+ "threshold": threshold,
4306
+ "severity": severity_for(threshold),
4307
+ "crossed_at": r["crossed_at_utc"],
4308
+ "alerted_at": r["alerted_at"],
4309
+ "context": {
4310
+ "week_start_at": r["week_start_at"],
4311
+ "project": label_by_key.get(project_key, project_key),
4312
+ "project_key": project_key,
4313
+ "budget_usd": float(r["budget_usd"]),
4314
+ "spent_usd": float(r["spent_usd"]),
4315
+ "consumption_pct": float(r["consumption_pct"]),
4316
+ },
4317
+ })
4318
+ return out
4319
+
4320
+
4255
4321
  # Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
4256
4322
  # in what order; this table supplies the bespoke heterogeneous row-mapper.
4257
4323
  _ENVELOPE_AXIS_MAPPERS = {
@@ -4259,6 +4325,7 @@ _ENVELOPE_AXIS_MAPPERS = {
4259
4325
  "five_hour": _envelope_rows_five_hour,
4260
4326
  "budget": _envelope_rows_budget,
4261
4327
  "projected": _envelope_rows_projected,
4328
+ "project_budget": _envelope_rows_project_budget,
4262
4329
  }
4263
4330
 
4264
4331
 
@@ -4269,19 +4336,20 @@ def _build_alerts_envelope_array(
4269
4336
  """Return the ``alerts`` array for the SSE snapshot envelope.
4270
4337
 
4271
4338
  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.
4339
+ ``budget_milestones``, ``projected_milestones``, and
4340
+ ``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
4341
+ ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4342
+ Single source of truth for both the dashboard panel (slices to 10
4343
+ client-side) and the modal (renders all 100). Forward-only semantics: only
4344
+ rows the alert-dispatch path stamped get included; pre-deploy crossings
4345
+ stay NULL and are intentionally invisible (spec §4.3).
4346
+
4347
+ All five axes share the same envelope schema; the ``axis`` field
4348
+ (``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
4349
+ ``project_budget``) discriminates. The ``projected`` axis additionally
4350
+ carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
4351
+ frontend can pick its metric-aware context renderer; ``project_budget``
4352
+ carries the project basename + ``$spent of $budget`` in its context.
4285
4353
 
4286
4354
  Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4287
4355
  up to ``limit``) and the union is re-sorted + sliced — important for
@@ -4702,7 +4770,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4702
4770
  _budget_cfg = _get_budget_config(_cfg_for_alerts)
4703
4771
  except _BudgetConfigError:
4704
4772
  _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
4705
- "alert_thresholds": [], "projected_enabled": False}
4773
+ "alert_thresholds": [], "projected_enabled": False,
4774
+ "projects": {}, "project_alerts_enabled": False}
4706
4775
  alerts_settings = {
4707
4776
  "enabled": _alerts_cfg["enabled"],
4708
4777
  "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
@@ -4714,6 +4783,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4714
4783
  # from the validated getters' ``projected_enabled`` (default False).
4715
4784
  "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
4716
4785
  "projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
4786
+ # Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
4787
+ # ``project_budget`` axis dispatch only (the display section always
4788
+ # renders configured projects). Sourced from the validated budget
4789
+ # getter's ``project_alerts_enabled`` (default False) — the frontend
4790
+ # SettingsOverlay seeds a single on/off toggle from it.
4791
+ "project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
4717
4792
  # Alert-dispatch notifier mirror (Phase B). `notifier` is the
4718
4793
  # validated backend selector ("auto"/"command"/etc.). The raw
4719
4794
  # `command_template` is NEVER mirrored — it routinely holds secrets
@@ -5673,7 +5748,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5673
5748
  budget_in = payload["budget"]
5674
5749
  for leaf in (
5675
5750
  "weekly_usd", "alerts_enabled", "alert_thresholds",
5676
- "projected_enabled",
5751
+ "projected_enabled", "project_alerts_enabled",
5677
5752
  ):
5678
5753
  if leaf in budget_in:
5679
5754
  merged_budget[leaf] = budget_in[leaf]
@@ -5766,7 +5841,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5766
5841
  # Runs AFTER save_config (config persisted first); best-effort —
5767
5842
  # never breaks the 200 response. Config write already left the
5768
5843
  # config_writer_lock, so the helper's open_db never nests.
5769
- _cctally()._reconcile_budget_on_config_write(validated_budget)
5844
+ #
5845
+ # Gate each axis on the touched leaves (parity with `config set`):
5846
+ # running on an unrelated leaf would latch a currently-over-but-
5847
+ # not-yet-dispatched threshold, permanently suppressing the next
5848
+ # tick's dispatch. The dashboard accepts no `projects` leaf (the
5849
+ # map is CLI-only), so the per-project axis keys on
5850
+ # project_alerts_enabled/alert_thresholds.
5851
+ budget_in = payload["budget"]
5852
+ touched = (
5853
+ set(budget_in.keys()) if isinstance(budget_in, dict) else set()
5854
+ )
5855
+ if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds"}:
5856
+ _cctally()._reconcile_budget_on_config_write(validated_budget)
5857
+ if touched & {"project_alerts_enabled", "alert_thresholds"}:
5858
+ _cctally()._reconcile_project_budget_milestones_on_write(
5859
+ validated_budget
5860
+ )
5770
5861
  if update_check_validated is not None:
5771
5862
  # Echo the full merged check block (cooked defaults included)
5772
5863
  # so the SettingsOverlay can repaint without a follow-up GET.
@@ -5818,11 +5909,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5818
5909
  dispatch status string in the JSON response.
5819
5910
 
5820
5911
  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.
5912
+ "weekly"|"five_hour"|"budget"|"projected"|"project_budget",
5913
+ "threshold": 1..100, "metric": "weekly_pct"|"budget_usd"}``. Defaults:
5914
+ axis="weekly", threshold=90, metric="weekly_pct". ``metric`` is only
5915
+ consulted for the ``projected`` axis (mirrors the CLI ``alerts test
5916
+ --axis projected --metric`` surface); it is ignored for the other
5917
+ axes. The ``project_budget`` axis dispatches a synthetic example
5918
+ project ($26 of $25) — no real ``budget.projects`` entry required.
5826
5919
 
5827
5920
  IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
5828
5921
  in the JSON API to match the dispatch payload's internal axis
@@ -5861,12 +5954,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5861
5954
  return
5862
5955
 
5863
5956
  axis = body.get("axis", "weekly")
5864
- if axis not in ("weekly", "five_hour", "budget", "projected"):
5957
+ if axis not in (
5958
+ "weekly", "five_hour", "budget", "projected", "project_budget",
5959
+ ):
5865
5960
  self._respond_json(
5866
5961
  400,
5867
5962
  {"error": (
5868
- "axis must be 'weekly', 'five_hour', 'budget' or "
5869
- f"'projected', got {axis!r}"
5963
+ "axis must be 'weekly', 'five_hour', 'budget', "
5964
+ f"'projected' or 'project_budget', got {axis!r}"
5870
5965
  )},
5871
5966
  )
5872
5967
  return
@@ -5917,6 +6012,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5917
6012
  spent_usd=300.0 * threshold / 100.0,
5918
6013
  consumption_pct=float(threshold),
5919
6014
  )
6015
+ elif axis == "project_budget":
6016
+ # Synthetic per-project budget payload — mirrors the CLI
6017
+ # ``alerts test --axis project_budget`` branch (NO DB writes,
6018
+ # test/real divergence contract). Uses a fixed example project
6019
+ # ($26 of $25 = 104%) so no real ``budget.projects`` entry is
6020
+ # required; ``project_key`` is a placeholder canonical path.
6021
+ payload = _build_alert_payload_project_budget(
6022
+ threshold=threshold,
6023
+ crossed_at_utc=now_utc_iso(),
6024
+ week_start_at=dt.date.today().isoformat(),
6025
+ project="example-project",
6026
+ project_key="/example/repos/example-project",
6027
+ budget_usd=25.0,
6028
+ spent_usd=26.0,
6029
+ consumption_pct=104.0,
6030
+ )
5920
6031
  elif axis == "projected":
5921
6032
  # Synthetic projected-pace payload — mirrors the CLI
5922
6033
  # cmd_alerts_test projected branch (NO DB writes, test/real