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 +29 -0
- package/bin/_cctally_cache.py +4 -2
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +50 -2
- package/bin/_cctally_db.py +79 -0
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_setup.py +80 -0
- package/bin/_lib_aggregators.py +18 -5
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_doctor.py +39 -0
- package/bin/_lib_jsonl.py +8 -1
- package/bin/_lib_render.py +236 -41
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1319 -90
- package/package.json +1 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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,
|
|
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
|
|
package/bin/_cctally_config.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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()
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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
|
-
|
|
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 (
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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 "
|
package/bin/_cctally_record.py
CHANGED
|
@@ -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
|
-
|
|
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 &
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"])
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -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
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
699
|
-
# cache
|
|
700
|
-
#
|
|
701
|
-
|
|
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"],
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -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"
|
|
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
|
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -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"),
|