#!/usr/bin/env python3
"""cctally — local CLI for Claude Code subscription usage and cost tracking.

Pricing reflects Anthropic's official Claude pricing documentation (Claude
models) and LiteLLM's model_prices_and_context_window.json (Codex models).
The `daily` / `monthly` / `weekly` / `blocks` / `session` / `codex-*`
subcommands replicate the output format of upstream `ccusage` and
`ccusage-codex` (npm tools by @ryoppippi) byte-for-byte for drop-in
compatibility.
"""

from __future__ import annotations

import sys

__min_python_version__ = (3, 11)

if sys.version_info < __min_python_version__:
    print(
        f"cctally requires Python {'.'.join(map(str, __min_python_version__))}+, "
        f"but {sys.version.split()[0]} is in use.",
        file=sys.stderr,
    )
    sys.exit(2)

# Register this running module under the canonical name `cctally` so sibling
# I/O modules (e.g. _cctally_release.py from commit #2 onward) can use
# `import cctally; cctally.APP_DIR / ...` for back-imports of monkeypatch-
# sensitive globals (spec Section 5).
#
# Gated on __name__ == "__main__" to:
#   - Activate when run as a script (production path; sys.modules['cctally']
#     not pre-set by anything).
#   - Skip when loaded via SourceFileLoader('cctally', ...) — the loader
#     already registers sys.modules['cctally'] before exec_module fires.
#     The setdefault below preserves the loader's registration even if
#     this branch ever triggered with both flags held.
#   - Skip under any non-standard load context where `__name__` resolves
#     to something other than __main__ or cctally (e.g. legacy
#     compile+exec-into-dict, where __name__ resolves to "builtins").
#
# Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §5.1
if __name__ == "__main__":
    sys.modules.setdefault("cctally", sys.modules["__main__"])

import argparse
import bisect
import contextlib
import datetime as dt
import fcntl
import hashlib
import io
import json
import os
import pathlib
import queue
import re
import secrets
import math
import shutil
import socket
import sqlite3
import subprocess
import tempfile
import textwrap
import threading
import time
import traceback
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError


def _load_sibling(name: str):
    """Load bin/<name>.py via spec_from_file_location, register in sys.modules.

    Robust across script-runtime AND test-loader contexts (the latter often
    lack bin/ on sys.path; tests/conftest.py historically used compile+exec
    which doesn't set it, and SourceFileLoader callers in tests/ insert
    sys.path explicitly only when needed). The spec_from_file_location path
    bypasses sys.path entirely.

    Registers in sys.modules BEFORE exec_module so dataclass(frozen=True)
    paths that inspect cls.__module__'s sys.modules entry work correctly.

    For `_cctally_*.py` siblings that back-reference `cctally`, the sibling
    module exposes a `_cctally()` accessor that reads `sys.modules['cctally']`
    at call-time (spec §5.5). We re-pin `sys.modules['cctally']` on every
    `_load_sibling` call to OUR bin/cctally module instance (resolved via
    the `_THIS_MODULE` closure cell — see below). Without this pin,
    `monkeypatch.setattr(cctally, "CHANGELOG_PATH", tmp)` in one test
    instance fails to propagate into MOVED helpers when a different test's
    `load_script()` has overwritten `sys.modules['cctally']`. The pin is a
    no-op in production (where bin/cctally is `sys.modules["cctally"]` by
    the `setdefault` at top-of-file) and decisive in pytest interleavings.

    Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3
    """
    # Re-pin sys.modules['cctally'] to OUR module instance before any sibling
    # load. `_THIS_MODULE` (set at module-load time below) holds a stable
    # reference to whichever bin/cctally module instance owns this
    # _load_sibling closure — regardless of pytest re-imports.
    sys.modules["cctally"] = _THIS_MODULE
    cached = sys.modules.get(name)
    if cached is not None:
        return cached
    import importlib.util as _ilu
    p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
    spec = _ilu.spec_from_file_location(name, p)
    mod = _ilu.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod


# Stable reference to THIS bin/cctally module instance, used by
# _load_sibling to re-pin sys.modules['cctally'] before sibling loads (so
# `_cctally_*.py` back-references resolve to OUR instance, not whichever
# instance some other pytest test left behind in sys.modules).
#
# Resolution order:
#   1. Production: __name__ == "__main__"; sys.modules["__main__"] is us.
#   2. SourceFileLoader / types.ModuleType("cctally") test contexts:
#      __name__ == "cctally"; sys.modules["cctally"] is us (the loader /
#      load_script sets it before exec_module / exec fires).
#   3. Bare `exec` into a fresh dict without sys.modules registration:
#      fall back to globals() (the dict-as-module bridge from spec §6.0a;
#      passing `globals()` to `sys.modules[__name__] = …` is unnecessary
#      since `_THIS_MODULE` is the value we want to keep stable).
_THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")


# Hook-tick non-path constants (Section 1 of onboarding spec).
HOOK_TICK_LOG_ROTATE_BYTES = 1024 * 1024  # 1 MB
HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0

# User-facing executables symlinked by `cctally setup` (Section 2.2 of spec).
SETUP_SYMLINK_NAMES = (
    "cctally",
    "cctally-alerts",
    "cctally-dashboard",
    "cctally-dollar-per-percent",
    "cctally-five-hour-blocks",
    "cctally-five-hour-breakdown",
    "cctally-forecast",
    "cctally-project",
    "cctally-refresh-usage",
    "cctally-statusline",
    "cctally-sync-week",
    "cctally-tui",
    "cctally-update",
)

# Hook events we register entries for (Section 1 of spec).
SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")

# === Release automation (issue #24) ===

# Public mirror repo identity (the GitHub `<owner>/<repo>` slug). Used by
# Phase 3 (mirror push), Phase 4 (`gh release create` / view), and the
# fallback printout. Distinct from `.mirror-allowlist`, which classifies
# paths by visibility — this is the publish target.
PUBLIC_REPO = "omrikais/cctally"

# === Eager-loaded library siblings ===
#
# Re-export pure-fn primitives from _lib_*.py so code defined later in
# this file (e.g. RELEASE_HEADER_RE uses _SEMVER_NUM) can reach them via
# the cctally module namespace. The actual definitions live in the
# sibling modules; bin/cctally's namespace simply re-aliases them.
#
# Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4

# === _cctally_core: leaf kernel (MUST load first; sibling imports
# `from _cctally_core import …` depend on this being in sys.modules
# before any other sibling's module body runs). Spec
# 2026-05-17-cctally-core-kernel-extraction.md §2.5. ===
_cctally_core = _load_sibling("_cctally_core")

# Eager re-exports for the 24 kernel symbols. Preserves
# `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
# paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
eprint = _cctally_core.eprint
now_utc_iso = _cctally_core.now_utc_iso
parse_iso_datetime = _cctally_core.parse_iso_datetime
parse_date_str = _cctally_core.parse_date_str
format_local_iso = _cctally_core.format_local_iso
_iso_to_epoch = _cctally_core._iso_to_epoch
_format_short_duration = _cctally_core._format_short_duration
_normalize_week_boundary_dt = _cctally_core._normalize_week_boundary_dt
_now_utc = _cctally_core._now_utc
_command_as_of = _cctally_core._command_as_of
get_week_start_name = _cctally_core.get_week_start_name
compute_week_bounds = _cctally_core.compute_week_bounds
WEEKDAY_MAP = _cctally_core.WEEKDAY_MAP
DEFAULT_WEEK_START = _cctally_core.DEFAULT_WEEK_START
ensure_dirs = _cctally_core.ensure_dirs
_AlertsConfigError = _cctally_core._AlertsConfigError
_ALERTS_CONFIG_VALID_KEYS = _cctally_core._ALERTS_CONFIG_VALID_KEYS
_validate_threshold_list = _cctally_core._validate_threshold_list
_get_alerts_config = _cctally_core._get_alerts_config
_BudgetConfigError = _cctally_core._BudgetConfigError
_BUDGET_DEFAULTS = _cctally_core._BUDGET_DEFAULTS
_BUDGET_CONFIG_VALID_KEYS = _cctally_core._BUDGET_CONFIG_VALID_KEYS
_get_budget_config = _cctally_core._get_budget_config
_budget_alerts_active = _cctally_core._budget_alerts_active
open_db = _cctally_core.open_db
WeekRef = _cctally_core.WeekRef
_canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
make_week_ref = _cctally_core.make_week_ref
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week

# === Path constants — re-exported from _cctally_core ================
#
# Promoted 2026-05-22 (docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md).
# `_cctally_core` is the single source of truth and the only legal
# monkeypatch target. These re-exports exist for ad-hoc REPL / scripts
# that read `cctally.APP_DIR` directly.

APP_DIR = _cctally_core.APP_DIR
LEGACY_APP_DIR = _cctally_core.LEGACY_APP_DIR
LOG_DIR = _cctally_core.LOG_DIR

DB_PATH = _cctally_core.DB_PATH
CACHE_DB_PATH = _cctally_core.CACHE_DB_PATH

CACHE_LOCK_PATH = _cctally_core.CACHE_LOCK_PATH
CACHE_LOCK_CODEX_PATH = _cctally_core.CACHE_LOCK_CODEX_PATH
CONFIG_LOCK_PATH = _cctally_core.CONFIG_LOCK_PATH

CONFIG_PATH = _cctally_core.CONFIG_PATH

MIGRATION_ERROR_LOG_PATH = _cctally_core.MIGRATION_ERROR_LOG_PATH

CHANGELOG_PATH = _cctally_core.CHANGELOG_PATH

HOOK_TICK_LOG_DIR = _cctally_core.HOOK_TICK_LOG_DIR
HOOK_TICK_LOG_PATH = _cctally_core.HOOK_TICK_LOG_PATH
HOOK_TICK_LOG_ROTATED_PATH = _cctally_core.HOOK_TICK_LOG_ROTATED_PATH
HOOK_TICK_THROTTLE_PATH = _cctally_core.HOOK_TICK_THROTTLE_PATH
HOOK_TICK_THROTTLE_LOCK_PATH = _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH

UPDATE_STATE_PATH = _cctally_core.UPDATE_STATE_PATH
UPDATE_SUPPRESS_PATH = _cctally_core.UPDATE_SUPPRESS_PATH
UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH

CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH

_lib_semver = _load_sibling("_lib_semver")
_SEMVER_NUM = _lib_semver._SEMVER_NUM
_SEMVER_RE = _lib_semver._SEMVER_RE

_lib_pricing = _load_sibling("_lib_pricing")
TIERED_THRESHOLD = _lib_pricing.TIERED_THRESHOLD
CLAUDE_MODEL_PRICING = _lib_pricing.CLAUDE_MODEL_PRICING
CODEX_TIERED_THRESHOLD = _lib_pricing.CODEX_TIERED_THRESHOLD
CODEX_MODEL_PRICING = _lib_pricing.CODEX_MODEL_PRICING
CODEX_LEGACY_FALLBACK_MODEL = _lib_pricing.CODEX_LEGACY_FALLBACK_MODEL
_unknown_model_warnings = _lib_pricing._unknown_model_warnings
_unknown_codex_model_warnings = _lib_pricing._unknown_codex_model_warnings
_chip_for_model = _lib_pricing._chip_for_model
_resolve_codex_pricing = _lib_pricing._resolve_codex_pricing
_is_codex_fallback = _lib_pricing._is_codex_fallback
_resolve_model_pricing = _lib_pricing._resolve_model_pricing
_calculate_entry_cost = _lib_pricing._calculate_entry_cost
_warn_unknown_codex_model = _lib_pricing._warn_unknown_codex_model
_calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
_codex_fast_multiplier = _lib_pricing._codex_fast_multiplier
CODEX_FAST_MULTIPLIER_OVERRIDES = _lib_pricing.CODEX_FAST_MULTIPLIER_OVERRIDES
CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
_codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
_short_model_name = _lib_pricing._short_model_name

# Pricing-freshness check (spec 2026-05-29): pure-fn kernel re-exported here
# like the other _lib_* kernels. The kernel takes the pricing predicates +
# tables + observed rows as injected args; the I/O glue (cache scan, HTTP
# fetchers, the doctor coverage wiring) lives in bin/cctally.
_lib_pricing_check = _load_sibling("_lib_pricing_check")
classify_coverage = _lib_pricing_check.classify_coverage
scope_litellm = _lib_pricing_check.scope_litellm
diff_pricing = _lib_pricing_check.diff_pricing
stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
check_table_shapes = _lib_pricing_check.check_table_shapes
pricing_issue_action = _lib_pricing_check.pricing_issue_action
CoverageGap = _lib_pricing_check.CoverageGap
DriftRow = _lib_pricing_check.DriftRow
DriftResult = _lib_pricing_check.DriftResult
PRICING_SNAPSHOT_DATE = _lib_pricing.PRICING_SNAPSHOT_DATE
PRICING_STALENESS_DAYS = _lib_pricing.PRICING_STALENESS_DAYS
PRICING_DRIFT_ALLOWLIST = _lib_pricing.PRICING_DRIFT_ALLOWLIST
LITELLM_PRICES_URL = _lib_pricing.LITELLM_PRICES_URL

# Budget kernel (spec 2026-05-29): pure-fn kernel re-exported here like the
# other _lib_* kernels. `project_linear` is the shared projection primitive
# (forecast routes its EOW % projection through it too — spec F1). The I/O
# glue (spend gather, the `budget` subcommand) lives in bin/cctally.
_lib_budget = _load_sibling("_lib_budget")
BudgetInputs = _lib_budget.BudgetInputs
BudgetStatus = _lib_budget.BudgetStatus
compute_budget_status = _lib_budget.compute_budget_status
project_linear = _lib_budget.project_linear
calendar_month_window = _lib_budget.calendar_month_window
calendar_week_window = _lib_budget.calendar_week_window

# CLAUDE_MODEL_CONTEXT_WINDOWS / …_DEFAULT_FAMILY moved to _cctally_statusline.py (re-exported below).

_lib_display_tz = _load_sibling("_lib_display_tz")
DISPLAY_TZ_DEFAULT = _lib_display_tz.DISPLAY_TZ_DEFAULT
_DISPLAY_TZ_BAD_CONFIG_WARNED = _lib_display_tz._DISPLAY_TZ_BAD_CONFIG_WARNED
_DISPLAY_TZ_RESOLVE_WARNED = _lib_display_tz._DISPLAY_TZ_RESOLVE_WARNED
_local_tz_name = _lib_display_tz._local_tz_name
_resolve_tz = _lib_display_tz._resolve_tz
normalize_display_tz_value = _lib_display_tz.normalize_display_tz_value
_config_has_explicit_display_tz = _lib_display_tz._config_has_explicit_display_tz
get_display_tz_pref = _lib_display_tz.get_display_tz_pref
resolve_display_tz = _lib_display_tz.resolve_display_tz
display_tz_label = _lib_display_tz.display_tz_label
_localize = _lib_display_tz._localize
_resolve_display_tz_obj = _lib_display_tz._resolve_display_tz_obj
_apply_display_tz_override = _lib_display_tz._apply_display_tz_override
_compute_display_block = _lib_display_tz._compute_display_block
format_display_dt = _lib_display_tz.format_display_dt
_argparse_tz = _lib_display_tz._argparse_tz


# fmt/color/table primitives moved to _lib_fmt.py (#126 C11)
_lib_fmt = _load_sibling("_lib_fmt")
_parse_iso_datetime_optional = _lib_fmt._parse_iso_datetime_optional
_format_ts_compact = _lib_fmt._format_ts_compact
_format_week_window = _lib_fmt._format_week_window
_supports_color_stdout = _lib_fmt._supports_color_stdout
_style_ansi = _lib_fmt._style_ansi
_supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
_display_width = _lib_fmt._display_width
_boxed_table = _lib_fmt._boxed_table
_fmt_num = _lib_fmt._fmt_num
_truncate_num = _lib_fmt._truncate_num
_ANSI_ESC_RE = _lib_fmt._ANSI_ESC_RE
_truncate_display = _lib_fmt._truncate_display


_cctally_parser = _load_sibling("_cctally_parser")
build_parser = _cctally_parser.build_parser
_nonneg_int = _cctally_parser._nonneg_int
CLIHelpFormatter = _cctally_parser.CLIHelpFormatter
_argparse_has_arg = _cctally_parser._argparse_has_arg
_add_mode_arg = _cctally_parser._add_mode_arg
_add_ccusage_alias_args = _cctally_parser._add_ccusage_alias_args
_add_codex_shared_args = _cctally_parser._add_codex_shared_args
_add_share_args = _cctally_parser._add_share_args
_share_validate_args = _cctally_parser._share_validate_args
_build_daily_parser = _cctally_parser._build_daily_parser
_build_monthly_parser = _cctally_parser._build_monthly_parser
_build_weekly_parser = _cctally_parser._build_weekly_parser
_build_session_parser = _cctally_parser._build_session_parser
_build_blocks_parser = _cctally_parser._build_blocks_parser
_build_statusline_parser = _cctally_parser._build_statusline_parser
_build_codex_daily_parser = _cctally_parser._build_codex_daily_parser
_build_codex_monthly_parser = _cctally_parser._build_codex_monthly_parser
_build_codex_weekly_parser = _cctally_parser._build_codex_weekly_parser
_build_codex_session_parser = _cctally_parser._build_codex_session_parser


_lib_alert_axes = _load_sibling("_lib_alert_axes")
severity_for = _lib_alert_axes.severity_for
AlertAxisDescriptor = _lib_alert_axes.AlertAxisDescriptor
AXIS_REGISTRY = _lib_alert_axes.AXIS_REGISTRY
AXIS_BY_ID = _lib_alert_axes.AXIS_BY_ID

_lib_alerts_payload = _load_sibling("_lib_alerts_payload")
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
_alert_text_budget = _lib_alerts_payload._alert_text_budget
_alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
_alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
_alert_text_projected = _lib_alerts_payload._alert_text_projected
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
_build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected

_lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
resolve_notifier = _lib_alert_dispatch.resolve_notifier
build_command = _lib_alert_dispatch.build_command
severity_to_urgency = _lib_alert_dispatch.severity_to_urgency

