cctally 1.2.0 → 1.4.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/bin/cctally CHANGED
@@ -83,7 +83,17 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
83
83
 
84
84
  # === Release automation (issue #24) ===
85
85
 
86
- CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
86
+ _CHANGELOG_OVERRIDE = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
87
+ if _CHANGELOG_OVERRIDE:
88
+ # Fixture-stability hook for `bin/cctally-share-test` — points
89
+ # `_share_resolve_version()` (and the broader release machinery) at a
90
+ # per-scenario CHANGELOG so version stamping in goldens stays
91
+ # deterministic regardless of the in-tree CHANGELOG state. Mirrors the
92
+ # `CCTALLY_AS_OF` env-only precedent: not in --help, no docstring
93
+ # surface; consumed exclusively by harness wrappers.
94
+ CHANGELOG_PATH = pathlib.Path(_CHANGELOG_OVERRIDE)
95
+ else:
96
+ CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
87
97
 
88
98
 
89
99
  def _package_json_path() -> pathlib.Path:
@@ -1446,79 +1456,82 @@ def _release_run_phase_gh(version: str, body: str) -> int:
1446
1456
  return 0
1447
1457
 
1448
1458
 
1459
+ _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT = 300.0
1460
+ _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT = 10.0
1461
+
1462
+
1463
+ def _release_npm_poll_timing() -> tuple[float, float]:
1464
+ """Return (timeout_s, interval_s) honoring env-hook overrides.
1465
+
1466
+ Hidden env hooks (mirrors ``CCTALLY_RELEASE_DATE_UTC``):
1467
+ - CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S
1468
+ - CCTALLY_RELEASE_NPM_POLL_INTERVAL_S
1469
+ Used by the harness (and pytest) to make Phase 5 fixtures deterministic.
1470
+ Not in --help.
1471
+ """
1472
+ def _f(name: str, default: float) -> float:
1473
+ try:
1474
+ return float(os.environ[name])
1475
+ except (KeyError, ValueError):
1476
+ return default
1477
+ return (
1478
+ _f("CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S", _RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT),
1479
+ _f("CCTALLY_RELEASE_NPM_POLL_INTERVAL_S", _RELEASE_NPM_POLL_INTERVAL_S_DEFAULT),
1480
+ )
1481
+
1482
+
1449
1483
  def _release_run_phase_npm(
1450
1484
  version: str,
1451
1485
  public_clone: pathlib.Path,
1452
1486
  *,
1453
1487
  dist_tag: str,
1454
1488
  ) -> int:
1455
- """Phase 5 — publish to npm from the public clone.
1489
+ """Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
1456
1490
 
1457
- ``dist_tag`` is ``"latest"`` for stable releases, ``"next"`` for
1458
- prereleases. Idempotent: short-circuits when
1459
- ``_release_phase_npm_done`` reports the version is already on npm.
1491
+ Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
1492
+ ``.github/workflows/release-npm.yml`` fires on tag-push and runs
1493
+ ``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
1494
+ no operator 2FA round-trip — fixes the passkey-in-subprocess failure
1495
+ mode where npm 2FA blocks ``npm publish`` from a non-interactive
1496
+ subprocess).
1460
1497
 
1461
- Auth-fallback parity with Phase 4: when ``npm whoami`` exits
1462
- nonzero (or npm isn't on PATH at all), prints a copy-pasteable
1463
- command to publish manually and returns 0 phases 1-4 already
1464
- succeeded, the release IS published to GitHub from the user's
1465
- perspective; npm is the third channel and treated as polish.
1498
+ Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
1499
+ it appears, with timeout. ``cctally`` never invokes ``npm publish``
1500
+ locally anymore Trusted Publisher binds the right to publish to the
1501
+ public-repo workflow, not to the operator's `npm login` token.
1466
1502
 
1467
- Returns:
1468
- - ``0`` on successful publish, idempotent short-circuit, OR
1469
- auth-fallback.
1470
- - ``3`` on hard failure of ``npm publish`` after auth was
1471
- confirmed OK; ``--resume`` retries.
1503
+ Returns ``0`` on observed success OR poll-timeout (soft-success: phases
1504
+ 1-4 landed; the workflow is either succeeding or visibly failing on
1505
+ github.com; ``--resume`` re-checks the registry).
1506
+
1507
+ Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
1508
+ ``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
1472
1509
  """
1473
- print(f"phase 5: npm publish (tag={dist_tag})")
1510
+ print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
1474
1511
  if _release_phase_npm_done(version):
1475
1512
  print(f" cctally@{version} already on npm — skipping.")
1476
1513
  return 0
1477
1514
 
1478
- # Auth probe. Wrap in try/except so missing npm-on-PATH falls
1479
- # cleanly into the auth-fallback branch (existing release-test
1480
- # scenarios run without npm on PATH and depend on this not crashing).
1481
- try:
1482
- auth = subprocess.run(
1483
- ["npm", "whoami"],
1484
- capture_output=True,
1485
- text=True,
1486
- check=False,
1487
- timeout=15,
1488
- )
1489
- except (subprocess.TimeoutExpired, FileNotFoundError):
1490
- print(
1491
- f"\n npm not on PATH or unresponsive. After installing npm and "
1492
- f"`npm login`, run:\n"
1493
- f" cd {public_clone} && npm publish --tag {dist_tag}\n",
1494
- file=sys.stderr,
1495
- )
1496
- return 0
1497
- if auth.returncode != 0:
1498
- print(
1499
- f"\n npm not authenticated. After `npm login`, run:\n"
1500
- f" cd {public_clone} && npm publish --tag {dist_tag}\n",
1501
- file=sys.stderr,
1502
- )
1503
- return 0
1504
-
1505
- pub = subprocess.run(
1506
- ["npm", "publish", "--tag", dist_tag],
1507
- cwd=str(public_clone),
1508
- check=False,
1509
- )
1510
- if pub.returncode != 0:
1511
- return 3
1512
-
1513
- if not _release_phase_npm_done(version):
1514
- print(
1515
- f" warning: `npm publish` returned 0 but `npm view "
1516
- f"cctally@{version}` doesn't see the version yet. Registry "
1517
- f"propagation lag is normal; check `npm view cctally version` "
1518
- f"in 30s.",
1519
- file=sys.stderr,
1520
- )
1521
- return 0
1515
+ timeout_s, interval_s = _release_npm_poll_timing()
1516
+ deadline = time.monotonic() + timeout_s
1517
+ while True:
1518
+ if _release_phase_npm_done(version):
1519
+ print(f" cctally@{version} on npm registry ✓")
1520
+ return 0
1521
+ if time.monotonic() >= deadline:
1522
+ print(
1523
+ f"\n timed out after {timeout_s:.0f}s waiting for "
1524
+ f"cctally@{version} on npm. The GHA workflow may still be "
1525
+ f"running or have failed — check:\n"
1526
+ f" https://github.com/{PUBLIC_REPO}/actions\n"
1527
+ f" Re-run `cctally release --resume` once the workflow "
1528
+ f"completes, or for emergency manual publish:\n"
1529
+ f" cd {public_clone} && npm publish --access public "
1530
+ f"--tag {dist_tag}\n",
1531
+ file=sys.stderr,
1532
+ )
1533
+ return 0
1534
+ time.sleep(interval_s)
1522
1535
 
1523
1536
 
1524
1537
  def _release_run_phase_brew(
@@ -1538,8 +1551,8 @@ def _release_run_phase_brew(
1538
1551
  version (idempotency under ``--resume``).
1539
1552
  - Dirty working tree — refuses with exit 2 and points the operator
1540
1553
  at ``--resume``.
1541
- - Push failure — auth-fallback parity with Phases 4 and 5: prints
1542
- the exact recovery command and returns 0. Phases 1-5 already
1554
+ - Push failure — auth-fallback parity with Phase 4: prints the
1555
+ exact recovery command and returns 0. Phases 1-5 already
1543
1556
  succeeded, the release IS published from the user's
1544
1557
  perspective; the brew tap is the third channel and treated as
1545
1558
  polish.
@@ -1620,8 +1633,40 @@ def _release_run_phase_brew(
1620
1633
  )
1621
1634
  # Tag is best-effort — re-running after a partial publish should
1622
1635
  # not fail just because the tag already exists locally.
1636
+ #
1637
+ # Annotated form with `-m` (issue #25): plain `git tag <name>` is
1638
+ # silently upgraded to `git tag -s <name>` under operator-global
1639
+ # `tag.gpgsign=true` and demands a message via editor. The release
1640
+ # script has no editor stdin, so git aborts with `fatal: no tag
1641
+ # message?` — the atomic push refspec then fails with "src refspec
1642
+ # does not match any" because the local tag was never created, and
1643
+ # the auth-fallback branch below silently swallows the failure as
1644
+ # exit 0. Mirrors Phase 2's signing detection (signing_key +
1645
+ # tag.gpgsign → -s, else fall back), with one defensive divergence:
1646
+ # the fallback uses --no-sign (not bare -a) so the tag still lands
1647
+ # under tag.gpgsign=true without a usable signing key configured.
1648
+ # Brew install reads the formula off the tap's default branch, not
1649
+ # the tag, so signing the tap tag is operationally moot — it exists
1650
+ # for history bookkeeping and atomic-push transport.
1651
+ signing_key = subprocess.run(
1652
+ ["git", "-C", str(brew_clone), "config", "--get", "user.signingkey"],
1653
+ capture_output=True,
1654
+ text=True,
1655
+ ).stdout.strip()
1656
+ tag_gpgsign = (
1657
+ subprocess.run(
1658
+ ["git", "-C", str(brew_clone), "config", "--get", "tag.gpgsign"],
1659
+ capture_output=True,
1660
+ text=True,
1661
+ )
1662
+ .stdout.strip()
1663
+ .lower()
1664
+ == "true"
1665
+ )
1666
+ sign_flag = "-s" if (signing_key and tag_gpgsign) else "--no-sign"
1623
1667
  subprocess.run(
1624
- ["git", "-C", str(brew_clone), "tag", f"v{version}"],
1668
+ ["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
1669
+ f"cctally v{version}", f"v{version}"],
1625
1670
  check=False,
1626
1671
  )
1627
1672
  # Single ATOMIC push of branch + tag in one transaction — the
@@ -1630,22 +1675,29 @@ def _release_run_phase_brew(
1630
1675
  # split branch-push + tag-push pair admits: a tag landing without
1631
1676
  # the branch landing would leave `brew install` serving the OLD
1632
1677
  # formula off the tap's default branch even though the remote
1633
- # carries the new tag. Tag refspec is explicit (`src:dst`) because
1634
- # `--follow-tags` skips lightweight tags and Phase 6's tag is
1635
- # lightweight (no `-a`/`-m`).
1678
+ # carries the new tag. Tag refspec is explicit (`src:dst`) for
1679
+ # atomic-push semantics `--atomic` requires named refs, not the
1680
+ # implicit `--follow-tags` path.
1636
1681
  push = subprocess.run(
1637
1682
  ["git", "-C", str(brew_clone), "push", "--atomic", "origin",
1638
1683
  "HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
1639
1684
  check=False,
1640
1685
  )
1641
1686
  if push.returncode != 0:
1687
+ # If the local tag isn't there (e.g., the operator hit the
1688
+ # tag.gpgsign edge case from issue #25 even with the fix), the
1689
+ # plain push refspec fails. Surface a tag-create fallback in
1690
+ # the hint so copy-paste recovery is self-contained.
1642
1691
  print(
1643
1692
  f"\n push failed. Manual recovery:\n"
1693
+ f" # If local tag v{version} is missing (e.g., gpgsign issue):\n"
1694
+ f" git -C {brew_clone} tag --no-sign -a -m \"cctally v{version}\" v{version}\n"
1695
+ f" # Then push:\n"
1644
1696
  f" git -C {brew_clone} push --atomic origin HEAD "
1645
1697
  f"refs/tags/v{version}:refs/tags/v{version}\n",
1646
1698
  file=sys.stderr,
1647
1699
  )
1648
- return 0 # auth-fallback semantics; mirrors Phase 5.
1700
+ return 0 # auth-fallback semantics; parity with Phase 4.
1649
1701
 
1650
1702
  return 0
1651
1703
 
@@ -1846,10 +1898,12 @@ def cmd_release(args: argparse.Namespace) -> int:
1846
1898
  is_prerelease = "-" in next_v
1847
1899
  dist_tag = "next" if is_prerelease else "latest"
1848
1900
 
1849
- # Phase 5 — npm publish (auth-fallback returns 0 to keep the release
1850
- # "published" from phases 1-4's perspective). `--skip-npm` is the
1851
- # operator escape hatch for ad-hoc cuts; idempotent re-running
1852
- # `--resume` without it picks Phase 5 back up.
1901
+ # Phase 5 — await npm publish via the public-repo GHA workflow
1902
+ # (release-npm.yml), which fires on the tag pushed in Phase 3. Phase 5
1903
+ # here is observation-only (poll `npm view` with timeout); poll-timeout
1904
+ # returns 0 the workflow runs independently on github.com, and
1905
+ # `--resume` re-checks the registry. `--skip-npm` is the operator
1906
+ # escape hatch for ad-hoc cuts.
1853
1907
  if args.skip_npm:
1854
1908
  print("phase 5: npm skipped (--skip-npm)")
1855
1909
  else:
@@ -1973,8 +2027,8 @@ def _release_dry_run(
1973
2027
  else:
1974
2028
  dist_tag = "next" if "-" in next_v else "latest"
1975
2029
  print(
1976
- f"Would publish: cctally@{next_v} to npmjs.org "
1977
- f"with --tag {dist_tag}"
2030
+ f"Would await: cctally@{next_v} on npmjs.org via GHA workflow "
2031
+ f"(release-npm.yml in public repo; tag={dist_tag})"
1978
2032
  )
1979
2033
  print()
1980
2034
  # Phase 6 — brew formula bump plan.
@@ -8185,6 +8239,41 @@ def _render_claude_session_table(
8185
8239
  return "\n".join(out)
8186
8240
 
8187
8241
 
8242
+ def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
8243
+ """Return ``{row_index: disambiguated_label}`` for project rows whose
8244
+ bare ``display_key`` collides with another row's basename.
8245
+
8246
+ When two projects share a basename (e.g., two ``app`` directories under
8247
+ different parents), suffix the colliding rows with the parent-directory
8248
+ segment ("(work)" / "(personal)") so they remain visually and
8249
+ semantically distinct. Prefer ``key.git_root`` as the disambiguation
8250
+ source when present; fall back to ``key.bucket_path`` for no-git rows.
8251
+
8252
+ Used by:
8253
+ - ``_render_project_table`` (terminal table render).
8254
+ - ``_build_project_snapshot`` (share artifact table + chart) — without
8255
+ this, two same-basename projects collapse to a single anonymous
8256
+ ``project-N`` after scrub, losing rank meaning AND uniqueness.
8257
+
8258
+ Rows that do not collide are absent from the returned dict; callers
8259
+ fall back to ``key.display_key`` for those.
8260
+ """
8261
+ display_counts: dict[str, int] = {}
8262
+ for r in rows:
8263
+ dk = r["key"].display_key
8264
+ display_counts[dk] = display_counts.get(dk, 0) + 1
8265
+ augmented: dict[int, str] = {}
8266
+ for idx, r in enumerate(rows):
8267
+ if display_counts[r["key"].display_key] > 1:
8268
+ source_path = r["key"].git_root or r["key"].bucket_path
8269
+ if source_path:
8270
+ parent = (
8271
+ os.path.basename(os.path.dirname(source_path)) or "/"
8272
+ )
8273
+ augmented[idx] = f"{r['key'].display_key} ({parent})"
8274
+ return augmented
8275
+
8276
+
8188
8277
  def _render_project_table(
8189
8278
  rows: list[dict],
8190
8279
  *,
@@ -8243,22 +8332,11 @@ def _render_project_table(
8243
8332
  ampm = "a.m." if local.hour < 12 else "p.m."
8244
8333
  return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
8245
8334
 
8246
- # Basename-collision disambiguation: if two rows share a display_key,
8247
- # suffix with the parent directory segment so the user can tell them
8248
- # apart. Prefer the git_root as the disambiguation source when present;
8249
- # fall back to bucket_path for no-git rows (whose displays also collide
8250
- # on basename even though they each carry a `(no-git)` marker).
8251
- display_counts: dict[str, int] = {}
8252
- for r in rows:
8253
- dk = r["key"].display_key
8254
- display_counts[dk] = display_counts.get(dk, 0) + 1
8255
- augmented: dict[int, str] = {}
8256
- for idx, r in enumerate(rows):
8257
- if display_counts[r["key"].display_key] > 1:
8258
- source_path = r["key"].git_root or r["key"].bucket_path
8259
- if source_path:
8260
- parent = os.path.basename(os.path.dirname(source_path)) or "/"
8261
- augmented[idx] = f"{r['key'].display_key} ({parent})"
8335
+ # Basename-collision disambiguation: hoisted to a module-level helper
8336
+ # so the share-snapshot builder can reuse the same logic (without it,
8337
+ # two same-basename projects collapse to a single anonymous `project-N`
8338
+ # after scrub, breaking both privacy uniqueness AND chart rank meaning).
8339
+ augmented = _project_disambiguate_labels(rows)
8262
8340
 
8263
8341
  def _project_cell(idx: int, r: dict) -> tuple[str, Any]:
8264
8342
  """Return (plain_text, color_fn_or_None) for the Project cell.
@@ -12278,12 +12356,36 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
12278
12356
  )
12279
12357
  pending_alerts.append(payload)
12280
12358
 
