cctally 1.80.4 → 1.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +55 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -57,6 +57,8 @@ def compute_week_cost(
57
57
  project: str | None,
58
58
  start_iso_override: str | None = None,
59
59
  end_iso_override: str | None = None,
60
+ *,
61
+ account_key: "str | None" = None,
60
62
  ) -> WeekCostResult:
61
63
  # internal fallback: host-local intentional
62
64
  now_local = dt.datetime.now().astimezone()
@@ -101,7 +103,11 @@ def compute_week_cost(
101
103
  end_dt = parse_iso_datetime(end_iso, "end")
102
104
 
103
105
  c = _cctally()
104
- cost = c._sum_cost_for_range(start_dt, end_dt, mode=mode, project=project)
106
+ # #341 P2-CQ2: scope the cost sum to the account the snapshot is stamped for
107
+ # (None = merged / byte-identical) so per-account weekly_cost_snapshots + the
108
+ # milestone $/1% cumulative cost carry genuinely per-account cost.
109
+ cost = c._sum_cost_for_range(
110
+ start_dt, end_dt, mode=mode, project=project, account_key=account_key)
105
111
 
106
112
  return WeekCostResult(
107
113
  week_start=week_start,
@@ -112,8 +118,12 @@ def compute_week_cost(
112
118
  )
113
119
 
114
120
 
115
- def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
116
- return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
121
+ def get_latest_cost_for_week(
122
+ conn: sqlite3.Connection, week_ref: WeekRef, *,
123
+ account_key: "str | None" = None,
124
+ ) -> sqlite3.Row | None:
125
+ return _get_latest_row_for_week(
126
+ conn, "weekly_cost_snapshots", week_ref, account_key=account_key)
117
127
 
118
128
 
119
129
  def insert_cost_snapshot(
@@ -131,9 +141,15 @@ def insert_cost_snapshot(
131
141
  commit: bool = True,
132
142
  as_of: "str | None" = None,
133
143
  journal: "tuple | None" = None,
144
+ account_key: str = "unattributed",
134
145
  ) -> int:
135
146
  """Insert a ``weekly_cost_snapshots`` row and return its rowid.
136
147
 
148
+ ``account_key`` (#341): the account dimension of the write. Default
149
+ ``"unattributed"`` mirrors the schema DEFAULT (rev 4.1 defensive backstop);
150
+ per-account ``sync-week`` materialization (Step 9 / Task 3) passes the
151
+ resolved account explicitly.
152
+
137
153
  Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
138
154
  ``commit=False`` skips the inner ``conn.commit()`` so the ingester can bundle
139
155
  this insert into its single cycle transaction; ``as_of`` (ISO-Z) overrides
@@ -174,6 +190,7 @@ def insert_cost_snapshot(
174
190
  "cost_usd": cost_usd,
175
191
  "mode": mode,
176
192
  "project": project,
193
+ "account_key": account_key,
177
194
  },
178
195
  at=captured,
179
196
  )
@@ -190,9 +207,10 @@ def insert_cost_snapshot(
190
207
  range_end_iso,
191
208
  cost_usd,
192
209
  mode,
193
- project
210
+ project,
211
+ account_key
194
212
  )
195
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
196
214
  """,
197
215
  (
198
216
  captured,
@@ -205,6 +223,7 @@ def insert_cost_snapshot(
205
223
  cost_usd,
206
224
  mode,
207
225
  project,
226
+ account_key,
208
227
  ),
209
228
  )
210
229
  if commit:
@@ -217,6 +236,7 @@ def get_max_milestone_for_week(
217
236
  week_start_date: str,
218
237
  *,
219
238
  reset_event_id: int = 0,
239
+ account_key: str,
220
240
  ) -> int | None:
221
241
  """Return the highest percent_threshold recorded for a week's segment,
222
242
  or None.
@@ -227,6 +247,11 @@ def get_max_milestone_for_week(
227
247
  segment id so the segment's threshold ledger is independent of the
228
248
  pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
229
249
  if the pre-credit segment already crossed those thresholds.
250
+
251
+ ``account_key`` (#341, review finding 11): MANDATORY — the max is scoped
252
+ to one account's ledger so a second account crossing the SAME threshold in
253
+ the SAME week is not silently deduped against the first. No silent global
254
+ fallback: the caller resolves the account being processed.
230
255
  """
231
256
  row = conn.execute(
232
257
  """
@@ -234,8 +259,9 @@ def get_max_milestone_for_week(
234
259
  FROM percent_milestones
235
260
  WHERE week_start_date = ?
236
261
  AND reset_event_id = ?
262
+ AND account_key = ?
237
263
  """,
238
- (week_start_date, reset_event_id),
264
+ (week_start_date, reset_event_id, account_key),
239
265
  ).fetchone()
240
266
  if row and row["max_pct"] is not None:
241
267
  return int(row["max_pct"])
@@ -248,6 +274,7 @@ def get_milestone_cost_for_week(
248
274
  percent_threshold: int,
249
275
  *,
250
276
  reset_event_id: int = 0,
277
+ account_key: str,
251
278
  ) -> float | None:
252
279
  """Return the cumulative_cost_usd for a specific (week, threshold,
253
280
  segment), or None.
@@ -257,6 +284,9 @@ def get_milestone_cost_for_week(
257
284
  marginal cost between consecutive thresholds inside the SAME segment
258
285
  — without the filter, the post-credit threshold-3 row would compute
259
286
  its marginal against the pre-credit threshold-2 cost (wrong segment).
287
+
288
+ ``account_key`` (#341): MANDATORY — scoped to one account's ledger (a
289
+ marginal must be computed against the SAME account's prior threshold row).
260
290
  """
261
291
  row = conn.execute(
262
292
  """
@@ -265,8 +295,9 @@ def get_milestone_cost_for_week(
265
295
  WHERE week_start_date = ?
266
296
  AND percent_threshold = ?
267
297
  AND reset_event_id = ?
298
+ AND account_key = ?
268
299
  """,
269
- (week_start_date, percent_threshold, reset_event_id),
300
+ (week_start_date, percent_threshold, reset_event_id, account_key),
270
301
  ).fetchone()
271
302
  if row:
272
303
  return float(row["cumulative_cost_usd"])
@@ -276,16 +307,25 @@ def get_milestone_cost_for_week(
276
307
  def get_milestones_for_week(
277
308
  conn: sqlite3.Connection,
278
309
  week_start_date: str,
310
+ *,
311
+ account_key: "str | None" = None,
279
312
  ) -> list[sqlite3.Row]:
280
- """Return all milestones for a week, ordered by threshold ascending."""
313
+ """Return all milestones for a week, ordered by threshold ascending.
314
+
315
+ ``account_key`` (#341, spec §3): ``None`` = the account-blind merged read
316
+ (today's byte-identical behavior); a real key / ``unattributed`` scopes the
317
+ ``percent_milestones`` read to that account (``percent-breakdown --account``).
318
+ """
319
+ acct_pred = "" if account_key is None else " AND account_key = ?"
320
+ acct_p: tuple = () if account_key is None else (account_key,)
281
321
  return conn.execute(
282
- """
322
+ f"""
283
323
  SELECT *
284
324
  FROM percent_milestones
285
- WHERE week_start_date = ?
325
+ WHERE week_start_date = ?{acct_pred}
286
326
  ORDER BY percent_threshold ASC
287
327
  """,
288
- (week_start_date,),
328
+ (week_start_date,) + acct_p,
289
329
  ).fetchall()
290
330
 
291
331
 
@@ -305,9 +345,18 @@ def insert_percent_milestone(
305
345
  commit: bool = True,
306
346
  reset_event_id: int = 0,
307
347
  as_of: "str | None" = None,
348
+ account_key: str = "unattributed",
308
349
  ) -> int:
309
350
  """Insert a percent_milestones row idempotently.
310
351
 
352
+ ``account_key`` (#341): the account dimension of the write. Default
353
+ ``"unattributed"`` mirrors the schema DEFAULT as a defensive backstop
354
+ (rev 4.1); the production writer (``maybe_record_milestone``) passes the
355
+ resolved account explicitly, enforced by the structural writer-audit test.
356
+ Participates in the ``UNIQUE(account_key, week_start_date, percent_threshold,
357
+ reset_event_id)`` dedup key so two accounts crossing the same threshold in
358
+ the same week each get their own row.
359
+
311
360
  ``as_of`` (DB journal redesign §5.2.3): overrides the ``captured_at_utc``
312
361
  wall-clock stamp with the record's capture time when the ingester drives
313
362
  this at fold time; ``None`` keeps the legacy ``now_utc_iso()`` behavior.
@@ -356,9 +405,10 @@ def insert_percent_milestone(
356
405
  usage_snapshot_id,
357
406
  cost_snapshot_id,
358
407
  five_hour_percent_at_crossing,
359
- reset_event_id
408
+ reset_event_id,
409
+ account_key
360
410
  )
361
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
411
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
362
412
  """,
363
413
  (
364
414
  as_of or now_utc_iso(),
@@ -373,6 +423,7 @@ def insert_percent_milestone(
373
423
  cost_snapshot_id,
374
424
  five_hour_percent_at_crossing,
375
425
  reset_event_id,
426
+ account_key,
376
427
  ),
377
428
  )
378
429
  if commit:
@@ -392,6 +443,7 @@ def insert_budget_milestone(
392
443
  consumption_pct: float,
393
444
  commit: bool = True,
394
445
  as_of: "str | None" = None,
446
+ account_key: str = "*",
395
447
  ) -> int:
396
448
  """INSERT OR IGNORE a budget threshold crossing into the unified vendor-tagged
397
449
  table (#143). Returns ``cur.rowcount`` (1 = genuinely new crossing, 0 =
@@ -407,7 +459,11 @@ def insert_budget_milestone(
407
459
  ``period`` (#137) is the configured period noun at crossing
408
460
  ('calendar-week'|'calendar-month'|'subscription-week'); it discriminates the
409
461
  UNIQUE key so calendar-week and calendar-month windows that share a start
410
- instant don't collide. A NULL ``period`` is the pre-011 "unknown" sentinel
462
+ instant don't collide. ``account_key`` (#341 Step 4-eval) discriminates the
463
+ per-account ladder from the vendor-wide ladder: ``"*"`` (the schema DEFAULT +
464
+ this arg's default) is the vendor-wide row; a real account key is that
465
+ account's own ladder — ``UNIQUE(vendor, account_key, period_start_at, period,
466
+ threshold)``. A NULL ``period`` is the pre-011 "unknown" sentinel
411
467
  (only seeded migration rows carry it). ``alerted_at`` is left NULL — the
412
468
  caller stamps it in the SAME transaction BEFORE dispatching (set-then-dispatch
413
469
  invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle
@@ -416,11 +472,12 @@ def insert_budget_milestone(
416
472
  """
417
473
  cur = conn.execute(
418
474
  "INSERT OR IGNORE INTO budget_milestones "
419
- "(vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
420
- " consumption_pct, crossed_at_utc) "
421
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
475
+ "(vendor, account_key, period_start_at, period, threshold, budget_usd, "
476
+ " spent_usd, consumption_pct, crossed_at_utc) "
477
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
422
478
  (
423
479
  str(vendor),
480
+ str(account_key),
424
481
  period_start_at,
425
482
  period,
426
483
  int(threshold),
@@ -562,7 +619,8 @@ def _projected_levels_already_latched(
562
619
  return all(int(level) in have for level in levels)
563
620
 
564
621
 
565
- def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz):
622
+ def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz,
623
+ window_account_key=None):
566
624
  """Resolve the Claude budget's ``(start_utc, end_utc)`` window for the
567
625
  configured ``period`` (calendar-period-codex-budgets generalization, spec
568
626
  §6). Subscription-week → the existing ``_resolve_current_budget_window``
@@ -570,14 +628,21 @@ def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz):
570
628
  yet). Calendar period → the pure ``_resolve_calendar_window`` (derived purely
571
629
  from ``now`` + the period; NEVER ``None``). The dedup key column is now
572
630
  ``period_start_at`` (#143) — it carries the resolved PERIOD-start instant
573
- (subscription-week OR calendar period-start)."""
631
+ (subscription-week OR calendar period-start).
632
+
633
+ ``window_account_key`` (#341, spec §6 `*`-anchor): scopes the
634
+ subscription-week snapshot window to one account (the active account for a
635
+ `*` ladder, the ladder's own account for a per-account ladder). ``None`` =
636
+ merged (byte-identical); calendar periods ignore it (pure calendar)."""
574
637
  c = _cctally()
575
638
  if period == "subscription-week":
576
- return c._resolve_current_budget_window(conn, now_utc)
639
+ return c._resolve_current_budget_window(
640
+ conn, now_utc, account_key=window_account_key)
577
641
  return c._resolve_calendar_window(period, now_utc, config, tz)
578
642
 
579
643
 
580
- def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz):
644
+ def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz,
645
+ window_account_key=None):
581
646
  """Resolve the budget period-start instant for ``vendor`` (#143). CHEAP — does
582
647
  NO cost SUM, preserving the pre-probe-before-spend hot path (spec §4.2): the
583
648
  firing paths resolve this cheap window, pre-probe which thresholds are already
@@ -594,7 +659,8 @@ def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz):
594
659
  pre-snapshot)."""
595
660
  if vendor == "claude":
596
661
  window = _resolve_claude_budget_window(
597
- conn, now_utc, period=period, config=config, tz=tz
662
+ conn, now_utc, period=period, config=config, tz=tz,
663
+ window_account_key=window_account_key,
598
664
  )
599
665
  else:
600
666
  window = _resolve_codex_budget_period_window(period, now_utc, config, tz)
@@ -604,20 +670,34 @@ def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz):
604
670
  return start_at
605
671
 
606
672
 
607
- def _budget_spend_for_vendor(conn, *, vendor, start_at, now_utc) -> float:
673
+ def _budget_spend_for_vendor(conn, *, vendor, start_at, now_utc,
674
+ account_key: str = "*") -> float:
608
675
  """Spend over ``[start_at, now]`` for ``vendor`` (#143) — the COSTLY leg,
609
676
  called only after the pre-probe finds pending thresholds (spec §4.2). claude
610
677
  routes through the Claude cost SUM (``mode="auto"``); codex through the Codex
611
- cost SUM."""
678
+ cost SUM.
679
+
680
+ ``account_key`` (#341 Step 4-eval, spec §6): the vendor-wide sentinel ``"*"``
681
+ sums EVERY account's spend (including ``unattributed`` — the guaranteed-
682
+ complete vendor total), so it reads unscoped (``account_key=None`` on the
683
+ cost SUM). A REAL account scopes the sum to that account's stamped entries so
684
+ a per-account ladder counts only its own spend (unattributed spend can never
685
+ trip a per-account alert)."""
612
686
  c = _cctally()
687
+ scope = None if account_key == "*" else account_key
688
+ # Byte-stability: the vendor-wide (`*`) path calls with the EXACT legacy
689
+ # signature (no `account_key` kwarg), so existing cost-sum test doubles +
690
+ # every non-account install are untouched. Only a per-account ladder passes
691
+ # the kwarg (its doubles accept it).
692
+ extra = {} if scope is None else {"account_key": scope}
613
693
  if vendor == "claude":
614
- return c._sum_cost_for_range(start_at, now_utc, mode="auto")
615
- return c._sum_codex_cost_for_range(start_at, now_utc)
694
+ return c._sum_cost_for_range(start_at, now_utc, mode="auto", **extra)
695
+ return c._sum_codex_cost_for_range(start_at, now_utc, **extra)
616
696
 
617
697
 
618
698
  def _reconcile_budget_milestones_on_set(
619
699
  conn, *, vendor, target, thresholds, now_utc, period, config=None, tz=None,
620
- as_of=None, commit=True,
700
+ as_of=None, commit=True, account_key: str = "*",
621
701
  ):
622
702
  """Forward-only-from-set reconcile for the budget axis (both vendors, #143):
623
703
  on `budget set`, every threshold ALREADY crossed for the current
@@ -644,7 +724,8 @@ def _reconcile_budget_milestones_on_set(
644
724
  return
645
725
  period_key = start_at.isoformat(timespec="seconds")
646
726
  spent = _budget_spend_for_vendor(
647
- conn, vendor=vendor, start_at=start_at, now_utc=now_utc
727
+ conn, vendor=vendor, start_at=start_at, now_utc=now_utc,
728
+ account_key=account_key,
648
729
  )
649
730
  # target > 0 guaranteed by the caller (validated weekly_usd / amount_usd);
650
731
  # the else is belt-and-suspenders.
@@ -662,15 +743,17 @@ def _reconcile_budget_milestones_on_set(
662
743
  consumption_pct=consumption_pct,
663
744
  commit=False,
664
745
  as_of=as_of,
746
+ account_key=account_key,
665
747
  )
666
- # alerted_at UPDATE keys on the CONCRETE (vendor, period) (not the
667
- # wildcard): only the row we just inserted is stamped, never a
668
- # pre-011 NULL-period sibling (#137) or another vendor's row (#143).
748
+ # alerted_at UPDATE keys on the CONCRETE (vendor, account_key, period)
749
+ # (not the wildcard): only the row we just inserted is stamped, never
750
+ # a pre-011 NULL-period sibling (#137), another vendor's row (#143),
751
+ # or a different account's ladder (#341).
669
752
  conn.execute(
670
753
  "UPDATE budget_milestones SET alerted_at = ? "
671
- "WHERE vendor = ? AND period_start_at = ? AND period = ? "
672
- " AND threshold = ? AND alerted_at IS NULL",
673
- (as_of or now_utc_iso(), vendor, period_key, period, t),
754
+ "WHERE vendor = ? AND account_key = ? AND period_start_at = ? "
755
+ " AND period = ? AND threshold = ? AND alerted_at IS NULL",
756
+ (as_of or now_utc_iso(), vendor, account_key, period_key, period, t),
674
757
  )
675
758
  if commit:
676
759
  conn.commit()
@@ -690,7 +773,7 @@ def _resolve_codex_budget_period_window(period, now_utc, config, tz):
690
773
 
691
774
  def _budget_crossings(
692
775
  conn, *, vendor, period_key, period=None, thresholds, target, spent, now_utc,
693
- as_of=None,
776
+ as_of=None, account_key: str = "*",
694
777
  ):
695
778
  """Shared INSERT-and-arm core for the budget axis (both vendors, #143): for
696
779
  every STILL-pending threshold that's been crossed at ``spent``, ``INSERT OR
@@ -727,17 +810,19 @@ def _budget_crossings(
727
810
  consumption_pct=consumption_pct,
728
811
  commit=False,
729
812
  as_of=as_of,
813
+ account_key=account_key,
730
814
  )
731
815
  if inserted == 1:
732
816
  crossed_at = as_of or now_utc_iso()
733
- # alerted_at UPDATE keys on the CONCRETE (vendor, period) (#137 /
734
- # #143): only the row just inserted is stamped, never a pre-011
735
- # NULL-period sibling or another vendor's row.
817
+ # alerted_at UPDATE keys on the CONCRETE (vendor, account_key,
818
+ # period) (#137 / #143 / #341): only the row just inserted is
819
+ # stamped, never a pre-011 NULL-period sibling, another vendor's
820
+ # row, or a different account's ladder.
736
821
  conn.execute(
737
822
  "UPDATE budget_milestones SET alerted_at = ? "
738
- "WHERE vendor = ? AND period_start_at = ? AND period = ? "
739
- " AND threshold = ? AND alerted_at IS NULL",
740
- (crossed_at, vendor, period_key, period, t),
823
+ "WHERE vendor = ? AND account_key = ? AND period_start_at = ? "
824
+ " AND period = ? AND threshold = ? AND alerted_at IS NULL",
825
+ (crossed_at, vendor, account_key, period_key, period, t),
741
826
  )
742
827
  fired.append((t, crossed_at, spent, target, consumption_pct))
743
828
  return fired
@@ -759,7 +844,11 @@ def _reconcile_codex_budget_on_config_write(validated_budget, *, conn=None, as_o
759
844
  if not codex:
760
845
  return
761
846
  thresholds = codex.get("alert_thresholds") or []
762
- if not (codex.get("alerts_enabled") and codex.get("amount_usd") and thresholds):
847
+ amount_usd = codex.get("amount_usd")
848
+ # Per-account Codex ladders (#341 Step 4-eval): reconcile each account too.
849
+ accounts = codex.get("accounts") or {}
850
+ if not (codex.get("alerts_enabled") and thresholds
851
+ and (amount_usd is not None or accounts)):
763
852
  return
764
853
  c = _cctally()
765
854
  own_conn = conn is None
@@ -767,21 +856,30 @@ def _reconcile_codex_budget_on_config_write(validated_budget, *, conn=None, as_o
767
856
  import argparse
768
857
  config = c.load_config()
769
858
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
859
+ now_utc = _as_of_or_command(as_of)
770
860
  if own_conn:
771
861
  conn = open_db()
772
862
  try:
773
- _reconcile_budget_milestones_on_set(
774
- conn,
775
- vendor="codex",
776
- target=codex["amount_usd"],
777
- thresholds=thresholds,
778
- now_utc=_as_of_or_command(as_of),
779
- period=codex["period"],
780
- config=config,
781
- tz=tz,
782
- as_of=as_of,
783
- commit=own_conn,
784
- )
863
+ ladders = []
864
+ if amount_usd is not None:
865
+ ladders.append(("*", amount_usd))
866
+ ladders.extend((k, v) for k, v in accounts.items())
867
+ for acct_key, acct_usd in ladders:
868
+ _reconcile_budget_milestones_on_set(
869
+ conn,
870
+ vendor="codex",
871
+ target=acct_usd,
872
+ thresholds=thresholds,
873
+ now_utc=now_utc,
874
+ period=codex["period"],
875
+ config=config,
876
+ tz=tz,
877
+ as_of=as_of,
878
+ commit=False,
879
+ account_key=acct_key,
880
+ )
881
+ if own_conn:
882
+ conn.commit()
785
883
  finally:
786
884
  if own_conn:
787
885
  conn.close()
@@ -801,7 +899,13 @@ def _reconcile_budget_on_config_write(validated_budget, *, conn=None, as_of=None
801
899
  open/commit/close); ``as_of`` (ISO-Z) injects the capture time. Both
802
900
  defaults keep the legacy own-connection, wall-clock behavior."""
803
901
  thresholds = validated_budget.get("alert_thresholds") or []
804
- if not (_budget_alerts_active(validated_budget) and thresholds):
902
+ alerts_enabled = bool(validated_budget.get("alerts_enabled"))
903
+ weekly_usd = validated_budget.get("weekly_usd")
904
+ # Per-account ladders (#341 Step 4-eval): reconcile each real account in
905
+ # `budget.accounts` too, so setting a per-account budget mid-week (already
906
+ # over) records the crossed thresholds as already-alerted WITHOUT dispatch.
907
+ accounts = validated_budget.get("accounts") or {}
908
+ if not thresholds or not alerts_enabled or (weekly_usd is None and not accounts):
805
909
  return
806
910
  c = _cctally()
807
911
  period = validated_budget.get("period", "subscription-week")
@@ -810,21 +914,30 @@ def _reconcile_budget_on_config_write(validated_budget, *, conn=None, as_of=None
810
914
  import argparse
811
915
  config = c.load_config()
812
916
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
917
+ now_utc = _as_of_or_command(as_of)
813
918
  if own_conn:
814
919
  conn = open_db()
815
920
  try:
816
- _reconcile_budget_milestones_on_set(
817
- conn,
818
- vendor="claude",
819
- target=validated_budget["weekly_usd"],
820
- thresholds=thresholds,
821
- now_utc=_as_of_or_command(as_of),
822
- period=period,
823
- config=config,
824
- tz=tz,
825
- as_of=as_of,
826
- commit=own_conn,
827
- )
921
+ ladders = []
922
+ if weekly_usd is not None:
923
+ ladders.append(("*", weekly_usd))
924
+ ladders.extend((k, v) for k, v in accounts.items())
925
+ for acct_key, acct_usd in ladders:
926
+ _reconcile_budget_milestones_on_set(
927
+ conn,
928
+ vendor="claude",
929
+ target=acct_usd,
930
+ thresholds=thresholds,
931
+ now_utc=now_utc,
932
+ period=period,
933
+ config=config,
934
+ tz=tz,
935
+ as_of=as_of,
936
+ commit=False,
937
+ account_key=acct_key,
938
+ )
939
+ if own_conn:
940
+ conn.commit()
828
941
  finally:
829
942
  if own_conn:
830
943
  conn.close()