cctally 1.77.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.
@@ -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.
@@ -0,0 +1,43 @@
1
+ """Strict outbound JSON contract shared by dashboard HTTP/SSE and Doctor.
2
+
3
+ Python's ``json.dumps`` defaults to emitting JavaScript-only ``NaN`` and
4
+ ``Infinity`` tokens. Browsers reject those tokens in ``JSON.parse`` and
5
+ ``Response.json``. Normalize supported JSON containers recursively, then keep
6
+ ``allow_nan=False`` as the final fail-loud guard. Unsupported objects and
7
+ non-finite mapping keys are deliberately not coerced.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from typing import Any
14
+
15
+
16
+ def normalize_dashboard_json(value: Any) -> Any:
17
+ """Return a non-mutating JSON value with non-finite floats mapped to null."""
18
+ if isinstance(value, float):
19
+ return value if math.isfinite(value) else None
20
+ if isinstance(value, dict):
21
+ return {
22
+ key: normalize_dashboard_json(item)
23
+ for key, item in value.items()
24
+ }
25
+ if isinstance(value, list):
26
+ return [normalize_dashboard_json(item) for item in value]
27
+ if isinstance(value, tuple):
28
+ return tuple(normalize_dashboard_json(item) for item in value)
29
+ return value
30
+
31
+
32
+ def encode_dashboard_json(value: Any, **kwargs: Any) -> str:
33
+ """Serialize one outbound payload with browser-strict JSON semantics."""
34
+ return json.dumps(
35
+ normalize_dashboard_json(value),
36
+ allow_nan=False,
37
+ **kwargs,
38
+ )
39
+
40
+
41
+ def encode_dashboard_json_bytes(value: Any, **kwargs: Any) -> bytes:
42
+ """UTF-8 bytes companion for HTTP response bodies."""
43
+ return encode_dashboard_json(value, **kwargs).encode("utf-8")
@@ -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
@@ -83,9 +83,26 @@ def assign_collision_safe_project_labels(
83
83
  selection the exact string that terminal, JSON, and share emit.
84
84
  """
85
85
  values = tuple(entries)
86
+ assigned = collision_safe_project_label_map(
87
+ (entry.project_key, entry.project_label) for entry in values
88
+ )
89
+ return tuple(
90
+ replace(entry, display_label=assigned.get(entry.project_key, entry.project_label))
91
+ for entry in values
92
+ )
93
+
94
+
95
+ def collision_safe_project_label_map(
96
+ identities_and_labels: Iterable[tuple[str, str]],
97
+ ) -> dict[str, str]:
98
+ """Allocate the shared deterministic privacy-safe label contract.
99
+
100
+ Callers supply opaque internal identities. Only the returned labels are
101
+ presentation data; the identity keys must remain inside the adapter.
102
+ """
86
103
  keys_by_label: dict[str, set[str]] = defaultdict(set)
87
- for entry in values:
88
- keys_by_label[entry.project_label].add(entry.project_key)
104
+ for identity, label in identities_and_labels:
105
+ keys_by_label[label].add(identity)
89
106
  # Never let an allocator-owned ``label (N)`` shadow a literal project
90
107
  # label. Reserving every raw label also makes a later presentation pass
91
108
  # unnecessary, which avoids producing ``label (N) (N)``.
