cctally 1.13.0 → 1.14.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,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.14.0] - 2026-05-26
9
+
10
+ ### Added
11
+ - **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).
12
+
13
+ ### Changed
14
+ - **`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)
15
+
16
+ ### Fixed
17
+ - **`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)
18
+ - **`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)
19
+ - **`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)
20
+ - **`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.
21
+
8
22
  ## [1.13.0] - 2026-05-25
9
23
 
10
24
  ### Added
@@ -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
@@ -2416,6 +2417,35 @@ def _001_banner_should_emit(conn: sqlite3.Connection) -> bool:
2416
2417
  return _recompute_banner_should_emit(data_present=row is not None)
2417
2418
 
2418
2419
 
2420
+ def _cache_db_lock_path_for_conn(conn: sqlite3.Connection) -> "pathlib.Path | None":
2421
+ """Return the fcntl lock-file path for the cache.db a connection is
2422
+ attached to — ``<main-db-file>.lock`` — or ``None`` for a path-less
2423
+ (``:memory:`` / temp) connection.
2424
+
2425
+ Derived from the LIVE connection (``PRAGMA database_list``) rather than
2426
+ the ``CACHE_LOCK_PATH`` constant so it tracks whatever cache.db the
2427
+ handler was handed: production uses ``APP_DIR/cache.db`` whose sibling
2428
+ is exactly ``CACHE_LOCK_PATH`` (the lock ``sync_cache`` opens — the
2429
+ ``CACHE_LOCK_PATH == <CACHE_DB_PATH>.lock`` identity is asserted by
2430
+ ``tests/test_migration_gate_concurrency.py``), while tests follow their
2431
+ tmp cache.db so no real-home lock is ever touched. A path-less
2432
+ connection has no sibling lock file and no cross-process concurrency to
2433
+ guard, so the caller skips locking.
2434
+ """
2435
+ try:
2436
+ rows = conn.execute("PRAGMA database_list").fetchall()
2437
+ except sqlite3.DatabaseError:
2438
+ return None
2439
+ for row in rows:
2440
+ # cache.db connection has no row_factory -> tuple (seq, name, file).
2441
+ if row[1] == "main":
2442
+ db_file = row[2]
2443
+ if not db_file:
2444
+ return None # :memory: / temp -> no sibling lock file
2445
+ return pathlib.Path(str(db_file) + ".lock")
2446
+ return None
2447
+
2448
+
2419
2449
  @cache_migration("001_dedup_highest_wins")
2420
2450
  def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
2421
2451
  """One-time re-ingest of session_entries with corrected msg_id+req_id dedup.
@@ -2464,6 +2494,55 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
2464
2494
  handler time anyway. Interactive surfaces (``report``,
2465
2495
  ``weekly``, ``percent-breakdown``, etc.) still see it once.
2466
2496
  """
2497
+ # #105 — mutual exclusion with ``sync_cache``. Acquire the SAME
2498
+ # ``cache.db.lock`` fcntl flock ``sync_cache`` holds for its entire
2499
+ # walk, BEFORE the ``BEGIN IMMEDIATE`` below. Both paths therefore
2500
+ # acquire fcntl -> SQLite write lock in ONE consistent order, so there
2501
+ # is no opposite-order deadlock (the hazard that deferred this fix:
2502
+ # SQLite-then-fcntl in 001 vs fcntl-then-SQLite in sync_cache). With
2503
+ # the lock held across the wipe, 001's destructive DELETEs can never
2504
+ # interleave a ``sync_cache`` walk: a sync runs entirely before 001
2505
+ # (then 001 wipes ``session_files`` so the next sync re-ingests from
2506
+ # offset 0) or entirely after (reading an empty post-wipe baseline).
2507
+ # That makes the compound straddle — a sync reading its ``existing``
2508
+ # baseline pre-wipe, then committing a full-size ``session_files`` row
2509
+ # whose pre-wipe prefix 001 just deleted — structurally impossible.
2510
+ #
2511
+ # On contention (a sync is mid-walk) we DEFER via ``MigrationGateNotMet``
2512
+ # BEFORE touching any data: the cache stays fully consistent, the
2513
+ # dispatcher records 001 as still-pending (no error log, no banner) and
2514
+ # retries it on the next open — matching ``sync_cache``'s own
2515
+ # non-blocking LOCK_NB-and-bail and the framework's "defer is the safe
2516
+ # side" contract. 008/009/010 already defer while 001 is pending, so the
2517
+ # system stays safe until a non-contended instant applies it.
2518
+ lock_path = _cache_db_lock_path_for_conn(conn)
2519
+ lock_fh = None
2520
+ if lock_path is not None:
2521
+ lock_fh = open(lock_path, "w")
2522
+ try:
2523
+ fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
2524
+ except BlockingIOError:
2525
+ lock_fh.close()
2526
+ raise MigrationGateNotMet(
2527
+ "cache.db.lock held by a concurrent sync_cache; deferring "
2528
+ "cache 001 dedup wipe (#105)"
2529
+ )
2530
+ try:
2531
+ _001_dedup_highest_wins_locked(conn)
2532
+ finally:
2533
+ if lock_fh is not None:
2534
+ try:
2535
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
2536
+ except OSError:
2537
+ pass
2538
+ lock_fh.close()
2539
+
2540
+
2541
+ def _001_dedup_highest_wins_locked(conn: sqlite3.Connection) -> None:
2542
+ """Body of cache 001, run with the ``cache.db.lock`` flock already held
2543
+ (or skipped for a path-less connection). Split from the public handler
2544
+ so the lock acquire/release wraps the whole wipe (#105); see
2545
+ ``_001_dedup_highest_wins`` for the lock-ordering rationale."""
2467
2546
  if _001_banner_should_emit(conn):
2468
2547
  eprint(
2469
2548
  "[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),
package/bin/cctally CHANGED
@@ -909,6 +909,17 @@ def _migrate_legacy_data_dir() -> None:
909
909
  Removable in a future major version once early users have been on
910
910
  cctally long enough that the legacy dir is gone everywhere.
911
911
  """
912
+ # Dev-instance isolation (F2): the legacy ccusage-subscription rename is a
913
+ # PROD-only concern. Skip it whenever the data dir was relocated away from
914
+ # the canonical prod path — i.e. dev-checkout auto-detect (DEV_MODE) or an
915
+ # explicit CCTALLY_DATA_DIR override — so a dev run (APP_DIR = cctally-dev)
916
+ # or a per-branch override never hijacks the one-shot move into the wrong
917
+ # dir. "not DEV_MODE and not CCTALLY_DATA_DIR" is exactly the prod-default
918
+ # resolution branch, i.e. APP_DIR == ~/.local/share/cctally. Under the test
919
+ # suppressor DEV_MODE is False and the existing migration tests (which pin
920
+ # APP_DIR directly, no override) still exercise the move.
921
+ if _cctally_core.DEV_MODE or os.environ.get("CCTALLY_DATA_DIR", "").strip():
922
+ return
912
923
  if _cctally_core.APP_DIR.exists():
913
924
  return # already migrated, or fresh install at the new path
914
925
  if not _cctally_core.LEGACY_APP_DIR.exists():
@@ -3369,7 +3380,16 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
3369
3380
  now_iso = now_utc_iso()
3370
3381
  now_dt = parse_iso_datetime(now_iso, "now")
3371
3382
 
3372
- conn.execute("BEGIN")
3383
+ # BEGIN IMMEDIATE (not deferred): this transaction's first DML is a
3384
+ # READ (min_row/max_row below), so a plain deferred BEGIN takes a read
3385
+ # snapshot and only tries to upgrade to the write lock at the first
3386
+ # INSERT OR IGNORE. Under concurrent first-run openers, a competing
3387
+ # commit landing between that read and the first write makes the upgrade
3388
+ # fail with SQLITE_BUSY_SNAPSHOT *immediately* — busy_timeout cannot
3389
+ # absorb it, and the whole backfill rolls back. Acquiring the write lock
3390
+ # up front serializes the backfill cleanly behind busy_timeout instead.
3391
+ # See cctally-dev#87.
3392
+ conn.execute("BEGIN IMMEDIATE")
3373
3393
  try:
3374
3394
  for key in keys:
3375
3395
  # MIN-captured row defines the immutable block boundary
@@ -9715,6 +9735,10 @@ def doctor_gather_state(
9715
9735
  effective_update_reason=effective_update_reason,
9716
9736
  now_utc=now_utc,
9717
9737
  cctally_version=cctally_version,
9738
+ # Dev-instance isolation (§4): which data dir resolved + how.
9739
+ dev_mode=_cctally_core.DEV_MODE,
9740
+ app_dir=str(_cctally_core.APP_DIR),
9741
+ is_dev_checkout=_cctally_core._is_dev_checkout(),
9718
9742
  )
9719
9743
 
9720
9744
 
@@ -11448,6 +11472,9 @@ def build_parser() -> argparse.ArgumentParser:
11448
11472
  help="Skip confirmations")
11449
11473
  sp.add_argument("--json", action="store_true",
11450
11474
  help="Emit machine-readable output")
11475
+ sp.add_argument("--force-dev", action="store_true", dest="force_dev",
11476
+ help="Allow setup to run from a dev checkout (writes "
11477
+ "dev-pointing hooks into ~/.claude/settings.json)")
11451
11478
  # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
11452
11479
  # post-parse validation). Spec Section 2 mode×flag matrix.
11453
11480
  mig_group = sp.add_mutually_exclusive_group()
@@ -13495,10 +13522,19 @@ def main(argv: list[str] | None = None) -> int:
13495
13522
  # works without a subcommand (`cctally --version`).
13496
13523
  if getattr(args, "version", False):
13497
13524
  v = _lib_changelog._read_latest_changelog_version()
13498
- if v is None:
13499
- print("cctally unknown")
13500
- else:
13501
- print(f"cctally {v[0]}")
13525
+ base = "cctally unknown" if v is None else f"cctally {v[0]}"
13526
+ # Dev-instance isolation (§4, P3): append the dev marker + resolved
13527
+ # data dir whenever running from a checkout — keyed on
13528
+ # _is_dev_checkout(), NOT DEV_MODE, so the CCTALLY_DATA_DIR
13529
+ # override-on-checkout case (DEV_MODE False) still shows the marker
13530
+ # instead of masquerading as the installed binary. Prod (no .git)
13531
+ # output is unchanged. The override case is labelled distinctly so
13532
+ # the user can tell auto-detect (cctally-dev) from an explicit dir.
13533
+ if _cctally_core.DEV_MODE:
13534
+ base += f" (dev — {_cctally_core.APP_DIR})"
13535
+ elif _cctally_core._is_dev_checkout():
13536
+ base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
13537
+ print(base)
13502
13538
  return 0
13503
13539
  if not getattr(args, "func", None):
13504
13540
  parser.error("a subcommand is required")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {