cctally 1.78.0 → 1.79.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 +13 -0
- package/README.md +2 -0
- package/bin/_cctally_cache.py +140 -2
- package/bin/_cctally_config.py +50 -0
- package/bin/_cctally_dashboard.py +175 -20
- package/bin/_cctally_dashboard_conversation.py +1 -1
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_sources.py +15 -1
- package/bin/_cctally_doctor.py +16 -1
- package/bin/_cctally_milestone_history.py +845 -0
- package/bin/_cctally_tui.py +23 -0
- package/bin/_cctally_update.py +338 -15
- package/bin/_cctally_weekrefs.py +6 -2
- package/bin/_lib_codex_conversation.py +1342 -18
- package/bin/_lib_codex_conversation_export.py +21 -1
- package/bin/_lib_codex_conversation_query.py +530 -67
- package/bin/_lib_conversation_dispatch.py +2 -2
- package/bin/_lib_doctor.py +38 -0
- package/bin/_lib_milestone_history.py +158 -0
- package/bin/cctally +31 -0
- package/dashboard/static/assets/index-BwvAcS_k.js +92 -0
- package/dashboard/static/assets/index-CrIlpAlQ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CkTe6VJt.js +0 -87
- package/dashboard/static/assets/index-DsEXuMpq.css +0 -1
|
@@ -787,13 +787,13 @@ def neutral_payload(
|
|
|
787
787
|
|
|
788
788
|
Provider-specific selectors: Claude keeps its existing contract (``tool_use_id``
|
|
789
789
|
+ ``which ∈ {input, result}``), Codex uses ``block_key`` + ``which ∈ {call,
|
|
790
|
-
output}``. Statuses: ``ok`` | ``not_found`` (404) | ``gone`` (410 — the physical
|
|
790
|
+
output, event}``. Statuses: ``ok`` | ``not_found`` (404) | ``gone`` (410 — the physical
|
|
791
791
|
record moved/mutated)."""
|
|
792
792
|
cref = resolve_conversation_ref(ref)
|
|
793
793
|
if cref is None:
|
|
794
794
|
return {"status": "not_found", "conversation_key": ref}
|
|
795
795
|
if cref.source == "codex":
|
|
796
|
-
if not block_key or which not in ("call", "output"):
|
|
796
|
+
if not block_key or which not in ("call", "output", "event"):
|
|
797
797
|
return {"status": "not_found", "block_key": block_key, "which": which}
|
|
798
798
|
return q.read_codex_payload(conn, cref.conversation_key, block_key, which)
|
|
799
799
|
# Claude: tool_use_id + which={input,result}, contract unchanged.
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -234,6 +234,13 @@ class DoctorState:
|
|
|
234
234
|
# retained corpus from unreadable metadata.
|
|
235
235
|
codex_project_metadata_health: Optional[dict] = None
|
|
236
236
|
codex_project_metadata_error: Optional[str] = None
|
|
237
|
+
# Beta-channel (spec 2026-07-21 §3): the configured RELEASE channel
|
|
238
|
+
# (stable|beta) that `cctally update` tracks — DISTINCT from the preview
|
|
239
|
+
# `channel` (prod|preview) above. Populated by doctor_gather_state from a
|
|
240
|
+
# raw config read (fail-soft to "stable"). Drives the `install.update_channel`
|
|
241
|
+
# check (WARN on beta+brew, else OK). Defaulted (placed last) so existing
|
|
242
|
+
# constructors stay valid and default to the stable posture.
|
|
243
|
+
update_channel: str = "stable"
|
|
237
244
|
|
|
238
245
|
|
|
239
246
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -445,6 +452,36 @@ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
|
|
|
445
452
|
)
|
|
446
453
|
|
|
447
454
|
|
|
455
|
+
def _check_install_update_channel(s: DoctorState) -> CheckResult:
|
|
456
|
+
"""Report the configured update (release) channel alongside the install
|
|
457
|
+
method (beta-channel, spec 2026-07-21 §3).
|
|
458
|
+
|
|
459
|
+
DISTINCT from the preview `channel` in install.mode: this reports the
|
|
460
|
+
`update.channel` config leaf (stable|beta) that `cctally update` tracks.
|
|
461
|
+
WARN on the brew+beta mismatch — Homebrew IS the stable channel (Q2), so
|
|
462
|
+
a beta opt-in on brew silently resolves stable; the WARN makes that
|
|
463
|
+
explicit and actionable. Otherwise always OK (a diagnostic surface, never
|
|
464
|
+
a hard failure — doctor exits 2 only on FAIL)."""
|
|
465
|
+
channel = s.update_channel or "stable"
|
|
466
|
+
if channel == "beta" and s.install_is_brew:
|
|
467
|
+
return CheckResult(
|
|
468
|
+
id="install.update_channel", title="Update channel",
|
|
469
|
+
severity="warn",
|
|
470
|
+
summary="beta (unavailable on Homebrew — installs track stable)",
|
|
471
|
+
remediation=(
|
|
472
|
+
"Homebrew tracks stable only. Install via npm or source to "
|
|
473
|
+
"receive beta releases, or run "
|
|
474
|
+
"`cctally config set update.channel stable`."
|
|
475
|
+
),
|
|
476
|
+
details={"channel": channel, "method": "brew"},
|
|
477
|
+
)
|
|
478
|
+
return CheckResult(
|
|
479
|
+
id="install.update_channel", title="Update channel",
|
|
480
|
+
severity="ok", summary=channel, remediation=None,
|
|
481
|
+
details={"channel": channel},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
448
485
|
_REQUIRED_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
449
486
|
|
|
450
487
|
|
|
@@ -1830,6 +1867,7 @@ def _check_db_conversations_reclaimable(s: DoctorState) -> CheckResult:
|
|
|
1830
1867
|
_CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
|
|
1831
1868
|
("install", "Install", (
|
|
1832
1869
|
("install.mode", "_check_install_dev_mode"),
|
|
1870
|
+
("install.update_channel", "_check_install_update_channel"),
|
|
1833
1871
|
("install.symlinks", "_check_install_symlinks"),
|
|
1834
1872
|
("install.path", "_check_install_path"),
|
|
1835
1873
|
("install.legacy_snippet", "_check_install_legacy_snippet"),
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Pure kernel for the hero-modal milestone-history feature (stdlib only).
|
|
2
|
+
|
|
3
|
+
Small, dependency-free helpers shared by the Claude + Codex history glue
|
|
4
|
+
in ``_cctally_milestone_history.py``:
|
|
5
|
+
|
|
6
|
+
* ``compute_detail_stamp`` — content digest used both on the SSE index
|
|
7
|
+
entry and the detail response so the client can cache by
|
|
8
|
+
``(source, key, detail_stamp)`` and revalidate when a week's underlying
|
|
9
|
+
rows move (spec §2 caching/invalidation).
|
|
10
|
+
* ``intersects`` — half-open interval intersection on epoch seconds
|
|
11
|
+
(a block appears in every week it intersects, spec §1b dual membership).
|
|
12
|
+
* ``coalesce_week_refs`` — collapse same-``key`` ``WeekRef`` segments
|
|
13
|
+
produced by reset correction for an in-place-credited week into ONE
|
|
14
|
+
navigation entry spanning the outer cycle boundary (spec §1a); the
|
|
15
|
+
per-segment structure lives in the detail payload, never as duplicate
|
|
16
|
+
chips.
|
|
17
|
+
|
|
18
|
+
No imports of the cctally namespace or ``_cctally_core`` — these are pure
|
|
19
|
+
functions. ``coalesce_week_refs`` is generic over any frozen dataclass
|
|
20
|
+
carrying ``key`` / ``week_start_at`` / ``week_end_at`` string fields
|
|
21
|
+
(the ``WeekRef`` shape) via ``dataclasses.replace``.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import hashlib
|
|
27
|
+
from dataclasses import replace
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Codex ``quota_window_blocks`` rows carry second-level capture jitter in their
|
|
31
|
+
# ``resets_at``: one physical reset (weekly *or* 5h) surfaces as several rows
|
|
32
|
+
# whose resets differ by a few seconds. This is the Codex-scoped analogue of
|
|
33
|
+
# the Claude 5h jitter floor (``_FIVE_HOUR_JITTER_FLOOR_SECONDS``) — same
|
|
34
|
+
# concept, different provider. Deliberately NOT ``_canonical_5h_window_key``,
|
|
35
|
+
# which is Claude-scoped per spec §1c. 600s (10 min) sits far above the
|
|
36
|
+
# observed jitter yet far below any genuine early re-anchor (hours apart) or a
|
|
37
|
+
# real weekly reset (~7 days apart), so only jitter is collapsed.
|
|
38
|
+
CODEX_CYCLE_JITTER_FLOOR_SECONDS = 600
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cluster_by_reset_jitter(items, *, reset_key, floor_seconds=CODEX_CYCLE_JITTER_FLOOR_SECONDS):
|
|
42
|
+
"""Group items that share one physical reset, collapsing capture jitter.
|
|
43
|
+
|
|
44
|
+
``items`` is any iterable; ``reset_key(item)`` returns that item's reset as
|
|
45
|
+
an epoch ``int``/``float``. Items are sorted by reset ascending, and each
|
|
46
|
+
run of consecutive items whose neighbour-to-neighbour reset gap is
|
|
47
|
+
``<= floor_seconds`` forms one cluster. A gap greater than the floor starts
|
|
48
|
+
a new cluster, so genuine early re-anchors (hours apart) and real weekly
|
|
49
|
+
resets (~7 days apart) stay in distinct clusters — only sub-floor jitter is
|
|
50
|
+
merged.
|
|
51
|
+
|
|
52
|
+
Returns a list of clusters (each a non-empty list of the original items),
|
|
53
|
+
ordered by ascending cluster reset; within a cluster items follow the reset
|
|
54
|
+
sort. An empty ``items`` yields ``[]``.
|
|
55
|
+
"""
|
|
56
|
+
ordered = sorted(items, key=reset_key)
|
|
57
|
+
clusters: list = []
|
|
58
|
+
current: list = []
|
|
59
|
+
prev = None
|
|
60
|
+
for item in ordered:
|
|
61
|
+
r = reset_key(item)
|
|
62
|
+
if prev is None or (r - prev) <= floor_seconds:
|
|
63
|
+
current.append(item)
|
|
64
|
+
else:
|
|
65
|
+
clusters.append(current)
|
|
66
|
+
current = [item]
|
|
67
|
+
prev = r
|
|
68
|
+
if current:
|
|
69
|
+
clusters.append(current)
|
|
70
|
+
return clusters
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compute_detail_stamp(*parts: object) -> str:
|
|
74
|
+
"""Return a 16-hex-char sha256 digest over ``"|"``-joined ``str(p)``.
|
|
75
|
+
|
|
76
|
+
``None`` parts serialize as the empty string so a nullable count/timestamp
|
|
77
|
+
contributes deterministically. Truncating to 16 chars keeps the wire small
|
|
78
|
+
while staying collision-safe for the small per-week input space.
|
|
79
|
+
"""
|
|
80
|
+
joined = "|".join("" if p is None else str(p) for p in parts)
|
|
81
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def intersects(start_a: int, end_a: int, start_b: int, end_b: int) -> bool:
|
|
85
|
+
"""Half-open interval intersection on epoch seconds.
|
|
86
|
+
|
|
87
|
+
``[start_a, end_a)`` overlaps ``[start_b, end_b)`` iff each interval
|
|
88
|
+
starts before the other ends. Used for block/week dual membership so a
|
|
89
|
+
block straddling a week boundary is reported in every week it intersects.
|
|
90
|
+
"""
|
|
91
|
+
return start_a < end_b and start_b < end_a
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_epoch(value: "str | None") -> "int | None":
|
|
95
|
+
"""Parse a canonical ISO-8601 timestamp to an epoch int, or ``None``.
|
|
96
|
+
|
|
97
|
+
Accepts a trailing ``Z`` or an explicit offset; a naive value is treated
|
|
98
|
+
as UTC. Returns ``None`` on empty / unparseable input so callers can skip
|
|
99
|
+
boundary-less refs.
|
|
100
|
+
"""
|
|
101
|
+
if not value:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
s = value.replace("Z", "+00:00")
|
|
105
|
+
parsed = dt.datetime.fromisoformat(s)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
if parsed.tzinfo is None:
|
|
109
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
110
|
+
return int(parsed.timestamp())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def coalesce_week_refs(refs: list) -> list:
|
|
114
|
+
"""Collapse same-``key`` refs into one ref spanning the outer boundary.
|
|
115
|
+
|
|
116
|
+
Input is the ``get_recent_weeks`` ``WeekRef`` list (newest-first). An
|
|
117
|
+
in-place-credited week is represented there as TWO same-``key`` refs
|
|
118
|
+
(a pre-credit segment ``[orig_start, effective)`` and a post-credit
|
|
119
|
+
segment ``[effective, orig_end)``); every other week is a single ref.
|
|
120
|
+
|
|
121
|
+
The result preserves the newest-first order of each key's FIRST
|
|
122
|
+
occurrence, and for a multi-ref key emits one ref whose
|
|
123
|
+
``week_start_at`` is the earliest segment start and ``week_end_at`` the
|
|
124
|
+
latest segment end (the outer cycle boundary). The original ISO strings
|
|
125
|
+
are carried through unchanged (no reformatting drift); comparison is on
|
|
126
|
+
parsed epochs so mixed ``Z``/offset forms order correctly.
|
|
127
|
+
"""
|
|
128
|
+
order: list = [] # keys in first-occurrence order
|
|
129
|
+
groups: dict = {} # key -> list of refs
|
|
130
|
+
for ref in refs:
|
|
131
|
+
k = ref.key
|
|
132
|
+
if k not in groups:
|
|
133
|
+
groups[k] = []
|
|
134
|
+
order.append(k)
|
|
135
|
+
groups[k].append(ref)
|
|
136
|
+
|
|
137
|
+
out: list = []
|
|
138
|
+
for k in order:
|
|
139
|
+
members = groups[k]
|
|
140
|
+
if len(members) == 1:
|
|
141
|
+
out.append(members[0])
|
|
142
|
+
continue
|
|
143
|
+
# Pick earliest start / latest end across the same-key segments.
|
|
144
|
+
earliest = members[0]
|
|
145
|
+
earliest_e = _parse_epoch(members[0].week_start_at)
|
|
146
|
+
latest_end_iso = members[0].week_end_at
|
|
147
|
+
latest_e = _parse_epoch(members[0].week_end_at)
|
|
148
|
+
for m in members[1:]:
|
|
149
|
+
se = _parse_epoch(m.week_start_at)
|
|
150
|
+
if se is not None and (earliest_e is None or se < earliest_e):
|
|
151
|
+
earliest, earliest_e = m, se
|
|
152
|
+
ee = _parse_epoch(m.week_end_at)
|
|
153
|
+
if ee is not None and (latest_e is None or ee > latest_e):
|
|
154
|
+
latest_e, latest_end_iso = ee, m.week_end_at
|
|
155
|
+
# Base on the earliest-start ref (its start_at/start date are the
|
|
156
|
+
# outer start), widen its end to the latest segment end.
|
|
157
|
+
out.append(replace(earliest, week_end_at=latest_end_iso))
|
|
158
|
+
return out
|
package/bin/cctally
CHANGED
|
@@ -1131,6 +1131,10 @@ _UPDATE_CHECK_TTL_HOURS_MIN = _cctally_update._UPDATE_CHECK_TTL_HOURS_MIN
|
|
|
1131
1131
|
_UPDATE_CHECK_TTL_HOURS_MAX = _cctally_update._UPDATE_CHECK_TTL_HOURS_MAX
|
|
1132
1132
|
_normalize_update_check_enabled_value = _cctally_update._normalize_update_check_enabled_value
|
|
1133
1133
|
_validate_update_check_ttl_hours_value = _cctally_update._validate_update_check_ttl_hours_value
|
|
1134
|
+
UPDATE_CHANNELS = _cctally_update.UPDATE_CHANNELS
|
|
1135
|
+
UPDATE_CHANNEL_DEFAULT = _cctally_update.UPDATE_CHANNEL_DEFAULT
|
|
1136
|
+
_validate_update_channel_value = _cctally_update._validate_update_channel_value
|
|
1137
|
+
resolve_update_channel = _cctally_update.resolve_update_channel
|
|
1134
1138
|
_UPDATE_STATE_SCHEMA_MAX = _cctally_update._UPDATE_STATE_SCHEMA_MAX
|
|
1135
1139
|
_UPDATE_SUPPRESS_SCHEMA_MAX = _cctally_update._UPDATE_SUPPRESS_SCHEMA_MAX
|
|
1136
1140
|
_load_update_state = _cctally_update._load_update_state
|
|
@@ -1153,6 +1157,9 @@ _BREW_VERSION_RE_LIST = _cctally_update._BREW_VERSION_RE_LIST
|
|
|
1153
1157
|
_update_user_agent = _cctally_update._update_user_agent
|
|
1154
1158
|
_fetch_url = _cctally_update._fetch_url
|
|
1155
1159
|
_check_npm_latest_version = _cctally_update._check_npm_latest_version
|
|
1160
|
+
_fetch_npm_dist_tag_version = _cctally_update._fetch_npm_dist_tag_version
|
|
1161
|
+
_resolve_npm_channel_target = _cctally_update._resolve_npm_channel_target
|
|
1162
|
+
_semver_or_parse_error = _cctally_update._semver_or_parse_error
|
|
1156
1163
|
_check_brew_latest_version = _cctally_update._check_brew_latest_version
|
|
1157
1164
|
_is_update_check_due = _cctally_update._is_update_check_due
|
|
1158
1165
|
_do_update_check = _cctally_update._do_update_check
|
|
@@ -1162,6 +1169,9 @@ _spawn_background_telemetry_beat = _cctally_update._spawn_background_telemetry_b
|
|
|
1162
1169
|
cmd_telemetry_beat_internal = _cctally_update.cmd_telemetry_beat_internal
|
|
1163
1170
|
SKIP_USE_STATE_LATEST = _cctally_update.SKIP_USE_STATE_LATEST
|
|
1164
1171
|
_format_update_command = _cctally_update._format_update_command
|
|
1172
|
+
BREW_BETA_ADVISORY = _cctally_update.BREW_BETA_ADVISORY
|
|
1173
|
+
_resolved_install_target = _cctally_update._resolved_install_target
|
|
1174
|
+
_resolved_update_command = _cctally_update._resolved_update_command
|
|
1165
1175
|
_prerelease_note = _cctally_update._prerelease_note
|
|
1166
1176
|
_format_update_check_json = _cctally_update._format_update_check_json
|
|
1167
1177
|
_UPDATE_METHOD_HUMAN_LABEL = _cctally_update._UPDATE_METHOD_HUMAN_LABEL
|
|
@@ -1631,6 +1641,14 @@ UPDATE_NPM_REGISTRY_URL = os.environ.get(
|
|
|
1631
1641
|
"CCTALLY_TEST_UPDATE_NPM_URL",
|
|
1632
1642
|
"https://registry.npmjs.org/cctally/latest",
|
|
1633
1643
|
)
|
|
1644
|
+
# Beta dist-tag endpoint (beta-channel, spec 2026-07-21 §3). The client
|
|
1645
|
+
# resolves the beta target as SemVer-max(dist-tags.beta, dist-tags.latest);
|
|
1646
|
+
# an exact 404 here means the `beta` dist-tag is not yet published
|
|
1647
|
+
# (transition window → fall back to latest). Env-overridable for fixtures.
|
|
1648
|
+
UPDATE_NPM_BETA_REGISTRY_URL = os.environ.get(
|
|
1649
|
+
"CCTALLY_TEST_UPDATE_NPM_BETA_URL",
|
|
1650
|
+
"https://registry.npmjs.org/cctally/beta",
|
|
1651
|
+
)
|
|
1634
1652
|
UPDATE_BREW_FORMULA_URL = os.environ.get(
|
|
1635
1653
|
"CCTALLY_TEST_UPDATE_BREW_URL",
|
|
1636
1654
|
"https://raw.githubusercontent.com/omrikais/homebrew-cctally/main/Formula/cctally.rb",
|
|
@@ -2936,6 +2954,18 @@ _RESET_ZERO_MIN_DROP_PCT = _cctally_weekrefs._RESET_ZERO_MIN_DROP_PCT
|
|
|
2936
2954
|
_is_reset_drop = _cctally_weekrefs._is_reset_drop
|
|
2937
2955
|
|
|
2938
2956
|
|
|
2957
|
+
# Eager re-export of bin/_cctally_milestone_history.py — the hero-modal
|
|
2958
|
+
# historical milestone navigation glue (week/cycle index + week/cycle detail).
|
|
2959
|
+
# Reached via `c.` from the envelope build, the source build, and the
|
|
2960
|
+
# /api/milestones route handler. Refs get their impure cctally symbols via the
|
|
2961
|
+
# module's own call-time _cctally() accessor, so load order is irrelevant.
|
|
2962
|
+
_cctally_milestone_history = _load_sibling("_cctally_milestone_history")
|
|
2963
|
+
build_claude_week_index = _cctally_milestone_history.build_claude_week_index
|
|
2964
|
+
build_claude_week_detail = _cctally_milestone_history.build_claude_week_detail
|
|
2965
|
+
build_codex_cycle_index = _cctally_milestone_history.build_codex_cycle_index
|
|
2966
|
+
build_codex_cycle_detail = _cctally_milestone_history.build_codex_cycle_detail
|
|
2967
|
+
|
|
2968
|
+
|
|
2939
2969
|
# Eager re-export of bin/_cctally_percent_breakdown.py — the percent-breakdown
|
|
2940
2970
|
# handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
|
|
2941
2971
|
_cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
|
|
@@ -3080,6 +3110,7 @@ _tui_sparkline_big = _cctally_tui._tui_sparkline_big
|
|
|
3080
3110
|
_tui_width_bucket = _cctally_tui._tui_width_bucket
|
|
3081
3111
|
# Snapshot builders (per-panel + orchestrator + empty)
|
|
3082
3112
|
_tui_build_percent_milestones = _cctally_tui._tui_build_percent_milestones
|
|
3113
|
+
_tui_build_five_hour_milestones = _cctally_tui._tui_build_five_hour_milestones # milestone-history week detail c.
|
|
3083
3114
|
_tui_build_current_week = _cctally_tui._tui_build_current_week
|
|
3084
3115
|
_tui_build_forecast = _cctally_tui._tui_build_forecast
|
|
3085
3116
|
_tui_build_trend = _cctally_tui._tui_build_trend
|