cctally 1.108.0 → 1.110.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 (85) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_account.py +4 -0
  4. package/bin/_cctally_alerts.py +6 -2
  5. package/bin/_cctally_cache.py +1652 -166
  6. package/bin/_cctally_config.py +3 -2
  7. package/bin/_cctally_core.py +594 -33
  8. package/bin/_cctally_dashboard.py +1585 -201
  9. package/bin/_cctally_dashboard_conversation.py +494 -48
  10. package/bin/_cctally_dashboard_envelope.py +167 -11
  11. package/bin/_cctally_dashboard_share.py +154 -36
  12. package/bin/_cctally_dashboard_sources.py +2615 -422
  13. package/bin/_cctally_db.py +678 -5
  14. package/bin/_cctally_diagnosis_sources.py +238 -26
  15. package/bin/_cctally_doctor.py +99 -18
  16. package/bin/_cctally_five_hour.py +1295 -40
  17. package/bin/_cctally_forecast.py +124 -28
  18. package/bin/_cctally_journal.py +408 -38
  19. package/bin/_cctally_milestone_history.py +150 -14
  20. package/bin/_cctally_parser.py +5 -0
  21. package/bin/_cctally_percent_breakdown.py +6 -1
  22. package/bin/_cctally_project.py +48 -76
  23. package/bin/_cctally_quota.py +39 -7
  24. package/bin/_cctally_quota_model.py +17 -2
  25. package/bin/_cctally_record.py +1647 -443
  26. package/bin/_cctally_rederive.py +899 -30
  27. package/bin/_cctally_refresh.py +2 -1
  28. package/bin/_cctally_share.py +24 -5
  29. package/bin/_cctally_source_analytics.py +1035 -78
  30. package/bin/_cctally_statusline.py +83 -19
  31. package/bin/_cctally_store.py +61 -4
  32. package/bin/_cctally_transcript.py +27 -2
  33. package/bin/_cctally_tui.py +264 -76
  34. package/bin/_cctally_weekrefs.py +28 -5
  35. package/bin/_lib_blocks.py +127 -20
  36. package/bin/_lib_cache_report.py +20 -5
  37. package/bin/_lib_codex_conversation_query.py +297 -64
  38. package/bin/_lib_codex_hooks.py +102 -0
  39. package/bin/_lib_codex_metadata.py +207 -0
  40. package/bin/_lib_conversation.py +119 -0
  41. package/bin/_lib_conversation_dispatch.py +31 -1
  42. package/bin/_lib_conversation_query.py +315 -51
  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 +12 -1
  47. package/bin/_lib_doctor.py +230 -8
  48. package/bin/_lib_ingest_frontier.py +1366 -49
  49. package/bin/_lib_journal.py +19 -2
  50. package/bin/_lib_merge_gate.py +307 -0
  51. package/bin/_lib_pricing.py +588 -29
  52. package/bin/_lib_record.py +130 -12
  53. package/bin/_lib_rederive.py +903 -2
  54. package/bin/_lib_render.py +9 -3
  55. package/bin/_lib_retained_size.py +27 -2
  56. package/bin/_lib_segment_summary.py +66 -8
  57. package/bin/_lib_share_templates.py +28 -18
  58. package/bin/_lib_snapshot_cache.py +115 -1
  59. package/bin/_lib_source_retry.py +320 -0
  60. package/bin/_lib_statusline_candidates.py +400 -36
  61. package/bin/_lib_tick_stats.py +119 -0
  62. package/bin/_lib_view_models.py +256 -76
  63. package/bin/cctally +69 -0
  64. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  65. package/dashboard/static/assets/{DoctorModal-CWn3U4Wl.js → DoctorModal-DFY2iPDf.js} +1 -1
  66. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  67. package/dashboard/static/assets/ProjectsDrillPanel-DAv39uSo.js +1 -0
  68. package/dashboard/static/assets/SourceDetailModal-DSAWo2Fp.js +1 -0
  69. package/dashboard/static/assets/{UpdateModal-D3m8GG6V.js → UpdateModal-R_9dOVnh.js} +3 -3
  70. package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
  71. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  72. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  73. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  74. package/dashboard/static/assets/useKeymap-CW7Up0Ay.js +1 -0
  75. package/dashboard/static/dashboard.html +3 -3
  76. package/package.json +4 -1
  77. package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +0 -72
  78. package/dashboard/static/assets/ModalRoot-SR070V6c.js +0 -1
  79. package/dashboard/static/assets/ProjectsDrillPanel-QLI9i5mZ.js +0 -1
  80. package/dashboard/static/assets/SourceDetailModal-pv0wlFxl.js +0 -1
  81. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
  82. package/dashboard/static/assets/index-BHu4mxd8.css +0 -1
  83. package/dashboard/static/assets/index-CIWsbux3.js +0 -13
  84. package/dashboard/static/assets/outlineNavigation-CJvKqmLV.js +0 -9
  85. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +0 -1