12281
- # ── Mid-week reset cross-flag (opportunistic, JOIN-based) ──
12282
- # Self-healing sweep against week_reset_events: every tick,
12283
- # flag any open block whose [block_start_at,
12284
- # last_observed_at_utc] interval contains a recorded reset
12285
- # moment. Symmetric with the historical-backfill predicate
12286
- # (§4.2 step 5). Idempotent (only flips 0 1).
12359
+ # ── Reset-crossing cross-flag (opportunistic, JOIN-based) ──
12360
+ # Self-healing sweep: every tick, flag any open block whose
12361
+ # [block_start_at, last_observed_at_utc] interval crosses a
12362
+ # weekly reset, from either of two sources:
12363
+ # (a) week_reset_events Anthropic-shifted MID-week resets
12364
+ # (prior week_end_at was still in the future at detect
12365
+ # time; see cmd_record_usage's reset-event detection).
12366
+ # (b) weekly_usage_snapshots.week_start_at — NATURAL weekly
12367
+ # boundaries. These never get a week_reset_events row
12368
+ # (mid-week detection requires the prior end to be in
12369
+ # the future), so source (a) silently misses blocks
12370
+ # that span a routine week reset. Without this clause
12371
+ # the dashboard's "Δ pp this block" delta is computed
12372
+ # against the pre-reset 7d% (~94%) versus post-reset
12373
+ # (~0%) and renders as a misleading −94pp drop.
12374
+ # Predicate (b) uses strict ``>`` on the lower bound so a
12375
+ # block that starts EXACTLY at the boundary (post-reset) is
12376
+ # not flagged. Symmetric with the historical-backfill
12377
+ # predicate (§4.2 step 5). Idempotent (only flips 0 → 1).
12378
+ #
12379
+ # Comparisons go through ``unixepoch()`` rather than a raw
12380
+ # lex BETWEEN: ``parse_iso_datetime`` returns host-local
12381
+ # tz-aware datetimes (line 9433: ``return parsed.astimezone()``),
12382
+ # so ``block_start_at`` is stored with the host's display
12383
+ # offset (e.g. ``+03:00``) while ``week_start_at`` is
12384
+ # ``+00:00`` and ``last_observed_at_utc`` is ``Z``. A lex
12385
+ # compare across mixed offsets silently mis-orders moments
12386
+ # for non-UTC hosts; ``unixepoch()`` normalizes all three
12387
+ # to seconds-since-epoch and is correct regardless of
12388
+ # offset suffix.
12287
12389
  #
12288
12390
  # Why the JOIN rather than a per-tick param: an earlier
12289
12391
  # design passed mid_week_reset_at only on the tick that
@@ -12298,13 +12400,21 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
12298
12400
  UPDATE five_hour_blocks
12299
12401
  SET crossed_seven_day_reset = 1
12300
12402
  WHERE crossed_seven_day_reset = 0
