cctally 1.100.0 → 1.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -1549,14 +1549,58 @@ _PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
1549
1549
  _PROJECTS_ENV_CURRENT: dict = {"state": None} # single-slot current-week fold
1550
1550
 
1551
1551
 
1552
- def projects_env_week_key(week_start):
1553
- """Canonical UTC-ISO key for a Monday-anchored subscription week start.
1552
+ _PROJECTS_ENV_KEY_SEP = "|"
1554
1553
 
1555
- The dashboard resolves week starts as aware-UTC datetimes; normalizing to
1554
+
1555
+ def projects_env_week_key(week_start, week_end=None):
1556
+ """Canonical UTC-ISO identity for one subscription-week INTERVAL.
1557
+
1558
+ #620 S1: the key carries both bounds, not the start alone. This cache was
1559
+ structurally seven-day — the start was the whole identity and
1560
+ ``reconcile_projects_env_cache`` reconstructed the end as ``start + 7d``
1561
+ — which was sound only while a week could not move. Once the Projects
1562
+ panel follows real reset instants, an early reset delivered mid-week ends
1563
+ a week sooner without moving its start, so a start-keyed entry answers
1564
+ with an aggregate folded over a window that no longer exists. Carrying
1565
+ the end makes that a MISS, which is the invalidation: the week is
1566
+ recomputed over its new interval and re-stored under its new identity.
1567
+
1568
+ ``week_end=None`` means the nominal seven-day week, so every pre-existing
1569
+ single-argument caller keeps its exact meaning.
1570
+
1571
+ The dashboard resolves week bounds as aware-UTC datetimes; normalizing to
1556
1572
  UTC before serializing keeps the key stable and parseable back by