@@ -172,12 +172,16 @@ def _select_current_block_for_envelope(
172
172
  *,
173
173
  current_used_pct: "float | None",
174
174
  now_utc: "dt.datetime",
175
+ account_key: "str | None" = None,
175
176
  ) -> "dict | None":
176
177
  """Select the current 5h block for the dashboard's current_week envelope.
177
178
 
178
179
  Selection rule (spec §4.1): pick the row from ``five_hour_blocks`` whose
179
180
  ``five_hour_window_key`` matches the latest ``weekly_usage_snapshots``
180
- row's ``five_hour_window_key``. Returns None when no row matches —
181
+ row's ``five_hour_window_key``. When ``account_key`` names the account
182
+ whose weekly percentage is displayed, all physical reads here use it;
183
+ a multi-account caller without that identity receives no block. Returns
184
+ None when no row matches —
181
185
  binding to the latest snapshot's key (rather than highest
182
186
  ``block_start_at``) prevents the panel's "current 5h session" copy from
183
187
  surfacing stale closed blocks.
@@ -211,7 +215,10 @@ def _select_current_block_for_envelope(
211
215
 
212
216
  Delta semantics:
213
217
  - Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
214
- (the natural "how much 7d% has changed during this 5h block" read).
218
+ (the natural "how much 7d% has changed during this 5h block" read), where
219
+ that axis is the CREDIT-AWARE read of the column rather than the stored
220
+ number (#834 S1, #835) — so a value an in-place weekly credit retired makes
221
+ both the published axis and the delta ``None``.
215
222
  - Crossed block (``crossed_seven_day_reset == 1``): the block straddles
216
223
  a weekly reset, so the natural delta would be dominated by the
217
224
  reset itself (e.g. −94pp) rather than the user's actual burn-rate.
@@ -222,36 +229,100 @@ def _select_current_block_for_envelope(
222
229
  - ``None`` only when ``current_used_pct`` is unknown OR the
223
230
  block-start anchor is missing AND no post-reset anchor was found.
224
231
  """
232
+ # HELD-INCLUSIVE, deliberately (#769 S11, #824). This read asks which
233
+ # five-hour window is current, and a `weekly_observation_held` row's
234
+ # five-hour fields are the writing tick's own — it is the freshest
235
+ # statement of that key. Its `week_start_at` is a byte copy of the
236
+ # non-held row it was derived from, so scoping the post-reset lookup
237
+ # below by it is unaffected.
225
238
  snap = conn.execute(
226
239
  """
227
240
  SELECT five_hour_window_key, week_start_at
228
241
  FROM weekly_usage_snapshots
229
242
  WHERE captured_at_utc <= ?
243
+ AND (? IS NULL OR account_key = ?)
230
244
  ORDER BY captured_at_utc DESC, id DESC
231
245
  LIMIT 1
232
246
  """,
233
- (_iso_z(now_utc),),
247
+ (_iso_z(now_utc), account_key, account_key),
234
248
  ).fetchone()
235
249
  if snap is None or snap["five_hour_window_key"] is None:
236
250
  return None
237
251
 
