cctally 1.7.2 → 1.7.4
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 +27 -0
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +104 -113
- package/bin/_cctally_db.py +271 -41
- package/bin/_cctally_record.py +516 -116
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +128 -52
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_render.py +20 -0
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/cctally +191 -779
- package/dashboard/static/assets/index-DhCnIFq9.js +18 -0
- package/dashboard/static/assets/index-Dv5Dzag5.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BgpoazlS.js +0 -18
- package/dashboard/static/assets/index-nJdUaGys.css +0 -1
package/bin/_cctally_refresh.py
CHANGED
|
@@ -55,6 +55,23 @@ def _cctally():
|
|
|
55
55
|
return sys.modules["cctally"]
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# === Honest imports from extracted homes ===================================
|
|
59
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
60
|
+
# import from _cctally_core. `load_config` plus out-of-scope helpers
|
|
61
|
+
# (`_resolve_oauth_token`, `_fetch_oauth_usage`, `_bust_statusline_cache`,
|
|
62
|
+
# `_refresh_usage_inproc`, `_get_oauth_usage_config`,
|
|
63
|
+
# `_seconds_since_iso`, `_select_last_known_snapshot`,
|
|
64
|
+
# `_newest_snapshot_age_seconds`, `_normalize_percent`,
|
|
65
|
+
# `_forecast_color_enabled`, `_discover_cc_version`, `cmd_record_usage`)
|
|
66
|
+
# stay on the _cctally() accessor.
|
|
67
|
+
from _cctally_core import (
|
|
68
|
+
eprint,
|
|
69
|
+
now_utc_iso,
|
|
70
|
+
_iso_to_epoch,
|
|
71
|
+
_format_short_duration,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
58
75
|
# =========================================================================
|
|
59
76
|
# Exception classes
|
|
60
77
|
# =========================================================================
|
|
@@ -289,14 +306,13 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
|
|
|
289
306
|
The ``5h`` segment is omitted entirely when ``payload["five_hour"]`` is None.
|
|
290
307
|
Color codes are emitted only when ``color`` is True.
|
|
291
308
|
"""
|
|
292
|
-
c = _cctally()
|
|
293
309
|
a = _REFRESH_USAGE_ANSI if color else {k: "" for k in _REFRESH_USAGE_ANSI}
|
|
294
310
|
|
|
295
311
|
seven = payload["seven_day"]
|
|
296
312
|
seven_pct = seven["used_percent"]
|
|
297
313
|
seven_resets = seven.get("resets_at_epoch")
|
|
298
314
|
if seven_resets is not None:
|
|
299
|
-
seven_ttl =
|
|
315
|
+
seven_ttl = _format_short_duration(seven_resets - now_epoch)
|
|
300
316
|
seven_seg = (
|
|
301
317
|
f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
|
|
302
318
|
f" {a['orange']}(in {seven_ttl}){a['reset']}"
|
|
@@ -309,7 +325,7 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
|
|
|
309
325
|
five_pct = five["used_percent"]
|
|
310
326
|
five_resets = five.get("resets_at_epoch")
|
|
311
327
|
if five_resets is not None:
|
|
312
|
-
five_ttl =
|
|
328
|
+
five_ttl = _format_short_duration(five_resets - now_epoch)
|
|
313
329
|
five_seg = (
|
|
314
330
|
f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
|
|
315
331
|
f" {a['orange']}(in {five_ttl}){a['reset']}"
|
|
@@ -420,14 +436,13 @@ def _bust_statusline_cache(path: str = _STATUSLINE_OAUTH_CACHE) -> str:
|
|
|
420
436
|
``"absent"`` (file did not exist), ``"error"`` (delete failed for
|
|
421
437
|
a non-FileNotFoundError reason — logged via eprint, does NOT raise).
|
|
422
438
|
"""
|
|
423
|
-
c = _cctally()
|
|
424
439
|
try:
|
|
425
440
|
os.remove(path)
|
|
426
441
|
return "busted"
|
|
427
442
|
except FileNotFoundError:
|
|
428
443
|
return "absent"
|
|
429
444
|
except OSError as exc:
|
|
430
|
-
|
|
445
|
+
eprint(f"refresh-usage: cache-bust failed: {exc}")
|
|
431
446
|
return "error"
|
|
432
447
|
|
|
433
448
|
|
|
@@ -450,7 +465,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
|
|
|
450
465
|
quiet = bool(getattr(args, "quiet", False))
|
|
451
466
|
|
|
452
467
|
if snap is None:
|
|
453
|
-
|
|
468
|
+
eprint("refresh-usage: rate-limited; no last-known data; "
|
|
454
469
|
"status-line will populate on next CC tick")
|
|
455
470
|
if json_mode:
|
|
456
471
|
print(json.dumps({
|
|
@@ -467,7 +482,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
|
|
|
467
482
|
_freshness_label(age_s, cfg) if age_s is not None else "stale"
|
|
468
483
|
)
|
|
469
484
|
|
|
470
|
-
|
|
485
|
+
eprint(f"refresh-usage: rate-limited; using last-known "
|
|
471
486
|
f"(captured {int(age_s) if age_s is not None else '?'}s ago)")
|
|
472
487
|
|
|
473
488
|
if json_mode:
|
|
@@ -550,7 +565,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
550
565
|
# built then a downstream cmd_record_usage call fails).
|
|
551
566
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
552
567
|
seven_resets_iso = seven["resets_at"]
|
|
553
|
-
seven_resets_epoch =
|
|
568
|
+
seven_resets_epoch = _iso_to_epoch(seven_resets_iso)
|
|
554
569
|
except (TypeError, ValueError, KeyError) as exc:
|
|
555
570
|
return _RefreshUsageResult(
|
|
556
571
|
status="parse_failed",
|
|
@@ -566,7 +581,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
566
581
|
try:
|
|
567
582
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
568
583
|
five_resets_iso = five["resets_at"]
|
|
569
|
-
five_resets_epoch =
|
|
584
|
+
five_resets_epoch = _iso_to_epoch(five_resets_iso)
|
|
570
585
|
except (TypeError, ValueError) as exc:
|
|
571
586
|
# 5h is optional - silently degrade rather than fail the command
|
|
572
587
|
# (parity with the previous cmd_refresh_usage behavior; the eprint
|
|
@@ -599,7 +614,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
599
614
|
# (tests/test_refresh_usage_cmd.py:55, test_refresh_usage_inproc.py:18).
|
|
600
615
|
cache_state = c._bust_statusline_cache()
|
|
601
616
|
|
|
602
|
-
fetched_at =
|
|
617
|
+
fetched_at = now_utc_iso()
|
|
603
618
|
fresh_envelope = {
|
|
604
619
|
"label": "fresh",
|
|
605
620
|
"captured_at": fetched_at,
|
|
@@ -652,7 +667,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
652
667
|
# Emit BEFORE rendering so stderr flushes consistently for harnesses
|
|
653
668
|
# that grep across both streams.
|
|
654
669
|
for warning in result.warnings:
|
|
655
|
-
|
|
670
|
+
eprint(f"refresh-usage: {warning}")
|
|
656
671
|
payload = result.payload or {}
|
|
657
672
|
if getattr(args, "json", False):
|
|
658
673
|
print(_serialize_refresh_usage_json(payload))
|
|
@@ -668,7 +683,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
668
683
|
args, RefreshUsageRateLimitError(result.reason or "rate limited"))
|
|
669
684
|
|
|
670
685
|
if result.status == "no_oauth_token":
|
|
671
|
-
|
|
686
|
+
eprint("refresh-usage: no OAuth token found "
|
|
672
687
|
"(run 'claude' once to authenticate)")
|
|
673
688
|
return 2
|
|
674
689
|
|
|
@@ -678,9 +693,9 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
678
693
|
# config-error reason string verbatim so we can detect it here.
|
|
679
694
|
reason = result.reason or ""
|
|
680
695
|
if reason.startswith("invalid oauth_usage config:"):
|
|
681
|
-
|
|
696
|
+
eprint(f"cctally: {reason}")
|
|
682
697
|
return 2
|
|
683
|
-
|
|
698
|
+
eprint(f"refresh-usage: OAuth fetch failed: {reason}")
|
|
684
699
|
return 3
|
|
685
700
|
|
|
686
701
|
if result.status == "parse_failed":
|
|
@@ -688,7 +703,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
688
703
|
# only seven_day failures (RefreshUsageMalformedError or unparseable
|
|
689
704
|
# field extraction) propagate here. Preserve the original exact
|
|
690
705
|
# error-line shape for bash harnesses that grep stderr.
|
|
691
|
-
|
|
706
|
+
eprint(f"refresh-usage: {result.reason or ''}")
|
|
692
707
|
return 4
|
|
693
708
|
|
|
694
709
|
if result.status == "record_failed":
|
|
@@ -698,13 +713,13 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
698
713
|
rc = int(reason.split()[1])
|
|
699
714
|
except (IndexError, ValueError):
|
|
700
715
|
rc = -1
|
|
701
|
-
|
|
716
|
+
eprint(f"refresh-usage: failed to record usage (exit {rc})")
|
|
702
717
|
else:
|
|
703
|
-
|
|
718
|
+
eprint(f"refresh-usage: failed to record usage: {reason}")
|
|
704
719
|
return 5
|
|
705
720
|
|
|
706
721
|
# Defensive: unknown status from _refresh_usage_inproc -> treat as parse error.
|
|
707
|
-
|
|
722
|
+
eprint(f"refresh-usage: unexpected status {result.status!r}")
|
|
708
723
|
return 4
|
|
709
724
|
|
|
710
725
|
|
|
@@ -757,7 +772,7 @@ def _hook_tick_oauth_refresh(
|
|
|
757
772
|
seven = api["seven_day"]
|
|
758
773
|
try:
|
|
759
774
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
760
|
-
seven_resets_epoch =
|
|
775
|
+
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
761
776
|
except (TypeError, ValueError, KeyError):
|
|
762
777
|
return "err(parse)", None
|
|
763
778
|
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
@@ -766,7 +781,7 @@ def _hook_tick_oauth_refresh(
|
|
|
766
781
|
if five is not None and "utilization" in five and "resets_at" in five:
|
|
767
782
|
try:
|
|
768
783
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
769
|
-
five_resets_epoch =
|
|
784
|
+
five_resets_epoch = _iso_to_epoch(five["resets_at"])
|
|
770
785
|
except (TypeError, ValueError):
|
|
771
786
|
five_pct = None
|
|
772
787
|
five_resets_epoch = None
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -63,6 +63,18 @@ def _cctally():
|
|
|
63
63
|
return sys.modules["cctally"]
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# === Honest imports from extracted homes ===================================
|
|
67
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
68
|
+
# import from _cctally_core. Path constants (`APP_DIR`,
|
|
69
|
+
# `CLAUDE_SETTINGS_PATH`, `HOOK_TICK_LOG_PATH`) plus the extensive
|
|
70
|
+
# out-of-scope setup-specific helpers (legacy migration, hook surgery,
|
|
71
|
+
# OAuth token, sync_cache, …) stay on the _cctally() accessor.
|
|
72
|
+
from _cctally_core import (
|
|
73
|
+
eprint,
|
|
74
|
+
_command_as_of,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
# ── settings.json hook surgery ─────────────────────────────────────────
|
|
67
79
|
|
|
68
80
|
|
|
@@ -430,7 +442,7 @@ def _legacy_resolve_backup_dir() -> pathlib.Path:
|
|
|
430
442
|
so a re-run never overwrites a prior migration's snapshot.
|
|
431
443
|
"""
|
|
432
444
|
c = _cctally()
|
|
433
|
-
now =
|
|
445
|
+
now = _command_as_of()
|
|
434
446
|
stamp = now.strftime("%Y%m%d-%H%M%S")
|
|
435
447
|
base = pathlib.Path.home() / ".claude" / f"{c._LEGACY_BACKUP_DIR_PREFIX}{stamp}"
|
|
436
448
|
base.mkdir(parents=True, exist_ok=True)
|
|
@@ -614,7 +626,6 @@ def _setup_read_legacy_prompt_input(stream, reprompt: str | None = None) -> bool
|
|
|
614
626
|
us). When None (test default), no reprompt is emitted — useful for unit
|
|
615
627
|
tests that drive `stream` from io.StringIO.
|
|
616
628
|
"""
|
|
617
|
-
eprint = _cctally().eprint
|
|
618
629
|
yes_words = {"y", "yes"}
|
|
619
630
|
no_words = {"n", "no"}
|
|
620
631
|
for attempt in range(3):
|
|
@@ -815,7 +826,7 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
815
826
|
try:
|
|
816
827
|
settings = c._load_claude_settings()
|
|
817
828
|
except c.SetupError as exc:
|
|
818
|
-
|
|
829
|
+
eprint(f"setup: warning: {exc}")
|
|
819
830
|
settings = {}
|
|
820
831
|
hook_counts = _setup_count_hook_entries(settings)
|
|
821
832
|
oauth = _setup_oauth_token_present()
|
|
@@ -911,14 +922,14 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
911
922
|
try:
|
|
912
923
|
settings = c._load_claude_settings()
|
|
913
924
|
except c.SetupError as exc:
|
|
914
|
-
|
|
925
|
+
eprint(f"setup: {exc}")
|
|
915
926
|
return 1
|
|
916
927
|
settings, removed = _settings_merge_uninstall(settings)
|
|
917
928
|
if removed:
|
|
918
929
|
try:
|
|
919
930
|
c._write_claude_settings_atomic(settings)
|
|
920
931
|
except OSError as exc:
|
|
921
|
-
|
|
932
|
+
eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
|
|
922
933
|
return 2
|
|
923
934
|
out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
|
|
924
935
|
|
|
@@ -938,7 +949,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
938
949
|
dst.unlink()
|
|
939
950
|
sym_removed += 1
|
|
940
951
|
except OSError as exc:
|
|
941
|
-
|
|
952
|
+
eprint(f"setup: failed to remove {dst}: {exc}")
|
|
942
953
|
out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
|
|
943
954
|
|
|
944
955
|
legacy = _setup_detect_legacy_snippet()
|
|
@@ -1000,7 +1011,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
1000
1011
|
"exit_code": 1,
|
|
1001
1012
|
}, indent=2))
|
|
1002
1013
|
else:
|
|
1003
|
-
|
|
1014
|
+
eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
|
|
1004
1015
|
return 1
|
|
1005
1016
|
else:
|
|
1006
1017
|
out.append(
|
|
@@ -1039,7 +1050,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1039
1050
|
# against an empty dict simply yields detected=False for entries (files
|
|
1040
1051
|
# detection is independent of settings). Mirror _setup_status's pattern
|
|
1041
1052
|
# so the user sees the same condition that would fail _setup_install.
|
|
1042
|
-
|
|
1053
|
+
eprint(f"setup: warning: {exc}")
|
|
1043
1054
|
settings = {}
|
|
1044
1055
|
detection = _setup_detect_legacy_bespoke_hooks(settings)
|
|
1045
1056
|
sym_results = []
|
|
@@ -1238,7 +1249,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1238
1249
|
|
|
1239
1250
|
claude_dir = pathlib.Path.home() / ".claude"
|
|
1240
1251
|
if not claude_dir.exists():
|
|
1241
|
-
|
|
1252
|
+
eprint(
|
|
1242
1253
|
f"~/.claude/ does not exist. If Claude Code isn't installed yet, "
|
|
1243
1254
|
f"install it first. If it is installed, run `claude` once to "
|
|
1244
1255
|
f"initialize, then re-run cctally setup."
|
|
@@ -1259,7 +1270,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1259
1270
|
try:
|
|
1260
1271
|
settings = c._load_claude_settings()
|
|
1261
1272
|
except c.SetupError as exc:
|
|
1262
|
-
|
|
1273
|
+
eprint(f"setup: {exc}")
|
|
1263
1274
|
return 1
|
|
1264
1275
|
|
|
1265
1276
|
# ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
|
|
@@ -1304,7 +1315,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1304
1315
|
# still propagates into this code path post-extraction (§5.6 option C).
|
|
1305
1316
|
backup_dir = c._legacy_resolve_backup_dir()
|
|
1306
1317
|
except OSError as exc:
|
|
1307
|
-
|
|
1318
|
+
eprint(f"setup: cannot create migration backup dir: {exc}")
|
|
1308
1319
|
return 1
|
|
1309
1320
|
# Unwire BEFORE the merge so the same atomic write removes legacy
|
|
1310
1321
|
# entries and adds cctally entries (spec §2 step 6).
|
|
@@ -1322,14 +1333,14 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1322
1333
|
try:
|
|
1323
1334
|
_settings_merge_install(settings, abs_path)
|
|
1324
1335
|
except c.SetupError as exc:
|
|
1325
|
-
|
|
1336
|
+
eprint(f"setup: {exc}")
|
|
1326
1337
|
return 1
|
|
1327
1338
|
|
|
1328
1339
|
sym_results = _setup_create_symlinks(repo_root, dst_dir)
|
|
1329
1340
|
failed = [r for r in sym_results if r.status == "failed"]
|
|
1330
1341
|
if failed:
|
|
1331
1342
|
for r in failed:
|
|
1332
|
-
|
|
1343
|
+
eprint(f"setup: symlink {r.name} failed: {r.detail}")
|
|
1333
1344
|
return 1
|
|
1334
1345
|
new_count = sum(1 for r in sym_results if r.status == "created")
|
|
1335
1346
|
same_count = sum(1 for r in sym_results if r.status == "already")
|
|
@@ -1356,7 +1367,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1356
1367
|
try:
|
|
1357
1368
|
c._write_claude_settings_atomic(settings)
|
|
1358
1369
|
except OSError as exc:
|
|
1359
|
-
|
|
1370
|
+
eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
|
|
1360
1371
|
return 2
|
|
1361
1372
|
|
|
1362
1373
|
# ── Post-write migration apply (spec §2 steps 6a, 6b) ──
|
|
@@ -1548,7 +1559,6 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1548
1559
|
|
|
1549
1560
|
|
|
1550
1561
|
def cmd_setup(args: argparse.Namespace) -> int:
|
|
1551
|
-
c = _cctally()
|
|
1552
1562
|
# Migration flags are install-mode-only. Reject combinations with
|
|
1553
1563
|
# --status or --uninstall (per spec Section 2 mode×flag matrix). The
|
|
1554
1564
|
# mutex group on the parser already prevents both flags being set
|
|
@@ -1560,7 +1570,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|
|
1560
1570
|
else None
|
|
1561
1571
|
)
|
|
1562
1572
|
if mig_flag and (getattr(args, "status", False) or getattr(args, "uninstall", False)):
|
|
1563
|
-
|
|
1573
|
+
eprint(f"setup: {mig_flag} is install-mode only")
|
|
1564
1574
|
return 2
|
|
1565
1575
|
if getattr(args, "uninstall", False):
|
|
1566
1576
|
return _setup_uninstall(args)
|
|
@@ -30,12 +30,27 @@ def _cctally():
|
|
|
30
30
|
return sys.modules["cctally"]
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# === Honest imports from extracted homes ===================================
|
|
34
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
35
|
+
# import from _cctally_core. `load_config` stays on the _cctally()
|
|
36
|
+
# accessor per spec §3.5 monkeypatch carve-out. Z-high helpers
|
|
37
|
+
# (`compute_week_cost`, `insert_cost_snapshot`) and out-of-scope
|
|
38
|
+
# (`pick_week_selection`) stay on the accessor per spec §3.7.
|
|
39
|
+
from _cctally_core import (
|
|
40
|
+
get_week_start_name,
|
|
41
|
+
open_db,
|
|
42
|
+
format_local_iso,
|
|
43
|
+
make_week_ref,
|
|
44
|
+
get_latest_usage_for_week,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
34
49
|
c = _cctally()
|
|
35
50
|
config = c.load_config()
|
|
36
|
-
week_start_name =
|
|
51
|
+
week_start_name = get_week_start_name(config, args.week_start_name)
|
|
37
52
|
|
|
38
|
-
conn =
|
|
53
|
+
conn = open_db()
|
|
39
54
|
try:
|
|
40
55
|
selection = c.pick_week_selection(
|
|
41
56
|
conn,
|
|
@@ -54,8 +69,8 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
54
69
|
start_iso_override=selection.start_iso_override,
|
|
55
70
|
end_iso_override=selection.end_iso_override,
|
|
56
71
|
)
|
|
57
|
-
week_start_at = selection.start_iso_override or
|
|
58
|
-
week_end_at = selection.end_iso_override or
|
|
72
|
+
week_start_at = selection.start_iso_override or format_local_iso(week_start, end_of_day=False)
|
|
73
|
+
week_end_at = selection.end_iso_override or format_local_iso(week_end, end_of_day=True)
|
|
59
74
|
insert_id = c.insert_cost_snapshot(
|
|
60
75
|
conn,
|
|
61
76
|
week_start=week_start,
|
|
@@ -69,13 +84,13 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
69
84
|
project=args.project,
|
|
70
85
|
)
|
|
71
86
|
|
|
72
|
-
week_ref =
|
|
87
|
+
week_ref = make_week_ref(
|
|
73
88
|
week_start_date=week_start.isoformat(),
|
|
74
89
|
week_end_date=week_end.isoformat(),
|
|
75
90
|
week_start_at=week_start_at,
|
|
76
91
|
week_end_at=week_end_at,
|
|
77
92
|
)
|
|
78
|
-
usage_row =
|
|
93
|
+
usage_row = get_latest_usage_for_week(conn, week_ref)
|
|
79
94
|
weekly_percent = float(usage_row["weekly_percent"]) if usage_row else None
|
|
80
95
|
dollars_per_percent = (
|
|
81
96
|
result.cost_usd / weekly_percent if weekly_percent and weekly_percent > 0 else None
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -202,43 +202,40 @@ def _cctally():
|
|
|
202
202
|
return sys.modules["cctally"]
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
# === Honest imports from extracted homes ===================================
|
|
206
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
|
|
207
|
+
from _cctally_core import (
|
|
208
|
+
eprint,
|
|
209
|
+
parse_iso_datetime,
|
|
210
|
+
_now_utc,
|
|
211
|
+
open_db,
|
|
212
|
+
get_latest_usage_for_week,
|
|
213
|
+
_canonicalize_optional_iso,
|
|
214
|
+
make_week_ref,
|
|
215
|
+
)
|
|
216
|
+
from _lib_display_tz import (
|
|
217
|
+
format_display_dt,
|
|
218
|
+
resolve_display_tz,
|
|
219
|
+
normalize_display_tz_value,
|
|
220
|
+
_compute_display_block,
|
|
221
|
+
)
|
|
222
|
+
from _lib_aggregators import _aggregate_monthly
|
|
223
|
+
|
|
224
|
+
|
|
205
225
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
206
226
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
207
227
|
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
208
|
-
# code unchanged.
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
217
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _now_utc(*args, **kwargs):
|
|
221
|
-
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def open_db(*args, **kwargs):
|
|
225
|
-
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
+
# code unchanged. `load_config` and `get_claude_session_entries` STAY as
|
|
229
|
+
# shims even though their natural homes are decentralized (_cctally_config
|
|
230
|
+
# / _cctally_cache) — tests monkeypatch them via `ns["X"]` (21 sites total,
|
|
231
|
+
# audited 2026-05-17); direct imports would silently bypass the patches.
|
|
232
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
228
233
|
def load_config(*args, **kwargs):
|
|
229
234
|
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
230
235
|
|
|
231
236
|
|
|
232
|
-
def
|
|
233
|
-
return sys.modules["cctally"].
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def resolve_display_tz(*args, **kwargs):
|
|
237
|
-
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def normalize_display_tz_value(*args, **kwargs):
|
|
241
|
-
return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
|
|
237
|
+
def get_claude_session_entries(*args, **kwargs):
|
|
238
|
+
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
242
239
|
|
|
243
240
|
|
|
244
241
|
def _resolve_display_tz_obj(*args, **kwargs):
|
|
@@ -253,10 +250,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
|
|
|
253
250
|
return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
|
|
254
251
|
|
|
255
252
|
|
|
256
|
-
def _compute_display_block(*args, **kwargs):
|
|
257
|
-
return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
|
|
258
|
-
|
|
259
|
-
|
|
260
253
|
def _compute_forecast(*args, **kwargs):
|
|
261
254
|
return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
|
|
262
255
|
|
|
@@ -297,18 +290,6 @@ def _aggregate_claude_sessions(*args, **kwargs):
|
|
|
297
290
|
return sys.modules["cctally"]._aggregate_claude_sessions(*args, **kwargs)
|
|
298
291
|
|
|
299
292
|
|
|
300
|
-
def _aggregate_monthly(*args, **kwargs):
|
|
301
|
-
return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def get_claude_session_entries(*args, **kwargs):
|
|
305
|
-
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def get_latest_usage_for_week(*args, **kwargs):
|
|
309
|
-
return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
|
|
310
|
-
|
|
311
|
-
|
|
312
293
|
def get_latest_cost_for_week(*args, **kwargs):
|
|
313
294
|
return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
|
|
314
295
|
|
|
@@ -317,10 +298,6 @@ def get_milestones_for_week(*args, **kwargs):
|
|
|
317
298
|
return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
|
|
318
299
|
|
|
319
300
|
|
|
320
|
-
def _canonicalize_optional_iso(*args, **kwargs):
|
|
321
|
-
return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
|
|
322
|
-
|
|
323
|
-
|
|
324
301
|
def get_recent_weeks(*args, **kwargs):
|
|
325
302
|
return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
|
|
326
303
|
|
|
@@ -1030,6 +1007,56 @@ def _tui_build_percent_milestones(
|
|
|
1030
1007
|
return out
|
|
1031
1008
|
|
|
1032
1009
|
|
|
1010
|
+
def _tui_build_five_hour_milestones(
|
|
1011
|
+
conn: sqlite3.Connection,
|
|
1012
|
+
five_hour_window_key: int | None,
|
|
1013
|
+
) -> list[dict]:
|
|
1014
|
+
"""Return per-percent 5h-block milestones for the given window, in
|
|
1015
|
+
capture-time order. Spec §5.3 — drives the CurrentWeekModal's new
|
|
1016
|
+
5h-milestone timeline section.
|
|
1017
|
+
|
|
1018
|
+
Bucket B per §3.2: NO ``reset_event_id`` filter — both pre- and
|
|
1019
|
+
post-credit segments render in the merged chronological stream so
|
|
1020
|
+
the user sees the full history of the active block including
|
|
1021
|
+
repeated threshold values after an in-place credit. The React layer
|
|
1022
|
+
differentiates rows by ``reset_event_id`` for key uniqueness.
|
|
1023
|
+
|
|
1024
|
+
Returns [] when the current week has no API-anchored 5h block. The
|
|
1025
|
+
envelope-shaped dict mirrors the CLI ``five-hour-breakdown --json``
|
|
1026
|
+
milestone objects but with snake_case keys (envelope convention).
|
|
1027
|
+
"""
|
|
1028
|
+
if five_hour_window_key is None:
|
|
1029
|
+
return []
|
|
1030
|
+
rows = conn.execute(
|
|
1031
|
+
"""
|
|
1032
|
+
SELECT percent_threshold, captured_at_utc, block_cost_usd,
|
|
1033
|
+
marginal_cost_usd, seven_day_pct_at_crossing,
|
|
1034
|
+
reset_event_id
|
|
1035
|
+
FROM five_hour_milestones
|
|
1036
|
+
WHERE five_hour_window_key = ?
|
|
1037
|
+
ORDER BY captured_at_utc ASC, id ASC
|
|
1038
|
+
""",
|
|
1039
|
+
(int(five_hour_window_key),),
|
|
1040
|
+
).fetchall()
|
|
1041
|
+
out: list[dict] = []
|
|
1042
|
+
for r in rows:
|
|
1043
|
+
out.append({
|
|
1044
|
+
"percent_threshold": int(r["percent_threshold"]),
|
|
1045
|
+
"captured_at_utc": r["captured_at_utc"],
|
|
1046
|
+
"block_cost_usd": float(r["block_cost_usd"]),
|
|
1047
|
+
"marginal_cost_usd": (
|
|
1048
|
+
None if r["marginal_cost_usd"] is None
|
|
1049
|
+
else float(r["marginal_cost_usd"])
|
|
1050
|
+
),
|
|
1051
|
+
"seven_day_pct_at_crossing": (
|
|
1052
|
+
None if r["seven_day_pct_at_crossing"] is None
|
|
1053
|
+
else float(r["seven_day_pct_at_crossing"])
|
|
1054
|
+
),
|
|
1055
|
+
"reset_event_id": int(r["reset_event_id"] or 0),
|
|
1056
|
+
})
|
|
1057
|
+
return out
|
|
1058
|
+
|
|
1059
|
+
|
|
1033
1060
|
@dataclass
|
|
1034
1061
|
class DataSnapshot:
|
|
1035
1062
|
"""All data needed to render one TUI frame. Produced by sync thread,
|
|
@@ -1062,6 +1089,13 @@ class DataSnapshot:
|
|
|
1062
1089
|
# `current_week.five_hour_block` is precomputed via
|
|
1063
1090
|
# `_select_current_block_for_envelope`).
|
|
1064
1091
|
alerts: list[dict] = field(default_factory=list)
|
|
1092
|
+
# ---- 5h in-place credit (v1.7.x) ----
|
|
1093
|
+
# Already-envelope-shaped dicts for the CurrentWeekModal's new 5h
|
|
1094
|
+
# milestone timeline (spec §5.3, Codex r1 finding 3). Parallel to
|
|
1095
|
+
# ``percent_milestones`` (which carries the WEEKLY timeline). Loaded
|
|
1096
|
+
# at sync-thread time so ``snapshot_to_envelope`` stays a pure
|
|
1097
|
+
# renderer; empty list when no current 5h block is bound.
|
|
1098
|
+
five_hour_milestones: list[dict] = field(default_factory=list)
|
|
1065
1099
|
|
|
1066
1100
|
@classmethod
|
|
1067
1101
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -1498,7 +1532,7 @@ def _tui_build_trend(
|
|
|
1498
1532
|
canon_start, canon_end = c._get_canonical_boundary_for_date(
|
|
1499
1533
|
conn, latest_usage["week_start_date"]
|
|
1500
1534
|
)
|
|
1501
|
-
current_ref =
|
|
1535
|
+
current_ref = make_week_ref(
|
|
1502
1536
|
week_start_date=latest_usage["week_start_date"],
|
|
1503
1537
|
week_end_date=latest_usage["week_end_date"],
|
|
1504
1538
|
week_start_at=canon_start,
|
|
@@ -1888,6 +1922,19 @@ def _tui_build_snapshot(
|
|
|
1888
1922
|
alerts = _build_alerts_envelope_array(conn)
|
|
1889
1923
|
except Exception as exc:
|
|
1890
1924
|
errors.append(f"alerts: {exc}")
|
|
1925
|
+
# ---- 5h in-place credit (v1.7.x) ----
|
|
1926
|
+
# Load 5h milestones (pre + post credit) for the current
|
|
1927
|
+
# block's window so CurrentWeekModal can render a merged
|
|
1928
|
+
# chronological timeline alongside its weekly milestones.
|
|
1929
|
+
# Spec §5.3 (Codex r1 finding 3).
|
|
1930
|
+
fh_milestones: list[dict] = []
|
|
1931
|
+
try:
|
|
1932
|
+
win_key = None
|
|
1933
|
+
if cw is not None and isinstance(cw.five_hour_block, dict):
|
|
1934
|
+
win_key = cw.five_hour_block.get("five_hour_window_key")
|
|
1935
|
+
fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
|
|
1936
|
+
except Exception as exc:
|
|
1937
|
+
errors.append(f"five-hour-milestones: {exc}")
|
|
1891
1938
|
return DataSnapshot(
|
|
1892
1939
|
current_week=cw,
|
|
1893
1940
|
forecast=fc,
|
|
@@ -1903,6 +1950,7 @@ def _tui_build_snapshot(
|
|
|
1903
1950
|
blocks_panel=blocks_panel,
|
|
1904
1951
|
daily_panel=daily_panel,
|
|
1905
1952
|
alerts=alerts,
|
|
1953
|
+
five_hour_milestones=fh_milestones,
|
|
1906
1954
|
)
|
|
1907
1955
|
finally:
|
|
1908
1956
|
conn.close()
|
|
@@ -2375,11 +2423,26 @@ def _tui_panel_current_week(
|
|
|
2375
2423
|
f"${cw.dollars_per_percent:.2f}"
|
|
2376
2424
|
if cw.dollars_per_percent is not None else "—"
|
|
2377
2425
|
)
|
|
2426
|
+
# Spec §5.4 — credit badge next to the 5h percent. Source: same
|
|
2427
|
+
# ``cw.five_hour_block.credits`` channel that drives the dashboard
|
|
2428
|
+
# chip; only show when at least one credit is present for the
|
|
2429
|
+
# current block. Format: ``⚡ -Xpp`` (single) / ``⚡ -Xpp, -Ypp``
|
|
2430
|
+
# (stacked across distinct 10-min slots).
|
|
2431
|
+
fh_credit_badge = ""
|
|
2432
|
+
fhb = getattr(cw, "five_hour_block", None)
|
|
2433
|
+
if isinstance(fhb, dict):
|
|
2434
|
+
fh_credits = fhb.get("credits") or []
|
|
2435
|
+
if fh_credits:
|
|
2436
|
+
deltas = ", ".join(
|
|
2437
|
+
f"{float(c.get('delta_pp', 0.0)):+.0f}pp"
|
|
2438
|
+
for c in fh_credits
|
|
2439
|
+
)
|
|
2440
|
+
fh_credit_badge = f" {{bright}}⚡ {deltas}{{/}}"
|
|
2378
2441
|
lines = [
|
|
2379
2442
|
"",
|
|
2380
2443
|
f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
|
|
2381
2444
|
"",
|
|
2382
|
-
f" 5-hour {{bar.accent}}{five_bar}{{/}} {{bright}}{int(five):>3d}%{{/}}",
|
|
2445
|
+
f" 5-hour {{bar.accent}}{five_bar}{{/}} {{bright}}{int(five):>3d}%{{/}}{fh_credit_badge}",
|
|
2383
2446
|
f" {{dim}}{fr_str}{{/}}" if fr_str else "",
|
|
2384
2447
|
"",
|
|
2385
2448
|
f" {{dim}}Spent{{/}} {{bright}}${cw.spent_usd:.2f}{{/}} "
|
|
@@ -2428,6 +2491,19 @@ def _tui_panel_current_week_hero(
|
|
|
2428
2491
|
else:
|
|
2429
2492
|
reset_suffix = ""
|
|
2430
2493
|
|
|
2494
|
+
# Spec §5.4 — credit badge in the hero variant. Same source as the
|
|
2495
|
+
# grid variant; append after the reset suffix so the badge follows
|
|
2496
|
+
# the "resets in" timer.
|
|
2497
|
+
fhb_hero = getattr(cw, "five_hour_block", None)
|
|
2498
|
+
if isinstance(fhb_hero, dict):
|
|
2499
|
+
fh_credits_hero = fhb_hero.get("credits") or []
|
|
2500
|
+
if fh_credits_hero:
|
|
2501
|
+
deltas_hero = ", ".join(
|
|
2502
|
+
f"{float(c.get('delta_pp', 0.0)):+.0f}pp"
|
|
2503
|
+
for c in fh_credits_hero
|
|
2504
|
+
)
|
|
2505
|
+
reset_suffix = f"{reset_suffix} {{bright}}⚡ {deltas_hero}{{/}}"
|
|
2506
|
+
|
|
2431
2507
|
if snap.last_sync_error:
|
|
2432
2508
|
health = "{warn}daemon error{/}"
|
|
2433
2509
|
elif snap.last_sync_at is None:
|