12301
- AND id IN (
12302
- SELECT b.id
12303
- FROM five_hour_blocks b
12304
- JOIN week_reset_events e
12305
- ON e.effective_reset_at_utc
12306
- BETWEEN b.block_start_at
12307
- AND b.last_observed_at_utc
12403
+ AND (
12404
+ EXISTS (
12405
+ SELECT 1 FROM week_reset_events e
12406
+ WHERE unixepoch(e.effective_reset_at_utc)
12407
+ BETWEEN unixepoch(five_hour_blocks.block_start_at)
12408
+ AND unixepoch(five_hour_blocks.last_observed_at_utc)
12409
+ )
12410
+ OR EXISTS (
12411
+ SELECT 1 FROM weekly_usage_snapshots ws
12412
+ WHERE ws.week_start_at IS NOT NULL
12413
+ AND unixepoch(ws.week_start_at)
12414
+ > unixepoch(five_hour_blocks.block_start_at)
12415
+ AND unixepoch(ws.week_start_at)
12416
+ <= unixepoch(five_hour_blocks.last_observed_at_utc)
12417
+ )
12308
12418
  )
12309
12419
  """,
12310
12420
  )
@@ -12414,16 +12524,36 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
12414
12524
  last_obs_dt = parse_iso_datetime(last_obs, "last_obs backfill")
12415
12525
 
12416
12526
  # Cross-reset detection (interval predicate, symmetric with
12417
- # the live path's UPDATE in T4).
12527
+ # the live path's UPDATE in T4). Two sources:
12528
+ # (a) week_reset_events — Anthropic-shifted mid-week resets.
12529
+ # (b) weekly_usage_snapshots.week_start_at — natural week
12530
+ # boundaries (no event row for these).
12531
+ # Strict ``>`` on the lower bound for (b) so a block whose
12532
+ # block_start_at coincides with a week boundary is not flagged.
12533
+ # ``unixepoch()`` normalizes the comparison across mixed tz
12534
+ # suffixes (block_start_at is host-local; week_start_at /
12535
+ # effective_reset_at_utc are ``+00:00``); see the live-path
12536
+ # comment for rationale.
12418
12537
  cross_row = conn.execute(
12419
12538
  """
12420
12539
  SELECT 1 FROM week_reset_events
12421
- WHERE effective_reset_at_utc >= ?
12422
- AND effective_reset_at_utc <= ?
12540
+ WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
12541
+ AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
12423
12542
  LIMIT 1
12424
12543
  """,
12425
12544
  (block_start_at, last_obs),
12426
12545
  ).fetchone()
12546
+ if cross_row is None:
12547
+ cross_row = conn.execute(
12548
+ """
12549
+ SELECT 1 FROM weekly_usage_snapshots
12550
+ WHERE week_start_at IS NOT NULL
12551
+ AND unixepoch(week_start_at) > unixepoch(?)
12552
+ AND unixepoch(week_start_at) <= unixepoch(?)
12553
+ LIMIT 1
12554
+ """,
12555
+ (block_start_at, last_obs),
12556
+ ).fetchone()
12427
12557
  crossed = 1 if cross_row is not None else 0
12428
12558
 
12429
12559
  # is_closed: 1 if the canonical reset moment is already past.
@@ -14203,6 +14333,7 @@ def cmd_cache_sync(args: argparse.Namespace) -> int:
14203
14333
 
14204
14334
  def cmd_daily(args: argparse.Namespace) -> int:
14205
14335
  """Show usage report grouped by display-timezone date."""
14336
+ _share_validate_args(args)
14206
14337
  config = load_config()
14207
14338
  tz = resolve_display_tz(args, config)
14208
14339
  args._resolved_tz = tz
@@ -14220,6 +14351,35 @@ def cmd_daily(args: argparse.Namespace) -> int:
14220
14351
  # Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
14221
14352
  days = _aggregate_daily(all_entries, mode="auto", tz=tz)
14222
14353
 
14354
+ # Shareable-reports gate: --format short-circuits the JSON / table
14355
+ # dispatch via `_share_render_and_emit`. The mutex in
14356
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
14357
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
14358
+ # render chronologically regardless of `--order`. Table rows in the
14359
+ # rendered artifact, however, must respect `--order desc` (parity
14360
+ # with terminal / JSON output) — handled by reversing snap.rows
14361
+ # post-build below; the chart points stay chronological because
14362
+ # they were built from ascending `days`.
14363
+ if getattr(args, "format", None):
14364
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
14365
+ # the headline daily-cost trend; per-model sub-rows aren't in the
14366
+ # share spec scope). Same convention applies to other share-enabled
14367
+ # subcommands (cmd_report's --detail, etc.).
14368
+ display_tz_str = _share_display_tz_label(tz)
14369
+ snap = _build_daily_snapshot(
14370
+ days,
14371
+ period_start=range_start,
14372
+ period_end=range_end,
14373
+ display_tz=display_tz_str,
14374
+ version=_share_resolve_version(),
14375
+ theme=args.theme,
14376
+ reveal_projects=args.reveal_projects,
14377
+ )
14378
+ if args.order == "desc":
14379
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
14380
+ _share_render_and_emit(snap, args)
14381
+ return 0
14382
+
14223
14383
  # Apply sort order.
14224
14384
  if args.order == "desc":
14225
14385
  days = list(reversed(days))
@@ -14241,6 +14401,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
14241
14401
 
14242
14402
  def cmd_monthly(args: argparse.Namespace) -> int:
14243
14403
  """Show usage report grouped by display-timezone calendar month."""
14404
+ _share_validate_args(args)
14244
14405
  config = load_config()
14245
14406
  tz = resolve_display_tz(args, config)
14246
14407
  args._resolved_tz = tz
@@ -14256,6 +14417,32 @@ def cmd_monthly(args: argparse.Namespace) -> int:
14256
14417
 
14257
14418
  months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
14258
14419
 
14420
+ # Shareable-reports gate: --format short-circuits the JSON / table
14421
+ # dispatch via `_share_render_and_emit`. The mutex in
14422
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
14423
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
14424
+ # render chronologically regardless of `--order`. Table rows in the
14425
+ # rendered artifact respect `--order desc` (parity with terminal /
14426
+ # JSON) via post-build snap.rows reversal; chart stays chronological.
14427
+ if getattr(args, "format", None):
14428
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
14429
+ # the headline monthly-cost trend; per-model sub-rows aren't in the
14430
+ # share spec scope). Same convention as cmd_daily / cmd_report.
14431
+ display_tz_str = _share_display_tz_label(tz)
14432
+ snap = _build_monthly_snapshot(
14433
+ months,
14434
+ period_start=range_start,
14435
+ period_end=range_end,
14436
+ display_tz=display_tz_str,
14437
+ version=_share_resolve_version(),
14438
+ theme=args.theme,
14439
+ reveal_projects=args.reveal_projects,
14440
+ )
14441
+ if args.order == "desc":
14442
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
14443
+ _share_render_and_emit(snap, args)
14444
+ return 0
14445
+
14259
14446
  if args.order == "desc":
14260
14447
  months = list(reversed(months))
14261
14448
 
@@ -14275,6 +14462,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
14275
14462
 
14276
14463
  def cmd_weekly(args: argparse.Namespace) -> int:
14277
14464
  """Show Claude usage grouped by subscription week."""
14465
+ _share_validate_args(args)
14278
14466
  config = load_config()
14279
14467
  args._resolved_tz = resolve_display_tz(args, config)
14280
14468
 
@@ -14351,6 +14539,33 @@ def cmd_weekly(args: argparse.Namespace) -> int:
14351
14539
  else:
14352
14540
  overlay.append((None, None))
14353
14541
 
14542
+ # Shareable-reports gate: --format short-circuits the JSON / table
14543
+ # dispatch via `_share_render_and_emit`. The mutex in
14544
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
14545
+ # Gate runs BEFORE the `--order desc` reversal so the BarChart bars
14546
+ # render chronologically regardless of `--order`. Table rows in the
14547
+ # rendered artifact respect `--order desc` (parity with terminal /
14548
+ # JSON) via post-build snap.rows reversal. `--breakdown` is honored:
14549
+ # when set, the snapshot adds per-model columns + stacked bar series
14550
+ # (vs. cmd_daily / cmd_monthly where --breakdown is a no-op under
14551
+ # --format).
14552
+ if getattr(args, "format", None):
14553
+ display_tz_str = _share_display_tz_label(args._resolved_tz)
14554
+ snap = _build_weekly_snapshot(
14555
+ buckets, overlay,
14556
+ period_start=range_start,
14557
+ period_end=range_end,
14558
+ display_tz=display_tz_str,
14559
+ version=_share_resolve_version(),
14560
+ theme=args.theme,
14561
+ reveal_projects=args.reveal_projects,
14562
+ breakdown_model=bool(getattr(args, "breakdown", False)),
14563
+ )
14564
+ if args.order == "desc":
14565
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
14566
+ _share_render_and_emit(snap, args)
14567
+ return 0
14568
+
14354
14569
  # Apply sort order. Buckets and overlay must reverse together so their
14355
14570
  # indices stay aligned (both _render_weekly_table and _weekly_to_json
14356
14571
  # assert len equality).
@@ -14819,6 +15034,7 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
14819
15034
 
14820
15035
  def cmd_project(args: argparse.Namespace) -> int:
14821
15036
  """Roll entries up by project (git-root) with per-project usage attribution."""
15037
+ _share_validate_args(args)
14822
15038
  config = load_config()
14823
15039
  args._resolved_tz = resolve_display_tz(args, config)
14824
15040
 
@@ -15157,6 +15373,33 @@ def cmd_project(args: argparse.Namespace) -> int:
15157
15373
  key=lambda r: _project_sort_key(r, args.sort, args.order),
15158
15374
  )
15159
15375
 
15376
+ # Shareable-reports gate: --format short-circuits the JSON / table
15377
+ # dispatch via `_share_render_and_emit`. The mutex in
15378
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
15379
+ # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
15380
+ # before rendering, so default output anonymizes project labels to
15381
+ # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
15382
+ # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
15383
+ # / `ChartPoint.x_label` with REAL names; the wrapper-level scrubber is
15384
+ # the single chokepoint that rewrites them.
15385
+ if getattr(args, "format", None):
15386
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
15387
+ # the headline per-project usage table + HBar chart; per-model
15388
+ # sub-rows aren't in the share spec scope). Same convention as
15389
+ # cmd_daily / cmd_weekly / cmd_report.
15390
+ display_tz_str = _share_display_tz_label(args._resolved_tz)
15391
+ snap = _build_project_snapshot(
15392
+ list(sorted_rows),
15393
+ period_start=since_dt,
15394
+ period_end=until_dt,
15395
+ display_tz=display_tz_str,
15396
+ version=_share_resolve_version(),
15397
+ theme=args.theme,
15398
+ reveal_projects=args.reveal_projects,
15399
+ )
15400
+ _share_render_and_emit(snap, args)
15401
+ return 0
15402
+
15160
15403
  if args.json:
15161
15404
  print(_project_json_output(
15162
15405
  since=since_dt,
@@ -16762,6 +17005,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
16762
17005
 
16763
17006
  def cmd_session(args: argparse.Namespace) -> int:
16764
17007
  """Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
17008
+ _share_validate_args(args)
16765
17009
  config = load_config()
16766
17010
  tz = resolve_display_tz(args, config)
16767
17011
  args._resolved_tz = tz
@@ -16775,6 +17019,52 @@ def cmd_session(args: argparse.Namespace) -> int:
16775
17019
 
16776
17020
  entries = get_claude_session_entries(range_start, range_end)
16777
17021
  sessions = _aggregate_claude_sessions(entries)
17022
+
17023
+ # Shareable-reports gate: --format short-circuits the JSON / table
17024
+ # dispatch via `_share_render_and_emit`. The mutex in
17025
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
17026
+ # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
17027
+ # before rendering, so default output anonymizes project labels to
17028
+ # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
17029
+ # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
17030
+ # / `ChartPoint.x_label` with REAL basenames; the wrapper-level scrubber
17031
+ # is the single chokepoint that rewrites them.
17032
+ if getattr(args, "format", None):
17033
+ # --top-n validation. Spec convention (Implementor 6 fix-loop):
17034
+ # invalid flag combinations exit 2; the soft-warn upper threshold
17035
+ # (>50) writes to stderr but proceeds.
17036
+ top_n = getattr(args, "top_n", None)
17037
+ if top_n is not None:
17038
+ if top_n < 1:
17039
+ print(
17040
+ "cctally: --top-n must be >= 1",
17041
+ file=sys.stderr,
17042
+ )
17043
+ return 2
17044
+ if top_n > 50:
17045
+ sys.stderr.write(
17046
+ f"cctally: --top-n {top_n} will likely produce an "
17047
+ "unreadable chart (consider 15 or fewer)\n"
17048
+ )
17049
+ # Note: --breakdown is a no-op under --format (snapshot focuses
17050
+ # on the headline per-session usage table + HBar chart; per-model
17051
+ # sub-rows aren't in the share spec scope). Same convention as
17052
+ # cmd_daily / cmd_project.
17053
+ display_tz_str = _share_display_tz_label(tz)
17054
+ snap = _build_session_snapshot(
17055
+ sessions,
17056
+ period_start=range_start,
17057
+ period_end=range_end,
17058
+ display_tz=display_tz_str,
17059
+ version=_share_resolve_version(),
17060
+ theme=args.theme,
17061
+ reveal_projects=args.reveal_projects,
17062
+ top_n=top_n,
17063
+ tz=tz,
17064
+ )
17065
+ _share_render_and_emit(snap, args)
17066
+ return 0
17067
+
16778
17068
  # Aggregator returns descending by last_activity; --order asc reverses.
16779
17069
  if args.order == "asc":
16780
17070
  sessions = list(reversed(sessions))
@@ -18019,6 +18309,7 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
18019
18309
 
18020
18310
 
18021
18311
  def cmd_report(args: argparse.Namespace) -> int:
18312
+ _share_validate_args(args)
18022
18313
  if args.sync_current:
18023
18314
  sync_ns = argparse.Namespace(
18024
18315
  week_start=None,
@@ -18066,6 +18357,40 @@ def cmd_report(args: argparse.Namespace) -> int:
18066
18357
 
18067
18358
  weeks = get_recent_weeks(conn, max(1, args.weeks))
18068
18359
  if not weeks:
18360
+ # Format-aware empty path mirrors cmd_forecast:18578-18629 — a
18361
+ # fresh install requesting `report --format html` should emit a
18362
+ # uniformly-shaped artifact, not a free-form "No data yet"
18363
+ # sentence the share consumer can't parse.
18364
+ if getattr(args, "format", None):
18365
+ display_tz_str = _share_display_tz_label(tz)
18366
+ # Anchor the period_label on the current subscription
18367
+ # week so the artifact's subtitle is meaningful (the
18368
+ # week the report WOULD describe if data existed).
18369
+ # `_command_as_of()` honors CCTALLY_AS_OF — keeps the
18370
+ # period_label coherent with `generated_at` (which goes
18371
+ # through `_share_now_utc` from the same env hook) so
18372
+ # fixture goldens don't drift when the harness host's
18373
+ # wall-clock day rolls past CCTALLY_AS_OF.
18374
+ now_local = _command_as_of().astimezone(tz)
18375
+ local_tz = now_local.tzinfo
18376
+ ws_d, we_d = compute_week_bounds(now_local, week_start_name)
18377
+ ws_dt = dt.datetime.combine(
18378
+ ws_d, dt.time.min, tzinfo=local_tz
18379
+ )
18380
+ we_dt = dt.datetime.combine(
18381
+ we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
18382
+ )
18383
+ snap = _build_report_snapshot(
18384
+ [],
18385
+ period_start=ws_dt,
18386
+ period_end=we_dt,
18387
+ display_tz=display_tz_str,
18388
+ version=_share_resolve_version(),
18389
+ theme=args.theme,
18390
+ reveal_projects=args.reveal_projects,
18391
+ )
18392
+ _share_render_and_emit(snap, args)
18393
+ return 0
18069
18394
  if args.json:
18070
18395
  print(json.dumps({"current": None, "trend": []}, indent=2))
18071
18396
  else:
@@ -18178,6 +18503,39 @@ def cmd_report(args: argparse.Namespace) -> int:
18178
18503
  for m in milestone_rows
18179
18504
  ]
18180
18505
 
18506
+ # Shareable-reports gate: --format short-circuits the terminal/JSON
18507
+ # paths. The mutex in `_add_share_args` guarantees --format and
18508
+ # --json are not both set, so checking --format first is unambiguous.
18509
+ # Snapshot rows are reversed to ascending chronological order so
18510
+ # the line chart trends left->right with time (`get_recent_weeks`
18511
+ # returns newest-first; `trend` mirrors that order).
18512
+ if getattr(args, "format", None):
18513
+ # Note: --detail is a no-op under --format (snapshot focuses on
18514
+ # the headline weekly-trend table + chart; per-percent milestone
18515
+ # detail isn't in the share spec scope). Same convention applies
18516
+ # to other share-enabled subcommands (cmd_daily's --breakdown,
18517
+ # etc.).
18518
+ ordered_trend = list(reversed(trend))
18519
+ if ordered_trend:
18520
+ first_wsd = ordered_trend[0].get("weekStartDate")
18521
+ last_wed = ordered_trend[-1].get("weekEndDate") or ordered_trend[-1].get("weekStartDate")
18522
+ period_start = _share_parse_date_to_dt(first_wsd, tz)
18523
+ period_end = _share_parse_date_to_dt(last_wed, tz)
18524
+ else:
18525
+ period_start = period_end = _share_now_utc()
18526
+ display_tz_str = _share_display_tz_label(tz)
18527
+ snap = _build_report_snapshot(
18528
+ ordered_trend,
18529
+ period_start=period_start,
18530
+ period_end=period_end,
18531
+ display_tz=display_tz_str,
18532
+ version=_share_resolve_version(),
18533
+ theme=args.theme,
18534
+ reveal_projects=args.reveal_projects,
18535
+ )
18536
+ _share_render_and_emit(snap, args)
18537
+ return 0
18538
+
18181
18539
  if args.json:
18182
18540
  print(json.dumps(output, indent=2))
18183
18541
  return 0
@@ -18289,6 +18647,7 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18289
18647
  """Project current-week usage to reset boundary. Emit terminal report,
18290
18648
  JSON, or status-line one-liner. See docs/commands/forecast.md.
18291
18649
  """
18650
+ _share_validate_args(args)
18292
18651
  if args.json and args.status_line:
18293
18652
  print("forecast: --json and --status-line are mutually exclusive",
18294
18653
  file=sys.stderr)
@@ -18314,6 +18673,58 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18314
18673
  inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
18315
18674
  if inputs is None:
18316
18675
  # No snapshot for the current week.
18676
+ if getattr(args, "format", None):
18677
+ # Shareable-reports empty-data path: emit a "no data" snapshot
18678
+ # rather than a free-form text message so consumers of the share
18679
+ # output (md / html / svg) get a uniformly-shaped artifact.
18680
+ #
18681
+ # Compute the real subscription-week boundaries from config
18682
+ # rather than collapsing to a 0-duration `now → now` window —
18683
+ # the period_label in the artifact's subtitle is meaningful
18684
+ # (the week the forecast WOULD describe if data existed).
18685
+ # Lift the `dt.date` boundaries from `compute_week_bounds`
18686
+ # to tz-aware datetimes anchored on local midnight so the
18687
+ # PeriodSpec stays consistent with sibling builders.
18688
+ tz = getattr(args, "_resolved_tz", None)
18689
+ display_tz_str = _share_display_tz_label(tz)
18690
+ week_start_name = get_week_start_name(
18691
+ config, getattr(args, "week_start_name", None)
18692
+ )
18693
+ ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
18694
+ # internal fallback: host-local intentional
18695
+ local_tz = dt.datetime.now().astimezone().tzinfo
18696
+ week_start_dt = dt.datetime.combine(
18697
+ ws_date, dt.time.min, tzinfo=local_tz
18698
+ )
18699
+ week_end_dt = dt.datetime.combine(
18700
+ we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
18701
+ )
18702
+ # Pass `low_conf=False` + explicit notes: the issue is "no data
18703
+ # recorded yet," not "thin data." LOW CONF would mislead the
18704
+ # reader into thinking a projection ran with sparse samples.
18705
+ snap = _build_forecast_snapshot(
18706
+ week_start=week_start_dt,
18707
+ week_end=week_end_dt,
18708
+ display_tz=display_tz_str,
18709
+ version=_share_resolve_version(),
18710
+ theme=args.theme,
18711
+ reveal_projects=args.reveal_projects,
18712
+ actual_series=[],
18713
+ projected_series=[],
18714
+ current_pct=0.0,
18715
+ projected_low_pct=0.0,
18716
+ projected_high_pct=0.0,
18717
+ days_remaining=0.0,
18718
+ dollars_per_percent=0.0,
18719
+ dollars_per_percent_source="this_week",
18720
+ low_conf=False,
18721
+ notes=(
18722
+ "No snapshots recorded for this week yet — run "
18723
+ "cctally record-usage to populate.",
18724
+ ),
18725
+ )
18726
+ _share_render_and_emit(snap, args)
18727
+ return 0
18317
18728
  if args.json:
18318
18729
  print(json.dumps({
18319
18730
  "error": "no_current_week_data",
@@ -18327,6 +18738,94 @@ def cmd_forecast(args: argparse.Namespace) -> int:
18327
18738
 
18328
18739
  output = _compute_forecast(inputs, targets)
18329
18740
 
18741
+ # Shareable-reports gate: --format short-circuits the JSON / status-line /
18742
+ # terminal dispatch via `_share_render_and_emit`. The mutex in
18743
+ # `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
18744
+ # `--status-line` from coexisting. The gate fires AFTER `_compute_forecast`
18745
+ # so the snapshot reuses the same projection math as the terminal/JSON
18746
+ # paths — no parallel computation.
18747
+ if getattr(args, "format", None):
18748
+ i = output.inputs
18749
+ # Re-fetch the samples for the LineChart's actual_series. The
18750
+ # `_load_forecast_inputs` path dropped them after deriving p_now /
18751
+ # p_24h_ago / snapshot_count; re-running `_fetch_current_week_snapshots`
18752
+ # is a single indexed query against `weekly_usage_snapshots` and only
18753
+ # fires when `--format` is requested, so the cost is bounded.
18754
+ # `_apply_midweek_reset_override` is replayed so the chart axis
18755
+ # matches the (possibly-shifted) week_start_at carried by `inputs`.
18756
+ fetched = _fetch_current_week_snapshots(conn, now_utc)
18757
+ actual_series: list[tuple[str, float, float]] = []
18758
+ if fetched is not None:
18759
+ _ws_at, _we_at, raw_samples = fetched
18760
+ _ws_at_shifted, samples = _apply_midweek_reset_override(
18761
+ conn, _ws_at, _we_at, raw_samples
18762
+ )
18763
+ tz_render = getattr(args, "_resolved_tz", None)
18764
+ for cap_at, pct, _five_hr in samples:
18765
+ elapsed_h = (
18766
+ (cap_at - i.week_start_at).total_seconds() / 3600.0
18767
+ )
18768
+ lbl = format_display_dt(
18769
+ cap_at, tz_render, fmt="%a %H:%M", suffix=False,
18770
+ )
18771
+ actual_series.append((lbl, elapsed_h, float(pct)))
18772
+ # Projected series: a 2-point ray from (now, p_now) to
18773
+ # (week_end, projected_high). The high-end matches the terminal
18774
+ # render's "may cap" warning so the chart and table tell the same
18775
+ # story. When `already_capped` is true the ray collapses to a flat
18776
+ # horizontal line at p_now from (now → week_end) — visually
18777
+ # signals "you are pinned at the cap; no further growth expected"
18778
+ # instead of the visually-empty (no-projection) chart that was
18779
+ # confusable with "no projection computed."
18780
+ projected_series: list[tuple[str, float, float]] = []
18781
+ if i.remaining_hours > 0:
18782
+ tz_render = getattr(args, "_resolved_tz", None)
18783
+ now_label = format_display_dt(
18784
+ i.now_utc, tz_render, fmt="%a %H:%M", suffix=False,
18785
+ )
18786
+ end_label = format_display_dt(
18787
+ i.week_end_at, tz_render, fmt="%a %H:%M", suffix=False,
18788
+ )
18789
+ now_x = (i.now_utc - i.week_start_at).total_seconds() / 3600.0
18790
+ end_x = (i.week_end_at - i.week_start_at).total_seconds() / 3600.0
18791
+ if output.already_capped:
18792
+ # Flat ray: y stays at p_now across the remaining window.
18793
+ projected_series.append(
18794
+ (now_label, now_x, float(i.p_now))
18795
+ )
18796
+ projected_series.append(
18797
+ (end_label, end_x, float(i.p_now))
18798
+ )
18799
+ else:
18800
+ projected_series.append(
18801
+ (now_label, now_x, float(i.p_now))
18802
+ )
18803
+ projected_series.append(
18804
+ (end_label, end_x, float(output.final_percent_high))
18805
+ )
18806
+ display_tz_str = _share_display_tz_label(
18807
+ getattr(args, "_resolved_tz", None)
18808
+ )
18809
+ snap = _build_forecast_snapshot(
18810
+ week_start=i.week_start_at,
18811
+ week_end=i.week_end_at,
18812
+ display_tz=display_tz_str,
18813
+ version=_share_resolve_version(),
18814
+ theme=args.theme,
18815
+ reveal_projects=args.reveal_projects,
18816
+ actual_series=actual_series,
18817
+ projected_series=projected_series,
18818
+ current_pct=float(i.p_now),
18819
+ projected_low_pct=float(output.final_percent_low),
18820
+ projected_high_pct=float(output.final_percent_high),
18821
+ days_remaining=float(i.remaining_days),
18822
+ dollars_per_percent=float(i.dollars_per_percent),
18823
+ dollars_per_percent_source=i.dollars_per_percent_source,
18824
+ low_conf=(i.confidence == "low"),
18825
+ )
18826
+ _share_render_and_emit(snap, args)
18827
+ return 0
18828
+
18330
18829
  if args.json:
18331
18830
  print(_emit_forecast_json(output))
18332
18831
  return 0
@@ -18755,6 +19254,7 @@ def _render_five_hour_blocks_table(
18755
19254
 
18756
19255
  def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
18757
19256
  """List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
19257
+ _share_validate_args(args)
18758
19258
  config = load_config()
18759
19259
  args._resolved_tz = resolve_display_tz(args, config)
18760
19260
  # Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
@@ -18831,6 +19331,61 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
18831
19331
  d["seven_day_pct_at_block_end"] = latest_7d
18832
19332
  block_dicts.append(d)
18833
19333
 
19334
+ # Shareable-reports gate: --format short-circuits the JSON / table
19335
+ # dispatch via `_share_render_and_emit`. The mutex in
19336
+ # `_add_share_args` keeps `--format` and `--json` from coexisting.
19337
+ # Note: --breakdown is a no-op under --format (snapshot focuses on
19338
+ # the headline 5h-block trend; per-axis sub-rows aren't in the
19339
+ # share spec scope). Cross-reset blocks render with `▲` x-axis
19340
+ # markers in the BarChart and `⚡` glyphs in the table cell —
19341
+ # both signals route to the share renderer's UTF-8-safe paths.
19342
+ # Gate runs BEFORE the optional `_load_breakdown` loop so a
19343
+ # 50-block --format invocation doesn't pay 50 wasted SQLite
19344
+ # queries the snapshot would discard.
19345
+ if getattr(args, "format", None):
19346
+ display_tz_str = _share_display_tz_label(args._resolved_tz)
19347
+ # Period bounds: prefer the user's --since/--until filter
19348
+ # window; fall back to oldest/newest block timestamps when no
19349
+ # filter was applied so the period label reflects what the
19350
+ # snapshot actually covers.
19351
+ # block_dicts is DESC-ordered: [-1] is oldest, [0] is newest.
19352
+ if since_iso:
19353
+ period_start = _share_parse_date_to_dt(
19354
+ since_iso, args._resolved_tz,
19355
+ )
19356
+ elif block_dicts:
19357
+ tail = block_dicts[-1].get("block_start_at")
19358
+ period_start = _share_parse_date_to_dt(
19359
+ (tail or "").split("T")[0] or None,
19360
+ args._resolved_tz,
19361
+ )
19362
+ else:
19363
+ period_start = _share_now_utc()
19364
+ if until_iso:
19365
+ period_end = _share_parse_date_to_dt(
19366
+ until_iso, args._resolved_tz,
19367
+ )
19368
+ elif block_dicts:
19369
+ head = block_dicts[0].get("block_start_at")
19370
+ period_end = _share_parse_date_to_dt(
19371
+ (head or "").split("T")[0] or None,
19372
+ args._resolved_tz,
19373
+ )
19374
+ else:
19375
+ period_end = _share_now_utc()
19376
+ snap = _build_five_hour_blocks_snapshot(
19377
+ block_dicts,
19378
+ period_start=period_start,
19379
+ period_end=period_end,
19380
+ display_tz=display_tz_str,
19381
+ version=_share_resolve_version(),
19382
+ theme=args.theme,
19383
+ reveal_projects=args.reveal_projects,
19384
+ tz=args._resolved_tz,
19385
+ )
19386
+ _share_render_and_emit(snap, args)
19387
+ return 0
19388
+
18834
19389
  # Optional breakdown.
18835
19390
  if args.breakdown:
18836
19391
  for bd in block_dicts:
@@ -19241,6 +19796,90 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
19241
19796
  conn.close()
19242
19797
 
19243
19798
  if not should_insert:
19799
+ # Self-heal: a prior record-usage invocation may have inserted
19800
+ # the snapshot but been killed (CC self-update, machine sleep,
19801
+ # OOM) before maybe_record_milestone / maybe_update_five_hour_block
19802
+ # could run. Pre-probe both surfaces with cheap indexed SELECTs
19803
+ # and only invoke the helpers when a row is actually missing or
19804
+ # stale. Steady-state cost: 1-3 SELECTs (latest snapshot always;
19805
+ # +max_milestone if floor>=1; +block last_observed if window_key
19806
+ # is set); ZERO JSONL re-ingest on healthy ticks. The helpers themselves are idempotent under
19807
+ # concurrent record-usage instances (INSERT OR IGNORE for
19808
+ # percent_milestones; SQLite write-lock serialization for the
19809
+ # 5h upsert). Without the pre-probe, every dedup tick would
19810
+ # trigger sync_cache + a window walk + replace-all rollups via
19811
+ # maybe_update_five_hour_block's unconditional _compute_block_totals
19812
+ # call. Regression: bin/cctally-record-usage-selfheal-test.
19813
+ try:
19814
+ heal_conn = open_db()
19815
+ try:
19816
+ latest_row = heal_conn.execute(
19817
+ "SELECT * FROM weekly_usage_snapshots "
19818
+ "WHERE week_start_date = ? "
19819
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
19820
+ (week_start_date,),
19821
+ ).fetchone()
19822
+ if latest_row is None:
19823
+ return 0
19824
+
19825
+ # Probe 1: do we owe a percent milestone? Snap up before
19826
+ # floor (status-line API returns 0.N*100 which can fall
19827
+ # one ULP short of N — same convention as
19828
+ # maybe_record_milestone).
19829
+ latest_floor = math.floor(
19830
+ float(latest_row["weekly_percent"]) + 1e-9
19831
+ )
19832
+ need_milestone_heal = False
19833
+ if latest_floor >= 1:
19834
+ max_existing = heal_conn.execute(
19835
+ "SELECT MAX(percent_threshold) AS m "
19836
+ "FROM percent_milestones "
19837
+ "WHERE week_start_date = ?",
19838
+ (week_start_date,),
19839
+ ).fetchone()
19840
+ if max_existing is None or max_existing["m"] is None:
19841
+ need_milestone_heal = True
19842
+ elif int(max_existing["m"]) < latest_floor:
19843
+ need_milestone_heal = True
19844
+
19845
+ # Probe 2: do we owe a 5h-block update? Either no row
19846
+ # for this canonical window, or the existing row's
19847
+ # last_observed_at_utc is stale relative to the latest
19848
+ # snapshot's captured_at_utc (the kill landed between
19849
+ # insert_usage_snapshot and maybe_update_five_hour_block).
19850
+ need_5h_heal = False
19851
+ window_key = latest_row["five_hour_window_key"]
19852
+ if window_key is not None:
19853
+ block_row = heal_conn.execute(
19854
+ "SELECT last_observed_at_utc "
19855
+ "FROM five_hour_blocks "
19856
+ "WHERE five_hour_window_key = ?",
19857
+ (int(window_key),),
19858
+ ).fetchone()
19859
+ if block_row is None:
19860
+ need_5h_heal = True
19861
+ elif (
19862
+ block_row["last_observed_at_utc"]
19863
+ < latest_row["captured_at_utc"]
19864
+ ):
19865
+ need_5h_heal = True
19866
+ finally:
19867
+ heal_conn.close()
19868
+
19869
+ if need_milestone_heal or need_5h_heal:
19870
+ latest_saved = _saved_dict_from_usage_row(latest_row)
19871
+ if need_milestone_heal:
19872
+ try:
19873
+ maybe_record_milestone(latest_saved)
19874
+ except Exception as exc:
19875
+ eprint(f"[milestone] self-heal error: {exc}")
19876
+ if need_5h_heal:
19877
+ try:
19878
+ maybe_update_five_hour_block(latest_saved)
19879
+ except Exception as exc:
19880
+ eprint(f"[5h-block] self-heal error: {exc}")
19881
+ except Exception as exc:
19882
+ eprint(f"[record-usage] self-heal lookup failed: {exc}")
19244
19883
  return 0
19245
19884
 
19246
19885
  payload = {
@@ -20430,6 +21069,44 @@ def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict
20430
21069
  return out
20431
21070
 
20432
21071
 
21072
+ def _saved_dict_from_usage_row(row: sqlite3.Row) -> dict[str, Any]:
21073
+ """Mirror ``insert_usage_snapshot``'s output dict from an existing
21074
+ weekly_usage_snapshots row. Used by ``cmd_record_usage``'s dedup
21075
+ self-heal path so ``maybe_record_milestone`` and
21076
+ ``maybe_update_five_hour_block`` can re-run on the latest snapshot
21077
+ when an earlier invocation was killed between snapshot insert and
21078
+ milestone insert (e.g. CC self-update kill window, 2026-05-08).
21079
+
21080
+ Field omissions match ``insert_usage_snapshot``: keys whose values
21081
+ would be ``None`` are not emitted, so downstream ``saved.get(...)``
21082
+ callers see the same shape they'd see on a fresh insert.
21083
+
21084
+ Note: ``resetText`` (the only userscript-payload-only key
21085
+ ``insert_usage_snapshot`` re-emits in its output dict) is
21086
+ intentionally omitted — no downstream ``saved``-dict consumer in
21087
+ this codebase reads it. ``pageUrl`` is a column on
21088
+ ``weekly_usage_snapshots`` but is never propagated into the output
21089
+ dict either path.
21090
+ """
21091
+ out: dict[str, Any] = {
21092
+ "id": int(row["id"]),
21093
+ "capturedAt": row["captured_at_utc"],
21094
+ "weekStartDate": row["week_start_date"],
21095
+ "weekEndDate": row["week_end_date"],
21096
+ "weeklyPercent": float(row["weekly_percent"]),
21097
+ }
21098
+ if row["week_start_at"] is not None:
21099
+ out["weekStartAt"] = row["week_start_at"]
21100
+ if row["week_end_at"] is not None:
21101
+ out["weekEndAt"] = row["week_end_at"]
21102
+ if row["five_hour_percent"] is not None:
21103
+ out["fiveHourPercent"] = float(row["five_hour_percent"])
21104
+ if row["five_hour_resets_at"] is not None:
21105
+ out["fiveHourResetsAt"] = row["five_hour_resets_at"]
21106
+ if row["five_hour_window_key"] is not None:
21107
+ out["fiveHourWindowKey"] = int(row["five_hour_window_key"])
21108
+ return out
21109
+
20433
21110
 
20434
21111
  def _resolve_cache_report_window(
20435
21112
  args: argparse.Namespace,
@@ -22265,6 +22942,142 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
22265
22942
  return 0
22266
22943
 
22267
22944
 
22945
+ def _argparse_has_arg(parser, option_string: str) -> bool:
22946
+ """Return True if ``parser`` already registered ``option_string``."""
22947
+ for action in parser._actions:
22948
+ if option_string in (action.option_strings or ()):
22949
+ return True
22950
+ return False
22951
+
22952
+
22953
+ def _add_share_args(parser, *, has_status_line: bool = False) -> None:
22954
+ """Attach shareable-reports flags + format/json mutex to a subparser.
22955
+
22956
+ Idempotent — call exactly once per subparser. Caller MUST remove any
22957
+ pre-existing ``--json`` (and ``--status-line`` for forecast) from the
22958
+ subparser before invoking this helper, so the mutex group owns those
22959
+ flags. Raises ``RuntimeError`` on contract violation — surfaces at
22960
+ parser-build time (i.e., on every CLI invocation, including ``--help``)
22961
+ instead of at the user invocation that hits the unguarded
22962
+ ``--format --json`` combo. The prior shape silently skipped re-adding,
22963
+ leaving the mutex unenforced for any future 9th share-enabled subparser
22964
+ whose existing ``--json`` was accidentally left in place.
22965
+ """
22966
+ if _argparse_has_arg(parser, "--json"):
22967
+ raise RuntimeError(
22968
+ f"_add_share_args: parser {parser.prog!r} already has --json; "
22969
+ "remove it before calling _add_share_args so mutex applies"
22970
+ )
22971
+ if has_status_line and _argparse_has_arg(parser, "--status-line"):
22972
+ raise RuntimeError(
22973
+ f"_add_share_args: parser {parser.prog!r} already has --status-line; "
22974
+ "remove it before calling _add_share_args(has_status_line=True)"
22975
+ )
22976
+ output_group = parser.add_mutually_exclusive_group()
22977
+ output_group.add_argument(
22978
+ "--format", choices=("md", "html", "svg"),
22979
+ help="Render output as shareable markdown, self-contained HTML, or SVG. "
22980
+ "Default destination: md->stdout, html/svg->~/Downloads file.")
22981
+ output_group.add_argument(
22982
+ "--json", action="store_true",
22983
+ help="Emit machine-readable JSON; suppresses terminal render.")
22984
+ if has_status_line:
22985
+ output_group.add_argument(
22986
+ "--status-line", action="store_true", dest="status_line",
22987
+ help="Emit one-line compact string for status-line injection.")
22988
+
22989
+ parser.add_argument(
22990
+ "--theme", choices=("light", "dark"), default="light",
22991
+ help="Color theme for HTML/SVG (default: light). No-op for markdown.")
22992
+ parser.add_argument(
22993
+ "--no-branding", action="store_true", dest="no_branding",
22994
+ help="Strip the 'Generated by cctally' footer from --format output.")
22995
+ parser.add_argument(
22996
+ "--output", metavar="PATH",
22997
+ help="Write --format output to PATH instead of the default destination "
22998
+ "(stdout for md; ~/Downloads/cctally-<cmd>-<utcdate>.<ext> for html/svg). "
22999
+ "Use '-' for stdout.")
23000
+ parser.add_argument(
23001
+ "--copy", action="store_true",
23002
+ help="Pipe --format md output to clipboard (pbcopy/xclip/clip). "
23003
+ "Rejected for html/svg.")
23004
+ parser.add_argument(
23005
+ "--open", action="store_true", dest="open_after_write",
23006
+ help="After writing --format html/svg to a file, open it in the default app. "
23007
+ "Rejected for md.")
23008
+
23009
+
23010
+ def _share_validate_args(args) -> None:
23011
+ """Reject share flag combinations BEFORE any DB / sync / render work.
23012
+
23013
+ Two layers of validation:
23014
+
23015
+ 1. Share-only flags (``--output``, ``--copy``, ``--open``) require
23016
+ ``--format``. Silent dropping trains users to assume the file
23017
+ was written.
23018
+
23019
+ 2. Destination-shape combinations (``--copy`` + ``--output``,
23020
+ ``--copy`` + non-md, ``--open`` + md, ``--open`` + ``--output -``).
23021
+ These were previously caught only inside ``_resolve_destination``
23022
+ / ``_share_render_and_emit`` — i.e. AFTER ``--sync-current`` had
23023
+ already mutated the DB and the snapshot had been built. Surfacing
23024
+ them at validation time means an exit-2 flag-shape error never
23025
+ triggers side effects.
23026
+
23027
+ Exit 2 with a stderr message naming the offending combo so the
23028
+ failure is loud and scriptable. Idempotent; safe to call from every
23029
+ share-enabled subcommand before the ``--format`` gate. Existing
23030
+ late checks in ``_resolve_destination`` / ``_share_render_and_emit``
23031
+ are kept as defense-in-depth for any future caller that bypasses
23032
+ this helper.
23033
+ """
23034
+ if not getattr(args, "format", None):
23035
+ offenders = []
23036
+ if getattr(args, "output", None):
23037
+ offenders.append("--output")
23038
+ if getattr(args, "copy", False):
23039
+ offenders.append("--copy")
23040
+ if getattr(args, "open_after_write", False):
23041
+ offenders.append("--open")
23042
+ if not offenders:
23043
+ return
23044
+ verb = "requires" if len(offenders) == 1 else "require"
23045
+ sys.stderr.write(
23046
+ f"cctally: {', '.join(offenders)} {verb} --format\n"
23047
+ )
23048
+ sys.exit(2)
23049
+
23050
+ # --format is set — validate destination-shape combos.
23051
+ fmt = args.format
23052
+ copy = getattr(args, "copy", False)
23053
+ output = getattr(args, "output", None)
23054
+ open_after_write = getattr(args, "open_after_write", False)
23055
+
23056
+ if copy and output is not None:
23057
+ # Mutex: a clipboard destination by definition has no path.
23058
+ sys.stderr.write(
23059
+ "cctally: --copy is mutually exclusive with --output\n"
23060
+ )
23061
+ sys.exit(2)
23062
+ if copy and fmt != "md":
23063
+ sys.stderr.write(
23064
+ "cctally: --copy is only valid with --format md\n"
23065
+ )
23066
+ sys.exit(2)
23067
+ if open_after_write and fmt == "md":
23068
+ sys.stderr.write(
23069
+ "cctally: --open is only valid with --format html or --format svg\n"
23070
+ )
23071
+ sys.exit(2)
23072
+ if open_after_write and output == "-":
23073
+ # Open-after-write to stdout has no file to launch — was a silent
23074
+ # no-op pre-fix; now an explicit exit 2 so users notice.
23075
+ sys.stderr.write(
23076
+ "cctally: --open is incompatible with --output - (no file to open)\n"
23077
+ )
23078
+ sys.exit(2)
23079
+
23080
+
22268
23081
  def build_parser() -> argparse.ArgumentParser:
22269
23082
  p = argparse.ArgumentParser(
22270
23083
  prog="cctally",
@@ -22429,9 +23242,11 @@ def build_parser() -> argparse.ArgumentParser:
22429
23242
  help="Project filter passed to sync-week when --sync-current is used.",
22430
23243
  )
22431
23244
  pr.add_argument(
22432
- "--json",
23245
+ "--reveal-projects",
22433
23246
  action="store_true",
22434
- help="Emit machine-readable JSON output.",
23247
+ dest="reveal_projects",
23248
+ help="In --format output, show real project basenames instead of "
23249
+ "the default project-1, project-2, ... anonymization.",
22435
23250
  )
22436
23251
  pr.add_argument(
22437
23252
  "--detail",
@@ -22443,6 +23258,7 @@ def build_parser() -> argparse.ArgumentParser:
22443
23258
  help="Display timezone: local, utc, or IANA name. "
22444
23259
  "Overrides config display.tz for this call.",
22445
23260
  )
23261
+ _add_share_args(pr)
22446
23262
  pr.set_defaults(func=cmd_report)
22447
23263
 
22448
23264
  fc = sub.add_parser(
@@ -22470,10 +23286,9 @@ def build_parser() -> argparse.ArgumentParser:
22470
23286
  """
22471
23287
  ),
22472
23288
  )
22473
- fc.add_argument("--json", action="store_true",
22474
- help="Emit machine-readable JSON; suppresses terminal render.")
22475
- fc.add_argument("--status-line", action="store_true", dest="status_line",
22476
- help="Emit one-line compact string for status-line injection.")
23289
+ fc.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
23290
+ help="In --format output, show real project basenames instead of "
23291
+ "the default project-1, project-2, ... anonymization.")
22477
23292
  fc.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
22478
23293
  help="Display timezone: local, utc, or IANA name. "
22479
23294
  "Overrides config display.tz for this call.")
@@ -22487,6 +23302,7 @@ def build_parser() -> argparse.ArgumentParser:
22487
23302
  help="Color output control (also honors NO_COLOR).")
22488
23303
  # Dev-only: override "now" for deterministic fixture tests. Hidden from --help.
22489
23304
  fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
23305
+ _add_share_args(fc, has_status_line=True)
22490
23306
  fc.set_defaults(func=cmd_forecast)
22491
23307
 
22492
23308
  pb = sub.add_parser(
@@ -23058,9 +23874,11 @@ def build_parser() -> argparse.ArgumentParser:
23058
23874
  help="Add per-axis rollup-child rows under each block.",
23059
23875
  )
23060
23876
  fhb.add_argument(
23061
- "--json",
23877
+ "--reveal-projects",
23062
23878
  action="store_true",
23063
- help="Emit camelCase JSON (schemaVersion 1).",
23879
+ dest="reveal_projects",
23880
+ help="In --format output, show real project basenames instead of "
23881
+ "the default project-1, project-2, ... anonymization.",
23064
23882
  )
23065
23883
  fhb.add_argument(
23066
23884
  "--no-color",
@@ -23075,6 +23893,7 @@ def build_parser() -> argparse.ArgumentParser:
23075
23893
  help="Display timezone: local, utc, or IANA name. "
23076
23894
  "Overrides config display.tz for this call.",
23077
23895
  )
23896
+ _add_share_args(fhb)
23078
23897
  fhb.set_defaults(func=cmd_five_hour_blocks)
23079
23898
 
23080
23899
  # -- cache-sync --
@@ -23134,16 +23953,18 @@ def build_parser() -> argparse.ArgumentParser:
23134
23953
  help="Sort direction by date (default: asc).",
23135
23954
  )
23136
23955
  dy.add_argument(
23137
- "--json",
23956
+ "--reveal-projects",
23138
23957
  action="store_true",
23139
- dest="json",
23140
- help="Output JSON matching upstream ccusage daily format.",
23958
+ dest="reveal_projects",
23959
+ help="In --format output, show real project basenames instead of "
23960
+ "the default project-1, project-2, ... anonymization.",
23141
23961
  )
23142
23962
  dy.add_argument(
23143
23963
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
23144
23964
  help="Display timezone: local, utc, or IANA name. "
23145
23965
  "Overrides config display.tz for this call.",
23146
23966
  )
23967
+ _add_share_args(dy)
23147
23968
  dy.set_defaults(func=cmd_daily)
23148
23969
 
23149
23970
  # -- monthly --
@@ -23185,16 +24006,18 @@ def build_parser() -> argparse.ArgumentParser:
23185
24006
  help="Sort direction by month (default: asc).",
23186
24007
  )
23187
24008
  mo.add_argument(
23188
- "--json",
24009
+ "--reveal-projects",
23189
24010
  action="store_true",
23190
- dest="json",
23191
- help="Output JSON matching upstream ccusage monthly format.",
24011
+ dest="reveal_projects",
24012
+ help="In --format output, show real project basenames instead of "
24013
+ "the default project-1, project-2, ... anonymization.",
23192
24014
  )
23193
24015
  mo.add_argument(
23194
24016
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
23195
24017
  help="Display timezone: local, utc, or IANA name. "
23196
24018
  "Overrides config display.tz for this call.",
23197
24019
  )
24020
+ _add_share_args(mo)
23198
24021
  mo.set_defaults(func=cmd_monthly)
23199
24022
 
23200
24023
  # -- weekly --
@@ -23223,11 +24046,13 @@ def build_parser() -> argparse.ArgumentParser:
23223
24046
  help="Show per-model cost breakdown sub-rows.")
23224
24047
  we.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
23225
24048
  help="Sort direction by week (default: asc).")
23226
- we.add_argument("--json", action="store_true", dest="json",
23227
- help="Output JSON.")
24049
+ we.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
24050
+ help="In --format output, show real project basenames instead of "
24051
+ "the default project-1, project-2, ... anonymization.")
23228
24052
  we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23229
24053
  help="Display timezone: local, utc, or IANA name. "
23230
24054
  "Overrides config display.tz for this call.")
24055
+ _add_share_args(we)
23231
24056
  we.set_defaults(func=cmd_weekly)
23232
24057
 
23233
24058
  # -- codex shared args helper --
@@ -23423,13 +24248,15 @@ def build_parser() -> argparse.ArgumentParser:
23423
24248
  help="Sort key (default: cost).")
23424
24249
  p_project.add_argument("--group", choices=("git-root", "full-path"), default="git-root",
23425
24250
  help="Bucket by resolved git-root (default) or raw project_path.")
23426
- p_project.add_argument("--json", action="store_true", dest="json",
23427
- help="Emit JSON instead of table.")
24251
+ p_project.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
24252
+ help="In --format output, show real project basenames instead of "
24253
+ "the default project-1, project-2, ... anonymization.")
23428
24254
  p_project.add_argument("--no-color", action="store_true", dest="no_color",
23429
24255
  help="Disable ANSI color.")
23430
24256
  p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23431
24257
  help="Display timezone: local, utc, or IANA name. "
23432
24258
  "Overrides config display.tz for this call.")
24259
+ _add_share_args(p_project)
23433
24260
  p_project.set_defaults(func=cmd_project)
23434
24261
 
23435
24262
  # -- diff --
@@ -23490,11 +24317,18 @@ def build_parser() -> argparse.ArgumentParser:
23490
24317
  help="Show per-model cost breakdown sub-rows.")
23491
24318
  se.add_argument("-o", "--order", choices=("asc", "desc"), default="asc",
23492
24319
  help="Sort direction by last activity (default: asc — earliest first).")
23493
- se.add_argument("--json", action="store_true", dest="json",
23494
- help="Output JSON.")
24320
+ se.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
24321
+ help="In --format output, show real project basenames instead of "
24322
+ "the default project-1, project-2, ... anonymization.")
24323
+ se.add_argument("--top-n", type=int, default=15, dest="top_n",
24324
+ metavar="N",
24325
+ help="In --format output, cap rows to top N by cost (default: 15). "
24326
+ "Must be >= 1; values above 50 emit a readability warning. "
24327
+ "Has no effect on terminal/JSON output.")
23495
24328
  se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
23496
24329
  help="Display timezone: local, utc, or IANA name. "
23497
24330
  "Overrides config display.tz for this call.")
24331
+ _add_share_args(se)
23498
24332
  se.set_defaults(func=cmd_session)
23499
24333
 
23500
24334
  # ---- config (persisted user preferences) ----
@@ -23828,6 +24662,1544 @@ def build_parser() -> argparse.ArgumentParser:
23828
24662
  return p
23829
24663
 
23830
24664
 
24665
+ # ============================================================
24666
+ # ==== Shareable reports: destination + emit ==== =
24667
+ # ============================================================
24668
+ # Translate parsed argparse args + a rendered string into actual delivery
24669
+ # (stdout / file / clipboard / open). These helpers live here, NOT in
24670
+ # `_lib_share.py`, so the kernel module stays I/O-pure (Section 5.8 of the
24671
+ # shareable-reports spec).
24672
+
24673
+
24674
+ # Module-level latch for the home-dir fallback hint. Spec Section 4.2 calls
24675
+ # for a one-shot stderr suggestion when share output lands in $HOME because
24676
+ # both XDG_DOWNLOAD_DIR and ~/Downloads were absent. Latched here (process
24677
+ # scope) so a user running, e.g., a `cctally daily --format html` followed
24678
+ # by `cctally weekly --format html` in the same shell sees the hint exactly
24679
+ # once. Tests reset by reaching into the module globals if needed.
24680
+ _DOWNLOADS_HOME_HINT_EMITTED = False
24681
+
24682
+
24683
+ def _share_resolve_download_dir() -> pathlib.Path:
24684
+ """XDG -> ~/Downloads -> ~ fallback (Section 4.2)."""
24685
+ global _DOWNLOADS_HOME_HINT_EMITTED
24686
+ xdg = os.environ.get("XDG_DOWNLOAD_DIR")
24687
+ if xdg:
24688
+ p = pathlib.Path(xdg).expanduser()
24689
+ if p.exists():
24690
+ return p
24691
+ downloads = pathlib.Path.home() / "Downloads"
24692
+ if downloads.exists():
24693
+ return downloads
24694
+ if not _DOWNLOADS_HOME_HINT_EMITTED:
24695
+ sys.stderr.write(
24696
+ "cctally: writing share output to home dir; "
24697
+ "pass --output <path> to choose a destination\n"
24698
+ )
24699
+ _DOWNLOADS_HOME_HINT_EMITTED = True
24700
+ return pathlib.Path.home()
24701
+
24702
+
24703
+ def _share_unique_path(base: pathlib.Path) -> pathlib.Path:
24704
+ """Auto-collision counter — base.html -> base-2.html -> base-3.html -> ... cap 99.
24705
+
24706
+ Exhaustion (>99 same-day collisions) exits 3 per spec Section 4.4. Prior
24707
+ code raised ``SystemExit("…")`` which yields exit 1 — broke the spec's
24708
+ distinct-exit-code contract for collision exhaustion vs. generic errors.
24709
+ """
24710
+ if not base.exists():
24711
+ return base
24712
+ stem = base.stem
24713
+ suffix = base.suffix
24714
+ parent = base.parent
24715
+ for n in range(2, 100):
24716
+ candidate = parent / f"{stem}-{n}{suffix}"
24717
+ if not candidate.exists():
24718
+ return candidate
24719
+ print(
24720
+ f"cctally: too many same-day collisions in {parent}; use --output <path>",
24721
+ file=sys.stderr,
24722
+ )
24723
+ sys.exit(3)
24724
+
24725
+
24726
+ def _resolve_destination(
24727
+ args, *, cmd: str, generated_at_utc_date: str
24728
+ ) -> tuple[str, pathlib.Path | None]:
24729
+ """Translate argparse args into (kind, value).
24730
+
24731
+ kind: "stdout" | "file" | "clipboard"
24732
+ value: pathlib.Path for "file"; None for "stdout" / "clipboard".
24733
+
24734
+ Exit-code contract (spec Section 4.4):
24735
+ - exit 2 on invalid flag combinations (--copy on non-md;
24736
+ --copy + --output; --copy with no clipboard tool; --open + md).
24737
+ - exit 3 on collision exhaustion (delegated to _share_unique_path).
24738
+ """
24739
+ fmt = args.format
24740
+ if getattr(args, "copy", False) and getattr(args, "output", None) is not None:
24741
+ # Mutex: a clipboard destination by definition has no path. Spec
24742
+ # Section 4.4 line 132 calls this out explicitly. Prior code silently
24743
+ # let --copy override --output, which surprised users who expected
24744
+ # the file to land alongside the clipboard write.
24745
+ print(
24746
+ "cctally: --copy is mutually exclusive with --output",
24747
+ file=sys.stderr,
24748
+ )
24749
+ sys.exit(2)
24750
+ if getattr(args, "copy", False):
24751
+ if fmt != "md":
24752
+ print("cctally: --copy is only valid with --format md", file=sys.stderr)
24753
+ sys.exit(2)
24754
+ return ("clipboard", None)
24755
+
24756
+ output = getattr(args, "output", None)
24757
+ if output == "-":
24758
+ return ("stdout", None)
24759
+ if output:
24760
+ return ("file", pathlib.Path(output).expanduser())
24761
+
24762
+ if fmt == "md":
24763
+ return ("stdout", None)
24764
+ # html/svg default -> ~/Downloads/cctally-<cmd>-<utcdate>.<ext>
24765
+ base = _share_resolve_download_dir() / f"cctally-{cmd}-{generated_at_utc_date}.{fmt}"
24766
+ return ("file", _share_unique_path(base))
24767
+
24768
+
24769
+ def _emit(content: str, *, kind: str, value: pathlib.Path | str | None) -> None:
24770
+ """Deliver rendered content to stdout/file/clipboard."""
24771
+ if kind == "stdout":
24772
+ sys.stdout.write(content)
24773
+ if not content.endswith("\n"):
24774
+ sys.stdout.write("\n")
24775
+ return
24776
+
24777
+ if kind == "file":
24778
+ path = pathlib.Path(value)
24779
+ path.parent.mkdir(parents=True, exist_ok=True)
24780
+ path.write_text(content, encoding="utf-8")
24781
+ sys.stderr.write(f"Wrote {path}\n")
24782
+ return
24783
+
24784
+ if kind == "clipboard":
24785
+ # Track tools that were found-but-failed separately from "no tool on
24786
+ # PATH" so the error message accurately describes what went wrong.
24787
+ # The prior shape ("requires pbcopy/xclip/clip on PATH") was
24788
+ # misleading when e.g. pbcopy was present but exited non-zero.
24789
+ tried = []
24790
+ for cmd_args in (
24791
+ ["pbcopy"],
24792
+ ["xclip", "-sel", "clip"],
24793
+ ["clip.exe"],
24794
+ ):
24795
+ tool = cmd_args[0]
24796
+ if shutil.which(tool):
24797
+ proc = subprocess.run(cmd_args, input=content, text=True, check=False)
24798
+ if proc.returncode == 0:
24799
+ sys.stderr.write(f"Copied to clipboard via {tool}\n")
24800
+ return
24801
+ tried.append(f"{tool} (exit {proc.returncode})")
24802
+ if tried:
24803
+ print(
24804
+ f"cctally: clipboard tool failed: {', '.join(tried)}",
24805
+ file=sys.stderr,
24806
+ )
24807
+ sys.exit(2)
24808
+ print(
24809
+ "cctally: --copy requires pbcopy, xclip, or clip on PATH",
24810
+ file=sys.stderr,
24811
+ )
24812
+ sys.exit(2)
24813
+
24814
+ raise ValueError(f"unknown destination kind: {kind!r}")
24815
+
24816
+
24817
+ def _share_load_lib():
24818
+ """Lazy-load `_lib_share` with sys.modules caching.
24819
+
24820
+ Single-load semantics keep ShareSnapshot / MoneyCell / etc. class
24821
+ identities stable across kernel imports: the test harness pre-registers
24822
+ `_lib_share` in `sys.modules`, the wrapper imports it via this helper,
24823
+ and snapshot builders import it via this helper — all paths must see
24824
+ the SAME module object so `isinstance` checks on snapshot cells compare
24825
+ across one class identity, not many. This is the chokepoint for the
24826
+ duplicate-class-identity bug surfaced under the test harness in
24827
+ Implementor 6's fix-loop.
24828
+
24829
+ Registers in sys.modules BEFORE exec_module: Python 3.14's `dataclass`
24830
+ decorator looks up `cls.__module__` in `sys.modules` for `KW_ONLY` type
24831
+ checks, and an absent entry would re-trigger the dual-load path under
24832
+ some import orders.
24833
+ """
24834
+ cached = sys.modules.get("_lib_share")
24835
+ if cached is not None:
24836
+ return cached
24837
+ import importlib.util as _ilu
24838
+ _lib_share_path = pathlib.Path(__file__).resolve().parent / "_lib_share.py"
24839
+ _spec = _ilu.spec_from_file_location("_lib_share", _lib_share_path)
24840
+ _mod = _ilu.module_from_spec(_spec)
24841
+ sys.modules["_lib_share"] = _mod
24842
+ _spec.loader.exec_module(_mod)
24843
+ return _mod
24844
+
24845
+
24846
+ def _share_now_utc() -> dt.datetime:
24847
+ """`generated_at` source — honors CCTALLY_AS_OF env hook for fixture stability.
24848
+
24849
+ Mirrors the existing `CCTALLY_AS_OF` precedent used by `project` /
24850
+ `forecast` for deterministic fixture goldens. Format: ISO-8601 with `Z`
24851
+ or explicit offset (e.g. `2026-05-09T12:00:00Z` or
24852
+ `2026-05-09T12:00:00+00:00`); falls back to wall-clock UTC when unset.
24853
+
24854
+ Raises ValueError on malformed `CCTALLY_AS_OF` input — deliberate
24855
+ fail-loud behavior for the dev hook so fixture authors notice typos
24856
+ immediately rather than silently falling back to wall-clock time.
24857
+ """
24858
+ override = os.environ.get("CCTALLY_AS_OF")
24859
+ if override:
24860
+ parsed = dt.datetime.fromisoformat(override.replace("Z", "+00:00"))
24861
+ if parsed.tzinfo is None:
24862
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
24863
+ return parsed.astimezone(dt.timezone.utc)
24864
+ return dt.datetime.now(dt.timezone.utc)
24865
+
24866
+
24867
+ def _share_resolve_version() -> str:
24868
+ """Source from CHANGELOG via the existing release helper. Empty string if unset.
24869
+
24870
+ `_release_read_latest_release_version` returns `(version, date) | None`;
24871
+ the snapshot's `version` field carries the version string only.
24872
+ """
24873
+ info = _release_read_latest_release_version()
24874
+ return info[0] if info else ""
24875
+
24876
+
24877
+ def _share_period_label(
24878
+ period_start: dt.datetime,
24879
+ period_end: dt.datetime,
24880
+ display_tz_label: str,
24881
+ ) -> str:
24882
+ """Render the canonical "<start> → <end> (<tz>)" period label.
24883
+
24884
+ Used by both the report and daily snapshot builders so the period label
24885
+ format stays consistent across share-enabled subcommands.
24886
+ """
24887
+ return (
24888
+ f"{period_start.strftime('%b %d')} → "
24889
+ f"{period_end.strftime('%b %d')} ({display_tz_label})"
24890
+ )
24891
+
24892
+
24893
+ def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
24894
+ """Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
24895
+
24896
+ Used by the share gate sites to lift week-boundary date strings
24897
+ (`weekStartDate`, `weekEndDate`) into the tz-aware datetimes that
24898
+ `PeriodSpec` expects. None / empty / unparseable -> current UTC; the
24899
+ caller already gated on a non-empty trend before reaching this path,
24900
+ so the fallback is purely defensive against missing-data corner cases.
24901
+ """
24902
+ if value is None:
24903
+ return _share_now_utc()
24904
+ if isinstance(value, dt.datetime):
24905
+ return value if value.tzinfo else value.replace(tzinfo=tz or dt.timezone.utc)
24906
+ if isinstance(value, dt.date):
24907
+ d = value
24908
+ else:
24909
+ try:
24910
+ d = dt.date.fromisoformat(str(value))
24911
+ except ValueError:
24912
+ return _share_now_utc()
24913
+ midnight = dt.datetime(d.year, d.month, d.day)
24914
+ return midnight.replace(tzinfo=tz or dt.timezone.utc)
24915
+
24916
+
24917
+ def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
24918
+ """Render a stable display-tz string for `PeriodSpec.display_tz`.
24919
+
24920
+ `resolve_display_tz` returns `None` for "local" (caller does bare
24921
+ astimezone); the share snapshot needs a non-None string. Map None ->
24922
+ "local" and use ZoneInfo.key otherwise.
24923
+ """
24924
+ return tz.key if tz is not None else "local"
24925
+
24926
+
24927
+ def _build_report_snapshot(
24928
+ rows: list[dict[str, object]],
24929
+ *,
24930
+ period_start: dt.datetime,
24931
+ period_end: dt.datetime,
24932
+ display_tz: str,
24933
+ version: str,
24934
+ theme: str,
24935
+ reveal_projects: bool,
24936
+ ) -> "ShareSnapshot":
24937
+ """Build a ShareSnapshot for `cctally report`.
24938
+
24939
+ `rows` is the in-memory `trend` list produced by `cmd_report` — a list
24940
+ of dicts keyed in the existing JSON-shape camelCase
24941
+ (`weekStartDate`, `weeklyPercent`, `weeklyCostUSD`, `dollarsPerPercent`).
24942
+ The plan's snake_case keys (`week_start_date`, `used_pct`, `cost_usd`,
24943
+ `dollar_per_pct`) are NOT the actual `cmd_report` data shape — see
24944
+ Implementor 7 commit body for the deviation.
24945
+
24946
+ Caller MUST pass `rows` in chronological order (oldest first) so the
24947
+ chart line trends left→right with time. `cmd_report`'s native `trend`
24948
+ is newest-first; the gate site reverses before calling.
24949
+
24950
+ `theme` and `reveal_projects` flow into the subtitle directly so the
24951
+ builder owns the canonical subtitle shape — no post-build re-stamp at
24952
+ the gate site. The forward-reference return type matches the kernel's
24953
+ lazy-import boundary.
24954
+ """
24955
+ _lib_share = _share_load_lib()
24956
+ columns = (
24957
+ _lib_share.ColumnSpec(key="week", label="Week", align="left"),
24958
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
24959
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right"),
24960
+ _lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
24961
+ emphasis=True),
24962
+ )
24963
+ snap_rows: list = []
24964
+ chart_pts: list = []
24965
+ for i, r in enumerate(rows):
24966
+ wsd = r.get("weekStartDate")
24967
+ # `weekStartDate` is sourced from `week_ref.week_start.isoformat()`
24968
+ # — guaranteed `str`. Empty / unparseable falls back to em-dash.
24969
+ if isinstance(wsd, str) and wsd:
24970
+ try:
24971
+ week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
24972
+ except ValueError:
24973
+ week_label = wsd
24974
+ else:
24975
+ week_label = "—"
24976
+ # Preserve None vs 0.0 distinction (parity with terminal/JSON).
24977
+ # Terminal _render_weekly_table renders missing values as "—";
24978
+ # share artifact follows the same convention. Coercing None to
24979
+ # 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
24980
+ # genuine zero, and would skew the avg / chart.
24981
+ used_pct_raw = r.get("weeklyPercent")
24982
+ cost_raw = r.get("weeklyCostUSD")
24983
+ dpp_raw = r.get("dollarsPerPercent")
24984
+ snap_rows.append(_lib_share.Row(cells={
24985
+ "week": _lib_share.TextCell(week_label),
24986
+ "used": (
24987
+ _lib_share.PercentCell(float(used_pct_raw))
24988
+ if used_pct_raw is not None else _lib_share.TextCell("—")
24989
+ ),
24990
+ "cost": (
24991
+ _lib_share.MoneyCell(float(cost_raw))
24992
+ if cost_raw is not None else _lib_share.TextCell("—")
24993
+ ),
24994
+ "dpp": (
24995
+ _lib_share.MoneyCell(float(dpp_raw))
24996
+ if dpp_raw is not None else _lib_share.TextCell("—")
24997
+ ),
24998
+ }))
24999
+ # Skip chart points for weeks with no $/% sample — the polyline
25000
+ # connects across the gap rather than dropping to 0, which would
25001
+ # misrepresent missing data as a crash to zero.
25002
+ if dpp_raw is not None:
25003
+ chart_pts.append(_lib_share.ChartPoint(
25004
+ x_label=week_label,
25005
+ x_value=float(i),
25006
+ y_value=float(dpp_raw),
25007
+ ))
25008
+ chart = (
25009
+ _lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
25010
+ if len(chart_pts) >= 3 else None
25011
+ )
25012
+ avg_dpp = (
25013
+ sum(p.y_value for p in chart_pts) / len(chart_pts)
25014
+ if chart_pts else 0.0
25015
+ )
25016
+ totals = (
25017
+ _lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
25018
+ )
25019
+ if rows:
25020
+ title = f"Weekly $ / % trend — last {len(rows)} weeks"
25021
+ else:
25022
+ title = "Weekly $ / % trend — no data"
25023
+ period_label = _share_period_label(period_start, period_end, display_tz)
25024
+ subtitle = " · ".join([
25025
+ period_label,
25026
+ theme,
25027
+ "real projects" if reveal_projects else "projects anonymized",
25028
+ ])
25029
+ return _lib_share.ShareSnapshot(
25030
+ cmd="report",
25031
+ title=title,
25032
+ subtitle=subtitle,
25033
+ period=_lib_share.PeriodSpec(
25034
+ start=period_start, end=period_end,
25035
+ display_tz=display_tz, label=period_label,
25036
+ ),
25037
+ columns=columns, rows=tuple(snap_rows),
25038
+ chart=chart, totals=totals, notes=(),
25039
+ generated_at=_share_now_utc(), version=version,
25040
+ )
25041
+
25042
+
25043
+ def _build_daily_snapshot(
25044
+ rows: list["BucketUsage"],
25045
+ *,
25046
+ period_start: dt.datetime,
25047
+ period_end: dt.datetime,
25048
+ display_tz: str,
25049
+ version: str,
25050
+ theme: str,
25051
+ reveal_projects: bool,
25052
+ ) -> "ShareSnapshot":
25053
+ """Build a ShareSnapshot for `cctally daily`.
25054
+
25055
+ `rows` is the in-memory `BucketUsage` list produced by `_aggregate_daily`
25056
+ inside `cmd_daily`. Each bucket has: `bucket` (YYYY-MM-DD date string),
25057
+ `cost_usd`, `model_breakdowns` (list[dict] sorted by cost desc; first
25058
+ entry is the top model).
25059
+
25060
+ Deviations from the plan sketch (which assumed dict rows with keys
25061
+ `date` / `cost_usd` / `pct_of_week` / `top_model`):
25062
+
25063
+ - Rows are `BucketUsage` dataclasses; we read fields by attribute.
25064
+ - Daily has no native `% of week` column — daily is range-scoped, not
25065
+ week-scoped. We render `% of period` (this row's cost / total range
25066
+ cost) so the column carries meaningful info; the `pct_week` key
25067
+ survives in the column spec for plan-shape parity.
25068
+ - `top_model` is the first entry of `model_breakdowns` (sorted by cost
25069
+ desc per upstream ccusage parity); empty → "—".
25070
+
25071
+ Caller MUST pass `rows` in chronological order so the BarChart bars
25072
+ line up left-to-right with time. `_aggregate_daily` already returns
25073
+ asc; the gate site re-sorts after `args.order == "desc"` was applied.
25074
+
25075
+ `theme` and `reveal_projects` flow into the subtitle directly so the
25076
+ builder owns the canonical subtitle shape — no post-build re-stamp at
25077
+ the gate site.
25078
+ """
25079
+ _lib_share = _share_load_lib()
25080
+ columns = (
25081
+ _lib_share.ColumnSpec(key="date", label="Date", align="left"),
25082
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
25083
+ emphasis=True),
25084
+ _lib_share.ColumnSpec(key="pct_week", label="% of Period",
25085
+ align="right"),
25086
+ _lib_share.ColumnSpec(key="top_model", label="Top Model",
25087
+ align="left"),
25088
+ )
25089
+ total_cost = 0.0
25090
+ for r in rows:
25091
+ total_cost += float(getattr(r, "cost_usd", 0.0) or 0.0)
25092
+
25093
+ snap_rows: list = []
25094
+ chart_pts: list = []
25095
+ for i, r in enumerate(rows):
25096
+ # `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
25097
+ # empty / unparseable but skip the dead `dt.date` branch.
25098
+ bucket = getattr(r, "bucket", None)
25099
+ if isinstance(bucket, str) and bucket:
25100
+ try:
25101
+ date_str = dt.date.fromisoformat(bucket).strftime("%b %d")
25102
+ except ValueError:
25103
+ date_str = bucket
25104
+ else:
25105
+ date_str = "—"
25106
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
25107
+ breakdowns = getattr(r, "model_breakdowns", None) or []
25108
+ top_model = (breakdowns[0].get("modelName") if breakdowns else None) or "—"
25109
+ pct_of_period = (cost_usd / total_cost * 100.0) if total_cost > 0 else 0.0
25110
+ snap_rows.append(_lib_share.Row(cells={
25111
+ "date": _lib_share.TextCell(date_str),
25112
+ "cost": _lib_share.MoneyCell(cost_usd),
25113
+ "pct_week": _lib_share.PercentCell(pct_of_period),
25114
+ "top_model": _lib_share.TextCell(top_model),
25115
+ }))
25116
+ chart_pts.append(_lib_share.ChartPoint(
25117
+ x_label=date_str,
25118
+ x_value=float(i),
25119
+ y_value=cost_usd,
25120
+ ))
25121
+ chart = (
25122
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
25123
+ if chart_pts else None
25124
+ )
25125
+ avg_cost = (total_cost / len(chart_pts)) if chart_pts else 0.0
25126
+ totals = (
25127
+ _lib_share.Totalled(label="Sum", value=f"${total_cost:,.2f}"),
25128
+ _lib_share.Totalled(label="Days", value=str(len(chart_pts))),
25129
+ _lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
25130
+ )
25131
+ if rows:
25132
+ title = (
25133
+ f"Daily usage — {period_start.strftime('%b %d')} → "
25134
+ f"{period_end.strftime('%b %d')}"
25135
+ )
25136
+ else:
25137
+ title = "Daily usage — no data"
25138
+ period_label = _share_period_label(period_start, period_end, display_tz)
25139
+ subtitle = " · ".join([
25140
+ period_label,
25141
+ theme,
25142
+ "real projects" if reveal_projects else "projects anonymized",
25143
+ ])
25144
+ return _lib_share.ShareSnapshot(
25145
+ cmd="daily",
25146
+ title=title,
25147
+ subtitle=subtitle,
25148
+ period=_lib_share.PeriodSpec(
25149
+ start=period_start, end=period_end,
25150
+ display_tz=display_tz, label=period_label,
25151
+ ),
25152
+ columns=columns, rows=tuple(snap_rows),
25153
+ chart=chart, totals=totals, notes=(),
25154
+ generated_at=_share_now_utc(), version=version,
25155
+ )
25156
+
25157
+
25158
+ def _build_monthly_snapshot(
25159
+ rows: list["BucketUsage"],
25160
+ *,
25161
+ period_start: dt.datetime,
25162
+ period_end: dt.datetime,
25163
+ display_tz: str,
25164
+ version: str,
25165
+ theme: str,
25166
+ reveal_projects: bool,
25167
+ ) -> "ShareSnapshot":
25168
+ """Build a ShareSnapshot for `cctally monthly`.
25169
+
25170
+ `rows` is the in-memory `BucketUsage` list produced by `_aggregate_monthly`
25171
+ inside `cmd_monthly`. Each bucket has: `bucket` (YYYY-MM string),
25172
+ `cost_usd`, and `model_breakdowns` (list[dict] sorted by cost desc).
25173
+
25174
+ Deviations from the plan sketch (which assumed dict rows with keys
25175
+ `month` / `cost_usd` / `sessions`):
25176
+
25177
+ - Rows are `BucketUsage` dataclasses; we read fields by attribute.
25178
+ - The plan's `Sessions` column has no source in the underlying data
25179
+ (`BucketUsage` carries no session count and `_aggregate_monthly`
25180
+ never computes one). Substituted with a `Tokens` column carrying
25181
+ total tokens — meaningful info already on the dataclass.
25182
+ - `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
25183
+ months, matching the plan's intent.
25184
+
25185
+ Caller MUST pass `rows` in chronological order so the BarChart bars line
25186
+ up left-to-right with time. `_aggregate_monthly` already returns asc;
25187
+ the gate site fires before the `--order desc` reversal in `cmd_monthly`.
25188
+
25189
+ `theme` and `reveal_projects` flow into the subtitle directly so the
25190
+ builder owns the canonical subtitle shape — no post-build re-stamp at
25191
+ the gate site.
25192
+ """
25193
+ _lib_share = _share_load_lib()
25194
+ columns = (
25195
+ _lib_share.ColumnSpec(key="month", label="Month", align="left"),
25196
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
25197
+ emphasis=True),
25198
+ _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
25199
+ _lib_share.ColumnSpec(key="delta", label="Δ vs prior", align="right"),
25200
+ )
25201
+ snap_rows: list = []
25202
+ chart_pts: list = []
25203
+ prev_cost: float | None = None
25204
+ for i, r in enumerate(rows):
25205
+ # `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
25206
+ # empty / unparseable but skip the dead `dt.date` branch.
25207
+ bucket = getattr(r, "bucket", None)
25208
+ month_str = bucket if isinstance(bucket, str) and bucket else "—"
25209
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
25210
+ total_tokens = int(getattr(r, "total_tokens", 0) or 0)
25211
+ if prev_cost is not None and prev_cost > 0:
25212
+ delta_pct = (cost_usd - prev_cost) / prev_cost * 100.0
25213
+ delta_cell = _lib_share.DeltaCell(value=delta_pct, unit="%")
25214
+ else:
25215
+ delta_cell = _lib_share.TextCell("—")
25216
+ snap_rows.append(_lib_share.Row(cells={
25217
+ "month": _lib_share.TextCell(month_str),
25218
+ "cost": _lib_share.MoneyCell(cost_usd),
25219
+ "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
25220
+ "delta": delta_cell,
25221
+ }))
25222
+ chart_pts.append(_lib_share.ChartPoint(
25223
+ x_label=month_str,
25224
+ x_value=float(i),
25225
+ y_value=cost_usd,
25226
+ ))
25227
+ prev_cost = cost_usd
25228
+ chart = (
25229
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
25230
+ if chart_pts else None
25231
+ )
25232
+ sum_cost = sum(p.y_value for p in chart_pts)
25233
+ avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
25234
+ totals = (
25235
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
25236
+ _lib_share.Totalled(label="Months", value=str(len(chart_pts))),
25237
+ _lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
25238
+ )
25239
+ if rows:
25240
+ title = (
25241
+ f"Monthly usage — {period_start.strftime('%Y-%m')} → "
25242
+ f"{period_end.strftime('%Y-%m')}"
25243
+ )
25244
+ else:
25245
+ title = "Monthly usage — no data"
25246
+ period_label = (
25247
+ f"{period_start.strftime('%Y-%m')} → "
25248
+ f"{period_end.strftime('%Y-%m')} ({display_tz})"
25249
+ )
25250
+ subtitle = " · ".join([
25251
+ period_label,
25252
+ theme,
25253
+ "real projects" if reveal_projects else "projects anonymized",
25254
+ ])
25255
+ return _lib_share.ShareSnapshot(
25256
+ cmd="monthly",
25257
+ title=title,
25258
+ subtitle=subtitle,
25259
+ period=_lib_share.PeriodSpec(
25260
+ start=period_start, end=period_end,
25261
+ display_tz=display_tz, label=period_label,
25262
+ ),
25263
+ columns=columns, rows=tuple(snap_rows),
25264
+ chart=chart, totals=totals, notes=(),
25265
+ generated_at=_share_now_utc(), version=version,
25266
+ )
25267
+
25268
+
25269
+ def _build_weekly_snapshot(
25270
+ rows: list["BucketUsage"],
25271
+ overlay: list[tuple[float | None, float | None]],
25272
+ *,
25273
+ period_start: dt.datetime,
25274
+ period_end: dt.datetime,
25275
+ display_tz: str,
25276
+ version: str,
25277
+ theme: str,
25278
+ reveal_projects: bool,
25279
+ breakdown_model: bool,
25280
+ ) -> "ShareSnapshot":
25281
+ """Build a ShareSnapshot for `cctally weekly`.
25282
+
25283
+ `rows` is the in-memory `BucketUsage` list produced by `_aggregate_weekly`
25284
+ inside `cmd_weekly`; each bucket carries `bucket` (week_start_date as
25285
+ "YYYY-MM-DD"), `cost_usd`, `total_tokens`, and `model_breakdowns`
25286
+ (list[dict] sorted by cost desc, each `{modelName, ..., cost}`).
25287
+
25288
+ `overlay` is the parallel list of `(used_pct, dollars_per_pct)` tuples
25289
+ computed by `cmd_weekly` from `weekly_usage_snapshots`. Either component
25290
+ may be `None` for a week that has no captured snapshot yet — surfaces
25291
+ in the snapshot row as a `0.0` PercentCell so the column stays aligned
25292
+ (matching the table renderer's "no data → 0%" behavior).
25293
+
25294
+ Deviations from the plan sketch (which assumed dict rows with keys
25295
+ `week_start_date` / `used_pct` / `cost_usd` / `sessions` /
25296
+ `model_breakdown` and a `breakdown_model: bool` derived from
25297
+ `args.breakdown == "model"`):
25298
+
25299
+ - Rows are `BucketUsage` dataclasses; per-week `used_pct` lives in the
25300
+ separate `overlay` list — neither shape matches the plan literal.
25301
+ - The plan's `Sessions` column has no source — `BucketUsage` carries
25302
+ no session count and `_aggregate_weekly` never computes one.
25303
+ Substituted with a `Tokens` column (`total_tokens` formatted with
25304
+ thousands separators).
25305
+ - `args.breakdown` for `cmd_weekly` is `action="store_true"` (not a
25306
+ `{model,project}` choice), so `breakdown_model` is just the boolean
25307
+ `args.breakdown` from the gate site.
25308
+ - `model_breakdowns` is a list-of-dicts (`modelName` / `cost`), not a
25309
+ `{model: cost}` mapping; we coerce to a dict before key lookup.
25310
+
25311
+ Honors `breakdown_model` by appending one `m_<model>` column per
25312
+ distinct model and populating `BarChart.stacks` with per-model series.
25313
+ All model-axis iteration uses a single sorted list (`all_model_keys`)
25314
+ so column / stack ordering is deterministic across runs.
25315
+
25316
+ Caller MUST pass `rows` (and `overlay` aligned to it) in chronological
25317
+ order so the BarChart bars line up left-to-right with time. The gate
25318
+ site fires BEFORE the `--order desc` reversal in `cmd_weekly`.
25319
+
25320
+ `theme` and `reveal_projects` flow into the subtitle directly so the
25321
+ builder owns the canonical subtitle shape — no post-build re-stamp at
25322
+ the gate site.
25323
+ """
25324
+ _lib_share = _share_load_lib()
25325
+ columns_list: list = [
25326
+ _lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
25327
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
25328
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
25329
+ emphasis=True),
25330
+ _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
25331
+ ]
25332
+ # Per-row model→cost lookup (BucketUsage exposes a list-of-dicts;
25333
+ # collapse to dict here so per-row column population is O(1) per
25334
+ # model). All breakdown-aware iteration goes through `all_model_keys`
25335
+ # for deterministic ordering.
25336
+ per_row_model_costs: list[dict[str, float]] = []
25337
+ for r in rows:
25338
+ breakdowns = getattr(r, "model_breakdowns", None) or []
25339
+ per_row_model_costs.append({
25340
+ (b.get("modelName") or "—"): float(b.get("cost") or 0.0)
25341
+ for b in breakdowns
25342
+ })
25343
+ if breakdown_model:
25344
+ all_model_keys = sorted({m for d in per_row_model_costs for m in d})
25345
+ for m in all_model_keys:
25346
+ columns_list.append(_lib_share.ColumnSpec(
25347
+ key=f"m_{m}", label=m, align="right",
25348
+ ))
25349
+ else:
25350
+ all_model_keys = []
25351
+
25352
+ snap_rows: list = []
25353
+ chart_pts: list = []
25354
+ stacks: dict[str, list] = {}
25355
+ for i, r in enumerate(rows):
25356
+ # `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
25357
+ # empty / unparseable but skip the dead `dt.date` branch.
25358
+ bucket = getattr(r, "bucket", None)
25359
+ if isinstance(bucket, str) and bucket:
25360
+ try:
25361
+ week_label = dt.date.fromisoformat(bucket).strftime("%b %d")
25362
+ except ValueError:
25363
+ week_label = bucket
25364
+ else:
25365
+ week_label = "—"
25366
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
25367
+ total_tokens = int(getattr(r, "total_tokens", 0) or 0)
25368
+ # `used_pct` is None when the week lacks a `weekly_usage_snapshots`
25369
+ # row — render as em-dash to match terminal `_render_weekly_table`.
25370
+ # Coercing to 0.0 would conflate "no snapshot recorded" with "0%
25371
+ # used," same divergence the report builder fixes. cost_usd from
25372
+ # session_entries is genuinely 0 when there are no entries (not
25373
+ # missing data) so that path keeps MoneyCell(0.0).
25374
+ used_pct_raw = (
25375
+ overlay[i][0] if i < len(overlay) else None
25376
+ )
25377
+ cells = {
25378
+ "week": _lib_share.TextCell(week_label),
25379
+ "used": (
25380
+ _lib_share.PercentCell(float(used_pct_raw))
25381
+ if used_pct_raw is not None else _lib_share.TextCell("—")
25382
+ ),
25383
+ "cost": _lib_share.MoneyCell(cost_usd),
25384
+ "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
25385
+ }
25386
+ if breakdown_model:
25387
+ row_costs = per_row_model_costs[i]
25388
+ for m in all_model_keys:
25389
+ m_cost = float(row_costs.get(m) or 0.0)
25390
+ cells[f"m_{m}"] = _lib_share.MoneyCell(m_cost)
25391
+ stacks.setdefault(m, []).append(_lib_share.ChartPoint(
25392
+ x_label=week_label,
25393
+ x_value=float(i),
25394
+ y_value=m_cost,
25395
+ series_key=m,
25396
+ ))
25397
+ snap_rows.append(_lib_share.Row(cells=cells))
25398
+ chart_pts.append(_lib_share.ChartPoint(
25399
+ x_label=week_label,
25400
+ x_value=float(i),
25401
+ y_value=cost_usd,
25402
+ ))
25403
+ # `BarChart.stacks` is `Mapping[str, tuple[ChartPoint, ...]] | None`
25404
+ # (Implementor 1's tightening); convert dict-of-lists to dict-of-tuples.
25405
+ stacks_immut = (
25406
+ {k: tuple(v) for k, v in stacks.items()} if stacks else None
25407
+ )
25408
+ chart = (
25409
+ _lib_share.BarChart(
25410
+ points=tuple(chart_pts), y_label="$", stacks=stacks_immut,
25411
+ )
25412
+ if chart_pts else None
25413
+ )
25414
+ sum_cost = sum(p.y_value for p in chart_pts)
25415
+ pct_values = [
25416
+ float(o[0]) for o in overlay
25417
+ if o is not None and o[0] is not None
25418
+ ]
25419
+ avg_pct = (sum(pct_values) / len(pct_values)) if pct_values else 0.0
25420
+ peak_pct = max(pct_values, default=0.0)
25421
+ totals = (
25422
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
25423
+ _lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
25424
+ _lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
25425
+ )
25426
+ title = (
25427
+ f"Weekly usage — last {len(rows)} weeks"
25428
+ if rows
25429
+ else "Weekly usage — no data"
25430
+ )
25431
+ period_label = _share_period_label(period_start, period_end, display_tz)
25432
+ subtitle = " · ".join([
25433
+ period_label,
25434
+ theme,
25435
+ "real projects" if reveal_projects else "projects anonymized",
25436
+ ])
25437
+ return _lib_share.ShareSnapshot(
25438
+ cmd="weekly",
25439
+ title=title,
25440
+ subtitle=subtitle,
25441
+ period=_lib_share.PeriodSpec(
25442
+ start=period_start, end=period_end,
25443
+ display_tz=display_tz, label=period_label,
25444
+ ),
25445
+ columns=tuple(columns_list), rows=tuple(snap_rows),
25446
+ chart=chart, totals=totals, notes=(),
25447
+ generated_at=_share_now_utc(), version=version,
25448
+ )
25449
+
25450
+
25451
+ def _build_forecast_snapshot(
25452
+ *,
25453
+ week_start: dt.datetime,
25454
+ week_end: dt.datetime,
25455
+ display_tz: str,
25456
+ version: str,
25457
+ theme: str,
25458
+ reveal_projects: bool,
25459
+ actual_series: list[tuple[str, float, float]],
25460
+ projected_series: list[tuple[str, float, float]],
25461
+ current_pct: float,
25462
+ projected_low_pct: float,
25463
+ projected_high_pct: float,
25464
+ days_remaining: float,
25465
+ dollars_per_percent: float,
25466
+ dollars_per_percent_source: str,
25467
+ low_conf: bool,
25468
+ notes: tuple[str, ...] = (),
25469
+ ) -> "ShareSnapshot":
25470
+ """Build a ShareSnapshot for `cctally forecast`.
25471
+
25472
+ `actual_series` is a list of `(x_label, x_value, y_value)` tuples drawn
25473
+ from `weekly_usage_snapshots` for the current week — each sample's
25474
+ `captured_at_utc` is the x_label (formatted compactly), `x_value` is
25475
+ elapsed-hours-since-week-start (a monotonic float so the LineChart
25476
+ renders left→right), and `y_value` is `weekly_percent` at that capture.
25477
+
25478
+ `projected_series` is a parallel list of `(x_label, x_value, y_value)`
25479
+ tuples for the projection ray — the simplest form is a 2-point line
25480
+ from `(now, current_pct)` to `(week_end, projected_eow_pct)`. The
25481
+ renderer treats it as a `multi_series` overlay on top of the actual line.
25482
+
25483
+ Deviations from the plan sketch (which assumed a single
25484
+ `_compute_forecast_data(args) -> dict` helper and `dpp_week_avg` /
25485
+ `dpp_24h` as separate columns):
25486
+
25487
+ - `cmd_forecast` already exposes the data as `ForecastOutput` (which
25488
+ wraps `ForecastInputs`). No helper extraction was needed; we pass
25489
+ the actual scalars in directly.
25490
+ - `ForecastInputs` carries a single `dollars_per_percent` value plus
25491
+ a `dollars_per_percent_source` enum (`this_week` /
25492
+ `trailing_4wk_median` / `this_week_sparse`); there is no separate
25493
+ `dpp_week_avg` and `dpp_24h`. The table renders one $/1% row with
25494
+ the source as a paren suffix in the metric cell.
25495
+ - The plan's single `projected_eow_pct` is split into a low/high
25496
+ range (matching `--render-forecast-terminal`'s "Forecast 80–95%"
25497
+ band). The table shows both ends; the projected_series ray uses
25498
+ the high end so the overlay aligns with the conservative budget
25499
+ consumers expect from the chart.
25500
+
25501
+ Reference lines at 90%/100% are LineChart-stable across all samples;
25502
+ severities `warn` (90%) and `alarm` (100%) drive the renderer's
25503
+ color mapping.
25504
+
25505
+ `theme` and `reveal_projects` flow into the subtitle directly so the
25506
+ builder owns the canonical subtitle shape — no post-build re-stamp
25507
+ at the gate site.
25508
+
25509
+ `notes`, when non-empty, overrides the auto-emitted "LOW CONF — data
25510
+ thin" note. The empty-data fast-path passes a clearer "no snapshots
25511
+ recorded" note so the artifact says what's actually wrong; the
25512
+ confidence-thin terminal path passes nothing and falls back to the
25513
+ auto LOW CONF banner.
25514
+ """
25515
+ _lib_share = _share_load_lib()
25516
+ actual_pts = tuple(
25517
+ _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
25518
+ for lbl, xv, yv in actual_series
25519
+ )
25520
+ projected_pts = tuple(
25521
+ _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
25522
+ for lbl, xv, yv in projected_series
25523
+ )
25524
+ chart = (
25525
+ _lib_share.LineChart(
25526
+ points=actual_pts,
25527
+ y_label="cumulative %",
25528
+ reference_lines=(
25529
+ (90.0, "90%", "warn"),
25530
+ (100.0, "100%", "alarm"),
25531
+ ),
25532
+ multi_series={"projected": projected_pts} if projected_pts else None,
25533
+ )
25534
+ if actual_pts else None
25535
+ )
25536
+
25537
+ columns = (
25538
+ _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
25539
+ _lib_share.ColumnSpec(key="value", label="Value", align="right",
25540
+ emphasis=True),
25541
+ )
25542
+ # Render the projected band as "low-high%" so a single PercentCell
25543
+ # carries the two-rate forecast spread. When the rates collapse to a
25544
+ # single value (no recent-24h sample), low == high.
25545
+ # 0.05 threshold: below .1f display precision — tighter spreads would
25546
+ # render as identical decimals, so collapse to a single value.
25547
+ if abs(projected_high_pct - projected_low_pct) < 0.05:
25548
+ projected_text = f"{projected_high_pct:.1f}%"
25549
+ else:
25550
+ projected_text = (
25551
+ f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
25552
+ )
25553
+ dpp_source_label = dollars_per_percent_source.replace("_", " ")
25554
+ snap_rows = (
25555
+ _lib_share.Row(cells={
25556
+ "metric": _lib_share.TextCell("Current %"),
25557
+ "value": _lib_share.PercentCell(float(current_pct)),
25558
+ }),
25559
+ _lib_share.Row(cells={
25560
+ "metric": _lib_share.TextCell("Projected end-of-week %"),
25561
+ "value": _lib_share.TextCell(projected_text),
25562
+ }),
25563
+ _lib_share.Row(cells={
25564
+ "metric": _lib_share.TextCell("Days remaining"),
25565
+ "value": _lib_share.TextCell(f"{days_remaining:.1f}"),
25566
+ }),
25567
+ _lib_share.Row(cells={
25568
+ "metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
25569
+ "value": _lib_share.MoneyCell(float(dollars_per_percent)),
25570
+ }),
25571
+ )
25572
+ # Caller-provided `notes` (e.g., empty-data path's clearer message)
25573
+ # take precedence over the auto LOW CONF banner. Sibling builders
25574
+ # don't expose this knob; forecast does because its empty-state and
25575
+ # thin-confidence states need different copy.
25576
+ final_notes = notes if notes else (
25577
+ ("LOW CONF — data thin",) if low_conf else ()
25578
+ )
25579
+ if actual_pts:
25580
+ title = f"Forecast — week of {week_start.strftime('%b %d')}"
25581
+ else:
25582
+ title = "Forecast — no data"
25583
+ # Reuse the shared period-label helper so forecast's subtitle period
25584
+ # format matches sibling builders (cmd_daily / cmd_project / etc.).
25585
+ period_label = _share_period_label(week_start, week_end, display_tz)
25586
+ subtitle = " · ".join([
25587
+ period_label,
25588
+ theme,
25589
+ "real projects" if reveal_projects else "projects anonymized",
25590
+ ])
25591
+ return _lib_share.ShareSnapshot(
25592
+ cmd="forecast",
25593
+ title=title,
25594
+ subtitle=subtitle,
25595
+ period=_lib_share.PeriodSpec(
25596
+ start=week_start, end=week_end,
25597
+ display_tz=display_tz, label=period_label,
25598
+ ),
25599
+ columns=columns, rows=snap_rows,
25600
+ chart=chart, totals=(), notes=final_notes,
25601
+ generated_at=_share_now_utc(), version=version,
25602
+ )
25603
+
25604
+
25605
+ def _build_project_snapshot(
25606
+ rows: list[dict],
25607
+ *,
25608
+ period_start: dt.datetime,
25609
+ period_end: dt.datetime,
25610
+ display_tz: str,
25611
+ version: str,
25612
+ theme: str,
25613
+ reveal_projects: bool,
25614
+ ) -> "ShareSnapshot":
25615
+ """Build a ShareSnapshot for `cctally project`.
25616
+
25617
+ `rows` is the in-memory per-project aggregate list produced inside
25618
+ `cmd_project` (`project_rows.values()` post-sort). Each row is a
25619
+ dict with: `key` (a `ProjectKey` carrying `display_key` /
25620
+ `bucket_path`), `cost_usd`, `attributed_pct` (`float | None`),
25621
+ `sessions` (a `set` of session-IDs).
25622
+
25623
+ Privacy invariant (Section 8.4 / Section 5.3): the builder populates
25624
+ `ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
25625
+ which is the project axis on a HorizontalBarChart) with the REAL
25626
+ `display_key`. The `_share_render_and_emit` wrapper then runs
25627
+ `_lib_share._scrub` BEFORE rendering — that's the single chokepoint
25628
+ that rewrites every project label to `project-1` / `project-2` /
25629
+ ... unless `--reveal-projects` is passed. The Section 8.4 canary
25630
+ test (`test_anonymized_output_contains_zero_original_tokens`) and
25631
+ the wrapper-level regression
25632
+ (`test_share_render_and_emit_scrubs_project_labels`) both anchor
25633
+ this contract.
25634
+
25635
+ Deviations from the plan sketch (which assumed dict rows with keys
25636
+ `project` / `cost_usd` / `used_pct` / `sessions`):
25637
+
25638
+ - Rows are dicts whose `key` field is a `ProjectKey` dataclass; the
25639
+ project label comes from `key.display_key`. The plan's `project`
25640
+ key does not exist on the actual `cmd_project` data shape.
25641
+ - `attributed_pct` may be `None` for projects whose contributing
25642
+ weeks all lacked a `weekly_usage_snapshots` row; the table renders
25643
+ that as em-dash (parity with terminal `_render_project_table`).
25644
+ - Sessions is a `set`; the cell carries its `len(...)` as text.
25645
+
25646
+ `HorizontalBarChart.cap=12` matches the plan; when more than 12
25647
+ projects exist, a note clarifies that the table includes all rows
25648
+ while the chart shows only the top 12 by cost.
25649
+
25650
+ Caller MUST pass `rows` already sorted in the desired order
25651
+ (cmd_project honors `--sort` / `--order` upstream). The builder
25652
+ preserves caller order for the table — terminal / JSON / share
25653
+ artifacts all show the same row ordering. Internally the builder
25654
+ ALSO computes a descending-cost copy that drives the HBar chart
25655
+ and the basename-disambiguation rank (both must match
25656
+ `_build_anon_mapping`'s descending-cost sort so `project-1` stays
25657
+ glued to the highest-cost bar regardless of `--sort`). Anonymization
25658
+ is row-identity based (`id(r)` → augmented label), not position
25659
+ based, so the table sees the same disambiguated label as the chart.
25660
+
25661
+ `theme` and `reveal_projects` flow into the subtitle directly so the
25662
+ builder owns the canonical subtitle shape — no post-build re-stamp
25663
+ at the gate site.
25664
+ """
25665
+ _lib_share = _share_load_lib()
25666
+ columns = (
25667
+ _lib_share.ColumnSpec(key="project", label="Project", align="left"),
25668
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
25669
+ emphasis=True),
25670
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
25671
+ _lib_share.ColumnSpec(key="sessions", label="Sessions", align="right"),
25672
+ )
25673
+ # Two orderings — same rows, different consumers:
25674
+ #
25675
+ # * `rows` (caller order) drives the table. `cmd_project` upstream
25676
+ # has already applied `--sort` / `--order`, so the share artifact's
25677
+ # table matches terminal / JSON output for any of `--sort cost`,
25678
+ # `--sort name`, `--sort sessions` × `--order asc|desc`.
25679
+ #
25680
+ # * `cost_sorted_rows` (descending cost) drives the HBar chart and
25681
+ # the basename-disambiguation rank — both must align with
25682
+ # `_build_anon_mapping`'s descending-cost sort so `project-1` stays
25683
+ # glued to the highest-cost bar regardless of `--sort` choice.
25684
+ cost_sorted_rows = sorted(
25685
+ rows, key=lambda r: -float(r.get("cost_usd") or 0.0)
25686
+ )
25687
+ # Basename-collision disambiguation: mirrors `_render_project_table`'s
25688
+ # terminal logic. Computed on cost_sorted_rows; mapped back to row
25689
+ # identity so the caller-ordered table picks up the same augmented
25690
+ # label as the chart (anonymization is row-identity based, not
25691
+ # position based). Without disambiguation, two `app` projects under
25692
+ # different parent dirs collapse to ONE anonymous `project-N` after
25693
+ # scrub — losing both privacy uniqueness and chart rank meaning.
25694
+ augmented_by_index = _project_disambiguate_labels(cost_sorted_rows)
25695
+ augmented_by_row_id: dict[int, str] = {
25696
+ id(cost_sorted_rows[idx]): label
25697
+ for idx, label in augmented_by_index.items()
25698
+ }
25699
+
25700
+ def _proj_label_for(r: dict) -> str:
25701
+ bare = getattr(r.get("key"), "display_key", None) or "(unknown)"
25702
+ return augmented_by_row_id.get(id(r), bare)
25703
+
25704
+ # Table rows in CALLER order (--sort / --order parity).
25705
+ snap_rows: list = []
25706
+ for r in rows:
25707
+ proj_label = _proj_label_for(r)
25708
+ cost = float(r.get("cost_usd") or 0.0)
25709
+ attr_pct = r.get("attributed_pct")
25710
+ sessions = r.get("sessions")
25711
+ sessions_count = len(sessions) if sessions is not None else 0
25712
+ snap_rows.append(_lib_share.Row(cells={
25713
+ "project": _lib_share.ProjectCell(proj_label),
25714
+ "cost": _lib_share.MoneyCell(cost),
25715
+ # Preserve None vs 0.0 — terminal renders missing as em-dash.
25716
+ # Coercing None -> 0.0 would conflate "no usage snapshot for
25717
+ # any week this project touched" with "0% attributed."
25718
+ "used": (
25719
+ _lib_share.PercentCell(float(attr_pct))
25720
+ if attr_pct is not None else _lib_share.TextCell("—")
25721
+ ),
25722
+ "sessions": _lib_share.TextCell(str(sessions_count)),
25723
+ }))
25724
+
25725
+ # Chart points in COST-SORTED order (HBar shows top-N by cost).
25726
+ chart_pts: list = []
25727
+ for r in cost_sorted_rows:
25728
+ proj_label = _proj_label_for(r)
25729
+ cost = float(r.get("cost_usd") or 0.0)
25730
+ chart_pts.append(_lib_share.ChartPoint(
25731
+ x_label=proj_label,
25732
+ x_value=cost,
25733
+ y_value=cost,
25734
+ project_label=proj_label,
25735
+ ))
25736
+ chart = (
25737
+ _lib_share.HorizontalBarChart(
25738
+ points=tuple(chart_pts), x_label="$", cap=12,
25739
+ )
25740
+ if chart_pts else None
25741
+ )
25742
+ notes: tuple[str, ...] = ()
25743
+ if chart is not None and len(chart_pts) > 12:
25744
+ notes = (
25745
+ f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
25746
+ )
25747
+ sum_cost = sum(p.y_value for p in chart_pts)
25748
+ totals = (
25749
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
25750
+ _lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
25751
+ )
25752
+ if rows:
25753
+ title = (
25754
+ f"Per-project usage — {period_start.strftime('%b %d')} → "
25755
+ f"{period_end.strftime('%b %d')}"
25756
+ )
25757
+ else:
25758
+ title = "Per-project usage — no data"
25759
+ period_label = _share_period_label(period_start, period_end, display_tz)
25760
+ subtitle = " · ".join([
25761
+ period_label,
25762
+ theme,
25763
+ "real projects" if reveal_projects else "projects anonymized",
25764
+ ])
25765
+ return _lib_share.ShareSnapshot(
25766
+ cmd="project",
25767
+ title=title,
25768
+ subtitle=subtitle,
25769
+ period=_lib_share.PeriodSpec(
25770
+ start=period_start, end=period_end,
25771
+ display_tz=display_tz, label=period_label,
25772
+ ),
25773
+ columns=columns, rows=tuple(snap_rows),
25774
+ chart=chart, totals=totals, notes=notes,
25775
+ generated_at=_share_now_utc(), version=version,
25776
+ )
25777
+
25778
+
25779
+ def _build_five_hour_blocks_snapshot(
25780
+ rows: list[dict],
25781
+ *,
25782
+ period_start: dt.datetime,
25783
+ period_end: dt.datetime,
25784
+ display_tz: str,
25785
+ version: str,
25786
+ theme: str,
25787
+ reveal_projects: bool,
25788
+ tz: "ZoneInfo | None",
25789
+ ) -> "ShareSnapshot":
25790
+ """Build a ShareSnapshot for `cctally five-hour-blocks`.
25791
+
25792
+ `rows` is the list of per-block dicts produced inside
25793
+ `cmd_five_hour_blocks` (sqlite Row converted to dict, with the
25794
+ `__is_active` side-channel attached). Schema fields used:
25795
+ `block_start_at` (ISO timestamp), `total_cost_usd`,
25796
+ `final_five_hour_percent`, `crossed_seven_day_reset` (0/1 int),
25797
+ `seven_day_pct_at_block_start`, `seven_day_pct_at_block_end`, plus
25798
+ the synthetic `__is_active` flag.
25799
+
25800
+ Deviations from the plan sketch (which assumed dict rows with keys
25801
+ `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
25802
+ `cross_reset`):
25803
+
25804
+ - Rows are sqlite-Row-derived dicts with snake_case schema column
25805
+ names — `block_start_at`, `total_cost_usd`,
25806
+ `final_five_hour_percent`, `crossed_seven_day_reset`. The plan
25807
+ keys `block_start` / `cost_usd` / `used_pct_5h` / `cross_reset`
25808
+ do not exist on the actual data shape.
25809
+ - `top_model` does not live on the `five_hour_blocks` row at all;
25810
+ `_load_breakdown` would have to be invoked per-block to derive
25811
+ it. Per share-spec convention (matches cmd_daily / cmd_monthly),
25812
+ the `--breakdown` flag is a no-op under `--format` and the
25813
+ headline snapshot omits the per-model "top model" column.
25814
+ - `crossed_seven_day_reset` is an INTEGER 0/1 (sqlite); coerce to
25815
+ `bool` for cell formatting.
25816
+
25817
+ Cross-reset markers (spec §6.5):
25818
+ - `chart_pts` — `▲` (U+25B2) prefix in `x_label` so the SVG
25819
+ x-axis label visually flags the crossed-reset blocks.
25820
+ - `snap_rows` — `⚡` (U+26A1) glyph in the `cross_reset` cell
25821
+ text so the markdown / HTML table cell carries the same
25822
+ signal. The two glyphs are distinct (triangle for chart axis,
25823
+ bolt for table cell) so the legend reads correctly in either
25824
+ surface.
25825
+
25826
+ `theme` and `reveal_projects` flow into the subtitle directly so
25827
+ the builder owns the canonical subtitle shape — no post-build
25828
+ re-stamp at the gate site.
25829
+
25830
+ Caller MUST pass `rows` already in the desired chronological order
25831
+ (cmd_five_hour_blocks pulls newest-first; we reverse here so the
25832
+ BarChart bars line up oldest→newest left-to-right). Tabular row
25833
+ order in the snapshot is irrelevant because the snapshot is what
25834
+ gets rendered (the gate site short-circuits the table renderer).
25835
+ """
25836
+ _lib_share = _share_load_lib()
25837
+ columns = (
25838
+ _lib_share.ColumnSpec(key="block_start", label="Block Start",
25839
+ align="left"),
25840
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
25841
+ emphasis=True),
25842
+ _lib_share.ColumnSpec(key="used_pct", label="5h %",
25843
+ align="right"),
25844
+ _lib_share.ColumnSpec(key="cross_reset", label="Reset",
25845
+ align="left"),
25846
+ )
25847
+ # Reverse so BarChart x-axis runs oldest→newest (cmd_five_hour_blocks
25848
+ # produces newest-first DESC); table-row order in the snapshot tracks
25849
+ # chart order so consumer expectations align.
25850
+ chrono_rows = list(reversed(rows))
25851
+ snap_rows: list = []
25852
+ chart_pts: list = []
25853
+ for i, r in enumerate(chrono_rows):
25854
+ block_iso = r.get("block_start_at") or ""
25855
+ # Compact label respecting --tz; previously hard-coded to UTC
25856
+ # (parsed.strftime renders the wall-clock IN the parsed tz, and
25857
+ # `parsed` is tz-aware UTC after fromisoformat). UTC-vs-display_tz
25858
+ # is orthogonal to the SVG x-axis width budget — both render at
25859
+ # the same character count. Route through `format_display_dt`
25860
+ # with suffix=False to satisfy the chokepoint rule while keeping
25861
+ # the bar label compact (the subtitle's period_label already
25862
+ # carries the active tz).
25863
+ try:
25864
+ parsed = dt.datetime.fromisoformat(
25865
+ block_iso.replace("Z", "+00:00")
25866
+ )
25867
+ block_lbl = format_display_dt(
25868
+ parsed, tz, fmt="%b %d %H:%M", suffix=False,
25869
+ )
25870
+ except (ValueError, AttributeError):
25871
+ block_lbl = str(block_iso)
25872
+ cost_usd = float(r.get("total_cost_usd") or 0.0)
25873
+ used_pct = float(r.get("final_five_hour_percent") or 0.0)
25874
+ crossed = bool(r.get("crossed_seven_day_reset"))
25875
+ cell_text = "⚡" if crossed else "—"
25876
+ snap_rows.append(_lib_share.Row(cells={
25877
+ "block_start": _lib_share.TextCell(block_lbl),
25878
+ "cost": _lib_share.MoneyCell(cost_usd),
25879
+ "used_pct": _lib_share.PercentCell(used_pct),
25880
+ "cross_reset": _lib_share.TextCell(cell_text),
25881
+ }))
25882
+ x_label = f"▲ {block_lbl}" if crossed else block_lbl
25883
+ chart_pts.append(_lib_share.ChartPoint(
25884
+ x_label=x_label,
25885
+ x_value=float(i),
25886
+ y_value=cost_usd,
25887
+ ))
25888
+ chart = (
25889
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
25890
+ if chart_pts else None
25891
+ )
25892
+ sum_cost = sum(p.y_value for p in chart_pts)
25893
+ avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
25894
+ crossed_count = sum(
25895
+ 1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
25896
+ )
25897
+ totals_list = [
25898
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
25899
+ _lib_share.Totalled(label="Blocks", value=str(len(chart_pts))),
25900
+ _lib_share.Totalled(label="Avg / block", value=f"${avg_cost:,.2f}"),
25901
+ ]
25902
+ if crossed_count:
25903
+ totals_list.append(_lib_share.Totalled(
25904
+ label="Crossed reset", value=str(crossed_count),
25905
+ ))
25906
+ totals = tuple(totals_list)
25907
+ notes: tuple[str, ...] = ()
25908
+ if crossed_count:
25909
+ notes = (
25910
+ "▲ / ⚡ marks blocks that crossed the weekly reset boundary.",
25911
+ )
25912
+ if rows:
25913
+ title = f"5-hour blocks — last {len(rows)} blocks"
25914
+ else:
25915
+ title = "5-hour blocks — no data"
25916
+ period_label = _share_period_label(period_start, period_end, display_tz)
25917
+ subtitle = " · ".join([
25918
+ period_label,
25919
+ theme,
25920
+ "real projects" if reveal_projects else "projects anonymized",
25921
+ ])
25922
+ return _lib_share.ShareSnapshot(
25923
+ cmd="five-hour-blocks",
25924
+ title=title,
25925
+ subtitle=subtitle,
25926
+ period=_lib_share.PeriodSpec(
25927
+ start=period_start, end=period_end,
25928
+ display_tz=display_tz, label=period_label,
25929
+ ),
25930
+ columns=columns, rows=tuple(snap_rows),
25931
+ chart=chart, totals=totals, notes=notes,
25932
+ generated_at=_share_now_utc(), version=version,
25933
+ )
25934
+
25935
+
25936
+ def _session_disambiguate_labels(
25937
+ sessions: list["ClaudeSessionUsage"],
25938
+ ) -> dict[int, str]:
25939
+ """Return ``{session_index: disambiguated_label}`` for sessions whose
25940
+ bare ``project_path`` basename collides with another session's.
25941
+
25942
+ Session-specific sibling of ``_project_disambiguate_labels`` (which
25943
+ operates over project rollup rows whose `key` is a ``ProjectKey``).
25944
+ Sessions carry only a `project_path` string — we derive the
25945
+ basename, count collisions, and append a parent-dir suffix
25946
+ ``" (parent)"`` to colliding rows so the post-scrub anonymization
25947
+ still produces unique anonymous labels (otherwise two `app/`
25948
+ sessions under different parents collapse to a single
25949
+ ``project-N``, breaking both privacy uniqueness and the chart's
25950
+ visual rank meaning).
25951
+
25952
+ Sessions without collisions are absent from the returned dict;
25953
+ callers fall back to the bare basename.
25954
+ """
25955
+ basenames: list[str] = []
25956
+ for s in sessions:
25957
+ path = s.project_path or ""
25958
+ basenames.append(os.path.basename(path) or path or "(unknown)")
25959
+ counts: dict[str, int] = {}
25960
+ for bn in basenames:
25961
+ counts[bn] = counts.get(bn, 0) + 1
25962
+ augmented: dict[int, str] = {}
25963
+ for idx, s in enumerate(sessions):
25964
+ bn = basenames[idx]
25965
+ # Skip suffixing the literal "(unknown)" bare label even on
25966
+ # collision: `_build_anon_mapping` literal-passthrough-protects
25967
+ # exact "(unknown)" only — a suffixed form like "(unknown) (/)"
25968
+ # would be mapped to a regular `project-N` slot, losing the
25969
+ # (unknown) semantic in the anonymized output.
25970
+ if counts[bn] > 1 and bn != "(unknown)":
25971
+ path = s.project_path or ""
25972
+ parent = os.path.basename(os.path.dirname(path)) or "/"
25973
+ augmented[idx] = f"{bn} ({parent})"
25974
+ return augmented
25975
+
25976
+
25977
+ def _build_session_snapshot(
25978
+ sessions: list["ClaudeSessionUsage"],
25979
+ *,
25980
+ period_start: dt.datetime,
25981
+ period_end: dt.datetime,
25982
+ display_tz: str,
25983
+ version: str,
25984
+ theme: str,
25985
+ reveal_projects: bool,
25986
+ top_n: int | None,
25987
+ tz: "ZoneInfo | None",
25988
+ ) -> "ShareSnapshot":
25989
+ """Build a ShareSnapshot for `cctally session`.
25990
+
25991
+ `sessions` is the in-memory `ClaudeSessionUsage` list produced by
25992
+ `_aggregate_claude_sessions` inside `cmd_session`. Each session has:
25993
+ `session_id` (UUID), `project_path` (filesystem path), `cost_usd`,
25994
+ `last_activity` (`dt.datetime`), `models` (first-seen-order
25995
+ `list[str]`), and the token aggregates.
25996
+
25997
+ Privacy invariant (Section 8.4 / Section 5.3): the builder populates
25998
+ `ProjectCell.label`, `ChartPoint.project_label`, and
25999
+ `ChartPoint.x_label` with the REAL `project_path` basename. The
26000
+ `_share_render_and_emit` wrapper runs `_lib_share._scrub` BEFORE
26001
+ rendering — that's the single chokepoint that rewrites every
26002
+ project label to `project-1` / `project-2` / ... unless
26003
+ `--reveal-projects` is passed.
26004
+
26005
+ Deviations from the plan sketch (which assumed dict rows with keys
26006
+ `session_id` / `started_at` / `project_path` / `cost_usd` /
26007
+ `models`):
26008
+
26009
+ - Sessions are `ClaudeSessionUsage` dataclasses; we read fields by
26010
+ attribute. `last_activity` is the canonical timestamp (no
26011
+ `started_at` field — sessions span a window via
26012
+ `first_activity` → `last_activity`).
26013
+ - The `project_path` column's basename can collide across two
26014
+ different parent dirs. We use the session-specific
26015
+ `_session_disambiguate_labels` helper (sibling of
26016
+ `_project_disambiguate_labels`, which expects `ProjectKey` rows
26017
+ not present on session data) to suffix `" (parent)"` on
26018
+ collisions before the scrubber runs.
26019
+
26020
+ Caller MUST pass `sessions` already sorted in the desired order;
26021
+ the builder re-sorts internally by descending cost so the chart's
26022
+ HBar bars rank consistently with the anonymization-mapping
26023
+ (`_build_anon_mapping` also sorts by descending cost) — keeping
26024
+ `project-1` aligned with the highest-cost bar in the chart even
26025
+ when the user asked for `--order asc`.
26026
+
26027
+ `top_n`, when set (must be `>= 1`; caller validates), truncates
26028
+ BOTH the table rows and the chart points to the top-N by cost.
26029
+ The title shifts to `"Top N sessions"` whenever `top_n` actually
26030
+ truncated (so users know rows were dropped). When more rows exist
26031
+ than the chart cap (15) but `top_n` is None or `>= len(sessions)`,
26032
+ the table includes all rows while the chart shows the top 15 by
26033
+ cost (a note clarifies).
26034
+
26035
+ `theme` and `reveal_projects` flow into the subtitle directly so
26036
+ the builder owns the canonical subtitle shape — no post-build
26037
+ re-stamp at the gate site.
26038
+ """
26039
+ _lib_share = _share_load_lib()
26040
+ columns = (
26041
+ _lib_share.ColumnSpec(key="session", label="Session", align="left"),
26042
+ _lib_share.ColumnSpec(key="project", label="Project", align="left"),
26043
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
26044
+ emphasis=True),
26045
+ _lib_share.ColumnSpec(key="last_activity", label="Last Activity",
26046
+ align="left"),
26047
+ _lib_share.ColumnSpec(key="models", label="Models", align="left"),
26048
+ )
26049
+ # Sort by descending cost so the snapshot's chart-order matches the
26050
+ # `_build_anon_mapping` sort key (also descending cost).
26051
+ sorted_sessions = sorted(
26052
+ sessions, key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0)
26053
+ )
26054
+ # Apply --top-n truncation (caller validated >= 1). Truncation status
26055
+ # gates the title shape below.
26056
+ truncated = (
26057
+ top_n is not None and top_n < len(sorted_sessions)
26058
+ )
26059
+ if top_n is not None:
26060
+ sorted_sessions = sorted_sessions[:top_n]
26061
+ # Basename-collision disambiguation: session-specific sibling of
26062
+ # `_project_disambiguate_labels`. Without this, two `app/` sessions
26063
+ # under different parents collapse to a single `project-N` after
26064
+ # scrub — losing both privacy uniqueness and chart rank meaning.
26065
+ augmented = _session_disambiguate_labels(sorted_sessions)
26066
+ snap_rows: list = []
26067
+ chart_pts: list = []
26068
+ for idx, s in enumerate(sorted_sessions):
26069
+ bare_label = (
26070
+ os.path.basename(s.project_path or "")
26071
+ or s.project_path
26072
+ or "(unknown)"
26073
+ )
26074
+ proj_label = augmented.get(idx, bare_label)
26075
+ cost_usd = float(getattr(s, "cost_usd", 0.0) or 0.0)
26076
+ sid_short = (s.session_id[:8] if s.session_id else "—") or "—"
26077
+ # Datetime chokepoint rule: route human-displayed timestamps
26078
+ # through `format_display_dt` so `--tz` is honored (was
26079
+ # `.astimezone()` which used host-local regardless of `--tz`).
26080
+ # `suffix=False` keeps the cell width tight — the subtitle's
26081
+ # period_label already carries the active tz.
26082
+ last_str = format_display_dt(
26083
+ s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
26084
+ )
26085
+ models_text = ", ".join(s.models) if s.models else "—"
26086
+ snap_rows.append(_lib_share.Row(cells={
26087
+ "session": _lib_share.TextCell(sid_short),
26088
+ "project": _lib_share.ProjectCell(proj_label),
26089
+ "cost": _lib_share.MoneyCell(cost_usd),
26090
+ "last_activity": _lib_share.TextCell(last_str),
26091
+ "models": _lib_share.TextCell(models_text),
26092
+ }))
26093
+ chart_pts.append(_lib_share.ChartPoint(
26094
+ x_label=proj_label,
26095
+ x_value=cost_usd,
26096
+ y_value=cost_usd,
26097
+ project_label=proj_label,
26098
+ ))
26099
+ chart = (
26100
+ _lib_share.HorizontalBarChart(
26101
+ points=tuple(chart_pts), x_label="$", cap=15,
26102
+ )
26103
+ if chart_pts else None
26104
+ )
26105
+ notes: tuple[str, ...] = ()
26106
+ if chart is not None and len(chart_pts) > 15:
26107
+ notes = (
26108
+ f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
26109
+ )
26110
+ sum_cost = sum(p.y_value for p in chart_pts)
26111
+ totals = (
26112
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
26113
+ _lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
26114
+ )
26115
+ if sorted_sessions:
26116
+ if truncated:
26117
+ title = f"Top {len(snap_rows)} sessions"
26118
+ else:
26119
+ title = (
26120
+ f"Sessions — {period_start.strftime('%b %d')} → "
26121
+ f"{period_end.strftime('%b %d')}"
26122
+ )
26123
+ else:
26124
+ title = "Sessions — no data"
26125
+ period_label = _share_period_label(period_start, period_end, display_tz)
26126
+ subtitle = " · ".join([
26127
+ period_label,
26128
+ theme,
26129
+ "real projects" if reveal_projects else "projects anonymized",
26130
+ ])
26131
+ return _lib_share.ShareSnapshot(
26132
+ cmd="session",
26133
+ title=title,
26134
+ subtitle=subtitle,
26135
+ period=_lib_share.PeriodSpec(
26136
+ start=period_start, end=period_end,
26137
+ display_tz=display_tz, label=period_label,
26138
+ ),
26139
+ columns=columns, rows=tuple(snap_rows),
26140
+ chart=chart, totals=totals, notes=notes,
26141
+ generated_at=_share_now_utc(), version=version,
26142
+ )
26143
+
26144
+
26145
+ def _share_render_and_emit(snap, args) -> None:
26146
+ """End-to-end: scrub -> render -> emit -> optional open.
26147
+
26148
+ Lazy-imports `_lib_share` so non-share invocations don't pay the import
26149
+ cost. The kernel module stays I/O-pure; this wrapper does all the
26150
+ side-effecting glue (destination resolution, file writes, clipboard,
26151
+ post-write `--open` launch).
26152
+
26153
+ Caller contract: ``args.format`` MUST be set ("md", "html", or "svg").
26154
+ The wrapper raises ValueError if called without it — surfaces the
26155
+ contract failure at the chokepoint instead of producing junk filenames
26156
+ like ``cctally-daily-<date>.None``.
26157
+ """
26158
+ if args.format is None:
26159
+ raise ValueError("_share_render_and_emit called without args.format")
26160
+ if args.open_after_write and args.format == "md":
26161
+ # Spec Section 4.4: --open is only meaningful for html/svg writes.
26162
+ # Reject explicitly with exit 2 instead of silently no-opping (which
26163
+ # the prior implementation did because the open-after-write branch
26164
+ # gates on ``kind == "file"``, and md routes to stdout by default).
26165
+ print(
26166
+ "cctally: --open is only valid with --format html or --format svg",
26167
+ file=sys.stderr,
26168
+ )
26169
+ sys.exit(2)
26170
+ # Routed through `_share_load_lib` so wrapper / builders / test harness
26171
+ # share one cached module object — see helper docstring for the
26172
+ # class-identity invariant this enforces.
26173
+ _lib_share = _share_load_lib()
26174
+
26175
+ scrubbed = _lib_share._scrub(snap, reveal_projects=args.reveal_projects)
26176
+ rendered = _lib_share.render(
26177
+ scrubbed,
26178
+ format=args.format,
26179
+ theme=args.theme,
26180
+ branding=not args.no_branding,
26181
+ )
26182
+
26183
+ utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
26184
+ kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
26185
+ _emit(rendered, kind=kind, value=value)
26186
+
26187
+ if args.open_after_write and kind == "file":
26188
+ _share_open_file(pathlib.Path(value))
26189
+
26190
+
26191
+ def _share_open_file(path: pathlib.Path) -> None:
26192
+ """Run `open` (macOS) / `xdg-open` (Linux). Silent fail if launcher missing."""
26193
+ for launcher in ("open", "xdg-open"):
26194
+ if shutil.which(launcher):
26195
+ subprocess.Popen(
26196
+ [launcher, str(path)],
26197
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
26198
+ )
26199
+ return
26200
+ sys.stderr.write("cctally: --open requires `open` or `xdg-open` on PATH; skipped\n")
26201
+
26202
+
23831
26203
  # ============================================================
23832
26204
  # ==== TUI ==== =
23833
26205
  # ============================================================
@@ -26276,13 +28648,22 @@ def _select_current_block_for_envelope(
26276
28648
  envelope convention (``current_week`` is snake_case; CLI ``--json`` is
26277
28649
  camelCase — separate conventions, see CLAUDE.md).
26278
28650
 
26279
- Delta is suppressed (``None``) when ``crossed_seven_day_reset == 1`` OR
26280
- when ``seven_day_pct_at_block_start IS NULL`` OR when
26281
- ``current_used_pct`` is None.
28651
+ Delta semantics:
28652
+ - Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
28653
+ (the natural "how much 7d% has changed during this 5h block" read).
28654
+ - Crossed block (``crossed_seven_day_reset == 1``): the block straddles
28655
+ a weekly reset, so the natural delta would be dominated by the
28656
+ reset itself (e.g. −94pp) rather than the user's actual burn-rate.
28657
+ Compute the POST-RESET delta instead — ``current_used_pct -
28658
+ weekly_percent_at_first_post_reset_snapshot_in_block``. The React
28659
+ panel prefixes this delta with ``⚡`` to show the reset crossing
28660
+ without hiding the informative number.
28661
+ - ``None`` only when ``current_used_pct`` is unknown OR the
28662
+ block-start anchor is missing AND no post-reset anchor was found.
26282
28663
  """
26283
28664
  snap = conn.execute(
26284
28665
  """
26285
- SELECT five_hour_window_key
28666
+ SELECT five_hour_window_key, week_start_at
26286
28667
  FROM weekly_usage_snapshots
26287
28668
  WHERE captured_at_utc <= ?
26288
28669
  ORDER BY captured_at_utc DESC, id DESC
@@ -26295,7 +28676,8 @@ def _select_current_block_for_envelope(
26295
28676
 
26296
28677
  block = conn.execute(
26297
28678
  """
26298
- SELECT block_start_at, seven_day_pct_at_block_start,
28679
+ SELECT block_start_at, last_observed_at_utc,
28680
+ seven_day_pct_at_block_start,
26299
28681
  crossed_seven_day_reset
26300
28682
  FROM five_hour_blocks
26301
28683
  WHERE five_hour_window_key = ?
@@ -26309,9 +28691,39 @@ def _select_current_block_for_envelope(
26309
28691
 
26310
28692
  crossed = bool(block["crossed_seven_day_reset"])
26311
28693
  p_start = block["seven_day_pct_at_block_start"]
28694
+
28695
+ # When the block crossed a weekly reset, recompute the delta against
28696
+ # the first post-reset snapshot inside the block instead of the
28697
+ # pre-reset block-start anchor. Use ``unixepoch()`` because
28698
+ # ``block_start_at`` is host-local-tz (``+03:00``) while
28699
+ # ``captured_at_utc`` is canonical UTC-Z; lex compares mis-order
28700
+ # mixed-offset moments. ``snap.week_start_at`` is the latest
28701
+ # (post-reset) week's anchor, so equality on that column scopes
28702
+ # the lookup to the current weekly window.
28703
+ p_anchor = p_start
28704
+ if crossed and snap["week_start_at"] is not None:
28705
+ post = conn.execute(
28706
+ """
28707
+ SELECT weekly_percent
28708
+ FROM weekly_usage_snapshots
28709
+ WHERE week_start_at = ?
28710
+ AND unixepoch(captured_at_utc) >= unixepoch(?)
28711
+ AND unixepoch(captured_at_utc) <= unixepoch(?)
28712
+ ORDER BY captured_at_utc ASC, id ASC
28713
+ LIMIT 1
28714
+ """,
28715
+ (
28716
+ snap["week_start_at"],
28717
+ block["block_start_at"],
28718
+ _iso_z(now_utc),
28719
+ ),
28720
+ ).fetchone()
28721
+ if post is not None and post["weekly_percent"] is not None:
28722
+ p_anchor = float(post["weekly_percent"])
28723
+
26312
28724
  delta = (
26313
- None if (crossed or p_start is None or current_used_pct is None)
26314
- else round(current_used_pct - p_start, 9)
28725
+ None if (p_anchor is None or current_used_pct is None)
28726
+ else round(current_used_pct - p_anchor, 9)
26315
28727
  )
26316
28728
  return {
26317
28729
  "block_start_at": block["block_start_at"],