cctally 1.67.0 → 1.68.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,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.68.0] - 2026-07-13
9
+
10
+ ### Added
11
+ - `cctally db checkpoint [--db {cache,stats}] [--json]` — fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db/stats.db; the manual escape hatch and the `doctor` remediation for a bloated write-ahead log. (#297)
12
+
13
+ ### Changed
14
+ - `cctally-test-all`: faster full-suite runs — `reconcile` now runs inside the parallel pool instead of as a serial barrier, and a three-role worker model (`CCTALLY_TEST_JOBS` outer budget + derived/overridable `CCTALLY_INNER_JOBS` / `CCTALLY_PYTEST_JOBS`) caps the nested per-harness fan-out that made individual harnesses 2–8× slower under load. Adds a slowest-harness timing report. (#292)
15
+ - `doctor` gains a read-only `cache.db WAL size` check that warns when the WAL exceeds 256 MB and points to `cctally db checkpoint`. (#297)
16
+
17
+ ### Fixed
18
+ - Prevent the cache.db write-ahead log from bloating to multi-GB under heavy concurrent multi-agent syncs, which surfaced as `Error: database is locked` on every `cctally` command — cap the WAL file (`PRAGMA journal_size_limit`), force a best-effort TRUNCATE checkpoint at end-of-sync, and raise `busy_timeout` from 5s to 15s. Existing bloated caches self-heal on the next sync (or run `cctally db checkpoint`). (#297)
19
+
20
+ ## [1.67.1] - 2026-07-12
21
+
22
+ ### Fixed
23
+ - Internal (maintainer-only): ship the golden-harness fixture-cache engine (`bin/_fixture_cache.py`, #281 S11) to the public repo. It is sourced by the mirrored `bin/_lib-fixture-cache.sh` wrapper (used by 18 public golden harnesses) and read by `tests/test_fixture_cache.py`, but was omitted from the mirror allowlist, so the public repo's CI failed at test collection with `FileNotFoundError`. A new structural guard now asserts no public test/harness references a mirror-private `bin/` sibling, so this stranded-dependency class is caught before it ships. No user-facing behavior change.
24
+
8
25
  ## [1.67.0] - 2026-07-12
9
26
 
10
27
  ### Added
@@ -107,7 +107,7 @@ import sqlite3
107
107
  import sys
108
108
  import time
109
109
  from dataclasses import dataclass, field
110
- from typing import Any, Callable, Iterator
110
+ from typing import Any, Callable, Iterator, NamedTuple
111
111
 
112
112
 
113
113
  def _cctally():
@@ -316,6 +316,96 @@ clear_conversation_messages = _cctally_db_sib.clear_conversation_messages
316
316
  _set_cache_meta = _cctally_db_sib._set_cache_meta
317
317
 
318
318
 
319
+ # cache.db WAL hardening (#297). See
320
+ # docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
321
+ # `journal_size_limit` bounds the *persistent* WAL file so a checkpoint that
322
+ # actually resets the WAL truncates the file back down to this cap — it is
323
+ # containment/recovery under transient reader contention, NOT a reader-proof
324
+ # hard cap (a pinned long-lived reader still defeats it until it releases).
325
+ CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 134217728
326
+ # End-of-sync physical-size shrink trigger: only force a TRUNCATE checkpoint
327
+ # once the -wal file has grown past this, so normal small syncs stay cheap.
328
+ CACHE_WAL_CHECKPOINT_TRIGGER_BYTES = 64 * 1024 * 1024 # 67108864
329
+ # Near-nonblocking busy_timeout for the auto end-of-sync checkpoint. It runs
330
+ # BEFORE the ingest flock is released, so a long wait here would stall every
331
+ # above-threshold sync's flock under the heavy-reader contention that motivates
332
+ # the fix — fail fast and rely on journal_size_limit + the next checkpoint.
333
+ CHECKPOINT_AUTO_BUSY_TIMEOUT_MS = 100
334
+ # The explicit `cctally db checkpoint` command may wait this long.
335
+ CHECKPOINT_CMD_BUSY_TIMEOUT_MS = 15000
336
+
337
+
338
+ class CheckpointResult(NamedTuple):
339
+ """Outcome of a single ``PRAGMA wal_checkpoint(TRUNCATE)`` (#297).
340
+
341
+ ``busy`` is the checkpoint's own busy flag (a reader/writer held it off);
342
+ ``truncated`` means the WAL was actually reset AND the -wal file is now
343
+ zero-length/absent — a checkpoint can copy some frames yet still report
344
+ ``busy`` (partial), which is NOT ``truncated``.
345
+ """
346
+
347
+ db: str
348
+ wal_bytes_before: int
349
+ wal_bytes_after: int
350
+ frames_checkpointed: int
351
+ busy: bool
352
+ truncated: bool
353
+
354
+
355
+ def _wal_file_size(db_path) -> int:
356
+ """Best-effort size of the -wal sidecar in bytes; 0 if absent/unreadable."""
357
+ try:
358
+ return os.path.getsize(f"{db_path}-wal")
359
+ except OSError:
360
+ return 0
361
+
362
+
363
+ def _run_wal_truncate(conn, db_path, *, db_label: str) -> "CheckpointResult":
364
+ """Run a best-effort TRUNCATE checkpoint on an already-open connection.
365
+
366
+ PRECONDITION: ``conn`` has NO active transaction (autocommit). The
367
+ ``db checkpoint`` command passes a fresh raw connection; the end-of-sync
368
+ caller has committed all ingest work first. ``PRAGMA
369
+ wal_checkpoint(TRUNCATE)`` returns ``(busy, log, checkpointed)``: ``busy=0``
370
+ means the WAL was reset. Measures the -wal size before and after so callers
371
+ can report the shrink without re-deriving it.
372
+ """
373
+ before = _wal_file_size(db_path)
374
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
375
+ busy = bool(row[0]) if row else True
376
+ frames = int(row[2]) if (row and row[2] is not None) else 0
377
+ after = _wal_file_size(db_path)
378
+ truncated = (not busy) and after == 0
379
+ return CheckpointResult(db_label, before, after, frames, busy, truncated)
380
+
381
+
382
+ def _maybe_truncate_wal(conn, db_path) -> None:
383
+ """End-of-sync best-effort WAL drain (#297).
384
+
385
+ Threshold-gated (only once the -wal file has grown past
386
+ ``CACHE_WAL_CHECKPOINT_TRIGGER_BYTES``) and run under a near-nonblocking
387
+ short busy_timeout so it NEVER stalls the held sync flock — the checkpoint
388
+ runs BEFORE the flock is released, so a long busy wait here would stall
389
+ every above-threshold sync under exactly the heavy-reader contention that
390
+ motivates the fix. Restores the prior busy_timeout. Fail-soft: a checkpoint
391
+ error must never fail the sync (observability/hygiene, not correctness).
392
+
393
+ PRECONDITION: ``conn`` has no active transaction — the caller has committed
394
+ all ingest work by this point.
395
+ """
396
+ try:
397
+ if _wal_file_size(db_path) <= CACHE_WAL_CHECKPOINT_TRIGGER_BYTES:
398
+ return
399
+ prior = conn.execute("PRAGMA busy_timeout").fetchone()[0]
400
+ try:
401
+ conn.execute(f"PRAGMA busy_timeout={CHECKPOINT_AUTO_BUSY_TIMEOUT_MS}")
402
+ _run_wal_truncate(conn, db_path, db_label="cache.db")
403
+ finally:
404
+ conn.execute(f"PRAGMA busy_timeout={prior}")
405
+ except sqlite3.DatabaseError:
406
+ pass # best-effort; observability/hygiene must never fail a sync
407
+
408
+
319
409
  _PARSE_HEALTH_SCHEMA = 1
320
410
 
321
411
 
@@ -1875,6 +1965,11 @@ def sync_cache(
1875
1965
  # write has materialized the -wal/-shm sidecars. open_cache_db hardens
1876
1966
  # cache.db + the data dir; this finishes the job for the sidecars.
1877
1967
  _harden_cache_sidecars()
1968
+ # #297: forced end-of-sync WAL drain. Threshold-gated + short-timeout +
1969
+ # best-effort. Runs here — all ingest work is committed (no active txn)
1970
+ # and the flock is still held, so the short busy_timeout keeps it from
1971
+ # stalling the lock under heavy-reader contention.
1972
+ _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
1878
1973
  return stats
1879
1974
  finally:
1880
1975
  try:
@@ -3262,6 +3357,12 @@ def sync_codex_cache(
3262
3357
  skip_reasons=stats.skip_reasons,
3263
3358
  rebuild=rebuild,
3264
3359
  )
3360
+ # #297: forced end-of-sync WAL drain (Codex half). Claude and Codex
3361
+ # ingests use SEPARATE flocks but commit into the SAME cache.db WAL, so
3362
+ # the fail-fast short timeout naturally dedupes concurrent attempts —
3363
+ # whoever wins truncates; the other gets `busy` immediately and moves
3364
+ # on. All Codex ingest work is committed here (no active txn).
3365
+ _maybe_truncate_wal(conn, _cctally_core.CACHE_DB_PATH)
3265
3366
  return stats
3266
3367
  finally:
3267
3368
  try:
@@ -3513,7 +3614,14 @@ def open_cache_db() -> sqlite3.Connection:
3513
3614
  eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
3514
3615
 
3515
3616
  conn.execute("PRAGMA journal_mode=WAL")
3516
- conn.execute("PRAGMA busy_timeout=5000")
3617
+ # #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
3618
+ # instead of instantly erroring with "database is locked". Covers plain
3619
+ # writer-vs-writer SQLITE_BUSY, not SQLITE_BUSY_SNAPSHOT (defended by
3620
+ # write-lock-up-front at the write sites, #87).
3621
+ conn.execute("PRAGMA busy_timeout=15000")
3622
+ # #297: bound the persistent WAL file so a resetting checkpoint truncates it
3623
+ # back to this cap instead of leaving it at its multi-GB high-water size.
3624
+ conn.execute(f"PRAGMA journal_size_limit={CACHE_WAL_SIZE_LIMIT_BYTES}")
3517
3625
  # Re-derivable DB under WAL: NORMAL risks at most the tail transaction on
3518
3626
  # power loss, and cache.db can always be rebuilt (cache-sync --rebuild).
3519
3627
  # Fewer ingest fsyncs than the default FULL. Matches stats.db
@@ -213,6 +213,14 @@ def _real_prod_data_dir() -> pathlib.Path:
213
213
  _init_paths_from_env()
214
214
 
215
215
 
216
+ # stats.db WAL cap (#297). Bounds the persistent WAL file so a resetting
217
+ # checkpoint truncates it back down instead of leaving it at its high-water
218
+ # size. stats.db writes are small (its -wal was observed at 0 bytes even under
219
+ # the contention that bloated cache.db to multi-GB), so a tighter 16 MB cap is
220
+ # ample. See docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
221
+ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
222
+
223
+
216
224
  # === Telemetry constants (non-path; see spec 2026-07-07) =============
217
225
  #
218
226
  # These are static (not APP_DIR-derived) so they live outside
@@ -939,16 +947,17 @@ def open_db() -> sqlite3.Connection:
939
947
  conn.execute("PRAGMA journal_mode=WAL")
940
948
  conn.execute("PRAGMA synchronous=NORMAL")
941
949
  # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
942
- # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
943
- # so this is not a behavior change it makes the multi-writer retry window
944
- # an explicit contract beside the WAL pragmas instead of an inherited
945
- # default a reader has to know about. NOTE: busy_timeout does NOT absorb
946
- # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
947
- # invalidated by a competing commit raises "database is locked" instantly,
948
- # bypassing the busy handler). The write paths defend against that by taking
949
- # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
950
- # first DML. See cctally-dev#87.
951
- conn.execute("PRAGMA busy_timeout=5000")
950
+ # #297: raised 5000 -> 15000 so a writer waits out a slow-but-normal
951
+ # sync (>5 s) instead of instantly erroring with "database is locked".
952
+ # NOTE: busy_timeout does NOT absorb SQLITE_BUSY_SNAPSHOT (a WAL
953
+ # read-then-write transaction whose snapshot is invalidated by a
954
+ # competing commit raises "database is locked" instantly, bypassing the
955
+ # busy handler). The write paths defend against that by taking the write
956
+ # lock up front BEGIN IMMEDIATE, or a write as the transaction's first
957
+ # DML. See cctally-dev#87.
958
+ conn.execute("PRAGMA busy_timeout=15000")
959
+ # #297: bound the persistent WAL file (symmetry with open_cache_db).
960
+ conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
952
961
  conn.execute("SELECT 1").fetchone()
953
962
  except sqlite3.DatabaseError as exc:
954
963
  try:
@@ -5453,3 +5453,66 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
5453
5453
  return 0
5454
5454
  finally:
5455
5455
  conn.close()
5456
+
5457
+
5458
+ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
5459
+ """Fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db /
5460
+ stats.db (#297).
5461
+
5462
+ Opens a RAW existing-file-only connection — NOT open_cache_db()/open_db(),
5463
+ which apply schema, run the migration dispatcher, can DELETE Codex rows on a
5464
+ column-add, and CREATE a missing DB; a checkpoint may do none of those.
5465
+ Precedent: cmd_db_recover's raw connect above. It flushes WAL frames into
5466
+ the main DB and truncates the -wal file — it changes no data, no schema, no
5467
+ user_version — so there is no prod guard and no --yes.
5468
+
5469
+ Exit 0 when drained / already-small / the DB is absent; 3 (staged) if the
5470
+ target stayed busy or was not fully truncated through the timeout — an
5471
+ actionable "something is still holding it" signal.
5472
+ """
5473
+ import _cctally_cache
5474
+ from _lib_json_envelope import stamp_schema_version
5475
+
5476
+ which = args.db # "cache" | "stats"
5477
+ if which == "cache":
5478
+ path, label = _cctally_core.CACHE_DB_PATH, "cache.db"
5479
+ else:
5480
+ path, label = _cctally_core.DB_PATH, "stats.db"
5481
+ timeout = int(getattr(args, "busy_timeout_ms", None)
5482
+ or _cctally_cache.CHECKPOINT_CMD_BUSY_TIMEOUT_MS)
5483
+ as_json = bool(getattr(args, "json", False))
5484
+
5485
+ # Absent file → nothing to drain; a missing re-derivable cache is not an
5486
+ # error (mirrors cmd_db_recover / cmd_db_unskip). Do NOT connect — mode=rw
5487
+ # would refuse to create it, but skip it entirely for a clean message.
5488
+ if not path.exists():
5489
+ if as_json:
5490
+ payload = {"db": label, "walBytesBefore": 0, "walBytesAfter": 0,
5491
+ "framesCheckpointed": 0, "busy": False, "truncated": True,
5492
+ "present": False}
5493
+ print(json.dumps(stamp_schema_version(payload, version=1)))
5494
+ else:
5495
+ print(f"cctally: no {label} database file present; nothing to drain.")
5496
+ return 0
5497
+
5498
+ conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
5499
+ try:
5500
+ conn.execute(f"PRAGMA busy_timeout={timeout}")
5501
+ result = _cctally_cache._run_wal_truncate(conn, path, db_label=label)
5502
+ finally:
5503
+ conn.close()
5504
+
5505
+ if as_json:
5506
+ payload = {"db": result.db, "walBytesBefore": result.wal_bytes_before,
5507
+ "walBytesAfter": result.wal_bytes_after,
5508
+ "framesCheckpointed": result.frames_checkpointed,
5509
+ "busy": result.busy, "truncated": result.truncated,
5510
+ "present": True}
5511
+ print(json.dumps(stamp_schema_version(payload, version=1)))
5512
+ else:
5513
+ mb_b = result.wal_bytes_before / (1024 * 1024)
5514
+ mb_a = result.wal_bytes_after / (1024 * 1024)
5515
+ state = "drained" if result.truncated else ("still busy" if result.busy else "partial")
5516
+ print(f"cctally: {result.db} WAL {mb_b:.1f} MB -> {mb_a:.1f} MB "
5517
+ f"({result.frames_checkpointed} frames; {state}).")
5518
+ return 0 if result.truncated else 3
@@ -473,6 +473,17 @@ def doctor_gather_state(
473
473
  except Exception:
474
474
  locks_held = None
475
475
 
476
+ # ── cache.db WAL size (#297) — read-only backstop ────────────────
477
+ # Gathered OUTSIDE the deep/quick_check branch (above) so the WAL-size
478
+ # check runs in both shallow and deep gather modes. Best-effort getsize;
479
+ # None on OSError/race (doctor never blocks or raises), 0 when absent.
480
+ cache_db_wal_bytes: "int | None"
481
+ try:
482
+ _wal = pathlib.Path(f"{_cctally_core.CACHE_DB_PATH}-wal")
483
+ cache_db_wal_bytes = _wal.stat().st_size if _wal.exists() else 0
484
+ except OSError:
485
+ cache_db_wal_bytes = None
486
+
476
487
  # ── Safety ───────────────────────────────────────────────────────
477
488
  # `dashboard.bind` is read via the same chokepoint that powers
478
489
  # `cctally config get dashboard.bind` — `_config_known_value`
@@ -646,6 +657,8 @@ def doctor_gather_state(
646
657
  stats_db_quick_check=stats_db_quick_check,
647
658
  cache_db_quick_check=cache_db_quick_check,
648
659
  locks_held=locks_held,
660
+ # #297: cache.db WAL size backstop (gathered outside the deep branch).
661
+ cache_db_wal_bytes=cache_db_wal_bytes,
649
662
  )
650
663
 
651
664
 
@@ -2576,6 +2576,29 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2576
2576
  help="Required for --db stats (non-re-derivable; may need a re-record)",
2577
2577
  )
2578
2578
  db_recover.set_defaults(func=c.cmd_db_recover)
2579
+ db_checkpoint = db_sub.add_parser(
2580
+ "checkpoint",
2581
+ help="Drain the WAL (TRUNCATE checkpoint) — fast, non-destructive",
2582
+ )
2583
+ db_checkpoint.add_argument(
2584
+ "--db",
2585
+ choices=("cache", "stats"),
2586
+ default="cache",
2587
+ help="Which DB to checkpoint (default: cache)",
2588
+ )
2589
+ db_checkpoint.add_argument(
2590
+ "--json",
2591
+ action="store_true",
2592
+ help="Emit JSON to stdout",
2593
+ )
2594
+ db_checkpoint.add_argument(
2595
+ "--busy-timeout-ms",
2596
+ dest="busy_timeout_ms",
2597
+ type=int,
2598
+ default=15000,
2599
+ help=argparse.SUPPRESS,
2600
+ )
2601
+ db_checkpoint.set_defaults(func=c.cmd_db_checkpoint)
2579
2602
 
2580
2603
  def _build_doctor_parser(subparsers, name, *, help_text, xref=None):
2581
2604
  """Build the `doctor` parser (registered via _REGISTRATION; #279 S6 W3).
@@ -194,6 +194,11 @@ class DoctorState:
194
194
  # (name -> True held / False free / None unreadable). Probe never
195
195
  # creates files (doctor read-only contract). None = probe errored.
196
196
  locks_held: Optional[dict] = None
197
+ # #297: size in bytes of cache.db-wal, gathered read-only in
198
+ # doctor_gather_state (getsize, OSError/absent -> None). Warns above
199
+ # DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
200
+ # + forced-checkpoint machinery has genuinely failed to contain the WAL.
201
+ cache_db_wal_bytes: Optional[int] = None
197
202
 
198
203
 
199
204
  @dataclasses.dataclass(frozen=True)
@@ -1340,6 +1345,36 @@ def _check_db_lock_state(s: DoctorState) -> CheckResult:
1340
1345
  )
1341
1346
 
1342
1347
 
1348
+ # #297: WARN when cache.db-wal exceeds 2x the Section 1 cap (128 MiB), so this
1349
+ # only fires when the journal_size_limit + forced-checkpoint machinery has
1350
+ # genuinely failed to contain the WAL — never in normal operation.
1351
+ DOCTOR_WAL_WARN_BYTES = 256 * 1024 * 1024 # 268435456
1352
+
1353
+
1354
+ def _check_db_wal_size(s: DoctorState) -> CheckResult:
1355
+ """Read-only backstop that makes a genuine cache.db WAL wedge visible and
1356
+ points at `cctally db checkpoint` (#297).
1357
+
1358
+ The exact byte count lives ONLY in the (fingerprint-excluded) details block;
1359
+ the summary stays a stable string so a below-threshold byte count that
1360
+ drifts tick-to-tick does not flip the doctor fingerprint — only an OK<->WARN
1361
+ crossing does.
1362
+ """
1363
+ wal = s.cache_db_wal_bytes
1364
+ details = {"cache_db_wal_bytes": wal}
1365
+ if isinstance(wal, int) and wal > DOCTOR_WAL_WARN_BYTES:
1366
+ return CheckResult(
1367
+ id="db.wal_size", title="cache.db WAL size", severity="warn",
1368
+ summary="oversized — cache.db WAL far above its cap",
1369
+ remediation="Run `cctally db checkpoint` to drain the WAL.",
1370
+ details=details,
1371
+ )
1372
+ return CheckResult(
1373
+ id="db.wal_size", title="cache.db WAL size", severity="ok",
1374
+ summary="within limit", remediation=None, details=details,
1375
+ )
1376
+
1377
+
1343
1378
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1344
1379
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1345
1380
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1370,6 +1405,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1370
1405
  ("db.migrations.applied", "_check_db_migrations_applied"),
1371
1406
  ("db.migrations.pending", "_check_db_migrations_pending"),
1372
1407
  ("db.lock_state", "_check_db_lock_state"),
1408
+ ("db.wal_size", "_check_db_wal_size"),
1373
1409
  )),
1374
1410
  ("data", "Data", (
1375
1411
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
package/bin/cctally CHANGED
@@ -831,6 +831,7 @@ _db_path_for_label = _cctally_db._db_path_for_label
831
831
  cmd_db_skip = _cctally_db.cmd_db_skip
832
832
  cmd_db_unskip = _cctally_db.cmd_db_unskip
833
833
  cmd_db_recover = _cctally_db.cmd_db_recover
834
+ cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
834
835
 
835
836
 
836
837
  # Session-entry cache subsystem (Claude + Codex) — read-through delta
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {