cctally 1.107.0 → 1.109.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 (86) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/bin/_cctally_account.py +4 -0
  3. package/bin/_cctally_alerts.py +152 -7
  4. package/bin/_cctally_cache.py +1590 -153
  5. package/bin/_cctally_config.py +3 -2
  6. package/bin/_cctally_core.py +881 -28
  7. package/bin/_cctally_dashboard.py +1231 -180
  8. package/bin/_cctally_dashboard_conversation.py +263 -8
  9. package/bin/_cctally_dashboard_envelope.py +247 -16
  10. package/bin/_cctally_dashboard_share.py +154 -36
  11. package/bin/_cctally_dashboard_sources.py +2146 -347
  12. package/bin/_cctally_db.py +634 -5
  13. package/bin/_cctally_diagnosis_sources.py +57 -11
  14. package/bin/_cctally_diff.py +15 -8
  15. package/bin/_cctally_doctor.py +155 -18
  16. package/bin/_cctally_five_hour.py +1295 -40
  17. package/bin/_cctally_forecast.py +140 -32
  18. package/bin/_cctally_journal.py +659 -64
  19. package/bin/_cctally_milestone_history.py +86 -6
  20. package/bin/_cctally_parser.py +9 -5
  21. package/bin/_cctally_percent_breakdown.py +284 -13
  22. package/bin/_cctally_project.py +48 -76
  23. package/bin/_cctally_quota.py +55 -27
  24. package/bin/_cctally_quota_model.py +173 -17
  25. package/bin/_cctally_record.py +1978 -613
  26. package/bin/_cctally_rederive.py +0 -2
  27. package/bin/_cctally_refresh.py +2 -1
  28. package/bin/_cctally_setup.py +272 -110
  29. package/bin/_cctally_share.py +24 -5
  30. package/bin/_cctally_source_analytics.py +553 -30
  31. package/bin/_cctally_statusline.py +113 -20
  32. package/bin/_cctally_store.py +61 -4
  33. package/bin/_cctally_tui.py +257 -99
  34. package/bin/_cctally_weekrefs.py +343 -107
  35. package/bin/_lib_alerts_payload.py +48 -1
  36. package/bin/_lib_blocks.py +127 -20
  37. package/bin/_lib_cache_report.py +20 -5
  38. package/bin/_lib_codex_conversation_query.py +11 -1
  39. package/bin/_lib_codex_hooks.py +796 -83
  40. package/bin/_lib_conversation.py +119 -0
  41. package/bin/_lib_conversation_dispatch.py +31 -1
  42. package/bin/_lib_conversation_query.py +61 -14
  43. package/bin/_lib_conversation_retention.py +413 -25
  44. package/bin/_lib_dashboard_sources.py +267 -7
  45. package/bin/_lib_diagnosis.py +10 -0
  46. package/bin/_lib_diff_kernel.py +45 -11
  47. package/bin/_lib_doctor.py +385 -29
  48. package/bin/_lib_ingest_frontier.py +1366 -49
  49. package/bin/_lib_journal.py +81 -0
  50. package/bin/_lib_merge_gate.py +307 -0
  51. package/bin/_lib_meter_rate_change.py +132 -3
  52. package/bin/_lib_pricing.py +544 -23
  53. package/bin/_lib_pricing_check.py +5 -4
  54. package/bin/_lib_record.py +130 -12
  55. package/bin/_lib_render.py +9 -3
  56. package/bin/_lib_retained_size.py +27 -2
  57. package/bin/_lib_segment_summary.py +66 -8
  58. package/bin/_lib_share_templates.py +28 -18
  59. package/bin/_lib_snapshot_cache.py +115 -1
  60. package/bin/_lib_source_retry.py +320 -0
  61. package/bin/_lib_statusline_candidates.py +400 -36
  62. package/bin/_lib_subscription_weeks.py +140 -63
  63. package/bin/_lib_tick_stats.py +119 -0
  64. package/bin/_lib_view_models.py +251 -76
  65. package/bin/cctally +83 -3
  66. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +72 -0
  67. package/dashboard/static/assets/{DoctorModal-DcyMPwvn.js → DoctorModal-BZGXOlc2.js} +1 -1
  68. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +1 -0
  69. package/dashboard/static/assets/{ProjectsDrillPanel-ecN8oCwv.js → ProjectsDrillPanel-KbO_KWa7.js} +1 -1
  70. package/dashboard/static/assets/SourceDetailModal-BrOPLVRL.js +1 -0
  71. package/dashboard/static/assets/{UpdateModal-CKlFE0ER.js → UpdateModal-CYgEFAi4.js} +3 -3
  72. package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
  73. package/dashboard/static/assets/index-Cj_l7T7u.css +1 -0
  74. package/dashboard/static/assets/index-DLVwACUM.js +13 -0
  75. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +9 -0
  76. package/dashboard/static/assets/useKeymap-DQx_A1MB.js +1 -0
  77. package/dashboard/static/dashboard.html +3 -3
  78. package/package.json +3 -1
  79. package/dashboard/static/assets/ConversationsView-BHbw2W1l.js +0 -72
  80. package/dashboard/static/assets/ModalRoot-BYV-99Rq.js +0 -1
  81. package/dashboard/static/assets/SourceDetailModal-CUwdD7_v.js +0 -1
  82. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
  83. package/dashboard/static/assets/index-D8svRv_9.js +0 -13
  84. package/dashboard/static/assets/index-klO46NcU.css +0 -1
  85. package/dashboard/static/assets/outlineNavigation-CVse0Hj9.js +0 -9
  86. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,155 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.109.0] - 2026-09-15
9
+
10
+ ### Added
11
+ - The dashboard picks up a new pricing revision without a restart. It re-reads the deployed pricing file every 60 seconds, adopts a complete newer revision, and keeps serving; no connected client is dropped.
12
+ - The conversation filter popover says `Projects appear once indexing finishes.` in place of `No projects.` while the transcript index is being rebuilt. Model counts stay live on the Claude tab.
13
+ - The dashboard's Projects panel gives every billing cycle of a credited week its own percentage. Both cycles shared one calendar date, and only the later reading survived, so the second cycle could never carry one.
14
+ - The milestone modal names an observation gap where it showed a bare em dash, and states each back-filled run once above the table, as `cctally percent-breakdown` already did. A gap in the current cycle is disclosed too.
15
+
16
+ ### Changed
17
+ - The dashboard's conversation routes open the transcript store read-only. Every browse used to run two write-capable pragmas first, so a read competed with maintenance for the SQLite write lock and lost.
18
+ - A conversation route answers with a typed degraded envelope instead of HTTP 500 while a rebuild, a reclaim pass or a schema upgrade holds the store. Sessions reappear on their own; no request returns a server error.
19
+ - Transcript reclaim runs under a two-second budget and continues on the next pass, so it no longer holds the maintenance and both provider locks until the freelist drains. It is reported as its own timed phase.
20
+ - `cctally project` reports the same per-cycle percentages as `cctally weekly` on the same store. A credited week now contributes one percentage per cycle to `totals.usedPercent`, so a week credited once reports both figures instead of one.
21
+ - A cycle with no observation of its own is reported as missing rather than given the previous cycle's reading, so a week credited `n` times can report up to `n + 1` independently missing observations.
22
+ - An observation captured at the exact instant of a weekly credit belongs to the cycle that starts there, matching where the spend recorded at that instant already goes.
23
+ - The Trend panel, the Trend modal, the Projects modal and the period modal count in billing cycles for both providers, and share artifacts follow.
24
+ - The Projects window pills render bare numbers under a `cycles` label, the Projects grid columns drop the `wk` prefix, and the period modal's `Subscription window` field is now `Reset cycle`.
25
+ - Two cycles of a credited week that fall on one calendar day now render distinct labels, with a time suffix on each. An uncredited week and an ordinarily credited week are unchanged.
26
+ - The dashboard's Projects share artifact states the period its data actually covers. It multiplied a cycle count by seven days, which overstates the span once a credited week is inside it.
27
+ - An All-tab project drill widens to a window that reaches the ranking the row came from, instead of assuming every cycle spans seven days.
28
+ - `cctally project`'s `Used %` cell counts billing cycles: `(3wk)` now reads `(3cy)`, and the trend share artifacts' `Δ` rows follow. A credited week contributes two cycles, so the week abbreviation named the wrong unit.
29
+ - Screen readers announce the Weekly modal's cost chart and the Current Week navigator in billing cycles on the Claude tab. They announced "week" and "period" while every visible label beside them said cycle.
30
+ - `--no-sync` no longer runs the two transcript derivations at startup, so a frozen dashboard starts promptly. While either is owed, conversation reads report that state rather than an empty list, until you run `cctally cache-sync`.
31
+ - A Codex project or quota block reports itself partial when any accounting record inside the past year is unreadable, and states that project metadata is withheld for that item, so a cost is no longer missing with nothing saying so.
32
+ - A momentary database error marks that refresh's Codex project and quota block views partial, and the next refresh recovers on its own once the store is healthy.
33
+ - When the dashboard cannot read the account registry, the Codex source says so: it reports the refresh as partial and states that per-account detail is withheld, instead of presenting the install as if it held one account.
34
+ - The source status chip names the withheld account detail instead of reading `Source degraded`, so a registry the dashboard could not read is distinguishable from a source that could not be built at all.
35
+ - The first journal rebuild after upgrading re-reads every segment once, because the stored segment-summary format changed. Later rebuilds skip unchanged segments as before.
36
+
37
+ ### Fixed
38
+ - `cctally record-credit` plans a credit from the confirmed account's own snapshots, floors and five-hour readings, and its preview names the account it will write to. An install with one account is unchanged.
39
+ - `cctally record-usage`, `cctally report` and `cctally percent-breakdown` read each account's own week boundary, so one account's earliest capture no longer sets another account's week.
40
+ - `cctally explain` decides Codex prompt evidence per conversation. A conversation whose transcript is no longer retained leaves the evaluated population and lowers reported coverage, instead of counting as a confident non-contributor.
41
+ - A journal segment summary survives an ordinary volume remount, after proving the file's bytes are unchanged, so a rebuild no longer re-reads the whole journal once an external or network volume is remounted.
42
+ - The dashboard rebuilds a Codex view whose project metadata was incomplete instead of serving it again for the life of the process, and retries on a bounded schedule rather than once.
43
+ - The dashboard tells a temporary read failure apart from a record it cannot interpret, so only the second advises `cctally cache-sync --source codex --rebuild`.
44
+ - The Projects drill issues one request at the width where two panels are mounted, so resizing the window no longer fetches the same project twice.
45
+ - `cctally five-hour-breakdown` and the current-usage modal report the weekly percentage a reader saw at each five-hour crossing, not the raw reading a held tick recorded. Where that reading is no longer retained the column reads `—`.
46
+ - A five-hour block shows only its own account's crossings. One window owns one block per account, and both the current-usage modal and the historical cycle view listed every account's crossings under each.
47
+ - `cctally five-hour-blocks` and `cctally five-hour-breakdown` withhold a weekly percentage a weekly credit retired, at a block's start and end, closed and reset-crossing blocks included. A block observed only before the credit is unchanged.
48
+ - A held status-line tick keeps its five-hour reading through an in-place weekly credit. The credit's cleanup of stale pre-credit replays removed that tick's row, the only record of the reading it carried.
49
+ - A status-line tick whose weekly percentage is held at its high-water mark no longer discards that tick's five-hour usage, so `cctally five-hour-blocks` and the dashboard's five-hour panel stay current.
50
+ - A status-line tick held at the weekly high-water mark does not advance the weekly percentage or its freshness stamp. `cctally forecast`, `cctally report`, the status line, the TUI and the dashboard keep the last observed reading.
51
+ - The Codex source recovers on the next refresh once the account registry is readable again. A refresh that could not read it republished that result for the life of the process, so the per-account panels stayed missing until a restart.
52
+ - A Codex quota block whose retained metadata is incomplete opens and states that, instead of replacing the whole panel with a message saying the dashboard had updated. Nothing had updated.
53
+ - Above one Codex account, clicking a quota block row in the Blocks panel opens that row's own account while the account selector is on All accounts. One of two rows sharing a window opened the other account's block.
54
+ - Opening a Codex project or quota block in the dashboard is immediate. Each click read a year of Codex accounting first, which on a large store is every row it has, and now reads only that project's or that window's own rows.
55
+ - A Codex quota block opens while one unreadable Codex accounting record sits elsewhere in the store. The view converted a year of records it never used, so a single bad record failed the whole request.
56
+ - A Codex quota block renders every observation of its own window. The detail read a thousand recent rows, so a busy window could lose members, or arrive empty and report the block as missing.
57
+ - Above one Codex account, opening a quota block returns that account's block. Two accounts whose blocks share a root, a limit, a slot, a window length and a reset produced one link, and whichever sorted first was shown.
58
+ - A Codex quota block older than the two hundred and fiftieth most recent now opens instead of reporting itself missing.
59
+ - A Claude transcript keeps its account after its volume is remounted. A remount renumbers the device each transcript records, and the next time one grew, its whole history took the account signed in then. Existing damage is not repaired.
60
+ - The conversation list names the transcript-import state and the command that clears it, instead of reporting a load failure beside a Retry that could never succeed. The server reported that state; the page discarded it.
61
+ - The current-usage modal describes a reconciling Codex quota projection the way the hero does, rather than calling it unavailable directly above the figures it publishes. The Codex-only view carries that qualification too.
62
+ - A Codex hero that is showing its last coherent figure says so in text and to assistive technology. The explanation was in a tooltip, which a touch user cannot reach.
63
+ - Opening a Codex row in Recent Sessions is immediate. The detail rebuilt every Codex session of the past year on each click, and now uses the row the dashboard already published, so it reads no accounting data at all.
64
+ - The dashboard's Codex hero keeps its last known spend, with a quiet updating marker, while the quota projection reconciles. It blanked the figure for that moment, and the account card beneath it went on showing a number.
65
+ - A Codex hero says it is pending, rather than rendering an empty value that reads as no spend, both before its first coherent reading and whenever its weekly cycle stops resolving afterwards.
66
+ - The dashboard publishes a recorded metering-rate change on the next refresh. Nothing the dashboard compared between refreshes changed when one was written, so an idle dashboard kept serving the alert list from before it.
67
+ - The dashboard evicts its least recently used Codex caches at their retained-memory budget instead of discarding all of them. A store permanently above the budget rebuilt every cached view on every refresh. Published figures are unchanged.
68
+ - The visible-population memo is still evicted whole, not in part, because it is one indivisible population. Two other cases take every Codex cache cold: a total eviction cannot reduce, and a refresh whose generation moved mid-build.
69
+ - The dashboard no longer discards its most expensive Codex caches first. Two reported no age and no size to the eviction order, so the visible-population memo and the quota memo always went first, however recently they had been used.
70
+ - `cctally dashboard-perf` reports what the Codex source caches retain. The figure came from a background measurement that never finished on a large store, so it read as zero.
71
+ - Codex spend and transcripts reach the dashboard within one tick of a rollout being appended to. An append made mid-turn left no evidence any freshness check examined, so only the two-minute safety walk ever found it.
72
+ - A Codex rollout cctally has read since this release is re-read from the start when it is replaced in place, even at an unchanged size and modification time. An older read position carries no file identity to compare.
73
+ - Adding or removing a `$CODEX_HOME` entry is noticed immediately. A configured root could be added or removed with nothing on disk changing, and the dashboard would keep reporting itself up to date.
74
+ - The conversation viewer keeps every session title and the browse list's cost, project and date columns while `cctally cache-sync --rebuild` replays. The rebuild cleared those tables first, so the list showed no titles throughout.
75
+ - A transcript sync keeps reading a file that the running session appended to mid-read. Any size or modification-time change read as a replacement, so an ordinary append discarded that pass's records and left the whole rebuild to run again.
76
+ - A transcript rebuild drops session titles whose sessions no longer exist. Every rebuild kept the previous titles in full, so titles for deleted sessions stayed forever and title search returned them.
77
+ - A transcript rebuild keeps each message's recorded account instead of re-attributing it to whichever account is active. The attribution was held in memory, so a rebuild resumed in a fresh process lost it.
78
+ - A transcript file replaced in place is recognised as a new file even when its size and modification time are unchanged, so records that no longer exist cannot lend their identity to whatever replaced them.
79
+ - `cctally cache-sync --rebuild` refuses, before deleting anything, when the volume holding `conversations.db` is nearly full, instead of failing partway through and leaving the store half-rebuilt.
80
+ - `cctally doctor` warns when it cannot read a store's rollup pricing fingerprint, instead of reporting no refused write. A locked store read as one that recorded nothing, and every writer of conversation cost authorized itself over it.
81
+ - `cctally cache-sync --prune-orphans` exits 3 and names the affected files when the conversation rollup re-derive is refused. It reported success, and the dashboard's automatic prune said nothing at all.
82
+ - The conversation browse rail falls back to live aggregation when the rollup's state cannot be read, instead of reporting it authoritative over a failed read. Sessions stay visible; cost sorting and project filtering degrade.
83
+ - `cctally doctor` reports a transcript reclaim backlog that is not draining, and fails above 16 GiB. A rebuild is refused at that point, because it adds churn to a store already failing to return the space it freed.
84
+ - `cctally cache-sync --rebuild` measures the free space it needs against the store instead of a fixed 64 MiB. A rebuild replays the whole corpus before the pages it frees come back, so the fixed figure admitted rebuilds that ran out of room.
85
+ - A transcript rebuild that finds no files on disk no longer deletes every message's recorded account. An unmounted volume produced that walk, and the account is the one thing in the store that cannot be derived again.
86
+ - A `cctally dashboard --no-sync` run advances the transcript schema once at startup. Nothing else does in that mode, so a store one migration behind stayed behind and every conversation route stayed degraded.
87
+ - `cctally five-hour-blocks`, `five-hour-breakdown` and the dashboard's Blocks panel record a five-hour credit only when one reporting source both observes the drop and confirms it. A stale status-line sample below an API reading minted one.
88
+ - `cctally statusline` records a weekly reset within 180 seconds even while another session still reports the pre-reset percentage. Agreement from every session was required, so one session that never agreed held the reset back forever.
89
+ - `cctally record-credit` keeps its journalled cleanup when the operation is retried. The list of rows to remove followed database row order, so a retry could describe the same removal differently and have the second description withheld.
90
+ - The stats-database writer guard `cctally doctor` reports now checks every write, not only the first of its kind on a connection. A repeated write that reused an earlier statement went unchecked, so it was neither refused nor recorded.
91
+ - `cctally five-hour-blocks` totals, their breakdowns and the dashboard's Blocks panel count an entry once when a reset shift makes two windows overlap. The later window also priced the earlier one's overlapping entries.
92
+ - The dashboard's Blocks panel and its block modal report a closed five-hour block's recorded totals and model split. They recomputed from the cache, so a session ingested after the block closed changed numbers that are meant to be final.
93
+ - The Blocks share artifact counts a project's cost in one five-hour block only. It read the recorded rollup with a key that never matched, then swept each block's raw interval, so two overlapping windows both claimed the shared entries.
94
+ - The dashboard's block modal says when a closed five-hour block's headline comes from the record kept at its close while the chart below is drawn from the local cache. The two can differ, and nothing said so.
95
+ - After `cctally cache-sync --rebuild`, `cctally doctor` warns that `conversations.db` has space to reclaim until the passes drain it. The dashboard drains it; without one, run `cctally db vacuum --db conversations`.
96
+ - `cctally cache-sync --rebuild` still runs when the free space on its volume cannot be measured. A failed measurement is now unknown and the check is skipped; it used to read as too little space and defer every rebuild.
97
+ - The conversation viewer's live-tail stream answers immediately while a rebuild holds the transcript store, instead of holding the connection open for the whole rebuild. Two other conversation-route paths answer the same way.
98
+ - A dashboard request, panel rebuild or transcript sync that is under way when a new pricing revision is adopted finishes on the revision it started with, so one response is never priced from two revisions.
99
+ - `cctally cache-report`'s per-call tier threshold follows an adopted pricing revision. It was a fixed figure that a reload could not replace.
100
+ - A transcript schema upgrade is started once when several conversation requests find the store behind head at the same moment, instead of once per request.
101
+ - The dashboard's Codex cache report keeps each row on the day it belongs to when a row moves to another day. Its former day kept a copy, so the report either refused to build or published the row under a date it had left.
102
+
103
+ ### Maintenance
104
+ - The test-evidence scrub no longer redacts a diagnostic line for containing the ordinary word "token". A token credential now needs a real separator, so a line such as `unexpected token ')' at line 4` is published rather than replaced.
105
+ - A failing envelope-oracle verification keeps the envelope that disagreed with the baseline, and the sanitized failure extract names it, so a mismatch can be diagnosed from the retained evidence instead of by reproducing it.
106
+ - The sanitized failure extract is withheld whole, and says which check withheld it, when the scrub's transformer fails a health probe before publication. A per-line refusal could otherwise publish beside a secret the backstop cannot see.
107
+ - The envelope-oracle harness emits its results in the one form the failure-extract reader parses, bringing it under the harness scrub contract so its output survives sanitization.
108
+
109
+ ## [1.108.0] - 2026-09-05
110
+
111
+ ### Added
112
+ - Codex reporting prices `gpt-6-astra` from OpenAI's published standard, cached-input, long-context and Fast-mode rates instead of the legacy `gpt-5` fallback.
113
+ - `cctally alerts test` gains `--axis quota` and `--axis meter-rate-change`, so you can rehearse those two notifications instead of waiting for a real crossing or rate change. `--threshold` does not apply to `meter-rate-change`.
114
+ - `cctally percent-breakdown` and `cctally report --detail` name a run of percent thresholds that one observation recorded, and print `observation_gap` where the marginal cost showed a bare `n/a`. `--json` adds `marginalCostWithheldCause`.
115
+ - On an install with more than one account for a provider, every alert toast names the account it belongs to, as the dashboard's Recent Alerts list already did.
116
+ - `cctally doctor` gains `hooks.codex_liveness_7d`, which FAILs when an enabled Codex hook has not succeeded in seven days. It reads the per-root success markers, so log rotation cannot hide a hook that stopped firing.
117
+
118
+ ### Changed
119
+ - `cctally weekly`, `cctally report` and `cctally project` render a week credited twice as three rows, one per billing cycle. A dictionary keyed on the week alone now keeps only the last cycle; key on the week and its start instant together.
120
+ - `cctally weekly`, `cctally report` and `cctally project` start the first cycle of a week whose boundary moved and was then credited at the moved boundary. The previous week keeps its own spend, which was counted into the credited week.
121
+ - Upgrading from 1.107.0 or earlier rebuilds the disposable stats index once, because a weekly reset now records the observation it came from and a rate change records its evidence. Commands report the rebuild until it finishes.
122
+ - A metering-rate change recorded before this upgrade reports no evidence rather than inventing any.
123
+ - A weekly credit is recorded at the exact second Anthropic issued it rather than the top of that hour, so a reading captured earlier in the same hour stays in the cycle it belongs to.
124
+ - A pre-credit reading captured before a second weekly credit now survives and is shown inside the earlier cycle, because each credit's cleanup is scoped to its own instant instead of reaching back past the previous credit.
125
+ - `cctally doctor` now reads Codex's own hook trust record and FAILs when the cctally handler is disabled or was never trusted in Codex `/hooks`, instead of reporting it as installed.
126
+ - `cctally doctor` warns when the Codex handler changed after Codex last recorded a trust decision about it, and when that record cannot be read at all.
127
+ - `cctally setup` refuses at exit 1 when reconciling the Codex handler would land it on a trust decision Codex recorded about a different handler. The message names the file, the slots and the fix.
128
+ - When Codex's trust record changes mid-run, the `cctally setup` refusal names each Codex hooks file it had already rewritten and the dated backup of its previous contents. `--json` adds `changed_hooks_backups`.
129
+ - `cctally doctor` states how many Codex roots are installed beside how many are enabled, so two installed-but-untrusted handlers no longer read as `0/2 root(s) enabled` alone.
130
+ - `cctally setup` recognizes a Codex handler written by a different install channel and collapses it into one canonical handler, instead of adding a second one beside it.
131
+ - `cctally setup --json` moves to `schema_version: 2`: the `installed_review_required` state is retired, and per-root `changes` becomes a per-event object with separate added, removed and unchanged counts.
132
+ - `cctally setup --dry-run` and the applied run now report the same added, removed and unchanged Codex handler counts for the same input.
133
+ - The dashboard and the TUI stop treating a Codex handler as trusted merely because it is present, and re-check that trust when `config.toml` changes, so a hook disabled while either was running no longer certifies stale conversation cost.
134
+ - A freshly installed Codex handler reports as untrusted, and `cctally doctor` FAILs, until you approve it in Codex `/hooks`. Until then the dashboard and the TUI walk every Codex session on each refresh.
135
+ - Two `cctally setup` messages read as ordinary English again: the orphaned `[hooks.state]` remedy says to remove the entry from the file by hand, and a moved trust slot says the handler would move from one key to the other.
136
+
137
+ ### Fixed
138
+ - A percent milestone crossed on a week that holds two recorded resets is listed again. It was filed against one cycle while `cctally percent-breakdown` listed another, so the crossing appeared under neither.
139
+ - Every view of a credited week picks the cycle the week is currently in, not the reset written last. `cctally diff`, the TUI's per-percent modal and the dashboard's milestone list could each name an earlier cycle.
140
+ - A weekly threshold notification names the billing cycle that crossed. On a week credited more than once it read `Week starting Jun 05` for every cycle, naming all of them at once; an uncredited week reads exactly as before.
141
+ - A second Anthropic usage reset inside one subscription week now registers. Every reset in a week shared one slot, so the first held it and later ones were discarded, leaving the reported percentage at the pre-reset high-water mark.
142
+ - A weekly reset observed while cctally is interrupted is no longer lost, and one zero reading can no longer confirm itself into a credit. The pending-reset state moved into the database and commits or rolls back with the reset it fired.
143
+ - A stale pre-credit reading that a credit removed stays removed after `cctally db rebuild --db stats`. The removal is recorded, so a rebuild no longer restores the reading and holds the reported percentage at the pre-credit high.
144
+ - An idle session no longer holds the reported weekly percentage at a stale value. It re-renders its status line from a cached rate-limit block, and that block is now ignored in full once its five-hour window has closed.
145
+ - A severe metering-rate drop now raises a red alert toast instead of the amber that made it look like a smaller drop, and the toast's rate chip matches the colour Recent Alerts already showed for the same event.
146
+ - A metering-rate change detected while the quota calibration was withheld now records which calibration was withheld and how many baseline days were missing, instead of pointing you at a fitted budget that may never have been produced.
147
+ - A metering-rate change whose notification failed and is retried later now carries that same disclosure, because it is read back from the recorded change rather than re-derived from a later run.
148
+ - A metering-rate toast now names the calibration that was withheld and why, instead of pointing you at a fitted budget that may not exist. An ordinary rate change reads exactly as it did before.
149
+ - Recent Alerts shows the evidence behind a metering-rate change: the withheld calibration, the detector's inputs, the composition provenance, and how many baseline days were withheld.
150
+ - An alert toast on a multi-account install states how to dismiss it again, on its own line below the alert's own text rather than above it, so a screen reader reads the alert before it reads how to close it.
151
+ - Alert toasts are usable from the keyboard: press Enter or Space to dismiss one, or to activate its own button and open the window it names. A focused toast states the alert before how to close it, and dismissing one restores focus.
152
+ - The dashboard's Daily panel reads `loading` while it hydrates, instead of reporting the 30-day window as `withheld` over a loading skeleton.
153
+ - The dashboard's Trend panel states its title in full on a phone. The week and cycle counts moved to the wrapping sub-line the other cards use, so the title is no longer cut off mid-word.
154
+ - The dashboard's Daily panel prints its total and peak-day amounts in full on a narrow window. Below about 400px the two summary columns stack instead of cutting the dollar figures off.
155
+ - An install upgrading from a version that predates the append-only journal no longer records a second copy of a reset it already had, so a credited week keeps its own cycles instead of gaining an extra one.
156
+
8
157
  ## [1.107.0] - 2026-09-04
