cctally 1.96.1 → 1.97.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 CHANGED
@@ -5,6 +5,29 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.97.0] - 2026-08-13
9
+
10
+ ### Changed
11
+ - Reveal-mode share exports now allow project directories whose basename is exactly a UUID or source-root-shaped hex token, while the same value outside a typed project field still fails the document-wide privacy guard (#521).
12
+ - Fix UI-QA provenance for subagent-driven UI work by allowing a durable report-only certification commit to name and verify the exact already-committed target, and stop unrelated linked worktrees from perturbing the QA cleanup fingerprint (#558).
13
+ - The dashboard's `All` headline now states a quantity you can check. It is the sum of each provider's own current billing period — Claude's subscription week plus Codex's native seven-day cycle — instead of a thirty-day Claude accounting rollup added to a seven-day Codex cycle under no period label, which on one production install read $18,401.71 beside provider tabs showing about $1,704 and about $4,108. The heading names the cycles it covers and names only the providers actually contributing, each provider block carries its own labelled reset, and the per-provider split is on the hero itself rather than behind a modal. Because the two legs are each provider's own cycle, the figure steps down twice a week at two different reset instants and is not comparable against itself across a reset (#556).
14
+ - The `All` staleness marker no longer sits on the figure permanently. The dashboard's `hero` freshness axis now means "the current cycle resolves and its counters are publishable" rather than "how old the last percent observation is", and percent age moved to the `quota` axis, which is what it always described. The two axes were being combined across clocks forty times apart — ninety seconds against one hour — so the marker was on almost always. A published combined figure is now qualified only by a real qualification, and the self-contradicting pairing of "combined totals are unavailable" with "combined totals use retained actuals" is gone (#556).
15
+ - On an install with more than one account on either provider, the `All` combined total is withheld with a named reason saying which provider has several accounts on separate cycles, and points at the per-account cards that carry each account's own spend. Publishing a single number there would have reproduced the same defect inside the decorated path, because it could not be reconciled against any tab. A count that cannot be read withholds the figure too, rather than assuming a single account (#556).
16
+
17
+ ### Fixed
18
+ - Deep doctor diagnostics and `db journal-repair` now preserve the same physical journal positions as rebuild when fingerprinting structural correction violations, so irrelevant observations can no longer make one path reject another path's durable resolution audit (#508).
19
+ - Root and setup help now describe both Claude and Codex, expose provider-aware `--source` analytics and native Codex quota commands, and name setup-managed Codex handlers (#555).
20
+ - The concurrent stats-heal writer-storm regression now waits for the detached worker's terminal success event before reading the rebuilt index, instead of racing publication artifacts that can become visible while the worker is still finishing under load (#559).
21
+ - CI no longer reports a reader jump as stranded when the reader is merely still travelling toward it. The transient-drain-failure regression measured whether the linked turn sat inside the reader viewport as soon as the turn mounted, but mounting only means the backward walk has reached it, not that the walk has stopped scrolling; the measurement now waits for the scroll to settle, the way every other jump assertion in that suite already did.
22
+ - The dashboard refreshes far more often on installs with a large Codex history, so newly recorded milestones and every other panel stop lagging minutes behind the database. A per-file lookup in the Codex source panel matched on the provider root alone, and because a machine normally has exactly one Codex root that lookup read every Codex usage row for every rollout file. On one real store of 2,324 files and 151,903 entries it took 57 seconds of an 84-second snapshot rebuild, which the dashboard's work-proportional cooldown then doubled into a staleness window of roughly 170 seconds. A composite index over the two columns the lookup actually matches makes it linear again, measured at 57.20s to 0.208s, which cuts the whole rebuild to about 32 seconds. The index is re-derivable, so an existing cache gains it on the next open with no migration, no data rewrite and no measurable size increase.
23
+
24
+ ## [1.96.2] - 2026-08-13
25
+
26
+ ### Fixed
27
+ - CI no longer mistakes loaded-runner latency for a transcript-rebuild phase failure, a hung dashboard budget reconcile, a fake app-server that failed to start, or a writer-storm regression when readers remain healthy relative to both the uncontended baseline and concurrent writers.
28
+ - Settings section links now scroll only the content pane, keep the modal chrome visible, and select the requested section consistently in Safari.
29
+ - Claude Sonnet 5 usage now uses its permanent $2/$10 per-million-token rate, and Claude Mythos 5 plus retained Mythos Preview usage are no longer left unpriced.
30
+
8
31
  ## [1.96.1] - 2026-08-13
9
32
 
10
33
  ### Fixed
@@ -45,6 +68,7 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
45
68
  - The sanctioned local test escape hatch now runs the same contract as a remote run. `CCTALLY_TEST_LOCAL=1` previously left the `agentmem`-gated tests skipping while every remote run required them, so a local green and a remote green were different claims and no document said so. The local path now pins the same policy. If `agentmem` is genuinely absent the run still completes rather than dying, but it states which contract it ran and how many tests that skipped, and classifies itself non-authoritative under a named reason (#529).
46
69
 
47
70
  ### Fixed
71
+ - Share charts now use an AA-compliant light warning colour and keep stacked segments distinguishable without colour (#524).
48
72
  - Remote test routing no longer mistakes runners last provisioned by different branches for a toolchain divergence; parity now compares the caller's declared Python closure while retaining live host-runtime checks (#548).
49
73
  - The stats-corruption epic test now waits for the heal worker's terminal outcome instead of racing the earlier request-marker cleanup, and its maintenance-holder handshake uses bounded readiness and release pipes rather than wall-clock guesses. Sanitized pytest failure extracts now retain safe assertion structure and normalized traceback locations while continuing to redact private paths and dynamic values (#552).
50
74
  - Remote test runs now refuse a conflicted checkout before contacting a runner, name the unmerged git index and its normal `git add` recovery, and always explain a manifest mismatch even when duplicate path records were the only difference (#550).
@@ -1188,6 +1188,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1188
1188
 
1189
1189
  week_lbl: "str | None" = None
1190
1190
  reset_at_utc: "dt.datetime | None" = None
1191
+ week_start_at_utc: "dt.datetime | None" = None
1191
1192
  if cw is not None:
1192
1193
  ws = getattr(cw, "week_start_at", None)
1193
1194
  we = getattr(cw, "week_end_at", None)
@@ -1203,6 +1204,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1203
1204
  elif ws is not None:
1204
1205
  week_lbl = format_display_dt(ws, resolved_tz_obj, fmt='%b %d', suffix=False)
1205
1206
  reset_at_utc = we
1207
+ week_start_at_utc = ws
1206
1208
 
1207
1209
  # Header forecast_pct should match the projection that drove the
1208
1210
  # verdict pill next to it. The View (issue #57) carries the
@@ -1616,7 +1618,19 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1616
1618
  None if cw.five_hour_resets_at is None
1617
1619
  else max(0, int((cw.five_hour_resets_at - now_utc).total_seconds())),
1618
1620
  "spent_usd": cw.spent_usd,
1621
+ # #556 S1 §3.3 — the token half of the same accumulation pass
1622
+ # that produced `spent_usd`. `getattr` keeps legacy fixture
1623
+ # modules that construct `TuiCurrentWeek` without the field
1624
+ # serializing, the same way `five_hour_block` does.
1625
+ "total_tokens": getattr(cw, "total_tokens", 0),
1619
1626
  "dollar_per_pct": cw.dollars_per_percent,
1627
+ # #556 S1 §3.5 — the effective cycle start, the companion of
1628
+ # the already-published `reset_at_utc` end. Composition needs
1629
+ # BOTH bounds to label the Claude leg's period, and the source
1630
+ # version needs them to detect a nominal rollover (§3.6).
1631
+ # Effective, not nominal: `_tui_build_current_week` stores this
1632
+ # AFTER `_apply_midweek_reset_override`.
1633
+ "week_start_at": _iso_z(week_start_at_utc),
1620
1634
  "reset_at_utc": _iso_z(reset_at_utc),
1621
1635
  "reset_in_sec":
1622
1636
  None if reset_at_utc is None
@@ -50,7 +50,6 @@ from _cctally_config import save_config, _load_config_unlocked
50
50
  from _lib_fmt import stable_sum
51
51
  from _lib_pricing import _calculate_entry_cost, claude_usage_dict
52
52
  from _lib_five_hour import _canonical_5h_window_key
53
- from _lib_dashboard_sources import source_domain_freshness
54
53
  from _lib_display_tz import _resolve_tz, resolve_display_tz_name
55
54
 
56
55
 
@@ -1783,12 +1782,33 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1783
1782
  )
1784
1783
 
1785
1784
 
1785
+ def _share_current_week_evidence_is_stale(state) -> bool:
1786
+ """Whether ONE provider's own current-cycle evidence is stale (#556 §4.7).
1787
+
1788
+ This used to read the shared ``domain_freshness.hero`` axis, which #556 S1
1789
+ repointed to accounting resolvability. The aggregate ``quota`` axis is not
1790
+ the substitute: a stale five-hour row stales it independently of the weekly
1791
+ cycle this note describes. So each provider is read on its own field —
1792
+ Claude's percent-observation label under ``hero.current_week.freshness``,
1793
+ Codex's additive ``hero.cycle_freshness``, which is omitted while fresh.
1794
+ """
1795
+ data = getattr(state, "data", None)
1796
+ hero = data.get("hero") if isinstance(data, Mapping) else None
1797
+ if not isinstance(hero, Mapping):
1798
+ return False
1799
+ if getattr(state, "source", None) == "claude":
1800
+ current_week = hero.get("current_week")
1801
+ freshness = (
1802
+ current_week.get("freshness")
1803
+ if isinstance(current_week, Mapping) else None
1804
+ )
1805
+ return isinstance(freshness, Mapping) and freshness.get("label") == "stale"
1806
+ return hero.get("cycle_freshness") == "stale"
1807
+
1808
+
1786
1809
  def _share_apply_current_week_freshness(snapshot, state, panel: str):
1787
1810
  """Qualify retained current-week actuals with provider-local evidence age."""
1788
- if (
1789
- panel != "current-week"
1790
- or source_domain_freshness(state, "hero") != "stale"
1791
- ):
1811
+ if panel != "current-week" or not _share_current_week_evidence_is_stale(state):
1792
1812
  return snapshot
1793
1813
  provider = "Claude" if state.source == "claude" else "Codex"
1794
1814
  note = (
@@ -1261,6 +1261,24 @@ def _codex_cache_report_wire(
1261
1261
  )
1262
1262
 
1263
1263
 
1264
+ #: Per-file terminal thread aliases, joined to their first accounting entry.
1265
+ #: The ``(source_root_key, source_path)`` join predicate needs the composite
1266
+ #: ``idx_codex_entries_root_path`` to stay linear: a single-column root index
1267
+ #: cannot discriminate when every rollout resolves to one provider root, so the
1268
+ #: join degenerates to files x entries. Module-level so the query-plan
1269
+ #: regression asserts THIS text rather than a copy that can drift from it.
1270
+ _CODEX_FILE_ALIAS_SQL = (
1271
+ "SELECT f.source_root_key, f.path, f.last_native_thread_id, "
1272
+ "f.last_session_id, MIN(e.timestamp_utc) "
1273
+ "FROM codex_session_files AS f "
1274
+ "LEFT JOIN codex_session_entries AS e "
1275
+ "ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
1276
+ "WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
1277
+ "GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
1278
+ "ORDER BY f.last_ingested_at DESC, f.path DESC"
1279
+ )
1280
+
1281
+
1264
1282
  def _codex_conversation_metadata(
1265
1283
  cache_conn: sqlite3.Connection,
1266
1284
  ) -> dict[tuple[str, str], dict[str, object]]:
@@ -1302,16 +1320,7 @@ def _codex_conversation_metadata(
1302
1320
  cwd, git_json, first_seen_at, _last_seen_at,
1303
1321
  ) in core_rows
1304
1322
  )
1305
- file_aliases = tuple(cache_conn.execute(
1306
- "SELECT f.source_root_key, f.path, f.last_native_thread_id, "
1307
- "f.last_session_id, MIN(e.timestamp_utc) "
1308
- "FROM codex_session_files AS f "
1309
- "LEFT JOIN codex_session_entries AS e "
1310
- "ON e.source_root_key=f.source_root_key AND e.source_path=f.path "
1311
- "WHERE f.last_native_thread_id IS NOT NULL AND f.last_native_thread_id != '' "
1312
- "GROUP BY f.source_root_key, f.path, f.last_native_thread_id, f.last_session_id "
1313
- "ORDER BY f.last_ingested_at DESC, f.path DESC"
1314
- ))
1323
+ file_aliases = tuple(cache_conn.execute(_CODEX_FILE_ALIAS_SQL))
1315
1324
  native_ids = tuple(sorted({
1316
1325
  str(native_thread_id) for _, _, native_thread_id, *_ in rows
1317
1326
  if isinstance(native_thread_id, str) and native_thread_id
@@ -2393,6 +2402,11 @@ def refresh_codex_source_clock(
2393
2402
  "hero",
2394
2403
  ),)
2395
2404
  availability = "partial"
2405
+ # #556 S1 §4.1: an EXPIRED boundary is exactly the state the
2406
+ # accounting axis reports as stale. Build time can never see it
2407
+ # (`_resolve_codex_weekly_cycle` retains only `resets_at > now`),
2408
+ # so this clock is the only writer of that value.
2409
+ domain_freshness["hero"] = "stale"
2396
2410
  cycle_changed = True
2397
2411
  # 3. budget last
2398
2412
  if refreshed_budget is not None:
@@ -2411,6 +2425,10 @@ def refresh_codex_source_clock(
2411
2425
  data=data,
2412
2426
  domain_freshness=domain_freshness,
2413
2427
  clock_data=state.clock_data,
2428
+ # #556 S1 §3.8: this constructor lists every field explicitly, so an
2429
+ # omission silently drops the authoritative account count and makes the
2430
+ # combined figure fail closed on an idle tick that changed nothing else.
2431
+ account_scope=state.account_scope,
2414
2432
  private_session_labels=state.private_session_labels,
2415
2433
  )
2416
2434
  return state if refreshed_state == state else refreshed_state
@@ -3408,7 +3426,6 @@ def build_codex_source_state(
3408
3426
  cache_conn=context.cache_conn,
3409
3427
  )
3410
3428
  budget_entries = _codex_entries_from_accounting(accounting_entries)
3411
- cycle_reason: str | None = None
3412
3429
  cycles_all: list[CodexCycleBoundary] = []
3413
3430
  try:
3414
3431
  # Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
@@ -3419,9 +3436,12 @@ def build_codex_source_state(
3419
3436
  # `conflicting`.
3420
3437
  cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
3421
3438
  cycle = cycles_all[0] if cycles_all else None
3422
- except CodexCycleUnavailable as exc:
3439
+ except CodexCycleUnavailable:
3440
+ # #556 S1 §4.1: the reason no longer moves a freshness axis. A
3441
+ # `stale` reason is observation AGE, which `quota` owns; every reason
3442
+ # here leaves `cycle` unresolved, which `cycle_failure` below turns
3443
+ # into the hero failure the accounting axis actually reports.
3423
3444
  cycle = None
3424
- cycle_reason = exc.reason
3425
3445
  cycle_failure = cycle is None and has_cached_codex_accounting_entries(
3426
3446
  cache_conn=context.cache_conn,
3427
3447
  )
@@ -3826,16 +3846,17 @@ def build_codex_source_state(
3826
3846
  if ingest_backlog is not None else {}),
3827
3847
  },
3828
3848
  domain_freshness={
3829
- "hero": (
3830
- "stale"
3831
- if cycle_reason == "stale"
3832
- or (
3833
- cycle is not None
3834
- and not hero_failure
3835
- and cycle.evidence_stale
3836
- )
3837
- else "fresh"
3838
- ),
3849
+ # #556 S1 §4.1: `hero` means current-cycle ACCOUNTING
3850
+ # resolvability, not observation age. A stale-but-still-future
3851
+ # boundary stays RESOLVED — the spend it bounds is correct, and
3852
+ # Codex has no background quota poll, so `stale_after_seconds`
3853
+ # (3600) makes an idle weekly observation stale within the hour
3854
+ # while nothing about the accounting changed. The percent age is
3855
+ # already carried by `quota` below and by the additive hero-local
3856
+ # `cycle_freshness` field. `_resolve_codex_weekly_cycle` retains
3857
+ # only boundaries with `resets_at > now`, so a resolved cycle is
3858
+ # never expired at build time; the idle clock owns expiry.
3859
+ "hero": "stale" if hero_failure else "fresh",
3839
3860
  "quota": (
3840
3861
  "stale"
3841
3862
  if quota["summary"]["freshness"] == "stale"
@@ -4310,6 +4310,16 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
4310
4310
  "CREATE INDEX IF NOT EXISTS idx_codex_entries_ts_root_conversation "
4311
4311
  "ON codex_session_entries(timestamp_utc, source_root_key, conversation_key)"
4312
4312
  )
4313
+ # The per-file alias join in `_codex_conversation_metadata` matches on
4314
+ # (source_root_key, source_path). `idx_codex_entries_source_root` cannot
4315
+ # serve it: a machine normally has ONE provider root, so a root-only search
4316
+ # visits every entry row for every file and the join costs files x entries
4317
+ # on every dashboard snapshot build. Re-derivable, so it belongs on the
4318
+ # unconditional path with the S3 index above rather than in a migration.
4319
+ conn.execute(
4320
+ "CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path "
4321
+ "ON codex_session_entries(source_root_key, source_path)"
4322
+ )
4313
4323
  # The per-file terminal thread facts seed a later append without rereading
4314
4324
  # the prefix. They are nullable for old cache rows; migration 024 never
4315
4325
  # fabricates these source facts and instead clears/rederives them.
@@ -38,6 +38,7 @@ import sys
38
38
 
39
39
  import _cctally_core
40
40
  import _lib_changelog
41
+ import _lib_journal_router
41
42
  from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
42
43
  from _lib_dashboard_json import encode_dashboard_json
43
44
 
@@ -46,10 +47,10 @@ from _lib_dashboard_json import encode_dashboard_json
46
47
  #: (`_lib_journal.resolve_effective_events`) reads only `evt`, `correction` and
47
48
  #: `correction_batch`; `op` is kept because the rebuild-equivalent account
48
49
  #: normalization is defined over evt/op records. Everything else — above all the
49
- #: `obs` lines, ~97% of a real journal — is dropped as it is decoded, so the deep
50
- #: gather's peak RSS tracks the decision history rather than the whole journal.
51
- _CONFLICT_SCAN_RECORD_TYPES = frozenset(
52
- {"evt", "correction", "correction_batch", "op"})
50
+ #: `obs` lines, ~97% of a real journal — becomes a `None` positional slot as it
51
+ #: is decoded, so peak RSS tracks decision dictionaries plus one pointer per
52
+ #: decoded line rather than the whole decoded journal.
53
+ _CONFLICT_SCAN_RECORD_TYPES = _lib_journal_router.RETAINED_RECORD_TYPES
53
54
 
54
55
  #: Doctor needs only recent evidence for this diagnostic. Bound both line count
55
56
  #: and bytes so a corrupt single-line file cannot defeat the tail limit.
@@ -1798,10 +1799,13 @@ def _doctor_gather_state_impl(
1798
1799
  # RETAIN ONLY what the selector consumes. `obs` lines are
1799
1800
  # ~97% of a real journal (984k of 1.02M) and
1800
1801
  # `resolve_effective_events` ignores them entirely —
1801
- # keeping them cost 4.3 GB of peak RSS for an identical
1802
- # result (#374 review).
1803
- if record.get("t") in _CONFLICT_SCAN_RECORD_TYPES:
1804
- decoded_records.append(record)
1802
+ # keeping their dictionaries cost 4.3 GB of peak RSS for
1803
+ # an identical result (#374 review). They still consume a
1804
+ # lightweight slot because their physical sequence is
1805
+ # part of three durable violation fingerprints (#508).
1806
+ decoded_records.append(
1807
+ _lib_journal_router.selector_slot(record)
1808
+ )
1805
1809
  prior_high_water = (
1806
1810
  seg,
1807
1811
  offset + len(raw) + 1,
@@ -1821,8 +1825,9 @@ def _doctor_gather_state_impl(
1821
1825
  else _jr.resolve_cutover_claude_account()
1822
1826
  )
1823
1827
  for record in decoded_records:
1824
- _jr._normalize_legacy_account_stamp(
1825
- record, cutover_claude)
1828
+ if record is not None:
1829
+ _jr._normalize_legacy_account_stamp(
1830
+ record, cutover_claude)
1826
1831
  selection = _jl.resolve_effective_events(
1827
1832
  decoded_records,
1828
1833
  protocol_prefix_evidence=protocol_evidence,
@@ -54,10 +54,10 @@ def _read_prefix(high_water):
54
54
  inline exactly as `rebuild_stats_index` captures it. Before this, those were
55
55
  four separate whole-prefix traversals on top of this one (#496 S5 §4).
56
56
 
57
- Every record stays decoded. Unlike the rebuild, the selector here feeds an
58
- acknowledgement the repair command may then mint, and unlike the rebuild's
59
- filtered retention there is no placeholder scheme to keep the `enumerate`
60
- numbering identical so the list is unfiltered, exactly as before.
57
+ Every decoded line contributes one selector slot. Decision records stay
58
+ decoded; observations and other irrelevant records become ``None``
59
+ placeholders, preserving the `enumerate` numbering that durable violation
60
+ fingerprints hash without retaining whole-prefix decoded history.
61
61
  """
62
62
  if high_water is None:
63
63
  return [], (), None, {}
@@ -106,7 +106,7 @@ def _read_prefix(high_water):
106
106
  and record.get("id") == _journal.CUTOVER_OP_ID
107
107
  ):
108
108
  cutover_captured = _journal._cutover_value_of(record)
109
- records.append(record)
109
+ records.append(_lib_journal_router.selector_slot(record))
110
110
  prior_high_water = record_end
111
111
  prefix_hash = hasher.digest_at(high_water)
112
112
  # The accumulator buffers the segment it is reading — 410 MB on the
@@ -136,7 +136,8 @@ def _read_prefix(high_water):
136
136
  else:
137
137
  cutover_claude = cutover_captured
138
138
  for record in records:
139
- _journal._normalize_legacy_account_stamp(record, cutover_claude)
139
+ if record is not None:
140
+ _journal._normalize_legacy_account_stamp(record, cutover_claude)
140
141
  return records, tuple(evidence), prefix_hash, audit_ends
141
142
 
142
143
 
@@ -58,6 +58,24 @@ class CLIHelpFormatter(
58
58
  super().__init__(prog, **kwargs) # type: ignore[arg-type]
59
59
 
60
60
 
61
+ _PHYSICAL_PROVIDER_SOURCES = ("claude", "codex")
62
+
63
+
64
+ def _analytics_source_choices() -> tuple[str, ...]:
65
+ """Return physical providers plus the provider-separated combined view."""
66
+ return (*_PHYSICAL_PROVIDER_SOURCES, "all")
67
+
68
+
69
+ def _provider_display_names() -> str:
70
+ """Render the canonical physical-provider tuple for user-facing help."""
71
+ labels = tuple(source.title() for source in _PHYSICAL_PROVIDER_SOURCES)
72
+ if len(labels) == 1:
73
+ return labels[0]
74
+ if len(labels) == 2:
75
+ return " and ".join(labels)
76
+ return f"{', '.join(labels[:-1])}, and {labels[-1]}"
77
+
78
+
61
79
  def _argparse_has_arg(parser, option_string: str) -> bool:
62
80
  """Return True if ``parser`` already registered ``option_string``."""
63
81
  for action in parser._actions:
@@ -288,11 +306,12 @@ def _add_source_args(
288
306
  """Attach the source selector or pin a nested provider alias."""
289
307
  if fixed_source is None:
290
308
  parser.add_argument(
291
- "--source", choices=("claude", "codex", "all"), default="claude",
292
- help="Analytics provider: claude (default), codex, or all.",
309
+ "--source", choices=_analytics_source_choices(),
310
+ default=_PHYSICAL_PROVIDER_SOURCES[0],
311
+ help="Analytics provider.",
293
312
  )
294
313
  else:
295
- if fixed_source not in {"claude", "codex"}:
314
+ if fixed_source not in _PHYSICAL_PROVIDER_SOURCES:
296
315
  raise ValueError(f"unsupported fixed source {fixed_source!r}")
297
316
  parser.set_defaults(source=fixed_source)
298
317
  if speed:
@@ -2717,8 +2736,9 @@ def _build_setup_parser(subparsers, name, *, help_text, xref=None):
2717
2736
  formatter_class=CLIHelpFormatter,
2718
2737
  description=textwrap.dedent(
2719
2738
  """\
2720
- Install cctally into Claude Code by adding hook entries to
2721
- ~/.claude/settings.json (additive, idempotent) and creating
2739
+ Install cctally for local providers by adding Claude hook entries
2740
+ to ~/.claude/settings.json and native Codex handlers to every
2741
+ configured Codex home (all additive and idempotent). Also create
2722
2742
  user-facing symlinks under ~/.local/bin/.
2723
2743
 
2724
2744
  Modes (mutually exclusive):
@@ -2823,7 +2843,7 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
2823
2843
  formatter_class=CLIHelpFormatter)
2824
2844
  t_search.add_argument("query", metavar="QUERY", help="Search text")
2825
2845
  t_search.add_argument(
2826
- "--source", choices=("claude", "codex"), default="claude",
2846
+ "--source", choices=_PHYSICAL_PROVIDER_SOURCES, default="claude",
2827
2847
  help="Which provider's conversations to search (default: claude)")
2828
2848
  t_search.add_argument(
2829
2849
  "--account", metavar="REF", default=None,
@@ -3270,7 +3290,7 @@ def _build_hook_tick_parser(subparsers, name, *, help_text, xref=None):
3270
3290
  help=argparse.SUPPRESS) # JSON string fed to mock fetch (tests only)
3271
3291
  ht.add_argument("--foreground", action="store_true",
3272
3292
  help=argparse.SUPPRESS) # Codex native hook wrapper
3273
- ht.add_argument("--source", choices=("claude", "codex"), default="claude",
3293
+ ht.add_argument("--source", choices=_PHYSICAL_PROVIDER_SOURCES, default="claude",
3274
3294
  help=argparse.SUPPRESS) # setup-managed native Codex hook
3275
3295
  ht.set_defaults(func=c.cmd_hook_tick)
3276
3296
 
@@ -3572,7 +3592,7 @@ _REGISTRATION = (
3572
3592
  _Reg('account', _build_account_parser, "Inspect per-provider account registry (list / show / label)", None, None),
3573
3593
  _Reg('telemetry', _build_telemetry_parser, "Show or change anonymous install-count telemetry", None, None),
3574
3594
  _Reg('alerts', _build_alerts_parser, "Manage threshold alerts", None, None),
3575
- _Reg('setup', _build_setup_parser, "Install cctally into Claude Code (hooks + symlinks)", None, None),
3595
+ _Reg('setup', _build_setup_parser, "Install provider hooks/handlers + symlinks", None, None),
3576
3596
  _Reg('db', _build_db_parser, "Migration / DB management (status, skip, unskip)", None, None),
3577
3597
  _Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
3578
3598
  _Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
@@ -3592,28 +3612,33 @@ _REGISTRATION = (
3592
3612
 
3593
3613
  def build_parser() -> argparse.ArgumentParser:
3594
3614
  c = _cctally()
3615
+ providers = _provider_display_names()
3616
+ source_choices = ",".join(_analytics_source_choices())
3617
+ quick_start_reports = "\n".join(
3618
+ f" cctally report --source {source}"
3619
+ for source in _PHYSICAL_PROVIDER_SOURCES
3620
+ )
3595
3621
  p = argparse.ArgumentParser(
3596
3622
  prog="cctally",
3597
3623
  formatter_class=CLIHelpFormatter,
3598
3624
  description=textwrap.dedent(
3599
- """\
3600
- Track Claude subscription weekly usage percent and weekly cost
3601
- in a local SQLite database.
3625
+ f"""\
3626
+ Track {providers} subscription usage and local USD cost in SQLite.
3602
3627
 
3603
3628
  Data flow:
3604
- 1) Claude Code status line captures rate limit data after each API call.
3605
- 2) record-usage stores usage snapshots and triggers percent milestones.
3606
- 3) sync-week computes weekly USD cost from Claude Code session data.
3607
- 4) report computes dollars per 1% and shows trend history.
3629
+ 1) Claude Code hooks and status lines retain Claude usage and sessions.
3630
+ 2) Native Codex handlers retain Codex sessions and quota windows.
3631
+ 3) Provider-aware project, diff, range-cost, cache-report, and report
3632
+ commands accept --source {{{source_choices}}}.
3633
+ 4) Provider-native reports keep {providers} quota percentages and
3634
+ reset windows separate; `cctally codex quota` exposes Codex windows.
3608
3635
  """
3609
3636
  ),
3610
- epilog=textwrap.dedent(
3611
- """\
3612
- Quick start:
3613
- # Add record-usage call to ~/.claude/statusline-command.sh (see record-usage --help)
3614
- cctally sync-week
3615
- cctally report
3616
- """
3637
+ epilog=(
3638
+ "Quick start:\n"
3639
+ " cctally setup\n"
3640
+ f"{quick_start_reports}\n"
3641
+ " cctally codex quota statusline"
3617
3642
  ),
3618
3643
  )
3619
3644
  p.add_argument(