252
+ # The account supplied by `_tui_build_current_week` is the owner of the
253
+ # displayed weekly percentage. On a decorated multi-account store the
254
+ # absence of that identity is not permission to select an arbitrary block.
255
+ import _cctally_account
256
+ if account_key is None and _cctally_account.provider_is_decorated(
257
+ conn, "claude"):
258
+ return None
259
+
260
+ # #834 S1 (#836): `id` and `account_key` are selected so the live
261
+ # current-week milestone read can name ONE account's block. Block uniqueness
262
+ # is `(account_key, five_hour_window_key)`, and the milestone read used to
263
+ # filter on the window key alone, so one physical window's per-account blocks
264
+ # all showed every account's milestones. Both land on the returned dict under
265
+ # a leading underscore. `_five_hour_block_wire` strips the selectors and
266
+ # publishes the account identity only above #341's R8 decoration threshold.
267
+ #
268
+ # Account-qualified selection binds the block and its downstream milestone
269
+ # read to the displayed weekly account; the unqualified order remains the
270
+ # historical <=1-real-account fallback for published byte stability.
271
+ # #834 S1 (#835) Gate A R8: `first_observed_at_utc` and `five_hour_resets_at`
272
+ # are selected because the credit-aware read below needs them — they are the
273
+ # START axis's capture instant and its fallback. `seven_day_pct_at_block_end`
274
+ # is deliberately NOT selected, and round three's comment claiming the
275
+ # credit-aware read needs it was wrong: this route publishes the start axis
276
+ # alone, the helper's second return value is discarded, and the helper tests
277
+ # each axis's stored value before resolving anything, so an absent end value
278
+ # skips a second week resolution and a second band set for a number nobody
279
+ # reads. `last_observed_at_utc` stays, and NOT because the helper needs it:
280
+ # every column the helper reads goes through `block.get(...)`, so dropping
281
+ # this one would raise nothing and change no published field — the end
282
+ # instant it feeds is discarded with the end axis. It stays because the
283
+ # helper's contract is a block ROW, and pruning this SELECT to the subset the
284
+ # helper happens to read today would couple this route to the helper's
285
+ # internals. Round four's comment implied necessity instead.
238
286
  block = conn.execute(
239
287
  """
240
- SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
288
+ SELECT id, account_key,
289
+ five_hour_window_key, block_start_at, five_hour_resets_at,
290
+ first_observed_at_utc, last_observed_at_utc,
241
291
  seven_day_pct_at_block_start,
242
292
  crossed_seven_day_reset
243
- FROM five_hour_blocks
293
+ FROM five_hour_blocks
244
294
  WHERE five_hour_window_key = ?
295
+ AND (? IS NULL OR account_key = ?)
245
296
  AND is_closed = 0
246
297
  AND five_hour_resets_at > ?
298
+ ORDER BY id ASC
299
+ LIMIT 1
247
300
  """,
248
- (snap["five_hour_window_key"], _iso_z(now_utc)),
301
+ (snap["five_hour_window_key"], account_key, account_key,
302
+ _iso_z(now_utc)),
249
303
  ).fetchone()
250
304
  if block is None:
251
305
  return None
252
306
 
253
307
  crossed = bool(block["crossed_seven_day_reset"])
254
- p_start = block["seven_day_pct_at_block_start"]
308
+ # #834 S1 (#835) Gate A R8: THE THIRD CONSUMER of the two derived weekly axes
309
+ # goes through the one read-time filter, the same one `cmd_five_hour_blocks` and
310
+ # `cmd_five_hour_breakdown` use. Reading the column raw published the value an
311
+ # in-place weekly credit retired, and `seven_day_pct_delta_pp` then published
312
+ # the difference between the live post-credit percentage and that retired
313
+ # number, which is a burn nobody observed. Measured on a store credited at 63.0
314
+ # with `current_used_pct` at 20.0: `seven_day_pct_at_block_start: 63.0` and
315
+ # `seven_day_pct_delta_pp: -43.0`, while the helper answered `(None, None)` for
316
+ # the same row. `sqlite3.Row` has no `.get()`, so the row is converted.
317
+ #
318
+ # Neither field is rendered by any client today: both are declared in
319
+ # `dashboard/web/src/types/envelope.ts` and used in one vitest module,
320
+ # `seven_day_pct_delta_pp` does not occur in the built bundle, and the TUI reads
321
+ # only `credits` / `five_hour_window_key` / `_account_key` / `_block_id` off
322
+ # this dict. This is therefore a correction to a published MACHINE surface, made
323
+ # because the marginal cost is one call site.
324
+ p_start, _p_end = sys.modules["cctally"]._credit_aware_block_weekly_axes(
325
+ conn, dict(block), now_utc=now_utc)
255
326
 
