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.
@@ -369,6 +369,52 @@ def insert_budget_milestone(
369
369
  return int(cur.rowcount)
370
370
 
371
371
 
372
+ def insert_project_budget_milestone(
373
+ conn: sqlite3.Connection,
374
+ *,
375
+ week_start_at: str,
376
+ project_key: str,
377
+ threshold: int,
378
+ budget_usd: float,
379
+ spent_usd: float,
380
+ consumption_pct: float,
381
+ commit: bool = True,
382
+ ) -> int:
383
+ """INSERT OR IGNORE a per-project budget threshold crossing. Returns
384
+ ``cur.rowcount`` (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a
385
+ pre-existing ``(week_start_at, project_key, threshold)`` row).
386
+
387
+ Mirrors :func:`insert_budget_milestone` EXACTLY, with ``project_key`` added
388
+ as the per-project dimension of the UNIQUE dedup key (spec §5.1) — each
389
+ project crosses each threshold once per week, independently. The rowcount
390
+ contract matches :func:`insert_percent_milestone` so the alert-fire predicate
391
+ (`if inserted == 1`) is race-safe without a follow-up SELECT. ``alerted_at``
392
+ is left NULL — the caller stamps it in the SAME transaction BEFORE
393
+ dispatching (set-then-dispatch invariant, CLAUDE.md Alerts gotcha).
394
+ ``commit=False`` lets the caller bundle the INSERT with the follow-up
395
+ ``alerted_at`` UPDATE in one transaction so a crash between them can't strand
396
+ ``alerted_at`` NULL forever.
397
+ """
398
+ cur = conn.execute(
399
+ "INSERT OR IGNORE INTO project_budget_milestones "
400
+ "(week_start_at, project_key, threshold, budget_usd, spent_usd, "
401
+ " consumption_pct, crossed_at_utc) "
402
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
403
+ (
404
+ week_start_at,
405
+ str(project_key),
406
+ int(threshold),
407
+ float(budget_usd),
408
+ float(spent_usd),
409
+ float(consumption_pct),
410
+ now_utc_iso(),
411
+ ),
412
+ )
413
+ if commit:
414
+ conn.commit()
415
+ return int(cur.rowcount)
416
+
417
+
372
418
  def insert_projected_milestone(
373
419
  conn: sqlite3.Connection,
374
420
  *,
@@ -499,3 +545,103 @@ def _reconcile_budget_on_config_write(validated_budget):
499
545
  conn.close()
500
546
  except Exception as exc: # best-effort; never fail the write
501
547
  eprint(f"[budget-milestone] reconcile on set failed: {exc}")
548
+
549
+
550
+ def _reconcile_project_budget_milestones_on_write(
551
+ validated_budget, touched_projects=None
552
+ ):
553
+ """Forward-only-from-write reconcile for PER-PROJECT budgets (spec §6.8).
554
+
555
+ Shared by all four per-project write surfaces: ``budget set --project`` /
556
+ ``unset --project`` (call sites in ``_cmd_budget_set_project`` /
557
+ ``_cmd_budget_unset_project``), ``config set budget.projects`` /
558
+ ``config set budget.project_alerts_enabled`` (call site in
559
+ ``_cmd_config_set``), and the dashboard ``project_alerts_enabled`` toggle
560
+ (POST /api/settings, Task 4). Mirrors :func:`_reconcile_budget_on_config_write`.
561
+
562
+ Mechanic: for each configured project, compute current-week spend (the
563
+ shared ``_sum_cost_by_project`` scan), and for each ALREADY-crossed
564
+ ``(project, threshold)`` ``INSERT OR IGNORE`` a milestone with ``alerted_at``
565
+ stamped and **NO dispatch** — so setting a project budget mid-week (already
566
+ over) records the crossed thresholds as already-alerted without an
567
+ instant-popup; only LATER crossings fire via
568
+ :func:`maybe_record_project_budget_milestone`.
569
+
570
+ Dedup via ``UNIQUE(week_start_at, project_key, threshold)`` + the
571
+ ``alerted_at IS NULL`` UPDATE guard, so a mid-week TARGET change never
572
+ re-stamps an already-alerted row (mirrors the global reconcile's
573
+ target-change semantics).
574
+
575
+ Gated: runs ONLY when per-project alerts are active (``projects`` non-empty
576
+ **and** ``project_alerts_enabled`` **and** ``alert_thresholds`` non-empty);
577
+ else records nothing. Best-effort — a stats.db failure never fails the config
578
+ write. Runs OUTSIDE any ``config_writer_lock`` (``open_db`` has its own
579
+ locking).
580
+
581
+ ``touched_projects``: when not ``None``, reconcile ONLY these project keys.
582
+ The single-project CLI writes (``budget set/unset --project``) pass
583
+ ``{root}`` so touching project A never latches a sibling project B's
584
+ already-crossed-but-not-yet-dispatched threshold — which would permanently
585
+ suppress B's real alert. ``None`` (config-set / dashboard toggle / wholesale
586
+ ``budget.projects`` set) reconciles every configured project: the intended
587
+ "axis enabled / map redefined → suppress the retroactive storm for all
588
+ currently-over projects" semantics.
589
+ """
590
+ projects = (validated_budget or {}).get("projects") or {}
591
+ thresholds = validated_budget.get("alert_thresholds") or []
592
+ if not (
593
+ projects
594
+ and validated_budget.get("project_alerts_enabled")
595
+ and thresholds
596
+ ):
597
+ return
598
+ c = _cctally()
599
+ try:
600
+ conn = open_db()
601
+ try:
602
+ now_utc = _command_as_of()
603
+ window = c._resolve_current_budget_window(conn, now_utc)
604
+ if window is None:
605
+ return
606
+ week_start_at, _week_end_at = window
607
+ week_key = week_start_at.isoformat(timespec="seconds")
608
+ by_proj = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
609
+ items = (
610
+ projects.items()
611
+ if touched_projects is None
612
+ else [
613
+ (k, v) for k, v in projects.items()
614
+ if k in touched_projects
615
+ ]
616
+ )
617
+ for project_key, target in items:
618
+ spent = float(by_proj.get(project_key, 0.0))
619
+ target = float(target)
620
+ consumption_pct = (
621
+ (spent / target * 100.0) if target > 0 else 0.0
622
+ )
623
+ for t in sorted(thresholds):
624
+ if consumption_pct + 1e-9 >= t:
625
+ insert_project_budget_milestone(
626
+ conn,
627
+ week_start_at=week_key,
628
+ project_key=project_key,
629
+ threshold=t,
630
+ budget_usd=target,
631
+ spent_usd=spent,
632
+ consumption_pct=consumption_pct,
633
+ commit=False,
634
+ )
635
+ conn.execute(
636
+ "UPDATE project_budget_milestones SET alerted_at = ? "
637
+ "WHERE week_start_at = ? AND project_key = ? "
638
+ " AND threshold = ? AND alerted_at IS NULL",
639
+ (now_utc_iso(), week_key, project_key, t),
640
+ )
641
+ conn.commit()
642
+ finally:
643
+ conn.close()
644
+ except Exception as exc: # best-effort; never fail the write
645
+ eprint(
646
+ f"[project-budget-milestone] reconcile on write failed: {exc}"
647
+ )
@@ -1273,6 +1273,7 @@ def build_parser() -> argparse.ArgumentParser:
1273
1273
  cctally budget
1274
1274
  cctally budget set 300
1275
1275
  cctally budget unset
1276
+ cctally budget set 25 --project
1276
1277
  cctally budget --json
1277
1278
  cctally budget --format md
1278
1279
  """
@@ -1285,9 +1286,13 @@ def build_parser() -> argparse.ArgumentParser:
1285
1286
  bg.add_argument("--config", default=None,
1286
1287
  help="Read status from this config file (read-only; "
1287
1288
  "rejected on set/unset).")
1289
+ bg.add_argument(
1290
+ "--project", nargs="?", const="__CWD__", default=None,
1291
+ help="Set/unset a per-project budget for this git repo "
1292
+ "(bare = current directory's git-root; or pass a path).")
1288
1293
  bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
1289
- help="Accepted for --format surface parity; inert for budget "
1290
- "(no per-project axis).")
1294
+ help="Show real project basenames in the per-project section "
1295
+ "of --format output (default anonymizes to project-1/…).")
1291
1296
  bg.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
1292
1297
  help="Display timezone: local, utc, or IANA name. "
1293
1298
  "Overrides config display.tz for this call.")
@@ -2186,15 +2191,17 @@ def build_parser() -> argparse.ArgumentParser:
2186
2191
  cctally alerts test
2187
2192
  cctally alerts test --axis five-hour --threshold 95
2188
2193
  cctally alerts test --axis budget --threshold 100
2194
+ cctally alerts test --axis project-budget --threshold 100
2189
2195
  cctally alerts test --axis projected --metric budget_usd
2190
2196
  """),
2191
2197
  )
