cctally 1.100.0 → 1.101.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 +44 -0
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +713 -166
- package/bin/_cctally_dashboard_envelope.py +28 -38
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +304 -96
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_forecast.py +12 -4
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +10 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
- package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,50 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.101.0] - 2026-08-19
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Dashboard refreshes now release their coherent `cache.db` snapshot before constructing provider views, cutting the measured median read hold from 3.40 seconds to 1.04 seconds while preserving the exact published payload (#617).
|
|
12
|
+
- Every dashboard warning state now offers the explanation for its own window. A row in Recent alerts, an alert toast, a warn or over budget block, and a capped forecast each carry a button that opens the surface explaining that warning: a weekly crossing opens the current week, a five-hour crossing opens that block, a calendar-month budget opens that month, a project-budget crossing opens that project's drill-down, and a projected-pace crossing opens the forecast. The window is checked before anything opens — a warning about a week the dashboard is no longer showing states that its window has closed and opens nothing at all, rather than quietly showing you the current week instead. A five-hour block still opens however old it is, as long as the alert recorded which block it was; an alert that recorded no block start says so and opens nothing. On Codex the button says `Open this cycle` rather than `Open this week`, because a Codex quota cycle is what it opens. A project-budget crossing on a machine with more than one account is recorded across all of them, so it states that it cannot be narrowed to one account's projects rather than picking one. Every button is an ordinary control reachable by Tab and activated by Enter or Space, and no keyboard shortcut is claimed (#620).
|
|
13
|
+
- The dashboard's Projects table now writes out what its two percentages mean, under the week selector rather than only in a tooltip — a tooltip never appears on a phone. The `Used pp` column is relabelled `Used pp (sum)` and the caption states that it adds each week's attributed usage percentage across the selected window, so it is a sum of percentage points rather than a share of any one week; the caption also names the denominator of the `Cost share` column as total project spend in that same window. No column is added or removed. On an installation with more than one Claude account, both the Projects panel and the Projects table now also state that accounts are folded into one ranking and that their weekly quota percentages are never added together. That sentence appears on the Claude and All views only, because the Codex view ranks Codex projects and folds no Claude accounts at all (#620).
|
|
14
|
+
- The conversation rail now states that the message count beside each session includes subagent sidechains, so a session whose work was mostly delegated no longer reads as a far longer conversation than it was. The count itself is unchanged (#620).
|
|
15
|
+
- Every alert notification now ends with the command that explains it, scoped to that alert's own provider and window. A weekly crossing offers `cctally percent-breakdown --week-start` for the week that actually crossed, a five-hour crossing offers `cctally five-hour-breakdown --block-start` for that block, a budget crossing offers `cctally budget`, a project-budget crossing offers `cctally project` over that project and those dates, a projected-pace crossing offers `cctally forecast --explain`, and a quota crossing offers `cctally codex quota breakdown --reset-at` for that reset. The line states the provider and both bounds of the window it addresses, so following it re-measures the window the alert fired against rather than whatever window happens to be current. Where the alert did not retain enough to name a window — a projected budget crossing does not record which period it ran on — the line says so and offers nothing, rather than substituting the live window. A five-hour alert whose block has since been purged is treated the same way: the window it fired against is still stated, but no command is offered, because the block detail it would open is gone. A crossing whose window has already closed is treated the same way again, because the two commands that would explain it — `cctally budget` and `cctally forecast` — report only the period and the week that are live when you run them, and neither has a way to select an earlier one. A weekly crossing states the reset instant its subscription week actually runs from, which for almost every account is not midnight; a crossing recorded before cctally kept that instant states its week as two calendar dates with no clock reading, rather than a midnight it never observed. `alerts.log` is unchanged: the next step is part of the message body, not a new field (#620).
|
|
16
|
+
- The warning states of `cctally forecast`, `budget`, `cache-report`, `project` and `diff` now name the command that explains them, in the same line the alerts use. A capped or low-confidence forecast offers `percent-breakdown` for that week; a warn or over budget verdict offers `project` over that vendor's own period, so the Codex block names Codex rather than the default; an anomalous `cache-report` row offers `range-cost` with a per-model breakdown of the same window; a `diff` whose two windows are of different lengths — where the dollars on screen are per-day normalizations — offers `range-cost` for window A's real total; and a `project` run whose cache is missing entries offers `cache-sync` for that provider. Each line states the provider and both bounds of the window it addresses. Healthy output is unchanged, and so is every `--json` payload. Two smaller consequences: the `project` warning about missing entries no longer repeats its remediation inline, because the new line carries it; and the `--until` a suggested command names is the last calendar day the window touches, which on a subscription week anchored to a real reset instant is not the day before the end. The `range-cost` commands suggested by `cache-report` and `diff` name full instants rather than dates, because `range-cost` reads its `-s` / `-e` as points in time and does not extend a bare date to the end of that day (#620).
|
|
17
|
+
- The release preflight's local Linux matrix now provisions its three interpreter lanes from a prebuilt image instead of installing everything three times per cut, and overlaps with the authoritative suite in the release runbook. Running the three lanes at the same time was implemented and measured against fixed thresholds, and it was falsified: it met every resource and wall-clock threshold but produced load-sensitive dashboard and config HTTP failures — eight in one run and three in another, a different set each time — that single-lane runs over the same images did not, so the lanes stay sequential. Provisioning — the apt packages, the generated locale, the checksum-verified Node tarball, the from-source SQLite shell, the Python virtualenv and the frontend dependency tree — is baked into a content-addressed image per interpreter and rebuilt automatically whenever any input that can change it changes, so a stale image cannot enter a release gate and no operator step is added. The image carries a bounded weekly freshness epoch, because the previous gate was not reproducible but ambient-fresh (every lane ran `apt-get update`), and caching without a replacement policy would have frozen that environment indefinitely without ever failing. A failing lane no longer cancels the other two: the loop returned on the first failure, discarding what the other interpreters would have reported and forcing a blind rerun of all three, and it also skipped the HEAD and clean-tree rechecks that exist to stop a changed candidate certifying itself. Lane output streams to the terminal as it is produced, so the suite stays watchable, and is printed again in interpreter order at the end, because the ordered blocks are the release evidence. The gate also bounds its own image store: the weekly freshness epoch means the first cut of each new week mints a fresh three-image set, so after each successful resolve it removes lane images older than the current generation and one retained prior, and the untagged intermediates its two-pass build leaves — reclaiming 8 GB on the maintainer's host at the first run. One clean sequential run over already-resolved images was measured end to end at 73 minutes 16 seconds, which is four to five times the authoritative suite rather than comparable to it; the release runbook now states that figure, and overlapping the two gates costs approximately the matrix. The net effect on a cut is measured rather than estimated: preflight goes from about 92 minutes to about 73, of which the Gate 0 overlap is worth 968 seconds and removing per-lane provisioning about 150 more. That second figure comes from timing a three-image build with the layer cache emptied first — 153.8 seconds, about 51 per interpreter — which is also what a weekly freshness-epoch rebuild costs, in place of the ten to eighteen minutes the design estimated before anything was measured. `--acceptance` adds a measurement mode that retains each lane container so its exit and OOM state can be read, samples memory and `/tmp` every 250 ms, and evaluates fixed thresholds against the aligned sample streams — taking the lane schedule as an explicit input, because three sequential lanes start a lane duration apart and the concurrent start-spread and phase ceilings would refuse a healthy run. That input is checked rather than trusted: the gate derives the schedule from whether the measured lane intervals overlap and refuses to report a verdict when the derivation disagrees with what the driver declares, so a future return to concurrent lanes cannot be evaluated under sequential thresholds (#621).
|
|
18
|
+
- Open dashboard tabs now share one browser update stream, so each server frame is parsed once and distributed to active tabs while hidden tabs still suspend independently (#607).
|
|
19
|
+
- The dashboard now reads provider session and project-window data only from each provider's physical source. The retired duplicate provider members under `All` remain as schema-10 `null` compatibility stubs, but malformed or stale non-null values there can no longer become a second source of truth (#613).
|
|
20
|
+
- Project drill-downs now render the exact provider-native interval queried by the server instead of reconstructing Claude's interval in the browser (#571).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Public-test dependency closure now preserves the real directory depth of `Path(__file__)` parent chains, so a public test cannot hide a mirror-private fixture by addressing it relative to `tests/` (#626).
|
|
24
|
+
- Conversation and dashboard verification now fail on missing goldens unless regeneration is explicit, and a failed regeneration write can no longer print success or let the scenario pass (#625).
|
|
25
|
+
- The committed dashboard envelope oracle now verifies identically on macOS and every supported Linux/Python release lane. Its generated store uses resolved filesystem paths for reads and privacy containment while hashing one explicit logical corpus root for opaque identities, and Claude fixture ingestion now has a deterministic lexical file order instead of inheriting APFS or ext4 enumeration order. The oracle therefore keeps asserting the full Codex session metadata and aggregate payload across platforms rather than publishing two different identity universes from the same corpus (#622).
|
|
26
|
+
- Project attribution percentages now describe the subscription week they are rendered against. The dashboard Projects panel measured each project's share of cost over ISO-Monday-midnight weeks while multiplying it by a quota percentage that belongs to your account's real reset cycle, so the numerator, the denominator and the percentage described three different windows. Published `attributed_pct` values change for every account whose weekly reset is not exactly Monday midnight UTC — in practice almost every account — and they change for historical weeks as well as the current one. This is a correction to figures that were previously wrong, not a change of definition. A related consequence: cost that fell between Monday midnight and your real reset instant was previously attributed to the wrong week, and on a reset late in the week it could vanish from the panel entirely (#620).
|
|
27
|
+
- `cctally project --weeks N` now measures exactly the N subscription weeks it names. It resolved the start of its range by stepping back `7 * (N - 1)` days from the current week's start, which is short of the real start by a day for every drifted week inside the range — Anthropic's reset day moves, and a drifted cycle produces a genuinely short week, so over `--weeks 12` the range could begin several days inside the week before the one you asked for. That gap was not empty. Cost recorded in it was added to the reported total and attributed across whichever projects were active there; the earlier week's entire quota percentage was added to `Used %`, so the command reported N+1 weeks of quota against N weeks of spend; and where that earlier interval carried no usage snapshot, the output stated `Used % unavailable for 1 week — no usage snapshots recorded` about a week you had not asked about. `rangeStart`, `weeksInRange` and both totals move as a result, and the `--json` payload carries the corrected values under the same keys. A range given explicitly with `--since` / `--until` is unaffected (#620).
|
|
28
|
+
- `cctally project --account <ref>` now buckets that account's spend against that account's own weekly boundaries, and reports that account's own quota percentage. It previously built the week boundaries and read the percentages across all accounts, so on a machine with two accounts that reset on different weekdays a filtered report measured one account's dollars against a merged window belonging to neither, and could add another account's quota percentage into the total. An invocation without `--account` is unchanged (#620).
|
|
29
|
+
- Five-hour block totals no longer shrink where a block crosses a week boundary. The dashboard Blocks panel discarded the part of a block that fell outside the displayed week, while opening that same block's detail showed all of it, so the panel and its own drilldown disagreed and the panel was always the lower of the two. The panel now reports each block's whole native total (#620).
|
|
30
|
+
- `cctally cache-report` now says which of four evaluation states each row is in, in a new `Eval` column: `anomaly`, `clear`, `partial`, or `not eval`. The terminal previously marked only rows that triggered an anomaly, so an unmarked row could equally mean "every check ran and found nothing", "one check was skipped because the baseline was too thin", or "no check could run at all", and there was no way to tell which. The thin-baseline skip itself has not changed; only its visibility. The same four states already drove the dashboard's Cache Report panel, and the two are now pinned to each other by a shared truth table so they cannot disagree. `--json` is unchanged. One consequence of the extra column: at the default 120-column terminal width the daily table no longer has room for the `Input` column and drops it, which is the same behaviour the by-session table has always had at that width — widen your terminal, or read `--json`, to see both (#620).
|
|
31
|
+
- `cctally forecast` no longer reports a dollars-per-percent rate of exactly $0.00 for a week in which no usage has been observed. With no observed usage there is nothing to divide spend by, so the rate and every dollar figure derived from it — the daily budgets, the shareable report's `$ / 1%` row, and the dashboard's dollars-per-day projections — are now reported as unavailable instead of zero. Percent projections are unaffected, because they never depended on the rate. In `--json` the `dollars_per_percent` key is still present and is `null`, and `dollars_per_percent_source` carries the new value `no_usage_observed` (#620).
|
|
32
|
+
- Shareable weekly, `$ / 1%` trend and forecast artifacts no longer print a measured-looking zero for a figure nothing measured. Three cases are corrected. A week's `$/% rate` on the weekly panel printed `$0.000` when the week carried no usage percentage to divide spend by. A week's `% used` on the weekly and trend panels printed `0.0%` when the week had a cost snapshot but no usage snapshot, which no reader could tell from a week that really used nothing — and on the trend panel that fabricated percentage was multiplied by a real rate, so it published a $0.00 weekly cost as well. The forecast artifact's `Days→90%` and `Days→100%` printed `0.0` when no rate had been observed, which states that you have already reached the ceiling; the truth is that the ceiling is not reachable on any timeline the data describes. All of these now read `n/a`, the same word these artifacts already use for a withheld dollar figure, and a target you genuinely have reached still reports `0.0` days. Artifacts whose figures were all measured are byte-for-byte unchanged (#620).
|
|
33
|
+
- `cctally report --source all` now renders the whole Claude report — the current-week table and the trend table with its `$ / 1%` column — instead of the single line `Data available.` The all-source path read a compatible-totals object the report payload does not carry, so it found nothing to print while `cctally report` on its own printed everything. Both paths now call one renderer, so they cannot diverge again. `cctally project --source all`, `range-cost --source all` and `cache-report --source all` no longer print `Data available.` either: where Claude has rows but no compatible cost or token total, they say so and name the command that shows the full Claude detail (#620).
|
|
34
|
+
- The `cctally report` trend table now marks a week shorter than seven days with `~` in the `#` column, and prints a line explaining the marker whenever at least one row carries it. An early reset ends a cycle before its scheduled boundary, and a `$ / 1%` computed over five days is not directly comparable with one computed over seven; the table previously gave no way to tell the two apart. A row is marked only when its span falls short of seven days by more than an hour, so ordinary reset jitter is never marked, and a table of full weeks is unchanged (#620).
|
|
35
|
+
- `cctally budget` now annotates a low-confidence projection `(LOW CONF — limited evidence)` instead of `(LOW CONF — early in week)`. The condition that raises the annotation is a disjunction — the period is barely elapsed, or nothing has been spent yet — so on a fully elapsed period with no spend the old wording stated something that was simply not true. The condition itself is unchanged; only what it says about itself (#620).
|
|
36
|
+
- `cctally forecast` now writes its low-confidence reasons in plain words rather than printing its internal codes. Where the terminal used to read `LOW CONF — insufficient data (elapsed_hours<24, no_sample_ge_24h)` it now reads `less than 24 hours into the week, no snapshot at least 24 hours old`, wrapped across as many lines as it needs so the panel frame stays intact. All four reasons are covered, an unfamiliar code is printed as-is rather than dropped, and `docs/commands/forecast.md` lists every trigger. `--json` is unchanged: `low_confidence_reasons` still carries the same codes (#620).
|
|
37
|
+
- `cctally project` gains a `Cost Share` column giving each project's percentage of the total cost of the projects listed, with that total and that project count stated in a line under the table — the terminal has no hover, so the denominator is written out. It answers a different question from `Used %`, which is a share of your weekly quota rather than of listed spend. Model rows under `--breakdown` leave it blank, because a model's share of its own project is a different denominator again. One consequence of the extra column: at the default 120-column terminal width the remaining columns are narrower and more headers abbreviate — widen your terminal, or read `--json`, whose payload is unchanged (#620).
|
|
38
|
+
- The dashboard's forecast tile now states when the data behind it is stale, degraded, or limited by what a provider can report. The combined view's provider cards already said so; a single-provider tab drew the projection and said nothing, so the same envelope produced a warning on one tab and silence on another. Both surfaces now render the same status and the same sentence (#620).
|
|
39
|
+
- The dashboard's forecast detail now explains an unavailable `$ / 1%` instead of printing a bare dash, which was indistinguishable from a failure to load. Where no usage has been observed in the window there is no rate to measure, and the panel now says that (#620).
|
|
40
|
+
- The dashboard's `$/1%` sparkline now leaves a gap for a week whose rate was never measured, instead of drawing a bar on the axis. A withheld week previously rendered at the chart's floor, which reads as a measured collapse to nearly zero; the shareable version of the same chart has always omitted such a point. The remaining weeks keep their positions, so the gap sits on the week that is actually missing, and they are no longer scaled against a value nothing measured (#620).
|
|
41
|
+
- A historical week whose exact start and reset were never recorded now says so when you open it, instead of showing a bare date and a dash with no explanation. Its milestones, percentage and progress bar are unaffected and were always correct (#620).
|
|
42
|
+
- The dashboard's week-history list no longer empties itself when one historical week is missing its reset instant. `percent_milestones.week_start_at` is nullable and post-dates the table, so a crossing recorded before that column existed carries only the week's calendar date; where such a week also has no usage snapshot, building the hero modal's week index raised, and the dashboard recorded the failure and published an empty list — so one old row removed every other week from the history at once, and `last_sync_error` was the only sign of it. A week with no recorded boundaries is now listed with its date label and no window, and every other week is listed as before (#620).
|
|
43
|
+
- Codex reports now recognize `gpt-daybreak-blue-latest` as the runtime alias of `gpt-5.6-sol`, so retained Daybreak usage uses the Sol standard and fast rate cards with `isFallback=false` and no unknown-model warning (#606).
|
|
44
|
+
- The pricing-freshness workflow now rewrites or closes only the auto-managed ledger carrying its durable body marker; a manually filed issue can share the `pricing-drift` label without being mistaken for automation-owned state (#606).
|
|
45
|
+
- The dashboard now distinguishes paused, resuming, disconnected, and silent-stream states with accurate copy and wall-clock data age instead of contradictory generic connection guidance (#611).
|
|
46
|
+
- A multi-account `All` view now shows combined spend only when every visible account subtotal and native provider period is server-certified and reconciles; otherwise it explains why the figure is withheld (#565).
|
|
47
|
+
- The authoritative suite now rebuilds the canonical small dashboard corpus and verifies its committed envelope byte count, stable digest, and corpus fingerprint, so a stale envelope oracle fails the gate instead of remaining an unchecked sidecar (#608).
|
|
48
|
+
- Doctor remediation commands now keep each CLI flag intact at 320px instead of wrapping between its leading hyphens and name (#616).
|
|
49
|
+
- All seven dashboard range notes remain visible while consuming less fixed-panel height, restoring room for Recent Alerts on desktop without creating phone overflow (#573).
|
|
50
|
+
- The pre-commit hook now rejects private maintainer identity literals in staged public-mirror blobs before they can enter a commit (#598).
|
|
51
|
+
|
|
8
52
|
## [1.100.0] - 2026-08-18
|
|
9
53
|
|
|
10
54
|
### Added
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -77,12 +77,15 @@ _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
|
77
77
|
_alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
|
|
78
78
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
79
79
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
80
|
+
_with_next_step = _lib_alerts_payload._with_next_step
|
|
81
|
+
alert_next_step_line = _lib_alerts_payload.alert_next_step_line
|
|
80
82
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
81
83
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
82
84
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
83
85
|
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
84
86
|
_build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
|
|
85
87
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
88
|
+
synthetic_preview_week_start = _lib_alerts_payload.synthetic_preview_week_start
|
|
86
89
|
|
|
87
90
|
|
|
88
91
|
def _build_alert_payload_quota(
|
|
@@ -133,7 +136,9 @@ def _alert_text_quota(payload: dict, _tz) -> tuple[str, str, str]:
|
|
|
133
136
|
body = f"Projected {float(context.get('projected_percent') or 0):.0f}% by reset"
|
|
134
137
|
else:
|
|
135
138
|
body = f"Actual usage {float(context.get('qualifying_percent') or 0):.0f}%"
|
|
136
|
-
|
|
139
|
+
# Seventh axis, same affordance (#620 S1): quota is absent from the
|
|
140
|
+
# dashboard alert envelope, so the CLI line is the only routing it gets.
|
|
141
|
+
return title, subtitle, _with_next_step(body, payload, _tz)
|
|
137
142
|
|
|
138
143
|
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
139
144
|
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
@@ -424,10 +429,16 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
424
429
|
)
|
|
425
430
|
return 2
|
|
426
431
|
if axis == "weekly":
|
|
432
|
+
# The preview must carry the reset INSTANT a real crossing carries;
|
|
433
|
+
# without it the body renders the day-granularity fallback a
|
|
434
|
+
# pre-column historical row degrades to, which is not what this
|
|
435
|
+
# command exists to show.
|
|
436
|
+
preview_week_start = synthetic_preview_week_start()
|
|
427
437
|
payload = _build_alert_payload_weekly(
|
|
428
438
|
threshold=threshold,
|
|
429
439
|
crossed_at_utc=now_utc_iso(),
|
|
430
|
-
week_start_date=
|
|
440
|
+
week_start_date=preview_week_start.date().isoformat(),
|
|
441
|
+
week_start_at=preview_week_start.isoformat().replace("+00:00", "Z"),
|
|
431
442
|
cumulative_cost_usd=1.23,
|
|
432
443
|
dollars_per_percent=0.01,
|
|
433
444
|
)
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -498,7 +498,9 @@ def _iter_claude_jsonl_files():
|
|
|
498
498
|
``projects/`` tree. Shared by ``sync_cache`` and the conversation backfill
|
|
499
499
|
so both ingest paths enumerate the IDENTICAL file set."""
|
|
500
500
|
for claude_dir in _get_claude_data_dirs():
|
|
501
|
-
for jp in (
|
|
501
|
+
for jp in sorted(
|
|
502
|
+
(claude_dir / "projects").glob("**/*.jsonl"), key=lambda path: str(path)
|
|
503
|
+
):
|
|
502
504
|
if jp.is_file():
|
|
503
505
|
yield jp
|
|
504
506
|
|
|
@@ -21,6 +21,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
21
21
|
from _cctally_core import eprint, now_utc_iso, parse_iso_datetime, _command_as_of
|
|
22
22
|
from _lib_fmt import stable_sum
|
|
23
23
|
import _lib_cache_report as crk
|
|
24
|
+
# #620 S1 D11: the one affordance shape every warning state renders.
|
|
25
|
+
import _lib_alert_scope
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def _cctally():
|
|
@@ -130,6 +132,16 @@ def _layout_cache_table(
|
|
|
130
132
|
|
|
131
133
|
content_widths = [len(h) for h in headers]
|
|
132
134
|
for cells, _rt in raw_rows:
|
|
135
|
+
# An over-long row is a caller defect that no padding can repair, and
|
|
136
|
+
# it is reached here first — `content_widths[i]` would raise a bare
|
|
137
|
+
# `IndexError`. Report it as the module's own `ValueError`, which
|
|
138
|
+
# `main()` already handles (#620 S1: a short row is padded in
|
|
139
|
+
# `make_row`; only the surplus-cell case is refused).
|
|
140
|
+
if len(cells) > num_cols:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"cache-report row has {len(cells)} cells, table has "
|
|
143
|
+
f"{num_cols}"
|
|
144
|
+
)
|
|
133
145
|
for i, (text, _c) in enumerate(cells):
|
|
134
146
|
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
135
147
|
|
|
@@ -299,6 +311,21 @@ def _layout_cache_table(
|
|
|
299
311
|
return text + " " * pad_needed
|
|
300
312
|
|
|
301
313
|
def make_row(cells: list[str]) -> str:
|
|
314
|
+
# #620 S1 D6. A short row used to render silently: `enumerate(cells)`
|
|
315
|
+
# simply stopped early, so the row closed its border after however
|
|
316
|
+
# many cells it was given and the table lost a column on that line
|
|
317
|
+
# alone. That made the "every row type supplies the twelfth cell"
|
|
318
|
+
# invariant live only in a test.
|
|
319
|
+
#
|
|
320
|
+
# The guard pads rather than asserts. A bare `assert` is elided under
|
|
321
|
+
# `python -O`, which would restore the unclosed border on exactly the
|
|
322
|
+
# interpreter flag that removes it, and when it does fire it raises an
|
|
323
|
+
# uncaught `AssertionError` outside the documented exit taxonomy. A
|
|
324
|
+
# padded row closes its border under every flag. The OVER-long row is
|
|
325
|
+
# refused earlier, at the column-width scan, because that loop reaches
|
|
326
|
+
# the surplus cell first.
|
|
327
|
+
if len(cells) < num_cols:
|
|
328
|
+
cells = list(cells) + [""] * (num_cols - len(cells))
|
|
302
329
|
parts: list[str] = []
|
|
303
330
|
for i, cell_text in enumerate(cells):
|
|
304
331
|
padded = padcell(cell_text, col_widths[i], aligns[i])
|
|
@@ -364,13 +391,37 @@ def _layout_cache_table(
|
|
|
364
391
|
return "\n".join(lines)
|
|
365
392
|
|
|
366
393
|
|
|
394
|
+
def _verdict_cell(row, *, red):
|
|
395
|
+
"""The ``Evaluation`` cell for one data row (#620 S1 D6).
|
|
396
|
+
|
|
397
|
+
Both terminal render paths marked ONLY triggered rows, so an unmarked row
|
|
398
|
+
could mean three different things — evaluated and clean, one predicate
|
|
399
|
+
skipped, or nothing evaluated at all — and the reader had no way to tell
|
|
400
|
+
which. The dashboard has distinguished all four since #443 S2; this is the
|
|
401
|
+
terminal saying the same thing in words.
|
|
402
|
+
|
|
403
|
+
The classification itself comes from the shared kernel, never re-derived
|
|
404
|
+
here. The thin-baseline skip is unchanged: the unmarked tick over it was
|
|
405
|
+
the defect, not the skip.
|
|
406
|
+
"""
|
|
407
|
+
verdict = crk.cache_row_verdict(
|
|
408
|
+
triggered=bool(row.anomaly_triggered),
|
|
409
|
+
reasons=list(row.anomaly_reasons or []),
|
|
410
|
+
unevaluated=list(row.anomaly_unevaluated or []),
|
|
411
|
+
)
|
|
412
|
+
label = crk.cache_row_verdict_label(verdict.state)
|
|
413
|
+
# Colour stays on the anomalous state only, matching the row's existing
|
|
414
|
+
# red treatment; the other three are informational, not warnings.
|
|
415
|
+
return (label, red if verdict.state == "anomalous" else None)
|
|
416
|
+
|
|
417
|
+
|
|
367
418
|
def _render_cache_day_rows(
|
|
368
419
|
rows: list["crk.CacheRow"], title: str, *, compact: bool = False,
|
|
369
420
|
) -> str:
|
|
370
421
|
"""Render daily-mode cache report.
|
|
371
422
|
|
|
372
423
|
Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
|
|
373
|
-
Total Tokens, Cost (USD), $ Saved, $ Wasted, Net
|
|
424
|
+
Total Tokens, Cost (USD), $ Saved, $ Wasted, Net $, Eval.
|
|
374
425
|
"""
|
|
375
426
|
c = _cctally()
|
|
376
427
|
color = c._supports_color_stdout()
|
|
@@ -394,11 +445,11 @@ def _render_cache_day_rows(
|
|
|
394
445
|
headers = [
|
|
395
446
|
"Date", "Models", "Cache %", "Input",
|
|
396
447
|
"Cache Create", "Cache Read", "Total Tokens", "Cost (USD)",
|
|
397
|
-
"$ Saved", "$ Wasted", "Net $",
|
|
448
|
+
"$ Saved", "$ Wasted", "Net $", "Eval",
|
|
398
449
|
]
|
|
399
450
|
aligns = [
|
|
400
451
|
"left", "left", "right", "right", "right", "right", "right", "right",
|
|
401
|
-
"right", "right", "right",
|
|
452
|
+
"right", "right", "right", "left",
|
|
402
453
|
]
|
|
403
454
|
|
|
404
455
|
arrow = " └─" if unicode_ok else " |_"
|
|
@@ -421,10 +472,11 @@ def _render_cache_day_rows(
|
|
|
421
472
|
(f"${row.saved_usd:.2f}", None),
|
|
422
473
|
(f"${row.wasted_usd:.2f}", None),
|
|
423
474
|
(f"${row.net_usd:+.2f}", None),
|
|
475
|
+
_verdict_cell(row, red=_red),
|
|
424
476
|
]
|
|
425
477
|
# Anomaly visual treatment (data rows only — never breakdown/footer).
|
|
426
478
|
# Cell-index map (daily): 0=Date, 1=Models, 2=Cache%, 3=Input, 4=CC,
|
|
427
|
-
# 5=CR, 6=Total, 7=Cost, 8=Saved, 9=Wasted, 10=Net
|
|
479
|
+
# 5=CR, 6=Total, 7=Cost, 8=Saved, 9=Wasted, 10=Net $, 11=Evaluation.
|
|
428
480
|
if row.anomaly_triggered:
|
|
429
481
|
first_text, first_style = data_cells[0]
|
|
430
482
|
data_cells[0] = (
|
|
@@ -460,6 +512,12 @@ def _render_cache_day_rows(
|
|
|
460
512
|
(f"${mb.saved_usd:.2f}", _gray),
|
|
461
513
|
(f"${mb.wasted_usd:.2f}", _gray),
|
|
462
514
|
(f"${mb.net_usd:+.2f}", _gray),
|
|
515
|
+
# The verdict is classified per day, not per model, so a
|
|
516
|
+
# child row leaves it blank — as the project table's model
|
|
517
|
+
# rows leave Cost Share blank. The cell is still supplied:
|
|
518
|
+
# `make_row` pads nothing, so a short row renders one column
|
|
519
|
+
# narrow and its right border never closes.
|
|
520
|
+
("", None),
|
|
463
521
|
]
|
|
464
522
|
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
465
523
|
|
|
@@ -484,6 +542,10 @@ def _render_cache_day_rows(
|
|
|
484
542
|
(f"${tot_saved:.2f}", _yellow),
|
|
485
543
|
(f"${tot_wasted:.2f}", _yellow),
|
|
486
544
|
(f"${tot_net:+.2f}", _yellow),
|
|
545
|
+
# A verdict describes one row's predicate run; there is no such
|
|
546
|
+
# thing as the total of several, so the footer leaves it blank —
|
|
547
|
+
# but it must still supply the cell (see the breakdown note above).
|
|
548
|
+
("", None),
|
|
487
549
|
]
|
|
488
550
|
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
489
551
|
|
|
@@ -507,7 +569,7 @@ def _render_cache_session_rows(
|
|
|
507
569
|
|
|
508
570
|
Columns: SessionId, Last Activity, Project, Cache %, Input,
|
|
509
571
|
Cache Create, Cache Read, Total Tokens, Cost (USD), $ Saved,
|
|
510
|
-
$ Wasted, Net
|
|
572
|
+
$ Wasted, Net $, Eval.
|
|
511
573
|
|
|
512
574
|
``tz`` is the resolved display zone (None = host local). Last-Activity
|
|
513
575
|
cells are rendered in this zone.
|
|
@@ -535,11 +597,13 @@ def _render_cache_session_rows(
|
|
|
535
597
|
"SessionId", "Last Activity", "Project",
|
|
536
598
|
"Cache %", "Input", "Cache Create", "Cache Read",
|
|
537
599
|
"Total Tokens", "Cost (USD)", "$ Saved", "$ Wasted", "Net $",
|
|
600
|
+
"Eval",
|
|
538
601
|
]
|
|
539
602
|
aligns = [
|
|
540
603
|
"left", "left", "left",
|
|
541
604
|
"right", "right", "right", "right",
|
|
542
605
|
"right", "right", "right", "right", "right",
|
|
606
|
+
"left",
|
|
543
607
|
]
|
|
544
608
|
|
|
545
609
|
arrow = " └─" if unicode_ok else " |_"
|
|
@@ -570,11 +634,12 @@ def _render_cache_session_rows(
|
|
|
570
634
|
(f"${row.saved_usd:.2f}", None),
|
|
571
635
|
(f"${row.wasted_usd:.2f}", None),
|
|
572
636
|
(f"${row.net_usd:+.2f}", None),
|
|
637
|
+
_verdict_cell(row, red=_red),
|
|
573
638
|
]
|
|
574
639
|
# Anomaly visual treatment (data rows only — never breakdown/footer).
|
|
575
640
|
# Cell-index map (session): 0=SessionId, 1=Last Activity, 2=Project,
|
|
576
641
|
# 3=Cache%, 4=Input, 5=CC, 6=CR, 7=Total, 8=Cost, 9=Saved, 10=Wasted,
|
|
577
|
-
# 11=Net
|
|
642
|
+
# 11=Net $, 12=Evaluation.
|
|
578
643
|
if row.anomaly_triggered:
|
|
579
644
|
first_text, first_style = data_cells[0]
|
|
580
645
|
data_cells[0] = (
|
|
@@ -608,6 +673,9 @@ def _render_cache_session_rows(
|
|
|
608
673
|
(f"${mb.saved_usd:.2f}", _gray),
|
|
609
674
|
(f"${mb.wasted_usd:.2f}", _gray),
|
|
610
675
|
(f"${mb.net_usd:+.2f}", _gray),
|
|
676
|
+
# Blank for the same reason as the daily table's child rows;
|
|
677
|
+
# the cell is still supplied because `make_row` pads nothing.
|
|
678
|
+
("", None),
|
|
611
679
|
]
|
|
612
680
|
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
613
681
|
|
|
@@ -634,6 +702,8 @@ def _render_cache_session_rows(
|
|
|
634
702
|
(f"${tot_saved:.2f}", _yellow),
|
|
635
703
|
(f"${tot_wasted:.2f}", _yellow),
|
|
636
704
|
(f"${tot_net:+.2f}", _yellow),
|
|
705
|
+
# Blank for the same reason as the daily table's footer.
|
|
706
|
+
("", None),
|
|
637
707
|
]
|
|
638
708
|
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
639
709
|
|
|
@@ -1238,4 +1308,31 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1238
1308
|
|
|
1239
1309
|
title = _build_cache_report_title(args, mode)
|
|
1240
1310
|
print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
|
|
1311
|
+
# #620 S1 D11: an anomalous row routes to the command that shows what the
|
|
1312
|
+
# flagged window was actually made of, over the same window and provider.
|
|
1313
|
+
if any(getattr(r, "anomaly_triggered", False) for r in rows):
|
|
1314
|
+
source = getattr(args, "source", None) or "claude"
|
|
1315
|
+
# `range-cost -s/-e` are ISO-8601 INSTANTS, not inclusive days:
|
|
1316
|
+
# `parse_iso_datetime` never extends a bare date to end-of-day and
|
|
1317
|
+
# reads a naive value as HOST-LOCAL. A date-only selector therefore
|
|
1318
|
+
# names a different, shorter window than the line states, so both
|
|
1319
|
+
# bounds are emitted as full UTC instants.
|
|
1320
|
+
#
|
|
1321
|
+
# `_resolve_cache_report_window` returns both bounds aware in the
|
|
1322
|
+
# DISPLAY zone (host-local when `display.tz` is `local`), and
|
|
1323
|
+
# `strftime` formats the datetime's own fields while appending the
|
|
1324
|
+
# `Z` as a literal character. Converting to UTC first is what makes
|
|
1325
|
+
# the suffix true; without it the selector is a local wall clock
|
|
1326
|
+
# labelled UTC.
|
|
1327
|
+
since_utc = since.astimezone(dt.timezone.utc)
|
|
1328
|
+
until_utc = until.astimezone(dt.timezone.utc)
|
|
1329
|
+
print(_lib_alert_scope.next_step_line(
|
|
1330
|
+
f"cctally range-cost -s {since_utc:%Y-%m-%dT%H:%M:%SZ} "
|
|
1331
|
+
f"-e {until_utc:%Y-%m-%dT%H:%M:%SZ} "
|
|
1332
|
+
f"-b --source {source}",
|
|
1333
|
+
provider=source,
|
|
1334
|
+
window_start=since,
|
|
1335
|
+
window_end=until,
|
|
1336
|
+
tz=tz,
|
|
1337
|
+
))
|
|
1241
1338
|
return 0
|