cctally 1.80.4 → 1.82.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 +32 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +56 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_pricing.py +27 -3
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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"
|
|
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(
|
|
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(
|
|
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
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1480
|
+
_nodata = {
|
|
1419
1481
|
"error": "no_current_week_data",
|
|
1420
1482
|
"meta": {"generated_at": _iso_z(now_utc), "tool_version": TOOL_VERSION},
|
|
1421
|
-
}
|
|
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
|
-
|
|
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:
|