cctally 1.100.0 → 1.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
package/CHANGELOG.md CHANGED
@@ -5,6 +5,79 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.102.0] - 2026-08-22
9
+
10
+ ### Added
11
+ - `cctally explain` answers one question about a window: which subjects account for the money it spent. It reports four contributor classes — an expensive model mix, one project dominating, one session dominating, and concentrated 5-hour bursts — ranked by observed cost against a denominator it names, with one `-> Run cctally …` next step per contributor that takes you to the evidence. Each next step reproduces this command's own accounting where the subcommand it names allows it, so the dollars you see after following it are the dollars that sent you there; `docs/commands/explain.md` names the two targets that cannot take that flag. It gives no behavioural advice and never projects a quota percentage onto a slice of spend. Every figure states the population it was measured over, and a figure that could not be measured is withheld with a typed cause rather than printed as a zero: a window nothing could be priced in says `pricing_unavailable` where the denominator would go, not `$0.00`. A class that finds no contributor is reported as such, stating the same population a contributor row states and closing with its confidence, so a healthy answer is distinguishable from an unmeasured one; a class that could not be measured states which minimum it missed and claims no confidence, because confidence is a statement about a measurement it never made. A `contributor_detected` verdict states how many applicable classes were withheld beside it, so it can never be read as a complete account of the window. `--source all` gives each provider its own section, its own denominator and its own verdict, and a provider whose store cannot be read withholds itself rather than ending the report. A store that could not be read is the one withheld cause that also sets the exit code: when no requested provider answered, the report is still printed — you need to see the typed cause — and the command exits 3, so a script sees the failure. Every other withheld cause is a correct answer about what the store holds and exits 0. Projects are anonymized by default; sessions stay opaque in every mode. `--json` emits a stamped `schemaVersion` 1 envelope. Flags: `--source`, `--account`, `--window`, `--speed`, `--tz`, `--json`, `--reveal-projects`. See `docs/commands/explain.md` (#620).
12
+ - `cctally explain` now reports three more contributor classes, drawn from your locally retained conversations rather than from accounting rows alone: **prompt-cache churn** (turns whose cached prefix collapsed and was re-created in the same turn), **short conversations carrying large context** (at most three human turns where one request used at least 80% of that request's own model context window), and **subagent fan-out** (cost attributable to delegated work, grouped by parent). Each contributes one aggregate row for the qualifying set, ranked on the same denominator as the other four, with its members published beneath it as evidence — how many turns were flagged, how many conversations they touched, the estimated wasted cost, the median human-turn count, the largest single request as a share of its context window, how many subagents were identified, the largest subagent's share, and any cost that could not be attributed to exactly one parent. Every class now also prints the rule that produced its verdict and the definition of a human turn, so a verdict can be reproduced rather than trusted; that definition matters because the message count beside a session includes subagent sidechains and is not a human-turn count.
13
+ - Every authoritative test run now records how long each test took. A standalone pytest plugin, enabled only on an authoritative run that has an evidence directory, writes one record per test phase — setup, call and teardown — with the node id, the duration, the outcome and which pytest leg produced it, and the run publishes them as `timings/pytest-tests.jsonl.gz` beside the rest of its evidence. Nobody could previously name the slowest test in the repository. The plugin is loaded with `-p` rather than from `pytest.ini` or a `conftest.py`, so running pytest directly is unaffected, and it refuses to write in an xdist worker, so only the controller — which receives every worker's reports — produces records. The main and benchmark legs are separate processes and write separate files, which the run then merges in a fixed order and publishes atomically. The merged artifact states its own completeness, and the run says so too. A leg whose process was killed leaves a truncated file, which the merge reads as far as it decodes and then publishes marked incomplete — it no longer abandons the artifact entirely on the one failure the completeness contract exists for. A leg that reached the end of pytest but did not run what it collected — an internal error, an interruption, a usage error — is also marked incomplete, because reaching the end is not the same as finishing the work; a run that merely had failing tests is a normal, complete run. An incomplete artifact is reported as a contract note on the run itself, so the state is visible without opening the file, and a consumer cannot read a truncated run as one in which the missing tests were simply fast. Only runs on the maintainer's LAN runners retain this history, because hosted CI destroys its evidence directory when the job ends (#630).
14
+
15
+ ### Fixed
16
+ - The public-mirror preview now recognizes the recorded README-refresh overlay, matching the release publisher's existing safety check instead of reporting an expected post-promotion refresh as unrelated drift.
17
+ - The README's compact Latest stable block now names the actual previous-stable-to-target upgrade range and links its complete release notes instead of making three target-release highlights look like the whole stable upgrade (#639).
18
+ - An authoritative red is now far more likely to mean the change is broken rather than that the runner was busy. One hundred and ninety-nine test budgets that a contended runner could blow — every one of them a wait for something that should arrive, including a hundred and eighteen loopback HTTP requests and connects at two to five seconds — now use the estate's one shared thirty-second backstop, derived from the slowest node ever measured under real contention. One further site was kept instead of raised, because it is one attempt's observation window inside a four-attempt retry and raising it would have made the failing path longer rather than the healthy one safer; it states that reason at the site.
19
+ - A failing authoritative test run no longer loses its whole failure extract to one over-eager sanitizer check. A refused line is now replaced with a placeholder naming the check that refused it, and the rest of the extract is published with a trailing notice stating how many lines went and why; the count and the reasons also reach stderr and the run manifest, so the removal is never silent. The offending line's own bytes still never reach the file. That per-line treatment applies to the three checks that judge the shape of a line the sanitizer deliberately keeps; a check that finds actual content the sanitizer should have removed — a machine path, an address, a credential, a sentence of prose — still refuses the whole export and writes no file, because that means the sanitizer itself leaked and the lines it did not flag cannot be trusted either. Refusing the entire file for any single violation was the previous rule, and it turned a sanitizer false positive into the operator receiving zero bytes of diagnostic evidence — over-redaction reappearing at the file level. Deciding it by how many lines were flagged instead was tried and withdrawn: a sanitizer fault confined to its vocabulary stage publishes production text verbatim while flagging about one line in fourteen, so a proportional threshold published exactly the extract the strict rule withheld. The console path degrades the same way, per line, instead of dropping a whole harness's block, and the run manifest now records a refused export as a refusal with its cause rather than as an absence (#630).
20
+ - Three of the export validator's structural checks were wrong and are corrected. The doubled-`E `-gutter refusal is withdrawn: pytest prefixes every line of a multi-line assertion message with its gutter, so the sanitizer legitimately emits `E E assert …`, and refusing it destroyed the extract for exactly the failure class the extract exists to explain. It closed nothing either, because the validator has no gutter awareness in any other check, the single-gutter form was already accepted, and the payload is judged identically either way — so the check is not replaced and the code records that plainly, together with the measurements behind it. The counters check no longer refuses a counters line that arrives behind that same gutter, which is how pytest reports a nested test session and is the single most diagnostic line such a failure produces; it accepts the gutter at the front of the line only, so foreign text anywhere else on a counters line is still refused. The counters check's trigger no longer depends on the sanitizer's own leading anchor: it now also fires on a counter pair plus pytest's `in <duration>` tail anywhere on the line, so a summary line carrying foreign text before its counts is judged rather than skipped. A pytest node name whose bracket closes before it opens is now normalized and refused as well, closing the last member of the retained-parameter-fragment class (#630).
21
+ - `bin/cctally-test-remote --status` no longer states a measured zero over an evidence ledger it never read. When the ledger cannot be opened, the eviction block reads `unmeasured — the ledger could not be read` and every count in `evidenceEviction` is null, which is a different answer from a window in which no retention pass ran. `docs/remote-testing.md` now documents the eviction block on both surfaces and the `evidenceEviction` and `evidenceEvictionWindow` JSON keys (#630).
22
+ - The retained-evidence budget on each test runner is now twelve days and 2 GiB, up from seven days and 1 GiB, and both figures are derived from measurement rather than chosen. The horizon rises because the run ledger's own window measured 11.06 days while the age cutoff counts whole days, so eleven does not cover it — at seven days the store was deleting evidence the ledger still reported on, and every coverage gap ever recorded came from age rather than from the size cap, which has never bound. The cap rises to hold the new per-test durations artifact at its uncompressed size, because a compression ratio is not a correctness guarantee: one measured run's artifact is 531,404 compressed bytes and 7,902,147 uncompressed, and projecting the uncompressed figure across the runs the twelve-day horizon holds does not fit under 1 GiB. Raising the horizon does not restore evidence already deleted, and historical gaps never heal, so a store can read degraded permanently; `docs/remote-testing.md` states the warm-up and what the acceptance actually is (#630).
23
+ - `bin/cctally-test-remote --report` now measures how often the evidence store actually loses runs, and the retention record it reads stops overstating what was reclaimed. The report gains an eviction block that states every total beside its denominator — how many retention passes ran in the window, how many of them evicted anything, how many runs and bytes went, and the split by reason. Passes are counted after deduplication, because every completed run re-imports the current retention record whether or not a pass occurred, so the raw event count runs about eight times the real one; and deduplication happens before the window filter, so a pass from outside the window that was merely re-imported inside it does not count. A rate over no passes reads as unmeasured rather than as zero. On the producer side, the record is now rebuilt from what is actually on disk after every deletion attempt rather than from the planned set: a deletion that failed previously left the record claiming the bytes were reclaimed and a coverage hole where the evidence still sat, and the byte cap is enforced against exactly that number. A partially deleted run whose manifest is gone is counted as an orphan — its bytes are included in the total and in the cap comparison, and the store reads degraded — instead of being invisible to both, and a pass that degrades only because of an orphan now prints the operator notice rather than degrading silently. A run directory that exists but has not written its first manifest yet is given a short grace window instead of being reported as an orphan, so a concurrent run no longer flips another run's coverage to degraded; its bytes are still counted, as are those of a manifest carrying a state this binary does not recognise, which previously belonged to no total at all and left the retained figure understating what is on disk. The record also now carries the exact bytes evicted and a per-reason run and byte split, and both text surfaces state the bytes retained and the eviction totals with their denominators — `--status` names the window its denominator covers, because it has no window flag of its own (#630).
24
+ - `bin/cctally-test-remote --status` and `--report` no longer crash on the live evidence store. Both surfaces read each retention coverage gap by position, while the producer has written keyed objects since the merge helper was introduced; the only fixtures covering them hand-built the positional shape, so both surfaces raised `KeyError: 0` on every real record and the fixtures agreed with each other rather than with the producer. Gaps are now normalized once, where the ledger record is decoded, and both renderers read named keys. An open-ended gap — the shape the retention planner emits when the newest evidence is what was lost — renders as `(ongoing)` rather than being dropped, which a key-only fix would have turned into a store with a permanent hole reported as complete. A record that cannot be read as the producer writes it is named on stderr and exits 3 with the rest of the report still rendered, rather than raising a traceback on the instrument you run because something is broken (#630).
25
+ - A failing authoritative test run now leaves a retained failure extract that says what failed. The sanitizer keeps the failing pytest node id together with its exception class instead of discarding the tail; it keeps an indented summary line on the node rule rather than dropping it to an unclassified marker, which is the shape the aggregator actually prints; it strips pytest's `E ` gutter and classifies what the gutter wraps, so a bare exception line survives as its class; and it admits pytest's own counters line without needing a repository vocabulary, including the `(H:MM:SS)` form pytest appends once a session runs a minute or longer. The exception message is still never retained, and a parameter id is still normalized to `[<param>]`. A parameter id containing a space — 309 of this repository's parametrize constants carry one — is normalized whole rather than truncated part-way through and published as a fragment of the value. The independent export validator gained three structural refusals written separately from the sanitizer's own rules: a parameter id that is not the placeholder, closed or unterminated; any text after the exception-message placeholder; and a foreign token on pytest's counters line, judged now by requiring every token on the line to come from a closed set rather than by restating the shape the sanitizer matches, which left the check blind to anything the sanitizer got wrong in that dimension. The parameter refusal is also scoped to a real pytest node identifier: it used to fire on any line containing a `::name[value]` span, and at the time a refusal deleted the whole export file, so one false positive turned a partially useful extract into zero bytes (#630).
26
+ - Dashboard modals now recover orphaned Tab and Shift+Tab focus into the topmost open surface, and five-hour blocks, block details, and recent alerts state their bracketed IANA display zone instead of showing only an ambiguous abbreviation or offset (#635, #636).
27
+ - Scheduled issue intake no longer mistakes a valid tracker ledger for malformed JSON when a retained mutation error contains an older tracker body.
28
+ - Conversation-derived diagnosis now keeps enough Codex event history to evaluate production-sized windows without letting unrelated payload rows consume the turn-inference budget. The reproducible benchmark now materializes Claude sidechains and Codex conversations, reports those populations, and can interleave a reference build with the candidate on one pinned runner; retained text normalization also follows one shared malformed-block convention across parsing and search (#631, #632, #633).
29
+ - Public Linux release validation no longer lets a cache-local JSON read trap affect unrelated stdlib callers, and dashboard settings tests now report the exact socket phase on timeout while closing every temporary server socket and thread. The hosted workflow now selects its Linux profile instead of running the macOS remote-runner harness, and retention unit fixtures no longer inherit the production free-disk emergency floor from a loaded Actions runner (#628).
30
+ - The dashboard can open the current five-hour block when Anthropic shifts a reset far enough that two recorded block windows overlap. The block grouping already assigned every entry in the overlap to exactly one window, but the detail endpoint selected entries from the raw time interval again, counted the overlap a second time, and failed its cost-reconciliation guard with HTTP 500. Block samples and model costs now consume the grouping pass's exact entry ownership, so the modal and the block summary describe the same calls and the guard remains load-bearing (#620).
31
+ - Fully decorated desktop headers now keep every account chip and global action on one row, while condensed 480px touch headers preserve the account selector and 44px action targets without horizontal page overflow.
32
+ - The dashboard's Projects envelope now reads only the latest usage snapshot for each rendered week instead of transferring the entire snapshot history into Python on every build, while preserving later lower percentages from in-place credits (#627).
33
+ - Dashboard refresh state now distinguishes queued requests from batches that have started, clears every rebuilding claim structurally, keeps OAuth network waits outside the shared state lock, prefers stale-maintenance recovery over an incidental busy remedy, updates settled sync text and accessibility state together, and renders over-budget remainders with the sign before the currency symbol (#605).
34
+ - Remote test runs where pytest exits non-zero without naming any failing test now always offer the exact private-log retrieval command, even when harmless pass counters survive the sanitized extract and would otherwise hide the missing cause. Test-loaded CLI generations also retain the exact sibling modules they completed, so an older request thread cannot adopt a newer generation's half-initialized module during suite teardown (#629).
35
+
36
+ ## [1.101.0] - 2026-08-19
37
+
38
+ ### Changed
39
+ - 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).
40
+ - 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).
41
+ - 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).
42
+ - 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).
43
+ - 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).
44
+ - 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).
45
+ - 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).
46
+ - 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).
47
+ - 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).
48
+ - Project drill-downs now render the exact provider-native interval queried by the server instead of reconstructing Claude's interval in the browser (#571).
49
+
50
+ ### Fixed
51
+ - The dashboard Projects table and its drill-down now measure the same subscription-week buckets for every `1w` / `4w` / `8w` / `12w` selection. Reset shifts can leave time gaps between those buckets; the drill previously included activity from the gaps while the table did not, and the table counted a session twice when it crossed a bucket boundary. Both cost and session totals now reconcile, and the drill states when its displayed span contains reset gaps (#634).
52
+ - 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).
53
+ - 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).
54
+ - 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).
55
+ - 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).
56
+ - `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).
57
+ - `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).
58
+ - 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).
59
+ - `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).
60
+ - `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).
61
+ - 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).
62
+ - `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).
63
+ - 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).
64
+ - `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).
65
+ - `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).
66
+ - `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).
67
+ - 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).
68
+ - 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).
69
+ - 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).
70
+ - 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).
71
+ - 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).
72
+ - 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).
73
+ - 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).
74
+ - 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).
75
+ - 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).
76
+ - 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).
77
+ - Doctor remediation commands now keep each CLI flag intact at 320px instead of wrapping between its leading hyphens and name (#616).
78
+ - 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).
79
+ - The pre-commit hook now rejects private maintainer identity literals in staged public-mirror blobs before they can enter a commit (#598).
80
+
8
81
  ## [1.100.0] - 2026-08-18
9
82
 
10
83
  ### Added
package/README.md CHANGED
@@ -30,9 +30,15 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
30
30
  </p>
31
31
 
32
32
  <!-- cctally:latest-stable:begin -->
33
- **Latest stable: v1.95.5** (2026-08-09)
33
+ **Latest stable: v1.101.0** (2026-08-19)
34
34
 
35
- - Dashboard share-digest validation now pins the template artifact clock as well as the dashboard data clock, so two equivalent renders cannot fail Linux CI merely because their frontmatter and footer crossed a one-second wall-clock boundary.
35
+ Highlights from the `v1.95.5` to `v1.101.0` stable upgrade:
36
+
37
+ - 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).
38
+ - 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).
39
+ - 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).
40
+
41
+ [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.101.0)
36
42
  <!-- cctally:latest-stable:end -->
37
43
 
38
44
  ## Quick start
@@ -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
- return title, subtitle, body
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=dt.date.today().isoformat(),
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
  )
@@ -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 (claude_dir / "projects").glob("**/*.jsonl"):
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