2192
2198
  p_alerts_test.add_argument(
2193
2199
  "--axis",
2194
- choices=["weekly", "five-hour", "budget", "projected"],
2200
+ choices=["weekly", "five-hour", "budget", "project-budget", "projected"],
2195
2201
  default="weekly",
2196
2202
  help="Alert axis to simulate: weekly subscription window, 5h block, "
2197
- "equiv-$ budget, or projected-pace (default: weekly).",
2203
+ "equiv-$ budget, per-project equiv-$ budget, or projected-pace "
2204
+ "(default: weekly).",
2198
2205
  )
2199
2206
  p_alerts_test.add_argument(
2200
2207
  "--threshold",
@@ -81,6 +81,57 @@ def _load_week_snapshots(
81
81
  conn.close()
82
82
 
83
83
 
84
+ def _sum_cost_by_project(
85
+ start: dt.datetime,
86
+ now: dt.datetime,
87
+ mode: str = "auto",
88
+ skip_sync: bool = False,
89
+ ) -> dict[str, float]:
90
+ """Return ``{canonical_git_root: spent_usd}`` over ``[start, now]``.
91
+
92
+ ONE scan over the joined session entries (the same iterator
93
+ ``cmd_project`` walks), bucketed in Python by each entry's resolved
94
+ git-root (``_resolve_project_key`` — a filesystem ``.git`` walk, NOT a
95
+ SQL ``GROUP BY``), with per-entry cost computed via the same
96
+ ``_calculate_entry_cost(model, usage, mode=...)`` path ``cmd_project``
97
+ uses (so pricing edits flow through uniformly). Keys are the resolved
98
+ ``ProjectKey.bucket_path`` (the canonical git-root when a ``.git`` is
99
+ found, else the normalized path) — identical to how ``cmd_project``
100
+ keys its rows, so configured ``budget.projects`` keys match by string
101
+ equality.
102
+
103
+ Synthetic entries (Claude Code internal markers) are skipped, mirroring
104
+ ``cmd_project`` / the other ``_JoinedClaudeEntry`` aggregators. A
105
+ configured project with no in-range entry simply never appears in the
106
+ returned map (the caller renders it as a ``$0`` row — spec §7.2).
107
+
108
+ Shared by the per-project budget display (§7.2, ``cmd_budget``) and the
109
+ alert-firing path (§6.4); ``skip_sync`` threads through to
110
+ ``get_claude_session_entries`` so the record-tick caller can reuse a
111
+ cache already warmed earlier in the same tick.
112
+ """
113
+ c = _cctally()
114
+ resolver_cache: dict[str, ProjectKey] = {}
115
+ out: dict[str, float] = {}
116
+ for entry in c.get_claude_session_entries(start, now, skip_sync=skip_sync):
117
+ if entry.model == "<synthetic>":
118
+ continue
119
+ cost = c._calculate_entry_cost(
120
+ entry.model,
121
+ {
122
+ "input_tokens": entry.input_tokens,
123
+ "output_tokens": entry.output_tokens,
124
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
125
+ "cache_read_input_tokens": entry.cache_read_tokens,
126
+ },
127
+ mode=mode,
128
+ cost_usd=entry.cost_usd,
129
+ )
130
+ key = c._resolve_project_key(entry.project_path, "git-root", resolver_cache)
131
+ out[key.bucket_path] = out.get(key.bucket_path, 0.0) + cost
132
+ return out
133
+
134
+
84
135
  def _accumulate_entry_into_bucket(
85
136
  b: dict,
86
137
  entry: "_JoinedClaudeEntry",
@@ -265,6 +265,26 @@ def _build_alert_payload_budget(*args, **kwargs):
265
265
  return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
266
266
 
267
267
 
268
+ def _build_alert_payload_project_budget(*args, **kwargs):
269
+ return sys.modules["cctally"]._build_alert_payload_project_budget(*args, **kwargs)
270
+
271
+
272
+ def _sum_cost_by_project(*args, **kwargs):
273
+ return sys.modules["cctally"]._sum_cost_by_project(*args, **kwargs)
274
+
275
+
276
+ def insert_project_budget_milestone(*args, **kwargs):
277
+ return sys.modules["cctally"].insert_project_budget_milestone(*args, **kwargs)
278
+
279
+
280
+ def _resolve_project_key(*args, **kwargs):
281
+ return sys.modules["cctally"]._resolve_project_key(*args, **kwargs)
282
+
283
+
284
+ def _project_disambiguate_labels(*args, **kwargs):
285
+ return sys.modules["cctally"]._project_disambiguate_labels(*args, **kwargs)
286
+
287
+
268
288
  def _get_budget_config(*args, **kwargs):
269
289
  return sys.modules["cctally"]._get_budget_config(*args, **kwargs)
270
290
 
@@ -820,6 +840,180 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
820
840
  eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
821
841
 
822
842
 
843
+ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
844
+ """Fire PER-PROJECT equiv-$ budget alerts on ACTUAL-spend threshold
845
+ crossings (spec §6 — called from ``cmd_record_usage`` alongside the
846
+ weekly-% / 5h-% / budget / projected milestone helpers). An independent
847
+ helper (its own ``load_config()`` / ``open_db()``), matching the existing
848
+ per-axis structure — NOT fused into ``maybe_record_budget_milestone``.
849
+
850
+ Gated, hot-path-cheap, pre-probed, set-then-dispatch, fire-once. Errors are
851
+ logged, not raised (the caller also wraps).
852
+
853
+ ``saved`` is accepted for call-site symmetry with the sibling helpers but is
854
+ unused: each project's live spend is resolved from ``session_entries`` via
855
+ the shared ``_sum_cost_by_project`` scan, independent of the just-recorded
856
+ 7d-% snapshot.
857
+
858
+ Invariants preserved byte-for-byte with the global budget path: gate-first,
859
+ pre-probe-before-the-cost-scan, ``rowcount==1`` race guard, set-then-dispatch.
860
+ The cost source is ``_sum_cost_by_project`` (NOT ``_sum_cost_for_range``): it
861
+ skips ``<synthetic>`` entries + buckets by canonical git-root, matching
862
+ ``cmd_project`` and the per-project DISPLAY — so the firing path reconciles
863
+ exactly with the displayed ``consumption_pct``.
864
+ """
865
+ # Gate FIRST (hot-path discipline): no per-project budget OR per-project
866
+ # alerts off → zero overhead for non-users. `load_config()` is safe outside
867
+ # any writer lock (atomic-rename). A malformed budget block is a quiet
868
+ # warn-once no-op (mirrors maybe_record_budget_milestone).
869
+ try:
870
+ budget_cfg = _get_budget_config(load_config())
871
+ except _BudgetConfigError as exc:
872
+ _warn_budget_bad_config_once(exc)
873
+ return
874
+ projects = budget_cfg.get("projects") or {}
875
+ if not projects or not budget_cfg.get("project_alerts_enabled"):
876
+ return
877
+ thresholds = budget_cfg["alert_thresholds"]
878
+ if not thresholds:
879
+ return
880
+
881
+ now_utc = _command_as_of()
882
+ pending_alerts: list[dict[str, Any]] = []
883
+ conn = open_db()
884
+ try:
885
+ window = _resolve_current_budget_window(conn, now_utc)
886
+ if window is None:
887
+ return # no resolvable week window yet
888
+ week_start_at, _week_end_at = window
889
+ week_key = week_start_at.isoformat(timespec="seconds")
890
+
891
+ # Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
892
+ # which configured (project, threshold) pairs are STILL un-recorded for
893
+ # this week? The cost scan is skipped ONLY when EVERY pair already has a
894
+ # row — so a partial prior run (some-but-not-all pairs) still scans for
895
+ # the remainder. The skip never owes a crossing: an un-recorded pair
896
+ # always forces the scan.
897
+ recorded = {
898
+ (str(r[0]), int(r[1]))
899
+ for r in conn.execute(
900
+ "SELECT project_key, threshold "
901
+ "FROM project_budget_milestones WHERE week_start_at = ?",
902
+ (week_key,),
903
+ )
904
+ }
905
+ sorted_thresholds = sorted(thresholds)
906
+ pending = [
907
+ (p, t)
908
+ for p in projects
909
+ for t in sorted_thresholds
910
+ if (p, t) not in recorded
911
+ ]
912
+ if not pending:
913
+ return # nothing left to cross this week → skip the cost scan
914
+
915
+ # Resolve every configured key to its ProjectKey ONCE, then route the
916
+ # firing-path labels through the SAME collision-disambiguation primitive
917
+ # the display uses (`_build_project_budget_rows` → `cmd_project`'s table
918
+ # / share snapshot). A bare `display_key` is just the basename, so a
919
+ # uniquely-named project keeps its bare basename in the notification —
920
+ # byte-matching the table + dashboard chip — and only same-basename
921
+ # roots (`/work/app` + `/personal/app`) get the `(parent)` segment
922
+ # ("app (work)" / "app (personal)"). The configured set is small + the
923
+ # resolver caches, so this is near-free on the rare crossing tick.
924
+ resolver_cache: dict = {}
925
+ proj_keys = sorted(projects)
926
+ pkeys = [
927
+ _resolve_project_key(p, "git-root", resolver_cache)
928
+ for p in proj_keys
929
+ ]
930
+ disambig = _project_disambiguate_labels(
931
+ [{"key": pk} for pk in pkeys]
932
+ )
933
+ label_by_key = {
934
+ proj_keys[i]: disambig.get(i, pkeys[i].display_key)
935
+ for i in range(len(proj_keys))
936
+ }
937
+
938
+ # ONE grouped scan over the week's session entries, bucketed by
939
+ # canonical git-root. skip_sync=False (self-sufficient): the global
940
+ # budget axis only warms the cache when `budget.weekly_usd` is set, so a
941
+ # project-only user (no global budget) reaches here with a cold cache on
942
+ # a no-5h-anchor tick — sync here or a crossing fires a tick late. The
943
+ # pre-probe above already gated this scan to the rare pending-crossing
944
+ # tick, so the self-sufficient sync is near-free.
945
+ by_proj = _sum_cost_by_project(
946
+ week_start_at, now_utc, mode="auto", skip_sync=False
947
+ )
948
+ for project_key, t in pending:
949
+ spent = float(by_proj.get(project_key, 0.0))
950
+ target = float(projects[project_key])
951
+ # +1e-9 snap-up: spent/target*100 can land one ULP below an integer
952
+ # threshold (CLAUDE.md float-floor gotcha).
953
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
954
+ if consumption_pct + 1e-9 >= t:
955
+ inserted = insert_project_budget_milestone(
956
+ conn,
957
+ week_start_at=week_key,
958
+ project_key=project_key,
959
+ threshold=t,
960
+ budget_usd=target,
961
+ spent_usd=spent,
962
+ consumption_pct=consumption_pct,
963
+ commit=False,
964
+ )
965
+ # Only the genuine-new-crossing winner (rowcount==1) dispatches;
966
+ # a racing record-usage instance gets rowcount==0 and skips.
967
+ if inserted == 1:
968
+ crossed_at = now_utc_iso()
969
+ # set-then-dispatch: alerted_at lands on the row BEFORE the
970
+ # Popen, sharing this transaction with the INSERT
971
+ # (commit=False). `alerted_at IS NULL` is write-once
972
+ # defense-in-depth.
973
+ conn.execute(
974
+ "UPDATE project_budget_milestones SET alerted_at = ? "
975
+ "WHERE week_start_at = ? AND project_key = ? "
976
+ " AND threshold = ? AND alerted_at IS NULL",
977
+ (crossed_at, week_key, project_key, t),
978
+ )
979
+ # Label is collision-aware, byte-matching the display: a
980
+ # uniquely-named project notifies as its bare basename,
981
+ # only same-basename roots get the `(parent)` segment (the
982
+ # `label_by_key` map built above via the shared
983
+ # `_project_disambiguate_labels` primitive, spec §5.3).
984
+ project_label = label_by_key.get(
985
+ project_key, os.path.basename(project_key) or project_key
986
+ )
987
+ pending_alerts.append(_build_alert_payload_project_budget(
988
+ threshold=t,
989
+ crossed_at_utc=crossed_at,
990
+ week_start_at=week_key,
991
+ project=project_label,
992
+ project_key=project_key,
993
+ budget_usd=target,
994
+ spent_usd=spent,
995
+ consumption_pct=consumption_pct,
996
+ ))
997
+ # Single commit: every INSERT + its alerted_at marker durable together.
998
+ conn.commit()
999
+ except Exception as exc:
1000
+ eprint(
1001
+ f"[project-budget-milestone] error recording project budget "
1002
+ f"milestone: {exc}"
1003
+ )
1004
+ finally:
1005
+ conn.close()
1006
+
1007
+ # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
1008
+ # (set-then-dispatch invariant — one queue attempt per crossing, deduped on
1009
+ # the alerted_at column).
1010
+ for payload in pending_alerts:
1011
+ try:
1012
+ _dispatch_alert_notification(payload, mode="real")
1013
+ except Exception as dispatch_exc:
1014
+ eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
1015
+
1016
+
823
1017
  def _weekly_pct_week_avg_projection(conn, now_utc):
824
1018
  """Compute the week-AVERAGE weekly-% projection for the current
825
1019
  subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
@@ -2486,6 +2680,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2486
2680
  ).fetchone()
2487
2681
  if latest_row is None:
2488
2682
  return 0
2683
+ latest_saved = _saved_dict_from_usage_row(latest_row)
2489
2684
 
2490
2685
  # Probe 1: do we owe a percent milestone? Snap up before
2491
2686
  # floor (status-line API returns 0.N*100 which can fall
@@ -2599,7 +2794,6 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2599
2794
  heal_conn.close()
2600
2795
 
2601
2796
  if need_milestone_heal or need_5h_heal:
2602
- latest_saved = _saved_dict_from_usage_row(latest_row)
2603
2797
  if need_milestone_heal:
2604
2798
  try:
2605
2799
  maybe_record_milestone(latest_saved)
@@ -2610,6 +2804,27 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2610
2804
  maybe_update_five_hour_block(latest_saved)
2611
2805
  except Exception as exc:
2612
2806
  eprint(f"[5h-block] self-heal error: {exc}")
2807
+
2808
+ # Dollar-decoupled axes (budget / project-budget / projected) heal on
2809
+ # EVERY dedup tick — USD spend can cross a $ threshold while the
2810
+ # weekly/5h percent is flat (so should_insert is False), and a prior
2811
+ # run may have died after insert_usage_snapshot but before the
2812
+ # post-insert axis block. Each helper gates first on config (a cheap
2813
+ # read for non-users) and pre-probes its recorded set before any cost
2814
+ # scan, so non-budget users pay ~nothing here. Order matches the
2815
+ # post-insert block so the projected budget_usd leg's skip_sync=True
2816
+ # cache-warming dependency on the actual-budget axis still holds.
2817
+ # [Dedup mustn't gate side effects]
2818
+ for _heal_fn, _heal_tag in (
2819
+ (maybe_record_budget_milestone, "budget-milestone"),
2820
+ (maybe_record_project_budget_milestone,
2821
+ "project-budget-milestone"),
2822
+ (maybe_record_projected_alert, "projected-alert"),
2823
+ ):
2824
+ try:
2825
+ _heal_fn(latest_saved)
2826
+ except Exception as exc:
2827
+ eprint(f"[{_heal_tag}] self-heal error: {exc}")
2613
2828
  except Exception as exc:
2614
2829
  eprint(f"[record-usage] self-heal lookup failed: {exc}")
2615
2830
  return 0
@@ -2652,6 +2867,17 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2652
2867
  except Exception as exc:
2653
2868
  eprint(f"[budget-milestone] unexpected error: {exc}")
2654
2869
 
2870
+ # NEW: per-project equiv-$ budget alert firing (axis `project_budget`,
2871
+ # #19/#121). Runs AFTER the global budget axis, but the per-project scan is
2872
+ # self-sufficient — it passes skip_sync=False so a project-only user (no
2873
+ # global budget warming the cache) still resolves live spend on this tick.
2874
+ # Gated FIRST on a non-empty budget.projects + project_alerts_enabled — non-
2875
+ # users pay only one config read.
2876
+ try:
2877
+ maybe_record_project_budget_milestone(saved)
2878
+ except Exception as exc:
2879
+ eprint(f"[project-budget-milestone] unexpected error: {exc}")
2880
+
2655
2881
  # NEW: projected-pace alert firing (axis `projected`, #121). Runs in its
2656
2882
  # OWN detect-and-arm AFTER the weekly/5h/budget blocks; gated up front on
2657
2883
  # (alerts.enabled && alerts.projected_enabled) ||
@@ -3,7 +3,7 @@
3
3
  Single source of truth for axis *metadata* — id, chip/title labels (kept
4
4
  byte-identical with dashboard/web/src/lib/alertAxis.ts), the milestone-table
5
5
  name used by the dashboard envelope, and the axis-uniform severity policy
6
- (amber <95 / red >=95). This kernel does NOT own the write/transaction path:
6
+ (info <90 / warn 90-99 / critical >=100). This kernel does NOT own the write/transaction path:
7
7
  each axis keeps its own detect-and-arm code in bin/_cctally_record.py. The
8
8
  descriptor is the metadata/render contract, not the write engine.
9
9
 
@@ -14,21 +14,30 @@ from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
16
 
17
- # Severity boundary: thresholds at or above this render red, below it amber.
18
- # Mirrors the legacy hardcoded amber<95 / red>=95 split (axis-uniform v1).
19
- _SEVERITY_RED_FLOOR = 95
17
+ # Severity bands (Phase B): info < warn floor <= warn < critical floor <= critical.
18
+ # The top tier means "hit the ceiling" (100% weekly = rate-limited, budget 100% =
19
+ # over). Maps onto notify-send's low/normal/critical urgency levels.
20
+ _SEVERITY_WARN_FLOOR = 90
21
+ _SEVERITY_CRITICAL_FLOOR = 100
20
22
 
21
23
 
22
24
  def severity_for(threshold: int) -> str:
23
- """Map a crossed integer threshold to a severity color ('amber' | 'red')."""
24
- return "red" if int(threshold) >= _SEVERITY_RED_FLOOR else "amber"
25
+ """Map a crossed integer threshold to a 3-tier severity
26
+ ('info' | 'warn' | 'critical'). Axis-uniform; the single authority kept
27
+ byte-identical with dashboard/web/src/lib/alertAxis.ts::alertSeverity."""
28
+ t = int(threshold)
29
+ if t >= _SEVERITY_CRITICAL_FLOOR:
30
+ return "critical"
31
+ if t >= _SEVERITY_WARN_FLOOR:
32
+ return "warn"
33
+ return "info"
25
34
 
26
35
 
27
36
  @dataclass(frozen=True)
28
37
  class AlertAxisDescriptor:
29
38
  """Axis-agnostic metadata shared by the record path + dashboard envelope."""
30
39
 
31
- id: str # 'weekly' | 'five_hour' | 'budget' | 'projected'
40
+ id: str # 'weekly' | 'five_hour' | 'budget' | 'projected' | 'project_budget'
32
41
  chip_label: str # SHOUT form, byte-identical with alertAxis.ts AXIS_CHIP_LABEL
33
42
  title_label: str # sentence-case form, byte-identical with AXIS_TITLE_LABEL
34
43
  milestone_table: str # SQLite table the dashboard envelope SELECTs from
@@ -39,6 +48,11 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
39
48
  AlertAxisDescriptor("five_hour", "5H-BLOCK", "5h-block", "five_hour_milestones"),
40
49
  AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones"),
41
50
  AlertAxisDescriptor("projected", "PROJECTED", "Projected", "projected_milestones"),
51
+ # Per-project weekly budget alerts (issue #19 / #121). Distinct "PROJECT"
52
+ # chip vs the global "BUDGET" chip; its own forward-only table.
53
+ AlertAxisDescriptor(
54
+ "project_budget", "PROJECT", "Project budget", "project_budget_milestones"
55
+ ),
42
56
  )
43
57
 
44
58
  AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}