9
158
 
10
159
  ### Changed
@@ -446,6 +446,10 @@ def _cmd_account_show(args: argparse.Namespace) -> int:
446
446
  ).fetchone()
447
447
  a = ({col: row[i] for i, col in enumerate(_ACCOUNT_COLUMNS)}
448
448
  if row is not None else None)
449
+ # HELD-INCLUSIVE, decided rather than overlooked (#769 S11,
450
+ # #824). This is an inventory of the rows the account actually
451
+ # wrote, not a reading of its weekly level, and a held row is a
452
+ # real write by a real tick.
449
453
  snap_count = _count_scoped(conn, "weekly_usage_snapshots", key)
450
454
  milestone_count = _count_scoped(conn, "percent_milestones", key)
451
455
  # Resolved while the connection is still open — the map is
@@ -252,9 +252,16 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
252
252
  install's alert text stays byte-identical to today.
253
253
 
254
254
  Delegates the R8 gate + label precedence to the single-definition helpers
255
- ``_cctally_account.real_account_count`` / ``account_label`` (P2-CQ1) so the
256
- ">1 real account" trigger and the key->label map have exactly one home; this
257
- wrapper only opens the RO connection and stays best-effort/never-raise.
255
+ ``_cctally_account.provider_is_decorated`` / ``account_label`` (P2-CQ1) so
256
+ the ">1 real account" trigger and the key->label map have exactly one home;
257
+ this wrapper only opens the RO connection and stays best-effort/never-raise.
258
+
259
+ #702: the gate is the canonical predicate itself, negated at the call site.
260
+ This site previously inverted the underlying counter by hand
261
+ (``real_account_count(conn, vendor) <= 1``), which is a second written form
262
+ of the R8 threshold — the arrangement that lets the two spellings disagree
263
+ if the threshold ever moves. ``provider_is_decorated`` is ``> 1``, so the
264
+ suppressed branch is ``not provider_is_decorated(...)``.
258
265
 
