cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -0,0 +1,395 @@
1
+ """Pure correction-plan kernel for journal rederivation.
2
+
3
+ Task B of #372 deliberately separates two responsibilities:
4
+
5
+ * eager code replays retained truth through the current derivation hooks in a
6
+ scratch index and produces the desired effective events; and
7
+ * this module compares those events with Task A's ``EffectiveSelection`` and
8
+ renders a deterministic, auditable correction plan.
9
+
10
+ No filesystem, SQLite, provider, alert, or journal operation belongs here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import hashlib
17
+ import json
18
+ from collections.abc import Iterable, Mapping
19
+
20
+
21
+ FAMILY = "claude-usage"
22
+
23
+
24
+ class RederiveError(RuntimeError):
25
+ """Base error for a plan that cannot be produced truthfully."""
26
+
27
+
28
+ class RederiveDataGap(RederiveError):
29
+ """Required retained source data is absent or too old for current logic."""
30
+
31
+
32
+ class RederiveConflict(RederiveError):
33
+ """The desired event set is internally inconsistent."""
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True)
37
+ class KindClassification:
38
+ mode: str
39
+ reason: str
40
+
41
+
42
+ _EVT_CLASSIFICATIONS = {
43
+ "snapshot_accept": KindClassification(
44
+ "rederived", "accepted Claude usage observation"),
45
+ "weekly_cost_snapshot": KindClassification(
46
+ "rederived", "as-of-bounded Claude cache cost"),
47
+ "weekly_credit_effects": KindClassification(
48
+ "rederived", "record-credit destructive effects"),
49
+ "week_reset": KindClassification(
50
+ "rederived", "Claude weekly reset or credit decision"),
51
+ "five_hour_credit": KindClassification(
52
+ "rederived", "Claude five-hour credit decision"),
53
+ "five_hour_block_close": KindClassification(
54
+ "rederived", "closed Claude five-hour projection"),
55
+ "percent_milestone": KindClassification(
56
+ "rederived", "dependent Claude weekly milestone"),
57
+ "five_hour_milestone": KindClassification(
58
+ "rederived", "dependent Claude five-hour milestone"),
59
+ "budget": KindClassification(
60
+ "re_materialized_projection",
61
+ "historical budget config is not journaled; stale Claude latches retire"),
62
+ "projected": KindClassification(
63
+ "re_materialized_projection",
64
+ "historical alert config is not journaled; stale Claude latches retire"),
65
+ "project_budget": KindClassification(
66
+ "re_materialized_projection",
67
+ "historical project budgets are not journaled; stale latches retire"),
68
+ "quota_alert_arming": KindClassification(
69
+ "retained", "Codex quota lifecycle state is outside claude-usage"),
70
+ }
71
+
72
+ _OP_CLASSIFICATIONS = {
73
+ "weekly_credit_floor": KindClassification(
74
+ "retained_input", "operator credit truth is replayed into scratch"),
75
+ "account_observe": KindClassification(
76
+ "retained_input", "account ownership truth is replayed into scratch"),
77
+ "account_label": KindClassification(
78
+ "retained_input", "operator account labels are replayed into scratch"),
79
+ "accounts_cutover": KindClassification(
80
+ "retained_input",
81
+ "legacy Claude ownership normalizes unstamped journal history"),
82
+ "sync_week": KindClassification(
83
+ "retained_input", "operator cost-sync request is re-executed in scratch"),
84
+ }
85
+
86
+
87
+ def _is_owned_event(record: Mapping) -> bool:
88
+ payload = record.get("payload") or {}
89
+ kind = payload.get("kind")
90
+ classification = _EVT_CLASSIFICATIONS.get(kind)
91
+ if classification is None or classification.mode not in {
92
+ "rederived", "re_materialized_projection",
93
+ }:
94
+ return False
95
+ if kind == "budget":
96
+ return payload.get("vendor", "claude") == "claude"
97
+ if kind == "projected":
98
+ metric = str(payload.get("metric") or "")
99
+ return not metric.startswith("codex_")
100
+ return True
101
+
102
+
103
+ @dataclasses.dataclass(frozen=True)
104
+ class FamilyRegistryReport:
105
+ family: str
106
+ evt: Mapping[str, KindClassification]
107
+ op: Mapping[str, KindClassification]
108
+ unclassified_evt_kinds: tuple[str, ...]
109
+ unclassified_op_kinds: tuple[str, ...]
110
+
111
+ def classification_for_evt(self, kind: str) -> KindClassification:
112
+ return self.evt[kind]
113
+
114
+ def classification_for_op(self, kind: str) -> KindClassification:
115
+ return self.op[kind]
116
+
117
+
118
+ def validate_family_registry(*, evt_kinds: set[str],
119
+ op_kinds: set[str]) -> FamilyRegistryReport:
120
+ """Return the closure table and expose any newly-added unclassified kind."""
121
+ return FamilyRegistryReport(
122
+ family=FAMILY,
123
+ evt=dict(_EVT_CLASSIFICATIONS),
124
+ op=dict(_OP_CLASSIFICATIONS),
125
+ unclassified_evt_kinds=tuple(sorted(evt_kinds - _EVT_CLASSIFICATIONS.keys())),
126
+ unclassified_op_kinds=tuple(sorted(op_kinds - _OP_CLASSIFICATIONS.keys())),
127
+ )
128
+
129
+
130
+ _SESSION_ENTRY_COLUMNS = frozenset({
131
+ "timestamp_utc",
132
+ "model",
133
+ "input_tokens",
134
+ "output_tokens",
135
+ "cache_create_tokens",
136
+ "cache_read_tokens",
137
+ "cache_create_1h_tokens",
138
+ "source_path",
139
+ "account_key",
140
+ })
141
+ _SESSION_FILE_COLUMNS = frozenset({"path", "session_id", "project_path"})
142
+
143
+
144
+ def validate_claude_cache_contract(tables: Mapping[str, set[str]]) -> None:
145
+ """Fail before planning if current Claude cost inputs are not retained."""
146
+ if "session_entries" not in tables:
147
+ raise RederiveDataGap("missing cache.db table session_entries")
148
+ missing_entries = sorted(
149
+ _SESSION_ENTRY_COLUMNS - set(tables["session_entries"]))
150
+ if missing_entries:
151
+ raise RederiveDataGap(
152
+ "missing cache.db session_entries column(s): "
153
+ + ", ".join(missing_entries)
154
+ )
155
+ if "session_files" not in tables:
156
+ raise RederiveDataGap("missing cache.db table session_files")
157
+ missing_files = sorted(_SESSION_FILE_COLUMNS - set(tables["session_files"]))
158
+ if missing_files:
159
+ raise RederiveDataGap(
160
+ "missing cache.db session_files column(s): " + ", ".join(missing_files)
161
+ )
162
+
163
+
164
+ def _canonical_bytes(value) -> bytes:
165
+ return json.dumps(
166
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
167
+ ).encode("utf-8")
168
+
169
+
170
+ def _sha256(value) -> str:
171
+ return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
172
+
173
+
174
+ def _semantic_event(record: Mapping) -> dict:
175
+ return {
176
+ "id": record.get("id"),
177
+ "at": record.get("at"),
178
+ "payload": record.get("payload") or {},
179
+ }
180
+
181
+
182
+ def _preserve_non_derivable_state(
183
+ current_record: Mapping, desired_record: Mapping
184
+ ) -> Mapping:
185
+ """Carry durable state that scratch replay cannot truthfully reconstruct.
186
+
187
+ Percent-milestone ``alerted_at`` is the latch recorded when the first
188
+ crossing actually dispatched. Historical alert configuration is not
189
+ retained, so scratch replay deliberately runs with alerts disabled and
190
+ produces ``None``. Preserve the selected event's exact latch both when the
191
+ rest of the milestone is retained and when another field needs an audited
192
+ higher-revision correction (#410 Task B).
193
+ """
194
+ current_payload = current_record.get("payload") or {}
195
+ desired_payload = desired_record.get("payload") or {}
196
+ if (
197
+ current_payload.get("kind") != "percent_milestone"
198
+ or desired_payload.get("kind") != "percent_milestone"
199
+ or "alerted_at" not in current_payload
200
+ ):
201
+ return desired_record
202
+ merged = dict(desired_record)
203
+ merged_payload = dict(desired_payload)
204
+ merged_payload["alerted_at"] = current_payload["alerted_at"]
205
+ merged["payload"] = merged_payload
206
+ return merged
207
+
208
+
209
+ @dataclasses.dataclass(frozen=True)
210
+ class PlanAction:
211
+ disposition: str
212
+ event_id: str
213
+ revision: int
214
+ at: str
215
+ payload: "dict | None"
216
+ payload_hash: str
217
+
218
+ def to_dict(self) -> dict:
219
+ out = {
220
+ "disposition": self.disposition,
221
+ "eventId": self.event_id,
222
+ "revision": self.revision,
223
+ "at": self.at,
224
+ "payloadHash": self.payload_hash,
225
+ }
226
+ if self.payload is not None:
227
+ out["payload"] = self.payload
228
+ return out
229
+
230
+ def to_correction_action(self) -> dict:
231
+ if self.disposition == "tombstone":
232
+ return {
233
+ "action": "tombstone",
234
+ "id": self.event_id,
235
+ "rev": self.revision,
236
+ "at": self.at,
237
+ "payload": None,
238
+ }
239
+ return {
240
+ "action": "replace",
241
+ "id": self.event_id,
242
+ "rev": self.revision,
243
+ "at": self.at,
244
+ "payload": dict(self.payload or {}),
245
+ }
246
+
247
+
248
+ @dataclasses.dataclass(frozen=True)
249
+ class RederivePlan:
250
+ family: str
251
+ journal_high_water: "tuple[str, int] | None"
252
+ cache_fingerprint: str
253
+ config_fingerprint: str
254
+ counts: Mapping[str, int]
255
+ actions: tuple[PlanAction, ...]
256
+ retained_event_count: int
257
+
258
+ def _body(self) -> dict:
259
+ return {
260
+ "schemaVersion": 1,
261
+ "family": self.family,
262
+ "journalHighWater": (
263
+ None if self.journal_high_water is None else {
264
+ "segment": self.journal_high_water[0],
265
+ "offset": self.journal_high_water[1],
266
+ }
267
+ ),
268
+ "cacheFingerprint": self.cache_fingerprint,
269
+ "configFingerprint": self.config_fingerprint,
270
+ "counts": dict(self.counts),
271
+ "retainedEventCount": self.retained_event_count,
272
+ "payloadHashes": sorted(action.payload_hash for action in self.actions),
273
+ "actions": [action.to_dict() for action in self.actions],
274
+ }
275
+
276
+ @property
277
+ def plan_hash(self) -> str:
278
+ return _sha256(self._body())
279
+
280
+ def to_bytes(self) -> bytes:
281
+ body = self._body()
282
+ body["planHash"] = self.plan_hash
283
+ return _canonical_bytes(body)
284
+
285
+ def to_correction_actions(self) -> list[dict]:
286
+ return [action.to_correction_action() for action in self.actions]
287
+
288
+
289
+ def _desired_by_id(events: Iterable[Mapping]) -> dict[str, Mapping]:
290
+ desired = {}
291
+ for event in events:
292
+ event_id = event.get("id")
293
+ if not isinstance(event_id, str) or not event_id:
294
+ raise RederiveConflict("desired event is missing a non-empty id")
295
+ if not _is_owned_event(event):
296
+ continue
297
+ prior = desired.get(event_id)
298
+ if prior is not None and _semantic_event(prior) != _semantic_event(event):
299
+ raise RederiveConflict(f"divergent desired event id {event_id}")
300
+ desired[event_id] = event
301
+ return desired
302
+
303
+
304
+ def build_claude_usage_plan(*, selection, desired_events: Iterable[Mapping],
305
+ journal_high_water: "tuple[str, int] | None",
306
+ cache_fingerprint: str,
307
+ config_fingerprint: str,
308
+ conflicted_event_ids=frozenset()) -> RederivePlan:
309
+ """Diff current effective events against one scratch-derived desired set.
310
+
311
+ ``conflicted_event_ids`` (#374) names the event ids this family owns whose
312
+ same-revision group the selector QUARANTINED at the winning revision. They
313
+ force a ``supersede`` at ``selected.rev + 1`` **even when the provisional
314
+ winner already equals the desired state** — without that, the equality
315
+ branch below returns ``retain`` and the rev-0 conflict survives in the
316
+ append-only journal forever, which is precisely the case the first design
317
+ called already-correct. The resulting correction is a semantic no-op in
318
+ content and a revision advance in effect, and the revision advance is what
319
+ suppresses the rev-0 group under the selector's revision filter."""
320
+ conflicted_event_ids = frozenset(conflicted_event_ids or ())
321
+ desired = _desired_by_id(desired_events)
322
+ current = {
323
+ event_id: selected.record
324
+ for event_id, selected in selection.by_id.items()
325
+ if selected.status == "active"
326
+ and selected.record is not None
327
+ and _is_owned_event(selected.record)
328
+ }
329
+ retained_event_count = sum(
330
+ 1 for selected in selection.by_id.values()
331
+ if selected.status == "active"
332
+ and selected.record is not None
333
+ and not _is_owned_event(selected.record)
334
+ )
335
+ counts = {"retain": 0, "supersede": 0, "tombstone": 0, "add": 0}
336
+ actions: list[PlanAction] = []
337
+
338
+ for event_id in sorted(set(current) | set(desired)):
339
+ current_record = current.get(event_id)
340
+ desired_record = desired.get(event_id)
341
+ selected = selection.by_id.get(event_id)
342
+ if current_record is not None and desired_record is not None:
343
+ desired_record = _preserve_non_derivable_state(
344
+ current_record, desired_record
345
+ )
346
+ if (
347
+ _semantic_event(current_record) == _semantic_event(desired_record)
348
+ and event_id not in conflicted_event_ids
349
+ ):
350
+ counts["retain"] += 1
351
+ continue
352
+ revision = int(selected.rev) + 1
353
+ disposition = "supersede"
354
+ at = str(desired_record["at"])
355
+ payload = dict(desired_record.get("payload") or {})
356
+ elif current_record is not None:
357
+ revision = int(selected.rev) + 1
358
+ disposition = "tombstone"
359
+ at = str(current_record["at"])
360
+ payload = None
361
+ else:
362
+ if selected is not None:
363
+ # A formerly-tombstoned id must advance rather than attempt rev 0.
364
+ revision = int(selected.rev) + 1
365
+ else:
366
+ revision = 0
367
+ disposition = "add"
368
+ at = str(desired_record["at"])
369
+ payload = dict(desired_record.get("payload") or {})
370
+ counts[disposition] += 1
371
+ action_shape = {
372
+ "disposition": disposition,
373
+ "eventId": event_id,
374
+ "revision": revision,
375
+ "at": at,
376
+ "payload": payload,
377
+ }
378
+ actions.append(PlanAction(
379
+ disposition=disposition,
380
+ event_id=event_id,
381
+ revision=revision,
382
+ at=at,
383
+ payload=payload,
384
+ payload_hash=_sha256(action_shape),
385
+ ))
386
+
387
+ return RederivePlan(
388
+ family=FAMILY,
389
+ journal_high_water=journal_high_water,
390
+ cache_fingerprint=cache_fingerprint,
391
+ config_fingerprint=config_fingerprint,
392
+ counts=counts,
393
+ actions=tuple(actions),
394
+ retained_event_count=retained_event_count,
395
+ )
package/bin/_lib_share.py CHANGED
@@ -1160,6 +1160,9 @@ _SVG_TABLE_MIN_COL_W = 24
1160
1160
  _SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6
1161
1161
  _SVG_WRAP_BREAK_CHARS = (" ", "/", "-", "_")
1162
1162
  _SVG_ELLIPSIS = "…"
1163
+ _SVG_NOTE_FONT = 11
1164
+ _SVG_NOTE_LINE_H = 16
1165
+ _SVG_NOTE_GAP = 14
1163
1166
 
1164
1167
 
1165
1168
  def _svg_text_width(text: str, font_size: float) -> float:
@@ -1236,6 +1239,25 @@ def _wrap_for_width(text: str, content_w: float, font_size: float) -> list[str]:
1236
1239
  return lines or [_SVG_ELLIPSIS]
1237
1240
 
1238
1241
 
1242
+ def _render_svg_notes(
1243
+ snap: "ShareSnapshot", *, palette: dict, x: float, y: float, width: float,
1244
+ ) -> tuple[str, float]:
1245
+ """Render deterministic, wrapped artifact notes and return body + height."""
1246
+ lines: list[str] = []
1247
+ for note in snap.notes:
1248
+ lines.extend(_wrap_for_width(note, width, _SVG_NOTE_FONT))
1249
+ if not lines:
1250
+ return "", 0.0
1251
+ body = svg_group([
1252
+ svg_text(
1253
+ x, y + ((idx + 1) * _SVG_NOTE_LINE_H), line,
1254
+ font_size=_SVG_NOTE_FONT, fill=palette["ref_warn"],
1255
+ )
1256
+ for idx, line in enumerate(lines)
1257
+ ])
1258
+ return body, float(len(lines) * _SVG_NOTE_LINE_H)
1259
+
1260
+
1239
1261
  def _svg_table_anchor_and_x(align: str, col_x: float, col_w: float,
1240
1262
  pad_x: float) -> tuple[str, float]:
1241
1263
  """Map `ColumnSpec.align` to SVG text-anchor + x-coordinate."""
@@ -1429,9 +1451,26 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1429
1451
  content_w = max(_SVG_WIDTH, table_w)
1430
1452
 
1431
1453
  table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
1454
+ note_block_h = 0.0
1455
+ note_svg = ""
1456
+ if include_chrome and snap.notes:
1457
+ note_y = (
1458
+ _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_NOTE_GAP
1459
+ )
1460
+ note_svg, note_h = _render_svg_notes(
1461
+ snap,
1462
+ palette=palette,
1463
+ x=_SVG_PADDING,
1464
+ y=note_y,
1465
+ width=_SVG_WIDTH,
1466
+ )
1467
+ note_block_h = _SVG_NOTE_GAP + note_h
1432
1468
 
1433
1469
  if include_chrome:
1434
- height = header_h + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
1470
+ height = (
1471
+ header_h + chart_h + table_block_h + note_block_h
1472
+ + _SVG_FOOTER_H + (_SVG_PADDING * 2)
1473
+ )
1435
1474
  else:
1436
1475
  height = chart_h + table_block_h + (_SVG_PADDING * 2)
1437
1476
 
@@ -1464,8 +1503,14 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
1464
1503
  if has_table:
1465
1504
  pieces.append(table_svg)
1466
1505
 
1506
+ if note_svg:
1507
+ pieces.append(note_svg)
1508
+
1467
1509
  if include_chrome:
1468
- footer_y = _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_FOOTER_BASELINE
1510
+ footer_y = (
1511
+ _SVG_PADDING + header_h + chart_h + table_block_h + note_block_h
1512
+ + _SVG_FOOTER_BASELINE
1513
+ )
1469
1514
  pieces.append(_render_svg_footer(
1470
1515
  snap, palette=palette,
1471
1516
  x=_SVG_PADDING, y=footer_y,
@@ -1581,6 +1626,16 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
1581
1626
  f'{_format_generated_at_iso(snap.generated_at)}</div>'
1582
1627
  )
1583
1628
  table_html = _render_html_table(snap, palette) if snap.columns else ""
1629
+ notes_html = (
1630
+ f'<aside aria-label="Data notes" '
1631
+ f'style="margin-top:14px;padding:8px 10px;border-left:3px solid {palette["ref_warn"]};'
1632
+ f'color:{palette["fg"]};font-size:13px">'
1633
+ + "".join(
1634
+ f'<div>{_xml_escape(note)}</div>' for note in snap.notes
1635
+ )
1636
+ + "</aside>"
1637
+ if snap.notes else ""
1638
+ )
1584
1639
  if branding:
1585
1640
  # "Generated by cctally" stays as a single plain-text substring so HTML
1586
1641
  # consumers can grep for the branding marker uniformly with the SVG
@@ -1598,6 +1653,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
1598
1653
  f'<header>{title_html}{source_html}{availability_html}{subtitle_html}{timestamp_html}</header>'
1599
1654
  f'{chart_html}'
1600
1655
  f'{table_html}'
1656
+ f'{notes_html}'
1601
1657
  f'{footer_html}'
1602
1658
  )
1603
1659
 
package/bin/cctally CHANGED
@@ -343,6 +343,7 @@ _SEMVER_RE = _lib_semver._SEMVER_RE
343
343
  _lib_pricing = _load_sibling("_lib_pricing")
344
344
  TIERED_THRESHOLD = _lib_pricing.TIERED_THRESHOLD
345
345
  CLAUDE_MODEL_PRICING = _lib_pricing.CLAUDE_MODEL_PRICING
346
+ CLAUDE_FAST_MULTIPLIER_OVERRIDES = _lib_pricing.CLAUDE_FAST_MULTIPLIER_OVERRIDES
346
347
  CODEX_TIERED_THRESHOLD = _lib_pricing.CODEX_TIERED_THRESHOLD
347
348
  CODEX_MODEL_PRICING = _lib_pricing.CODEX_MODEL_PRICING
348
349
  CODEX_LEGACY_FALLBACK_MODEL = _lib_pricing.CODEX_LEGACY_FALLBACK_MODEL
@@ -353,6 +354,11 @@ _resolve_codex_pricing = _lib_pricing._resolve_codex_pricing
353
354
  _is_codex_fallback = _lib_pricing._is_codex_fallback
354
355
  _resolve_model_pricing = _lib_pricing._resolve_model_pricing
355
356
  _calculate_entry_cost = _lib_pricing._calculate_entry_cost
357
+ _claude_fast_multiplier = _lib_pricing._claude_fast_multiplier
358
+ # #195: the single construction point for every cost-feeding usage dict,
359
+ # plus the derived 1-hour cache-write multiplier.
360
+ claude_usage_dict = _lib_pricing.claude_usage_dict
361
+ CACHE_WRITE_1H_MULTIPLIER = _lib_pricing.CACHE_WRITE_1H_MULTIPLIER
356
362
  _warn_unknown_codex_model = _lib_pricing._warn_unknown_codex_model
357
363
  _calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
358
364
  _codex_fast_multiplier = _lib_pricing._codex_fast_multiplier
@@ -493,6 +499,14 @@ _apply_overlap_clamp_to_subweeks = _lib_subscription_weeks._apply_overlap_clamp_
493
499
  _apply_reset_events_to_subweeks = _lib_subscription_weeks._apply_reset_events_to_subweeks
494
500
  _compute_subscription_weeks = _lib_subscription_weeks._compute_subscription_weeks
495
501
 
502
+ # Codex quota-pool classification (#373). A pure leaf: stdlib only, no
503
+ # cctally imports, so `_lib_jsonl` can consume it without dragging in the
504
+ # quota kernel. Loaded BEFORE `_lib_jsonl`, which imports from it.
505
+ _lib_codex_pools = _load_sibling("_lib_codex_pools")
506
+ codex_model_scoped_quota_pool = _lib_codex_pools.codex_model_scoped_quota_pool
507
+ is_model_scoped_codex_quota = _lib_codex_pools.is_model_scoped_codex_quota
508
+ codex_history_is_model_scoped = _lib_codex_pools.codex_history_is_model_scoped
509
+
496
510
  _lib_jsonl = _load_sibling("_lib_jsonl")
497
511
  UsageEntry = _lib_jsonl.UsageEntry
498
512
  CodexEntry = _lib_jsonl.CodexEntry
@@ -969,6 +983,9 @@ _run_pending_migrations = _cctally_db._run_pending_migrations
969
983
  _recover_version_ahead = _cctally_db._recover_version_ahead
970
984
  _log_migration_error = _cctally_db._log_migration_error
971
985
  _clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
986
+ _reconcile_durable_applied_migration_errors = (
987
+ _cctally_db._reconcile_durable_applied_migration_errors
988
+ )
972
989
  _render_migration_error_banner = _cctally_db._render_migration_error_banner
973
990
  _BANNER_SUPPRESSED_COMMANDS = _cctally_db._BANNER_SUPPRESSED_COMMANDS
974
991
  _print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
@@ -1115,6 +1132,20 @@ _derive_week_from_payload = _cctally_record._derive_week_from_payload
1115
1132
  insert_usage_snapshot = _cctally_record.insert_usage_snapshot
1116
1133
  _saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row
1117
1134
 
1135
+ # #372 Task B: side-effect-free Claude usage rederive planner. Loaded after the
1136
+ # record sibling so its scratch adapter can reuse the registered ingest hooks.
1137
+ _lib_rederive = _load_sibling("_lib_rederive")
1138
+ _cctally_rederive = _load_sibling("_cctally_rederive")
1139
+ plan_claude_usage_rederive = _cctally_rederive.plan_claude_usage
1140
+ read_rederive_journal_prefix = _cctally_rederive.read_rederive_journal_prefix
1141
+ preview_db_rederive = _cctally_rederive.preview_db_rederive
1142
+ apply_db_rederive = _cctally_rederive.apply_db_rederive
1143
+ cmd_db_rederive = _cctally_rederive.cmd_db_rederive
1144
+
1145
+ # #402 Task B: preview-first append-only structural protocol acknowledgement.
1146
+ _cctally_journal_repair = _load_sibling("_cctally_journal_repair")
1147
+ cmd_db_journal_repair = _cctally_journal_repair.cmd_db_journal_repair
1148
+
1118
1149
  # Phase F #21: ``bin/_cctally_update.py`` is loaded eagerly per spec §4.8
1119
1150
  # carve-out — ``tests/test_update.py`` reaches into ~30 symbols via
1120
1151
  # ``ns["X"]`` direct-dict reads and ``monkeypatch.setitem(ns, "X", …)``
@@ -2018,15 +2049,23 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
2018
2049
  drop the fast-tier flag and render ``<model>`` where the normal path
2019
2050
  renders ``<model>-fast``. ``je.usage_extra`` is ``{"speed": …}`` or
2020
2051
  ``None``, so the merge never shadows the token integers.
2052
+
2053
+ #195: the dict is built by ``claude_usage_dict``, NOT by hand. This is a
2054
+ cost-feeding usage dict — ``_fold_entry`` prices from ``entry.usage`` —
2055
+ so a hand-rolled literal here silently drops the cache-write TTL split
2056
+ and under-prices the whole ``daily -i`` / ``daily -p`` branch (and makes
2057
+ ``--debug`` report phantom pricing mismatches). ``getattr`` keeps the
2058
+ adapter tolerant of the duck-typed joined-entry stand-ins that a couple
2059
+ of call sites synthesize.
2021
2060
  """
2022
- usage = {
2023
- "input_tokens": je.input_tokens,
2024
- "output_tokens": je.output_tokens,
2025
- "cache_creation_input_tokens": je.cache_creation_tokens,
2026
- "cache_read_input_tokens": je.cache_read_tokens,
2027
- }
2028
- if je.usage_extra:
2029
- usage.update(je.usage_extra)
2061
+ usage = claude_usage_dict( # #195 chokepoint
2062
+ input_tokens=je.input_tokens,
2063
+ output_tokens=je.output_tokens,
2064
+ cache_creation_tokens=je.cache_creation_tokens,
2065
+ cache_read_tokens=je.cache_read_tokens,
2066
+ cache_1h_tokens=getattr(je, "cache_1h_tokens", None),
2067
+ speed=getattr(je, "speed", None),
2068
+ )
2030
2069
  return UsageEntry(
2031
2070
  timestamp=je.timestamp,
2032
2071
  model=je.model,
@@ -3401,6 +3440,15 @@ def _post_command_update_hooks(command: str | None, args) -> None:
3401
3440
  # no-mutation contract (test_pricing_check_offline_does_not_mutate_
3402
3441
  # fresh_home) requires skipping the whole hook.
3403
3442
  return
3443
+ if (
3444
+ command == "db"
3445
+ and getattr(args, "db_action", None) == "journal-repair"
3446
+ ):
3447
+ # `db journal-repair` preview has the same literal no-mutation contract
3448
+ # as doctor/pricing-check. In particular, do not create config/update/
3449
+ # telemetry state after the preview handler has proved the journal
3450
+ # prefix without writing.
3451
+ return
3404
3452
  if command == "repair-symlinks":
3405
3453
  return
3406
3454
  if command == "__preview":