cctally 1.12.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,35 @@ 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
+
22
+ ## [1.13.0] - 2026-05-25
23
+
24
+ ### Added
25
+ - **Every Claude reporting command now accepts the `ccusage` flag surface, so `ccusage <cmd> [flags]` invocations paste into `cctally` unchanged.** Across all 10 reporting subcommands (`daily`, `monthly`, `weekly`, `session`, `blocks`, `five-hour-blocks`, `project`, `diff`, `range-cost`, `cache-report`): `-z`/`--timezone` aliases the existing `--tz`; the 8 date-taking commands accept `--since`/`--until` in both `YYYY-MM-DD` and `YYYYMMDD` forms; `--compact` forces compact table layout; `--color`/`--no-color` (plus the `NO_COLOR` / `FORCE_COLOR` env vars) control ANSI on the color-emitting commands (`project`, `diff`) and are accepted-but-inert elsewhere; and `-O`/`--offline`, `--single-thread`, `-d`/`--debug`, and `--config` are accepted as documented no-ops where cctally has no divergent behavior. A top-level `-v` short alias for `--version` is also added. The pass is purely additive — no existing output changes. (#86)
26
+ - **`cctally session` gains `-i`/`--id <session-id>` to filter to a single session.** Exact-string match against the post-resume-merge `sessionId`; an unknown id renders empty and exits 0. (#86)
27
+ - **`--config <path>` is now a real per-invocation config override.** Previously a documented no-op from the flag-alias pass, `--config` now loads configuration from the given path for that invocation only, leaving the persisted `~/.local/share/cctally/config.json` untouched. (#88)
28
+ - **`-d`/`--debug` now emits a real "Pricing Mismatch Debug Report" on stderr for the Claude reporting commands.** The report compares each entry's recorded `costUSD` against the token-recomputed cost, surfacing totals + per-model stats + a sample of the largest discrepancies, matching ccusage's `printMismatchReport` shape. `--debug-samples N` caps the sample block (default 5; `N=0` prints totals only; negative N rejected at parse time). The report goes to stderr only — `--json` / `--format` pipelines stay byte-stable — and `diff` emits one report per window. (#89)
29
+ - **`--compact` now reshapes output on the 5 reporting commands where it was previously accepted-but-inert: `five-hour-blocks`, `project`, `diff`, `range-cost`, and `cache-report`.** Brings them in line with the commands that already honored `--compact`. (#91)
30
+ - **`codex-daily` / `codex-monthly` / `codex-weekly` / `codex-session` now accept `-d/--debug` + `--debug-samples N`**, extending the Claude-side `--debug` diagnostic surface to the Codex commands. Because Codex JSONL records no `costUSD` to diff against, the report is a Codex variant — a "Codex Pricing Debug Report" on stderr with totals (entries processed, models seen, total computed cost) plus a "Sample Top Entries" block of the N highest computed-cost entries (`Recorded cost: (none)` per sample; `gpt-5`-fallback models tagged `(fallback→gpt-5)`). `--debug-samples` caps the sample block (default 5; `N=0` prints totals only). (#92)
31
+
32
+ ### Fixed
33
+ - **`project --compact` and `session --compact` no longer corrupt numeric values or overflow the terminal on narrow widths.** The header-width column floor added to fix box misalignment had two flip-side bugs on sub-fit terminals: (A) any cell wider than its column was ellipsis-truncated — including numeric columns, so a token count like `12,345,678` rendered as the silently-wrong `12,345,…`; and (B) when the header floors summed past the available width nothing shrank them, so the box drew WIDER than the terminal. The scale-down policy is now: numeric (right-aligned) columns are floored at their full value width and are never ellipsis-truncated (a wrong number is worse than honest overflow), while text columns — and their header labels, which now truncate the same way data cells do — absorb the squeeze so the table fits when the numbers allow and stays box-aligned when they don't. Shared `_scale_down_col_widths` + `_ellipsize` helpers centralize the policy across both renderers. The codex session renderer is intentionally left as-is (ccusage/codex parity). Regression: `tests/test_compact_rendering.py` (numeric-never-truncated + box-fits + unit coverage); project goldens regenerated. (#102)
34
+ - **`cache-report --since/--until` again accept space-separated datetimes (`'2026-05-01 12:30:00'`) and ISO week-dates (`2026-W18-1`).** The Session A dual-form refactor replaced cache-report's date-only fallthrough with a parser that rejects anything other than `YYYY-MM-DD` / `YYYYMMDD`, silently dropping the other forms that `datetime.fromisoformat` (and the pre-refactor code) accepted — they now failed with `--since must be YYYY-MM-DD or YYYYMMDD format`. The `parse_iso_datetime` second-chance is restored for inputs the dual-form parser rejects; a full datetime is used verbatim (no `--until` end-of-day rounding), matching the old behavior. Genuinely invalid input (e.g. `26-01-01`) still surfaces the clearer centralized `YYYY-MM-DD or YYYYMMDD` diagnostic rather than the generic ISO message, and the dual-form parse is attempted silently so a successful second-chance never leaks a spurious error line to stderr. cache-report is the only date command with this leniency; `daily` / `monthly` / `weekly` / `blocks` accept the two canonical forms only. Regression: `tests/test_cache_report_since_until_fallthrough.py`. (#101)
35
+ - **`diff` and `project` no longer emit ANSI color into a piped or redirected stdout when `CI` is set.** The `--color` resolver placed its `CI` rung above the `stdout.isatty()` check, so on a CI runner `cctally diff … | cat` (or `> out.txt`) wrote raw escape sequences into the capture — a behavior change from the pre-Session-A contract, which keyed color on `sys.stdout.isatty()` alone and always produced clean text on a pipe. The `CI` rung is now gated behind `sys.stdout.isatty()`: CI still forces color on a real terminal (over a dumb `TERM`, matching picocolors), but a non-TTY stdout stays plain text regardless of `CI`. `FORCE_COLOR` / `NO_COLOR` / `--color` / `--no-color` precedence is unchanged. Regression: `tests/test_color_resolution.py::test_ci_with_piped_stdout_stays_uncolored` + `::test_ci_with_tty_stdout_enables`. (#100)
36
+
8
37
  ## [1.12.0] - 2026-05-24
9
38
 
10
39
  ### Fixed
@@ -661,7 +661,7 @@ def sync_cache(
661
661
  try:
662
662
  with open(jp, "r", encoding="utf-8", errors="replace") as fh:
663
663
  fh.seek(start_offset)
664
- for offset, entry, msg_id, req_id in _iter_jsonl_entries_with_offsets(fh):
664
+ for offset, entry, msg_id, req_id in _iter_jsonl_entries_with_offsets(fh, str(jp)):
665
665
  usage = entry.usage
666
666
  inp = int(usage.get("input_tokens", 0) or 0)
667
667
  out = int(usage.get("output_tokens", 0) or 0)
@@ -839,7 +839,8 @@ def iter_entries(
839
839
 
840
840
  sql = (
841
841
  "SELECT timestamp_utc, model, input_tokens, output_tokens, "
842
- "cache_create_tokens, cache_read_tokens, usage_extra_json, cost_usd_raw "
842
+ "cache_create_tokens, cache_read_tokens, usage_extra_json, "
843
+ "cost_usd_raw, source_path "
843
844
  "FROM session_entries "
844
845
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
845
846
  )
@@ -875,6 +876,7 @@ def iter_entries(
875
876
  model=row[1],
876
877
  usage=usage,
877
878
  cost_usd=row[7],
879
+ source_path=row[8],
878
880
  ))
879
881
  return entries
880
882
 
@@ -46,6 +46,7 @@ import json
46
46
  import os
47
47
  import secrets
48
48
  import sys
49
+ from pathlib import Path
49
50
  from typing import Any
50
51
 
51
52
 
@@ -162,17 +163,60 @@ def config_writer_lock():
162
163
  fh.close()
163
164
 
164
165
 
165
- def load_config() -> dict[str, Any]:
166
+ def _load_config_from_explicit_path(path: "str | Path") -> dict[str, Any]:
167
+ """Read config from an explicit per-invocation override path (issue #88).
168
+
169
+ Contract differs from the default ``load_config()``:
170
+ - Missing file → ``SystemExit(2)`` with a clear stderr message.
171
+ - Unreadable / malformed JSON / non-object root → ``SystemExit(2)``
172
+ with a clear stderr message.
173
+ - Never writes, never acquires ``config_writer_lock``, never
174
+ creates the on-disk default config — the override is read-only
175
+ for this invocation.
176
+
177
+ Used by the ccusage drop-in ``--config <path>`` flag wired onto the
178
+ 10 Claude reporting commands (spec §3 T1.6 / issue #86 Session A).
179
+ """
180
+ p = Path(path)
181
+ if not p.exists():
182
+ eprint(f"cctally: --config: file not found: {p}")
183
+ raise SystemExit(2)
184
+ try:
185
+ raw = p.read_text(encoding="utf-8")
186
+ except OSError as exc:
187
+ eprint(f"cctally: --config: read failed for {p}: {exc}")
188
+ raise SystemExit(2) from exc
189
+ try:
190
+ data = json.loads(raw)
191
+ except json.JSONDecodeError as exc:
192
+ eprint(f"cctally: --config: invalid JSON in {p}: {exc}")
193
+ raise SystemExit(2) from exc
194
+ if not isinstance(data, dict):
195
+ eprint(
196
+ f"cctally: --config: {p} top-level must be a JSON object"
197
+ )
198
+ raise SystemExit(2)
199
+ return data
200
+
201
+
202
+ def load_config(path: "str | Path | None" = None) -> dict[str, Any]:
166
203
  """Read config.json, falling back to in-memory defaults on corruption.
167
204
 
168
- Concurrent-safety: readers see either the pre-rename or post-rename
169
- contents thanks to save_config's atomic os.replace. On corrupt or
170
- non-object JSON, emits a one-shot stderr warning and returns
171
- in-memory defaults WITHOUT re-saving the next legitimate
172
- save_config call (under config_writer_lock) will overwrite the bad
173
- bytes atomically. On first run (file missing), creates the file
174
- with a fresh collector token under the writer lock so two parallel
175
- first-run processes don't clobber each other.
205
+ When ``path`` is None (default): reads the persisted user config at
206
+ ``_cctally_core.CONFIG_PATH``, creating it on first run with a fresh
207
+ collector token under the writer lock. Concurrent-safety: readers see
208
+ either the pre-rename or post-rename contents thanks to save_config's
209
+ atomic os.replace. On corrupt or non-object JSON, emits a one-shot
210
+ stderr warning and returns in-memory defaults WITHOUT re-saving the
211
+ next legitimate save_config call (under config_writer_lock) will
212
+ overwrite the bad bytes atomically.
213
+
214
+ When ``path`` is set (issue #88 ccusage drop-in ``--config <path>``):
215
+ reads from the explicit override path and bypasses the default-path
216
+ branch entirely. Missing / unreadable / malformed paths surface as
217
+ ``SystemExit(2)`` with a clear stderr message — see
218
+ ``_load_config_from_explicit_path``. No writes, no first-run create,
219
+ no mutation of the on-disk default config.
176
220
 
177
221
  DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
178
222
  process. Callers that already hold config_writer_lock MUST use
@@ -180,6 +224,8 @@ def load_config() -> dict[str, Any]:
180
224
  inside an outer lock would block forever (verified during issue
181
225
  #17 fix).
182
226
  """
227
+ if path is not None:
228
+ return _load_config_from_explicit_path(path)
183
229
  c = _cctally()
184
230
  ensure_dirs()
185
231
  parsed = _try_read_config()
@@ -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"],
@@ -1234,11 +1234,18 @@ def _diff_render_section_table(
1234
1234
  used_pct_mode_a: str,
1235
1235
  used_pct_mode_b: str,
1236
1236
  threshold: "NoiseThreshold | None" = None,
1237
+ compact: bool = False,
1237
1238
  ) -> str:
1238
1239
  """Render one bordered table for a section. The Total row sums all rows
1239
1240
  (visible + hidden) — the caller passes the unfiltered aggregate map as
1240
- total_a/total_b so hidden rows still contribute (spec §4 invariant)."""
1241
+ total_a/total_b so hidden rows still contribute (spec §4 invariant).
1242
+
1243
+ ``compact`` (issue #91, Shape B) drops the 1-space cell padding to 0 on
1244
+ this content-sized table, which has no proportional-width path to force.
1245
+ ``pad == 1`` (the default) reproduces the prior output byte-for-byte."""
1241
1246
  boxes = _diff_box_chars()
1247
+ pad = 0 if compact else 1
1248
+ pad_s = " " * pad
1242
1249
  out: list = [_diff_section_heading(section.name, width), ""]
1243
1250
 
1244
1251
  header_cells: list = ["Model" if section.name == "models"
@@ -1318,7 +1325,7 @@ def _diff_render_section_table(
1318
1325
  fill = fill or boxes["h"]
1319
1326
  parts = [left]
1320
1327
  for i, w in enumerate(col_w):
1321
- parts.append(fill * (w + 2))
1328
+ parts.append(fill * (w + 2 * pad))
1322
1329
  parts.append(right if i == n_cols - 1 else mid)
1323
1330
  return "".join(parts)
1324
1331
 
@@ -1340,7 +1347,7 @@ def _diff_render_section_table(
1340
1347
  # result. Spaces stay outside the ANSI escape so column rules
1341
1348
  # align identically with or without color.
1342
1349
  styled = _style_ansi(padded, code, enabled=bool(code))
1343
- parts.append(f" {styled} ")
1350
+ parts.append(f"{pad_s}{styled}{pad_s}")
1344
1351
  parts.append(boxes["v"])
1345
1352
  out_lines.append("".join(parts))
1346
1353
  return "\n".join(out_lines)
@@ -1376,11 +1383,13 @@ def _diff_render_full_output(
1376
1383
  width: int,
1377
1384
  raw_aggregates: dict,
1378
1385
  tz: "ZoneInfo | None" = None,
1386
+ compact: bool = False,
1379
1387
  ) -> str:
1380
1388
  """Compose banner + window header + each section's table.
1381
1389
 
1382
1390
  ``tz`` is forwarded to ``_diff_render_window_header`` for the date
1383
- labels; ``tz=None`` means host-local.
1391
+ labels; ``tz=None`` means host-local. ``compact`` (issue #91, Shape B)
1392
+ is forwarded to each section table's pad-reduction branch.
1384
1393
  """
1385
1394
  parts: list = [
1386
1395
  _diff_render_banner(), "",
@@ -1395,6 +1404,7 @@ def _diff_render_full_output(
1395
1404
  used_pct_mode_a=result.used_pct_mode_a,
1396
1405
  used_pct_mode_b=result.used_pct_mode_b,
1397
1406
  threshold=result.threshold,
1407
+ compact=compact,
1398
1408
  ))
1399
1409
  return "\n".join(parts)
1400
1410
 
@@ -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"),