_lib_five_hour = _load_sibling("_lib_five_hour")
_FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
_floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
_canonical_5h_window_key = _lib_five_hour._canonical_5h_window_key

_lib_subscription_weeks = _load_sibling("_lib_subscription_weeks")
SubWeek = _lib_subscription_weeks.SubWeek
_discover_week_anchor = _lib_subscription_weeks._discover_week_anchor
_clamp_end_ats_to_next_start = _lib_subscription_weeks._clamp_end_ats_to_next_start
_apply_overlap_clamp_to_subweeks = _lib_subscription_weeks._apply_overlap_clamp_to_subweeks
_apply_reset_events_to_subweeks = _lib_subscription_weeks._apply_reset_events_to_subweeks
_compute_subscription_weeks = _lib_subscription_weeks._compute_subscription_weeks

_lib_jsonl = _load_sibling("_lib_jsonl")
UsageEntry = _lib_jsonl.UsageEntry
CodexEntry = _lib_jsonl.CodexEntry
_parse_usage_entries = _lib_jsonl._parse_usage_entries
_iter_jsonl_entries_with_offsets = _lib_jsonl._iter_jsonl_entries_with_offsets
_CODEX_FILENAME_UUID_RE = _lib_jsonl._CODEX_FILENAME_UUID_RE
_CodexIterState = _lib_jsonl._CodexIterState
_iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_with_offsets

_lib_blocks = _load_sibling("_lib_blocks")
BLOCK_DURATION = _lib_blocks.BLOCK_DURATION
Block = _lib_blocks.Block
_floor_to_hour = _lib_blocks._floor_to_hour
_group_entries_into_blocks = _lib_blocks._group_entries_into_blocks
_aggregate_block = _lib_blocks._aggregate_block
_build_activity_block = _lib_blocks._build_activity_block
_blocks_to_json = _lib_blocks._blocks_to_json
_max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
_parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit

_lib_statusline = _load_sibling("_lib_statusline")
STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS

_lib_changelog = _load_sibling("_lib_changelog")
# Backward-compat re-export: callers and tests reach the helper through
# ``cctally._release_read_latest_release_version`` (incl. monkeypatch
# sites in tests/test_release_internals.py + tests/test_update.py and
# the ``_cctally()._release_read_latest_release_version()`` call in
# bin/_cctally_release.py). The canonical name is
# ``_read_latest_changelog_version`` on _lib_changelog; the alias keeps
# the historical cctally-namespace name reachable for the monkeypatch
# surface those tests depend on.
_release_read_latest_release_version = _lib_changelog._read_latest_changelog_version

_lib_aggregators = _load_sibling("_lib_aggregators")
BucketUsage = _lib_aggregators.BucketUsage
CodexBucketUsage = _lib_aggregators.CodexBucketUsage
CodexSessionUsage = _lib_aggregators.CodexSessionUsage
ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
_aggregate_buckets = _lib_aggregators._aggregate_buckets
_aggregate_daily = _lib_aggregators._aggregate_daily
_aggregate_daily_by_project = _lib_aggregators._aggregate_daily_by_project
_aggregate_monthly = _lib_aggregators._aggregate_monthly
_aggregate_weekly = _lib_aggregators._aggregate_weekly
_aggregate_codex_buckets = _lib_aggregators._aggregate_codex_buckets
_aggregate_codex_daily = _lib_aggregators._aggregate_codex_daily
_aggregate_codex_monthly = _lib_aggregators._aggregate_codex_monthly
_aggregate_codex_weekly = _lib_aggregators._aggregate_codex_weekly
_aggregate_codex_sessions = _lib_aggregators._aggregate_codex_sessions
_session_path_parts = _lib_aggregators._session_path_parts
_aggregate_claude_sessions = _lib_aggregators._aggregate_claude_sessions

# View-model kernel — per-domain frozen dataclasses + builders.
# Spec: docs/superpowers/specs/2026-05-17-view-model-unification-design.md.
_lib_view_models = _load_sibling("_lib_view_models")
DailyView = _lib_view_models.DailyView
build_daily_view = _lib_view_models.build_daily_view
MonthlyView = _lib_view_models.MonthlyView
build_monthly_view = _lib_view_models.build_monthly_view
WeeklyView = _lib_view_models.WeeklyView
build_weekly_view = _lib_view_models.build_weekly_view
TrendView = _lib_view_models.TrendView
build_trend_view = _lib_view_models.build_trend_view
SessionsView = _lib_view_models.SessionsView
build_sessions_view = _lib_view_models.build_sessions_view
BlocksView = _lib_view_models.BlocksView
build_blocks_view = _lib_view_models.build_blocks_view
build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
ForecastView = _lib_view_models.ForecastView
build_forecast_view = _lib_view_models.build_forecast_view
CodexDailyView = _lib_view_models.CodexDailyView
build_codex_daily_view = _lib_view_models.build_codex_daily_view
CodexMonthlyView = _lib_view_models.CodexMonthlyView
build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
CodexWeeklyView = _lib_view_models.CodexWeeklyView
build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
CodexSessionView = _lib_view_models.CodexSessionView
build_codex_session_view = _lib_view_models.build_codex_session_view

_lib_render = _load_sibling("_lib_render")
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
_render_blocks_table = _lib_render._render_blocks_table
_render_active_block_box = _lib_render._render_active_block_box
_daily_row_dict = _lib_render._daily_row_dict
_bucket_totals_dict = _lib_render._bucket_totals_dict
_bucket_to_json = _lib_render._bucket_to_json
_bucket_by_project_to_json = _lib_render._bucket_by_project_to_json
_weekly_to_json = _lib_render._weekly_to_json
_daily_compact_split = _lib_render._daily_compact_split
_monthly_compact_split = _lib_render._monthly_compact_split
_codex_daily_bucket_display = _lib_render._codex_daily_bucket_display
_codex_monthly_bucket_display = _lib_render._codex_monthly_bucket_display
_codex_last_activity_iso = _lib_render._codex_last_activity_iso
_emit_codex_no_data = _lib_render._emit_codex_no_data
_codex_models_dict = _lib_render._codex_models_dict
_codex_bucket_to_json = _lib_render._codex_bucket_to_json
_codex_sessions_to_json = _lib_render._codex_sessions_to_json
_claude_sessions_to_json = _lib_render._claude_sessions_to_json
_render_bucket_table = _lib_render._render_bucket_table
_render_weekly_table = _lib_render._render_weekly_table
_render_codex_bucket_table = _lib_render._render_codex_bucket_table
_render_codex_session_table = _lib_render._render_codex_session_table
_render_claude_session_table = _lib_render._render_claude_session_table
_project_disambiguate_labels = _lib_render._project_disambiguate_labels
_render_project_table = _lib_render._render_project_table
_five_hour_blocks_to_json = _lib_render._five_hour_blocks_to_json
_render_five_hour_blocks_table = _lib_render._render_five_hour_blocks_table

# Eager re-export of bin/_cctally_share.py (eager sibling holding the
# share destination/emit path + the _build_*_snapshot builders + the
# _share_* helpers). Loaded here, after _lib_render / _lib_display_tz /
# _lib_changelog (its three sibling deps), so its module-top imports
# resolve. Every moved symbol is re-bound onto cctally's __dict__ so the
# cmd_* handlers' bare `_share_render_and_emit(...)` / `_build_*_snapshot(...)`
# calls, the staying _build_budget_snapshot dispatch, and the dashboard's
# `sys.modules["cctally"].X` share thunks all resolve to the same objects.
_cctally_share = _load_sibling("_cctally_share")
_DOWNLOADS_HOME_HINT_EMITTED = _cctally_share._DOWNLOADS_HOME_HINT_EMITTED
_share_resolve_download_dir = _cctally_share._share_resolve_download_dir
_share_unique_path = _cctally_share._share_unique_path
_resolve_destination = _cctally_share._resolve_destination
_emit = _cctally_share._emit
_share_load_lib = _cctally_share._share_load_lib
_share_now_utc = _cctally_share._share_now_utc
_share_now_utc_iso = _cctally_share._share_now_utc_iso
_SHARE_HISTORY_RING_CAP = _cctally_share._SHARE_HISTORY_RING_CAP
_share_history_recipe_id = _cctally_share._share_history_recipe_id
_share_resolve_version = _cctally_share._share_resolve_version
_share_period_label = _cctally_share._share_period_label
_share_parse_date_to_dt = _cctally_share._share_parse_date_to_dt
_share_display_tz_label = _cctally_share._share_display_tz_label
_build_report_snapshot = _cctally_share._build_report_snapshot
_build_daily_snapshot = _cctally_share._build_daily_snapshot
_build_monthly_snapshot = _cctally_share._build_monthly_snapshot
_build_weekly_snapshot = _cctally_share._build_weekly_snapshot
_build_forecast_snapshot = _cctally_share._build_forecast_snapshot
_build_project_snapshot = _cctally_share._build_project_snapshot
_build_five_hour_blocks_snapshot = _cctally_share._build_five_hour_blocks_snapshot
_session_disambiguate_labels = _cctally_share._session_disambiguate_labels
_build_session_snapshot = _cctally_share._build_session_snapshot
_share_iso = _cctally_share._share_iso
_share_render_and_emit = _cctally_share._share_render_and_emit
_share_open_file = _cctally_share._share_open_file

# Eager re-export of bin/_cctally_setup.py (lazy I/O sibling that loads
# at startup to keep `ns["cmd_setup"](...)` / `ns["_setup_X"](...)`
# direct-dict test patterns working — dict-key access on `mod.__dict__`
# (the form tests/test_setup_legacy_migrate.py uses via the
# `ns = load_script()` fixture) bypasses any module-level `__getattr__`.
# Eager binding here makes the symbols visible on the dict regardless
# of how callers reach them. Same pattern as the `_lib_*` modules above.
_cctally_setup = _load_sibling("_cctally_setup")
_settings_merge_install = _cctally_setup._settings_merge_install
_settings_merge_uninstall = _cctally_setup._settings_merge_uninstall
_settings_merge_unwire_legacy = _cctally_setup._settings_merge_unwire_legacy
_setup_resolve_repo_root = _cctally_setup._setup_resolve_repo_root
_setup_local_bin_dir = _cctally_setup._setup_local_bin_dir
_SetupSymlinkResult = _cctally_setup._SetupSymlinkResult
_setup_resolve_symlink_source = _cctally_setup._setup_resolve_symlink_source
_setup_resolve_hook_target = _cctally_setup._setup_resolve_hook_target
_setup_create_symlinks = _cctally_setup._setup_create_symlinks
_setup_repair_symlinks = _cctally_setup._setup_repair_symlinks
_setup_path_includes_local_bin = _cctally_setup._setup_path_includes_local_bin
_setup_symlink_is_retired = _cctally_setup._setup_symlink_is_retired
_setup_is_brew_install = _cctally_setup._setup_is_brew_install
_setup_shell_rc_hint = _cctally_setup._setup_shell_rc_hint
_setup_detect_legacy_snippet = _cctally_setup._setup_detect_legacy_snippet
_setup_detect_legacy_bespoke_hooks = _cctally_setup._setup_detect_legacy_bespoke_hooks
_legacy_resolve_backup_dir = _cctally_setup._legacy_resolve_backup_dir
_legacy_move_files_to_backup = _cctally_setup._legacy_move_files_to_backup
_legacy_stop_active_poller = _cctally_setup._legacy_stop_active_poller
_legacy_cleanup_tmp_sentinels = _cctally_setup._legacy_cleanup_tmp_sentinels
_setup_read_legacy_prompt_input = _cctally_setup._setup_read_legacy_prompt_input
_setup_legacy_decide_action = _cctally_setup._setup_legacy_decide_action
_setup_oauth_token_present = _cctally_setup._setup_oauth_token_present
_setup_count_hook_entries = _cctally_setup._setup_count_hook_entries
_setup_data_dir_size_bytes = _cctally_setup._setup_data_dir_size_bytes
_setup_format_bytes = _cctally_setup._setup_format_bytes
_setup_recent_log_stats = _cctally_setup._setup_recent_log_stats
_setup_compute_symlink_state = _cctally_setup._setup_compute_symlink_state
_setup_status = _cctally_setup._setup_status
_setup_uninstall = _cctally_setup._setup_uninstall
_setup_dry_run = _cctally_setup._setup_dry_run
_setup_emit_text = _cctally_setup._setup_emit_text
_setup_render_legacy_prompt = _cctally_setup._setup_render_legacy_prompt
_setup_install = _cctally_setup._setup_install
cmd_setup = _cctally_setup.cmd_setup
# C10 (#125 Batch E): settings/hook glue moved into _cctally_setup.py.
# Eager re-exports: SetupError class-identity for doctor's `except c.SetupError`;
# cmd_repair_symlinks for the parser's set_defaults(func=c.cmd_repair_symlinks);
# the 3 settings helpers + _is_cctally_hook_command for doctor + the
# setitem(ns, ...) test contract (setup keeps reaching these via c.).
SetupError = _cctally_setup.SetupError
_is_cctally_hook_command = _cctally_setup._is_cctally_hook_command
_load_claude_settings = _cctally_setup._load_claude_settings
_backup_claude_settings = _cctally_setup._backup_claude_settings
_write_claude_settings_atomic = _cctally_setup._write_claude_settings_atomic
cmd_repair_symlinks = _cctally_setup.cmd_repair_symlinks

# Eager re-export of bin/_cctally_alerts.py — the dispatch I/O peers
# (`_alerts_log_path`, `_dispatch_alert_notification`) are called by
# bare name from non-extracted `cmd_record_usage` and the dashboard
# alerts/test handler, so they must live in cctally's namespace
# (bare-name lookups inside cctally bypass PEP 562). The
# `cmd_alerts_test` thunk is re-exported too so `argparse`'s
# `set_defaults(func=cmd_alerts_test)` and the harness's
# `m._dispatch_alert_notification(...)` SourceFileLoader-attribute
# access both resolve unchanged.
_cctally_alerts = _load_sibling("_cctally_alerts")
_alerts_log_path = _cctally_alerts._alerts_log_path
_dispatch_alert_notification = _cctally_alerts._dispatch_alert_notification
cmd_alerts_test = _cctally_alerts.cmd_alerts_test

# Eager re-export of bin/_cctally_sync_week.py — `cmd_sync_week` is
# called by bare name from non-extracted `cmd_record_usage` (the
# milestone cost-sync path) and `cmd_report` (the lazy-sync path),
# so it must live in cctally's namespace (bare-name lookups inside
# cctally bypass PEP 562).
_cctally_sync_week = _load_sibling("_cctally_sync_week")
cmd_sync_week = _cctally_sync_week.cmd_sync_week

# Eager re-export of bin/_cctally_project.py — `cmd_project` is invoked
# only via the parser's `set_defaults(func=c.cmd_project)`, which resolves
# through cctally's namespace, so it must live here. The 4 helpers stay
# module-private (no external caller).
_cctally_project = _load_sibling("_cctally_project")
cmd_project = _cctally_project.cmd_project
# Shared per-project cost compute (#19/#121, spec §7.1). Re-exported so the
# per-project budget display (cmd_budget) and the Task 3 firing path reach it
# via the cctally namespace.
_sum_cost_by_project = _cctally_project._sum_cost_by_project
# Shared per-project label disambiguation (#130). Re-exported so the budget
# display, firing path, and dashboard SSE envelope reach the SAME primitive.
_project_budget_labels = _cctally_project._project_budget_labels

# Eager re-export of bin/_cctally_pricing_check.py — `cmd_pricing_check`
# is invoked via the parser's `set_defaults(func=c.cmd_pricing_check)`,
# which resolves through cctally's namespace, so it must live here.
_cctally_pricing_check = _load_sibling("_cctally_pricing_check")
cmd_pricing_check = _cctally_pricing_check.cmd_pricing_check
# _pricing_observed_models moved to bin/_cctally_pricing_check.py (#125
# Batch E, C7). Re-exported so doctor (c._pricing_observed_models) resolves.
_pricing_observed_models = _cctally_pricing_check._pricing_observed_models
_PRICING_SCAN_DEFAULT_WINDOW = _cctally_pricing_check._PRICING_SCAN_DEFAULT_WINDOW

# Eager re-export of bin/_cctally_doctor.py — `cmd_doctor` is the parser's
# `set_defaults(func=c.cmd_doctor)` target; `doctor_gather_state` is a
# PATCHABLE binding reached by the dashboard shim
# (sys.modules["cctally"].doctor_gather_state) and `ns["doctor_gather_state"]`
# tests, so it must be a re-exported module global here.
_cctally_doctor = _load_sibling("_cctally_doctor")
doctor_gather_state = _cctally_doctor.doctor_gather_state
cmd_doctor = _cctally_doctor.cmd_doctor

# config.json reader/writer/lock + validators + `cctally config` entry point.
# Eager-loaded with per-symbol re-export so bare-name callers in cctally
# (dashboard `/api/settings`, `cmd_record_usage` reading `load_config()`,
# refresh-usage gating, update-check predicate, sync-week, …) resolve
# unchanged, and `monkeypatch.setitem(ns, "load_config", …)` test patches
# still propagate via cctally's namespace.
_cctally_config = _load_sibling("_cctally_config")
_CONFIG_CORRUPT_WARNED = _cctally_config._CONFIG_CORRUPT_WARNED
_warn_config_corrupt_once = _cctally_config._warn_config_corrupt_once
_default_config_data = _cctally_config._default_config_data
_try_read_config = _cctally_config._try_read_config
config_writer_lock = _cctally_config.config_writer_lock
load_config = _cctally_config.load_config
_load_config_unlocked = _cctally_config._load_config_unlocked
save_config = _cctally_config.save_config
ALLOWED_CONFIG_KEYS = _cctally_config.ALLOWED_CONFIG_KEYS
cmd_config = _cctally_config.cmd_config
_config_known_value = _cctally_config._config_known_value
_cmd_config_get = _cctally_config._cmd_config_get
_cmd_config_set = _cctally_config._cmd_config_set
_cmd_config_unset = _cctally_config._cmd_config_unset


# `cctally refresh-usage` + the OAuth-usage fetch/render/config surface,
# the in-process refresh helper consumed by the dashboard `POST /api/sync`
# handler, and the hook-tick OAuth refresh path. Eager-loaded with
# per-symbol re-export so every test that reaches in via `ns["X"]`
# direct-dict access (extensive — tests/test_refresh_usage_*, test_oauth_*,
# test_ua_discovery, test_hook_tick_*) still works, and exception class
# identity is preserved across raise (inside the sibling) / except
# (outside, in non-extracted callers).
_cctally_refresh = _load_sibling("_cctally_refresh")
RefreshUsageNetworkError = _cctally_refresh.RefreshUsageNetworkError
RefreshUsageRateLimitError = _cctally_refresh.RefreshUsageRateLimitError
RefreshUsageMalformedError = _cctally_refresh.RefreshUsageMalformedError
_RefreshUsageResult = _cctally_refresh._RefreshUsageResult
_OAUTH_USAGE_URL = _cctally_refresh._OAUTH_USAGE_URL
_CC_SEMVER_RE = _cctally_refresh._CC_SEMVER_RE
_parse_cc_semver = _cctally_refresh._parse_cc_semver
CLAUDE_CODE_UA_FALLBACK_VERSION = _cctally_refresh.CLAUDE_CODE_UA_FALLBACK_VERSION
_discover_cc_version = _cctally_refresh._discover_cc_version
_resolve_oauth_usage_user_agent = _cctally_refresh._resolve_oauth_usage_user_agent
_fetch_oauth_usage = _cctally_refresh._fetch_oauth_usage
_REFRESH_USAGE_ANSI = _cctally_refresh._REFRESH_USAGE_ANSI
_render_refresh_usage_text = _cctally_refresh._render_refresh_usage_text
_serialize_refresh_usage_json = _cctally_refresh._serialize_refresh_usage_json
OauthUsageConfigError = _cctally_refresh.OauthUsageConfigError
_OAUTH_USAGE_DEFAULTS = _cctally_refresh._OAUTH_USAGE_DEFAULTS
_OAUTH_USAGE_THROTTLE_MIN = _cctally_refresh._OAUTH_USAGE_THROTTLE_MIN
_OAUTH_USAGE_THROTTLE_MAX = _cctally_refresh._OAUTH_USAGE_THROTTLE_MAX
_OAUTH_USAGE_USER_AGENT_MAX_LEN = _cctally_refresh._OAUTH_USAGE_USER_AGENT_MAX_LEN
_get_oauth_usage_config = _cctally_refresh._get_oauth_usage_config
_STATUSLINE_OAUTH_CACHE = _cctally_refresh._STATUSLINE_OAUTH_CACHE
_bust_statusline_cache = _cctally_refresh._bust_statusline_cache
_freshness_label = _cctally_refresh._freshness_label
_cmd_refresh_usage_handle_rate_limit = _cctally_refresh._cmd_refresh_usage_handle_rate_limit
_refresh_usage_inproc = _cctally_refresh._refresh_usage_inproc
_nudge_dashboard_repaint = _cctally_refresh._nudge_dashboard_repaint
cmd_refresh_usage = _cctally_refresh.cmd_refresh_usage
_hook_tick_oauth_refresh = _cctally_refresh._hook_tick_oauth_refresh
_hook_tick_make_mock_refresh = _cctally_refresh._hook_tick_make_mock_refresh


# Stats.db / cache.db migration framework, dispatcher, error-banner
# render, the three production migration handlers + test-only
# registration block, and the `cctally db status/skip/unskip` surface.
# Eager-loaded with per-symbol re-export so bare-name callers
# (open_db / open_cache_db calling _run_pending_migrations; pre-DB
# banner render on every interactive command; cmd_db_* argparse
# dispatch) all resolve unchanged. Migration handlers register their
# decorators ONCE at _cctally_db's module-load time — subsequent
# bin/cctally re-imports under SourceFileLoader hit the sys.modules
# cache and reuse the populated registry, preserving the
# "len(registry) == NNN" ordering invariant per-DB.
_cctally_db = _load_sibling("_cctally_db")
_MIGRATION_IDENT_RE = _cctally_db._MIGRATION_IDENT_RE
add_column_if_missing = _cctally_db.add_column_if_missing
_MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
Migration = _cctally_db.Migration
DowngradeDetected = _cctally_db.DowngradeDetected
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
_make_migration_decorator = _cctally_db._make_migration_decorator
stats_migration = _cctally_db.stats_migration
cache_migration = _cctally_db.cache_migration
_LEGACY_MARKER_ALIASES_BY_DB = _cctally_db._LEGACY_MARKER_ALIASES_BY_DB
_bootstrap_rename_legacy_markers = _cctally_db._bootstrap_rename_legacy_markers
_stamp_applied = _cctally_db._stamp_applied
_run_pending_migrations = _cctally_db._run_pending_migrations
_recover_version_ahead = _cctally_db._recover_version_ahead
_log_migration_error = _cctally_db._log_migration_error
_clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
_render_migration_error_banner = _cctally_db._render_migration_error_banner
_BANNER_SUPPRESSED_COMMANDS = _cctally_db._BANNER_SUPPRESSED_COMMANDS
_print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
cmd_db_status = _cctally_db.cmd_db_status
_db_status_for = _cctally_db._db_status_for
_db_status_failed_names_from_log = _cctally_db._db_status_failed_names_from_log
_db_status_format_row = _cctally_db._db_status_format_row
_db_resolve_migration_name = _cctally_db._db_resolve_migration_name
_db_path_for_label = _cctally_db._db_path_for_label
cmd_db_skip = _cctally_db.cmd_db_skip
cmd_db_unskip = _cctally_db.cmd_db_unskip
cmd_db_recover = _cctally_db.cmd_db_recover


# Session-entry cache subsystem (Claude + Codex) — read-through delta
# ingest, schema + cache.db migration dispatcher, in-range SELECT
# helpers, direct-JSONL fallbacks, and the ``cache-sync`` subcommand.
# Eager-loaded with per-symbol re-export so the ~90 bare-name callers
# in cctally (every JSONL-reading subcommand, the hot ``record-usage``
# / ``hook-tick`` tick path, the dashboard panels, the share render
# kernel) resolve unchanged, and ``monkeypatch.setitem(ns, X, …)``
# test patches against `get_claude_session_entries` /
# `_JoinedClaudeEntry` / `open_cache_db` continue to propagate via
# cctally's namespace. The sibling itself does ``_load_lib`` of
# ``_lib_jsonl`` (UsageEntry / CodexEntry / iter helpers) and
# ``_cctally_db`` (add_column_if_missing / _run_pending_migrations /
# _CACHE_MIGRATIONS) at its own module-load time — both are no-ops
# when bin/cctally already imported them above, but they make the
# sibling self-contained for isolated test loads.
_cctally_cache = _load_sibling("_cctally_cache")
ProjectKey = _cctally_cache.ProjectKey
_resolve_project_key = _cctally_cache._resolve_project_key
_discover_codex_session_files = _cctally_cache._discover_codex_session_files
IngestStats = _cctally_cache.IngestStats
_progress_stderr = _cctally_cache._progress_stderr
_ensure_session_files_row = _cctally_cache._ensure_session_files_row
sync_cache = _cctally_cache.sync_cache
backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
backfill_ai_titles = _cctally_cache.backfill_ai_titles
iter_entries = _cctally_cache.iter_entries
_collect_entries_direct = _cctally_cache._collect_entries_direct
_JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
get_claude_session_entries = _cctally_cache.get_claude_session_entries
_direct_parse_claude_session_entries = _cctally_cache._direct_parse_claude_session_entries
CodexIngestStats = _cctally_cache.CodexIngestStats
_progress_codex_stderr = _cctally_cache._progress_codex_stderr
sync_codex_cache = _cctally_cache.sync_codex_cache
iter_codex_entries = _cctally_cache.iter_codex_entries
_collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
get_codex_entries = _cctally_cache.get_codex_entries
_sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
get_entries = _cctally_cache.get_entries
open_cache_db = _cctally_cache.open_cache_db
cmd_cache_sync = _cctally_cache.cmd_cache_sync


# Record-usage / hook-tick hot-path subsystem — the runtime path that
# fires on every Claude Code statusline tick (``record-usage``) and
# every CC hook fire (``hook-tick``), plus the percent-crossing
# milestone detector and the 5h-block rollup helper invoked from
# inside ``record-usage``. Eager-loaded with per-symbol re-export so
# the ~30 bare-name callers in cctally (CLI dispatch, dashboard panel
# data binding, hook-tick OAuth path, refresh-usage in-process re-fire)
# resolve unchanged, and the test-side ``monkeypatch.setitem(ns, X, …)``
# patches on ``cmd_record_usage`` / ``_normalize_percent`` /
# ``_derive_week_from_payload`` propagate via cctally's namespace.
# Load order is AFTER ``_cctally_cache`` because the moved bodies call
# ``open_cache_db`` / ``sync_cache`` / ``get_claude_session_entries``
# (all in ``_cctally_cache``), and AFTER ``_cctally_refresh`` because
# ``cmd_hook_tick`` calls ``_hook_tick_oauth_refresh`` /
# ``_hook_tick_make_mock_refresh`` / ``_get_oauth_usage_config``.
# Path constants (``APP_DIR``, ``HOOK_TICK_*``) stay in bin/cctally
# and are accessed via the ``c = _cctally()`` call-time accessor
# inside each moved function (spec §5.5 precedent set in Phase D #17).
_cctally_record = _load_sibling("_cctally_record")
_PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
_normalize_percent = _cctally_record._normalize_percent
maybe_record_milestone = _cctally_record.maybe_record_milestone
maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
maybe_record_codex_budget_milestone = _cctally_record.maybe_record_codex_budget_milestone
maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
_weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
_compute_block_totals = _cctally_record._compute_block_totals
maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
cmd_record_usage = _cctally_record.cmd_record_usage
_hook_tick_log_line = _cctally_record._hook_tick_log_line
_hook_tick_log_rotate_if_needed = _cctally_record._hook_tick_log_rotate_if_needed
_hook_tick_throttle_age_seconds = _cctally_record._hook_tick_throttle_age_seconds
_hook_tick_throttle_touch = _cctally_record._hook_tick_throttle_touch
_hook_tick_read_stdin_event = _cctally_record._hook_tick_read_stdin_event
_hook_tick_session_short = _cctally_record._hook_tick_session_short
_hook_tick_format_log_line = _cctally_record._hook_tick_format_log_line
cmd_hook_tick = _cctally_record.cmd_hook_tick
_safe_float = _cctally_record._safe_float
_validate_date_optional = _cctally_record._validate_date_optional
DerivedWeekWindow = _cctally_record.DerivedWeekWindow
_coerce_payload_captured_at = _cctally_record._coerce_payload_captured_at
_derive_week_from_payload = _cctally_record._derive_week_from_payload
insert_usage_snapshot = _cctally_record.insert_usage_snapshot
_saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row

# Phase F #21: ``bin/_cctally_update.py`` is loaded eagerly per spec §4.8
# carve-out — ``tests/test_update.py`` reaches into ~30 symbols via
# ``ns["X"]`` direct-dict reads and ``monkeypatch.setitem(ns, "X", …)``
# mutations (PEP 562 does NOT fire on dict-key access). The eager
# re-export below means cctally's ``__dict__`` carries the same function /
# class / constant objects the sibling defines; cross-module callers
# (dashboard ``/api/update*`` handlers, ``main()`` banner hook, doctor's
# safety check, ``_cctally_config``'s update.check validator) all resolve
# the same identity via ``cctally.X``, so ``except UpdateError`` /
# ``isinstance(_, InstallMethod)`` / etc. work uniformly. Internal
# cross-calls from one moved body to another moved body route through
# the ``c = _cctally()`` accessor at call time so
# ``setitem(ns, "_do_update_check", mock)`` propagates into
# ``cmd_update_check_internal`` / ``_DashboardUpdateCheckThread.run``.
# Path constants (UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH,
# UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH,
# UPDATE_CHECK_LAST_FETCH_PATH) were promoted to _cctally_core
# 2026-05-22 (#84). Moved bodies in _cctally_update read them via
# call-time ``_cctally_core.UPDATE_STATE_PATH`` etc.; tests patch via
# ``monkeypatch.setattr(_cctally_core, "X", v)`` (the conftest
# ``redirect_paths()`` helper covers the full set). The legacy
# ``setitem(ns, …)`` pattern is forbidden by
# ``test_no_old_style_test_patches_for_promoted_globals``.
_cctally_update = _load_sibling("_cctally_update")
UpdateError = _cctally_update.UpdateError
UpdateValidationError = _cctally_update.UpdateValidationError
UpdateInProgressError = _cctally_update.UpdateInProgressError
UpdateCheckNetworkError = _cctally_update.UpdateCheckNetworkError
UpdateCheckRateLimited = _cctally_update.UpdateCheckRateLimited
UpdateCheckHTTPError = _cctally_update.UpdateCheckHTTPError
UpdateCheckParseError = _cctally_update.UpdateCheckParseError
_UPDATE_CHECK_TTL_HOURS_MIN = _cctally_update._UPDATE_CHECK_TTL_HOURS_MIN
_UPDATE_CHECK_TTL_HOURS_MAX = _cctally_update._UPDATE_CHECK_TTL_HOURS_MAX
_normalize_update_check_enabled_value = _cctally_update._normalize_update_check_enabled_value
_validate_update_check_ttl_hours_value = _cctally_update._validate_update_check_ttl_hours_value
_UPDATE_STATE_SCHEMA_MAX = _cctally_update._UPDATE_STATE_SCHEMA_MAX
_UPDATE_SUPPRESS_SCHEMA_MAX = _cctally_update._UPDATE_SUPPRESS_SCHEMA_MAX
_load_update_state = _cctally_update._load_update_state
_save_update_state = _cctally_update._save_update_state
_load_update_suppress = _cctally_update._load_update_suppress
_save_update_suppress = _cctally_update._save_update_suppress
_read_lock_pid = _cctally_update._read_lock_pid
_acquire_update_lock = _cctally_update._acquire_update_lock
_release_update_lock = _cctally_update._release_update_lock
_rotate_update_log_if_needed = _cctally_update._rotate_update_log_if_needed
_log_update_event = _cctally_update._log_update_event
InstallMethod = _cctally_update.InstallMethod
_resolve_npm_prefix = _cctally_update._resolve_npm_prefix
_persist_npm_prefix_to_state = _cctally_update._persist_npm_prefix_to_state
_detect_install_method = _cctally_update._detect_install_method
_persist_install_method_to_state = _cctally_update._persist_install_method_to_state
_stamp_install_success_to_state = _cctally_update._stamp_install_success_to_state
_self_heal_current_version = _cctally_update._self_heal_current_version
_BREW_VERSION_RE_LIST = _cctally_update._BREW_VERSION_RE_LIST
_update_user_agent = _cctally_update._update_user_agent
_fetch_url = _cctally_update._fetch_url
_check_npm_latest_version = _cctally_update._check_npm_latest_version
_check_brew_latest_version = _cctally_update._check_brew_latest_version
_is_update_check_due = _cctally_update._is_update_check_due
_do_update_check = _cctally_update._do_update_check
_spawn_background_update_check = _cctally_update._spawn_background_update_check
cmd_update_check_internal = _cctally_update.cmd_update_check_internal
SKIP_USE_STATE_LATEST = _cctally_update.SKIP_USE_STATE_LATEST
_format_update_command = _cctally_update._format_update_command
_prerelease_note = _cctally_update._prerelease_note
_format_update_check_json = _cctally_update._format_update_check_json
_UPDATE_METHOD_HUMAN_LABEL = _cctally_update._UPDATE_METHOD_HUMAN_LABEL
_format_update_check_human = _cctally_update._format_update_check_human
_do_update_skip = _cctally_update._do_update_skip
_do_update_remind_later = _cctally_update._do_update_remind_later
_UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE = _cctally_update._UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE
_do_update_check_user = _cctally_update._do_update_check_user
_preflight_install = _cctally_update._preflight_install
_build_update_steps = _cctally_update._build_update_steps
_run_streaming = _cctally_update._run_streaming
_do_update_install = _cctally_update._do_update_install
_resolve_execvp_target = _cctally_update._resolve_execvp_target
UpdateWorker = _cctally_update.UpdateWorker
_DashboardUpdateCheckThread = _cctally_update._DashboardUpdateCheckThread
cmd_update = _cctally_update.cmd_update
_args_emit_json = _cctally_update._args_emit_json
_args_emit_machine_stdout = _cctally_update._args_emit_machine_stdout
_UPDATE_BANNER_EXTRA_SUPPRESSED = _cctally_update._UPDATE_BANNER_EXTRA_SUPPRESSED
_semver_gt = _cctally_update._semver_gt
_compute_effective_update_available = _cctally_update._compute_effective_update_available
_should_show_update_banner = _cctally_update._should_show_update_banner
_format_update_banner = _cctally_update._format_update_banner