256
327
  # When the block crossed a weekly reset, recompute the delta against
257
328
  # the first post-reset snapshot inside the block instead of the
@@ -263,11 +334,19 @@ def _select_current_block_for_envelope(
263
334
  # the lookup to the current weekly window.
264
335
  p_anchor = p_start
265
336
  if crossed and snap["week_start_at"] is not None:
337
+ # `weekly_observation_held = 0` (#769 S11, #824): this is a weekly
338
+ # value selected by a CAPTURE-TIME window, which is exactly the shape a
339
+ # held row corrupts. A held row can be the first row captured after the
340
+ # reset while its weekly value was copied from a row captured BEFORE
341
+ # it, so the block's delta would be anchored on a pre-reset percentage
342
+ # and report a burn nobody observed.
266
343
  post = conn.execute(
267
344
  """
268
345
  SELECT weekly_percent
269
- FROM weekly_usage_snapshots
346
+ FROM weekly_usage_snapshots
270
347
  WHERE week_start_at = ?
348
+ AND (? IS NULL OR account_key = ?)
349
+ AND weekly_observation_held = 0
271
350
  AND unixepoch(captured_at_utc) >= unixepoch(?)
272
351
  AND unixepoch(captured_at_utc) <= unixepoch(?)
273
352
  ORDER BY captured_at_utc ASC, id ASC
@@ -275,6 +354,7 @@ def _select_current_block_for_envelope(
275
354
  """,
276
355
  (
277
356
  snap["week_start_at"],
357
+ account_key, account_key,
278
358
  block["block_start_at"],
279
359
  _iso_z(now_utc),
280
360
  ),
@@ -298,9 +378,10 @@ def _select_current_block_for_envelope(
298
378
  SELECT effective_reset_at_utc, prior_percent, post_percent
299
379
  FROM five_hour_reset_events
300
380
  WHERE five_hour_window_key = ?
381
+ AND (? IS NULL OR account_key = ?)
301
382
  ORDER BY effective_reset_at_utc ASC
302
383
  """,
303
- (int(block["five_hour_window_key"]),),
384
+ (int(block["five_hour_window_key"]), account_key, account_key),
304
385
  ).fetchall()
305
386
  credits = [
306
387
  {
@@ -321,9 +402,34 @@ def _select_current_block_for_envelope(
321
402
  "seven_day_pct_delta_pp": delta,
322
403
  "crossed_seven_day_reset": crossed,
323
404
  "credits": credits,
405
+ # #834 S1 (#836): INTERNAL selector fields, stripped by
406
+ # `_five_hour_block_wire` before publication. See the block SELECT above.
407
+ "_block_id": int(block["id"]),
408
+ "_account_key": block["account_key"],
409
+ "_account_decorated": _cctally_account.provider_is_decorated(
410
+ conn, "claude"),
324
411
  }
325
412
 
326
413
 
414
+ #: The selector fields retained for downstream reads. A rowid is meaningless to
415
+ #: a client; the account key is published only when #341's R8 gate permits
416
+ #: account decoration (more than one real Claude account).
417
+ _INTERNAL_BLOCK_KEYS = ("_block_id", "_account_key", "_account_decorated")
418
+
419
+
420
+ def _five_hour_block_wire(block: "dict | None") -> "dict | None":
421
+ """Publish the current block with R8-gated account identity.
422
+
423
+ Single-account output keeps the historical six-key shape. The caller's
424
+ dict retains its selectors for the live milestone read."""
425
+ if not isinstance(block, dict):
426
+ return block
427
+ wire = {k: v for k, v in block.items() if k not in _INTERNAL_BLOCK_KEYS}
428
+ if block.get("_account_decorated"):
429
+ wire["account_key"] = block["_account_key"]
430
+ return wire
431
+
432
+
327
433
  # === Alerts-envelope per-axis row-mappers (Task F) =========================
328
434
  # Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
329
435
  # the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
@@ -766,6 +872,11 @@ def _unavailable_source_wire() -> dict:
766
872
  "last_success_at": None,
767
873
  "capabilities": {},
768
874
  "data": None,
875
+ # #834 S2 (#828, #829): present and null, not absent. Every source
876
+ # entry within one schema version carries the same keys, so a client
877
+ # never has to branch on key presence to tell a v12 payload's honest
878
+ # "no evidence" from an older server's payload that has no such field.
879
+ "metadata_health": None,
769
880
  }
770
881
 
771
882
 
@@ -812,6 +923,15 @@ def _source_state_to_wire(state: object) -> dict:
812
923
  for name, capability in capabilities.items()
813
924
  },
814
925
  "data": _source_wire_value(getattr(state, "data", None)),
926
+ # #834 S2 (#828, #829): published from the state's carrier and from
927
+ # nowhere else, so the value the detail routes decide from and the
928
+ # value the client normalizes are the same object. `None` on a
929
+ # provider that describes no Codex metadata — and on every payload an
930
+ # older server produced, which is why the client normalizes an absent
931
+ # object to unknown rather than to healthy.
932
+ "metadata_health": _source_wire_value(
933
+ getattr(state, "metadata_health", None)
934
+ ),
815
935
  }
816
936
 
817
937
 
@@ -1320,6 +1440,27 @@ def _basis_presentation(basis):
1320
1440
  return out or None
1321
1441
 
1322
1442
 
1443
+ def _trend_week_start_at(w) -> "str | None":
1444
+ """``TuiTrendRow.week_start_at`` as canonical UTC ISO, or None.
1445
+
1446
+ #750 S4 §3.1. The row already carries the instant as a required
1447
+ `dt.datetime`; the envelope simply omitted it, so the client had only
1448
+ `label` to key on and a credited week's cycles can render one label.
1449
+ `getattr` tolerates the minimal trend shapes (SimpleNamespace test rows,
1450
+ older fixtures) the neighbouring fields already tolerate.
1451
+ """
1452
+ value = getattr(w, "week_start_at", None)
1453
+ if value is None:
1454
+ return None
1455
+ if isinstance(value, str):
1456
+ return value
1457
+ try:
1458
+ return value.astimezone(dt.timezone.utc).isoformat().replace(
1459
+ "+00:00", "Z")
1460
+ except (AttributeError, TypeError, ValueError):
1461
+ return None
1462
+
1463
+
1323
1464
  def _forecast_quota_envelope(fc, cw, rate_change):
1324
1465
  """Spec §10's optional typed `forecast.quota` object.
1325
1466
 
@@ -1756,7 +1897,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1756
1897
  }
1757
1898
 
1758
1899
  def _blocks_row_to_dict(r: "BlocksPanelRow") -> dict:
1759
- return {
1900
+ row = {
1760
1901
  "start_at": r.start_at,
1761
1902
  "end_at": r.end_at,
1762
1903
  "anchor": r.anchor,
@@ -1765,6 +1906,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1765
1906
  "models": list(r.models),
1766
1907
  "label": r.label,
1767
1908
  }
1909
+ # Additive only for retained rows: the client treats absence as
1910
+ # computed, preserving the pre-existing computed-row wire shape.
1911
+ if r.facts_source == "retained":
1912
+ row["facts_source"] = "retained"
1913
+ return row
1768
1914
 
1769
1915
  def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
1770
1916
  # #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
@@ -2157,7 +2303,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2157
2303
  # `_tui_build_current_week`. `getattr` with default keeps
2158
2304
  # legacy fixture modules that construct TuiCurrentWeek
2159
2305
  # directly (without the new field) compatible.
2160
- "five_hour_block": getattr(cw, "five_hour_block", None),
2306
+ # #834 S1 (#836): the ONE publication site, so
2307
+ # `_five_hour_block_wire` is where the internal selector fields
2308
+ # are dropped and the wire shape stays byte-unchanged.
2309
+ "five_hour_block": _five_hour_block_wire(
2310
+ getattr(cw, "five_hour_block", None)),
2161
2311
  "milestones": [
2162
2312
  {
2163
2313
  "percent": m.percent,
@@ -2222,6 +2372,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2222
2372
  "dollar_per_pct": w.dollars_per_percent,
2223
2373
  "delta": w.delta_dpp,
2224
2374
  "is_current": bool(w.is_current),
2375
+ # #750 S4 §3.1: the segment instant. `label` is
2376
+ # year-free and a credited week's cycles can render
2377
+ # the same one, so it cannot be a React key.
2378
+ "week_start_at": _trend_week_start_at(w),
2225
2379
  # #264 S3: additive weekly cost (already on the row via
2226
2380
  # build_trend_view); rendered by the Trend modal's Cost
2227
2381
  # column. ``getattr`` (like ``project_key`` /
@@ -2241,6 +2395,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2241
2395
  "dollar_per_pct": w.dollars_per_percent,
2242
2396
  "delta": w.delta_dpp,
2243
2397
  "is_current": bool(w.is_current),
2398
+ # #750 S4 §3.1: the segment instant (see weeks[]).
2399
+ "week_start_at": _trend_week_start_at(w),
2244
2400
  # #264 S3: additive weekly cost (see weeks[] above; same
2245
2401
  # getattr tolerance for minimal/older trend shapes).
2246
2402
  "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
@@ -49,7 +49,6 @@ from _cctally_core import open_db, parse_iso_datetime
49
49
  from _cctally_config import save_config, _load_config_unlocked
50
50
  from _lib_fmt import stable_sum
51
51
  from _lib_pricing import _calculate_entry_cost, claude_usage_dict
52
- from _lib_five_hour import _canonical_5h_window_key
53
52
  from _lib_display_tz import _resolve_tz, resolve_display_tz_name
54
53
 
55
54
 
@@ -525,48 +524,154 @@ def _share_per_day_per_project_for_range(
525
524
  return out
526
525
 
527
526
 
527
+ def _share_block_ownership_context(range_start, range_end):
528
+ """The canonical five-hour windows competing over ``[range_start,
529
+ range_end]``, as ``(windows, owner_key_by_block_start_epoch)``.
530
+
531
+ #751a: a block cannot decide what it owns without knowing what its
532
+ neighbours own, so the caller widens this range past what it displays
533
+ purely in order to exclude a predecessor's entries.
534
+ """
535
+ import _lib_blocks
536
+
537
+ _selected, _overrides, canonical_intervals = (
538
+ sys.modules["cctally"]._load_recorded_five_hour_windows(
539
+ range_start, range_end,
540
+ )
541
+ )
542
+ windows = []
543
+ owner_key_by_start: dict[int, object] = {}
544
+ for anchor, (block_start, reset) in canonical_intervals.items():
545
+ windows.append(_lib_blocks.OwnedWindow(
546
+ key=anchor, start=block_start, reset=reset,
547
+ ))
548
+ owner_key_by_start[int(block_start.timestamp())] = anchor
549
+ return windows, owner_key_by_start
550
+
551
+
552
+ def _share_entry_cost(entry) -> float:
553
+ usage = claude_usage_dict( # #195 chokepoint
554
+ input_tokens=entry.input_tokens,
555
+ output_tokens=entry.output_tokens,
556
+ cache_creation_tokens=entry.cache_creation_tokens,
557
+ cache_read_tokens=entry.cache_read_tokens,
558
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
559
+ speed=getattr(entry, "speed", None),
560
+ )
561
+ return _calculate_entry_cost(
562
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
563
+ )
564
+
565
+
566
+ def _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch):
567
+ """Per-block per-project costs over the entries each block OWNS.
568
+
569
+ The rollup-table path above is the canonical source; this runs only
570
+ when that table is empty or unreadable. It used to sweep each block's
571
+ raw ``[start, start + 5h]`` interval through
572
+ ``_share_all_projects_for_range``, which is an independent
573
+ reselection: two windows overlapping after a reset shift each claimed
574
+ the overlap entries, and the artifact's per-block per-project
575
+ breakdown double-counted them (#751a review P2-1).
576
+
577
+ Ownership is resolved once, by the one rule in
578
+ ``_lib_blocks.partition_entries_by_owner``. A block with no canonical
579
+ row is heuristic, so it reads the leftovers — the entries no exact
580
+ window contains — inside its own interval, which is the grouping
581
+ pass's own treatment of them.
582
+ """
583
+ import _lib_blocks
584
+
585
+ five_hours = dt.timedelta(hours=5)
586
+ earliest, latest = min(starts), max(starts)
587
+ out: dict[str, dict[str, float]] = {
588
+ iso: {} for iso in iso_by_start_epoch.values()
589
+ }
590
+ try:
591
+ windows, owner_key_by_start = _share_block_ownership_context(
592
+ earliest - five_hours, latest + 2 * five_hours,
593
+ )
594
+ # late-binding: reach the dashboard module object so both the ns-patch
595
+ # (share tests) and a dashboard-object patch (rebuild-parity) are
596
+ # honored (#279 S5 F1 / spec §3).
597
+ entries = list(
598
+ sys.modules["_cctally_dashboard"].get_claude_session_entries(
599
+ earliest, latest + 2 * five_hours, skip_sync=True,
600
+ )
601
+ )
602
+ except Exception:
603
+ return out
604
+ owned, leftovers = _lib_blocks.partition_entries_by_owner(entries, windows)
605
+ for start in starts:
606
+ block_iso = iso_by_start_epoch[int(start.timestamp())]
607
+ owner_key = owner_key_by_start.get(int(start.timestamp()))
608
+ if owner_key is None:
609
+ mine = [
610
+ e for e in leftovers
611
+ if start <= e.timestamp < start + five_hours
612
+ ]
613
+ else:
614
+ mine = owned.get(owner_key, [])
615
+ bucket = out[block_iso]
616
+ for entry in mine:
617
+ key = entry.project_path or "(unknown)"
618
+ bucket[key] = bucket.get(key, 0.0) + _share_entry_cost(entry)
619
+ return out
620
+
621
+
528
622
  def _share_per_block_per_project(
529
623
  recent_blocks: list[dict],
530
624
  ) -> dict[str, dict[str, float]]:
531
625
  """Aggregate per-block per-project costs from `five_hour_block_projects`.
532
626
 
533
627
  Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
534
- Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
535
- (10-min floor; same chokepoint as `maybe_update_five_hour_block`,
536
- per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
537
-
538
- Fallback (rollup empty/unreadable): per-block sweep over
539
- `_share_all_projects_for_range` — uncapped, accuracy parity with the
540
- canonical path. Fires only during the first tick after fresh install
541
- or before stats-migration `002_five_hour_block_projects_backfill_v1`
542
- completes. Issue #33.
628
+
629
+ The rollup rows are reached through their parent's `block_start_at`
630
+ rather than through a window key derived here. `five_hour_block_projects.
631
+ five_hour_window_key` is canonicalized from the window's RESET, so a key
632
+ derived from the block's START missed every row by the block duration and
633
+ the lookup never resolved — which made the fallback the effective path
634
+ (#751a review P2-1). Resolving the parent by its own recorded start is a
635
+ lookup rather than a second derivation, so no third key shape is created
636
+ (CLAUDE.md "5-hour windows").
637
+
638
+ Fallback (rollup empty/unreadable): `_share_per_block_per_project_by_
639
+ ownership`, which prices the entries each block owns. Fires only during
640
+ the first tick after fresh install or before stats-migration
641
+ `002_five_hour_block_projects_backfill_v1` completes. Issue #33.
543
642
  """
544
643
  if not recent_blocks:
545
644
  return {}
546
645
  out: dict[str, dict[str, float]] = {}
547
- keys: list[int] = []
548
- iso_by_key: dict[int, str] = {}
646
+ starts: list[dt.datetime] = []
647
+ iso_by_start_epoch: dict[int, str] = {}
549
648
  for b in recent_blocks:
550
649
  try:
551
650
  ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
552
651
  except (ValueError, KeyError):
553
652
  continue
554
- wk = _canonical_5h_window_key(int(ts.timestamp()))
555
- keys.append(wk)
556
- iso_by_key[wk] = b["start_at"]
557
- if not keys:
653
+ ts = ts.astimezone(dt.timezone.utc)
654
+ starts.append(ts)
655
+ iso_by_start_epoch[int(ts.timestamp())] = b["start_at"]
656
+ if not starts:
558
657
  return out
658
+ epochs = list(iso_by_start_epoch)
559
659
  try:
560
660
  conn = open_db()
561
- placeholders = ",".join("?" for _ in keys)
562
- rows = conn.execute(
563
- f"SELECT five_hour_window_key, project_path, cost_usd "
564
- f"FROM five_hour_block_projects "
565
- f"WHERE five_hour_window_key IN ({placeholders})",
566
- keys,
567
- ).fetchall()
568
- for wk, project_path, cost in rows:
569
- block_iso = iso_by_key.get(wk)
661
+ try:
662
+ placeholders = ",".join("?" for _ in epochs)
663
+ rows = conn.execute(
664
+ f"SELECT unixepoch(b.block_start_at) AS start_epoch, "
665
+ f" p.project_path, p.cost_usd "
666
+ f" FROM five_hour_block_projects p "
667
+ f" JOIN five_hour_blocks b ON b.id = p.block_id "
668
+ f" WHERE unixepoch(b.block_start_at) IN ({placeholders})",
669
+ epochs,
670
+ ).fetchall()
671
+ finally:
672
+ conn.close()
673
+ for start_epoch, project_path, cost in rows:
674
+ block_iso = iso_by_start_epoch.get(int(start_epoch))
570
675
  if block_iso is None:
571
676
  continue
572
677
  proj = project_path or "(unknown)"
@@ -576,15 +681,7 @@ def _share_per_block_per_project(
576
681
  return out
577
682
  except (sqlite3.DatabaseError, OSError):
578
683
  pass
579
- # Fallback: per-block uncapped session_entries sweep.
580
- for b in recent_blocks:
581
- try:
582
- ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
583
- except (ValueError, KeyError):
584
- continue
585
- end = ts + dt.timedelta(hours=5)
586
- out[b["start_at"]] = sys.modules["cctally"]._share_all_projects_for_range(ts, end)
587
- return out
684
+ return _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch)
588
685
 
589
686
 
590
687
  def _build_share_panel_data(panel: str, options: dict,
@@ -1327,7 +1424,28 @@ def _build_projects_share_panel_data(options: dict,
1327
1424
  week_end = cw_start + dt.timedelta(days=7)
1328
1425
  now = _share_now_utc()
1329
1426
  period_end = week_end if week_end <= now else now
1330
- period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1427
+ # #750 S4 §4.7: the period start comes from the SELECTED INTERVAL
1428
+ # IDENTITIES, not from `cw_start - 7 * (effective_weeks - 1)`. A credited
1429
+ # week adds a billing cycle without adding seven calendar days, so the
1430
+ # seven-day arithmetic advertises a period WIDER than the data the
1431
+ # artifact aggregated the moment one is inside the window. The instants
1432
+ # come from `projects.trend.weeks[].week_start_at` (§2.2). The old
1433
+ # arithmetic remains the fallback for an envelope that predates the
1434
+ # field, where it is still the best available answer.
1435
+ _selected_starts = [
1436
+ w.get("week_start_at") for w in (trend.get("weeks") or [])
1437
+ if isinstance(w, dict) and w.get("week_start_at")
1438
+ ][-effective_weeks:]
1439
+ period_start = None
1440
+ if _selected_starts:
1441
+ try:
1442
+ period_start = parse_iso_datetime(
1443
+ _selected_starts[0], "projects.trend.week_start_at",
1444
+ )
1445
+ except (TypeError, ValueError):
1446
+ period_start = None
1447
+ if period_start is None:
1448
+ period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1331
1449
 
1332
1450
  return {
1333
1451
  "rows": rows,
@@ -1533,7 +1651,7 @@ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
1533
1651
  ),
1534
1652
  data_version=(
1535
1653
  f"share:codex:{panel}:{range_start.isoformat()}:"
1536
- f"{now_override.isoformat()}:{semantics.identity}"
1654
+ f"{now_override.isoformat()}:{semantics.codex_identity}"
1537
1655
  ),
1538
1656
  )
1539
1657
  finally: