cctally 1.80.3 → 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 +29 -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 +459 -59
  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-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -116,11 +116,21 @@ def _resolve_forecast_now(as_of: str | None) -> dt.datetime:
116
116
  return _command_as_of()
117
117
 
118
118
 
119
- def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime):
119
+ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime,
120
+ *, account_key: "str | None" = None):
120
121
  """Return (week_start_at, week_end_at, list[(captured_at, percent, five_hr)])
121
122
  for the subscription week containing `now_utc`, or None if no snapshot
122
123
  exists for the current week.
123
124
 
125
+ ``account_key`` (#341, spec §6 rev-4 `*`-anchor): scopes the snapshot window
126
+ to ONE account's `weekly_usage_snapshots` rows so a `*`-scoped
127
+ subscription-week writer (vendor budget ladder / vendor-budget projected
128
+ metric / project_budget) resolves "the current week" as the ACTIVE account's
129
+ week rather than an account-blind latest. ``None`` (every non-account caller —
130
+ forecast / weekly / TUI / the budget display) is the merged read, byte-
131
+ identical to today; a <=1-real-account install's rows all default to
132
+ `unattributed`, so scoping to `unattributed` returns the same window.
133
+
124
134
  Selection: the snapshot whose [week_start_at, week_end_at) contains
125
135
  now_utc; ties (none expected) broken by max(captured_at_utc).
126
136
 
@@ -137,11 +147,17 @@ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime
137
147
  is deterministic (no leak of future snapshots into samples[-1] / p_now /
138
148
  snapshot_count / latest_snapshot_at).
139
149
  """
150
+ # #341: optional account scoping — same predicate on every leg so the whole
151
+ # window resolution stays consistent (a real key or the `unattributed`
152
+ # sentinel both match `account_key = ?`; `None` omits the predicate = merged).
153
+ _acct_pred = "" if account_key is None else " AND account_key = ?"
154
+ _acct_p: tuple = () if account_key is None else (account_key,)
140
155
  candidates = conn.execute(
141
156
  "SELECT week_start_at, week_end_at, week_start_date, MAX(captured_at_utc) AS latest_cap "
142
157
  "FROM weekly_usage_snapshots "
143
- "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
144
- "GROUP BY week_start_at, week_end_at, week_start_date"
158
+ "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL" + _acct_pred +
159
+ " GROUP BY week_start_at, week_end_at, week_start_date",
160
+ _acct_p,
145
161
  ).fetchall()
146
162
  chosen = None
147
163
  chosen_cap = None
@@ -165,10 +181,10 @@ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime
165
181
  drow = conn.execute(
166
182
  "SELECT week_start_date, week_end_date "
167
183
  "FROM weekly_usage_snapshots "
168
- "WHERE week_start_date <= ? AND week_end_date >= ? "
169
- "GROUP BY week_start_date, week_end_date "
184
+ "WHERE week_start_date <= ? AND week_end_date >= ?" + _acct_pred +
185
+ " GROUP BY week_start_date, week_end_date "
170
186
  "ORDER BY MAX(captured_at_utc) DESC LIMIT 1",
171
- (today_local_str, today_local_str),
187
+ (today_local_str, today_local_str) + _acct_p,
172
188
  ).fetchone()
173
189
  if drow is None:
174
190
  return None
@@ -181,9 +197,9 @@ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime
181
197
  rows = conn.execute(
182
198
  "SELECT captured_at_utc, weekly_percent, five_hour_percent "
183
199
  "FROM weekly_usage_snapshots "
184
- "WHERE week_start_date = ? "
185
- "ORDER BY captured_at_utc ASC",
186
- (drow[0],),
200
+ "WHERE week_start_date = ?" + _acct_pred +
201
+ " ORDER BY captured_at_utc ASC",
202
+ (drow[0],) + _acct_p,
187
203
  ).fetchall()