# Phase F #22: ``bin/_cctally_dashboard.py`` is loaded eagerly per spec §4.8
# carve-out — ``tests/test_dashboard_*.py`` and ``tests/test_share_*.py``
# reach into ~25 dashboard symbols via ``ns["X"]`` direct-dict reads and
# ``monkeypatch.setitem(ns, "X", …)`` mutations (PEP 562 does NOT fire on
# dict-key access). The eager re-export below means cctally's
# ``__dict__`` carries the same function / class / constant objects the
# sibling defines; cross-module callers (e.g. ``cmd_config`` validating
# ``dashboard.bind`` via ``_validate_dashboard_bind_value``, the
# share-period override tests, the dashboard-handler unit tests) all
# resolve the same identity via ``cctally.X``, so ``isinstance`` /
# ``except`` / dataclass replace etc. work uniformly. Internal
# cross-calls from one moved body to another route through the
# ``c = _cctally()`` accessor or module-level shim at call time so
# ``setitem(ns, "_dashboard_build_weekly_periods", spy)`` propagates
# into ``_share_apply_period_override`` / ``DashboardHTTPHandler``
# request paths. Path constants (``STATIC_DIR``,
# ``_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS``) stay re-exported here so
# ``setitem(ns, "STATIC_DIR", tmp)`` in
# ``tests/test_dashboard_api_sync_refresh.py`` and the
# ``monkeypatch.setitem(ns, "_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS", …)``
# sites propagate transparently.
_cctally_dashboard = _load_sibling("_cctally_dashboard")
_DASHBOARD_BIND_SEMANTIC_ALIASES = _cctally_dashboard._DASHBOARD_BIND_SEMANTIC_ALIASES
_validate_dashboard_bind_value = _cctally_dashboard._validate_dashboard_bind_value
_resolve_dashboard_bind_for_runtime = _cctally_dashboard._resolve_dashboard_bind_for_runtime
_SHARE_PANELS_PERIOD_FIXED = _cctally_dashboard._SHARE_PANELS_PERIOD_FIXED
_SHARE_PANELS_PERIOD_OVERRIDABLE = _cctally_dashboard._SHARE_PANELS_PERIOD_OVERRIDABLE
_share_resolve_period = _cctally_dashboard._share_resolve_period
_share_custom_window_n = _cctally_dashboard._share_custom_window_n
_share_previous_period_delta = _cctally_dashboard._share_previous_period_delta
_share_apply_period_override = _cctally_dashboard._share_apply_period_override
_share_apply_content_toggles = _cctally_dashboard._share_apply_content_toggles
_SHARE_TOP_PROJECTS_BUILDER_CAP = _cctally_dashboard._SHARE_TOP_PROJECTS_BUILDER_CAP
_share_top_projects_for_range = _cctally_dashboard._share_top_projects_for_range
_share_all_projects_for_range = _cctally_dashboard._share_all_projects_for_range
_share_per_day_per_project_for_range = _cctally_dashboard._share_per_day_per_project_for_range
_share_per_block_per_project = _cctally_dashboard._share_per_block_per_project
_build_share_panel_data = _cctally_dashboard._build_share_panel_data
_share_empty_week_stub = _cctally_dashboard._share_empty_week_stub
_build_weekly_share_panel_data = _cctally_dashboard._build_weekly_share_panel_data
_build_current_week_share_panel_data = _cctally_dashboard._build_current_week_share_panel_data
_build_trend_share_panel_data = _cctally_dashboard._build_trend_share_panel_data
_build_daily_share_panel_data = _cctally_dashboard._build_daily_share_panel_data
_build_monthly_share_panel_data = _cctally_dashboard._build_monthly_share_panel_data
_build_forecast_share_panel_data = _cctally_dashboard._build_forecast_share_panel_data
_build_blocks_share_panel_data = _cctally_dashboard._build_blocks_share_panel_data
_build_sessions_share_panel_data = _cctally_dashboard._build_sessions_share_panel_data
_build_projects_share_panel_data = _cctally_dashboard._build_projects_share_panel_data
_SnapshotRef = _cctally_dashboard._SnapshotRef
SSEHub = _cctally_dashboard.SSEHub
STATIC_DIR = _cctally_dashboard.STATIC_DIR
_format_url = _cctally_dashboard._format_url
_discover_lan_ip = _cctally_dashboard._discover_lan_ip
_model_breakdowns_to_models = _cctally_dashboard._model_breakdowns_to_models
_compute_intensity_buckets = _cctally_dashboard._compute_intensity_buckets
_dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_periods
_dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
_build_block_detail = _cctally_dashboard._build_block_detail
_dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
_iso_z = _cctally_dashboard._iso_z
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
# Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
# can reach the dashboard sibling's aggregator via `c = _cctally()`.
_build_projects_envelope = _cctally_dashboard._build_projects_envelope
_projects_reset_memo = _cctally_dashboard._projects_reset_memo
_project_detail_for_window = _cctally_dashboard._project_detail_for_window
_handle_get_project_detail_impl = _cctally_dashboard._handle_get_project_detail_impl
_select_current_block_for_envelope = _cctally_dashboard._select_current_block_for_envelope
_build_alerts_envelope_array = _cctally_dashboard._build_alerts_envelope_array
snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
_session_detail_to_envelope = _cctally_dashboard._session_detail_to_envelope
_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = _cctally_dashboard._DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS
DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
cmd_dashboard = _cctally_dashboard.cmd_dashboard


# Eager re-export of bin/_cctally_five_hour.py — the three cmd_* are the
# parser's `set_defaults(func=c.cmd_*)` targets; `_load_recorded_five_hour_windows`
# is reached by the dashboard via sys.modules["cctally"]._load_recorded_five_hour_windows;
# `_format_block_start` by `_lib_render` via sys.modules["cctally"]._format_block_start;
# `_resolve_block_selector` / `_maybe_swap_active_block_to_canonical` / `cmd_blocks`
# by `ns["…"]` tests. So EVERY moved symbol must live here.
_cctally_five_hour = _load_sibling("_cctally_five_hour")
_resolve_block_selector = _cctally_five_hour._resolve_block_selector
_CANONICAL_WEIGHT_THRESHOLD = _cctally_five_hour._CANONICAL_WEIGHT_THRESHOLD
_select_non_overlapping_recorded_windows = _cctally_five_hour._select_non_overlapping_recorded_windows
_load_recorded_five_hour_windows = _cctally_five_hour._load_recorded_five_hour_windows
cmd_blocks = _cctally_five_hour.cmd_blocks
_maybe_swap_active_block_to_canonical = _cctally_five_hour._maybe_swap_active_block_to_canonical
_format_block_start = _cctally_five_hour._format_block_start
_format_hhmm_in_tz = _cctally_five_hour._format_hhmm_in_tz
_block_is_active = _cctally_five_hour._block_is_active
_latest_seven_day_and_window = _cctally_five_hour._latest_seven_day_and_window
_parse_date_filter = _cctally_five_hour._parse_date_filter
_load_breakdown = _cctally_five_hour._load_breakdown
cmd_five_hour_blocks = _cctally_five_hour.cmd_five_hour_blocks
cmd_five_hour_breakdown = _cctally_five_hour.cmd_five_hour_breakdown
_backfill_five_hour_blocks = _cctally_five_hour._backfill_five_hour_blocks

# Eager re-export of bin/_cctally_statusline.py — `cmd_statusline` is the
# parser's `set_defaults(func=c.cmd_statusline)` target; `_resolve_statusline_tz`
# is retrieved by tests off the cctally namespace. The two context dicts are
# re-exported HERE (not at their old L315 home) because this line must run
# after `_load_sibling("_cctally_statusline")`; no module-level reader of the
# dicts exists before this point (spec §5).
_cctally_statusline = _load_sibling("_cctally_statusline")
CLAUDE_MODEL_CONTEXT_WINDOWS = _cctally_statusline.CLAUDE_MODEL_CONTEXT_WINDOWS
CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY = _cctally_statusline.CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY
_resolve_statusline_tz = _cctally_statusline._resolve_statusline_tz
cmd_statusline = _cctally_statusline.cmd_statusline
_resolve_context_window = _cctally_statusline._resolve_context_window
_read_last_assistant_usage = _cctally_statusline._read_last_assistant_usage
_build_statusline_injections = _cctally_statusline._build_statusline_injections


# Eager re-export of bin/_cctally_codex.py — the four cmd_codex_* are the
# parser's `set_defaults(func=c.cmd_codex_*)` targets; the cost-stats cluster
# (`_compute_codex_cost_stats`, `_render_codex_cost_report`) + the resolvers
# (`_resolve_codex_speed`, `_detect_codex_fast_service_tier`) are retrieved by
# tests off the cctally namespace. So EVERY moved symbol must live here.
# (_DEBUG_REPORT_EMITTED is NOT re-exported — it stays defined as a
# bin/cctally module-global; the codex emitter mutates it via the accessor.)
_cctally_codex = _load_sibling("_cctally_codex")
_CodexCostSample = _cctally_codex._CodexCostSample
_CodexCostStats = _cctally_codex._CodexCostStats
_compute_codex_cost_stats = _cctally_codex._compute_codex_cost_stats
_render_codex_cost_report = _cctally_codex._render_codex_cost_report
_emit_codex_debug_samples_if_set = _cctally_codex._emit_codex_debug_samples_if_set
_resolve_codex_tz_name = _cctally_codex._resolve_codex_tz_name
_detect_codex_fast_service_tier = _cctally_codex._detect_codex_fast_service_tier
_resolve_codex_speed = _cctally_codex._resolve_codex_speed
cmd_codex_daily = _cctally_codex.cmd_codex_daily
cmd_codex_monthly = _cctally_codex.cmd_codex_monthly
cmd_codex_weekly = _cctally_codex.cmd_codex_weekly
cmd_codex_session = _cctally_codex.cmd_codex_session

# Eager re-export of bin/_cctally_reporting.py — the five cmd_* are the
# parser's `set_defaults(func=c.cmd_*)` targets; `cmd_session` + the others
# are retrieved by tests via `ns["…"]`. So EVERY moved symbol must live here.
_cctally_reporting = _load_sibling("_cctally_reporting")
_emit_daily_view_table_or_json = _cctally_reporting._emit_daily_view_table_or_json
cmd_daily = _cctally_reporting.cmd_daily
cmd_monthly = _cctally_reporting.cmd_monthly
cmd_weekly = _cctally_reporting.cmd_weekly
cmd_session = _cctally_reporting.cmd_session
cmd_range_cost = _cctally_reporting.cmd_range_cost   # parser: c.cmd_range_cost (_cctally_parser.py:1799)

# Eager re-export of bin/_cctally_forecast.py — cmd_report/cmd_forecast/cmd_budget
# are the parser's set_defaults(func=c.cmd_*) targets; the forecast cluster is
# reached by TUI/dashboard/refresh/view_models via shim/accessor, and tests
# retrieve any of these off the cctally namespace. So EVERY moved symbol lives
# here. _iso_z is LAST: it overrides the L943 `_iso_z = _cctally_dashboard._iso_z`
# binding so cctally._iso_z stays the FORECAST version (the dt-only variant
# _lib_diff_kernel + _cctally_five_hour depend on). This block MUST be after L943.
_cctally_forecast = _load_sibling("_cctally_forecast")
cmd_report = _cctally_forecast.cmd_report
cmd_forecast = _cctally_forecast.cmd_forecast
cmd_budget = _cctally_forecast.cmd_budget
ForecastInputs = _cctally_forecast.ForecastInputs
ForecastOutput = _cctally_forecast.ForecastOutput
BudgetRow = _cctally_forecast.BudgetRow
_resolve_forecast_now = _cctally_forecast._resolve_forecast_now
_fetch_current_week_snapshots = _cctally_forecast._fetch_current_week_snapshots
_apply_midweek_reset_override = _cctally_forecast._apply_midweek_reset_override
_resolve_current_budget_window = _cctally_forecast._resolve_current_budget_window
_build_budget_status_inputs = _cctally_forecast._build_budget_status_inputs
_build_vendor_budget_inputs = _cctally_forecast._build_vendor_budget_inputs
_select_dollars_per_percent = _cctally_forecast._select_dollars_per_percent
_assess_forecast_confidence = _cctally_forecast._assess_forecast_confidence
_pick_p_24h_ago = _cctally_forecast._pick_p_24h_ago
_load_forecast_inputs = _cctally_forecast._load_forecast_inputs
_compute_forecast = _cctally_forecast._compute_forecast
_parse_forecast_targets = _cctally_forecast._parse_forecast_targets
TOOL_VERSION = _cctally_forecast.TOOL_VERSION
_build_forecast_json_payload = _cctally_forecast._build_forecast_json_payload
_emit_forecast_json = _cctally_forecast._emit_forecast_json
_render_forecast_status_line = _cctally_forecast._render_forecast_status_line
_forecast_color_enabled = _cctally_forecast._forecast_color_enabled
_render_forecast_progress_bar = _cctally_forecast._render_forecast_progress_bar
_render_forecast_terminal = _cctally_forecast._render_forecast_terminal
_BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
# Single-sourced `--period` spellings (code-review #5): the parser's `choices=`
# derives from the same map the handler normalizer uses, so they can't drift.
_BUDGET_PERIOD_ALIASES = _cctally_forecast._BUDGET_PERIOD_ALIASES
_BUDGET_PERIOD_CHOICES = _cctally_forecast._BUDGET_PERIOD_CHOICES
_normalize_budget_period = _cctally_forecast._normalize_budget_period
_resolve_calendar_window = _cctally_forecast._resolve_calendar_window
_cmd_budget_set = _cctally_forecast._cmd_budget_set
_cmd_budget_unset = _cctally_forecast._cmd_budget_unset
# Per-project budget set/unset (#19/#121, spec §4.3 / §7).
_resolve_project_budget_target = _cctally_forecast._resolve_project_budget_target
_cmd_budget_set_project = _cctally_forecast._cmd_budget_set_project
_cmd_budget_unset_project = _cctally_forecast._cmd_budget_unset_project
_build_project_budget_rows = _cctally_forecast._build_project_budget_rows
_budget_render_unset = _cctally_forecast._budget_render_unset
_budget_verdict_ansi_code = _cctally_forecast._budget_verdict_ansi_code
_budget_render_terminal = _cctally_forecast._budget_render_terminal
_budget_alerts_line = _cctally_forecast._budget_alerts_line
_budget_emit_json = _cctally_forecast._budget_emit_json
_build_budget_snapshot = _cctally_forecast._build_budget_snapshot
_build_budget_no_data_snapshot = _cctally_forecast._build_budget_no_data_snapshot
_build_budget_no_budget_snapshot = _cctally_forecast._build_budget_no_budget_snapshot
# _iso_z LAST — overrides the L943 dashboard binding (forecast version wins).
_iso_z = _cctally_forecast._iso_z


RELEASE_HEADER_RE = re.compile(
    rf'^## \[({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
    rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*$',
    re.MULTILINE,
)

RELEASE_SUBSECTION_ORDER = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")


# `cmd_release` is NOT defined here: the release subcommand was extracted
# from the main `cctally` CLI and lives as a standalone entry point
# (bin/cctally-release). The implementation is in bin/_cctally_release.py.
# Tests that exercise `cmd_release` import it directly from
# `_cctally_release`.


# Files to scan when detecting the legacy status-line snippet (Section 5).
LEGACY_STATUSLINE_PATHS = (
    pathlib.Path.home() / ".claude" / "statusline-command.sh",
    pathlib.Path.home() / ".claude" / "statusline.sh",
    pathlib.Path.home() / ".claude" / "scripts" / "statusline.sh",
)
LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"

CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"


def _codex_home_roots() -> list[pathlib.Path]:
    """ALL raw $CODEX_HOME entries (comma-split), else [~/.codex].

    These are the entries upstream calls "CODEX_HOME roots" — the full
    comma-split list, BEFORE the sessions/-subdir rule decides home-vs-direct.
    The config reader (_detect_codex_fast_service_tier) reads <entry>/config.toml
    for EVERY entry returned here, including ones that turn out to be direct
    JSONL dirs; only _codex_session_roots() applies the sessions/-subdir
    narrowing. Blank/whitespace entries are dropped; each is expanduser'd
    (literal '~'; the shell already expands $VAR before we see the value)
    then made ABSOLUTE via .absolute() — a relative $CODEX_HOME (e.g.
    `./codexA`) would otherwise glob and store relative source_paths, which
    the cache-prune step cannot distinguish from synthetic relative-path
    fixture rows (issue #108; see the prune guard in _cctally_cache.py).
    Canonicalizing here makes every REAL ingested source_path absolute, so
    the prune's `isabs` fixture carve-out is correct by construction.
    Order is preserved (first-match-wins downstream).
    """
    raw = os.environ.get("CODEX_HOME", "").strip()
    if not raw:
        return [pathlib.Path.home() / ".codex"]
    roots: list[pathlib.Path] = []
    saw_part = False
    for part in raw.split(","):
        part = part.strip()
        if not part:
            continue
        saw_part = True
        try:
            # expanduser() raises RuntimeError on a malformed `~user` entry
            # (e.g. `~nonexistentuser/x` — the user doesn't exist). Drop the
            # bad entry rather than aborting the whole command, so valid
            # roots beside it survive. .absolute() canonicalizes a relative
            # entry (e.g. `./codexA`) against cwd so real ingested source_paths
            # are always absolute (issue #108 — keeps the cache-prune correct).
            roots.append(pathlib.Path(part).expanduser().absolute())
        except (RuntimeError, OSError, ValueError):
            continue
    # Fall back to the default home ONLY when the variable is unset or carries
    # no non-blank entry at all. An explicit-but-all-invalid value (e.g. a lone
    # `~baduser` that expanduser() drops) yields [] — respect the override and
    # read nothing rather than silently reading the default account (issue
    # #108). A plain dead path like `/typo` already reaches [] via the is_dir()
    # filter in _codex_session_roots(); this aligns the `~user` flavor with it.
    if not saw_part:
        return [pathlib.Path.home() / ".codex"]
    return roots


def _codex_session_roots() -> list[pathlib.Path]:
    """Directories to walk for *.jsonl, applying the sessions/-subdir rule.

    For each home root r (in $CODEX_HOME order):
      (r / "sessions").is_dir()  -> r / "sessions"   (Codex home)
      elif r.is_dir()            -> r                 (direct JSONL dir)
      else                       -> skipped
    Exact-duplicate roots are de-duped (first occurrence kept). File-level
    de-dup for overlapping/prefix roots happens in the discovery walkers
    (set of absolute paths); this function only collapses identical roots.
    """
    out: list[pathlib.Path] = []
    seen: set[pathlib.Path] = set()
    for r in _codex_home_roots():
        sess = r / "sessions"
        if sess.is_dir():
            cand = sess
        elif r.is_dir():
            cand = r
        else:
            continue
        if cand in seen:
            continue
        seen.add(cand)
        out.append(cand)
    return out


