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.
- package/CHANGELOG.md +73 -0
- package/README.md +8 -2
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- 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
|
-
|
|
1553
|
-
"""Canonical UTC-ISO key for a Monday-anchored subscription week start.
|
|
1552
|
+
_PROJECTS_ENV_KEY_SEP = "|"
|
|
1554
1553
|
|
|
1555
|
-
|
|
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
|
-
``
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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 =
|
|
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
|
|
97
|
-
|
|
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.
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
189
|
-
#
|
|
190
|
-
# circular import
|
|
191
|
-
# exec_module
|
|
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:
|
|
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 @@
|
|
|
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)}})();
|