1557
- ``reconcile_projects_env_cache`` (for the ``week_end`` watermark compare).
1573
+ ``projects_env_week_bounds`` (for the watermark compare).
1574
+ """
1575
+ start = week_start.astimezone(dt.timezone.utc)
1576
+ end = (
1577
+ start + dt.timedelta(days=7) if week_end is None
1578
+ else week_end.astimezone(dt.timezone.utc)
1579
+ )
1580
+ return f"{start.isoformat()}{_PROJECTS_ENV_KEY_SEP}{end.isoformat()}"
1581
+
1582
+
1583
+ def projects_env_week_bounds(week_iso):
1584
+ """Parse a key produced by ``projects_env_week_key`` back to
1585
+ ``(start, end)``, or ``None`` when it is not one.
1586
+
1587
+ Returning ``None`` rather than raising is deliberate: the watermark
1588
+ eviction below treats an unparseable key as dirty, and over-eviction is
1589
+ byte-safe (the week is simply recomputed) while a raised exception on the
1590
+ sync thread is not.
1558
1591
  """
1559
- return week_start.astimezone(dt.timezone.utc).isoformat()
1592
+ if not isinstance(week_iso, str):
1593
+ return None
1594
+ start_s, sep, end_s = week_iso.partition(_PROJECTS_ENV_KEY_SEP)
1595
+ if not sep:
1596
+ return None
1597
+ try:
1598
+ return (
1599
+ dt.datetime.fromisoformat(start_s),
1600
+ dt.datetime.fromisoformat(end_s),
1601
+ )
1602
+ except ValueError:
1603
+ return None
1560
1604
 
1561
1605
 
1562
1606
  def reset_projects_env_state():
@@ -1819,11 +1863,20 @@ def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_mutation_seq,
1819
1863
  ):
1820
1864
  # NOTE (#271 §9): max_wus_id is deliberately NOT a full-clear trigger. The
1821
1865
  # cached per-(project, week) aggregates are session_entries-only; a WUS
1822
- # bump (a `record-usage` write) changes only the attribution denominator,
1866
+ # bump (a `record-usage` write) changes the attribution denominator,
1823
1867
  # which the whole-envelope memo (_PROJECTS_ENV_MEMO, still keyed on
1824
- # max_wus_id) recomputes fresh on its own miss. Reusing this cost cache
1825
- # across a WUS bump is byte-identical. Do NOT re-add
1868
+ # max_wus_id) recomputes fresh on its own miss. Do NOT re-add
1826
1869
  # `max_wus_id != ls["max_wus_id"]` here.
1870
+ #
1871
+ # #620 S1 amends the REASON, not the decision. A WUS bump can now also
1872
+ # move a week BOUNDARY, because the Projects panel follows real reset
1873
+ # instants — so the old justification ("a WUS bump changes only the
1874
+ # denominator") is no longer true. A full clear is still unnecessary,
1875
+ # because the cache identity now carries the interval: a moved
1876
+ # boundary produces a different key, which misses and recomputes,
1877
+ # while every week whose interval did not move stays a legitimate hit.
1878
+ # That is a strictly narrower invalidation than a full clear and is
1879
+ # what keeps a `record-usage` write from re-folding twelve weeks.
1827
1880
  _PROJECTS_ENV_WEEK_CACHE.clear()
1828
1881
  _PROJECTS_ENV_WEEK_TOTALS.clear()
1829
1882
  # #271 M4 (spec §20): the current-week accumulator rides the SAME
@@ -1838,11 +1891,16 @@ def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_mutation_seq,
1838
1891
  if max_mutation_seq > ls["max_seq"]:
1839
1892
  wm = changed_min_timestamp(cache_conn, ls["max_seq"])
1840
1893
  if wm is not None:
1841
- dirty = [
1842
- wk
1843
- for wk in list(_PROJECTS_ENV_WEEK_TOTALS)
1844
- if dt.datetime.fromisoformat(wk) + dt.timedelta(days=7) >= wm
1845
- ]
1894
+ # #620 S1: the week's real end comes off the key rather than being
1895
+ # reconstructed as `start + 7d`, because a drifted reset day makes
1896
+ # a genuinely short week and a `+7d` reconstruction would compare
1897
+ # the wrong instant. An unparseable key is treated as dirty;
1898
+ # over-eviction is byte-safe (the week is recomputed).
1899
+ dirty = []
1900
+ for wk in list(_PROJECTS_ENV_WEEK_TOTALS):
1901
+ bounds = projects_env_week_bounds(wk)
1902
+ if bounds is None or bounds[1] >= wm:
1903
+ dirty.append(wk)
1846
1904
  for wk in dirty:
1847
1905
  _PROJECTS_ENV_WEEK_TOTALS.pop(wk, None)
1848
1906
  for key in [k for k in _PROJECTS_ENV_WEEK_CACHE if k[1] == wk]:
@@ -2,14 +2,61 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import base64
5
+ import contextlib
6
+ import contextvars
5
7
  import hashlib
6
8
  import json
9
+ import os
7
10
  import re
8
11
 
9
12
 
10
13
  IDENTITY_VERSION = 1
11
14
  _SOURCES = frozenset(("claude", "codex"))
12
15
  _SOURCE_ROOT_KEY_RE = re.compile(r"[0-9a-f]{32}\Z")
16
+ _IDENTITY_PATH_ALIAS = contextvars.ContextVar(
17
+ "cctally_identity_path_alias", default=None,
18
+ )
19
+
20
+
21
+ @contextlib.contextmanager
22
+ def identity_path_alias(physical_root: object, logical_root: object):
23
+ """Temporarily map one physical fixture root to a stable logical root.
24
+
25
+ Production callers never enter this context. The generated benchmark
26
+ corpus does so when a byte-stable cross-platform oracle needs provider and
27
+ file identities that do not change merely because macOS resolves ``/tmp``
28
+ through ``/private/tmp``. The mapping is boundary-aware and scoped through
29
+ a ContextVar so nested callers restore the previous identity exactly.
30
+ """
31
+ physical = os.path.normpath(os.fspath(physical_root))
32
+ logical = os.path.normpath(os.fspath(logical_root))
33
+ if not os.path.isabs(physical) or not os.path.isabs(logical):
34
+ raise ValueError("identity path aliases must use absolute roots")
35
+ token = _IDENTITY_PATH_ALIAS.set((physical, logical))
36
+ try:
37
+ yield
38
+ finally:
39
+ _IDENTITY_PATH_ALIAS.reset(token)
40
+
41
+
42
+ def identity_path(value: str) -> str:
43
+ """Return the active logical spelling for an identity-bearing path.
44
+
45
+ This is deliberately a no-op outside ``identity_path_alias``. Oracle
46
+ renderers use it at the last path-bearing identity seam; ordinary runtime
47
+ callers therefore retain their existing physical-path identity contract.
48
+ """
49
+ alias = _IDENTITY_PATH_ALIAS.get()
50
+ if alias is None:
51
+ return value
52
+ physical, logical = alias
53
+ normalized = os.path.normpath(value)
54
+ if normalized == physical:
55
+ return logical
56
+ prefix = physical + os.sep
57
+ if normalized.startswith(prefix):
58
+ return logical + normalized[len(physical):]
59
+ return value
13
60
 
14
61
 
15
62
  def _required_string(value: object, name: str) -> str:
@@ -20,7 +67,7 @@ def _required_string(value: object, name: str) -> str:
20
67
 
21
68
  def source_root_key(canonical_root: str) -> str:
22
69
  """Return the non-reversible, domain-separated key for one source root."""
23
- root = _required_string(canonical_root, "canonical_root")
70
+ root = identity_path(_required_string(canonical_root, "canonical_root"))
24
71
  digest = hashlib.sha256(b"cctally-source-root-v1\0" + root.encode("utf-8"))
25
72
  return digest.hexdigest()[:32]
26
73
 
@@ -41,7 +88,8 @@ def codex_file_key(root_key: str, canonical_physical_path: str) -> str:
41
88
  durable key never embeds an operator path.
42
89
  """
43
90
  root = _required_string(root_key, "root_key")
44
- path = _required_string(canonical_physical_path, "canonical_physical_path")
91
+ path = identity_path(
92
+ _required_string(canonical_physical_path, "canonical_physical_path"))
45
93
  digest = hashlib.sha256(
46
94
  b"cctally-codex-file-v1\0" + root.encode("utf-8")
47
95
  + b"\0" + path.encode("utf-8")
@@ -279,6 +279,71 @@ def _apply_reset_events_to_subweeks(
279
279
  return out
280
280
 
281
281
 
282
+ def subscription_window_probe_range(
283
+ anchor_utc: "dt.datetime", weeks_back: int,
284
+ ) -> "tuple[dt.datetime, dt.datetime]":
285
+ """The generous ``[range_start, range_end)`` to compute subscription
286
+ weeks over when the caller wants ``weeks_back`` intervals ending at the
287
+ one containing ``anchor_utc``.
288
+
289
+ ONE definition, shared by the dashboard Projects panel and by
290
+ ``cctally project``. The range matters beyond covering the answer:
291
+ ``_compute_subscription_weeks`` picks its extrapolation anchor relative
292
+ to ``range_start``, so two callers asking the same question over
293
+ different ranges can receive differently-phased intervals for the same
294
+ pre-snapshot history.
295
+
296
+ The lower bound allows one extra week because the anchor's own interval
297
+ can start up to a week before an ISO-Monday snap of it; the upper bound
298
+ allows two so the containing interval is always emitted whole.
299
+ """
300
+ base = anchor_utc.astimezone(dt.timezone.utc)
301
+ monday = (base - dt.timedelta(days=base.weekday())).replace(
302
+ hour=0, minute=0, second=0, microsecond=0,
303
+ )
304
+ return (monday - dt.timedelta(days=7 * (weeks_back + 1)),
305
+ monday + dt.timedelta(days=14))
306
+
307
+
308
+ def subscription_window_ending_at(
309
+ bounds: "list[tuple[dt.datetime, dt.datetime]]",
310
+ cw_start: "dt.datetime",
311
+ weeks_back: int,
312
+ ) -> "list[tuple[dt.datetime, dt.datetime]]":
313
+ """The last ``weeks_back`` intervals up to and including the one starting
314
+ at ``cw_start``, oldest first.
315
+
316
+ ONE definition, shared by the dashboard Projects panel
317
+ (``_ProjectsWeekGrid.window_ending_at``) and by ``cctally project``, so
318
+ the two surfaces cannot resolve different windows for the same request.
319
+ ``cmd_project`` used to step back in seven-day multiples instead, which
320
+ is short of the truth by the accumulated shortfall of every drifted week
321
+ in the window — one day per six-day week — and the leading extrapolated
322
+ slice covering that deficit then carried real cost, moved the
323
+ ``usedPercent`` denominator by a whole extra week, and drove a rendered
324
+ note claiming a week had no usage snapshot.
325
+
326
+ ``bounds`` must be sorted by start. When fewer than ``weeks_back``
327
+ intervals sit at or before ``cw_start``, the head is padded backwards in
328
+ seven-day steps: that padding is a genuine no-anchor tail — history older
329
+ than any snapshot — so the seven-day assumption is the right one there.
330
+
331
+ Returns ``[]`` when ``cw_start`` is not itself an interval start in
332
+ ``bounds``; a caller that cannot locate its own anchor must not silently
333
+ receive a window anchored somewhere else.
334
+ """
335
+ starts = [b[0] for b in bounds]
336
+ idx = bisect.bisect_right(starts, cw_start) - 1
337
+ if idx < 0 or starts[idx] != cw_start:
338
+ return []
339
+ lo = max(0, idx + 1 - weeks_back)
340
+ window = [(bounds[i][0], bounds[i][1]) for i in range(lo, idx + 1)]
341
+ while len(window) < weeks_back:
342
+ s = window[0][0] - dt.timedelta(days=7)
343
+ window.insert(0, (s, window[0][0]))
344
+ return window
345
+
346
+
282
347
  def _compute_subscription_weeks(
283
348
  conn: sqlite3.Connection,
284
349
  range_start: dt.datetime,
package/bin/cctally CHANGED
@@ -93,8 +93,10 @@ def _load_sibling(name: str):
93
93
  `_lib_transcript_access.transcripts_allowed`), then self-heal once the body
94
94
  finished. `_SIBLING_LOCK` serializes the load so a concurrent caller blocks
95
95
  until the body completes; `_LOADED_SIBLINGS` gates a lock-free fast path for
96
- already-loaded siblings. Fail-closed by construction — a race waits, it never
97
- returns a partial module early.
96
+ already-loaded siblings and retains the completed object owned by this
97
+ cctally module generation. Fail-closed by construction — a race waits, it
98
+ never returns a partial module early or adopts one registered by a newer
99
+ test-loader generation.
98
100
 
99
101
  For `_cctally_*.py` siblings that back-reference `cctally`, the sibling
100
102
  module exposes a `_cctally()` accessor that reads `sys.modules['cctally']`
@@ -115,19 +117,23 @@ def _load_sibling(name: str):
115
117
  # _load_sibling closure — regardless of pytest re-imports.
116
118
  sys.modules["cctally"] = _THIS_MODULE
117
119
  # Fast path: a sibling whose body has FULLY executed is returned without
118
- # contending on the load lock. `_LOADED_SIBLINGS` is only ever added to,
119
- # after a successful exec_module (below), so its membership is a stable
120
- # "complete" signal — keeping the hot path lock-free for the ~50 sequential
121
- # sibling loads at CLI startup and every warm request-thread lazy-load.
122
- if name in _LOADED_SIBLINGS:
123
- return sys.modules[name]
120
+ # contending on the load lock. The mapping is populated only after a
121
+ # successful exec_module (below), so its value is both a stable "complete"
122
+ # signal and the exact module object this cctally generation completed.
123
+ # Tests deliberately replace sys.modules entries between load_script()
124
+ # generations while older request threads may still run; consulting the
125
+ # process-global entry here would let an old loader adopt a newer partial.
126
+ loaded = _LOADED_SIBLINGS.get(name)
127
+ if loaded is not None:
128
+ return loaded
124
129
  # Slow path — serialize the actual load (#306). Any concurrent caller blocks
125
130
  # here until the owning thread finishes exec_module and records the name
126
131
  # complete, so it can never observe the half-initialized module.
127
132
  with _SIBLING_LOCK:
128
133
  # Completed while we waited for the lock.
129
- if name in _LOADED_SIBLINGS:
130
- return sys.modules[name]
134
+ loaded = _LOADED_SIBLINGS.get(name)
135
+ if loaded is not None:
136
+ return loaded
131
137
  cached = sys.modules.get(name)
132
138
  if cached is not None:
133
139
  # Registered but not yet recorded complete. Under the lock this is
@@ -159,7 +165,7 @@ def _load_sibling(name: str):
159
165
  # broken module is never recorded complete (mirrors importlib).
160
166
  sys.modules.pop(name, None)
161
167
  raise
162
- _LOADED_SIBLINGS.add(name)
168
+ _LOADED_SIBLINGS[name] = mod
163
169
  return mod
164
170
 
165
171
 
@@ -185,12 +191,12 @@ _THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")
185
191
  # conversation-path siblings are lazy-loaded on those threads. `_SIBLING_LOCK`
186
192
  # serializes the actual load so a concurrent caller can never observe a module
187
193
  # that is registered in sys.modules but whose exec_module is still mid-body (the
188
- # pre-registration window below), and `_LOADED_SIBLINGS` records the names whose
189
- # bodies have fully executed, gating a lock-free fast path. RLock (not Lock) so a
190
- # circular import that re-enters _load_sibling on the SAME thread during
191
- # exec_module cannot self-deadlock.
194
+ # pre-registration window below), and `_LOADED_SIBLINGS` records the completed
195
+ # module objects, gating a lock-free and generation-local fast path. RLock (not
196
+ # Lock) lets a circular import re-enter _load_sibling on the SAME thread during
197
+ # exec_module without self-deadlocking.
192
198
  _SIBLING_LOCK = threading.RLock()
193
- _LOADED_SIBLINGS: set[str] = set()
199
+ _LOADED_SIBLINGS: dict[str, object] = {}
194
200
 
195
201
 
196
202
  # Hook-tick non-path constants (Section 1 of onboarding spec).
@@ -204,6 +210,7 @@ SETUP_SYMLINK_NAMES = (
204
210
  "cctally-budget",
205
211
  "cctally-dashboard",
206
212
  "cctally-dollar-per-percent",
213
+ "cctally-explain",
207
214
  "cctally-five-hour-blocks",
208
215
  "cctally-five-hour-breakdown",
209
216
  "cctally-forecast",
@@ -404,6 +411,40 @@ project_linear = _lib_budget.project_linear
404
411
  calendar_month_window = _lib_budget.calendar_month_window
405
412
  calendar_week_window = _lib_budget.calendar_week_window
406
413
 
414
+ # Diagnosis kernel (#620 S2): the pure classification layer behind
415
+ # `cctally explain` and `GET /api/diagnosis`. Re-exported like the other
416
+ # _lib_* kernels; the store adapter and the renderer are separate siblings.
417
+ _lib_diagnosis = _load_sibling("_lib_diagnosis")
418
+ DIAGNOSIS_CONTRACT_VERSION = _lib_diagnosis.DIAGNOSIS_CONTRACT_VERSION
419
+ DIAGNOSIS_CONTRIBUTOR_SHARE_FLOOR = _lib_diagnosis.DIAGNOSIS_CONTRIBUTOR_SHARE_FLOOR
420
+ DIAGNOSIS_SHARE_FLOOR_EPSILON = _lib_diagnosis.DIAGNOSIS_SHARE_FLOOR_EPSILON
421
+ DIAGNOSIS_TIE_EPSILON_USD = _lib_diagnosis.DIAGNOSIS_TIE_EPSILON_USD
422
+ CONTRIBUTOR_REGISTRY = _lib_diagnosis.CONTRIBUTOR_REGISTRY
423
+ ContributorSpec = _lib_diagnosis.ContributorSpec
424
+ ContributorRow = _lib_diagnosis.ContributorRow
425
+ ClassResult = _lib_diagnosis.ClassResult
426
+ DiagnosisReport = _lib_diagnosis.DiagnosisReport
427
+ DiagnosisWindow = _lib_diagnosis.DiagnosisWindow
428
+ Denominator = _lib_diagnosis.Denominator
429
+ EvidenceField = _lib_diagnosis.EvidenceField
430
+ PopulationCoverage = _lib_diagnosis.PopulationCoverage
431
+ SubjectFacts = _lib_diagnosis.SubjectFacts
432
+ ProviderResult = _lib_diagnosis.ProviderResult
433
+ EstablishmentError = _lib_diagnosis.EstablishmentError
434
+ EstablishmentFailure = _lib_diagnosis.EstablishmentFailure
435
+ WithheldCause = _lib_diagnosis.WithheldCause
436
+ BaselineOutcome = _lib_diagnosis.BaselineOutcome
437
+ VerdictState = _lib_diagnosis.VerdictState
438
+ OverallVerdict = _lib_diagnosis.OverallVerdict
439
+ assess_confidence = _lib_diagnosis.assess_confidence
440
+ support_shortfall = _lib_diagnosis.support_shortfall
441
+ classify_class = _lib_diagnosis.classify_class
442
+ rank_rows = _lib_diagnosis.rank_rows
443
+ overall_verdict = _lib_diagnosis.overall_verdict
444
+ build_provider_result = _lib_diagnosis.build_provider_result
445
+ build_report = _lib_diagnosis.build_report
446
+ unreadable_store_is_terminal = _lib_diagnosis.unreadable_store_is_terminal
447
+
407
448
  # CLAUDE_MODEL_CONTEXT_WINDOWS / …_DEFAULT_FAMILY moved to _cctally_statusline.py (re-exported below).
408
449
 
409
450
  _lib_display_tz = _load_sibling("_lib_display_tz")
@@ -484,6 +525,7 @@ _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
484
525
  _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
485
526
  _build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
486
527
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
528
+ synthetic_preview_week_start = _lib_alerts_payload.synthetic_preview_week_start
487
529
 
488
530
  _lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
489
531
  resolve_notifier = _lib_alert_dispatch.resolve_notifier
@@ -503,6 +545,8 @@ _clamp_end_ats_to_next_start = _lib_subscription_weeks._clamp_end_ats_to_next_st
503
545
  _apply_overlap_clamp_to_subweeks = _lib_subscription_weeks._apply_overlap_clamp_to_subweeks
504
546
  _apply_reset_events_to_subweeks = _lib_subscription_weeks._apply_reset_events_to_subweeks
505
547
  _compute_subscription_weeks = _lib_subscription_weeks._compute_subscription_weeks
548
+ subscription_window_ending_at = _lib_subscription_weeks.subscription_window_ending_at
549
+ subscription_window_probe_range = _lib_subscription_weeks.subscription_window_probe_range
506
550
 
507
551
  # Codex quota-pool classification (#373). A pure leaf: stdlib only, no
508
552
  # cctally imports, so `_lib_jsonl` can consume it without dragging in the
@@ -1116,6 +1160,12 @@ maybe_record_codex_budget_milestone = _cctally_record.maybe_record_codex_budget_
1116
1160
  maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
1117
1161
  _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
1118
1162
  _compute_block_totals = _cctally_record._compute_block_totals
1163
+ # #620 S2 E2: the pure fold lifted out of _compute_block_totals, plus the
1164
+ # canonical priced record and bucket shapes it folds over.
1165
+ PricedEntry = _cctally_record.PricedEntry
1166
+ BlockBucket = _cctally_record.BlockBucket
1167
+ BlockTotals = _cctally_record.BlockTotals
1168
+ fold_block_totals = _cctally_record.fold_block_totals
1119
1169
  maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
1120
1170
  cmd_record_usage = _cctally_record.cmd_record_usage
1121
1171
  CreditPlan = _cctally_record.CreditPlan
@@ -1407,6 +1457,12 @@ legacy_project_labels = _cctally_dashboard.legacy_project_labels
1407
1457
  build_cached_claude_range_aggregates = (
1408
1458
  _cctally_dashboard.build_cached_claude_range_aggregates
1409
1459
  )
1460
+ capture_cached_claude_range_aggregates = (
1461
+ _cctally_dashboard.capture_cached_claude_range_aggregates
1462
+ )
1463
+ build_cached_claude_range_aggregates_from_capture = (
1464
+ _cctally_dashboard.build_cached_claude_range_aggregates_from_capture
1465
+ )
1410
1466
  reset_claude_range_aggregate_memo = (
1411
1467
  _cctally_dashboard.reset_claude_range_aggregate_memo
1412
1468
  )
@@ -1581,6 +1637,7 @@ cmd_range_cost = _cctally_reporting.cmd_range_cost # parser: c.cmd_range_cost
1581
1637
  # here.
1582
1638
  _cctally_forecast = _load_sibling("_cctally_forecast")
1583
1639
  cmd_report = _cctally_forecast.cmd_report
1640
+ render_report_terminal = _cctally_forecast.render_report_terminal
1584
1641
  cmd_forecast = _cctally_forecast.cmd_forecast
1585
1642
  cmd_budget = _cctally_forecast.cmd_budget
1586
1643
  ForecastInputs = _cctally_forecast.ForecastInputs
@@ -2989,6 +3046,28 @@ cmd_diff = _cctally_diff.cmd_diff # parser: c.c
2989
3046
  _emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples # test_debug_sample_emission mod._emit_diff_debug_samples
2990
3047
 
2991
3048
 
3049
+ # Eager re-export of bin/_cctally_diagnosis.py — the `explain` handler, the
3050
+ # ONE wire adapter both surfaces serialize through, and the canonical
3051
+ # projection their equivalence is defined over (#620 S2). MUST load after the
3052
+ # _lib_diff_kernel block above, because `explain` resolves its window through
3053
+ # the same `_parse_diff_window` grammar. The store adapter it calls stays
3054
+ # lazily loaded, so an ordinary `cctally` invocation never opens a database
3055
+ # it does not need.
3056
+ #
3057
+ # Measured 2026-08-19: loading this sibling costs 20.5 ms of the median
3058
+ # `cctally --version`, which is compiling this module plus `_lib_diagnosis`.
3059
+ # It stays EAGER anyway, for the reason recorded at the `_lib_diff_kernel`
3060
+ # block below: the test surface reaches these symbols by dict-key access on
3061
+ # `mod.__dict__`, which no lazy binding can serve, and spec §4.8 makes the
3062
+ # eager pattern mandatory rather than preferred. Recovering the 20 ms belongs
3063
+ # to a change that shortens `cctally`'s import path as a whole.
3064
+ _cctally_diagnosis = _load_sibling("_cctally_diagnosis")
3065
+ cmd_explain = _cctally_diagnosis.cmd_explain # parser: c.cmd_explain
3066
+ diagnosis_to_wire = _cctally_diagnosis.diagnosis_to_wire
3067
+ canonical_projection = _cctally_diagnosis.canonical_projection
3068
+ render_diagnosis_terminal = _cctally_diagnosis.render_terminal
3069
+
3070
+
2992
3071
  # Provider-aware analytics deliberately wrap the established command entries
2993
3072
  # here, leaving their Claude implementation modules untouched below the branch.
2994
3073
  _cmd_project_claude = cmd_project
@@ -3567,6 +3646,14 @@ def _post_command_update_hooks(command: str | None, args) -> None:
3567
3646
  return
3568
3647
  if command == "doctor":
3569
3648
  return
3649
+ if command == "explain":
3650
+ # Read-only diagnostic by contract (#620 S3, spec 5.1): every store it
3651
+ # touches is opened `mode=ro`, and it must reach no update check, no
3652
+ # telemetry beat and no configuration write. Without this early return
3653
+ # `_spawn_background_update_check` writes `update-state.json` and
3654
+ # `update.log` into a data directory the command only read from. Same
3655
+ # rationale class as `doctor` and `pricing-check` below.
3656
+ return
3570
3657
  if command == "pricing-check":
3571
3658
  # Read-only diagnostic (same rationale as doctor): load_config() would
3572
3659
  # call ensure_dirs() and write a stub config.json on a fresh HOME, and
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Thin wrapper: `cctally-explain` → `cctally explain`.
3
+ # Mirrors `cctally-forecast` / `cctally-project` pattern.
4
+ set -euo pipefail
5
+ exec "$(dirname "$0")/cctally" explain "$@"
@@ -0,0 +1 @@
1
+ (function(){function e(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function t(e){return typeof e==`number`&&Number.isSafeInteger(e)&&e>0}function n(t){if(!e(t)||t.envelope_version!==2||typeof t.generated_at!=`string`||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(t.generated_at)||!Number.isFinite(Date.parse(t.generated_at)))return!1;let n=t=>t==null||e(t);return e(t.header)&&n(t.current_week)&&n(t.forecast)&&n(t.trend)&&e(t.weekly)&&Array.isArray(t.weekly.rows)&&e(t.monthly)&&Array.isArray(t.monthly.rows)&&e(t.blocks)&&Array.isArray(t.blocks.rows)&&e(t.daily)&&Array.isArray(t.daily.rows)&&e(t.sessions)&&Array.isArray(t.sessions.rows)&&n(t.projects)&&e(t.display)&&Array.isArray(t.alerts)&&e(t.alerts_settings)}function r(n){return!e(n)||n.version!==1||!t(n.generation)?!1:n.type===`subscribe`||n.type===`suspend`||n.type===`resume`||n.type===`unsubscribe`}let i=new class{createEventSource;parse;ports=new Map;stream=null;lastSnapshot=null;deliveryGeneration=0;constructor(e,t=JSON.parse){this.createEventSource=e,this.parse=t}connect(e){this.ports.set(e,{port:e,active:!1,generation:0}),e.onmessage=t=>this.onPortMessage(e,t.data);try{e.start()}catch{this.ports.delete(e)}}onPortMessage(e,t){let n=this.ports.get(e);if(n==null||!r(t))return;let i=t;if(!(i.generation<=n.generation)){if(n.generation=i.generation,i.type===`unsubscribe`){this.ports.delete(e),this.reconcileStream();return}n.active=i.type===`subscribe`||i.type===`resume`,this.reconcileStream(),n.active&&this.stream!=null&&(n.port.postMessage({version:1,type:`ready`,generation:n.generation}),this.lastSnapshot!=null&&n.port.postMessage({version:1,type:`snapshot`,generation:n.generation,deliveryGeneration:this.lastSnapshot.deliveryGeneration,snapshot:this.lastSnapshot.snapshot}))}}reconcileStream(){let e=[...this.ports.values()].some(e=>e.active);e&&this.stream==null&&this.openStream(),!e&&this.stream!=null&&(this.stream.close(),this.stream=null)}openStream(){let e;try{e=this.createEventSource()}catch{this.broadcastError();return}this.stream=e,e.addEventListener(`update`,t=>{if(this.stream===e)try{let e=this.parse(t.data);if(!n(e))throw Error(`invalid dashboard envelope`);let r=e;this.deliveryGeneration+=1,this.lastSnapshot={snapshot:r,deliveryGeneration:this.deliveryGeneration};for(let e of this.ports.values())e.active&&e.port.postMessage({version:1,type:`snapshot`,generation:e.generation,deliveryGeneration:this.deliveryGeneration,snapshot:r})}catch{console.error(`Dashboard stream frame was rejected.`)}}),e.onerror=()=>{this.stream===e&&this.broadcastError()}}broadcastError(){for(let e of this.ports.values()){if(!e.active)continue;let t={version:1,type:`stream_error`,generation:e.generation};e.port.postMessage(t)}}}(()=>new EventSource(`/api/events`)),a=globalThis;a.onconnect=e=>{for(let t of e.ports)i.connect(t)}})();