# Note: `_cctally_db` reads its four path constants
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
# `_cctally_core.X` at call time — the canonical sibling pattern after
# the data-globals promotion (2026-05-22, issue #84). The previous
# eager-seed block here is no longer needed: `_cctally_core` is the
# single source of truth, and `monkeypatch.setattr(_cctally_core, "X",
# v)` propagates into every reader without a sibling-side mirror.
#
# Note: `_cctally_cache.py` reaches `APP_DIR` / `CACHE_DB_PATH` /
# `CACHE_LOCK_PATH` / `CACHE_LOCK_CODEX_PATH` via call-time
# `_cctally_core.X` (promoted 2026-05-22, #84). Only `CODEX_SESSIONS_DIR`
# is out of scope for #84 — that one is still read via the
# `c = _cctally()` accessor and lives in this file.

# === Update subcommand (Section 1 of update-subcommand spec) ===
# Non-path constants for the `cctally update` feature. Path constants
# now live in bin/_cctally_core.py (promoted 2026-05-22; see #84).
UPDATE_LOG_ROTATE_BYTES   = 1024 * 1024  # 1 MB; spec §1.5

UPDATE_NPM_REGISTRY_URL = os.environ.get(
    "CCTALLY_TEST_UPDATE_NPM_URL",
    "https://registry.npmjs.org/cctally/latest",
)
UPDATE_BREW_FORMULA_URL = os.environ.get(
    "CCTALLY_TEST_UPDATE_BREW_URL",
    "https://raw.githubusercontent.com/omrikais/homebrew-cctally/main/Formula/cctally.rb",
)

UPDATE_DEFAULT_TTL_HOURS  = 24
UPDATE_MAX_TTL_HOURS      = 720    # 30 days
UPDATE_NETWORK_TIMEOUT_S  = 5.0
UPDATE_NPM_PREFIX_TIMEOUT_S = 2.0
UPDATE_NPM_PREFIX_TTL_DAYS  = 7
UPDATE_DASHBOARD_CHECK_POLL_S = 30 * 60


def _migrate_legacy_data_dir() -> None:
    """One-shot: ~/.local/share/ccusage-subscription → ~/.local/share/cctally.

    Removable in a future major version once early users have been on
    cctally long enough that the legacy dir is gone everywhere.
    """
    # Dev-instance isolation (F2): the legacy ccusage-subscription rename is a
    # PROD-only concern. Skip it whenever the data dir was relocated away from
    # the canonical prod path — i.e. dev-checkout auto-detect (DEV_MODE) or an
    # explicit CCTALLY_DATA_DIR override — so a dev run (APP_DIR = cctally-dev)
    # or a per-branch override never hijacks the one-shot move into the wrong
    # dir. "not DEV_MODE and not CCTALLY_DATA_DIR" is exactly the prod-default
    # resolution branch, i.e. APP_DIR == ~/.local/share/cctally. Under the test
    # suppressor DEV_MODE is False and the existing migration tests (which pin
    # APP_DIR directly, no override) still exercise the move.
    if _cctally_core.DEV_MODE or os.environ.get("CCTALLY_DATA_DIR", "").strip():
        return
    if _cctally_core.APP_DIR.exists():
        return  # already migrated, or fresh install at the new path
    if not _cctally_core.LEGACY_APP_DIR.exists():
        return  # fresh install, no legacy data
    _cctally_core.APP_DIR.parent.mkdir(parents=True, exist_ok=True)
    os.rename(_cctally_core.LEGACY_APP_DIR, _cctally_core.APP_DIR)
    print(
        f"cctally: migrated data dir {_cctally_core.LEGACY_APP_DIR} -> {_cctally_core.APP_DIR}",
        file=sys.stderr,
    )


def _get_claude_data_dirs() -> list[pathlib.Path]:
    """Return Claude Code data directories containing a projects/ subdir."""
    env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
    if env_val:
        dirs = [pathlib.Path(p.strip()) for p in env_val.split(",") if p.strip()]
        result = [d for d in dirs if d.is_dir() and (d / "projects").is_dir()]
        if result:
            return result

    candidates = [
        pathlib.Path.home() / ".config" / "claude",
        pathlib.Path.home() / ".claude",
    ]
    return [c for c in candidates if c.is_dir() and (c / "projects").is_dir()]


def _discover_session_files(
    range_start: dt.datetime,
    project: str | None = None,
) -> list[pathlib.Path]:
    """Glob JSONL session files, filtering by mtime >= range_start."""
    claude_dirs = _get_claude_data_dirs()
    if not claude_dirs:
        eprint("[cost] no Claude data directories found")
        return []

    start_ts = range_start.timestamp()
    result: list[pathlib.Path] = []

    for claude_dir in claude_dirs:
        projects_dir = claude_dir / "projects"
        if project:
            # Recursive: Claude stores subagent/tool-results JSONLs under
            # <project>/<uuid>/subagents/ and <project>/<uuid>/tool-results/.
            # Non-recursive would diverge from the cache-path SQL pattern
            # `source_path LIKE %/projects/<slug>/%`.
            glob_pattern = f"{project}/**/*.jsonl"
        else:
            glob_pattern = "**/*.jsonl"
        for jsonl_path in projects_dir.glob(glob_pattern):
            if not jsonl_path.is_file():
                continue
            try:
                mtime = jsonl_path.stat().st_mtime
            except OSError:
                continue
            if mtime < start_ts:
                continue
            result.append(jsonl_path)

    return result


def _decode_escaped_cwd(dir_name: str) -> str:
    """Best-effort reverse of Claude's cwd->directory-name escape.

    Claude stores JSONL files under ~/.claude/projects/<escaped-cwd>/<uuid>.jsonl
    where `<escaped-cwd>` replaces path separators with '-' and adds a leading
    '-'. This function reverses that transform:
        "-Volumes-TRANSCEND-repos-foo" -> "/Volumes/TRANSCEND/repos/foo"

    Lossy: original path dashes become slashes. Preferred alternative at the
    caller site is to pull `cwd` from the JSONL itself when present.
    """
    if not dir_name:
        return ""
    stripped = dir_name.lstrip("-")
    return "/" + stripped.replace("-", "/")


def _sum_cost_for_range(
    start: dt.datetime,
    end: dt.datetime,
    mode: str = "auto",
    project: str | None = None,
    *,
    skip_sync: bool = False,
) -> float:
    """Sum USD cost of all Claude Code usage entries in [start, end].

    Pass `skip_sync=True` to read only the cache-as-of-now without running
    a JSONL ingest pass (for forecast --no-sync).
    """
    total = 0.0
    for entry in get_entries(start, end, project=project, skip_sync=skip_sync):
        total += _calculate_entry_cost(
            entry.model,
            entry.usage,
            mode=mode,
            cost_usd=entry.cost_usd,
        )
    return total


def _bridge_z_into_tz(args: argparse.Namespace,
                      config: "dict | None" = None) -> None:
    """Promote ``-z`` / ``--timezone`` onto ``args.tz`` when ``--tz`` is unset.

    Session A (spec §7.2) defines a 4-rung precedence for Claude display tz:
    ``--tz`` > ``-z --timezone`` > ``config.display.tz`` > host-local. The
    existing ``resolve_display_tz`` reads only ``args.tz`` and config; the
    minimal-invasive way to fold in ``-z`` without modifying the shared
    pure-fn layer is to set ``args.tz`` here when ``--tz`` wasn't
    supplied. This mutates the namespace and is safe because each cmd_*
    receives a fresh ``args`` per invocation.

    Internally delegates to :func:`_resolve_claude_tz_name`, which
    encodes the 4-rung precedence and returns the tz-name string (or
    ``None`` for host-local fallback). This wires the resolver into the
    production path so the 7-case test contract (spec §9.2a) is
    exercised end-to-end, not only by unit tests (Review-A P2-A).

    Validates the bridged rung-2 value via ``_argparse_tz`` so an
    invalid tz name from ``-z`` surfaces the same canonical error as
    ``--tz`` (argparse-time validation can't run on the alias because
    the alias flag is plain string, not ``type=_argparse_tz``). The
    resolver returns the raw string, so we revalidate here on the path
    that actually bridges into ``args.tz``.

    Error attribution is rung-aware (#90). A malformed value typed on
    the command line (rung-2, ``-z``/``--timezone``) is a usage error
    and hard-fails with the argparse-style message + exit 2. A malformed
    ``config.display.tz`` (rung-3) is NOT promoted or hard-failed here:
    it falls through to ``resolve_display_tz`` / ``get_display_tz_pref``
    downstream, which warn once and default to host-local (exit 0). That
    restores the pre-Session-A contract and matches how codex commands
    and the dashboard already treat a malformed persisted tz pref.
    """
    # Short-circuit: --tz already set wins all rungs (the resolver
    # returns args.tz unchanged anyway, but skipping avoids touching
    # the namespace and re-validating an already-canonical value).
    if getattr(args, "tz", None) is not None:
        return
    resolved = _resolve_claude_tz_name(args, config)
    if resolved is None:
        return
    # ``args.tz`` is None here (short-circuited above), so ``resolved``
    # came from rung-2 (``-z``/``--timezone``) iff ``args.timezone`` is
    # set; otherwise it is the rung-3 ``config.display.tz`` value.
    from_cli_alias = getattr(args, "timezone", None) is not None
    # Validate via _argparse_tz so an invalid --timezone surfaces the
    # canonical argparse-style error (matches --tz's type-check).
    try:
        canonical = _argparse_tz(resolved)
    except argparse.ArgumentTypeError as exc:
        if from_cli_alias:
            # Rung-2: a value the user typed on the command line. Surface
            # the same error --tz gives at parse time. We can't call
            # parser.error from here, so emit-and-raise via SystemExit(2)
            # for argparse-shape parity.
            eprint(f"cctally: argument -z/--timezone: {exc}")
            raise SystemExit(2) from exc
        # Rung-3: malformed config.display.tz with no CLI tz flag. Don't
        # mis-attribute the error to -z/--timezone or hard-fail (#90).
        # Leave args.tz unset; resolve_display_tz / get_display_tz_pref
        # warn once and default to host-local downstream (exit 0).
        return
    args.tz = canonical


def _resolve_claude_tz_name(args: argparse.Namespace,
                            config: "dict | None") -> "str | None":
    """Resolve display-tz precedence for Claude reporting commands.

    Returns the tz name string (or ``None`` for host-local fallback).
    Precedence (top wins):
      1. ``--tz`` flag (canonical cctally flag).
      2. ``-z`` / ``--timezone`` flag (ccusage-codex sharedArgs alias).
      3. ``config.display.tz`` (persisted user pref).
      4. ``None`` → host-local (existing fallback in ``resolve_display_tz``).

    Spec §7.2 (issue #86 Session A). Mirrors ``_resolve_codex_tz_name``'s
    structural shape for code-review familiarity, but the rung order
    diverges: codex's resolver falls back to upstream's ``--timezone``
    ONLY when neither ``--tz`` nor ``display.tz`` is set, while Claude's
    Session A contract puts ``-z --timezone`` ahead of ``display.tz``.
    Both shapes are intentional — the Codex helper preserves drop-in
    parity with upstream's ``ccusage-codex sharedArgs``; the Claude
    helper expresses the Session A precedence the spec promises.
    """
    tz = getattr(args, "tz", None)
    if tz is not None:
        return tz
    tzname = getattr(args, "timezone", None)
    if tzname is not None:
        return tzname
    display = (config or {}).get("display") or {}
    cfg_tz = display.get("tz")
    if cfg_tz:
        return cfg_tz
    return None


# ---------------------------------------------------------------------------
# Issue #89 — Real --debug diagnostic-sample emission
# ---------------------------------------------------------------------------
# Spec: docs/superpowers/specs/2026-05-23-issue-89-debug-sample-emission.md
# Replaces the §7.6.2 placeholder note with a real ccusage-parity
# "Pricing Mismatch Debug Report" on stderr.

# Pricing-mismatch debug kernel extracted to bin/_lib_pricing_debug.py
# (#125 Batch E, C9). _lib_pricing is already loaded above, so its
# `from _lib_pricing import ...` resolves. The 5 symbols are eager-
# re-exported here so `_emit_debug_samples_if_set` (below, stays inline)
# and `_cctally_diff.py` (c._compute_pricing_mismatch_stats /
# c._render_pricing_mismatch_report) + the mod.X unit tests resolve to the
# same objects. The shared `_DEBUG_REPORT_EMITTED` guard + the impure
# emitter intentionally STAY inline (codex/diff write the guard via c.).
_lib_pricing_debug = _load_sibling("_lib_pricing_debug")
_MismatchModelStat = _lib_pricing_debug._MismatchModelStat
_MismatchSample = _lib_pricing_debug._MismatchSample
_MismatchStats = _lib_pricing_debug._MismatchStats
_compute_pricing_mismatch_stats = _lib_pricing_debug._compute_pricing_mismatch_stats
_render_pricing_mismatch_report = _lib_pricing_debug._render_pricing_mismatch_report


_DEBUG_REPORT_EMITTED = False


def _emit_debug_samples_if_set(
    args,
    entries_or_loader,
    *,
    command_label: str,
) -> None:
    """Emit the §7.6.2 normative stderr report exactly once per process
    when ``args.debug`` is True (issue #89).

    ``entries_or_loader`` is either a list[UsageEntry] (eager) or a
    zero-arg callable returning list[UsageEntry] (deferred). The deferred
    form keeps the JSONL scan off the hot path on SQL-backed cmds whose
    --debug is rare.

    The report mirrors ccusage's ``printMismatchReport`` shape per spec
    §7.1.2; the one-time-per-process guard ensures a single CLI
    invocation that composes multiple cmd_* doesn't double-emit. The
    diff two-window case (``_emit_diff_debug_samples``) bypasses this
    guard internally for the second window then sets the guard at the
    end.
    """
    global _DEBUG_REPORT_EMITTED
    if _DEBUG_REPORT_EMITTED:
        return
    if not getattr(args, "debug", False):
        return
    sample_limit = int(getattr(args, "debug_samples", 5))
    # Negative values are rejected at argparse parse time via _nonneg_int
    # (§7.5); the helper assumes sample_limit >= 0.

    # P1.2 (issue #89 review-loop): only the loader call is wrapped — the
    # pure compute + render functions raising would be a programmer bug,
    # not a transient. On loader failure we degrade gracefully with a
    # one-line stderr notice and DO set the guard so a downstream cmd_*
    # composition doesn't retry (and re-emit) on the same transient.
    if callable(entries_or_loader):
        try:
            entries = entries_or_loader()
        except (sqlite3.DatabaseError, OSError) as exc:
            eprint(f"cctally --debug: report unavailable: {exc}")
            _DEBUG_REPORT_EMITTED = True
            return
    else:
        entries = entries_or_loader
    stats = _compute_pricing_mismatch_stats(entries)
    stats.command_label = command_label
    for line in _render_pricing_mismatch_report(stats, sample_limit):
        eprint(line)
    _DEBUG_REPORT_EMITTED = True


# ---------------------------------------------------------------------------
# Codex --debug report (issue #92).
#
# Codex JSONL carries NO recorded costUSD, so the Claude-side "recorded vs
# calculated mismatch" framing does not apply. The codex variant (chosen in
# #92's design Q&A, option 2; #89 spec §12 precedent) is a "Codex Pricing
# Debug Report": totals header (entries processed, models seen, total
# computed cost) + a "Sample Top Entries" block of the N highest
# computed-cost entries, each tagged ``Recorded cost: (none)``. Reuses the
# process-wide ``_DEBUG_REPORT_EMITTED`` guard so one CLI invocation emits a
# single debug report regardless of which family it ran.
# ---------------------------------------------------------------------------


def _usage_entry_from_joined(je) -> "UsageEntry":
    """Shape a ``_JoinedClaudeEntry`` into a ``UsageEntry`` so the §7.1
    mismatch helpers can consume both pipelines uniformly (issue #89
    spec §7.2.1).

    The joined-entry shape already carries ``source_path``, ``cost_usd``,
    and the per-token integers; this adapter is pure shape conversion
    with no cache re-read.

    Non-token ``usage`` extras (``je.usage_extra``) — now just ``speed`` —
    are merged AFTER the four token keys, mirroring ``iter_entries``'
    reconstruction of ``usage["speed"]`` from the materialized
    ``session_entries.speed`` column (#181). Without this, the project-axis
    ``daily`` path (and the diff/report joined-entry consumers) would
    drop the fast-tier flag and render ``<model>`` where the normal path
    renders ``<model>-fast``. ``je.usage_extra`` is ``{"speed": …}`` or
    ``None``, so the merge never shadows the token integers.
    """
    usage = {
        "input_tokens": je.input_tokens,
        "output_tokens": je.output_tokens,
        "cache_creation_input_tokens": je.cache_creation_tokens,
        "cache_read_input_tokens": je.cache_read_tokens,
    }
    if je.usage_extra:
        usage.update(je.usage_extra)
    return UsageEntry(
        timestamp=je.timestamp,
        model=je.model,
        usage=usage,
        cost_usd=je.cost_usd,
        source_path=je.source_path,
    )


def _resolve_session_id_for_filter(je) -> str:
    """Mirror ``_aggregate_claude_sessions``'s session_id resolution
    (``bin/_lib_aggregators.py:623-627``): use ``je.session_id`` when
    set, else fall back to the filename stem of ``je.source_path``.

    Used by ``cmd_session``'s ``--id`` filter on the joined-entry list
    (spec §7.2.1.1) so a rendered session that was assigned its id via
    the filename-stem fallback isn't silently dropped from the --debug
    report scope.
    """
    if je.session_id is not None:
        return je.session_id
    return os.path.splitext(os.path.basename(je.source_path))[0]


def _project_filter_matches(key, project_patterns):
    """Mirror ``cmd_project``'s ``--project`` predicate
    (``bin/cctally:4792-4803``): substring match against
    ``key.display_key`` AND ``key.git_root or key.bucket_path``.

    Used by ``cmd_project``'s --debug report scope so basename-collision
    suffixes (e.g. ``foo (repos)``) are still selectable by their path
    segment (spec §7.2.1.2). Do NOT simplify to a raw
    ``p in (je.project_path or "")`` substring — the report scope would
    drift from the rendered scope.
    """
    if not project_patterns:
        return True
    dname = key.display_key.lower()
    pname = (key.git_root or key.bucket_path or "").lower()
    return any((p in dname) or (p in pname) for p in project_patterns)


def _parse_project_aliases(raw):
    """Parse a ``--project-aliases`` value into ``{key: label}``.

    Form: comma-separated ``key=Label`` pairs. Whitespace around keys, labels,
    and pairs is stripped; segments without ``=`` or with an empty key/label are
    dropped (ported from ccusage's tolerant parser). ``None``/"" → ``{}``.
    """
    result: dict[str, str] = {}
    if not raw:
        return result
    for pair in raw.split(","):
        pair = pair.strip()
        if not pair or "=" not in pair:
            continue
        k, _, v = pair.partition("=")
        k = k.strip()
        v = v.strip()
        if k and v:
            result[k] = v
    return result


def _alias_for(key, aliases):
    """Return the alias label for a ProjectKey, or None.

    Looks up ``aliases`` by ``display_key``, then ``git_root``, then
    ``bucket_path`` (first hit). Display-only — never alters JSON keys.
    """
    if not aliases:
        return None
    for cand in (key.display_key, key.git_root, key.bucket_path):
        if cand and cand in aliases:
            return aliases[cand]
    return None


# _emit_diff_debug_samples moved to bin/_cctally_diff.py (C4, #125 Batch C);
# re-exported on the cctally ns alongside cmd_diff (see the _cctally_diff load
# below, after the _lib_diff_kernel block). It reaches _DEBUG_REPORT_EMITTED via
# c._DEBUG_REPORT_EMITTED, so that flag's definition (below) MUST survive.


def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
    """Load config honoring the ccusage ``--config <path>`` per-invocation override.

    Thin shim over :func:`load_config` so the 10 in-scope Claude reporting
    commands (spec §3 T1.6 / issue #88) surface the override behavior
    uniformly. When ``args.config`` is set, reads from that explicit path
    only — no first-run create, no writer-lock acquisition, no mutation
    of the persisted default config at ``_cctally_core.CONFIG_PATH``.
    Missing / unreadable / non-object-JSON paths raise ``SystemExit(2)``
    with a clear stderr message (see ``_load_config_from_explicit_path``).
    When ``args.config`` is unset (or absent), behavior is identical to
    a bare ``load_config()`` call.
    """
    return load_config(getattr(args, "config", None))


def _resolve_primary_model_for_block(
    conn: sqlite3.Connection, five_hour_window_key: int
) -> "str | None":
    """Return the highest-cost model active in this 5h block, or ``None``.

    Reads from ``five_hour_block_models`` (recompute-every-tick rollup-child;
    UNIQUE on ``(five_hour_window_key, model)``). Returns ``None`` when the
    child table has no row for this window (possible when the parent block
    was just created with empty totals — e.g. API/web-only users — or when
    direct-JSONL fallback bypassed the cache and the child table hasn't been
    rebuilt yet).

    Deterministic tie-break: when two models have identical ``cost_usd``,
    the secondary ``model ASC`` ordering ensures stable selection across
    runs (matters for fixture-driven golden tests and for reproducible
    payloads under cost-tie scenarios).
    """
    row = conn.execute(
        "SELECT model FROM five_hour_block_models "
        "WHERE five_hour_window_key = ? "
        "ORDER BY cost_usd DESC, model ASC LIMIT 1",
        (int(five_hour_window_key),),
    ).fetchone()
    if row is None:
        return None
    return row["model"]


def _read_keychain_oauth_blob() -> str | None:
    """Read the Claude Code keychain entry on macOS via `security`.

    Returns the raw stdout as a string, or ``None`` if the call fails
    (non-macOS, password not found, sandbox denial, etc.). Does NOT
    parse — that's the caller's job.
    """
    try:
        proc = subprocess.run(
            ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
            capture_output=True, text=True, timeout=5,
        )
    except (FileNotFoundError, subprocess.SubprocessError, OSError):
        return None
    if proc.returncode != 0:
        return None
    out = proc.stdout.strip()
    return out or None


def _resolve_oauth_token(
    keychain_reader=_read_keychain_oauth_blob,
    credentials_path=None,
) -> str | None:
    """Resolve the Claude Code OAuth bearer token.

    Priority: macOS keychain (via ``keychain_reader``), then a JSON
    credentials file at ``credentials_path`` (defaults to
    ``~/.claude/.credentials.json``). Returns ``None`` if no source
    yields a non-empty token.

    Both sources are expected to contain a JSON object with shape
    ``{"claudeAiOauth": {"accessToken": "..."}}``. Malformed data on
    either path is treated as a miss (silently falls through).

    The injectable seams (``keychain_reader``, ``credentials_path``) are
    for unit testing only; production code calls the defaults.
    """
    # 1. Keychain
    blob = None
    try:
        blob = keychain_reader()
    except Exception:
        blob = None
    if blob:
        try:
            data = json.loads(blob)
            tok = data.get("claudeAiOauth", {}).get("accessToken")
            if tok:
                return tok
        except (json.JSONDecodeError, AttributeError, TypeError):
            pass  # fall through to file
    # 2. Credentials file
    path = credentials_path or (pathlib.Path.home() / ".claude" / ".credentials.json")
    try:
        text = pathlib.Path(path).read_text()
    except (FileNotFoundError, IsADirectoryError, PermissionError, OSError):
        return None
    try:
        data = json.loads(text)
        tok = data.get("claudeAiOauth", {}).get("accessToken")
        return tok or None
    except (json.JSONDecodeError, AttributeError, TypeError):
        return None


# Update subsystem exception hierarchy (UpdateError + 6 subclasses) was
# extracted to bin/_cctally_update.py in Phase F #21 (eager re-export at
# the top of the file — see comment block before _cctally_update =
# _load_sibling(...)). The classes are caught at this module's
# /api/update* dashboard handlers, doctor safety check, and main()'s
# banner hook; the eager re-export preserves class identity across all
# raise/except call sites.


def _select_last_known_snapshot() -> dict | None:
    """Return the newest weekly_usage_snapshots row, shaped to match the
    payload format used by cmd_refresh_usage on success — but with
    `source = "db-fallback"` instead of `"api"`.

    Schema columns used:
      - `weekly_percent`   = OAuth seven_day.utilization at capture
      - `week_end_at`      = OAuth seven_day.resets_at at capture
      - `five_hour_percent` / `five_hour_resets_at` = optional 5h block

    Returns None if the table has no rows. `captured_at_utc` is added
    on top of the standard payload shape so consumers can compute
    freshness without re-querying.
    """
    try:
        conn = open_db()
        try:
            row = conn.execute(
                "SELECT captured_at_utc, weekly_percent, week_end_at, "
                "       five_hour_percent, five_hour_resets_at "
                "FROM weekly_usage_snapshots "
                "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
            ).fetchone()
        finally:
            conn.close()
    except sqlite3.Error:
        return None
    if not row:
        return None

    captured_at, weekly_pct, week_end_at, five_pct, five_resets_at = row

    seven = {
        "used_percent": float(weekly_pct),
        "resets_at": week_end_at,
        "resets_at_epoch": _iso_to_epoch(week_end_at) if week_end_at else None,
    }

    five_block = None
    if five_pct is not None and five_resets_at:
        five_block = {
            "used_percent": float(five_pct),
            "resets_at": five_resets_at,
            "resets_at_epoch": _iso_to_epoch(five_resets_at),
        }

    return {
        "schema_version": 1,
        "fetched_at": now_utc_iso(),
        "seven_day": seven,
        "five_hour": five_block,
        "source": "db-fallback",
        "statusline_cache": "absent",
        "captured_at_utc": captured_at,
    }


def _normalize_alerts_enabled_value(raw: str) -> bool:
    """Normalize a CLI string value to a JSON bool. Raises ValueError on unknown.

    Accepts (case-insensitive, surrounding whitespace stripped):
      true | yes | 1 | on   -> True
      false | no | 0 | off  -> False
    """
    canonical = (raw or "").strip().lower()
    if canonical in {"true", "yes", "1", "on"}:
        return True
    if canonical in {"false", "no", "0", "off"}:
        return False
    raise ValueError(
        f"invalid boolean value for alerts.enabled: {raw!r} "
        "(expected true|false|yes|no|1|0|on|off)"
    )


# update.check config validators (_normalize_update_check_enabled_value,
# _validate_update_check_ttl_hours_value, _UPDATE_CHECK_TTL_HOURS_MIN/MAX)
# extracted to bin/_cctally_update.py in Phase F #21. The eager re-export
# at the top of the file preserves the cctally namespace so _cctally_config
# (CLI config get/set/unset + dashboard POST /api/settings) keeps
# resolving them via `c = _cctally(); c._validate_update_check_ttl_hours_value`.


_ALERTS_BAD_CONFIG_WARNED = False  # one-shot warn flag for malformed alerts block


def _warn_alerts_bad_config_once(exc: Exception) -> None:
    """Emit a single stderr warning per process when the alerts config
    block is malformed. Both the weekly and 5h dispatch paths share this
    flag — the underlying problem is config-wide, not axis-specific, so
    one warning per process is correct. Mirrors `_warn_config_corrupt_once`
    and `_DISPLAY_TZ_BAD_CONFIG_WARNED`.
    """
    global _ALERTS_BAD_CONFIG_WARNED
    if _ALERTS_BAD_CONFIG_WARNED:
        return
    _ALERTS_BAD_CONFIG_WARNED = True
    eprint(f"[alerts] invalid config, skipping dispatch: {exc}")


_BUDGET_BAD_CONFIG_WARNED = False  # one-shot warn flag for malformed budget block


def _warn_budget_bad_config_once(exc: Exception) -> None:
    """Emit a single stderr warning per process when the budget config block
    is malformed, so a hand-edited bad block becomes a quiet no-op on the
    record-usage hot path instead of unthrottled per-tick stderr. Mirrors
    `_warn_alerts_bad_config_once`.
    """
    global _BUDGET_BAD_CONFIG_WARNED
    if _BUDGET_BAD_CONFIG_WARNED:
        return
    _BUDGET_BAD_CONFIG_WARNED = True
    eprint(f"[budget] invalid config, skipping dispatch: {exc}")


# === Update subsystem main body (state/lock/log, install-method
# detection, version-check pipeline, install execution, dashboard
# UpdateWorker + _DashboardUpdateCheckThread, cmd_update,
# cmd_update_check_internal) extracted to bin/_cctally_update.py in
# Phase F #21. The eager re-export at the top of this file (after
# _cctally_record's block) brings every symbol into cctally's
# namespace; ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT / _UPDATE_WORKER
# stay defined here (below) as cmd_dashboard's `global` statement at
# server boot writes them in this module's namespace, and the moved
# _resolve_execvp_target / dashboard /api/update* handlers read them
# via `c.X`. ============================================================

# ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
# server boot in cmd_dashboard. The moved _resolve_execvp_target
# uses them to return (entrypoint, exec_argv) for os.execvp:
#
#   - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
#     re-resolves CCTALLY_PYTHON before re-spawning Python (so a
#     custom interpreter setting survives the restart).
#   - brew: entrypoint = <brew>/bin/cctally → symlink into the
#     post-upgrade Python script with its rewritten shebang.
#   - Fallback when shutil.which("cctally") returned None: use
#     sys.argv[0] directly. Loses the npm shim layer; we accept the
#     degraded edge case rather than guess.
ORIGINAL_SYS_ARGV: list[str] = []
ORIGINAL_ENTRYPOINT: "str | None" = None

# Module-level singleton. Populated by cmd_dashboard on server boot;
# consumed by DashboardHTTPHandler's /api/update* routes. Kept None
# for non-dashboard subcommands so a stray test that loads the script
# without booting the dashboard sees a clean uninitialized state.
_UPDATE_WORKER: "UpdateWorker | None" = None


# fmt/color/table primitives + _parse_iso_datetime_optional now live in
# _lib_fmt.py (re-exported above) — #126 C11.


def _resolve_color_enabled(args: argparse.Namespace) -> bool:
    """Resolve effective color-on/off for the 2 real ANSI Claude cmds.

    Spec §7.3 (issue #86 Session A). Precedence (top wins):

      1. ``args.no_color``  → False (deny-wins; overrides ``FORCE_COLOR``
         env AND a co-supplied ``--color`` flag).
      2. ``args.color``     → True (overrides ``NO_COLOR`` env).
      3. ``FORCE_COLOR``    → True.
      4. ``NO_COLOR``       → False.
      5. ``CI`` env with a TTY stdout → True (CI forces color, but only
                            when stdout is itself a terminal — a piped /
                            redirected stdout under CI stays plain text,
                            issue #100); else ``isatty(stdout)`` with
                            non-dumb TERM → True (the diff/project
                            auto-detect).
      6. else               → False (incl. a piped stdout under CI).

    The helper is consumed by ``cmd_project`` and ``cmd_diff`` — the only
    two in-scope Claude cmds whose renderers honor color. The other 8
    in-scope cmds parse ``--color`` / ``--no-color`` as documented no-op
    surface (spec §3 T1.5).

    The rung-5 auto-detect keys on ``sys.stdout.isatty()`` ONLY — NOT the
    shared ``_supports_color_stdout()`` (which is stdout-OR-stderr to match
    ccusage's picocolors behavior). ``diff``'s pre-Session-A computation was
    ``sys.stdout.isatty() and not args.no_color`` (stdout-only), and spec
    §7.3 specifies preserving the *existing* auto-detect on the no-flag
    rungs. Routing the fallback through the stdout-OR-stderr helper would
    write ANSI into the pipe under ``cctally diff … | cat`` (stderr is still
    a TTY) — a backwards-incompatible regression for plain-text consumers.
    """
    # Rung 1 — deny-wins: --no-color always disables.
    if getattr(args, "no_color", False):
        return False
    # Rung 2 — --color overrides env (NO_COLOR auto-detect).
    if getattr(args, "color", False):
        return True
    # Rung 3 — FORCE_COLOR env always enables (any value).
    if "FORCE_COLOR" in os.environ:
        return True
    # Rung 4 — NO_COLOR env always disables (any value).
    if "NO_COLOR" in os.environ:
        return False
    # Rung 5 — CI forces color, but ONLY with a TTY stdout; else stdout-only
    # isatty with non-dumb TERM. Both branches key on sys.stdout.isatty()
    # (NOT the stdout-OR-stderr _supports_color_stdout) so a piped stdout —
    # under CI or otherwise — stays uncolored, preserving diff/project's
    # pre-Session-A plain-text-on-pipe contract (issue #100). CI still wins
    # over a dumb TERM when stdout is a real terminal (picocolors parity).
    if sys.stdout.isatty():
        if "CI" in os.environ:
            return True
        return os.environ.get("TERM", "").lower() != "dumb"
    # Rung 6 — no flag, no env, non-tty stdout (incl. a piped stdout under CI).
    return False


def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
    for key in ("asOf", "costCapturedAt", "usageCapturedAt", "weekStartAt"):
        parsed = _parse_iso_datetime_optional(row.get(key))
        if parsed is not None:
            return parsed.timestamp()
    return 0.0


# === Cache-report kernel re-exports (Task A2 onward) =========================
# The dataclasses + pure helpers below previously lived inline in bin/cctally;
# the cache-report panel/modal effort moved them to bin/_lib_cache_report
# so the dashboard sync builder can reuse the same pure aggregation as the
# CLI. cctally-side callers continue to reach for ``CacheRow`` /
# ``CacheModelBreakdown`` / ``_compute_cache_hit_percent`` /
# ``_filename_uuid_stem`` by bare name on the cctally ns (and tests via
# ``ns[...]``); per-symbol re-export here preserves those call sites unchanged.
#
# Spec: docs/superpowers/specs/2026-05-21-cache-report-panel-design.md §5.2
_lib_cache_report = _load_sibling("_lib_cache_report")
CacheModelBreakdown = _lib_cache_report.CacheModelBreakdown
CacheRow = _lib_cache_report.CacheRow
_compute_cache_hit_percent = _lib_cache_report._compute_cache_hit_percent

# Re-export the kernel's filename stem helper so any bare-name callers
# inside bin/cctally (and tests poking via ``ns["_filename_uuid_stem"]``)
# resolve unchanged. Kernel is pure-string; ``os.path.basename``
# equivalence is asserted by ``test_aggregate_by_session_falls_back_*``.
_filename_uuid_stem = _lib_cache_report._filename_uuid_stem

# === Cache-report glue (render + window + IO wrappers + command) ============
# The render/window/IO-aggregation/command layer that previously lived inline
# here moved to bin/_cctally_cache_report.py (matching the _lib_<x> kernel /
# _cctally_<x> glue convention — statusline, doctor). Re-export the symbols
# tests/parser reach on the cctally ns; the glue reaches back into this module
# via the call-time ``c = _cctally()`` accessor and into the kernel via
# ``import _lib_cache_report as crk``.
_cctally_cache_report = _load_sibling("_cctally_cache_report")
cmd_cache_report = _cctally_cache_report.cmd_cache_report
_render_cache_report_table = _cctally_cache_report._render_cache_report_table
_layout_cache_table = _cctally_cache_report._layout_cache_table
_resolve_cache_report_window = _cctally_cache_report._resolve_cache_report_window


# Eager re-export of bin/_cctally_milestones.py — the cost-snapshot + milestone
# DB layer (C2, #125 Batch B). All 11 symbols are reached on the cctally ns by
# consumers via c. / sys.modules["cctally"] shims / ns[...] (see comments).
_cctally_milestones = _load_sibling("_cctally_milestones")
WeekCostResult                      = _cctally_milestones.WeekCostResult                      # default-keep (compute_week_cost returns it)
compute_week_cost                   = _cctally_milestones.compute_week_cost                   # _cctally_sync_week c.
get_latest_cost_for_week            = _cctally_milestones.get_latest_cost_for_week            # _lib_view_models c.; record/tui shim
insert_cost_snapshot                = _cctally_milestones.insert_cost_snapshot                # _cctally_sync_week c.
get_max_milestone_for_week          = _cctally_milestones.get_max_milestone_for_week          # record shim
get_milestone_cost_for_week         = _cctally_milestones.get_milestone_cost_for_week         # record shim
get_milestones_for_week             = _cctally_milestones.get_milestones_for_week             # forecast c.; tui shim; percent-breakdown c.
insert_percent_milestone            = _cctally_milestones.insert_percent_milestone            # record shim; idempotency-test mod.
insert_budget_milestone             = _cctally_milestones.insert_budget_milestone             # record shim; test_budget_alerts / test_project_budget_dashboard ns[] (+ test_codex_budget_alerts / test_projected_alerts post-#143 vendor-param unification)
insert_project_budget_milestone     = _cctally_milestones.insert_project_budget_milestone     # record shim; project-budget-config-test ns[]
_budget_crossings                   = _cctally_milestones._budget_crossings                   # record shim (shared INSERT-and-arm core for the budget axis, both vendors, #143)
_resolve_codex_budget_period_window = _cctally_milestones._resolve_codex_budget_period_window # record shim; milestones c. (codex period window)
_resolve_budget_window              = _cctally_milestones._resolve_budget_window              # record shim; milestones c. (per-vendor cheap budget window dispatcher, #143)
_budget_spend_for_vendor            = _cctally_milestones._budget_spend_for_vendor            # record shim; milestones c. (per-vendor budget spend dispatcher, #143)
_reconcile_codex_budget_on_config_write = _cctally_milestones._reconcile_codex_budget_on_config_write  # forecast/config c. (forward-only codex-budget reconcile)
_resolve_claude_budget_window       = _cctally_milestones._resolve_claude_budget_window       # record shim; milestones c. (period-aware Claude budget window)
_project_crossings                  = _cctally_milestones._project_crossings                  # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
insert_projected_milestone          = _cctally_milestones.insert_projected_milestone          # record shim
_projected_levels_already_latched   = _cctally_milestones._projected_levels_already_latched   # record shim
_reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts / test_codex_budget_alerts ns[] (vendor-param, #143)
_reconcile_budget_on_config_write   = _cctally_milestones._reconcile_budget_on_config_write   # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
_reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write  # forecast/config/dashboard c. (forward-only project-budget reconcile)


# === Update-banner predicate (spec §4.2) extracted to
# bin/_cctally_update.py in Phase F #21 — restored from the Phase C
# #16 over-extraction (which had pulled these into _cctally_db) and
# now lives in the update vertical where it belongs. The eager
# re-export at the top of this file brings _args_emit_json,
# _args_emit_machine_stdout, _semver_gt,
# _compute_effective_update_available, _should_show_update_banner,
# _format_update_banner, and _UPDATE_BANNER_EXTRA_SUPPRESSED into
# cctally's namespace so the dashboard envelope builder + main()
# banner hook + doctor safety check resolve them unchanged.
# ====================================================================

def _parse_payload_json(raw_json: Any) -> dict[str, Any]:
    if not isinstance(raw_json, str) or raw_json.strip() == "":
        return {}
    try:
        payload = json.loads(raw_json)
        return payload if isinstance(payload, dict) else {}
    except json.JSONDecodeError:
        return {}


def _extract_range_overrides(payload: dict[str, Any]) -> tuple[str | None, str | None]:
    ws = payload.get("weekStartAt")
    we = payload.get("weekEndAt")
    if isinstance(ws, str) and isinstance(we, str):
        start_at = _canonicalize_optional_iso(ws, "weekStartAt")
        end_at = _canonicalize_optional_iso(we, "weekEndAt")
        if start_at and end_at:
            start_dt = parse_iso_datetime(start_at, "weekStartAt")
            end_dt = parse_iso_datetime(end_at, "weekEndAt")
            if end_dt <= start_dt:
                raise ValueError("weekEndAt must be after weekStartAt")
            return (start_at, end_at)
    return None, None


@dataclass
class WeekSelection:
    week_start: dt.date
    week_end: dt.date
    start_iso_override: str | None = None
    end_iso_override: str | None = None


def _extract_range_overrides_from_usage_row(row: sqlite3.Row) -> tuple[str | None, str | None]:
    try:
        start_at = _canonicalize_optional_iso(row["week_start_at"], "weekStartAt")
        end_at = _canonicalize_optional_iso(row["week_end_at"], "weekEndAt")
        if start_at and end_at:
            start_dt = parse_iso_datetime(start_at, "weekStartAt")
            end_dt = parse_iso_datetime(end_at, "weekEndAt")
            if end_dt <= start_dt:
                raise ValueError("weekEndAt must be after weekStartAt")
            return (start_at, end_at)
    except (KeyError, ValueError):
        pass

    payload = _parse_payload_json(row["payload_json"])
    return _extract_range_overrides(payload)


def pick_week_selection(
    conn: sqlite3.Connection,
    week_start_raw: str | None,
    week_end_raw: str | None,
    week_start_name: str,
) -> WeekSelection:
    if week_start_raw:
        start = parse_date_str(week_start_raw, "--week-start")
        end = parse_date_str(week_end_raw, "--week-end") if week_end_raw else start + dt.timedelta(days=6)
        if end < start:
            raise ValueError("--week-end must be on or after --week-start")
        return WeekSelection(week_start=start, week_end=end)

    latest_usage = conn.execute(
        """
        SELECT week_start_date, week_end_date, week_start_at, week_end_at, payload_json
        FROM weekly_usage_snapshots
        ORDER BY captured_at_utc DESC, id DESC
        LIMIT 1
        """
    ).fetchone()
    if latest_usage is not None:
        try:
            start_override, end_override = _extract_range_overrides_from_usage_row(latest_usage)
        except ValueError:
            start_override, end_override = (None, None)

        if start_override and end_override:
            start_dt = parse_iso_datetime(start_override, "weekStartAt")
            end_dt = parse_iso_datetime(end_override, "weekEndAt")
            # See `_derive_week_from_payload` for the host-TZ rationale.
            # The bucket-key date is anchored on the canonical UTC ISO so
            # `cmd_sync_week`'s `weekly_cost_snapshots.week_start_date`
            # matches `cmd_record_usage`'s `weekly_usage_snapshots.week_
            # start_date` for the same subscription week regardless of
            # the host process's local TZ offset.
            week_start = start_dt.astimezone(dt.timezone.utc).date()
            week_end = end_dt.astimezone(dt.timezone.utc).date()
        else:
            week_start = dt.date.fromisoformat(latest_usage["week_start_date"])
            week_end = dt.date.fromisoformat(latest_usage["week_end_date"])

        return (
            WeekSelection(
                week_start=week_start,
                week_end=week_end,
                start_iso_override=start_override,
                end_iso_override=end_override,
            )
        )

    # internal fallback: host-local intentional
    now_local = dt.datetime.now().astimezone()
    start, end = compute_week_bounds(now_local, week_start_name)
    return WeekSelection(week_start=start, week_end=end)


def _try_dual_form_date(raw: str) -> dt.datetime | None:
    """Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` WITHOUT emitting an error.

    Returns the parsed naive datetime, or ``None`` when ``raw`` matches
    neither form. The non-eprinting core of ``_parse_dual_form_date``:
    callers that have their own fallback for a non-dual-form input must
    use this, because ``_parse_dual_form_date`` eprints before it raises
    — so a successful fallback parse would otherwise leak a spurious
    "must be YYYY-MM-DD" line to stderr (cache-report's ``parse_iso_datetime``
    second-chance, issue #101).
    """
    for fmt in ("%Y-%m-%d", "%Y%m%d"):
        try:
            return dt.datetime.strptime(raw, fmt)
        except ValueError:
            continue
    return None


def _parse_dual_form_date(raw: str, flag: str) -> dt.datetime:
    """Parse a CLI date arg accepting both ``YYYY-MM-DD`` and ``YYYYMMDD``.

    Tries ``%Y-%m-%d`` first (the more readable upstream ccusage / codex
    sharedArgs preference), then ``%Y%m%d``. On failure, emits a stderr
    error and raises ``ValueError``. Callers swallow the ``ValueError``
    and return exit code 1.

    Promoted from ``_resolve_codex_range._parse_date_arg`` so Claude
    reporting commands can share the dual-form contract (issue #86
    Session A, spec §7.1.1). Centralizes the error message so the eight
    in-scope date-taking commands all surface the same diagnostic.
    """
    naive = _try_dual_form_date(raw)
    if naive is not None:
        return naive
    eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
    raise ValueError


def _parse_cli_date_range(
    args: argparse.Namespace,
    *,
    tz_name: str | None = None,
    now_utc: dt.datetime | None = None,
) -> tuple[dt.datetime, dt.datetime] | int:
    """Parse --since / --until from CLI args into an aware-tz range.

    Accepts both ``YYYY-MM-DD`` and ``YYYYMMDD`` to match upstream
    ccusage-codex (its `sharedArgs` documents both: *"Filter from date
    (YYYY-MM-DD or YYYYMMDD)"*). Hyphenated form is tried first — it's
    the more readable/common upstream form.

    When ``tz_name`` is supplied (e.g. via ``--timezone`` from a codex
    subcommand) the boundary datetimes are interpreted in that IANA zone
    instead of the local OS zone. On invalid zone, emits an error and
    returns exit code 1.

    ``now_utc`` is an optional testing-hook override for "now"; when
    provided (via ``_command_as_of()``) it replaces the wall-clock default
    used to build ``range_end`` when ``--until`` is not supplied. Must be
    a tz-aware UTC datetime. Omit to keep the legacy wall-clock behavior.

    Returns either ``(range_start, range_end)`` or an int exit code (1) if
    either flag failed to parse.  An error message is written to stderr
    before returning the exit code.

    `.astimezone()` is called on naive datetimes so the OS timezone
    database is consulted for the *actual* local offset that applies at
    the given date — this avoids DST-boundary drift that would occur if
    we used today's offset (via `datetime.now().astimezone().tzinfo`).
    """
    # Local binding to the module-level dual-form helper (spec §7.1.1).
    # Keeps the diff inside _parse_cli_date_range minimal while sharing
    # the centralized error message with cmd_blocks, _parse_date_filter,
    # and _parse_window_arg.
    _parse_date_arg = _parse_dual_form_date

    tz: Any = None
    if tz_name:
        try:
            tz = ZoneInfo(tz_name)
        except (ZoneInfoNotFoundError, ValueError, OSError):
            eprint(f"Error: --timezone must be a valid IANA zone, got {tz_name!r}")
            return 1

    def _to_aware(naive: dt.datetime) -> dt.datetime:
        # internal fallback: host-local intentional (else branch)
        return naive.replace(tzinfo=tz) if tz is not None else naive.astimezone()

    if args.since:
        try:
            since_date = _parse_date_arg(args.since, "--since")
        except ValueError:
            return 1
        range_start = _to_aware(since_date)
    else:
        range_start = _to_aware(dt.datetime(2020, 1, 1))

    if args.until:
        try:
            until_date = _parse_date_arg(args.until, "--until")
        except ValueError:
            return 1
        range_end = _to_aware(until_date.replace(
            hour=23, minute=59, second=59, microsecond=999999,
        ))
    else:
        if now_utc is not None:
            # Testing hook: CCTALLY_AS_OF override, threaded via
            # _command_as_of(). Convert to the same target tz as the
            # wall-clock branch so downstream logic sees an equivalently
            # formatted aware datetime.
            range_end = (
                now_utc.astimezone(tz) if tz is not None
                # internal fallback: host-local intentional
                else now_utc.astimezone()
            )
        else:
            range_end = (
                dt.datetime.now(tz=tz) if tz is not None
                # internal fallback: host-local intentional
                else dt.datetime.now().astimezone()
            )

    return range_start, range_end


# ─────────────────────────────────────────────────────────────────────
# diff subcommand
# ─────────────────────────────────────────────────────────────────────


# Eager load of bin/_lib_diff_kernel.py (pure-fn diff kernel). Eager
# pattern (not lazy PEP 562 __getattr__) is mandatory per spec §4.8 —
# the test surface under tests/test_diff_*.py reaches into the sibling
# via direct dict access on `mod.__dict__` (e.g. `ns["ParsedWindow"]`,
# `ns["_build_diff_result"]`, `ns["_diff_aggregate_overall"]`), and
# PEP 562 `__getattr__` only fires on real module-attribute access,
# not dict-key access. Eager re-export keeps the existing test surface
# working unchanged. Same pattern as Phase E #19 `_lib_render.py`.
_lib_diff_kernel = _load_sibling("_lib_diff_kernel")
ParsedWindow = _lib_diff_kernel.ParsedWindow
WindowMismatchError = _lib_diff_kernel.WindowMismatchError
NoAnchorError = _lib_diff_kernel.NoAnchorError
MetricBundle = _lib_diff_kernel.MetricBundle
DeltaBundle = _lib_diff_kernel.DeltaBundle
ColumnSpec = _lib_diff_kernel.ColumnSpec
DiffRow = _lib_diff_kernel.DiffRow
DiffSection = _lib_diff_kernel.DiffSection
NoiseThreshold = _lib_diff_kernel.NoiseThreshold
DiffResult = _lib_diff_kernel.DiffResult
_build_delta_bundle = _lib_diff_kernel._build_delta_bundle
_DIFF_NW_AGO_RE = _lib_diff_kernel._DIFF_NW_AGO_RE
_DIFF_NM_AGO_RE = _lib_diff_kernel._DIFF_NM_AGO_RE
_DIFF_LAST_ND_RE = _lib_diff_kernel._DIFF_LAST_ND_RE
_DIFF_PREV_ND_RE = _lib_diff_kernel._DIFF_PREV_ND_RE
_DIFF_RANGE_RE = _lib_diff_kernel._DIFF_RANGE_RE
_parse_diff_window = _lib_diff_kernel._parse_diff_window
_humanize_tokens = _lib_diff_kernel._humanize_tokens
_diff_iter_claude_entries = _lib_diff_kernel._diff_iter_claude_entries
_diff_aggregate_overall = _lib_diff_kernel._diff_aggregate_overall
_diff_aggregate_models = _lib_diff_kernel._diff_aggregate_models
_diff_aggregate_projects = _lib_diff_kernel._diff_aggregate_projects
_diff_aggregate_cache = _lib_diff_kernel._diff_aggregate_cache
_diff_resolve_used_pct = _lib_diff_kernel._diff_resolve_used_pct
_DIFF_DEFAULT_COLUMNS_OVERALL = _lib_diff_kernel._DIFF_DEFAULT_COLUMNS_OVERALL
_DIFF_DEFAULT_COLUMNS_MODELS = _lib_diff_kernel._DIFF_DEFAULT_COLUMNS_MODELS
_DIFF_DEFAULT_COLUMNS_PROJECTS = _lib_diff_kernel._DIFF_DEFAULT_COLUMNS_PROJECTS
_DIFF_DEFAULT_COLUMNS_CACHE = _lib_diff_kernel._DIFF_DEFAULT_COLUMNS_CACHE
_diff_sort_rows = _lib_diff_kernel._diff_sort_rows
_apply_noise_threshold = _lib_diff_kernel._apply_noise_threshold
_diff_build_section = _lib_diff_kernel._diff_build_section
_normalize_metric_bundle_per_day = _lib_diff_kernel._normalize_metric_bundle_per_day
_sum_metric_bundles = _lib_diff_kernel._sum_metric_bundles
_build_diff_result = _lib_diff_kernel._build_diff_result
_check_diff_invariants = _lib_diff_kernel._check_diff_invariants
_DIFF_EM_DASH = _lib_diff_kernel._DIFF_EM_DASH
_diff_or_emdash = _lib_diff_kernel._diff_or_emdash
_diff_fmt_cost_cell = _lib_diff_kernel._diff_fmt_cost_cell
_diff_fmt_delta_cost_cell = _lib_diff_kernel._diff_fmt_delta_cost_cell
_diff_fmt_pct_cell = _lib_diff_kernel._diff_fmt_pct_cell
_diff_fmt_pp_cell = _lib_diff_kernel._diff_fmt_pp_cell
_diff_fmt_tokens_cell = _lib_diff_kernel._diff_fmt_tokens_cell
_diff_fmt_delta_tokens_cell = _lib_diff_kernel._diff_fmt_delta_tokens_cell
_diff_color_for_delta = _lib_diff_kernel._diff_color_for_delta
_diff_render_banner = _lib_diff_kernel._diff_render_banner
_diff_render_window_header = _lib_diff_kernel._diff_render_window_header
_diff_box_chars = _lib_diff_kernel._diff_box_chars
_diff_section_heading = _lib_diff_kernel._diff_section_heading
_diff_render_section_table = _lib_diff_kernel._diff_render_section_table
_diff_render_full_output = _lib_diff_kernel._diff_render_full_output
_diff_metric_to_json = _lib_diff_kernel._diff_metric_to_json
_diff_delta_to_json = _lib_diff_kernel._diff_delta_to_json
_diff_window_to_json = _lib_diff_kernel._diff_window_to_json
_diff_to_json_payload = _lib_diff_kernel._diff_to_json_payload
_diff_render_json = _lib_diff_kernel._diff_render_json
_diff_resolve_anchor = _lib_diff_kernel._diff_resolve_anchor


# Eager re-export of bin/_cctally_diff.py — the diff handler + its two-window
# debug-sample reporter (C4, #125 Batch C). MUST load after the _lib_diff_kernel
# block above: _cctally_diff does `import _lib_diff_kernel as dk` at module-load
# time and relies on the primed sys.modules cache. _cctally_diff honest-imports
# the SAME kernel instance, so the ns["ParsedWindow"] surface above stays intact.
_cctally_diff = _load_sibling("_cctally_diff")
cmd_diff                 = _cctally_diff.cmd_diff                  # parser: c.cmd_diff (_cctally_parser.py:2032)
_emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples  # test_debug_sample_emission mod._emit_diff_debug_samples


_cctally_weekrefs = _load_sibling("_cctally_weekrefs")
_get_canonical_boundary_for_date    = _cctally_weekrefs._get_canonical_boundary_for_date
get_recent_weeks                    = _cctally_weekrefs.get_recent_weeks
_apply_reset_events_to_weekrefs     = _cctally_weekrefs._apply_reset_events_to_weekrefs
_backfill_week_reset_events         = _cctally_weekrefs._backfill_week_reset_events
_week_ref_has_reset_event           = _cctally_weekrefs._week_ref_has_reset_event
_compute_cost_for_weekref           = _cctally_weekrefs._compute_cost_for_weekref
_apply_overlap_clamp_to_weekrefs    = _cctally_weekrefs._apply_overlap_clamp_to_weekrefs
_RESET_PCT_DROP_THRESHOLD           = _cctally_weekrefs._RESET_PCT_DROP_THRESHOLD
_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = _cctally_weekrefs._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD
_RESET_ZERO_FLOOR_PCT               = _cctally_weekrefs._RESET_ZERO_FLOOR_PCT
_RESET_ZERO_MIN_DROP_PCT            = _cctally_weekrefs._RESET_ZERO_MIN_DROP_PCT
_is_reset_drop                      = _cctally_weekrefs._is_reset_drop


# Eager re-export of bin/_cctally_percent_breakdown.py — the percent-breakdown
# handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
_cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
cmd_percent_breakdown = _cctally_percent_breakdown.cmd_percent_breakdown


# _is_cctally_hook_command, SetupError, _load_claude_settings,
# _backup_claude_settings, _write_claude_settings_atomic moved to
# bin/_cctally_setup.py (#125 Batch E, C10). Re-exported at the
# _cctally_setup load site below so doctor (except c.SetupError) + the
# setup tests' setitem(ns, …) patches resolve to the moved objects.
# _seconds_since_iso / _newest_snapshot_age_seconds (refresh-domain) and
# the _LEGACY_* constants intentionally STAY inline here.


def _seconds_since_iso(iso_s: str) -> float | None:
    """Helper used by the fallback path to compute age. Returns None on
    parse failure rather than raising."""
    if not iso_s:
        return None
    try:
        dtv = dt.datetime.fromisoformat(iso_s.replace("Z", "+00:00"))
        if dtv.tzinfo is None:
            dtv = dtv.replace(tzinfo=dt.timezone.utc)
    except ValueError:
        return None
    return max(0.0, (dt.datetime.now(dt.timezone.utc) - dtv).total_seconds())


def _newest_snapshot_age_seconds(now_utc: dt.datetime | None = None) -> float | None:
    """Return seconds since newest weekly_usage_snapshots.captured_at_utc,
    or None if the table is empty / unreadable. `now_utc` is injectable
    for testability; defaults to current UTC time."""
    if now_utc is None:
        now_utc = dt.datetime.now(dt.timezone.utc)
    try:
        conn = open_db()
        try:
            row = conn.execute(
                "SELECT captured_at_utc FROM weekly_usage_snapshots "
                "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
            ).fetchone()
        finally:
            conn.close()
    except sqlite3.Error:
        return None
    if not row or not row[0]:
        return None
    captured_iso = row[0]
    try:
        captured_dt = dt.datetime.fromisoformat(captured_iso.replace("Z", "+00:00"))
        if captured_dt.tzinfo is None:
            captured_dt = captured_dt.replace(tzinfo=dt.timezone.utc)
    except ValueError:
        return None
    delta = (now_utc - captured_dt).total_seconds()
    return max(0.0, delta)


# Legacy bespoke hook set — see docs/superpowers/specs/2026-05-09-auto-migrate-legacy-hooks-design.md
_LEGACY_BESPOKE_HOOKS_DIR = pathlib.Path.home() / ".claude" / "hooks"
_LEGACY_BESPOKE_COMMANDS: tuple[tuple[str, str], ...] = (
    ("Stop",          "python3 ~/.claude/hooks/record-usage-stop.py"),
    ("SubagentStart", "python3 ~/.claude/hooks/usage-poller-start.py"),
    ("SubagentStop",  "python3 ~/.claude/hooks/usage-poller-stop.py"),
)
_LEGACY_BESPOKE_FILENAMES: tuple[str, ...] = (
    "record-usage-stop.py",
    "usage-poller-start.py",
    "usage-poller-stop.py",
    "usage-poller.py",
)
_LEGACY_POLLER_PID_FILE = pathlib.Path("/tmp/claude-usage-poller.pid")
_LEGACY_POLLER_COUNT_FILE = pathlib.Path("/tmp/claude-usage-poller.count")
_LEGACY_BACKUP_DIR_PREFIX = "cctally-legacy-hook-backup-"
_LEGACY_POLLER_SIGTERM_GRACE_S = 0.250


# _pricing_observed_models + _PRICING_SCAN_DEFAULT_WINDOW moved to
# bin/_cctally_pricing_check.py (#125 Batch E, C7). Re-exported below at the
# pricing-check load site so doctor (c._pricing_observed_models) resolves.


# Phase F #23 (the FINAL Phase F extraction): ``bin/_cctally_tui.py`` is
# loaded eagerly per spec §4.8 carve-out — ``tests/test_dashboard_*.py``
# and ``tests/test_tui_*.py`` reach into TUI symbols via ``ns["X"]``
# direct-dict reads (11 distinct names — ``DataSnapshot`` (6 sites),
# ``WeeklyPeriodRow``/``MonthlyPeriodRow``/``BlocksPanelRow``/``DailyPanelRow``
# (3 each), ``TuiCurrentWeek`` (2), ``_tui_empty_snapshot`` (2),
# ``_tui_build_snapshot`` / ``_make_run_sync_now`` /
# ``_make_run_sync_now_locked`` (1 each) + ``monkeypatch.setitem`` on
# ``_tui_build_snapshot`` in ``tests/test_dashboard_api_sync_refresh.py``).
# PEP 562 ``__getattr__`` does NOT fire on dict-key access. Eager
# re-export below means cctally's ``__dict__`` carries the same dataclass
# / function / class objects the sibling defines; cross-module callers
# (the dashboard's existing 5 dataclass shims at
# ``bin/_cctally_dashboard.py:487-504`` plus ``c._TuiSyncThread`` inside
# ``cmd_dashboard``) all resolve to the same identity via
# ``cctally.X``. Internal cross-calls from one moved body to another
# route through the module-level shim at call time so
# ``setitem(ns, "_tui_build_snapshot", spy)`` propagates into
# ``_make_run_sync_now_locked``.
_cctally_tui = _load_sibling("_cctally_tui")
# Constants + theme metadata
TUI_RICH_MISSING_MSG = _cctally_tui.TUI_RICH_MISSING_MSG
TUI_PALETTE = _cctally_tui.TUI_PALETTE
_TUI_TAG_SHORTHAND = _cctally_tui._TUI_TAG_SHORTHAND
_TUI_VALID_STYLE_NAMES = _cctally_tui._TUI_VALID_STYLE_NAMES
_TUI_THEME_KEYS = _cctally_tui._TUI_THEME_KEYS
_TUI_BOX = _cctally_tui._TUI_BOX
_TUI_SPARK_GLYPHS = _cctally_tui._TUI_SPARK_GLYPHS
_TUI_VERDICT_CLS = _cctally_tui._TUI_VERDICT_CLS
_TUI_VERDICT_SHORT = _cctally_tui._TUI_VERDICT_SHORT
_TUI_SORT_KEYS = _cctally_tui._TUI_SORT_KEYS
_TUI_SORT_ASC = _cctally_tui._TUI_SORT_ASC
_TUI_TAG_RE = _cctally_tui._TUI_TAG_RE
_TUI_HELP_LINES = _cctally_tui._TUI_HELP_LINES
# Shared dataclasses (consumed by BOTH the TUI and the dashboard;
# Phase F #22 deferred these here so the dashboard's 5 dataclass shims
# at _cctally_dashboard.py:487-504 keep resolving via cctally.X).
TuiCurrentWeek = _cctally_tui.TuiCurrentWeek
TuiTrendRow = _cctally_tui.TuiTrendRow
TuiSessionRow = _cctally_tui.TuiSessionRow
TuiSessionDetail = _cctally_tui.TuiSessionDetail
TuiPercentMilestone = _cctally_tui.TuiPercentMilestone
WeeklyPeriodRow = _cctally_tui.WeeklyPeriodRow
MonthlyPeriodRow = _cctally_tui.MonthlyPeriodRow
BlocksPanelRow = _cctally_tui.BlocksPanelRow
DailyPanelRow = _cctally_tui.DailyPanelRow
DataSnapshot = _cctally_tui.DataSnapshot
RuntimeState = _cctally_tui.RuntimeState
# Theme + drawing primitives
_tui_build_theme = _cctally_tui._tui_build_theme
_tui_colortag = _cctally_tui._tui_colortag
_tui_escape_tags = _cctally_tui._tui_escape_tags
_tui_box_lines = _cctally_tui._tui_box_lines
_tui_bar_string = _cctally_tui._tui_bar_string
_tui_bar_color = _cctally_tui._tui_bar_color
_tui_sparkline_inline = _cctally_tui._tui_sparkline_inline
_tui_sparkline_big = _cctally_tui._tui_sparkline_big
_tui_width_bucket = _cctally_tui._tui_width_bucket
# Snapshot builders (per-panel + orchestrator + empty)
_tui_build_percent_milestones = _cctally_tui._tui_build_percent_milestones
_tui_build_current_week = _cctally_tui._tui_build_current_week
_tui_build_forecast = _cctally_tui._tui_build_forecast
_tui_build_trend = _cctally_tui._tui_build_trend
_tui_build_weekly_history = _cctally_tui._tui_build_weekly_history
_tui_build_sessions = _cctally_tui._tui_build_sessions
_tui_build_session_detail = _cctally_tui._tui_build_session_detail
_tui_build_session_detail_indexed = _cctally_tui._tui_build_session_detail_indexed
_tui_build_snapshot = _cctally_tui._tui_build_snapshot
_tui_empty_snapshot = _cctally_tui._tui_empty_snapshot
# Key reader + dispatcher + sync thread base class
TuiKeyReader = _cctally_tui.TuiKeyReader
_tui_handle_key = _cctally_tui._tui_handle_key
_TuiSyncThread = _cctally_tui._TuiSyncThread
# Panel renderers
_tui_panel_current_week = _cctally_tui._tui_panel_current_week
_tui_panel_current_week_hero = _cctally_tui._tui_panel_current_week_hero
_tui_verdict_of = _cctally_tui._tui_verdict_of
_tui_panel_forecast = _cctally_tui._tui_panel_forecast
_tui_panel_trend = _cctally_tui._tui_panel_trend
_tui_session_model_cls = _cctally_tui._tui_session_model_cls
_tui_format_started = _cctally_tui._tui_format_started
_tui_format_dur = _cctally_tui._tui_format_dur
_tui_sort_sessions = _cctally_tui._tui_sort_sessions
_tui_next_sort_key = _cctally_tui._tui_next_sort_key
_tui_apply_session_filter = _cctally_tui._tui_apply_session_filter
_tui_sessions_title = _cctally_tui._tui_sessions_title
_tui_panel_sessions = _cctally_tui._tui_panel_sessions
# Chrome + variant composers
_tui_header_strip_a = _cctally_tui._tui_header_strip_a
_tui_footer_keys = _cctally_tui._tui_footer_keys
_tui_render_input_prompt = _cctally_tui._tui_render_input_prompt
_tui_strip_tags = _cctally_tui._tui_strip_tags
_tui_tagged_box_lines = _cctally_tui._tui_tagged_box_lines
_tui_lines_to_text = _cctally_tui._tui_lines_to_text
_tui_render_variant_a = _cctally_tui._tui_render_variant_a
_tui_render_variant_b = _cctally_tui._tui_render_variant_b
_tui_render_help = _cctally_tui._tui_render_help
_tui_modal_max_width = _cctally_tui._tui_modal_max_width
_tui_render_modal = _cctally_tui._tui_render_modal
_tui_modal_current_week = _cctally_tui._tui_modal_current_week
_tui_modal_forecast = _cctally_tui._tui_modal_forecast
_tui_modal_trend = _cctally_tui._tui_modal_trend
_tui_modal_session = _cctally_tui._tui_modal_session
_tui_render_toast = _cctally_tui._tui_render_toast
# Argparse type validators
_tui_sync_interval_type = _cctally_tui._tui_sync_interval_type
_tui_refresh_interval_type = _cctally_tui._tui_refresh_interval_type
# Shared snapshot-rebuilder closures (consumed by TUI loop + dashboard
# POST /api/sync handler + periodic thread)
_make_run_sync_now_locked = _cctally_tui._make_run_sync_now_locked
_make_run_sync_now = _cctally_tui._make_run_sync_now
# Subcommand entry + fixture dev path
cmd_tui = _cctally_tui.cmd_tui
_tui_render_once = _cctally_tui._tui_render_once


# cmd_repair_symlinks moved to bin/_cctally_setup.py (#125 Batch E, C10).
# Re-exported at the _cctally_setup load site so the parser's
# set_defaults(func=c.cmd_repair_symlinks) resolves unchanged.


def main(argv: list[str] | None = None) -> int:
    _migrate_legacy_data_dir()
    parser = build_parser()
    args = parser.parse_args(argv)
    # `--version` is a top-level flag that reads CHANGELOG.md's latest
    # release header (issue #24); handle BEFORE subcommand dispatch so it
    # works without a subcommand (`cctally --version`).
    if getattr(args, "version", False):
        v = _lib_changelog._read_latest_changelog_version()
        base = "cctally unknown" if v is None else f"cctally {v[0]}"
        # Dev-instance isolation (§4, P3): append the dev marker + resolved
        # data dir whenever running from a checkout — keyed on
        # _is_dev_checkout(), NOT DEV_MODE, so the CCTALLY_DATA_DIR
        # override-on-checkout case (DEV_MODE False) still shows the marker
        # instead of masquerading as the installed binary. Prod (no .git)
        # output is unchanged. The override case is labelled distinctly so
        # the user can tell auto-detect (cctally-dev) from an explicit dir.
        if _cctally_core.DEV_MODE:
            base += f" (dev — {_cctally_core.APP_DIR})"
        elif _cctally_core._is_dev_checkout():
            base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
        print(base)
        return 0
    if not getattr(args, "func", None):
        parser.error("a subcommand is required")
    _print_migration_error_banner_if_needed(args)
    try:
        rc = int(args.func(args))
    except KeyboardInterrupt:
        return 130
    except DowngradeDetected as exc:
        eprint(f"cctally: {exc}")
        return 2
    except ProdMigrationRefused as exc:
        eprint(f"{exc}")  # message already carries the 'cctally:' prefix
        return 2
    except Exception as exc:  # pragma: no cover
        eprint(f"Error: {exc}")
        rc = 1
    # Post-command update hooks (spec §4.2). Best-effort: any exception
    # in the banner / background-spawn path must not perturb the parent
    # command's exit code or perceived output. Runs on both success and
    # error paths so the user sees pending-update banners even when the
    # current command failed.
    try:
        _post_command_update_hooks(getattr(args, "command", None), args)
    except Exception:
        pass
    return rc


def _post_command_update_hooks(command: str | None, args) -> None:
    """Render the update banner if applicable + spawn the background
    refresh check if its TTL has elapsed (spec §4.2 + §3.6).

    Both paths are wrapped in their own try/except by the caller so a
    failure here can't break the parent command. Reading state /
    suppress / config is itself best-effort: a corrupt JSON file would
    raise, but the outer caller swallows it.

    Skip-after-uninstall: ``setup --uninstall`` (with or without
    ``--purge``) deletes ~/.local/bin/ symlinks and may wipe APP_DIR.
    Running ``load_config()`` afterwards would recreate APP_DIR via its
    ``ensure_dirs()`` first-run path, leaving a stub config.json behind
    and breaking the post-purge "data dir gone" invariant. Skip the
    hook entirely for that command — the banner is moot post-uninstall
    anyway.

    Skip-for-doctor: ``doctor`` is a read-only diagnostic by contract.
    ``load_config()`` would call ``ensure_dirs()`` and write a stub
    ``config.json`` on a fresh HOME; ``_spawn_background_update_check``
    would write ``update-state.json`` and ``update.log``. Adding doctor
    to ``_BANNER_SUPPRESSED_COMMANDS`` only silences the banner — it
    does not stop these side effects. Doctor reads update state for
    its report via the gather layer; it must not refresh that state
    opportunistically. Users who want a fresh check have
    ``cctally update --check``.

    Skip-for-repair-symlinks: ``repair-symlinks`` is spawned by the npm
    postinstall on every install (issue #114). It must touch nothing but
    ~/.local/bin/ symlinks — running ``load_config()`` /
    ``_spawn_background_update_check`` here would create ``config.json``,
    ``update-state.json``, and ``update.log`` on a fresh install where
    the existing-install gate already makes the symlink work a no-op.
    Same rationale as doctor.

    Test/CI seam: ``CCTALLY_DISABLE_UPDATE_CHECK`` short-circuits the whole
    hook (mirrors ``CCTALLY_DISABLE_DEV_AUTODETECT``). The background
    ``_spawn_background_update_check`` is a DETACHED process that
    ``mkdir``s APP_DIR to write ``update-state.json`` / ``update.log``; a
    fixture harness that runs a command and then asserts on APP_DIR (e.g.
    ``cctally-setup-test``'s uninstall-purge "data dir is gone" check)
    otherwise races that detached writer re-creating the dir after the
    command returns. Setting this env var makes such harnesses
    deterministic without disabling the feature for real users."""
    if os.environ.get("CCTALLY_DISABLE_UPDATE_CHECK"):
        return
    if command == "setup" and getattr(args, "uninstall", False):
        return
    if command == "doctor":
        return
    if command == "pricing-check":
        # Read-only diagnostic (same rationale as doctor): load_config() would
        # call ensure_dirs() and write a stub config.json on a fresh HOME, and
        # _spawn_background_update_check would write update-state.json/log. The
        # no-mutation contract (test_pricing_check_offline_does_not_mutate_
        # fresh_home) requires skipping the whole hook.
        return
    if command == "repair-symlinks":
        return
    # Self-heal: reconcile current_version with the running binary's
    # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
    # command after a manual upgrade). Runs before load_config so a
    # bumped version is visible to the banner predicate immediately.
    _self_heal_current_version()
    config = load_config()
    state = _load_update_state()
    suppress = _load_update_suppress()
    if _should_show_update_banner(command, args, state, suppress, config):
        sys.stderr.write(_format_update_banner(state) + "\n")
    if _is_update_check_due(config):
        _spawn_background_update_check()


if __name__ == "__main__":
    raise SystemExit(main())
