cctally 1.92.2 → 1.93.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -1024,10 +1024,12 @@ def _build_project_snapshot(
1024
1024
  Privacy invariant (Section 8.4 / Section 5.3): the builder populates
1025
1025
  `ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
1026
1026
  which is the project axis on a HorizontalBarChart) with the REAL
1027
- `display_key`. The `_share_render_and_emit` wrapper then runs
1028
- `_lib_share._scrub` BEFORE rendering that's the single chokepoint
1029
- that rewrites every project label to `project-1` / `project-2` /
1030
- ... unless `--reveal-projects` is passed. The Section 8.4 canary
1027
+ `display_key`. `_lib_share.render()` is the chokepoint: it prepares
1028
+ the RAW snapshot it is handed, rewriting every project label to
1029
+ `project-1` / `project-2` / ... unless `--reveal-projects` is
1030
+ passed. (It is NOT `_scrub()`: that function is retained for
1031
+ backward compatibility and no production path calls it.) The
1032
+ Section 8.4 canary
1031
1033
  test (`test_anonymized_output_contains_zero_original_tokens`) and
1032
1034
  the wrapper-level regression
1033
1035
  (`test_share_render_and_emit_scrubs_project_labels`) both anchor
@@ -1133,6 +1135,7 @@ def _build_project_snapshot(
1133
1135
  x_value=cost,
1134
1136
  y_value=cost,
1135
1137
  project_label=proj_label,
1138
+ x_label_kind="project",
1136
1139
  ))
1137
1140
  chart = (
1138
1141
  _lib_share.HorizontalBarChart(
@@ -1377,27 +1380,43 @@ def _session_disambiguate_labels(
1377
1380
 
1378
1381
  Sessions without collisions are absent from the returned dict;
1379
1382
  callers fall back to the bare basename.
1383
+
1384
+ #503 S1: the algorithm itself now lives in the share kernel as
1385
+ ``_lib_share.disambiguate_basenames``, because the kernel's reveal-mode
1386
+ preparation needs the same resolution on the dashboard path. This is the
1387
+ delegating wrapper, so "the kernel matches the CLI" is one implementation
1388
+ rather than two that agree by inspection. The kernel additionally widens
1389
+ the qualifier when the parents also repeat, where this function used to
1390
+ emit the same colliding label twice.
1391
+
1392
+ The kernel's input contract is one entry per DISTINCT identity, so this
1393
+ wrapper deduplicates by path before delegating and fans the resulting
1394
+ label back out to every session sharing that path. A session list
1395
+ normally carries one project across several rows, and handing those
1396
+ duplicates to the kernel makes its ordinal fallback invent
1397
+ ``repo (parent) (1)`` / ``(2)`` / ``(3)`` for what is one project — which
1398
+ also splits that project's cost across three alias slots, because a
1399
+ session-derived ``ProjectCell`` carries no ``identity`` and the alias key
1400
+ is therefore the label itself.
1380
1401
  """
1381
- basenames: list[str] = []
1382
- for s in sessions:
1383
- path = s.project_path or ""
1384
- basenames.append(os.path.basename(path) or path or "(unknown)")
1385
- counts: dict[str, int] = {}
1386
- for bn in basenames:
1387
- counts[bn] = counts.get(bn, 0) + 1
1388
- augmented: dict[int, str] = {}
1389
- for idx, s in enumerate(sessions):
1390
- bn = basenames[idx]
1391
- # Skip suffixing the literal "(unknown)" bare label even on
1392
- # collision: `_build_anon_mapping` literal-passthrough-protects
1393
- # exact "(unknown)" only — a suffixed form like "(unknown) (/)"
1394
- # would be mapped to a regular `project-N` slot, losing the
1395
- # (unknown) semantic in the anonymized output.
1396
- if counts[bn] > 1 and bn != "(unknown)":
1397
- path = s.project_path or ""
1398
- parent = os.path.basename(os.path.dirname(path)) or "/"
1399
- augmented[idx] = f"{bn} ({parent})"
1400
- return augmented
1402
+ _lib_share = _share_load_lib()
1403
+ paths = [(s.project_path or "") for s in sessions]
1404
+ distinct: list[str] = []
1405
+ index_of: dict[str, int] = {}
1406
+ for p in paths:
1407
+ if p not in index_of:
1408
+ index_of[p] = len(distinct)
1409
+ distinct.append(p)
1410
+ resolved = _lib_share.disambiguate_basenames(distinct)
1411
+ bare = [
1412
+ (os.path.basename(p) or p or "(unknown)") for p in paths
1413
+ ]
1414
+ out: dict[int, str] = {}
1415
+ for idx, p in enumerate(paths):
1416
+ label = resolved[index_of[p]]
1417
+ if label != bare[idx]:
1418
+ out[idx] = label
1419
+ return out
1401
1420
 
1402
1421
 
1403
1422
  def _build_session_snapshot(
@@ -1428,11 +1447,12 @@ def _build_session_snapshot(
1428
1447
 
1429
1448
  Privacy invariant (Section 8.4 / Section 5.3): the builder populates
1430
1449
  `ProjectCell.label`, `ChartPoint.project_label`, and
1431
- `ChartPoint.x_label` with the REAL `project_path` basename. The
1432
- `_share_render_and_emit` wrapper runs `_lib_share._scrub` BEFORE
1433
- rendering that's the single chokepoint that rewrites every
1434
- project label to `project-1` / `project-2` / ... unless
1435
- `--reveal-projects` is passed.
1450
+ `ChartPoint.x_label` with the REAL `project_path` basename.
1451
+ `_lib_share.render()` is the chokepoint: it prepares the RAW
1452
+ snapshot it is handed, rewriting every project label to
1453
+ `project-1` / `project-2` / ... unless `--reveal-projects` is
1454
+ passed. (It is NOT `_scrub()`: that function is retained for
1455
+ backward compatibility and no production path calls it.)
1436
1456
 
1437
1457
  Deviations from the plan sketch (which assumed dict rows with keys
1438
1458
  `session_id` / `started_at` / `project_path` / `cost_usd` /
@@ -1530,6 +1550,7 @@ def _build_session_snapshot(
1530
1550
  x_value=cost_usd,
1531
1551
  y_value=cost_usd,
1532
1552
  project_label=proj_label,
1553
+ x_label_kind="project",
1533
1554
  ))
1534
1555
  chart = (
1535
1556
  _lib_share.HorizontalBarChart(
@@ -1651,7 +1672,7 @@ def _share_iso(value) -> "str | None":
1651
1672
  # control.
1652
1673
 
1653
1674
  def _share_render_and_emit(snap, args) -> None:
1654
- """End-to-end: scrub -> render -> emit -> optional open.
1675
+ """End-to-end: render -> emit -> optional open.
1655
1676
 
1656
1677
  Lazy-imports `_lib_share` so non-share invocations don't pay the import
1657
1678
  cost. The kernel module stays I/O-pure; this wrapper does all the
@@ -1680,13 +1701,29 @@ def _share_render_and_emit(snap, args) -> None:
1680
1701
  # class-identity invariant this enforces.
1681
1702
  _lib_share = _share_load_lib()
1682
1703
 
1683
- scrubbed = _lib_share._scrub(snap, reveal_projects=args.reveal_projects)
1684
- rendered = _lib_share.render(
1685
- scrubbed,
1686
- format=args.format,
1687
- theme=args.theme,
1688
- branding=not args.no_branding,
1689
- )
1704
+ # No pre-scrub. `render()` owns the privacy contract and must receive the
1705
+ # RAW snapshot: a second aliasing pass over an already-aliased legacy
1706
+ # label (identity None) renumbers it by re-ranking (#503 S1).
1707
+ #
1708
+ # A privacy refusal is a MESSAGE, not a traceback. `render()` raises
1709
+ # `SharePrivacyViolation` rather than redacting, so this is a reachable
1710
+ # user-facing outcome and the CLI owes the user a sentence naming what it
1711
+ # found. Exit 3 per `docs/cli-contract.md`: the share surface's staged
1712
+ # family already uses 2 for a flag-combo error and 3 for a stage that
1713
+ # failed after the flags validated, which is exactly what this is. The
1714
+ # refusal precedes destination resolution, so nothing is written.
1715
+ try:
1716
+ rendered = _lib_share.render(
1717
+ snap,
1718
+ format=args.format,
1719
+ theme=args.theme,
1720
+ branding=not args.no_branding,
1721
+ reveal_projects=args.reveal_projects,
1722
+ )
1723
+ except _lib_share.SharePrivacyViolation as exc:
1724
+ print(f"cctally: refused to write a share artifact — {exc}",
1725
+ file=sys.stderr)
1726
+ sys.exit(3)
1690
1727
 
1691
1728
  utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
1692
1729
  kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
@@ -1919,15 +1919,13 @@ def _emit_source_share(
1919
1919
  claude_snap = build_source_share_snapshot(
1920
1920
  command, claude, reveal_projects=reveal_projects,
1921
1921
  )
1922
+ # RAW sections (#503 S1): `compose()` prepares them together under one
1923
+ # merged alias namespace, so `project-1` denotes the same project in
1924
+ # the Claude and the Codex section. A per-section pre-scrub gave each
1925
+ # section its own numbering.
1922
1926
  sections = (
1923
- lib.ComposedSection(
1924
- snap=lib._scrub(claude_snap, reveal_projects=reveal_projects),
1925
- drift_detected=False,
1926
- ),
1927
- lib.ComposedSection(
1928
- snap=lib._scrub(codex_snap, reveal_projects=reveal_projects),
1929
- drift_detected=False,
1930
- ),
1927
+ lib.ComposedSection(snap=claude_snap, drift_detected=False),
1928
+ lib.ComposedSection(snap=codex_snap, drift_detected=False),
1931
1929
  )
1932
1930
  content = lib.compose(
1933
1931
  sections,
@@ -638,9 +638,17 @@ _STATS_OPEN_TIME_MAINTENANCE_WAIT_S = 30.0
638
638
 
639
639
 
640
640
  @contextlib.contextmanager
641
- def stats_open_time_guard(*, live: bool = True):
641
+ def stats_open_time_guard(
642
+ *, live: bool = True, wait_seconds: "float | None" = None,
643
+ ):
642
644
  """Hold maintenance-exclusive + the sanctioned scope across open-time DDL.
643
645
 
646
+ ``wait_seconds`` overrides the generous default wait. #496 S5b's
647
+ quota-projection reconciliation passes ``0.0``: that path is optional work
648
+ on the open path, and waiting thirty seconds for a competitor to finish its
649
+ own maintenance is strictly worse than leaving the flag set for the next
650
+ armed open, which is the fail-closed direction anyway.
651
+
644
652
  ``live=False`` marks a ``_target_path`` (scratch) build: it enters the
645
653
  sanctioned scope but takes NO flock, mirroring the divergence
646
654
  ``stats_open_guarded`` already documents. Two reasons, both concrete:
@@ -668,7 +676,10 @@ def stats_open_time_guard(*, live: bool = True):
668
676
  )
669
677
  acquired = False
670
678
  try:
671
- deadline = time.monotonic() + _STATS_OPEN_TIME_MAINTENANCE_WAIT_S
679
+ deadline = time.monotonic() + (
680
+ _STATS_OPEN_TIME_MAINTENANCE_WAIT_S if wait_seconds is None
681
+ else float(wait_seconds)
682
+ )
672
683
  while True:
673
684
  try:
674
685
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -270,6 +270,11 @@ from _cctally_dashboard_sources import (
270
270
  refresh_codex_source_clock,
271
271
  resolve_dashboard_source_semantics,
272
272
  )
273
+ # #496 S5b §4.7. Module level rather than lazy, unlike this module's other
274
+ # `_cctally_quota` uses: an `except` clause needs the name bound before the
275
+ # `try`, and `_cctally_dashboard_sources` above already imports that module at
276
+ # ITS module level, so this adds no edge to the import graph.
277
+ from _cctally_quota import QuotaProjectionIncomplete
273
278
  from _lib_dashboard_sources import (
274
279
  SOURCE_SCHEMA_VERSION,
275
280
  CapabilityRecord,
@@ -2607,6 +2612,23 @@ def _tui_build_source_bundle(
2607
2612
  raw_config if raw_config is not None else c.load_config(),
2608
2613
  display_tz_name=display_tz_name,
2609
2614
  )
2615
+ # `codex_stats_digest`'s relation table reads `quota_projection_state`
2616
+ # and `quota_window_blocks` from a pure kernel that may not import
2617
+ # `_cctally_quota`, so its callers gate it (#496 S5b section 4.7).
2618
+ #
2619
+ # This ONE call covers the second `codex_stats_digest` below as well,
2620
+ # and the reason is not that the gate is a property of the connection:
2621
+ # this connection's statement-scoped autocommit reads may see mixed
2622
+ # generations, which is exactly what `stats_generation_moved` exists to
2623
+ # catch, so the post-build read CAN see a generation this gate never
2624
+ # checked. It is safe because that digest is never rendered — it is only
2625
+ # compared against this one. A generation carrying an incomplete
2626
+ # projection produces a different digest, `stats_generation_moved` is
2627
+ # then true, and the build returns the prior bundle or raises. An EQUAL
2628
+ # digest means the relations did not move, so the post-build read saw
2629
+ # the generation this gate did check.
2630
+ from _cctally_quota import assert_projection_readable
2631
+ assert_projection_readable(stats_conn)
2610
2632
  stats_digest = codex_stats_digest(stats_conn)
2611
2633
  # #341 finding 9: the account registry/active-identity digest. Empty for
2612
2634
  # every <=1-account install (byte-neutral — appended only when non-empty),
@@ -3254,6 +3276,13 @@ def _tui_build_snapshot_once(
3254
3276
  with _perf.phase("signature"):
3255
3277
  try:
3256
3278
  dispatch_sig = _tui_compute_dispatch_signature(conn)
3279
+ except QuotaProjectionIncomplete as exc:
3280
+ # #496 S5b §4.7: ahead of the generic handler on purpose.
3281
+ # Below it the refusal is sanitized into a generic
3282
+ # stats-or-cache failure, which names no cause and no
3283
+ # remedy; the message this leg records carries both.
3284
+ capture_failure("quota-projection", "other", exc)
3285
+ dispatch_sig = None
3257
3286
  except Exception as exc:
3258
3287
  capture_failure("dispatch-signature", "stats_or_cache", exc)
3259
3288
  dispatch_sig = None
@@ -3886,6 +3915,15 @@ def _tui_build_snapshot_once(
3886
3915
  )
3887
3916
  if source_bundle is None:
3888
3917
  raise RuntimeError("source bundle builder returned no bundle")
3918
+ except QuotaProjectionIncomplete as exc:
3919
+ # #496 S5b §4.7: ahead of the generic handler on purpose. Below
3920
+ # it the refusal was sanitized into a generic stats-or-cache
3921
+ # failure and the bundle fell back to `prior_source_bundle`,
3922
+ # which is `None` on a cold start — a permanently blank Codex
3923
+ # source panel with no cause and no remedy stated. The message
3924
+ # this leg records carries both.
3925
+ capture_failure("quota-projection", "other", exc)
3926
+ source_bundle = prior_source_bundle
3889
3927
  except Exception as exc:
3890
3928
  # Public source warnings are stable/sanitized; the detailed
3891
3929
  # exception remains only on the internal rebuild-error string.
@@ -4012,6 +4050,11 @@ def _tui_compute_dispatch_signature(stats_conn):
4012
4050
  """
4013
4051
  c = _cctally()
4014
4052
  sc = c._load_sibling("_lib_snapshot_cache")
4053
+ # Same gate as `_tui_build_source_bundle`, for the same reason: this is the
4054
+ # second caller of `codex_stats_digest`, whose relation table reads the two
4055
+ # projection tables from a pure kernel (#496 S5b section 4.7).
4056
+ from _cctally_quota import assert_projection_readable
4057
+ assert_projection_readable(stats_conn)
4015
4058
  cache_conn = c.open_cache_db()
4016
4059
  try:
4017
4060
  return sc.compute_signature(
@@ -4202,6 +4245,16 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4202
4245
  )
4203
4246
  except _StatsSnapshotCorruption:
4204
4247
  raise
4248
+ except QuotaProjectionIncomplete as exc:
4249
+ # #496 S5b §4.7, same reason as the two handlers in
4250
+ # `_tui_build_snapshot_once`: the branch below classifies against
4251
+ # the connection that faulted and would report a stats-or-cache
4252
+ # fault, which is the wrong cause and the wrong remedy.
4253
+ errors.append(f"quota-projection: {exc}")
4254
+ idle_failures.append(SyncFailureAttribution(
4255
+ leg="quota-projection", database="other", corruption=False,
4256
+ ))
4257
+ source_bundle = prior.source_bundle
4205
4258
  except Exception as exc: # noqa: BLE001 — retain prior complete bundle
4206
4259
  # #496 S3 §8 (F16). This branch read stats through
4207
4260
  # `source_stats_conn` and swallowed the failure into a plain