259
266
  #697: the VENDOR comes from the payload's own ``provider`` when it carries
260
267
  one, and from ``_AXIS_VENDOR`` otherwise. The metering-rate-change family
@@ -285,10 +292,12 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
285
292
  import _cctally_store
286
293
  conn = _cctally_store.stats_open_guarded(
287
294
  db_path,
288
- connect=lambda p: _sq.connect(f"file:{p}?mode=ro", uri=True),
295
+ # #778: forward the opener's `cached_statements=0`.
296
+ connect=lambda p, **kw: _sq.connect(
297
+ f"file:{p}?mode=ro", uri=True, **kw),
289
298
  )
290
299
  try:
291
- if _cctally_account.real_account_count(conn, vendor) <= 1:
300
+ if not _cctally_account.provider_is_decorated(conn, vendor):
292
301
  return ""
293
302
  # #416 §6: population-aware, so two accounts that auto-label to
294
303
  # one email do not print the same alert prefix.
@@ -400,8 +409,27 @@ def _dispatch_alert_notification(
400
409
  # rate transition has no percentage threshold for `severity_for` to
401
410
  # map. Reading it verbatim is what keeps the three tiers meaningful
402
411
  # here; falling through would floor every transition at `info`.
412
+ #
413
+ # #750 S2: the vocabulary is RESOLVED from the kernel's own tuple
414
+ # rather than restated as a literal. A restated literal makes this the
415
+ # second written form of the same rule, which is exactly the
416
+ # arrangement #747 closed on the envelope: a fourth member added to
417
+ # `RATE_CHANGE_SEVERITIES`, to the TypeScript tuple and to the CSS
418
+ # passes every parity test and is still clamped to `info` here, so the
419
+ # OS notification's urgency and the trailing `alerts.log` column are
420
+ # wrong while the suite is green.
421
+ #
422
+ # Guarded the same way `_cctally_dashboard_envelope` guards its copy,
423
+ # and an unresolvable kernel degrades to NO clamp. This function's
424
+ # contract is that it never raises, and clamping against an empty
425
+ # vocabulary would send every correct severity to `info`.
403
426
  severity = str(payload.get("severity") or "info")
404
- if severity not in ("info", "warn", "alarm"):
427
+ try:
428
+ _severities = frozenset(
429
+ _load_lib("_lib_meter_rate_change").RATE_CHANGE_SEVERITIES)
430
+ except Exception: # noqa: BLE001
431
+ _severities = None
432
+ if _severities is not None and severity not in _severities:
405
433
  severity = "info"
406
434
  else:
407
435
  try:
@@ -484,6 +512,56 @@ def _dispatch_alert_notification(
484
512
  return status
485
513
 
486
514
 
515
+ def _synthetic_instant_days_ago(days: int) -> str:
516
+ """A timezone-aware ISO instant `days` before now (#699).
517
+
518
+ Negative goes forward, which is what a quota reset needs. Both non-registry
519
+ synthetics require real instants rather than placeholders: the rate-change
520
+ body renders an effective date, and the quota body renders a reset.
521
+ """
522
+ moment = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
523
+ return moment.isoformat().replace("+00:00", "Z")
524
+
525
+
526
+ def _test_alert_account_key(vendor: str) -> str:
527
+ """A REAL account key for `vendor`, else the vendor-wide sentinel (#699).
528
+
529
+ This is what makes the R8 `[<label>]` prefix observable from
530
+ `alerts test`: on a decorated install the synthetic carries a key the
531
+ registry can label, and on a single-account install it falls back to `*`,
532
+ which `_alert_label_prefix` suppresses — so that install's output stays
533
+ byte-identical, exactly as R8 requires.
534
+
535
+ Best-effort and never-raise, following `_alert_label_prefix`: a missing
536
+ database, an unreadable registry or an empty one all degrade to the
537
+ sentinel rather than turning a rehearsal into an error path.
538
+ """
539
+ try:
540
+ import sqlite3 as _sq
541
+ import _cctally_account
542
+ import _cctally_store
543
+ db_path = _cctally_core.DB_PATH
544
+ if not db_path.exists():
545
+ return _lib_accounts.VENDOR_WIDE
546
+ conn = _cctally_store.stats_open_guarded(
547
+ db_path,
548
+ # #778: forward the opener's `cached_statements=0`.
549
+ connect=lambda p, **kw: _sq.connect(
550
+ f"file:{p}?mode=ro", uri=True, **kw),
551
+ )
552
+ try:
553
+ for row in _cctally_account.load_accounts(conn, vendor):
554
+ key = str(row.get("account_key") or "")
555
+ if key and key not in (_lib_accounts.UNATTRIBUTED,
556
+ _lib_accounts.VENDOR_WIDE):
557
+ return key
558
+ finally:
559
+ conn.close()
560
+ except Exception: # noqa: BLE001
561
+ return _lib_accounts.VENDOR_WIDE
562
+ return _lib_accounts.VENDOR_WIDE
563
+
564
+
487
565
  def cmd_alerts_test(args: argparse.Namespace) -> int:
488
566
  """Send a synthetic test alert through the dispatch pipeline.
489
567
 
@@ -510,9 +588,28 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
510
588
  axis = "codex_budget"
511
589
  elif args.axis == "projected":
512
590
  axis = "projected"
591
+ elif args.axis == "quota":
592
+ axis = "quota"
593
+ elif args.axis == "meter-rate-change":
594
+ axis = "meter_rate_change"
513
595
  else:
514
596
  axis = "five_hour"
515
- threshold = int(args.threshold)
597
+ # #699: `--threshold` now defaults to None so that "supplied" is
598
+ # distinguishable from "defaulted". Every axis that takes one still gets 90
599
+ # when it is omitted, which keeps the existing surface byte-identical.
600
+ raw_threshold = getattr(args, "threshold", None)
601
+ if axis == "meter_rate_change" and raw_threshold is not None:
602
+ # Exit 2 is native-usage per docs/cli-contract.md. The message names
603
+ # BOTH the axis and the flag: a refusal that does not say what failed
604
+ # costs the reader more than one that gives no reason at all.
605
+ print(
606
+ "cctally: --axis meter-rate-change does not accept --threshold; "
607
+ "a metering-rate change has no percentage threshold and carries "
608
+ "an explicit severity instead",
609
+ file=sys.stderr,
610
+ )
611
+ return 2
612
+ threshold = 90 if raw_threshold is None else int(raw_threshold)
516
613
  # --threshold range stays [1, 100] (F5): the cap is axis-uniform with the
517
614
  # existing weekly/5h thresholds. Over-budget tiers (>100%) are a v2
518
615
  # deferral, not an oversight — see spec §2 (F5).
@@ -603,6 +700,54 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
603
700
  denominator=denominator,
604
701
  week_start_at=dt.date.today().isoformat(),
605
702
  )
703
+ elif axis == "meter_rate_change":
704
+ # #699: the family is deliberately NOT in `AXIS_REGISTRY`, so there is
705
+ # no `_build_alert_payload_*` helper for it. The synthetic descriptor
706
+ # is built HERE and passed through the real
707
+ # `_lib_meter_rate_change.alert_payload`, which is what keeps that
708
+ # kernel clock-, database- and policy-free while still rehearsing the
709
+ # exact payload production dispatches.
710
+ import _lib_meter_rate_change as _mrc
711
+ previous, new = 2_442_620.0, 1_665_096.0
712
+ effective = _synthetic_instant_days_ago(7)
713
+ payload = _mrc.alert_payload(_mrc.RateChangeTransition(
714
+ provider="claude",
715
+ account_key=_test_alert_account_key("claude"),
716
+ effective_from=effective,
717
+ previous_units_per_point=previous,
718
+ new_units_per_point=new,
719
+ # Computed by the real kernel rather than hardcoded, so a change
720
+ # to the severity boundaries is rehearsed here too. These rates
721
+ # are a 31.8% drop — the maintainer's own observed transition —
722
+ # which lands on `alarm`.
723
+ severity=_mrc.transition_severity(previous, new),
724
+ detected_at=now_utc_iso(),
725
+ # The #690 disclosure, so the rehearsal exercises the withheld
726
+ # copy rather than only the ordinary path.
727
+ withholding_status="unsupported-model-mix",
728
+ detector_input_causes='["unsupported-composition"]',
729
+ composition_provenance='["forecast-aggregate"]',
730
+ baseline_withheld_days=3,
731
+ ))
732
+ elif axis == "quota":
733
+ # #699: also outside `AXIS_REGISTRY`, but this one HAS a payload
734
+ # builder, so the synthetic goes through it unchanged. `source` is
735
+ # `codex` because that is the only vendor this family observes, and
736
+ # `_AXIS_VENDOR` maps the axis to the same vendor for the R8 lookup.
737
+ payload = _build_alert_payload_quota(
738
+ source="codex",
739
+ source_root_key="default",
740
+ logical_limit_key="weekly",
741
+ observed_slot=_synthetic_instant_days_ago(2),
742
+ window_minutes=10080,
743
+ resets_at_utc=_synthetic_instant_days_ago(-5),
744
+ threshold=threshold,
745
+ kind="weekly",
746
+ crossed_at_utc=now_utc_iso(),
747
+ qualifying_percent=float(threshold),
748
+ projected_percent=min(100.0, float(threshold) + 5.0),
749
+ account_key=_test_alert_account_key("codex"),
750
+ )
606
751
  else:
607
752
  payload = _build_alert_payload_five_hour(
608
753
  threshold=threshold,