cctally 1.11.0 → 1.12.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,58 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.12.0] - 2026-05-24
9
+
10
+ ### Fixed
11
+ - **Dedup of streaming + post-stream JSONL rows now picks the post-stream finalization (matching upstream `ccusage`).** Earlier versions kept the FIRST emission of each `(message.id, requestId)` pair, which on tool-using turns is a streaming intermediate with `output_tokens=1`; the post-stream row (with the real `output_tokens` count) was rejected. Result was a systematic ~60% under-count of output tokens on agentic workloads, propagating to roughly $5/active block of missing cost on opus-4-7. cctally now picks the row with the higher token total (ccusage `should_replace_deduped_entry`), with a `speed`-set tiebreak for equal totals.
12
+ - **Pre-v1.12.0 cache.db upgrades now actually run the new dedup-wipe migration instead of being stamp-only.** The dispatcher's fresh-install heuristic classified every pre-v1.12.0 cache.db as a fresh install (because the cache migration framework was new in this release) and then stamped every pending migration's marker WITHOUT invoking its handler — silently skipping the `001_dedup_highest_wins` wipe on every upgrading user. The heuristic now also requires the DB's primary data table (`session_entries` for cache.db, `weekly_cost_snapshots` for stats.db) to be empty/absent before taking the stamp-only path. Genuine fresh installs still get the optimization; pre-framework upgrades now actually run their handlers, so the dedup fix above lands on upgrading users instead of being silently no-op'd. Symmetric protection applied to stats migration 008 for parity. (D1)
13
+ - **`cctally db skip 001_dedup_highest_wins` no longer traps stats migration 008 in infinite deferral.** The cross-DB migration gate consulted only `schema_migrations` for the 001 marker, ignoring its sibling `schema_migrations_skipped`. Operators choosing to defer the dedup wipe via `db skip` would leave 008 stuck pending forever (it's gated on 001 having "completed"). The gate now treats an explicit skip the same as an applied marker: the operator's affirmation that they accept dedup won't apply on this machine is also acceptance that downstream consumers can proceed against whatever's currently in `session_entries`. The `MigrationGateNotMet` operator hint also now mentions `cctally db skip` as the documented escape hatch. (G1)
14
+ - **Cache migration 001 and stats migration 008 marker INSERTs are now race-safe under concurrent `open_db()`.** Both migrations issued plain `INSERT INTO schema_migrations`; under concurrent dispatcher invocations (the dashboard's `open_db()` racing the CLI's `open_db()` on the same physical DB), one process won the marker INSERT and the other surfaced as `sqlite3.IntegrityError` → migration-error banner. Both switch to `INSERT OR IGNORE`, matching the convention every other production migration handler uses; the dispatcher's per-process `applied` set still provides idempotency, the OR IGNORE is purely cross-process race safety. (D3)
15
+ - **Cross-DB migration deferrals no longer block independent later migrations.** The dispatcher's `MigrationGateNotMet` branch `break`'d the registry walk on the first deferral, mirroring the failure-break rule used for generic `Exception`. The two cases are not symmetric: a gate-defer leaves the DB in a fully-consistent prior state (handler raised before touching anything), so later migrations with no dependency on the gated one can legitimately run. The branch now `continue`s; the gated migration stays out of both `applied` and `skipped`, so `user_version` still does not advance until the gated migration succeeds. The generic-Exception `break` is unchanged. (D2)
16
+ - **The cross-DB migration gate now translates SQLite transient errors (`SQLITE_BUSY` 5, `SQLITE_LOCKED` 6, `SQLITE_CANTOPEN` 14) to deferrals instead of letting them escape as the migration-error banner.** Pre-fix: a real concurrent writer holding the cache.db lock (BUSY/LOCKED) or a cache.db file unlinked between the `exists()` probe and `sqlite3.connect` (CANTOPEN, TOCTOU race) escaped to the dispatcher's `except Exception` and rendered the migration-error banner — bad UX for self-healing conditions that retry cleanly on the next open. A new `_is_transient_sqlite_error` predicate identifies the transient set; the gate helper and the 008 body both route through it. The redundant `cache_db_path.exists()` probe in 008 is removed entirely (the race window is eliminated because `sqlite3.connect` on a non-existent file raises CANTOPEN, which is the same outcome — a gate defer — without the race). (G4 + G5)
17
+ - **Stats migration 008 now uses a CLOSED interval (`unixepoch(timestamp_utc) <= unixepoch(range_end_iso)`) so its one-time recompute matches the production writer's predicate (`iter_entries` in `bin/_cctally_cache.py`: lex `timestamp_utc >= ? AND timestamp_utc <= ?`). Pre-fix the migration used a half-open `<` end that silently excluded any `session_entries` row whose `timestamp_utc` equalled a snapshot's `range_end_iso` boundary — an edge with positive probability on subscription-week boundaries where Claude Code's status-line tick can land an entry exactly on the reset instant. After this fix the migration's recompute is byte-for-byte symmetric with every subsequent `sync-week` write, and R-DEDUP2 in `bin/cctally-reconcile-test` no longer needs to caveat the divergence. Regression: `tests/test_migration_008_boundary_inclusive.py`. (V1)
18
+ - **Stats migration 008 now refuses to zero historical `weekly_cost_snapshots` rows when `_resolve_claude_projects_dirs()` returns `[]` and stats.db has snapshot rows to recompute.** Pre-fix the migration unconditionally fell back to `[_cctally_core.CLAUDE_PROJECTS_DIR]` when the resolver came back empty; when THAT default was ALSO absent (e.g. operator set `CLAUDE_CONFIG_DIR` to a stale path AND `~/.claude/projects` was missing on this machine), the gate's Layer C empty-disk fallback fired, the migration ran against an empty `session_entries`, and silently UPDATEd every `mode='auto' AND project IS NULL` snapshot to `cost_usd = 0.0` — destruction with no recovery path. The migration now raises `MigrationGateNotMet` in that topology (deferred by the dispatcher); the operator hint surfaces `cctally db skip 008_recompute_weekly_cost_snapshots_dedup_fix` as the documented escape hatch. Truly-fresh installs (no projects dirs AND no snapshot rows) still complete cleanly as a no-op so users with no Claude usage can still finish the upgrade. Regression: `tests/test_migration_008_refuses_zero_out.py`. (G3)
19
+ - **Stats migration 008 now applies in the SAME invocation as the first post-upgrade `cctally` command, instead of deferring until cache.db happens to be opened separately.** Pre-fix the gate read cache.db for the 001 marker BEFORE cache.db's own dispatcher had run that session; stats-only commands (notably `cctally report` without `--sync-current`) never opened cache.db at all, so 008 stayed pending indefinitely while `report` continued to read stale `weekly_cost_snapshots`. The migration body now calls a new `_eagerly_apply_cache_migrations()` helper BEFORE the gate read — opens cache.db, runs its dispatcher (applying cache 001), and closes the connection. After the helper returns, Layer A is satisfied; users with no JSONL on disk pass the rest of the gate via Layer C and 008 completes immediately. Users with JSONL on disk see Layer B fail (cache 001 wiped `session_files`) so 008 still defers on this invocation, but ANY subsequent JSONL-reading command (`cctally weekly`, `daily`, `blocks`) repopulates `session_files` and the next stats-touching command runs 008 cleanly — worst case is now one extra invocation instead of unbounded deferral. Cross-DB plumbing: the helper lazy-opens cache.db inline (not via `_cctally_cache.open_cache_db`) so it has no dependency on `sys.modules['cctally']`, avoiding a module-load cycle with the cache sibling. Regression: `tests/test_migration_008_eager_cache_trigger.py`. (V4)
20
+ - **`session_entries.source_path` now stays pinned to whichever JSONL file FIRST inserted a `(msg_id, req_id)` row, even when a later UPSERT from a DIFFERENT file wins the higher-token dedup contest.** Pre-fix the ccusage-parity `ON CONFLICT DO UPDATE` clause included `source_path = excluded.source_path` (and `line_offset = excluded.line_offset`), so a dedup row's source_path flipped to whichever file most recently won the tiebreaker. Downstream aggregators (`cctally project`, dashboard project panel, share `--reveal-projects`) attribute tokens to a `project_path` via `LEFT JOIN session_files ON sf.path = se.source_path`, so the flip silently moved usage between project buckets on every dedup swap. Sticky source_path matches pre-dedup `INSERT OR IGNORE` semantics and the operator's mental model: rows are attributed to where they were first observed, not where they were last updated. Regression: `tests/test_cache_dedup_source_path_sticky.py`. (U1)
21
+ - **`IngestStats.rows_inserted` renamed to `rows_changed` to reflect that the count covers both genuinely-new INSERTs and ccusage-parity UPSERT replacements.** Pre-fix the field name implied "new rows" but, under the new `ON CONFLICT DO UPDATE` semantics, SQLite's `total_changes` counter increments on both INSERTs and matching DO UPDATEs — so the reported number conflated new ingest with dedup tiebreaker swaps. The metric is observability-only (stderr progress, hook-tick log line, setup bootstrap message); behavior is unchanged. Callers (`bin/_cctally_setup.py`, `bin/_cctally_record.py`) updated; `CodexIngestStats.rows_changed` parallels the rename even though Codex still uses `INSERT OR IGNORE` (where ignored conflicts don't increment `total_changes`, so the count is still "new inserts" in practice — name parity makes the two ingest paths read uniformly). Stderr now reads `N rows changed` instead of `N new rows` / `N rows inserted`. (U2)
22
+ - **Truncation of a JSONL file no longer drops dedup-winning rows from the cache.** Pre-fix combo of U1 (sticky source_path) and the existing per-file truncation handler (`DELETE FROM session_entries WHERE source_path = ?`) opened a regression: when file A inserted a `(msg_id, req_id)` row first (sticky source) and file B's later UPSERT updated the token columns (winning the dedup contest), a truncation of A wiped the row even though B still carried the winning data on disk. Because B's `size_bytes` was unchanged, B's per-file delta-resume path took the early-exit and the winning data stayed missing from the cache until a manual `cache-sync --rebuild`. `sync_cache` now pre-scans tracked files for any truncation: when one is detected, it drops the entire `session_entries` table inside the same flock and forces every file to re-ingest from offset 0. The cache is fully re-derivable from JSONL so the wipe is safe, and truncation is a rare event (log rotation / manual edits / `--rebuild` scenarios), so the throughput cost is bounded. Files that didn't actually shrink are not counted toward `files_reset_truncated`; only the file(s) whose `st_size < prev_size` are. Regression: `tests/test_cache_dedup_truncation_no_loss.py` (4 scenarios — loser-truncates, winner-source-truncates, pure-growth no-op, non-overlapping rotation). (U3)
23
+ - **Closed historical `five_hour_blocks` totals (parent + per-(window, model) + per-(window, project) rollup-children) now recompute under the corrected dedup.** New stats migration `009_recompute_five_hour_blocks_dedup_fix` walks every block row (active AND closed) and recomputes `total_input_tokens`, `total_output_tokens`, `total_cache_create_tokens`, `total_cache_read_tokens`, `total_cost_usd` from the corrected `session_entries` over `[block_start_at, last_observed_at_utc]`. The live writer (`maybe_update_five_hour_block`) only recomputes the currently active block — closed historical blocks would otherwise carry the pre-dedup inflated totals forever. Per-(window, model) and per-(window, project) rollup-children are replace-all'd in the same pass; NULL `session_files.project_path` collapses to the `(unknown)` sentinel matching the live writer. Closed interval (`<=`) matches the writer's `get_claude_session_entries` predicate. Gated on cache migration 001 (same Layer A/B/C gate as 008); transient SQLite errors translate to deferrals (G4/G5 parity); G3-style fail-closed when no projects/ dir resolves on disk and there are block rows to recompute. Banner gated on `five_hour_blocks` non-emptiness (symmetric with 008's snapshot_rows gate). New reconcile invariant `R-DEDUP4` in `bin/cctally-reconcile-test` covers up to 50 eligible closed blocks per run (1-day recency exclusion + cache-drift skip gated on 009's applied_at). Regression: `tests/test_migration_009_per_migration_goldens.py` + `tests/test_migration_009_scope.py` + `tests/test_migration_009_boundary_inclusive.py`. (B1)
24
+ - **Historical `percent_milestones.cumulative_cost_usd` + `marginal_cost_usd` now recompute under the corrected dedup.** New stats migration `010_recompute_percent_milestones_dedup_fix` walks every milestone row, sums `session_entries` cost over `[week_start_at_or_date_midnight, captured_at_utc]` for the new `cumulative_cost_usd`, then derives `marginal_cost_usd` = `cumulative - prior.cumulative` for the immediately lower threshold within the same `(week_start_date, reset_event_id)` segment (first milestone of a segment gets `marginal == cumulative`). Pre-fix `percent_milestones` was write-once forward-only per the "Write-once milestones" rule — but the historical inflated values were now demonstrably wrong against the corrected weekly cost, so this one-time scoped recompute trades that rule for parity with 008. Forward-going behavior is unchanged: new crossings still capture cost-at-moment-of-crossing once. Same gate semantics + closed-interval + G3 fail-closed + banner-gating-on-non-emptiness as 009. Legacy rows with both `week_start_at IS NULL` AND no parseable `week_start_date` are skipped (mirrors 008's NULL `range_*_iso` carve-out). New reconcile invariant `R-DEDUP5` covers up to 100 eligible milestones per run (1-day recency exclusion + cache-drift skip gated on 010's applied_at). Regression: `tests/test_migration_010_per_migration_goldens.py` + `tests/test_migration_010_scope.py` (multi-week marginal scoping, multi-segment reset_event_id boundaries, legacy NULL week_start_at fallback). (B2)
25
+ - **The direct-JSONL fallback path now keeps `source_path` sticky to the FIRST file that contributed a `(msg_id, req_id)` key, matching the cache UPSERT path.** When `get_claude_session_entries()` falls back to `_direct_parse_claude_session_entries()` (cache DB unavailable, or `sync_cache` lost its lock), the cross-file merge previously did `dedupe_map[key] = (entry, source_path)` — flipping BOTH the entry data AND its source_path to the higher-token winner from a later file. The cache ingest path deliberately omits `source_path` from its `ON CONFLICT DO UPDATE SET` clause (U1), so the two paths disagreed on project attribution exactly when the fallback was exercised: the fallback emits each row's `session_id`/`project_path` from `meta_by_path[source_path]`, so a flipped source_path silently re-attributed cross-file duplicates to the winner's project (`cctally project`, dashboard project panel, share `--reveal-projects`). The fallback now takes the winner's DATA but the first contributor's source_path. Regression: `tests/test_cache_dedup_fallback_source_path_sticky.py`. (Codex round 1 P2)
26
+ - **The cross-DB migration gate (Layer B) now accepts a post-001 `sync_cache` ingest that completes in the SAME wall-clock second as the 001 marker.** `001.applied_at_utc` and `session_files.last_ingested_at` both come from whole-second `now_utc_iso()`; a JSONL-reading command that applied 001 and then ran `sync_cache` within that same second wrote `last_ingested_at == applied_at_utc`, which the strict `unixepoch(last_ingested_at) > unixepoch(applied_at_utc)` rejected — deferring 008/009/010 indefinitely if the user only ran stats-only commands afterward. The comparison is now `>=`. This is safe precisely because 001 DELETEs every `session_files` row inside the same transaction that stamps its marker, so no pre-001 row can survive: an equal timestamp can only be a genuine post-001 ingest. Regression: `tests/test_migration_gate_not_met.py::test_gate_passes_when_ingest_is_same_second_as_001_marker`. (Codex round 1 P2)
27
+ - **Cache migration 001's "Re-ingesting Claude session history with corrected dedup..." banner is suppressed on hot paths and empty caches.** Pre-fix the migration unconditionally `eprint`-ed the banner on every upgrade-time invocation: hot paths (record-usage, hook-tick, sync-week, cache-sync, refresh-usage, tui, dashboard, db, doctor) machine-consume stderr or take over the screen so the banner has nowhere safe to land; empty `session_entries` topologies (most fresh-install upgrades, every golden fixture) make the handler body a marker-only no-op so the banner has nothing to announce. A new `_001_banner_should_emit(conn)` helper composes two gates: (a) `session_entries` non-emptiness (symmetric with migration 008's `snapshot_rows` gate, mirroring Pass 2's V3 lesson); (b) `sys.argv[1]` not in `_BANNER_SUPPRESSED_COMMANDS` (the same set the dispatcher consults for its post-failure banner — single source of truth; migration handlers don't receive `args`, so we read `sys.argv` directly since argparse hasn't run at handler time). Defensive: any error reading either signal falls back to "don't print" — silence is the safer side under uncertainty. Interactive commands (`report`, `weekly`, `percent-breakdown`, etc.) on non-empty caches still see the one-line announcement once on the upgrade. Regression: `tests/test_cache_001_banner_suppression.py`. (SW5)
28
+ - **The cross-DB migration gate's empty-disk fallback no longer zeroes historical aggregates when the Claude JSONL tree has been pruned.** `_resolve_claude_projects_dirs()` only checks that the `projects/` dir EXISTS, not that it contains JSONL — so a user who pruned `~/.claude/projects/` (or pointed `CLAUDE_CONFIG_DIR` at an emptied `projects/` dir) AFTER cache migration 001 wiped `session_entries` would slip past the G3 "no projects dir" guard. The gate's Layer C empty-disk fallback then fired, 008/009/010 recomputed over an empty `session_entries`, and every historical `weekly_cost_snapshots` / `five_hour_blocks` / `percent_milestones` row was UPDATEd to zero — destroying the only surviving copy of the data. `_gate_001_post_ingest_completed` now takes a `data_present` flag (each recompute migration passes `bool(its_rows)`); when the caller still holds historical rows AND the empty-disk fallback would fire, the gate raises `MigrationGateNotMet` (deferred) instead of silently passing. Truly-fresh installs (no rows) still no-op cleanly. Regression: `tests/test_migration_008_scope.py` + `tests/test_migration_008_eager_cache_trigger.py`. (Codex round 2 P1)
29
+ - **The fresh-install probe now consults every recompute-target stats table, not just `weekly_cost_snapshots`.** The dispatcher's fresh-install heuristic probed only `weekly_cost_snapshots` for stats.db, so a legacy stats.db with live `five_hour_blocks` history but no weekly snapshots (e.g. a user who only ran 5h-window commands) was misclassified as a fresh install — every pending stats migration got stamped applied WITHOUT running, so `009_recompute_five_hour_blocks_dedup_fix` never executed and historical 5h totals stayed inflated forever. The probe now checks `weekly_cost_snapshots`, `five_hour_blocks`, AND `percent_milestones`; non-emptiness in ANY recompute target flips `fresh_install` to False so the handlers actually run. Regression: `tests/test_migration_001_pre_framework_upgrade.py::test_stats_pre_framework_5h_only_is_not_classified_fresh`. (Codex round 2 P2)
30
+ - **`cctally db skip 001_dedup_highest_wins` no longer permanently strands the dependent stats recomputes.** The skip path let 008/009/010 run against the still-stale (un-deduped) `session_entries` and stamp their own markers. A later `cctally db unskip 001_dedup_highest_wins` resets only cache.db's `user_version` (so 001 reruns and the cache rebuilds), but it cannot re-trigger an already-stamped stats migration — so `report` and the historical 5h aggregates would stay permanently inflated even after the operator reverted the skip. With historical rows present (`data_present=True`), the gate now DEFERS the dependent recompute while 001 is skipped, leaving it pending (marker unstamped, `user_version` not advanced) until 001 has actually applied and post-ingest data exists. A skip with no rows to recompute still passes (nothing to corrupt). Regression: `tests/test_migration_008_gate_honors_skipped.py::test_gate_defers_when_001_skipped_and_data_present`. (Codex round 2 P2)
31
+ - **The cross-DB migration gate (Layer B) now requires a COMPLETED post-001 ingest, not merely a fresh `session_files` timestamp.** `sync_cache` writes each `session_files` row in two phases: `_ensure_session_files_row` commits a placeholder row (`size_bytes=0, last_byte_offset=0`, fresh `last_ingested_at`) at the top of the per-file loop BEFORE the file's JSONL is parsed, then the post-entries commit UPSERTs the real `size_bytes`/`last_byte_offset` AFTER the `session_entries` inserts. If `sync_cache` was interrupted (kill -9, power loss, crash) between the placeholder commit and the first entries commit, Layer B's timestamp-only predicate passed on that placeholder while `session_entries` was empty/partial — letting 008/009/010 recompute over a near-empty cache and stamp their markers permanently, silently zeroing historical weekly cost, 5h block totals, and percent milestones until a manual rerun. Layer B now also requires `size_bytes > 0 OR last_byte_offset > 0`, which a placeholder row never satisfies; a genuinely 0-byte JSONL contributes no entries to undercount and is covered by the Layer C empty-disk fallback. (Residual: a multi-file ingest interrupted after some files completed but before others can still pass the gate and undercount the not-yet-ingested files until the next `sync_cache`; fully closing that needs a whole-walk completion sentinel, tracked in cctally-dev#93.) (Codex round 3 P1)
32
+ - **`_eagerly_apply_cache_migrations` now mirrors `open_cache_db`'s `session_id`/`project_path` columns on `session_files`.** The eager helper (used by stats migrations 008/009/010 to bootstrap cache.db's dispatcher before their gate check) created `session_files` via `CREATE TABLE IF NOT EXISTS` with only the base five columns and never ran the `add_column_if_missing(session_id/project_path)` ALTERs that `open_cache_db` runs. On an older `cache.db` whose pre-existing `session_files` table predates those columns, the `CREATE` is a no-op (table exists) and the columns stay absent — so migration 009's `LEFT JOIN session_files sf … sf.project_path` on the eager-bootstrapped read-only connection would raise `no such column: sf.project_path` at prepare time. The helper now runs both idempotent `add_column_if_missing` calls, keeping its inline open a strict schema superset-mirror of `open_cache_db` as its own docstring mandates. (Codex round 3 P2)
33
+ - **Cache migration 001's destructive wipe is now race-guarded with `BEGIN IMMEDIATE` + an in-transaction marker re-check, not just an `INSERT OR IGNORE` on its marker.** The dispatcher snapshots the applied-migration set once before its registry walk, so two concurrent openers (e.g. dashboard + CLI on the same `cache.db`) can both classify 001 as pending and both enter the handler. Under a plain deferred `BEGIN`, each acquired the write lock only on its first DELETE: the loser would wait for the winner's COMMIT, then `DELETE FROM session_entries`/`session_files` — wiping rows the winner's subsequent `sync_cache` had already reingested, leaving the cache partially rebuilt until another full sync. `BEGIN IMMEDIATE` now takes the write lock up front so the loser blocks BEFORE touching data; once it acquires the lock it sees the winner's committed marker and the in-transaction re-check turns its body into a no-op (commit + return). The marker INSERT stays `INSERT OR IGNORE` as belt-and-suspenders. (Codex round 3 P2)
34
+ - **The cross-DB migration upgrade-gate that decides whether the dedup recompute (008/009/010) may proceed or must defer is now a single pure state-machine resolver, replacing the hand-grown layered checks.** All proceed/defer logic lives in one unit-testable truth-table function; the surrounding code is a thin shell that reads the cache.db state, the on-disk JSONL topology, and the migration markers, then asks the resolver for the verdict. Behavior is preserved across every previously-covered scenario (the prior layered checks are reproduced row-for-row), but the decision is now exhaustively exercised by a parametrized unit sweep rather than scattered across handler bodies — a class of "one path forgot to check X" regression is eliminated, and the operator-facing `MigrationGateNotMet` hint is now derived from the same resolver so it always matches the actual reason for the defer. (#93)
35
+ - **A new ingest-completeness sentinel closes a partial-walk false-pass that could let the dedup recompute run against an incompletely-rebuilt cache.** Previously the gate trusted a per-file freshness timestamp, so a multi-file ingest interrupted after some files completed but before others (kill -9, power loss, a crash mid-rebuild) could satisfy the gate while the cache was only partially repopulated — letting the recompute migrations stamp themselves applied against undercounted data and silently zero or deflate historical weekly cost, 5h block totals, and percent milestones. The gate now requires a `cache_meta` `claude_ingest_walk_complete` marker that `sync_cache` writes ONLY after a full clean walk completes (and that cache migration 001, `--rebuild`, and a truncation-reset all clear atomically); a partial walk never sets it, so the recompute correctly defers until a clean walk lands. (#93)
36
+ - **The cache.db schema is now defined in a single shared helper, eliminating the schema-drift class of bug where one open-path created a table or column the other did not.** The two code paths that open cache.db (normal open and the eager bootstrap used by stats migrations to apply cache.db's own dispatcher first) previously each carried their own inline DDL; they had already drifted once (a missing `session_files.project_path` column on the eager path surfaced as a `no such column` at migration-prepare time). Both paths now route through one idempotent schema function, so any future column or index is defined exactly once and can never be present on one path and absent on the other. (#93)
37
+ - **`sync_cache` now invalidates the `claude_ingest_walk_complete` marker when a tracked JSONL file has been deleted from disk, so the recompute gate cannot certify a cache that still holds orphaned rows.** Pre-fix, if a user got one clean post-001 sync and then deleted/pruned a Claude JSONL before 008/009/010 ran, `sync_cache` left that file's `session_entries`/`session_files` rows in place (the per-file walk only iterates files still on disk) yet still refreshed the walk-complete marker — so `_gate_001_post_ingest_completed` treated the cache as disk-complete and the recompute migrations stamped weekly/5h/milestone totals that still INCLUDED data from files no longer on disk (an overcount), with no automatic rerun. `sync_cache` now pre-scans the tracked-file set against the on-disk glob: when any tracked path that carried ingested bytes (`size_bytes > 0`) is absent from disk, it DELETEs the walk-complete marker (clearing a stale one a prior clean walk left behind) and withholds this run's end-of-loop rewrite. The cache is no longer disk-complete, so the gate DEFERs the recompute until the operator runs `cache-sync --rebuild` (the documented re-derive path, which prunes the orphans and re-establishes the marker). Marker invalidation — not in-place pruning — is the chosen mechanism: a per-orphan `DELETE … WHERE source_path = ?` shares the truncation hazard (under sticky-source_path dedup a surviving file may carry the same `(msg_id, req_id)` yet keep its `size_bytes`, so the delete could drop a row the survivor still owns without re-ingesting it), and a blanket full-reset would wrongly fire on the legitimate synthetic-source_path fixture pattern. A `size_bytes=0` tracking row holds no entries, so its absence does not trigger the invalidation. Regression: `tests/test_cache_dedup_truncation_no_loss.py` (orphan-withholds-marker + size_bytes=0 carve-out). (Codex round 2 P2)
38
+ - **Stats migrations 008/009/010 now pin a single consistent `cache.db` snapshot for the whole recompute.** Pre-fix `_open_cache_ro_with_gate_defer` returned a plain autocommit read-only connection; because `cache.db` is WAL and Python's sqlite3 only auto-BEGINs before DML, each `cache_ro.execute(SELECT …)` in the per-row recompute loop ran as its own read transaction and could observe a NEWER `session_entries` snapshot if a concurrent `record-usage`/`hook-tick`/`cache-sync --rebuild` committed between iterations — so one migration run could recompute different rows from different cache states and still stamp its schema marker (e.g. two `weekly_cost_snapshots` rows landing with `cost_usd` values drawn from different cache versions). The helper now issues an explicit deferred `BEGIN` immediately after opening, locking the snapshot at the gate's first read and holding it through the recompute loop until the caller's `finally: cache_ro.close()`. In WAL the reader never blocks the writer, so concurrent ingests still commit — they just land in a newer WAL frame this read transaction won't observe; the recompute writes target `stats.db` (`conn`), not this connection, so the open read transaction never blocks them. Regression: `tests/test_migration_gate_concurrency.py::test_open_cache_ro_with_gate_defer_pins_one_snapshot`. (Codex round 2 P2)
39
+ - **Documented limitation: the one-time dedup recompute reflects only the JSONL surviving on disk at the moment of upgrade.** Because the historical aggregate snapshots carry no per-snapshot record of which JSONL files originally contributed to them, a date range whose JSONL has been fully pruned recomputes to `$0`, and a partially-pruned range undercounts — there is no way to distinguish "this range was always $0" from "this range's source was deleted" without a per-snapshot source manifest, which does not exist. The gate refuses the destructive whole-cache zeroing case (no surviving JSONL at all), but selective per-range source loss is inherent and accepted. The remaining compound case — a concurrent `sync_cache` straddling cache migration 001 — is tracked separately under #87 (a naive lock-everything fix deadlocks, so it is deferred). (#93)
40
+
41
+ ### Changed
42
+ - **`report` historical costs will be lower (corrected) after upgrade.** Migration `008_recompute_weekly_cost_snapshots_dedup_fix` (stats.db) recomputes every `weekly_cost_snapshots.cost_usd` from the corrected session_entries for rows where `mode='auto' AND project IS NULL`. `cctally weekly` was already self-healing (it recomputes on read per CLAUDE.md). `report` and `weekly` now agree on historical figures for those rows.
43
+ - **`five-hour-blocks` historical totals will be lower (corrected) after upgrade.** Migration `009_recompute_five_hour_blocks_dedup_fix` recomputes every `five_hour_blocks` row (parent + per-(window, model) + per-(window, project) rollup-children) from the corrected session_entries. The live writer only recomputed the currently active block; closed historical blocks would otherwise carry pre-dedup inflated totals forever.
44
+ - **`percent-breakdown` pre-fix milestones will show lower (corrected) cumulative cost after upgrade.** Migration `010_recompute_percent_milestones_dedup_fix` rewrites historical `cumulative_cost_usd` + `marginal_cost_usd` to match the corrected weekly cost. This is a one-time scoped exception to the "Write-once milestones" rule — forward-going behavior is unchanged.
45
+ - **`five-hour-breakdown` (`five_hour_milestones`) pre-fix rows stay as-recorded — recompute deferred to a follow-up.** Honest accounting: `five_hour_milestones.block_cost_usd` is recoverable the same way `percent_milestones.cumulative_cost_usd` is (sum `session_entries` cost over `[block_start_at, captured_at_utc]` joined to the parent `five_hour_blocks` row), so the "no aggregated snapshot to reconcile against" framing in earlier drafts overstated the difficulty. The actual reason this round skips the recompute: the 5h milestones table has an extra `reset_event_id` segment column (migration 006) that complicates marginal-cost re-derivation across in-place credit boundaries, and we elected to ship 008/009/010 over the simpler weekly + closed-5h-block + per-week milestone surface first. New 5h milestones from the upgrade forward are correct; historical 5h per-percent rows reflect the pre-dedup inflated cost-at-moment until a follow-up migration ships. Tracked separately; the "Write-once milestones" rule applies in the meantime.
46
+ - **Dashboard test fixtures now ship fully-migrated, matching the share fixtures.** `tests/fixtures/dashboard/*/stats.db` stamp every stats-database migration applied (the state a real database reaches after a fully-upgraded first open), so dashboard reads render the seeded display tables instead of recomputing them — removing a latent fragility where a future dashboard `sync_cache` walk could have let the #93 dedup-recompute migrations overwrite the seeded data. Rendered output is unchanged.
47
+
48
+ ### Migration notes
49
+ - First `cctally` command after upgrade triggers cache migration `001_dedup_highest_wins` (cache.db): wipes session_entries + session_files and re-ingests from `~/.claude/projects/**/*.jsonl`. Expect a 10-30s pause on the first command for typical histories. The "Re-ingesting Claude session history..." banner now surfaces only on interactive commands with a non-empty cache (SW5); machine-consumed surfaces (status-line, hook-tick) stay quiet.
50
+ - Stats migrations `008_recompute_weekly_cost_snapshots_dedup_fix`, `009_recompute_five_hour_blocks_dedup_fix`, and `010_recompute_percent_milestones_dedup_fix` are all gated on cache migration 001. Each body eagerly triggers cache.db's dispatcher BEFORE checking the gate (V4), so the very first `cctally` command after upgrade applies cache 001 even when the command itself doesn't read JSONL (e.g. `cctally report`). On hosts with JSONL under `~/.claude/projects`, the recompute migrations defer ONE more invocation while the next JSONL-reading command repopulates the wiped cache; on hosts with no JSONL on disk, they complete immediately. Heavy users may see banners on the first interactive command — three lines, one per migration, gated on each migration's source table non-emptiness. Run `cctally db status` to verify all three migrations applied.
51
+ - `weekly_cost_snapshots` rows with `range_start_iso IS NULL` are skipped by 008 (those columns were added later). Pre-fix value persists; delete the row and re-run `sync-week` if you need it recomputed.
52
+ - `percent_milestones` rows with both `week_start_at IS NULL` AND missing `week_start_date` are skipped by 010 (legacy schemas only). Pre-fix value persists.
53
+ - To defer: `cctally db skip 001_dedup_highest_wins` (or `008_…`, `009_…`, `010_…`) with a `--reason`. Reverse with `db unskip`. Numbers stay pre-fix until you do.
54
+
55
+ ## [1.11.1] - 2026-05-22
56
+
57
+ ### Changed
58
+ - Promote 23 path globals (`APP_DIR`, `LEGACY_APP_DIR`, `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `CACHE_LOCK_PATH`, `CACHE_LOCK_CODEX_PATH`, `CONFIG_PATH`, `CONFIG_LOCK_PATH`, `MIGRATION_ERROR_LOG_PATH`, `CHANGELOG_PATH`, all five `HOOK_TICK_*` paths, all six `UPDATE_*` paths, and `CLAUDE_SETTINGS_PATH`) from `bin/cctally` into `bin/_cctally_core.py`; `_cctally_core` is now the single source of truth and the single legal monkeypatch target for these names. `bin/cctally` keeps eager re-exports so external `cctally.X` references (ad-hoc REPL, scripts) still resolve, but internal `bin/cctally` reads (~28 sites) and every `bin/_*.py` sibling read (149 sites across 11 siblings) now go through `_cctally_core.X` at call time. `tests/conftest.py:redirect_paths()` patches `_cctally_core` directly (with an `ns["X"]` introspection mirror); the 80 historical `monkeypatch.setitem(ns, "<NAME>", v)` / `monkeypatch.setattr(cctally, "<NAME>", v)` test sites are migrated to `monkeypatch.setattr(_cctally_core, "<NAME>", v)`. `bin/_cctally_alerts.py` drops its `_cctally()` accessor helper (its only data-global use was `LOG_DIR`); other siblings keep the helper for legitimate non-data deps (validators, threshold constants, `PUBLIC_REPO`, `RELEASE_HEADER_RE`, etc.). The 2026-05-17 kernel-extraction spec's §2.3 path-constant accessor carve-out is retired. `tests/test_kernel_extraction_invariants.py` gains four new AST regression guards that lock the new invariants going forward (`test_promoted_globals_live_in_core`, `test_no_sibling_accessor_reads_promoted_globals`, `test_no_old_style_test_patches_for_promoted_globals`, `test_no_value_imports_of_promoted_globals_in_siblings`). (#84)
59
+
8
60
  ## [1.11.0] - 2026-05-22
9
61
 
10
62
  ### Added
@@ -24,11 +24,11 @@ in `bin/_lib_alerts_payload.py` (Phase A extraction); this module
24
24
  imports them directly via `_load_lib`, which keeps the dispatch path
25
25
  free of an extra bounce through cctally's re-exports.
26
26
 
27
- bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same
28
- as `bin/_cctally_setup.py`):
29
- - `LOG_DIR` base dir under which `alerts.log` lives (subject to
30
- HOME-redirection by test fixtures via `monkeypatch.setitem(ns,
31
- "LOG_DIR", ...)`).
27
+ Kernel reads from `bin/_cctally_core` (call-time module-attribute access):
28
+ - `LOG_DIR` — base dir under which `alerts.log` lives. Promoted to
29
+ `_cctally_core` 2026-05-22 (#84); test fixtures redirect via
30
+ `monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)` (or the
31
+ conftest `redirect_paths()` helper).
32
32
  - `now_utc_iso` — single timestamp source used for both the log-line
33
33
  timestamp and the synthetic test payload's `crossed_at_utc`.
34
34
 
@@ -50,10 +50,7 @@ import pathlib
50
50
  import subprocess
51
51
  import sys
52
52
 
53
-
54
- def _cctally():
55
- """Resolve the current `cctally` module at call-time (spec §5.5)."""
56
- return sys.modules["cctally"]
53
+ import _cctally_core
57
54
 
58
55
 
59
56
  def _load_lib(name: str):
@@ -77,22 +74,21 @@ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_h
77
74
 
78
75
 
79
76
  # === Honest imports from extracted homes ===================================
80
- # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
81
- # import from _cctally_core. `LOG_DIR` stays on the _cctally() accessor
82
- # per Q1=B (path constants propagate via monkeypatch.setitem against the
83
- # cctally namespace).
77
+ # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
78
+ # LOG_DIR was promoted to _cctally_core 2026-05-22 (#84) and is read
79
+ # via call-time module-attribute access (this sibling no longer needs
80
+ # the historical _cctally() accessor).
84
81
  from _cctally_core import now_utc_iso
85
82
 
86
83
 
87
84
  def _alerts_log_path() -> "pathlib.Path":
88
85
  """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
89
86
 
90
- Resolves through the same ``APP_DIR`` / ``LOG_DIR`` derived at module
91
- import time from ``Path.home()``, so a HOME override before import (the
92
- pattern used elsewhere in this codebase — e.g. ``cctally-config-test``)
93
- transparently relocates the log without a separate env-var convention.
87
+ Reads ``LOG_DIR`` from ``_cctally_core`` at call time. Tests patch via
88
+ ``monkeypatch.setattr(_cctally_core, "LOG_DIR", tmp)`` (or the
89
+ conftest ``redirect_paths()`` helper).
94
90
  """
95
- log_dir = _cctally().LOG_DIR
91
+ log_dir = _cctally_core.LOG_DIR
96
92
  log_dir.mkdir(parents=True, exist_ok=True)
97
93
  return log_dir / "alerts.log"
98
94