cctally 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.15.0] - 2026-05-26
9
+
10
+ ### Added
11
+ - **`cctally claude <cmd>` and `cctally codex <cmd>` subgroup commands** let you paste ccusage's hierarchical syntax verbatim — `cctally claude {daily,monthly,weekly,session,blocks}` is a drop-in for `ccusage claude <cmd>`, and `cctally codex {daily,monthly,session,weekly}` for `ccusage codex <cmd>` (`codex weekly` is a cctally extension; upstream has none). Each subgroup leaf routes to the exact same engine as its flat form, so the table / `--json` / exit code are byte-identical — only `--help` adds a one-line alias/canonical cross-reference. The flat forms (`cctally daily`, `cctally codex-daily`, …) remain fully supported as back-compat aliases, with no deprecation warning. (#86)
12
+
13
+ ## [1.14.0] - 2026-05-26
14
+
15
+ ### Added
16
+ - **Running `cctally` from a git checkout now uses a separate `~/.local/share/cctally-dev/` data dir instead of the installed copy's `~/.local/share/cctally/`, so developing against the source tree can no longer corrupt the production instance.** Previously both the source checkout and the npm/brew-installed copy resolved every runtime path from `~/.local/share/cctally/`, so a single dev run that advanced the schema would trip a version mismatch on the still-installed prod binary (and on its background Claude Code hooks) — and vice versa. A checkout is now auto-detected (its `bin/` parent contains a `.git` directory or file, which also covers worktrees) and transparently relocated to `cctally-dev/`; the npm and brew copies ship without `.git`, so installed users are byte-for-byte unaffected. The real Claude session JSONL, `~/.claude/settings.json`, and OAuth credentials stay shared read-only, so dev cost numbers remain real. `cctally doctor` now reports whether it is the installed copy or a dev checkout plus the resolved data dir, `cctally --version` shows a dev-mode marker, and `cctally setup` refuses to wire a dev checkout into `~/.claude/settings.json` (read-only `--status`/`--dry-run` still work) unless given `--force-dev`. Set `CCTALLY_DATA_DIR` to point the data dir somewhere explicit (e.g. a distinct dir per feature branch).
17
+
18
+ ### Changed
19
+ - **`cctally session` `totalTokens` (the table column, the `--breakdown` per-model sub-rows, and the `--json` per-session + `totals` fields) now sums all four token components — input + output + cache create + cache read — matching `daily`/`monthly` and upstream `ccusage` v20.** Previously it counted input + output only (the original Spec A2.8 convention), which left the session roll-up ~99% below `ccusage` v20 even though the four component token fields and cost already matched within rounding. The `--json` `totalTokens` field name and shape are unchanged — only the value widened to include cache, so a consumer that previously read it as input+output will now see the cache-inclusive figure. `codex-session` deliberately keeps input + output because Codex `inputTokens` is already cache-inclusive (LiteLLM convention), so it already reports the same "all tokens processed" total. (#104)
20
+
21
+ ### Fixed
22
+ - **`cctally codex-session` no longer misaligns its table on narrow terminals (including the default 120-column width) or under `--compact`.** On any terminal narrower than the table's natural width the scale-down branch could shrink a column below its header-label width, and headers like `Reasoning`, `Cache Read`, `Total Tokens` and `Last Activity` are padded — never truncated — in the header render, so the header row grew wider than the box border and the grid broke. Column widths now mirror the `session` and `project` tables via the shared scale-down policy: numeric columns keep their full value (never ellipsis-truncated), header and text labels ellipsize to fit, and every box-drawing line shares one width. (#99)
23
+ - **`cctally`'s `stats.db` write paths are hardened against a `SQLITE_BUSY_SNAPSHOT` "database is locked" crash under concurrent multi-process use** (multiple dashboards plus background `record-usage` / `hook-tick`, magnified by worktrees that share one `~/.local/share/cctally/stats.db`). The one-shot `five_hour_blocks` historical backfill ran a deferred transaction that read its source rows before its first write, so a competing commit landing in that window raised "database is locked" *instantly* — a `busy_timeout` can never absorb that case — and rolled the whole backfill back; it and the live 5h-block upsert now take the write lock up front via `BEGIN IMMEDIATE`. Contrary to the issue's original framing, `busy_timeout` was never the missing piece: `sqlite3.connect()`'s default `timeout=5.0` already gives every `stats.db` open a 5s retry window, so the real fix is acquiring the write lock before the first read so the busy handler can actually wait. Regression: `tests/test_stats_db_busy_timeout.py`. (#87)
24
+ - **`cctally`'s cache-rebuild migration can no longer corrupt session history when it runs concurrently with a cache ingest.** The cache-`db` migration that wipes and recomputes derived state now acquires the same `cache.db.lock` `fcntl` flock that `sync_cache` holds — in one consistent `fcntl`→SQLite acquisition order — before its `BEGIN IMMEDIATE`, so the wipe and a concurrent ingest walk are mutually exclusive and the partial-walk straddle is structurally impossible; under contention the migration defers and retries on the next open rather than interleaving. (#105)
25
+ - **`cctally refresh-usage` no longer crashes when the current 5-hour window is inactive.** When the OAuth usage payload reports the 5h window with a `null` `resets_at` (no active window), `refresh-usage` previously fed that missing reset timestamp into 5h-window-key derivation and raised; the inactive window is now dropped instead.
26
+
8
27
  ## [1.13.0] - 2026-05-25
9
28
 
10
29
  ### Added
package/README.md CHANGED
@@ -113,7 +113,7 @@ For status-line integration, alerts, and configuration, see [docs/installation.m
113
113
 
114
114
  ## What cctally adds
115
115
 
116
- `cctally` started as a local-first replacement for [`ccusage`](https://github.com/ryoppippi/ccusage), and it stays compatible at the level of common CLI flows (`daily`, `monthly`, `weekly`, `session`, `blocks`). Beyond that, it adds:
116
+ `cctally` started as a local-first replacement for [`ccusage`](https://github.com/ryoppippi/ccusage), and it stays compatible at the level of common CLI flows (`daily`, `monthly`, `weekly`, `session`, `blocks`). Paste from ccusage verbatim: `cctally claude <cmd>` is a drop-in for `ccusage claude <cmd>` (and `cctally codex <cmd>` for `ccusage codex <cmd>`), with the flat forms (`cctally daily`, `cctally codex-daily`, …) kept as aliases. Beyond that, it adds:
117
117
 
118
118
  - **Live web dashboard.** Nine-panel SSE-driven view at `localhost:8789` (current week, forecast, trend, sessions, weekly, monthly, blocks, daily, recent alerts), with per-panel modals, a mobile layout, threshold alerts, and a settings drawer.
119
119
  - **TUI live mode.** The same data inside your terminal (`cctally tui`; requires the optional `rich` package).
@@ -123,7 +123,7 @@ For status-line integration, alerts, and configuration, see [docs/installation.m
123
123
  - **5-hour block analytics.** Per-block usage with model and project breakdowns (`cctally five-hour-blocks --breakdown=model`).
124
124
  - **Time-window diff.** Compare two windows with model and project decomposition (`cctally diff`).
125
125
  - **Project rollup.** Usage by Git project (`cctally project`).
126
- - **Codex parity.** Drop-in replacements for `ccusage-codex daily / monthly / session`, plus an added `cctally codex-weekly` rollup (upstream has no `codex weekly`).
126
+ - **Codex parity.** `cctally codex daily / monthly / session` are drop-ins for `ccusage codex daily / monthly / session`; the flat `codex-*` forms (drop-ins for the standalone `ccusage-codex` binary) remain as aliases, plus an added `cctally codex weekly` / `cctally codex-weekly` rollup (upstream has no `codex weekly`).
127
127
  - **Persistent SQLite.** Week-over-week comparisons survive across runs.
128
128
 
129
129
  **On speed.** Pricing is embedded and computed at query time from a delta-tail SQLite cache (`~/.local/share/cctally/cache.db`), with no shell-outs. First-table latency on 30 days of session data: **~2.6s (cctally) vs ~31s (ccusage)**, about 12× faster. Measured by `bench/cctally-vs-ccusage.sh` on macOS arm64, 2026-05-05; your numbers will vary.[^bench]
@@ -58,7 +58,7 @@ def _init_paths_from_env() -> None:
58
58
  break tests that cached the module object via a top-level
59
59
  `import _cctally_core`).
60
60
  """
61
- global APP_DIR, LEGACY_APP_DIR, LOG_DIR
61
+ global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
62
62
  global DB_PATH, CACHE_DB_PATH
63
63
  global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CONFIG_LOCK_PATH
64
64
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
@@ -70,7 +70,23 @@ def _init_paths_from_env() -> None:
70
70
  global CLAUDE_PROJECTS_DIR
71
71
 
72
72
  home = pathlib.Path.home()
73
- APP_DIR = home / ".local" / "share" / "cctally"
73
+
74
+ # Dev-instance isolation (docs/superpowers/specs/2026-05-26-dev-instance-
75
+ # isolation-design.md). Resolve the APP_DIR base first; all other path
76
+ # constants derive from it. First match wins:
77
+ # 1. explicit CCTALLY_DATA_DIR override (also the test/harness pin)
78
+ # 2. auto-detected dev checkout -> cctally-dev (sets DEV_MODE)
79
+ # 3. prod default (byte-identical to pre-feature behavior)
80
+ _data_dir_override = os.environ.get("CCTALLY_DATA_DIR", "").strip()
81
+ if _data_dir_override:
82
+ APP_DIR = pathlib.Path(_data_dir_override).expanduser()
83
+ DEV_MODE = False
84
+ elif _is_dev_checkout():
85
+ APP_DIR = home / ".local" / "share" / "cctally-dev"
86
+ DEV_MODE = True
87
+ else:
88
+ APP_DIR = home / ".local" / "share" / "cctally"
89
+ DEV_MODE = False
74
90
  LEGACY_APP_DIR = home / ".local" / "share" / "ccusage-subscription"
75
91
  LOG_DIR = APP_DIR / "logs"
76
92
 
@@ -122,6 +138,27 @@ def _init_paths_from_env() -> None:
122
138
  CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
123
139
 
124
140
 
141
+ def _repo_root() -> pathlib.Path:
142
+ """Repo root when running from a source checkout: this file lives at
143
+ ``<repo>/bin/_cctally_core.py``, so the root is two parents up. Factored
144
+ out as the single monkeypatch seam for the dev-mode tests."""
145
+ return pathlib.Path(__file__).resolve().parent.parent
146
+
147
+
148
+ def _is_dev_checkout() -> bool:
149
+ """True iff running from a git checkout (a ``.git`` entry at the repo
150
+ root — a directory for a main checkout, a file for a worktree) AND the
151
+ test/harness suppressor ``CCTALLY_DISABLE_DEV_AUTODETECT`` is unset.
152
+
153
+ Deliberately INDEPENDENT of ``CCTALLY_DATA_DIR``: this predicate gates
154
+ the ``setup`` guard (which protects WHICH BINARY gets wired into
155
+ ~/.claude/settings.json), not the data-dir relocation. The npm/brew
156
+ install copies ship without ``.git`` so they never read True."""
157
+ if os.environ.get("CCTALLY_DISABLE_DEV_AUTODETECT"):
158
+ return False
159
+ return (_repo_root() / ".git").exists()
160
+
161
+
125
162
  _init_paths_from_env()
126
163
 
127
164
 
@@ -494,6 +531,17 @@ def open_db() -> sqlite3.Connection:
494
531
  conn.row_factory = sqlite3.Row
495
532
  conn.execute("PRAGMA journal_mode=WAL")
496
533
  conn.execute("PRAGMA synchronous=NORMAL")
534
+ # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
535
+ # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
536
+ # so this is not a behavior change — it makes the multi-writer retry window
537
+ # an explicit contract beside the WAL pragmas instead of an inherited
538
+ # default a reader has to know about. NOTE: busy_timeout does NOT absorb
539
+ # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
540
+ # invalidated by a competing commit raises "database is locked" instantly,
541
+ # bypassing the busy handler). The write paths defend against that by taking
542
+ # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
543
+ # first DML. See cctally-dev#87.
544
+ conn.execute("PRAGMA busy_timeout=5000")
497
545
  conn.execute(
498
546
  """
499
547
  CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
@@ -62,6 +62,7 @@ from __future__ import annotations
62
62
  import argparse
63
63
  import datetime as dt
64
64
  import enum
65
+ import fcntl
65
66
  import json
66
67
  import os
67
68
  import pathlib
@@ -2370,6 +2371,10 @@ def _recompute_banner_should_emit(
2370
2371
  confuses scripted pipelines. Banner still lands on the
2371
2372
  next interactive non-report command (``report``,
2372
2373
  ``weekly``, ``percent-breakdown``, etc.) once on upgrade.
2374
+ Subgroup forms (``cctally claude/codex <cmd>``, issue #86
2375
+ Session B) carry the source group in ``argv[1]`` and the
2376
+ leaf in ``argv[2]``; we resolve the leaf so suppression is
2377
+ byte-identical to the flat alias.
2373
2378
 
2374
2379
  Returns True iff the banner should be printed. Defensive: any
2375
2380
  error reading ``sys.argv`` falls back to "don't print" — silence
@@ -2387,6 +2392,14 @@ def _recompute_banner_should_emit(
2387
2392
  return False
2388
2393
  try:
2389
2394
  argv1 = sys.argv[1] if len(sys.argv) > 1 else None
2395
+ # Subgroup forms (`cctally claude <cmd>` / `cctally codex <cmd>`) carry
2396
+ # the source group in argv[1]; the suppression key is the leaf command
2397
+ # in argv[2]. Resolve it so the recompute banner suppresses identically
2398
+ # to the flat alias (issue #86 Session B; matches the args.command leaf
2399
+ # resolution used by the error-sentinel banner). Purely additive — flat
2400
+ # invocations (argv1 not in {claude,codex}) are byte-identical to before.
2401
+ if argv1 in ("claude", "codex") and len(sys.argv) > 2:
2402
+ argv1 = sys.argv[2]
2390
2403
  except Exception:
2391
2404
  argv1 = None
2392
2405
  if argv1 in _BANNER_SUPPRESSED_COMMANDS:
@@ -2416,6 +2429,35 @@ def _001_banner_should_emit(conn: sqlite3.Connection) -> bool:
2416
2429
  return _recompute_banner_should_emit(data_present=row is not None)
2417
2430
 
2418
2431
 
2432
+ def _cache_db_lock_path_for_conn(conn: sqlite3.Connection) -> "pathlib.Path | None":
2433
+ """Return the fcntl lock-file path for the cache.db a connection is
2434
+ attached to — ``<main-db-file>.lock`` — or ``None`` for a path-less
2435
+ (``:memory:`` / temp) connection.
2436
+
2437
+ Derived from the LIVE connection (``PRAGMA database_list``) rather than
2438
+ the ``CACHE_LOCK_PATH`` constant so it tracks whatever cache.db the
2439
+ handler was handed: production uses ``APP_DIR/cache.db`` whose sibling
2440
+ is exactly ``CACHE_LOCK_PATH`` (the lock ``sync_cache`` opens — the
2441
+ ``CACHE_LOCK_PATH == <CACHE_DB_PATH>.lock`` identity is asserted by
2442
+ ``tests/test_migration_gate_concurrency.py``), while tests follow their
2443
+ tmp cache.db so no real-home lock is ever touched. A path-less
2444
+ connection has no sibling lock file and no cross-process concurrency to
2445
+ guard, so the caller skips locking.
2446
+ """
2447
+ try:
2448
+ rows = conn.execute("PRAGMA database_list").fetchall()
2449
+ except sqlite3.DatabaseError:
2450
+ return None
2451
+ for row in rows:
2452
+ # cache.db connection has no row_factory -> tuple (seq, name, file).
2453
+ if row[1] == "main":
2454
+ db_file = row[2]
2455
+ if not db_file:
2456
+ return None # :memory: / temp -> no sibling lock file
2457
+ return pathlib.Path(str(db_file) + ".lock")
2458
+ return None
2459
+
2460
+
2419
2461
  @cache_migration("001_dedup_highest_wins")
2420
2462
  def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
2421
2463
  """One-time re-ingest of session_entries with corrected msg_id+req_id dedup.
@@ -2464,6 +2506,55 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
2464
2506
  handler time anyway. Interactive surfaces (``report``,
2465
2507
  ``weekly``, ``percent-breakdown``, etc.) still see it once.
2466
2508
  """
2509
+ # #105 — mutual exclusion with ``sync_cache``. Acquire the SAME
2510
+ # ``cache.db.lock`` fcntl flock ``sync_cache`` holds for its entire
2511
+ # walk, BEFORE the ``BEGIN IMMEDIATE`` below. Both paths therefore
2512
+ # acquire fcntl -> SQLite write lock in ONE consistent order, so there
2513
+ # is no opposite-order deadlock (the hazard that deferred this fix:
2514
+ # SQLite-then-fcntl in 001 vs fcntl-then-SQLite in sync_cache). With
2515
+ # the lock held across the wipe, 001's destructive DELETEs can never
2516
+ # interleave a ``sync_cache`` walk: a sync runs entirely before 001
2517
+ # (then 001 wipes ``session_files`` so the next sync re-ingests from
2518
+ # offset 0) or entirely after (reading an empty post-wipe baseline).
2519
+ # That makes the compound straddle — a sync reading its ``existing``
2520
+ # baseline pre-wipe, then committing a full-size ``session_files`` row
2521
+ # whose pre-wipe prefix 001 just deleted — structurally impossible.
2522
+ #
2523
+ # On contention (a sync is mid-walk) we DEFER via ``MigrationGateNotMet``
2524
+ # BEFORE touching any data: the cache stays fully consistent, the
2525
+ # dispatcher records 001 as still-pending (no error log, no banner) and
2526
+ # retries it on the next open — matching ``sync_cache``'s own
2527
+ # non-blocking LOCK_NB-and-bail and the framework's "defer is the safe
2528
+ # side" contract. 008/009/010 already defer while 001 is pending, so the
2529
+ # system stays safe until a non-contended instant applies it.
2530
+ lock_path = _cache_db_lock_path_for_conn(conn)
2531
+ lock_fh = None
2532
+ if lock_path is not None:
2533
+ lock_fh = open(lock_path, "w")
2534
+ try:
2535
+ fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
2536
+ except BlockingIOError:
2537
+ lock_fh.close()
2538
+ raise MigrationGateNotMet(
2539
+ "cache.db.lock held by a concurrent sync_cache; deferring "
2540
+ "cache 001 dedup wipe (#105)"
2541
+ )
2542
+ try:
2543
+ _001_dedup_highest_wins_locked(conn)
2544
+ finally:
2545
+ if lock_fh is not None:
2546
+ try:
2547
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
2548
+ except OSError:
2549
+ pass
2550
+ lock_fh.close()
2551
+
2552
+
2553
+ def _001_dedup_highest_wins_locked(conn: sqlite3.Connection) -> None:
2554
+ """Body of cache 001, run with the ``cache.db.lock`` flock already held
2555
+ (or skipped for a path-less connection). Split from the public handler
2556
+ so the lock acquire/release wraps the whole wipe (#105); see
2557
+ ``_001_dedup_highest_wins`` for the lock-ordering rationale."""
2467
2558
  if _001_banner_should_emit(conn):
2468
2559
  eprint(
2469
2560
  "[cctally] Re-ingesting Claude session history with "
@@ -799,7 +799,13 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
799
799
  # mid-sequence failure doesn't leave the prior block closed
800
800
  # without the current block opened/updated.
801
801
  now_iso = now_utc_iso()
802
- conn.execute("BEGIN")
802
+ # BEGIN IMMEDIATE (not deferred): the first DML below is a write (the
803
+ # close-older UPDATE), so this transaction already takes the write lock
804
+ # up front today. Stating IMMEDIATE makes that the explicit contract —
805
+ # a future edit that slips a SELECT before the first write here cannot
806
+ # silently reintroduce a SQLITE_BUSY_SNAPSHOT crash (busy_timeout does
807
+ # not absorb that). See cctally-dev#87.
808
+ conn.execute("BEGIN IMMEDIATE")
803
809
  try:
804
810
  # Step 5: close any STRICTLY OLDER open block. `<` not `!=`
805
811
  # — record-usage runs in parallel via background hook-tick &
@@ -577,7 +577,12 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
577
577
  five_resets_iso = None
578
578
  five_resets_epoch = None
579
579
  warnings: list = []
580
- if five is not None and "utilization" in five and "resets_at" in five:
580
+ # An inactive 5h window arrives as `resets_at: null` (key present, value
581
+ # null). Require a string here — mirrors the seven_day guard in
582
+ # _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
583
+ # a malformed (non-null) string still degrades via the except below.
584
+ if (five is not None and "utilization" in five
585
+ and isinstance(five.get("resets_at"), str)):
581
586
  try:
582
587
  five_pct = c._normalize_percent(float(five["utilization"]))
583
588
  five_resets_iso = five["resets_at"]
@@ -778,7 +783,12 @@ def _hook_tick_oauth_refresh(
778
783
  five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
779
784
  five_pct: float | None = None
780
785
  five_resets_epoch: int | None = None
781
- if five is not None and "utilization" in five and "resets_at" in five:
786
+ # An inactive 5h window arrives as `resets_at: null` (key present, value
787
+ # null). Require a string here — mirrors the seven_day guard in
788
+ # _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
789
+ # a malformed (non-null) string still degrades via the except below.
790
+ if (five is not None and "utilization" in five
791
+ and isinstance(five.get("resets_at"), str)):
782
792
  try:
783
793
  five_pct = c._normalize_percent(float(five["utilization"]))
784
794
  five_resets_epoch = _iso_to_epoch(five["resets_at"])
@@ -77,6 +77,39 @@ from _cctally_core import (
77
77
  )
78
78
 
79
79
 
80
+ # Dev-instance isolation (§3): refusal message when `cctally setup` is run
81
+ # from a git checkout without --force-dev. {data_dir} is the resolved
82
+ # APP_DIR for context (cctally-dev in plain dev mode, the override path if
83
+ # CCTALLY_DATA_DIR was set — the guard keys on _is_dev_checkout(), not the
84
+ # data dir, so the override still cannot rewrite prod's hooks).
85
+ _DEV_SETUP_REFUSAL_MSG = (
86
+ "cctally setup: refusing to run from a dev checkout (data dir: {data_dir}).\n"
87
+ "This would rewrite the hooks in ~/.claude/settings.json that point at your\n"
88
+ "installed (prod) cctally. Run setup from the installed binary instead, or\n"
89
+ "pass --force-dev to override (e.g. to install dev-pointing hooks on purpose)."
90
+ )
91
+
92
+
93
+ # Dev-instance isolation (§3, P2): warning when `--force-dev` installs hooks
94
+ # while CCTALLY_DATA_DIR is set. The hook command saved into settings.json is
95
+ # just `<binary> hook-tick` — it does NOT carry the override env. A hook fire
96
+ # that doesn't inherit the override (GUI-launched Claude, a different shell)
97
+ # resolves APP_DIR via dev-checkout auto-detect ({autodetect_dir}), while
98
+ # interactive runs in this shell use {override_dir} — silently splitting one
99
+ # intended instance across two DBs. CCTALLY_DATA_DIR is an interactive-only
100
+ # hatch (spec "Out of scope / accepted"); baking it into the global hook
101
+ # command would persist a transient path machine-wide, so we warn instead.
102
+ _DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING = (
103
+ "cctally setup: warning: installing hooks with --force-dev while "
104
+ "CCTALLY_DATA_DIR is set.\n"
105
+ " Interactive runs in this shell use: {override_dir}\n"
106
+ " Background hook fires (no env inherited) will use: {autodetect_dir}\n"
107
+ "The hook command can't carry CCTALLY_DATA_DIR, so these two paths split "
108
+ "your\ndata across separate DBs. CCTALLY_DATA_DIR is an interactive-only "
109
+ "override."
110
+ )
111
+
112
+
80
113
  # ── settings.json hook surgery ─────────────────────────────────────────
81
114
 
82
115
 
@@ -1818,6 +1851,53 @@ def _setup_install(args: argparse.Namespace) -> int:
1818
1851
 
1819
1852
 
1820
1853
  def cmd_setup(args: argparse.Namespace) -> int:
1854
+ # Dev-instance isolation (§3): refuse the MUTATING modes (install +
1855
+ # uninstall) when run from a git checkout, unless --force-dev. Those
1856
+ # rewrite ~/.claude/settings.json (prod's hooks), which is NOT under
1857
+ # APP_DIR — from the dev checkout this would repoint prod's hooks at the
1858
+ # dev binary or remove them. --status / --dry-run are read-only previews
1859
+ # (they never write settings.json) and stay usable from a checkout, so
1860
+ # the guard is scoped to the write modes only. The three mode flags are a
1861
+ # mutually-exclusive argparse group, so the write modes (uninstall +
1862
+ # default install) are exactly the complement of {status, dry_run}.
1863
+ # Keyed on _is_dev_checkout() (NOT DEV_MODE / the cctally-dev path
1864
+ # string), so a per-branch CCTALLY_DATA_DIR override relocates the data
1865
+ # dir but still cannot rewrite prod's hooks (the F1 fix). The test
1866
+ # suppressor forces _is_dev_checkout() False, so the setup tests +
1867
+ # golden harness behave exactly like prod.
1868
+ mode_is_mutating = not (
1869
+ getattr(args, "status", False) or getattr(args, "dry_run", False)
1870
+ )
1871
+ if (
1872
+ mode_is_mutating
1873
+ and _cctally_core._is_dev_checkout()
1874
+ and not getattr(args, "force_dev", False)
1875
+ ):
1876
+ eprint(_DEV_SETUP_REFUSAL_MSG.format(data_dir=_cctally_core.APP_DIR))
1877
+ return 2
1878
+ # P2: --force-dev install on a checkout with CCTALLY_DATA_DIR set splits
1879
+ # interactive runs (override dir) from background hook fires (auto-detect
1880
+ # dir, since the saved hook command can't carry the override env). Only
1881
+ # the install path writes hooks, so scope the warning to it (uninstall
1882
+ # removes hooks; --status/--dry-run don't write). Fires only on the
1883
+ # doubly-rare --force-dev + CCTALLY_DATA_DIR combination.
1884
+ is_install = not (
1885
+ getattr(args, "status", False)
1886
+ or getattr(args, "dry_run", False)
1887
+ or getattr(args, "uninstall", False)
1888
+ )
1889
+ override_dir = os.environ.get("CCTALLY_DATA_DIR", "").strip()
1890
+ if (
1891
+ is_install
1892
+ and _cctally_core._is_dev_checkout()
1893
+ and getattr(args, "force_dev", False)
1894
+ and override_dir
1895
+ ):
1896
+ autodetect_dir = pathlib.Path.home() / ".local" / "share" / "cctally-dev"
1897
+ eprint(_DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING.format(
1898
+ override_dir=pathlib.Path(override_dir).expanduser(),
1899
+ autodetect_dir=autodetect_dir,
1900
+ ))
1821
1901
  # Migration flags are install-mode-only. Reject combinations with
1822
1902
  # --status or --uninstall (per spec Section 2 mode×flag matrix). The
1823
1903
  # mutex group on the parser already prevents both flags being set
@@ -590,7 +590,13 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
590
590
  cached_input_tokens=s["cached_input"],
591
591
  output_tokens=s["output"],
592
592
  reasoning_output_tokens=s["reasoning"],
593
- total_tokens=s["input"] + s["output"], # derived, matches upstream
593
+ # Codex `input` is cache-inclusive (LiteLLM convention; see the
594
+ # "Codex token semantics" gotcha in CLAUDE.md) and `output`
595
+ # subsumes reasoning, so `input + output` already counts ALL
596
+ # tokens processed — the same "all tokens" semantic the Claude
597
+ # session roll-up reaches via input+output+cache (issue #104).
598
+ # Adding cache here would double-count. Matches upstream.
599
+ total_tokens=s["input"] + s["output"],
594
600
  cost_usd=s["cost"],
595
601
  models=list(s["models_order"]),
596
602
  model_breakdowns=model_breakdowns,
@@ -695,10 +701,17 @@ def _aggregate_claude_sessions(
695
701
  [sess["models"][m] for m in sess["models_order"]],
696
702
  key=lambda mb: -mb["cost"],
697
703
  )
698
- # Spec A2.8 (design.md:422): Total Tokens = input + output only;
699
- # cache tokens shown separately but not summed — parallels
700
- # `codex-session` (see `_codex_sessions_to_json`, line ~3603).
701
- total_tokens = sess["input"] + sess["output"]
704
+ # Issue #104: Total Tokens sums ALL four components (input + output
705
+ # + cache create + cache read), matching `daily`/`monthly` and
706
+ # upstream ccusage v20. (Supersedes the original Spec A2.8
707
+ # input+output-only convention.) The `codex-session` parallel is
708
+ # preserved at the SEMANTIC level — both report "all tokens
709
+ # processed" — even though its surface formula stays `input+output`
710
+ # (Codex `input_tokens` is already cache-inclusive; see line ~593).
711
+ total_tokens = (
712
+ sess["input"] + sess["output"]
713
+ + sess["cache_create"] + sess["cache_read"]
714
+ )
702
715
  results.append(ClaudeSessionUsage(
703
716
  session_id=sess["session_id"],
704
717
  project_path=sess["project_path"],
@@ -94,6 +94,15 @@ class DoctorState:
94
94
  # Meta
95
95
  now_utc: dt.datetime
96
96
  cctally_version: str
97
+ # Dev-instance isolation (2026-05-26): which data dir this process
98
+ # resolved, and whether it was via dev-checkout auto-detect.
99
+ # `is_dev_checkout` is the binary-location fact (running from a git
100
+ # checkout), independent of `dev_mode` (which is False when an explicit
101
+ # CCTALLY_DATA_DIR override won at step 1). The override-on-checkout case
102
+ # is `is_dev_checkout=True, dev_mode=False` — distinct from installed.
103
+ dev_mode: bool
104
+ app_dir: str
105
+ is_dev_checkout: bool = False
97
106
 
98
107
 
99
108
  @dataclasses.dataclass(frozen=True)
@@ -212,6 +221,35 @@ def _check_install_legacy_bespoke(s: DoctorState) -> CheckResult:
212
221
  )
213
222
 
214
223
 
224
+ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
225
+ """Always-present, always-ok: reports the resolved data dir and whether
226
+ this process is a dev-checkout or the installed binary.
227
+ Dev-instance isolation (§4, P3).
228
+
229
+ Three states, not two — `dev_mode` alone collapses the override case:
230
+ - dev_mode → auto-detected checkout (cctally-dev)
231
+ - is_dev_checkout, not dev_mode → checkout + CCTALLY_DATA_DIR override
232
+ - neither → installed (prod)
233
+ Reporting the override case as "installed" was misleading exactly when a
234
+ user runs the per-branch hatch and wants to confirm which instance they
235
+ are on (the binary IS a checkout; setup still refuses it as one)."""
236
+ if s.dev_mode:
237
+ summary = "DEV (auto-detected git checkout)"
238
+ elif s.is_dev_checkout:
239
+ summary = "DEV (git checkout, custom data dir via CCTALLY_DATA_DIR)"
240
+ else:
241
+ summary = "installed"
242
+ return CheckResult(
243
+ id="install.mode", title="Mode",
244
+ severity="ok", summary=summary, remediation=None,
245
+ details={
246
+ "dev_mode": s.dev_mode,
247
+ "is_dev_checkout": s.is_dev_checkout,
248
+ "app_dir": s.app_dir,
249
+ },
250
+ )
251
+
252
+
215
253
  _REQUIRED_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
216
254
 
217
255
 
@@ -840,6 +878,7 @@ def _check_safety_update_available(s: DoctorState) -> CheckResult:
840
878
  # success-vs-raise transitions.
841
879
  _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
842
880
  ("install", "Install", (
881
+ ("install.mode", "_check_install_dev_mode"),
843
882
  ("install.symlinks", "_check_install_symlinks"),
844
883
  ("install.path", "_check_install_path"),
845
884
  ("install.legacy_snippet", "_check_install_legacy_snippet"),
@@ -1006,13 +1006,18 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
1006
1006
 
1007
1007
 
1008
1008
  def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
1009
- """Serialize Claude sessions to JSON per spec A2.8.
1009
+ """Serialize Claude sessions to JSON (spec A2.8, amended by issue #104).
1010
1010
 
1011
1011
  Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
1012
1012
  / lastActivity ISO strings, modelsUsed, token counts
1013
1013
  (input/cacheCreation/cacheRead/output/total), totalCost, modelBreakdowns
1014
1014
  (camelCased token field names, cost).
1015
1015
 
1016
+ `totalTokens` (per-session + totals) sums ALL four token components
1017
+ (input + output + cacheCreation + cacheRead) per issue #104 — matching
1018
+ `daily`/`monthly` and ccusage v20. (The field name/shape is unchanged;
1019
+ only the value definition widened to include cache.)
1020
+
1016
1021
  totals: same 6 numeric fields aggregated across sessions.
1017
1022
  """
1018
1023
  sess_list: list[dict[str, Any]] = []
@@ -2118,12 +2123,23 @@ def _render_codex_session_table(
2118
2123
  # auto-detect so the narrow layout renders regardless of terminal width.
2119
2124
  if force_compact or (sum(col_widths) + border_overhead > term_width):
2120
2125
  available = term_width - border_overhead
2121
- total_col = sum(col_widths)
2122
- scale = available / total_col if total_col > 0 else 1.0
2123
- col_widths = [max(int(w * scale), 8) for w in col_widths]
2124
- remainder = available - sum(col_widths)
2125
- if remainder > 0:
2126
- col_widths[3] += remainder # grow Models column
2126
+ # Issue #99 / #102: the prior bare-`8` floor could scale a column
2127
+ # below its header label, and headers are padded (never truncated)
2128
+ # in the header render so the header row grew wider than the box
2129
+ # border and the grid misaligned on narrow terminals. Mirror the
2130
+ # sibling renderers (`_render_claude_session_table` + the project
2131
+ # renderer) via the shared `_scale_down_col_widths` chokepoint:
2132
+ # numeric columns are protected at their widest DATA value while
2133
+ # text columns (incl. header labels) absorb the squeeze and may
2134
+ # truncate, keeping every box line the same width. Grows the Models
2135
+ # column (index 3) with any slack, preserving prior behavior.
2136
+ data_widths = [0] * num_cols
2137
+ for cells, _rt in raw_rows:
2138
+ for i, (text, _c) in enumerate(cells):
2139
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2140
+ col_widths = _scale_down_col_widths(
2141
+ col_widths, aligns, data_widths, available, grow_idx=3,
2142
+ )
2127
2143
 
2128
2144
  def _split_cell(text: str) -> list[str]:
2129
2145
  return text.split("\n") if text else [""]
@@ -2165,13 +2181,17 @@ def _render_codex_session_table(
2165
2181
 
2166
2182
  out.append(_border_row(TL, T_DOWN, TR))
2167
2183
 
2168
- # Header
2184
+ # Header — labels ellipsize like data cells so a column scaled below
2185
+ # its header width stays box-aligned (issue #99 / #102 (a)). Previously
2186
+ # the header padded without truncating, so a label wider than its
2187
+ # scaled column overflowed the box border.
2169
2188
  header_cells = [_split_cell(h) for h in headers]
2170
2189
  max_h = max(len(c) for c in header_cells)
2171
2190
  for li in range(max_h):
2172
2191
  parts = [_dim(V)]
2173
2192
  for i, cell in enumerate(header_cells):
2174
2193
  content = cell[li] if li < len(cell) else ""
2194
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2175
2195
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2176
2196
  parts.append(_dim(V))
2177
2197
  out.append("".join(parts))
@@ -2187,11 +2207,14 @@ def _render_codex_session_table(
2187
2207
  parts = [_dim(V)]
2188
2208
  for i, (text, cfn) in enumerate(cells):
2189
2209
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2190
- # Truncate with ellipsis if cell content exceeds column width
2210
+ # Ellipsis-truncate only TEXT cells. Numeric (right-aligned)
2211
+ # cells are NEVER truncated \u2014 a wrong number is worse than
2212
+ # honest overflow (issue #102 (b)); _scale_down_col_widths
2213
+ # floors numeric columns at their full number width so this
2214
+ # normally never overflows. Mirrors the sibling renderers.
2191
2215
  w = col_widths[i]
2192
- if len(content) > w:
2193
- ell = "\u2026" if unicode_ok else "..."
2194
- content = content[: max(0, w - len(ell))] + ell
2216
+ if aligns[i] != "right":
2217
+ content = _ellipsize(content, w, unicode_ok)
2195
2218
  padded = _pad_cell(content, w, aligns[i])
2196
2219
  if cfn is not None:
2197
2220
  padded = cfn(padded)
@@ -2281,9 +2304,10 @@ def _render_claude_session_table(
2281
2304
  for s in sessions:
2282
2305
  short_models = sorted({_short_model_name(m) for m in s.models})
2283
2306
  models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
2284
- # Spec A2.8: Total Tokens = input + output (cache shown separately,
2285
- # not summed). Parallels `_render_codex_session_table` line ~4644.
2286
- session_total = s.input_tokens + s.output_tokens
2307
+ # Issue #104: Total Tokens = all four components (input + output +
2308
+ # cache), matching daily/monthly + ccusage v20. Read the single
2309
+ # source of truth on the aggregate rather than recomputing.
2310
+ session_total = s.total_tokens
2287
2311
  data_cells = [
2288
2312
  (_date_cell(s.last_activity), None),
2289
2313
  (s.project_path, None),
@@ -2306,8 +2330,8 @@ def _render_claude_session_table(
2306
2330
  mb_cc = int(mb["cache_create"])
2307
2331
  mb_cr = int(mb["cache_read"])
2308
2332
  mb_output = int(mb["output"])
2309
- # Spec A2.8: Total Tokens = input + output only.
2310
- mb_total = mb_input + mb_output
2333
+ # Issue #104: per-model Total Tokens sums all four components.
2334
+ mb_total = mb_input + mb_output + mb_cc + mb_cr
2311
2335
  mb_cost = float(mb["cost"])
2312
2336
  bd_cells = [
2313
2337
  (f"{arrow} {name}", _gray),
@@ -2328,8 +2352,8 @@ def _render_claude_session_table(
2328
2352
  tot_cc = sum(s.cache_creation_tokens for s in sessions)
2329
2353
  tot_cr = sum(s.cache_read_tokens for s in sessions)
2330
2354
  tot_output = sum(s.output_tokens for s in sessions)
2331
- # Spec A2.8: Total Tokens = input + output only.
2332
- tot_tokens = tot_input + tot_output
2355
+ # Issue #104: Total Tokens footer sums all four components.
2356
+ tot_tokens = sum(s.total_tokens for s in sessions)
2333
2357
  tot_cost = sum(s.cost_usd for s in sessions)
2334
2358
  footer_cells = [
2335
2359
  ("Total", _yellow),