@@ -107,10 +124,7 @@ def assign_collision_safe_project_labels(
107
124
  assigned[key] = candidate
108
125
  used_labels.add(candidate)
109
126
  ordinal += 1
110
- return tuple(
111
- replace(entry, display_label=assigned.get(entry.project_key, entry.project_label))
112
- for entry in values
113
- )
127
+ return assigned
114
128
 
115
129
 
116
130
  def opaque_project_key(source: str, root_key: str, resolved_key: str) -> str:
package/bin/cctally CHANGED
@@ -558,6 +558,10 @@ _lib_log = _load_sibling("_lib_log")
558
558
  _lib_json_envelope = _load_sibling("_lib_json_envelope")
559
559
  stamp_schema_version = _lib_json_envelope.stamp_schema_version
560
560
 
561
+ # Strict outbound dashboard/Doctor JSON: recursive non-finite normalization
562
+ # followed by allow_nan=False serialization.
563
+ _lib_dashboard_json = _load_sibling("_lib_dashboard_json")
564
+
561
565
  _lib_changelog = _load_sibling("_lib_changelog")
562
566
  # Backward-compat re-export: callers and tests reach the helper through
563
567
  # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
@@ -1127,6 +1131,10 @@ _UPDATE_CHECK_TTL_HOURS_MIN = _cctally_update._UPDATE_CHECK_TTL_HOURS_MIN
1127
1131
  _UPDATE_CHECK_TTL_HOURS_MAX = _cctally_update._UPDATE_CHECK_TTL_HOURS_MAX
1128
1132
  _normalize_update_check_enabled_value = _cctally_update._normalize_update_check_enabled_value
1129
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
1130
1138
  _UPDATE_STATE_SCHEMA_MAX = _cctally_update._UPDATE_STATE_SCHEMA_MAX
1131
1139
  _UPDATE_SUPPRESS_SCHEMA_MAX = _cctally_update._UPDATE_SUPPRESS_SCHEMA_MAX
1132
1140
  _load_update_state = _cctally_update._load_update_state
@@ -1149,6 +1157,9 @@ _BREW_VERSION_RE_LIST = _cctally_update._BREW_VERSION_RE_LIST
1149
1157
  _update_user_agent = _cctally_update._update_user_agent
1150
1158
  _fetch_url = _cctally_update._fetch_url
1151
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
1152
1163
  _check_brew_latest_version = _cctally_update._check_brew_latest_version
1153
1164
  _is_update_check_due = _cctally_update._is_update_check_due
1154
1165
  _do_update_check = _cctally_update._do_update_check
@@ -1158,6 +1169,9 @@ _spawn_background_telemetry_beat = _cctally_update._spawn_background_telemetry_b
1158
1169
  cmd_telemetry_beat_internal = _cctally_update.cmd_telemetry_beat_internal
1159
1170
  SKIP_USE_STATE_LATEST = _cctally_update.SKIP_USE_STATE_LATEST
1160
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
1161
1175
  _prerelease_note = _cctally_update._prerelease_note
1162
1176
  _format_update_check_json = _cctally_update._format_update_check_json
1163
1177
  _UPDATE_METHOD_HUMAN_LABEL = _cctally_update._UPDATE_METHOD_HUMAN_LABEL
@@ -1336,6 +1350,7 @@ _build_statusline_injections = _cctally_statusline._build_statusline_injections
1336
1350
  # `cctally._statusline_persist` (test surface) + the `_cctally()` accessor
1337
1351
  # resolve them.
1338
1352
  _statusline_persist = _cctally_statusline._statusline_persist
1353
+ _statusline_oauth_tick = _cctally_statusline._statusline_oauth_tick
1339
1354
  _fork_persist = _cctally_statusline._fork_persist
1340
1355
  _record_args = _cctally_statusline._record_args
1341
1356
  _try_acquire_persist_lock = _cctally_statusline._try_acquire_persist_lock
@@ -1626,6 +1641,14 @@ UPDATE_NPM_REGISTRY_URL = os.environ.get(
1626
1641
  "CCTALLY_TEST_UPDATE_NPM_URL",
1627
1642
  "https://registry.npmjs.org/cctally/latest",
1628
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
+ )
1629
1652
  UPDATE_BREW_FORMULA_URL = os.environ.get(
1630
1653
  "CCTALLY_TEST_UPDATE_BREW_URL",
1631
1654
  "https://raw.githubusercontent.com/omrikais/homebrew-cctally/main/Formula/cctally.rb",
@@ -2931,6 +2954,18 @@ _RESET_ZERO_MIN_DROP_PCT = _cctally_weekrefs._RESET_ZERO_MIN_DROP_PCT
2931
2954
  _is_reset_drop = _cctally_weekrefs._is_reset_drop
2932
2955
 
2933
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
+
2934
2969
  # Eager re-export of bin/_cctally_percent_breakdown.py — the percent-breakdown
2935
2970
  # handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
2936
2971
  _cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
@@ -3075,6 +3110,7 @@ _tui_sparkline_big = _cctally_tui._tui_sparkline_big
3075
3110
  _tui_width_bucket = _cctally_tui._tui_width_bucket
3076
3111
  # Snapshot builders (per-panel + orchestrator + empty)
3077
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.
3078
3114
  _tui_build_current_week = _cctally_tui._tui_build_current_week
3079
3115
  _tui_build_forecast = _cctally_tui._tui_build_forecast
3080
3116
  _tui_build_trend = _cctally_tui._tui_build_trend