188
204
  samples = [
189
205
  (parse_iso_datetime(r[0], "captured_at_utc"), float(r[1]),
@@ -212,10 +228,10 @@ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime
212
228
  rows = conn.execute(
213
229
  f"SELECT captured_at_utc, weekly_percent, five_hour_percent "
214
230
  f"FROM weekly_usage_snapshots "
215
- f"WHERE week_start_at IN ({placeholders}) "
216
- f" OR (week_start_at IS NULL AND week_start_date = ?) "
231
+ f"WHERE (week_start_at IN ({placeholders}) "
232
+ f" OR (week_start_at IS NULL AND week_start_date = ?)){_acct_pred} "
217
233
  f"ORDER BY captured_at_utc ASC",
218
- tuple(matching_texts) + (chosen_date,),
234
+ tuple(matching_texts) + (chosen_date,) + _acct_p,
219
235
  ).fetchall()
220
236
  samples = [
221
237
  (parse_iso_datetime(r[0], "captured_at_utc"), float(r[1]),
@@ -264,11 +280,15 @@ def _apply_midweek_reset_override(
264
280
  return week_start_at, samples
265
281
 
266
282
 
267
- def _resolve_current_budget_window(conn, now_utc):
283
+ def _resolve_current_budget_window(conn, now_utc, *, account_key=None):
268
284
  """Return ``(effective_week_start_dt, week_end_dt)`` for the subscription
269
285
  week containing ``now_utc``, honoring a mid-week reset re-anchor; or
270
286
  ``None`` if no snapshot exists yet.
271
287
 
288
+ ``account_key`` (#341, spec §6 `*`-anchor): scopes the window to one
289
+ account's snapshots (passed through to ``_fetch_current_week_snapshots``).
290
+ ``None`` = merged / byte-identical.
291
+
272
292
  Reuses the SAME reset-aware resolution forecast/weekly use
273
293
  (``_fetch_current_week_snapshots`` + ``_apply_midweek_reset_override``)
274
294
  so the budget display window and the alert-firing window (Task 3) agree.
@@ -278,7 +298,7 @@ def _resolve_current_budget_window(conn, now_utc):
278
298
  the window, so the worst case is ``spent_usd = 0`` (spec §6), not a
279
299
  no-window outcome.
280
300
  """
281
- fetched = _fetch_current_week_snapshots(conn, now_utc)
301
+ fetched = _fetch_current_week_snapshots(conn, now_utc, account_key=account_key)
282
302
  if fetched is None:
283
303
  return None
284
304
  week_start_at, week_end_at, samples = fetched
@@ -347,12 +367,18 @@ def _select_dollars_per_percent(
347
367
  *,
348
368
  skip_sync: bool = False,
349
369
  use_weekref_cost_cache: bool = False,
370
+ account_key: "str | None" = None,
350
371
  ) -> tuple[float, str]:
351
372
  """Return (dollars_per_percent, source_label). See spec §1 selection rule.
352
373
 
353
374
  Eligible prior week: week_end_at < now_utc AND final_weekly_percent >= 1.
354
375
  Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
355
376
  via `get_entries`); `conn` is only used for snapshot queries.
377
+
378
+ ``account_key`` (#341, spec §3): ``None`` = the account-blind merged read
379
+ (byte-identical to today); a real key / ``unattributed`` scopes both the
380
+ current-week ``spent_usd``/``p_now`` inputs (already scoped by the caller) and
381
+ the trailing-4wk-median prior-week snapshot + cost reads to that account.
356
382
  """
357
383
  c = _cctally()
358
384
  # Path 1: current week, stable sample.
@@ -365,13 +391,15 @@ def _select_dollars_per_percent(
365
391
  # needs 4 eligible; 12 leaves margin so flooring a credited week below 1%
366
392
  # can't starve it, while keeping this hot path (live forecast view +
367
393
  # dashboard refresh) from materializing all history.
394
+ _acct_pred = "" if account_key is None else " AND account_key = ?"
395
+ _acct_p: tuple = () if account_key is None else (account_key,)
368
396
  cand = conn.execute(
369
397
  "SELECT DISTINCT week_start_at, week_end_at, week_start_date "
370
398
  "FROM weekly_usage_snapshots "
371
399
  "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
372
- " AND datetime(week_start_at) < datetime(?) "
373
- "ORDER BY datetime(week_start_at) DESC LIMIT 12",
374
- (current_week_start.isoformat(),),
400
+ " AND datetime(week_start_at) < datetime(?)" + _acct_pred +
401
+ " ORDER BY datetime(week_start_at) DESC LIMIT 12",
402
+ (current_week_start.isoformat(),) + _acct_p,
375
403
  ).fetchall()
376
404
  we_by_ws: dict[dt.datetime, dt.datetime] = {}
377
405
  rows_in: list = []
@@ -382,8 +410,8 @@ def _select_dollars_per_percent(
382
410
  "SELECT week_start_date, week_start_at, week_end_at, "
383
411
  " captured_at_utc, weekly_percent "
384
412
  "FROM weekly_usage_snapshots "
385
- "WHERE week_start_at IN (" + placeholders + ")",
386
- ws_iso_list,
413
+ "WHERE week_start_at IN (" + placeholders + ")" + _acct_pred,
414
+ ws_iso_list + list(_acct_p),
387
415
  ).fetchall()
388
416
  for wsd, ws_iso, we_iso, cap_iso, pct in snap:
389
417
  if pct is None:
@@ -395,7 +423,8 @@ def _select_dollars_per_percent(
395
423
  continue
396
424
  we_by_ws.setdefault(ws, we) # first-wins; all rows share one instant
397
425
  rows_in.append((ws, wsd, ws_iso, we_iso, cap_iso, pct))
398
- floored = c._floored_week_max(conn, rows_in) # {ws_instant -> floored max}
426
+ floored = c._floored_week_max(
427
+ conn, rows_in, account_key=account_key) # {ws_instant -> floored max}
399
428
  eligible: list[tuple[dt.datetime, dt.datetime, float]] = [
400
429
  (ws, we_by_ws[ws], floored[ws])
401
430
  for ws in floored
@@ -422,12 +451,14 @@ def _select_dollars_per_percent(
422
451
  week_cost = _sc.cached_weekref_cost(
423
452
  week_start_at=ws, week_end_at=we, now_utc=now_utc,
424
453
  compute=lambda ws=ws, we=we: c._sum_cost_for_range(
425
- ws, we, mode="auto", skip_sync=True
454
+ ws, we, mode="auto", skip_sync=True,
455
+ account_key=account_key,
426
456
  ),
427
457
  )
428
458
  else:
429
459
  week_cost = c._sum_cost_for_range(
430
- ws, we, mode="auto", skip_sync=skip_sync
460
+ ws, we, mode="auto", skip_sync=skip_sync,
461
+ account_key=account_key,
431
462
  )
432
463
  values.append(week_cost / final_pct)
433
464
  return statistics.median(values), "trailing_4wk_median"
@@ -476,14 +507,20 @@ def _load_forecast_inputs(
476
507
  *,
477
508
  skip_sync: bool = False,
478
509
  use_weekref_cost_cache: bool = False,
510
+ account_key: "str | None" = None,
479
511
  ) -> ForecastInputs | None:
480
512
  """Gather everything from the DB. Returns None if no current-week snapshot.
481
513
 
482
514
  When `skip_sync=True`, all JSONL-backed cost lookups skip the ingest pass
483
515
  and serve whatever is already in the cache (honors `forecast --no-sync`).
516
+
517
+ ``account_key`` (#341, spec §3): ``None`` = the account-blind merged read
518
+ (today's byte-identical behavior); a real key / ``unattributed`` scopes the
519
+ current-week snapshot window, the live spend, and the $/1% derivation to that
520
+ account (``forecast --account``).
484
521
  """
485
522
  c = _cctally()
486
- fetched = _fetch_current_week_snapshots(conn, now_utc)
523
+ fetched = _fetch_current_week_snapshots(conn, now_utc, account_key=account_key)
487
524
  if fetched is None:
488
525
  return None
489
526
  week_start_at, week_end_at, samples = fetched
@@ -512,7 +549,8 @@ def _load_forecast_inputs(
512
549
  # Live compute current-week spend via the existing helper (opens cache.db
513
550
  # internally); mirrors `weekly`'s pattern of not trusting weekly_cost_snapshots.
514
551
  spent_usd = c._sum_cost_for_range(
515
- week_start_at, now_utc, mode="auto", skip_sync=skip_sync
552
+ week_start_at, now_utc, mode="auto", skip_sync=skip_sync,
553
+ account_key=account_key,
516
554
  )
517
555
  p_24h_ago, t_24h = _pick_p_24h_ago(samples, now_utc)
518
556
 
@@ -522,6 +560,7 @@ def _load_forecast_inputs(
522
560
  dpp, dpp_source = _select_dollars_per_percent(
523
561
  conn, now_utc, week_start_at, p_now, spent_usd, skip_sync=True,
524
562
  use_weekref_cost_cache=use_weekref_cost_cache,
563
+ account_key=account_key,
525
564
  )
526
565
  confidence, reasons = _assess_forecast_confidence(elapsed_hours, p_now, len(samples))
527
566
  target_24h = now_utc - dt.timedelta(hours=24)
@@ -637,11 +676,14 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
637
676
  return payload
638
677
 
639
678
 
640
- def _emit_forecast_json(out: ForecastOutput) -> str:
679
+ def _emit_forecast_json(out: ForecastOutput, *, extra: "dict | None" = None) -> str:
641
680
  # Stamp at the CLI boundary ONLY — never in _build_forecast_json_payload,
642
681
  # which also feeds the dashboard `explain` subtree (spec gate F3).
682
+ payload = _build_forecast_json_payload(out)
683
+ if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
684
+ payload.update(extra)
643
685
  return json.dumps(
644
- _cctally().stamp_schema_version(_build_forecast_json_payload(out)),
686
+ _cctally().stamp_schema_version(payload),
645
687
  indent=2)
646
688
 
647
689
 
@@ -911,6 +953,13 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
911
953
  def cmd_report(args: argparse.Namespace) -> int:
912
954
  c = _cctally()
913
955
  c._share_validate_args(args)
956
+ # #341 --account: resolve the render filter (provider=claude; fail closed
957
+ # with exit 3 when the entry cache is unavailable, since reset-affected
958
+ # weeks live-compute cost from session_entries). None = merged / byte-stable.
959
+ acct_key, acct_exit = c.resolve_account_filter(args, "claude", needs_cache=True)
960
+ if acct_exit is not None:
961
+ return acct_exit
962
+ _acct_fields = c.account_json_fields(acct_key) # #341 R8 (empty unless --account)
914
963
  if args.sync_current:
915
964
  sync_ns = argparse.Namespace(
916
965
  week_start=None,
@@ -931,13 +980,12 @@ def cmd_report(args: argparse.Namespace) -> int:
931
980
 
932
981
  conn = open_db()
933
982
  try:
983
+ _rep_acct_pred = "" if acct_key is None else " WHERE account_key = ?"
984
+ _rep_acct_p = () if acct_key is None else (acct_key,)
934
985
  latest_usage = conn.execute(
935
- """
936
- SELECT *
937
- FROM weekly_usage_snapshots
938
- ORDER BY captured_at_utc DESC, id DESC
939
- LIMIT 1
940
- """
986
+ "SELECT * FROM weekly_usage_snapshots" + _rep_acct_pred +
987
+ " ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
988
+ _rep_acct_p,
941
989
  ).fetchone()
942
990
  if latest_usage is not None:
943
991
  date_str = latest_usage["week_start_date"]
@@ -974,7 +1022,7 @@ def cmd_report(args: argparse.Namespace) -> int:
974
1022
  if _adjusted_current:
975
1023
  current_ref = _adjusted_current[0]
976
1024
 
977
- weeks = c.get_recent_weeks(conn, max(1, args.weeks))
1025
+ weeks = c.get_recent_weeks(conn, max(1, args.weeks), account_key=acct_key)
978
1026
  if not weeks:
979
1027
  # Format-aware empty path mirrors cmd_forecast:18578-18629 — a
980
1028
  # fresh install requesting `report --format html` should emit a
@@ -1011,7 +1059,9 @@ def cmd_report(args: argparse.Namespace) -> int:
1011
1059
  c._share_render_and_emit(snap, args)
1012
1060
  return 0
1013
1061
  if args.json:
1014
- payload = c.stamp_schema_version({"current": None, "trend": []})
1062
+ _empty = {"current": None, "trend": []}
1063
+ _empty.update(_acct_fields) # #341 R8 decoration
1064
+ payload = c.stamp_schema_version(_empty)
1015
1065
  sink = getattr(args, "_source_result_sink", None)
1016
1066
  if sink is not None:
1017
1067
  sink(payload)
@@ -1033,7 +1083,7 @@ def cmd_report(args: argparse.Namespace) -> int:
1033
1083
  # cmd_report's JSON contract is newest-first to mirror
1034
1084
  # get_recent_weeks's order — we reverse below.
1035
1085
  view = c.build_trend_view(conn, now_utc=_command_as_of(), n=args.weeks,
1036
- display_tz=tz)
1086
+ display_tz=tz, account_key=acct_key)
1037
1087
  # Serialize TuiTrendRow → today's camelCase keys. Order:
1038
1088
  # newest-first (matches the prior cmd_report behavior).
1039
1089
  # Map week_start_date → original WeekRef ISO strings so the
@@ -1153,7 +1203,8 @@ def cmd_report(args: argparse.Namespace) -> int:
1153
1203
  }
1154
1204
 
1155
1205
  if args.detail:
1156
- milestone_rows = c.get_milestones_for_week(conn, current_ref.week_start.isoformat())
1206
+ milestone_rows = c.get_milestones_for_week(
1207
+ conn, current_ref.week_start.isoformat(), account_key=acct_key)
1157
1208
  output["milestones"] = [
1158
1209
  {
1159
1210
  "percentThreshold": int(m["percent_threshold"]),
@@ -1209,6 +1260,7 @@ def cmd_report(args: argparse.Namespace) -> int:
1209
1260
  return 0
1210
1261
 
1211
1262
  if args.json:
1263
+ output.update(_acct_fields) # #341 R8 decoration
1212
1264
  payload = c.stamp_schema_version(output)
1213
1265
  sink = getattr(args, "_source_result_sink", None)
1214
1266
  if sink is not None:
@@ -1302,7 +1354,8 @@ def cmd_report(args: argparse.Namespace) -> int:
1302
1354
  )
1303
1355
 
1304
1356
  if args.detail:
1305
- milestone_rows = c.get_milestones_for_week(conn, current_ref.week_start.isoformat())
1357
+ milestone_rows = c.get_milestones_for_week(
1358
+ conn, current_ref.week_start.isoformat(), account_key=acct_key)
1306
1359
  if milestone_rows:
1307
1360
  print()
1308
1361
  print("Percent breakdown (current week):\n")
@@ -1344,6 +1397,14 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1344
1397
  # cctally-native usage error → exit 2 (docs/cli-contract.md; #279 S6 W2).
1345
1398
  return 2
1346
1399
 
1400
+ # #341 --account: resolve the render filter (provider=claude; fail closed
1401
+ # with exit 3 when the entry cache is unavailable, since current-week spend
1402
+ # comes from session_entries). None = merged / byte-stable.
1403
+ acct_key, acct_exit = c.resolve_account_filter(args, "claude", needs_cache=True)
1404
+ if acct_exit is not None:
1405
+ return acct_exit
1406
+ _acct_fields = c.account_json_fields(acct_key) # #341 R8 (empty unless --account)
1407
+
1347
1408
  now_utc = _resolve_forecast_now(args.as_of)
1348
1409
  conn = open_db()
1349
1410
  # Cache sync is gated inside get_entries(..., skip_sync=args.no_sync); no
@@ -1358,6 +1419,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1358
1419
  view = c.build_forecast_view(
1359
1420
  conn, now_utc=now_utc, targets=tuple(targets),
1360
1421
  skip_sync=args.no_sync, display_tz=args._resolved_tz,
1422
+ account_key=acct_key,
1361
1423
  )
1362
1424
  inputs = view.output.inputs if view.output is not None else None
1363
1425
  if inputs is None:
@@ -1415,10 +1477,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1415
1477
  c._share_render_and_emit(snap, args)
1416
1478
  return 0
1417
1479
  if args.json:
1418
- print(json.dumps(c.stamp_schema_version({
1480
+ _nodata = {
1419
1481
  "error": "no_current_week_data",
1420
1482
  "meta": {"generated_at": _iso_z(now_utc), "tool_version": TOOL_VERSION},
1421
- }), indent=2))
1483
+ }
1484
+ _nodata.update(_acct_fields) # #341 R8 decoration
1485
+ print(json.dumps(c.stamp_schema_version(_nodata), indent=2))
1422
1486
  elif args.status_line:
1423
1487
  pass # silent segment
1424
1488
  else:
@@ -1442,7 +1506,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1442
1506
  # fires when `--format` is requested, so the cost is bounded.
1443
1507
  # `_apply_midweek_reset_override` is replayed so the chart axis
1444
1508
  # matches the (possibly-shifted) week_start_at carried by `inputs`.
1445
- fetched = _fetch_current_week_snapshots(conn, now_utc)
1509
+ fetched = _fetch_current_week_snapshots(conn, now_utc, account_key=acct_key)
1446
1510
  actual_series: list[tuple[str, float, float]] = []
1447
1511
  if fetched is not None:
1448
1512
  _ws_at, _we_at, raw_samples = fetched
@@ -1516,7 +1580,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1516
1580
  return 0
1517
1581
 
1518
1582
  if args.json:
1519
- print(_emit_forecast_json(output))
1583
+ print(_emit_forecast_json(output, extra=_acct_fields))
1520
1584
  return 0
1521
1585
  if args.status_line:
1522
1586
  # --status-line is invoked via $(cmd 2>/dev/null) by design — stdout is
@@ -1700,7 +1764,7 @@ def _civil_period_label(period, start_utc, end_utc, tz):
1700
1764
 
1701
1765
  def _build_vendor_budget_inputs(
1702
1766
  *, vendor, period, target_usd, alert_thresholds, now_utc, config, tz,
1703
- skip_sync=False,
1767
+ skip_sync=False, window_account_key=None,
1704
1768
  ):
1705
1769
  """Resolve the window + live spend for one (vendor, period) budget and
1706
1770
  return a :class:`BudgetInputs` (or ``None`` only for the Claude /
@@ -1730,7 +1794,12 @@ def _build_vendor_budget_inputs(
1730
1794
  if vendor == "claude" and period == "subscription-week":
1731
1795
  conn = open_db()
1732
1796
  try:
1733
- window = _resolve_current_budget_window(conn, now_utc)
1797
+ # #341 `*`-anchor: the projected vendor-budget metric passes the
1798
+ # active account so its window matches the actual-budget ladder;
1799
+ # `None` (the budget DISPLAY + every other caller) = merged /
1800
+ # byte-identical.
1801
+ window = _resolve_current_budget_window(
1802
+ conn, now_utc, account_key=window_account_key)
1734
1803
  finally:
1735
1804
  conn.close()
1736
1805
  if window is None: