cctally 1.8.1 → 1.9.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
@@ -43,19 +43,6 @@ if sys.version_info < __min_python_version__:
43
43
  if __name__ == "__main__":
44
44
  sys.modules.setdefault("cctally", sys.modules["__main__"])
45
45
 
46
- # Bind `cctally` as a local alias for sibling-back-reference patterns within
47
- # this module. STAYING release helpers (e.g. `_release_run_phase_stamp`) call
48
- # MOVED helpers (e.g. `_release_stamp_changelog` in `_cctally_release.py`)
49
- # via `cctally.<name>(...)`. Attribute access on the module object triggers
50
- # PEP 562 `__getattr__`, which lazy-loads `_cctally_release` and splices the
51
- # whole symbol set into this module's globals on first hit. Subsequent
52
- # accesses bypass `__getattr__` (normal dict lookup wins). Spec §5.5.
53
- #
54
- # Falls back to the current module if `cctally` isn't yet registered (e.g.
55
- # under unusual loaders); the binding is then a self-reference, which is
56
- # harmless because the same lookup-then-PEP562 chain applies.
57
- cctally = sys.modules.get("cctally") or sys.modules[__name__]
58
-
59
46
  import argparse
60
47
  import bisect
61
48
  import contextlib
@@ -170,7 +157,6 @@ SETUP_SYMLINK_NAMES = (
170
157
  "cctally-forecast",
171
158
  "cctally-project",
172
159
  "cctally-refresh-usage",
173
- "cctally-release",
174
160
  "cctally-sync-week",
175
161
  "cctally-tui",
176
162
  "cctally-update",
@@ -202,12 +188,10 @@ PUBLIC_REPO = "omrikais/cctally"
202
188
 
203
189
  # === Eager-loaded library siblings ===
204
190
  #
205
- # Re-export pure-fn primitives from _lib_*.py so test/harness code that
206
- # reaches into cctally._release_parse_semver, cctally._SEMVER_RE, etc.
207
- # continues to work via the same attribute path. The actual definitions
208
- # live in the sibling modules; bin/cctally's namespace simply re-aliases
209
- # them. Eager because these are touched by code defined later in this
210
- # file (e.g. RELEASE_HEADER_RE uses _SEMVER_NUM).
191
+ # Re-export pure-fn primitives from _lib_*.py so code defined later in
192
+ # this file (e.g. RELEASE_HEADER_RE uses _SEMVER_NUM) can reach them via
193
+ # the cctally module namespace. The actual definitions live in the
194
+ # sibling modules; bin/cctally's namespace simply re-aliases them.
211
195
  #
212
196
  # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4
213
197
 
@@ -220,8 +204,6 @@ _cctally_core = _load_sibling("_cctally_core")
220
204
  # Eager re-exports for the 24 kernel symbols. Preserves
221
205
  # `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
222
206
  # paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
223
- # Don't add to PEP 562 __getattr__ registry — eager binding is the
224
- # whole point.
225
207
  eprint = _cctally_core.eprint
226
208
  now_utc_iso = _cctally_core.now_utc_iso
227
209
  parse_iso_datetime = _cctally_core.parse_iso_datetime
@@ -251,10 +233,6 @@ get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
251
233
  _lib_semver = _load_sibling("_lib_semver")
252
234
  _SEMVER_NUM = _lib_semver._SEMVER_NUM
253
235
  _SEMVER_RE = _lib_semver._SEMVER_RE
254
- _release_parse_semver = _lib_semver._release_parse_semver
255
- _release_format_semver = _lib_semver._release_format_semver
256
- _release_compute_next_version = _lib_semver._release_compute_next_version
257
- _release_semver_sort_key = _lib_semver._release_semver_sort_key
258
236
 
259
237
  _lib_pricing = _load_sibling("_lib_pricing")
260
238
  TIERED_THRESHOLD = _lib_pricing.TIERED_THRESHOLD
@@ -329,6 +307,17 @@ _aggregate_block = _lib_blocks._aggregate_block
329
307
  _build_activity_block = _lib_blocks._build_activity_block
330
308
  _blocks_to_json = _lib_blocks._blocks_to_json
331
309
 
310
+ _lib_changelog = _load_sibling("_lib_changelog")
311
+ # Backward-compat re-export: callers and tests reach the helper through
312
+ # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
313
+ # sites in tests/test_release_internals.py + tests/test_update.py and
314
+ # the ``_cctally()._release_read_latest_release_version()`` call in
315
+ # bin/_cctally_release.py). The canonical name is
316
+ # ``_read_latest_changelog_version`` on _lib_changelog; the alias keeps
317
+ # the historical cctally-namespace name reachable for the monkeypatch
318
+ # surface those tests depend on.
319
+ _release_read_latest_release_version = _lib_changelog._read_latest_changelog_version
320
+
332
321
  _lib_aggregators = _load_sibling("_lib_aggregators")
333
322
  BucketUsage = _lib_aggregators.BucketUsage
334
323
  CodexBucketUsage = _lib_aggregators.CodexBucketUsage
@@ -359,6 +348,19 @@ TrendView = _lib_view_models.TrendView
359
348
  build_trend_view = _lib_view_models.build_trend_view
360
349
  SessionsView = _lib_view_models.SessionsView
361
350
  build_sessions_view = _lib_view_models.build_sessions_view
351
+ BlocksView = _lib_view_models.BlocksView
352
+ build_blocks_view = _lib_view_models.build_blocks_view
353
+ build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
354
+ ForecastView = _lib_view_models.ForecastView
355
+ build_forecast_view = _lib_view_models.build_forecast_view
356
+ CodexDailyView = _lib_view_models.CodexDailyView
357
+ build_codex_daily_view = _lib_view_models.build_codex_daily_view
358
+ CodexMonthlyView = _lib_view_models.CodexMonthlyView
359
+ build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
360
+ CodexWeeklyView = _lib_view_models.CodexWeeklyView
361
+ build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
362
+ CodexSessionView = _lib_view_models.CodexSessionView
363
+ build_codex_session_view = _lib_view_models.build_codex_session_view
362
364
 
363
365
  _lib_render = _load_sibling("_lib_render")
364
366
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
@@ -387,13 +389,11 @@ _render_five_hour_blocks_table = _lib_render._render_five_hour_blocks_table
387
389
 
388
390
  # Eager re-export of bin/_cctally_setup.py (lazy I/O sibling that loads
389
391
  # at startup to keep `ns["cmd_setup"](...)` / `ns["_setup_X"](...)`
390
- # direct-dict test patterns working — PEP 562 `__getattr__` only fires
391
- # on module-attribute access, not dict-key access on `mod.__dict__`,
392
- # which is how `tests/test_setup_legacy_migrate.py` reaches in via the
393
- # `ns = load_script()` fixture. Spec §4.8 explicitly contemplates this
394
- # "partially-pre-imported lazy module" form for the rare case where
395
- # tests bypass PEP 562. Same pattern as Phase A `_lib_*` modules; no
396
- # entry in `_LAZY_MODULES` below.
392
+ # direct-dict test patterns working — dict-key access on `mod.__dict__`
393
+ # (the form tests/test_setup_legacy_migrate.py uses via the
394
+ # `ns = load_script()` fixture) bypasses any module-level `__getattr__`.
395
+ # Eager binding here makes the symbols visible on the dict regardless
396
+ # of how callers reach them. Same pattern as the `_lib_*` modules above.
397
397
  _cctally_setup = _load_sibling("_cctally_setup")
398
398
  _settings_merge_install = _cctally_setup._settings_merge_install
399
399
  _settings_merge_uninstall = _cctally_setup._settings_merge_uninstall
@@ -777,6 +777,7 @@ _dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_p
777
777
  _dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
778
778
  _build_block_detail = _cctally_dashboard._build_block_detail
779
779
  _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
780
+ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
780
781
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
781
782
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
782
783
  _iso_z = _cctally_dashboard._iso_z
@@ -789,73 +790,6 @@ DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
789
790
  cmd_dashboard = _cctally_dashboard.cmd_dashboard
790
791
 
791
792
 
792
- # === Lazy-loaded library siblings (PEP 562 registry) ===
793
- #
794
- # Tests reach into cctally's namespace for private symbols via
795
- # SourceFileLoader (e.g. cctally._release_phase_stamp_done). When those
796
- # symbols live in lazy I/O modules (_cctally_*.py), Python's
797
- # module-level __getattr__ resolves them on first access by importing
798
- # the owning module and splicing its whole symbol surface into
799
- # bin/cctally's globals. Subsequent accesses skip __getattr__ entirely.
800
- #
801
- # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4
802
-
803
- _LAZY_MODULES: dict[str, list[str]] = {
804
- "_cctally_release": [
805
- "_FORMULA_VERSION_RE",
806
- "_RELEASE_NPM_POLL_INTERVAL_S_DEFAULT",
807
- "_RELEASE_NPM_POLL_TIMEOUT_S_DEFAULT",
808
- "_homebrew_template_path",
809
- "_package_json_path",
810
- "_release_brew_archive_url",
811
- "_release_canonical_body",
812
- "_release_compute_brew_sha256",
813
- "_release_dry_run",
814
- "_release_extract_formula_version",
815
- "_release_npm_poll_timing",
816
- "_release_parse_changelog",
817
- "_release_preflight_tag_clobber",
818
- "_release_print_gh_fallback",
819
- "_release_stamp_changelog",
820
- "_release_stamp_package_json",
821
- "cmd_release",
822
- ],
823
- }
824
-
825
- # Inverted lookup: symbol -> module name. Built at module load with a
826
- # collision assertion so two modules can't accidentally claim the same name.
827
- _LAZY_NAME_TO_MODULE: dict[str, str] = {}
828
- for _mod_name, _names in _LAZY_MODULES.items():
829
- for _n in _names:
830
- assert _n not in _LAZY_NAME_TO_MODULE, (
831
- f"PEP 562 registry collision: {_n!r} claimed by both "
832
- f"{_LAZY_NAME_TO_MODULE.get(_n)!r} and {_mod_name!r}"
833
- )
834
- _LAZY_NAME_TO_MODULE[_n] = _mod_name
835
-
836
-
837
- def __getattr__(name: str): # PEP 562
838
- """Lazy-load a registered sibling module on first attribute access.
839
-
840
- Tests do `cctally._release_phase_stamp_done(...)` and similar; this
841
- function fires, imports the owning module via _load_sibling, splices
842
- every symbol the registry claims for that module into globals(), and
843
- returns the requested attribute. Subsequent attribute accesses on
844
- cctally.X find the symbol directly in globals — no __getattr__ trip.
845
-
846
- Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.2
847
- """
848
- mod_name = _LAZY_NAME_TO_MODULE.get(name)
849
- if mod_name is None:
850
- raise AttributeError(
851
- f"module {__name__!r} has no attribute {name!r}"
852
- )
853
- mod = _load_sibling(mod_name)
854
- for n in _LAZY_MODULES[mod_name]:
855
- globals()[n] = getattr(mod, n)
856
- return globals()[name]
857
-
858
-
859
793
  RELEASE_HEADER_RE = re.compile(
860
794
  rf'^## \[({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
861
795
  rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*$',
@@ -865,1284 +799,11 @@ RELEASE_HEADER_RE = re.compile(
865
799
  RELEASE_SUBSECTION_ORDER = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
866
800
 
867
801
 
868
- def _release_read_latest_release_version() -> tuple[str, str] | None:
869
- """Read latest `## [X.Y.Z] - YYYY-MM-DD` header. Returns (version, date) or None."""
870
- try:
871
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
872
- except FileNotFoundError:
873
- return None
874
- m = RELEASE_HEADER_RE.search(text)
875
- if not m:
876
- return None
877
- return (m.group(1), m.group(2))
878
-
879
-
880
- def _release_preflight_branch(allow_branch: str | None) -> None:
881
- """Refuse unless on main or --allow-branch matches.
882
-
883
- Spec §10.1 step 2: branch check fires for both real runs and --dry-run
884
- (a dry-run on the wrong branch would mislead).
885
- """
886
- branch = subprocess.check_output(
887
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
888
- text=True,
889
- cwd=str(CHANGELOG_PATH.parent),
890
- ).strip()
891
- expected = allow_branch if allow_branch else "main"
892
- if branch != expected:
893
- if allow_branch:
894
- print(
895
- f"release: refusing to cut from {branch}; allow-branch was {allow_branch}",
896
- file=sys.stderr,
897
- )
898
- else:
899
- print(
900
- f"release: refusing to cut from {branch}; "
901
- f"use --allow-branch {branch} if intentional",
902
- file=sys.stderr,
903
- )
904
- sys.exit(2)
905
-
906
-
907
- def _release_preflight_clean_tree() -> None:
908
- """Refuse if working tree is dirty (spec §10.1 step 3)."""
909
- out = subprocess.check_output(
910
- ["git", "status", "--porcelain"],
911
- text=True,
912
- cwd=str(CHANGELOG_PATH.parent),
913
- )
914
- if out.strip():
915
- print("release: working tree dirty; commit or stash first", file=sys.stderr)
916
- sys.exit(2)
917
-
918
-
919
- def _release_preflight_up_to_date(remote: str) -> None:
920
- """Refuse if local branch is behind <remote>/<branch>; local-ahead OK.
921
-
922
- Spec §10.1 step 4. Network failure is non-fatal — operator can re-run
923
- with --resume after fixing connectivity.
924
- """
925
- branch = subprocess.check_output(
926
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
927
- text=True,
928
- cwd=str(CHANGELOG_PATH.parent),
929
- ).strip()
930
- try:
931
- subprocess.check_call(
932
- ["git", "fetch", "--quiet", remote, branch],
933
- stdout=subprocess.DEVNULL,
934
- stderr=subprocess.DEVNULL,
935
- cwd=str(CHANGELOG_PATH.parent),
936
- )
937
- except subprocess.CalledProcessError:
938
- # Network failure — proceed; operator can re-run with --resume.
939
- return
940
- local = subprocess.check_output(
941
- ["git", "rev-parse", branch],
942
- text=True,
943
- cwd=str(CHANGELOG_PATH.parent),
944
- ).strip()
945
- remote_sha = subprocess.check_output(
946
- ["git", "rev-parse", f"{remote}/{branch}"],
947
- text=True,
948
- cwd=str(CHANGELOG_PATH.parent),
949
- ).strip()
950
- if local == remote_sha:
951
- return
952
- base = subprocess.check_output(
953
- ["git", "merge-base", local, remote_sha],
954
- text=True,
955
- cwd=str(CHANGELOG_PATH.parent),
956
- ).strip()
957
- if base == remote_sha:
958
- return # local is strictly ahead — fine.
959
- print(
960
- f"release: local {branch} is behind {remote}/{branch}; pull first",
961
- file=sys.stderr,
962
- )
963
- sys.exit(2)
964
-
965
-
966
- def _release_phase_stamp_done(version: str) -> tuple[bool, str | None]:
967
- """Phase-1 read-only signal: returns (True, head_sha) when CHANGELOG.md
968
- on disk AND HEAD's tree both contain a `## [version] - YYYY-MM-DD` header
969
- line AND HEAD's commit subject is exactly `chore(release): vX.Y.Z`.
970
-
971
- Date is read from the existing CHANGELOG, NOT recomputed from "today" —
972
- `--resume` after UTC midnight rollover must still detect a stamp written
973
- yesterday. Returns (False, None) on any miss (file gone, header absent,
974
- HEAD blob lacks the header). When the header IS present on HEAD but
975
- HEAD's subject is not the stamp subject — meaning an unrelated commit
976
- landed on top of the stamp — exits 2 with a diagnostic rather than
977
- tagging the wrong SHA. Read-only — never mutates.
978
- """
979
- expected_prefix = f"## [{version}] - "
980
- try:
981
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
982
- except FileNotFoundError:
983
- return (False, None)
984
- if not any(line.startswith(expected_prefix) for line in text.splitlines()):
985
- return (False, None)
986
- head_sha = subprocess.check_output(
987
- ["git", "rev-parse", "HEAD"],
988
- text=True,
989
- cwd=str(CHANGELOG_PATH.parent),
990
- ).strip()
991
- try:
992
- blob = subprocess.check_output(
993
- ["git", "show", "HEAD:CHANGELOG.md"],
994
- text=True,
995
- stderr=subprocess.DEVNULL,
996
- cwd=str(CHANGELOG_PATH.parent),
997
- )
998
- except subprocess.CalledProcessError:
999
- return (False, None)
1000
- if not any(line.startswith(expected_prefix) for line in blob.splitlines()):
1001
- return (False, None)
1002
- # HEAD's blob carries the stamp; verify HEAD itself IS the stamp commit.
1003
- # An unrelated commit landed on top of the stamp would still satisfy the
1004
- # blob check (CHANGELOG.md unchanged on top), but tagging that SHA would
1005
- # mis-tag the release. Refuse with a clear diagnostic instead.
1006
- head_subject = subprocess.check_output(
1007
- ["git", "log", "-1", "--format=%s", "HEAD"],
1008
- text=True,
1009
- cwd=str(CHANGELOG_PATH.parent),
1010
- ).strip()
1011
- expected_subject = f"chore(release): v{version}"
1012
- if head_subject != expected_subject:
1013
- print(
1014
- f"release: HEAD is not the stamp commit (subject: {head_subject!r}); "
1015
- f"--resume cannot continue. Either checkout the stamp commit or "
1016
- f"revert the on-top commits.",
1017
- file=sys.stderr,
1018
- )
1019
- sys.exit(2)
1020
- # Belt-and-suspenders: package.json's version must also be stamped
1021
- # (Phase 1 co-stamp invariant). When package.json doesn't exist
1022
- # (legacy fixtures, source clones pre-Task-1), skip silently —
1023
- # the CHANGELOG header check above is still authoritative.
1024
- pj_path = cctally._package_json_path()
1025
- if pj_path.exists():
1026
- try:
1027
- pj = json.loads(pj_path.read_text(encoding="utf-8"))
1028
- except json.JSONDecodeError:
1029
- return (False, None)
1030
- if pj.get("version") != version:
1031
- return (False, None)
1032
- return (True, head_sha)
1033
-
1034
-
1035
- def _release_build_stamp_message(
1036
- version: str,
1037
- body: str,
1038
- invocation: str,
1039
- prior_head: str,
1040
- bump_kind: str,
1041
- subsection_counts: dict[str, int],
1042
- ) -> str:
1043
- """Build the full stamp commit message: private body + `--- public ---`
1044
- block + canonical body (spec §7.1).
1045
-
1046
- Subject is identical on both surfaces (`chore(release): vX.Y.Z`); the
1047
- public body is the canonical CHANGELOG section body byte-for-byte.
1048
- """
1049
- counts_str = ", ".join(
1050
- f"{name} ({n})" for name, n in subsection_counts.items() if n > 0
1051
- ) or "(none)"
1052
- total = sum(subsection_counts.values())
1053
- private_body = (
1054
- f"chore(release): v{version}\n"
1055
- f"\n"
1056
- f"Stamp release v{version} over {total} [Unreleased] entries.\n"
1057
- f"\n"
1058
- f"Run by `{invocation}` from main at {prior_head[:7]}.\n"
1059
- f"Bump kind: {bump_kind}.\n"
1060
- f"Subsections stamped: {counts_str}.\n"
1061
- )
1062
- public_block = f"--- public ---\nchore(release): v{version}\n\n{body}\n"
1063
- return private_body + "\n" + public_block
1064
-
1065
-
1066
- def _release_run_phase_stamp(
1067
- version: str,
1068
- remote: str,
1069
- invocation: str,
1070
- bump_kind: str = "(unspecified)",
1071
- ) -> str:
1072
- """Phase 1 — stamp `[Unreleased]` into a `[X.Y.Z]` section and commit.
1073
-
1074
- Idempotent: if `_release_phase_stamp_done(version)` reports done,
1075
- short-circuits with the existing HEAD SHA. Otherwise rewrites
1076
- CHANGELOG.md atomically (`os.replace`), stages it, verifies the
1077
- staging cache contains exactly `CHANGELOG.md` (defends against
1078
- operator-prestaged content slipping into the release commit), then
1079
- invokes `git commit -F <msgfile> --cleanup=verbatim` so the
1080
- `### Added` / `### Fixed` headings survive (default cleanup strips
1081
- `#`-prefixed lines as comments). Returns the new commit SHA.
1082
- """
1083
- done, head_sha = _release_phase_stamp_done(version)
1084
- if done:
1085
- print(
1086
- f"release: stamp ✓ (already done — commit {head_sha[:7]})"
1087
- )
1088
- return head_sha
1089
-
1090
- print(f"release: stamp v{version}")
1091
- today = (
1092
- os.environ.get("CCTALLY_RELEASE_DATE_UTC")
1093
- or dt.datetime.now(dt.timezone.utc).date().isoformat()
1094
- )
1095
- old_text = CHANGELOG_PATH.read_text(encoding="utf-8")
1096
- try:
1097
- new_text, body = cctally._release_stamp_changelog(old_text, version, today)
1098
- except ValueError as e:
1099
- # Mirror the dry-run path's contract: refuse with exit 2 when
1100
- # `[Unreleased]` is empty (spec §3 step 4 / §11.4 row 9).
1101
- print(f"release: {e}", file=sys.stderr)
1102
- sys.exit(2)
1103
-
1104
- # Subsection counts — read from the OLD parse (before the stamp moved
1105
- # bullets out of [Unreleased]); private-body diagnostics only.
1106
- parsed = cctally._release_parse_changelog(old_text)
1107
- counts: dict[str, int] = {}
1108
- if parsed["sections"]:
1109
- for sub in parsed["sections"][0]["subsections"]:
1110
- if sub["bullets"]:
1111
- heading = sub["heading"].replace("###", "").strip()
1112
- counts[heading] = len(sub["bullets"])
1113
-
1114
- prior_head = subprocess.check_output(
1115
- ["git", "rev-parse", "HEAD"],
1116
- text=True,
1117
- cwd=str(CHANGELOG_PATH.parent),
1118
- ).strip()
1119
-
1120
- # Atomic write: write to .tmp.<pid> sibling, then os.replace(). Wrapped
1121
- # in try/finally so a failure between write_text and os.replace doesn't
1122
- # leak a stray .tmp.<pid> file next to CHANGELOG.md.
1123
- tmp = CHANGELOG_PATH.with_suffix(f".md.tmp.{os.getpid()}")
1124
- try:
1125
- tmp.write_text(new_text, encoding="utf-8")
1126
- os.replace(tmp, CHANGELOG_PATH)
1127
- finally:
1128
- try:
1129
- tmp.unlink()
1130
- except FileNotFoundError:
1131
- pass
1132
-
1133
- # Stage CHANGELOG.md only (`cwd` so the relative path resolves).
1134
- subprocess.check_call(
1135
- ["git", "add", CHANGELOG_PATH.name],
1136
- cwd=str(CHANGELOG_PATH.parent),
1137
- )
1138
-
1139
- # Co-stamp package.json (npm channel; Phase 5 reads this version field).
1140
- # Path resolved via _package_json_path() (which derives from
1141
- # CHANGELOG_PATH) so tests monkeypatching CHANGELOG_PATH to a temp
1142
- # repo automatically see the correct (absent) package.json there.
1143
- # Guarded by `.exists()` so fixture replays for old scenarios
1144
- # pre-dating the npm channel still pass — they have no package.json
1145
- # to stamp.
1146
- pj_path = cctally._package_json_path()
1147
- if pj_path.exists():
1148
- pj_old = pj_path.read_text(encoding="utf-8")
1149
- pj_new = cctally._release_stamp_package_json(pj_old, version)
1150
- pj_path.write_text(pj_new, encoding="utf-8")
1151
- subprocess.check_call(
1152
- ["git", "add", pj_path.name],
1153
- cwd=str(CHANGELOG_PATH.parent),
1154
- )
1155
-
1156
- # Trailer staging guard: refuse if anything else is staged. Defensive
1157
- # — preflight should have caught a dirty index, but operators can race
1158
- # `git add` between preflight and stamp.
1159
- staged = (
1160
- subprocess.check_output(
1161
- ["git", "diff", "--cached", "--name-only"],
1162
- text=True,
1163
- cwd=str(CHANGELOG_PATH.parent),
1164
- )
1165
- .strip()
1166
- .splitlines()
1167
- )
1168
- expected_staged = (
1169
- ["CHANGELOG.md", "package.json"]
1170
- if pj_path.exists()
1171
- else ["CHANGELOG.md"]
1172
- )
1173
- if staged != expected_staged:
1174
- print(
1175
- f"release: stamp aborted; expected only {expected_staged} staged, "
1176
- f"got {staged}",
1177
- file=sys.stderr,
1178
- )
1179
- recover_paths = " ".join(expected_staged)
1180
- print(
1181
- "release: to recover, run `git reset HEAD` and "
1182
- f"`git checkout -- {recover_paths}`",
1183
- file=sys.stderr,
1184
- )
1185
- sys.exit(3)
1186
-
1187
- msg = _release_build_stamp_message(
1188
- version, body, invocation, prior_head, bump_kind, counts
1189
- )
1190
- msg_file = CHANGELOG_PATH.parent / f".release-msg.{os.getpid()}.txt"
1191
- msg_file.write_text(msg, encoding="utf-8")
1192
- try:
1193
- subprocess.check_call(
1194
- [
1195
- "git",
1196
- "commit",
1197
- "-F",
1198
- str(msg_file),
1199
- "--cleanup=verbatim",
1200
- ],
1201
- cwd=str(CHANGELOG_PATH.parent),
1202
- )
1203
- finally:
1204
- try:
1205
- msg_file.unlink()
1206
- except FileNotFoundError:
1207
- pass
1208
-
1209
- new_sha = subprocess.check_output(
1210
- ["git", "rev-parse", "HEAD"],
1211
- text=True,
1212
- cwd=str(CHANGELOG_PATH.parent),
1213
- ).strip()
1214
- print(f"release: stamp ✓ (commit {new_sha[:7]})")
1215
- return new_sha
1216
-
1217
-
1218
- def _release_phase_tag_done(version: str, remote: str) -> bool:
1219
- """Phase-2 read-only signal: tag exists locally AND on `<remote>`.
1220
-
1221
- Both are required (spec §5.1): a local-only tag means the push step
1222
- still has work; a remote-only tag (rare — implies a manual push)
1223
- is treated as not-done so the local annotation is recreated.
1224
- Read-only — never mutates.
1225
- """
1226
- tag = f"v{version}"
1227
- local = (
1228
- subprocess.check_output(
1229
- ["git", "tag", "-l", tag],
1230
- text=True,
1231
- cwd=str(CHANGELOG_PATH.parent),
1232
- )
1233
- .strip()
1234
- .splitlines()
1235
- )
1236
- if tag not in local:
1237
- return False
1238
- try:
1239
- out = subprocess.check_output(
1240
- ["git", "ls-remote", "--tags", remote, f"refs/tags/{tag}"],
1241
- text=True,
1242
- stderr=subprocess.DEVNULL,
1243
- cwd=str(CHANGELOG_PATH.parent),
1244
- ).strip()
1245
- return bool(out)
1246
- except subprocess.CalledProcessError:
1247
- return False
1248
-
1249
-
1250
- def _release_run_phase_tag(version: str, remote: str, stamp_sha: str) -> None:
1251
- """Phase 2 — write annotated tag `vX.Y.Z` and push commit + tag.
1252
-
1253
- `stamp_sha` is the SHA returned by Phase 1; tagging is pinned to that
1254
- SHA rather than re-reading HEAD, so an unrelated commit landing on top
1255
- between phases never causes a mis-tag (defense in depth — the done-
1256
- signal in `_release_phase_stamp_done` already refuses that scenario).
1257
-
1258
- Idempotent: short-circuits when `_release_phase_tag_done`. The
1259
- body is re-parsed from CHANGELOG.md and run through
1260
- `_release_canonical_body` so the annotation is byte-identical to
1261
- Phase 1's commit-message public block (body-canonical-three-sources
1262
- invariant, spec §7.4 — same string flows into Phase 4's GH Release
1263
- notes).
1264
-
1265
- Resume scenarios:
1266
- - Local tag missing, remote tag missing: create + push as normal.
1267
- - Local tag exists, remote tag missing (push failed last run, or
1268
- operator pushed `main` manually): skip `git tag` (would conflict
1269
- with the existing local tag) and push only the tag explicitly.
1270
- - Both present: short-circuited by `_release_phase_tag_done` above.
1271
-
1272
- Signing: use `-s` only when both `user.signingkey` is set AND
1273
- `tag.gpgsign` is `true`; otherwise `-a` (annotated, unsigned).
1274
- Operators that have signing keys configured but not enabled for
1275
- tags get the unsigned path. Signed tags will have a PGP signature
1276
- block appended to the tag object — the eventual harness scenario
1277
- must strip lines from `-----BEGIN PGP SIGNATURE-----` onward
1278
- before comparing the annotation against the canonical body.
1279
-
1280
- `--cleanup=verbatim` is required: default cleanup strips
1281
- `#`-prefixed lines, eating every `### Added` / `### Fixed`
1282
- heading. Push uses `--follow-tags` to ship commit + tag in one
1283
- operation, plus an explicit `refs/tags/...:refs/tags/...` push as
1284
- belt-and-suspenders: `--follow-tags` skips tags whose target commit
1285
- is already on the remote (the resume-after-manual-push case), so
1286
- the explicit push is what guarantees the tag actually lands.
1287
- """
1288
- if _release_phase_tag_done(version, remote):
1289
- print(
1290
- f"release: tag ✓ (already done — v{version} on {remote})"
1291
- )
1292
- return
1293
-
1294
- print(f"release: tag v{version}")
1295
- tag = f"v{version}"
1296
-
1297
- # Resume-aware: if the local tag already exists from a prior run, skip
1298
- # `git tag` (would fail with "tag already exists") and push only.
1299
- local_tags = (
1300
- subprocess.check_output(
1301
- ["git", "tag", "-l", tag],
1302
- text=True,
1303
- cwd=str(CHANGELOG_PATH.parent),
1304
- )
1305
- .strip()
1306
- .splitlines()
1307
- )
1308
- if tag in local_tags:
1309
- print(f"release: tag {tag} exists locally; pushing tag only")
1310
- else:
1311
- # Body — re-parse CHANGELOG so the annotation reuses the canonical
1312
- # body string (matches the public block of Phase 1's commit byte
1313
- # for byte; same string flows into Phase 4's GH Release notes).
1314
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
1315
- parsed = cctally._release_parse_changelog(text)
1316
- target_section = next(
1317
- (
1318
- s
1319
- for s in parsed["sections"]
1320
- if s["heading"].lstrip().startswith(f"## [{version}]")
1321
- ),
1322
- None,
1323
- )
1324
- if target_section is None:
1325
- print(
1326
- f"release: cannot find [{version}] section in CHANGELOG.md",
1327
- file=sys.stderr,
1328
- )
1329
- sys.exit(3)
1330
- body = cctally._release_canonical_body(target_section)
1331
-
1332
- annotation = f"{tag}\n\n{body}\n"
1333
- msg_file = CHANGELOG_PATH.parent / f".release-tag-msg.{os.getpid()}.txt"
1334
- try:
1335
- msg_file.write_text(annotation, encoding="utf-8")
1336
- signing_key = subprocess.run(
1337
- ["git", "config", "--get", "user.signingkey"],
1338
- capture_output=True,
1339
- text=True,
1340
- cwd=str(CHANGELOG_PATH.parent),
1341
- ).stdout.strip()
1342
- tag_gpgsign = (
1343
- subprocess.run(
1344
- ["git", "config", "--get", "tag.gpgsign"],
1345
- capture_output=True,
1346
- text=True,
1347
- cwd=str(CHANGELOG_PATH.parent),
1348
- )
1349
- .stdout.strip()
1350
- .lower()
1351
- == "true"
1352
- )
1353
- sign_flag = "-s" if (signing_key and tag_gpgsign) else "-a"
1354
- subprocess.check_call(
1355
- [
1356
- "git",
1357
- "tag",
1358
- sign_flag,
1359
- "-F",
1360
- str(msg_file),
1361
- "--cleanup=verbatim",
1362
- tag,
1363
- stamp_sha,
1364
- ],
1365
- cwd=str(CHANGELOG_PATH.parent),
1366
- )
1367
- finally:
1368
- try:
1369
- msg_file.unlink()
1370
- except FileNotFoundError:
1371
- pass
1372
-
1373
- branch = subprocess.check_output(
1374
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1375
- text=True,
1376
- cwd=str(CHANGELOG_PATH.parent),
1377
- ).strip()
1378
- subprocess.check_call(
1379
- ["git", "push", remote, branch, "--follow-tags"],
1380
- cwd=str(CHANGELOG_PATH.parent),
1381
- )
1382
- # Belt-and-suspenders: --follow-tags skips tags whose target is already
1383
- # on the remote (e.g., resume after operator pushed `main` manually).
1384
- # Explicit refs-spec always pushes the tag; no-op when both are already
1385
- # on remote (true resume-after-success).
1386
- subprocess.check_call(
1387
- ["git", "push", remote, f"refs/tags/{tag}:refs/tags/{tag}"],
1388
- cwd=str(CHANGELOG_PATH.parent),
1389
- )
1390
- print(f"release: tag ✓ (annotated, pushed to {remote})")
1391
-
1392
-
1393
- def _release_discover_public_clone(args: argparse.Namespace) -> pathlib.Path:
1394
- """Resolve the public-clone path (spec §9.1).
1395
-
1396
- Priority chain (highest first):
1397
- 1. ``--public-clone <path>`` flag.
1398
- 2. ``git config --get release.publicClone``.
1399
- 3. ``$APP_DIR/release-public-clone-path`` plain-text marker file.
1400
-
1401
- Each source is silently skipped when missing (unset config key, no
1402
- marker file, no flag). Refuses with exit 2 only when ALL three
1403
- sources are absent — operator setup is one-time and explicit, no
1404
- silent fallback to a hard-coded path.
1405
- """
1406
- candidates: list[tuple[str, pathlib.Path]] = []
1407
-
1408
- if getattr(args, "public_clone", None):
1409
- candidates.append(("--public-clone", pathlib.Path(args.public_clone)))
1410
-
1411
- # Source 2 — git config. `git config --get` exits 1 when the key is
1412
- # unset; that's the silent-skip path. Other failures (e.g., not in a
1413
- # git repo) also fall through silently — preflight has already
1414
- # established we're inside a repo.
1415
- #
1416
- # Key is `release.publicClone` (camelCase) per git config naming
1417
- # conventions; git 2.46+ rejects underscore-bearing keys at write
1418
- # time, so the spec's informal `release.public_clone` wording maps
1419
- # to this canonical form. Git config lookup is case-insensitive on
1420
- # the trailing variable, so `release.publicclone` works too.
1421
- try:
1422
- out = subprocess.run(
1423
- ["git", "config", "--get", "release.publicClone"],
1424
- capture_output=True,
1425
- text=True,
1426
- check=True,
1427
- ).stdout.strip()
1428
- if out:
1429
- candidates.append(
1430
- ("git config release.publicClone", pathlib.Path(out))
1431
- )
1432
- except subprocess.CalledProcessError:
1433
- pass
1434
-
1435
- # Source 3 — marker file under APP_DIR.
1436
- marker = APP_DIR / "release-public-clone-path"
1437
- if marker.exists():
1438
- text = marker.read_text(encoding="utf-8").strip()
1439
- if text:
1440
- candidates.append((str(marker), pathlib.Path(text)))
1441
-
1442
- for _source, path in candidates:
1443
- if (path / ".git").exists():
1444
- return path.resolve()
1445
- # Bare repo — `<path>/HEAD` exists alongside `objects/`, `refs/`.
1446
- if path.is_dir() and (path / "HEAD").exists():
1447
- return path.resolve()
1448
- # Lenient fallthrough — if the path simply exists but doesn't
1449
- # match either layout, return it anyway. The next subprocess
1450
- # (`git -C <path> ...`) will fail loudly with a clear error;
1451
- # pre-validating here would only duplicate that diagnostic.
1452
- if path.exists():
1453
- return path.resolve()
1454
-
1455
- marker_path = APP_DIR / "release-public-clone-path"
1456
- print(
1457
- "release: cannot discover public clone path; pass --public-clone "
1458
- "<path>, set 'release.publicClone' in git config, or write the "
1459
- f"path to {marker_path}",
1460
- file=sys.stderr,
1461
- )
1462
- sys.exit(2)
1463
-
1464
-
1465
- def _release_discover_brew_clone(
1466
- args: argparse.Namespace,
1467
- ) -> pathlib.Path | None:
1468
- """Resolve the brew tap clone path. Returns ``None`` if unconfigured.
1469
-
1470
- Priority chain (mirrors :func:`_release_discover_public_clone`):
1471
- 1. ``--brew-clone <path>`` flag.
1472
- 2. ``git config --get release.brewClone``.
1473
- 3. ``$APP_DIR/release-brew-clone-path`` plain-text marker file.
1474
-
1475
- Unlike :func:`_release_discover_public_clone` (which exits 2 when
1476
- none of the sources resolve), this returns ``None`` — Phase 6 is a
1477
- graceful skip, not a hard refusal, since release channels are
1478
- added incrementally and not every operator has a brew tap clone
1479
- on hand.
1480
- """
1481
- if getattr(args, "brew_clone", None):
1482
- return pathlib.Path(args.brew_clone).expanduser()
1483
-
1484
- cfg = subprocess.run(
1485
- ["git", "config", "--get", "release.brewClone"],
1486
- capture_output=True,
1487
- text=True,
1488
- check=False,
1489
- )
1490
- if cfg.returncode == 0 and cfg.stdout.strip():
1491
- return pathlib.Path(cfg.stdout.strip()).expanduser()
1492
-
1493
- marker = APP_DIR / "release-brew-clone-path"
1494
- if marker.exists():
1495
- path_str = marker.read_text(encoding="utf-8").strip()
1496
- if path_str:
1497
- return pathlib.Path(path_str).expanduser()
1498
-
1499
- return None
1500
-
1501
-
1502
- def _release_phase_brew_done(
1503
- version: str, brew_clone: pathlib.Path
1504
- ) -> bool:
1505
- """Phase 6 done iff the brew tap **remote** serves the ``vX.Y.Z``
1506
- formula on its default branch AND carries the ``v<version>`` tag.
1507
-
1508
- Tag presence alone is not enough — ``brew install`` reads the
1509
- formula from the tap's default branch, NOT from the tag, so a
1510
- half-failed push (tag landed, branch did not) would still serve
1511
- the prior formula to users. Mirrors the
1512
- :func:`_release_phase_mirror_done` pattern (Phase 3) and adds a
1513
- branch-tip check on top.
1514
-
1515
- Three-step check (each gates the next):
1516
-
1517
- 1. Local-file sniff — cheap pre-check; skip the network when
1518
- the formula isn't even staged locally.
1519
- 2. ``git ls-remote --tags origin refs/tags/v<version>`` — tag
1520
- must be on the remote.
1521
- 3. ``git ls-remote origin refs/heads/<branch>`` SHA == local
1522
- clone's ``HEAD`` SHA — the local formula commit must be the
1523
- remote default-branch tip. After a successful Phase 6 push,
1524
- these match; after a half-failed push (branch fail, tag
1525
- succeed), they diverge.
1526
-
1527
- Returns ``False`` on any subprocess failure (no origin configured,
1528
- network glitch); the caller proceeds to run the phase, whose own
1529
- push step is independently idempotent.
1530
- """
1531
- formula = brew_clone / "Formula" / "cctally.rb"
1532
- if not formula.exists():
1533
- return False
1534
- if f"/v{version}.tar.gz" not in formula.read_text(encoding="utf-8"):
1535
- return False
1536
- try:
1537
- origin = subprocess.check_output(
1538
- ["git", "-C", str(brew_clone), "remote", "get-url", "origin"],
1539
- text=True,
1540
- ).strip()
1541
- tag_out = subprocess.check_output(
1542
- ["git", "ls-remote", "--tags", origin, f"refs/tags/v{version}"],
1543
- text=True,
1544
- ).strip()
1545
- if not tag_out:
1546
- return False
1547
- local_head = subprocess.check_output(
1548
- ["git", "-C", str(brew_clone), "rev-parse", "HEAD"],
1549
- text=True,
1550
- ).strip()
1551
- branch = subprocess.check_output(
1552
- ["git", "-C", str(brew_clone), "rev-parse", "--abbrev-ref", "HEAD"],
1553
- text=True,
1554
- ).strip()
1555
- head_out = subprocess.check_output(
1556
- ["git", "ls-remote", origin, f"refs/heads/{branch}"],
1557
- text=True,
1558
- ).strip()
1559
- # ls-remote line format: "<sha>\trefs/heads/<branch>"; empty
1560
- # output means the remote has no such branch (fresh tap).
1561
- remote_head = head_out.split("\t", 1)[0] if head_out else ""
1562
- return remote_head == local_head
1563
- except subprocess.CalledProcessError:
1564
- return False
1565
-
1566
-
1567
- def _release_phase_mirror_done(
1568
- version: str, public_clone: pathlib.Path
1569
- ) -> bool:
1570
- """Phase 3 done iff the public clone's ``origin`` carries ``vX.Y.Z``.
1571
-
1572
- Read-only signal — runs ``git ls-remote`` against the public clone's
1573
- own ``origin`` (the public mirror's URL is the single source of
1574
- truth here, per spec §9.1). Returns ``False`` on any subprocess
1575
- failure (no origin remote configured, network glitch, etc.); the
1576
- caller proceeds to run all three sub-steps, each idempotent on
1577
- its own.
1578
- """
1579
- tag = f"v{version}"
1580
- try:
1581
- public_origin = subprocess.check_output(
1582
- ["git", "-C", str(public_clone), "remote", "get-url", "origin"],
1583
- text=True,
1584
- ).strip()
1585
- out = subprocess.check_output(
1586
- ["git", "ls-remote", "--tags", public_origin, f"refs/tags/{tag}"],
1587
- text=True,
1588
- ).strip()
1589
- return bool(out)
1590
- except subprocess.CalledProcessError:
1591
- return False
1592
-
1593
-
1594
- def _release_run_phase_mirror(
1595
- version: str, public_clone: pathlib.Path, remote: str
1596
- ) -> None:
1597
- """Phase 3 — replay private commits onto the public clone, then push
1598
- the branch + tag (spec §9.1).
1599
-
1600
- Three sub-steps, each its own subprocess; any failure halts with
1601
- exit 3 so ``--resume`` can re-run from the failed step (each
1602
- sub-step is independently idempotent).
1603
-
1604
- 3a. ``bin/cctally-mirror-public --yes <public-clone>`` — replay
1605
- private commits onto the local public clone. ``--yes`` is
1606
- mandatory in non-interactive context (the mirror tool prompts
1607
- ``apply? [y/N]`` otherwise).
1608
- 3b. ``git -C <public-clone> push origin <branch>`` — push the
1609
- public-clone branch to public origin. The branch is read
1610
- dynamically via ``git -C <public-clone> rev-parse --abbrev-ref
1611
- HEAD`` rather than hardcoded ``main``, since some operators
1612
- may run their public clone on a non-default branch.
1613
- 3c. ``git -C <public-clone> push origin refs/tags/v<version>``
1614
- — push the new tag.
1615
-
1616
- The ``remote`` arg is currently unused (the public-clone push always
1617
- targets the clone's own ``origin``); kept in the signature for
1618
- parity with ``_release_run_phase_tag`` and possible future
1619
- multi-remote scenarios.
1620
- """
1621
- del remote # spec §9.1 — public-clone push always targets `origin`.
1622
- if _release_phase_mirror_done(version, public_clone):
1623
- print(
1624
- f"release: mirror ✓ (already done — v{version} on public origin)"
1625
- )
1626
- return
1627
-
1628
- print("release: mirror push")
1629
-
1630
- # Locate `bin/cctally-mirror-public` alongside this script. Resolve
1631
- # via __file__ so symlinked invocations (~/.local/bin/cctally) still
1632
- # find the in-repo sibling.
1633
- mirror_tool = (
1634
- pathlib.Path(__file__).resolve().parent / "cctally-mirror-public"
1635
- )
1636
- if not mirror_tool.exists():
1637
- print(
1638
- f"release: cannot find {mirror_tool}",
1639
- file=sys.stderr,
1640
- )
1641
- sys.exit(3)
1642
-
1643
- # Step 3a — replay private commits onto the public clone.
1644
- rc = subprocess.call(
1645
- [str(mirror_tool), "--yes", "--public-clone", str(public_clone)]
1646
- )
1647
- if rc != 0:
1648
- print(
1649
- f"release: mirror replay (3a) failed (exit {rc}); "
1650
- "see output above",
1651
- file=sys.stderr,
1652
- )
1653
- sys.exit(3)
1654
-
1655
- # Step 3b — push the public-clone branch to public origin. The branch
1656
- # is read dynamically (don't hardcode `main`).
1657
- try:
1658
- branch = subprocess.check_output(
1659
- ["git", "-C", str(public_clone), "rev-parse", "--abbrev-ref", "HEAD"],
1660
- text=True,
1661
- ).strip()
1662
- except subprocess.CalledProcessError as e:
1663
- print(
1664
- f"release: mirror branch lookup failed (exit {e.returncode})",
1665
- file=sys.stderr,
1666
- )
1667
- sys.exit(3)
1668
- rc = subprocess.call(
1669
- ["git", "-C", str(public_clone), "push", "origin", branch]
1670
- )
1671
- if rc != 0:
1672
- print(
1673
- f"release: mirror push branch (3b) failed (exit {rc})",
1674
- file=sys.stderr,
1675
- )
1676
- sys.exit(3)
1677
-
1678
- # Step 3c — push the new tag.
1679
- tag = f"v{version}"
1680
- rc = subprocess.call(
1681
- [
1682
- "git", "-C", str(public_clone),
1683
- "push", "origin", f"refs/tags/{tag}",
1684
- ]
1685
- )
1686
- if rc != 0:
1687
- print(
1688
- f"release: mirror push tag (3c) failed (exit {rc})",
1689
- file=sys.stderr,
1690
- )
1691
- sys.exit(3)
1692
-
1693
- print(f"release: mirror ✓ (v{version} propagated)")
1694
-
1695
-
1696
- def _release_phase_gh_done(version: str) -> bool:
1697
- """Phase-4 read-only signal: ``gh release view vX.Y.Z`` returns 0.
1698
-
1699
- Read-only — issues a `gh release view` against the public repo and
1700
- treats exit 0 as "release exists." Suppresses stderr/stdout because
1701
- the call is purely a probe; the operator-visible state is recorded
1702
- in ``_release_run_phase_gh``'s own logging. A never-authed operator
1703
- sees this return False (gh exits non-zero on auth error), the helper
1704
- falls through to its own auth probe, and the fallback path prints a
1705
- copy-pasteable command — same UX whether the release simply doesn't
1706
- exist yet or whether gh can't see it.
1707
- """
1708
- rc = subprocess.call(
1709
- ["gh", "release", "view", f"v{version}", "--repo", PUBLIC_REPO],
1710
- stdout=subprocess.DEVNULL,
1711
- stderr=subprocess.DEVNULL,
1712
- )
1713
- return rc == 0
1714
-
1715
-
1716
- def _release_phase_npm_done(version: str) -> bool:
1717
- """True iff ``cctally@<version>`` is already published on npm.
1718
-
1719
- Probes via ``npm view cctally@<v> dist.tarball --json``. Returns
1720
- True when the command succeeds AND stdout is a JSON-quoted
1721
- registry.npmjs.org URL. False on any other condition (timeout,
1722
- npm not on PATH, version absent, etc.). Used as the idempotency
1723
- short-circuit for Phase 5 (parity with ``_release_phase_gh_done``).
1724
- """
1725
- try:
1726
- result = subprocess.run(
1727
- ["npm", "view", f"cctally@{version}", "dist.tarball", "--json"],
1728
- capture_output=True,
1729
- text=True,
1730
- check=False,
1731
- timeout=15,
1732
- )
1733
- except (subprocess.TimeoutExpired, FileNotFoundError):
1734
- return False
1735
- if result.returncode != 0:
1736
- return False
1737
- out = result.stdout.strip()
1738
- return (
1739
- out.startswith('"')
1740
- and out.endswith('"')
1741
- and "registry.npmjs.org" in out
1742
- )
1743
-
1744
-
1745
- def _release_run_phase_gh(version: str, body: str) -> int:
1746
- """Phase 4 — ``gh release create`` with auth fallback (spec §9.2).
1747
-
1748
- Returns:
1749
- - ``0`` on successful publish OR auth-fallback (don't fail the
1750
- whole release on missing gh auth — phases 1-3 already succeeded;
1751
- Phase 4 is polish).
1752
- - ``3`` on hard failure of ``gh release create`` after auth was
1753
- confirmed OK (network glitch, server-side rejection, etc.); the
1754
- operator can address it then re-run ``cctally release --resume``.
1755
-
1756
- Idempotent: ``_release_phase_gh_done`` short-circuits when the
1757
- release already exists. The body is passed in (not re-fetched here);
1758
- the caller is responsible for extracting it from CHANGELOG so the
1759
- body-canonical-three-sources invariant (spec §7.4) is preserved
1760
- across phases 1, 2, and 4.
1761
-
1762
- Auth-mismatch semantics (spec §9.3): if ``gh release view`` finds
1763
- an existing release whose body differs from the current CHANGELOG
1764
- section, treat the existing as authoritative — no ``gh release
1765
- edit`` rewrite. Body divergence after the fact is a separate
1766
- concern that warrants explicit operator action.
1767
- """
1768
- if _release_phase_gh_done(version):
1769
- url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
1770
- print(f"release: gh release ✓ (already published — {url})")
1771
- return 0
1772
-
1773
- print("release: gh release")
1774
-
1775
- # Auth probe — both must succeed for the operator to be able to
1776
- # write to the public repo.
1777
- auth_status_ok = (
1778
- subprocess.call(
1779
- ["gh", "auth", "status", "--hostname", "github.com"],
1780
- stdout=subprocess.DEVNULL,
1781
- stderr=subprocess.DEVNULL,
1782
- )
1783
- == 0
1784
- )
1785
- repo_access_ok = (
1786
- subprocess.call(
1787
- ["gh", "api", f"repos/{PUBLIC_REPO}"],
1788
- stdout=subprocess.DEVNULL,
1789
- stderr=subprocess.DEVNULL,
1790
- )
1791
- == 0
1792
- )
1793
- if not (auth_status_ok and repo_access_ok):
1794
- cctally._release_print_gh_fallback(version, body)
1795
- return 0
1796
-
1797
- # Happy path. Notes go through a tmpfile to dodge shell escaping.
1798
- notes_file = pathlib.Path(tempfile.gettempdir()) / (
1799
- f"release-notes-v{version}-{os.getpid()}.md"
1800
- )
1801
- notes_file.write_text(body, encoding="utf-8")
1802
- try:
1803
- cmd = [
1804
- "gh", "release", "create", f"v{version}",
1805
- "--repo", PUBLIC_REPO,
1806
- "--title", f"v{version}",
1807
- "--notes-file", str(notes_file),
1808
- ]
1809
- if "-" in version:
1810
- cmd.append("--prerelease")
1811
- rc = subprocess.call(cmd)
1812
- if rc != 0:
1813
- print(
1814
- f"release: gh release create failed (exit {rc}); "
1815
- "--resume to retry",
1816
- file=sys.stderr,
1817
- )
1818
- return 3
1819
- finally:
1820
- notes_file.unlink(missing_ok=True)
1821
-
1822
- url = f"https://github.com/{PUBLIC_REPO}/releases/tag/v{version}"
1823
- print(f"release: gh release ✓ ({url})")
1824
- return 0
1825
-
1826
-
1827
- def _release_run_phase_npm(
1828
- version: str,
1829
- public_clone: pathlib.Path,
1830
- *,
1831
- dist_tag: str,
1832
- ) -> int:
1833
- """Phase 5 — wait for the public-repo GHA workflow to publish ``cctally@<v>``.
1834
-
1835
- Phase 3 pushes ``v<version>`` to ``omrikais/cctally``; the workflow at
1836
- ``.github/workflows/release-npm.yml`` fires on tag-push and runs
1837
- ``npm publish --provenance`` via OIDC trusted publisher (no NPM_TOKEN,
1838
- no operator 2FA round-trip — fixes the passkey-in-subprocess failure
1839
- mode where npm 2FA blocks ``npm publish`` from a non-interactive
1840
- subprocess).
1841
-
1842
- Phase 5 here is observation-only: poll ``npm view cctally@<v>`` until
1843
- it appears, with timeout. ``cctally`` never invokes ``npm publish``
1844
- locally anymore — Trusted Publisher binds the right to publish to the
1845
- public-repo workflow, not to the operator's `npm login` token.
1846
-
1847
- Returns ``0`` on observed success OR poll-timeout (soft-success: phases
1848
- 1-4 landed; the workflow is either succeeding or visibly failing on
1849
- github.com; ``--resume`` re-checks the registry).
1850
-
1851
- Timing overridable via ``CCTALLY_RELEASE_NPM_POLL_TIMEOUT_S`` and
1852
- ``CCTALLY_RELEASE_NPM_POLL_INTERVAL_S`` env vars.
1853
- """
1854
- print(f"phase 5: await npm publish via GHA (tag={dist_tag})")
1855
- if _release_phase_npm_done(version):
1856
- print(f" cctally@{version} already on npm — skipping.")
1857
- return 0
1858
-
1859
- timeout_s, interval_s = cctally._release_npm_poll_timing()
1860
- deadline = time.monotonic() + timeout_s
1861
- while True:
1862
- if _release_phase_npm_done(version):
1863
- print(f" cctally@{version} on npm registry ✓")
1864
- return 0
1865
- if time.monotonic() >= deadline:
1866
- print(
1867
- f"\n timed out after {timeout_s:.0f}s waiting for "
1868
- f"cctally@{version} on npm. The GHA workflow may still be "
1869
- f"running or have failed — check:\n"
1870
- f" https://github.com/{PUBLIC_REPO}/actions\n"
1871
- f" Re-run `cctally release --resume` once the workflow "
1872
- f"completes, or for emergency manual publish:\n"
1873
- f" cd {public_clone} && npm publish --access public "
1874
- f"--tag {dist_tag}\n",
1875
- file=sys.stderr,
1876
- )
1877
- return 0
1878
- time.sleep(interval_s)
1879
-
1880
-
1881
- def _release_run_phase_brew(
1882
- version: str,
1883
- brew_clone: pathlib.Path | None,
1884
- allow_downgrade: bool = False,
1885
- ) -> int:
1886
- """Phase 6 — render ``Formula/cctally.rb`` and push to the brew tap.
1887
-
1888
- Idempotent. Pre-releases are skipped at the cmd_release call site —
1889
- this function never runs for them.
1890
-
1891
- Phase semantics:
1892
- - ``brew_clone is None`` — graceful skip (no error). Operator can
1893
- opt in later by setting ``release.brewClone`` in git config.
1894
- - Done-signal short-circuit — prints "already at vX.Y.Z" and
1895
- returns 0 when ``Formula/cctally.rb`` already references this
1896
- version (idempotency under ``--resume``).
1897
- - Dirty working tree — refuses with exit 2 and points the operator
1898
- at ``--resume``.
1899
- - **Monotonic-version gate (issue #30).** Refuses with exit 2 when
1900
- the existing on-disk formula's URL pins a *higher* SemVer than
1901
- ``version``. Catches the f02b2f1 regression class — operator
1902
- runs Phase 6 from a stale CHANGELOG / fixture leak / accidental
1903
- old branch and would otherwise silently write a lower version
1904
- over a higher one. Override via ``allow_downgrade=True``
1905
- (operator-driven, for genuine yank/revert cases).
1906
- - Push failure — auth-fallback parity with Phase 4: prints the
1907
- exact recovery command and returns 0. Phases 1-5 already
1908
- succeeded, the release IS published from the user's
1909
- perspective; the brew tap is the third channel and treated as
1910
- polish.
1911
-
1912
- Returns:
1913
- - ``0`` on success, graceful skip, idempotent short-circuit, OR
1914
- push fallback.
1915
- - ``2`` on dirty-working-tree refusal OR downgrade-gate refusal.
1916
- """
1917
- print("phase 6: brew formula bump")
1918
- if brew_clone is None:
1919
- print(
1920
- " brew tap clone not configured. Set with:\n"
1921
- " git config release.brewClone /path/to/homebrew-cctally\n"
1922
- " Skipping phase 6 (no error).",
1923
- file=sys.stderr,
1924
- )
1925
- return 0
1926
- if _release_phase_brew_done(version, brew_clone):
1927
- print(f" Formula/cctally.rb already at v{version} on tap — skipping.")
1928
- return 0
1929
-
1930
- # Refuse on dirty working tree — we're about to write the formula
1931
- # and commit; mixing operator-staged work into our commit is a
1932
- # footgun. Resume after the operator resolves.
1933
- status = subprocess.run(
1934
- ["git", "-C", str(brew_clone), "status", "--porcelain"],
1935
- capture_output=True,
1936
- text=True,
1937
- check=True,
1938
- )
1939
- if status.stdout.strip():
1940
- print(
1941
- f" brew clone has uncommitted changes:\n{status.stdout}\n"
1942
- " Resolve and re-run `cctally release --resume`.",
1943
- file=sys.stderr,
1944
- )
1945
- return 2
1946
-
1947
- formula_path = brew_clone / "Formula" / "cctally.rb"
1948
- local_at_version = (
1949
- formula_path.exists()
1950
- and f"/v{version}.tar.gz"
1951
- in formula_path.read_text(encoding="utf-8")
1952
- )
1953
-
1954
- if local_at_version:
1955
- # Resume after a push failure (the done-check verifies remote
1956
- # tag, so we only reach here when the local commit is in place
1957
- # but the tap origin hasn't seen it). Skip render + commit; go
1958
- # straight to (re)tag + push. Re-rendering would no-op the
1959
- # commit (`git commit` exits 1 with nothing to commit), so
1960
- # short-circuiting is also what keeps the function tidy.
1961
- print(
1962
- f" local formula already at v{version}; re-pushing to tap…"
1963
- )
1964
- else:
1965
- # Monotonic-version gate (issue #30). The brew tap regressed
1966
- # from v1.3.0 → v1.0.0 twice in one day via this code path; the
1967
- # equality fingerprint above (`local_at_version`) is False on a
1968
- # downgrade, so without this gate we'd silently overwrite a
1969
- # higher version. Compare the existing formula's URL-pinned
1970
- # SemVer against `version` (SemVer-aware so prereleases sort
1971
- # below their stable counterpart per §11.4); refuse with exit 2
1972
- # when the on-disk version is strictly higher. Unparseable
1973
- # formulas are treated as unversioned and allowed through.
1974
- if formula_path.exists():
1975
- existing_text = formula_path.read_text(encoding="utf-8")
1976
- existing_v = cctally._release_extract_formula_version(existing_text)
1977
- if existing_v is not None:
1978
- try:
1979
- existing_key = _release_semver_sort_key(
1980
- _release_parse_semver(existing_v)
1981
- )
1982
- target_key = _release_semver_sort_key(
1983
- _release_parse_semver(version)
1984
- )
1985
- except ValueError:
1986
- existing_key = target_key = None
1987
- if (
1988
- existing_key is not None
1989
- and target_key is not None
1990
- and existing_key > target_key
1991
- and not allow_downgrade
1992
- ):
1993
- print(
1994
- f" refuse: existing formula pins v{existing_v}, "
1995
- f"target is v{version} (downgrade).\n"
1996
- " Common causes: stale CHANGELOG.md in this clone, "
1997
- "fixture leak into a real `release.brewClone`, or an "
1998
- "accidental old branch.\n"
1999
- " Verify intent, then re-run with "
2000
- "`--allow-formula-downgrade` to override "
2001
- "(yank / revert cases). See issue #30.",
2002
- file=sys.stderr,
2003
- )
2004
- return 2
2005
- if (
2006
- existing_key is not None
2007
- and target_key is not None
2008
- and existing_key > target_key
2009
- and allow_downgrade
2010
- ):
2011
- print(
2012
- f" WARNING: writing v{version} over existing v{existing_v} "
2013
- "(--allow-formula-downgrade); intentional yank?",
2014
- file=sys.stderr,
2015
- )
2016
- print(f" computing sha256 of v{version} archive…")
2017
- sha = cctally._release_compute_brew_sha256(version)
2018
-
2019
- template = cctally._homebrew_template_path().read_text(encoding="utf-8")
2020
- rendered = (
2021
- template
2022
- .replace("<<VERSION>>", version)
2023
- .replace("<<SHA256>>", sha)
2024
- )
2025
- formula_path.parent.mkdir(parents=True, exist_ok=True)
2026
- formula_path.write_text(rendered, encoding="utf-8")
2027
-
2028
- subprocess.run(
2029
- ["git", "-C", str(brew_clone), "add", "Formula/cctally.rb"],
2030
- check=True,
2031
- )
2032
- subprocess.run(
2033
- ["git", "-C", str(brew_clone), "commit", "-m",
2034
- f"chore(formula): cctally {version}"],
2035
- check=True,
2036
- )
2037
- # Tag is best-effort — re-running after a partial publish should
2038
- # not fail just because the tag already exists locally.
2039
- #
2040
- # Annotated form with `-m` (issue #25): plain `git tag <name>` is
2041
- # silently upgraded to `git tag -s <name>` under operator-global
2042
- # `tag.gpgsign=true` and demands a message via editor. The release
2043
- # script has no editor stdin, so git aborts with `fatal: no tag
2044
- # message?` — the atomic push refspec then fails with "src refspec
2045
- # does not match any" because the local tag was never created, and
2046
- # the auth-fallback branch below silently swallows the failure as
2047
- # exit 0. Mirrors Phase 2's signing detection (signing_key +
2048
- # tag.gpgsign → -s, else fall back), with one defensive divergence:
2049
- # the fallback uses --no-sign (not bare -a) so the tag still lands
2050
- # under tag.gpgsign=true without a usable signing key configured.
2051
- # Brew install reads the formula off the tap's default branch, not
2052
- # the tag, so signing the tap tag is operationally moot — it exists
2053
- # for history bookkeeping and atomic-push transport.
2054
- signing_key = subprocess.run(
2055
- ["git", "-C", str(brew_clone), "config", "--get", "user.signingkey"],
2056
- capture_output=True,
2057
- text=True,
2058
- ).stdout.strip()
2059
- tag_gpgsign = (
2060
- subprocess.run(
2061
- ["git", "-C", str(brew_clone), "config", "--get", "tag.gpgsign"],
2062
- capture_output=True,
2063
- text=True,
2064
- )
2065
- .stdout.strip()
2066
- .lower()
2067
- == "true"
2068
- )
2069
- sign_flag = "-s" if (signing_key and tag_gpgsign) else "--no-sign"
2070
- subprocess.run(
2071
- ["git", "-C", str(brew_clone), "tag", sign_flag, "-a", "-m",
2072
- f"cctally v{version}", f"v{version}"],
2073
- check=False,
2074
- )
2075
- # Single ATOMIC push of branch + tag in one transaction — the
2076
- # remote either accepts both refs or neither (server-side atomic
2077
- # push, `--atomic`). Avoids the half-failed-push asymmetry that a
2078
- # split branch-push + tag-push pair admits: a tag landing without
2079
- # the branch landing would leave `brew install` serving the OLD
2080
- # formula off the tap's default branch even though the remote
2081
- # carries the new tag. Tag refspec is explicit (`src:dst`) for
2082
- # atomic-push semantics — `--atomic` requires named refs, not the
2083
- # implicit `--follow-tags` path.
2084
- push = subprocess.run(
2085
- ["git", "-C", str(brew_clone), "push", "--atomic", "origin",
2086
- "HEAD", f"refs/tags/v{version}:refs/tags/v{version}"],
2087
- check=False,
2088
- )
2089
- if push.returncode != 0:
2090
- # If the local tag isn't there (e.g., the operator hit the
2091
- # tag.gpgsign edge case from issue #25 even with the fix), the
2092
- # plain push refspec fails. Surface a tag-create fallback in
2093
- # the hint so copy-paste recovery is self-contained.
2094
- print(
2095
- f"\n push failed. Manual recovery:\n"
2096
- f" # If local tag v{version} is missing (e.g., gpgsign issue):\n"
2097
- f" git -C {brew_clone} tag --no-sign -a -m \"cctally v{version}\" v{version}\n"
2098
- f" # Then push:\n"
2099
- f" git -C {brew_clone} push --atomic origin HEAD "
2100
- f"refs/tags/v{version}:refs/tags/v{version}\n",
2101
- file=sys.stderr,
2102
- )
2103
- return 0 # auth-fallback semantics; parity with Phase 4.
2104
-
2105
- return 0
2106
-
2107
-
2108
- def _release_extract_body_from_changelog(version: str) -> str:
2109
- """Re-read the canonical body for ``version`` from CHANGELOG.md.
2110
-
2111
- Same parse + canonicalize path as Phases 1 and 2 (spec §7.4 — the
2112
- body-canonical-three-sources invariant). Refuses with exit 3 if
2113
- the section isn't found; ``--resume`` is the recovery once the
2114
- operator re-stamps.
2115
- """
2116
- text = CHANGELOG_PATH.read_text(encoding="utf-8")
2117
- parsed = cctally._release_parse_changelog(text)
2118
- section = next(
2119
- (
2120
- s for s in parsed["sections"]
2121
- if s["heading"].startswith(f"## [{version}]")
2122
- ),
2123
- None,
2124
- )
2125
- if section is None:
2126
- print(
2127
- f"release: cannot find [{version}] section in CHANGELOG.md",
2128
- file=sys.stderr,
2129
- )
2130
- sys.exit(3)
2131
- return cctally._release_canonical_body(section)
2132
-
2133
-
2134
- def cmd_release(args: argparse.Namespace) -> int:
2135
- """Dispatch thunk — actual implementation in bin/_cctally_release.py.
2136
-
2137
- Lazy-load + invoke. Test/harness code that calls cctally.cmd_release
2138
- directly hits this thunk; the underlying _cctally_release.cmd_release
2139
- handles the work. The thunk fires only when cmd_release is invoked,
2140
- so the lazy module's parse cost is paid only on `cctally release ...`
2141
- or test access — not on every `cctally <other-subcmd>` invocation.
2142
-
2143
- Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §4.1
2144
- """
2145
- return _load_sibling("_cctally_release").cmd_release(args)
802
+ # `cmd_release` is NOT defined here: the release subcommand was extracted
803
+ # from the main `cctally` CLI and lives as a standalone entry point
804
+ # (bin/cctally-release). The implementation is in bin/_cctally_release.py.
805
+ # Tests that exercise `cmd_release` import it directly from
806
+ # `_cctally_release`.
2146
807
 
2147
808
 
2148
809
  # Files to scan when detecting the legacy status-line snippet (Section 5).
@@ -2317,7 +978,6 @@ def _decode_escaped_cwd(dir_name: str) -> str:
2317
978
  return "/" + stripped.replace("-", "/")
2318
979
 
2319
980
 
2320
-
2321
981
  def _sum_cost_for_range(
2322
982
  start: dt.datetime,
2323
983
  end: dt.datetime,
@@ -2400,7 +1060,6 @@ def _resolve_primary_model_for_block(
2400
1060
  return row["model"]
2401
1061
 
2402
1062
 
2403
-
2404
1063
  def _read_keychain_oauth_blob() -> str | None:
2405
1064
  """Read the Claude Code keychain entry on macOS via `security`.
2406
1065
 
@@ -5016,13 +3675,30 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5016
3675
  range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
5017
3676
  )
5018
3677
 
5019
- # Group into blocks
5020
- blocks = _group_entries_into_blocks(
5021
- all_entries, mode="auto",
3678
+ # Group into blocks via the view-model kernel (issue #56). The
3679
+ # heuristic-aware ``aggregated`` tuple holds the full Block list
3680
+ # (gaps included, oldest-first) — same shape the JSON / table
3681
+ # renderers expect. We materialize back to a list because
3682
+ # ``_maybe_swap_active_block_to_canonical`` mutates in-place.
3683
+ #
3684
+ # ``skip_rows=True`` (issue #60 review fix) opts out of the
3685
+ # dashboard-row construction inside ``build_blocks_view`` — the
3686
+ # per-block per-model enrichment that scans every entry per
3687
+ # non-gap block (O(B × N)). The CLI never reads ``view.rows``
3688
+ # (only ``view.aggregated`` here), so on large all-history blocks
3689
+ # runs we avoid quadratic-ish work we'd discard.
3690
+ view = build_blocks_view(
3691
+ all_entries,
3692
+ now_utc=now_utc,
5022
3693
  recorded_windows=recorded_windows,
5023
3694
  block_start_overrides=block_start_overrides,
5024
- now=now_utc,
3695
+ range_start=range_start,
3696
+ range_end=range_end,
3697
+ display_tz=tz,
3698
+ mode="auto",
3699
+ skip_rows=True,
5025
3700
  )
3701
+ blocks = list(view.aggregated)
5026
3702
 
5027
3703
  # Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
5028
3704
  # but a canonical ``five_hour_blocks`` row exists for the current 5h
@@ -5245,7 +3921,6 @@ def _parse_cli_date_range(
5245
3921
  return range_start, range_end
5246
3922
 
5247
3923
 
5248
-
5249
3924
  def cmd_daily(args: argparse.Namespace) -> int:
5250
3925
  """Show usage report grouped by display-timezone date."""
5251
3926
  _share_validate_args(args)
@@ -5537,7 +4212,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5537
4212
  range_start, range_end = range
5538
4213
 
5539
4214
  entries = get_codex_entries(range_start, range_end)
5540
- days = _aggregate_codex_daily(entries, tz_name=tz_name)
4215
+ # Route through ``build_codex_daily_view`` (issue #58). The View
4216
+ # wraps ``_aggregate_codex_daily`` without changing it — preserves
4217
+ # LiteLLM token semantics, intentional dedup vs upstream, and
4218
+ # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
4219
+ view = build_codex_daily_view(
4220
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
4221
+ )
4222
+ days = list(view.rows) # asc — matches aggregator default
5541
4223
  if args.order == "desc":
5542
4224
  days = list(reversed(days))
5543
4225
 
@@ -5559,7 +4241,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5559
4241
  y, m, d = bucket.split("-")
5560
4242
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5561
4243
 
5562
- tz_label = tz_name or _local_tz_name()
4244
+ tz_label = view.display_tz_label
5563
4245
  title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
5564
4246
  print(_render_codex_bucket_table(
5565
4247
  days,
@@ -5589,7 +4271,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5589
4271
  range_start, range_end = range
5590
4272
 
5591
4273
  entries = get_codex_entries(range_start, range_end)
5592
- months = _aggregate_codex_monthly(entries, tz_name=tz_name)
4274
+ # Route through ``build_codex_monthly_view`` (issue #58).
4275
+ view = build_codex_monthly_view(
4276
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
4277
+ )
4278
+ months = list(view.rows)
5593
4279
  if args.order == "desc":
5594
4280
  months = list(reversed(months))
5595
4281
 
@@ -5611,7 +4297,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5611
4297
  y, m = bucket.split("-")
5612
4298
  return f"{_CODEX_MONTHS[int(m) - 1]}\n{y}"
5613
4299
 
5614
- tz_label = tz_name or _local_tz_name()
4300
+ tz_label = view.display_tz_label
5615
4301
  title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
5616
4302
  print(_render_codex_bucket_table(
5617
4303
  months,
@@ -5644,7 +4330,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5644
4330
  week_start_idx = WEEKDAY_MAP[week_start_name]
5645
4331
 
5646
4332
  entries = get_codex_entries(range_start, range_end)
5647
- weeks = _aggregate_codex_weekly(entries, tz_name, week_start_idx)
4333
+ # Route through ``build_codex_weekly_view`` (issue #58).
4334
+ view = build_codex_weekly_view(
4335
+ entries, now_utc=now_utc, tz_name=tz_name,
4336
+ week_start_idx=week_start_idx,
4337
+ )
4338
+ weeks = list(view.rows)
5648
4339
  if args.order == "desc":
5649
4340
  weeks = list(reversed(weeks))
5650
4341
 
@@ -5669,7 +4360,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5669
4360
  y, m, d = bucket.split("-")
5670
4361
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5671
4362
 
5672
- tz_label = tz_name or _local_tz_name()
4363
+ tz_label = view.display_tz_label
5673
4364
  title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
5674
4365
  print(_render_codex_bucket_table(
5675
4366
  weeks,
@@ -5699,8 +4390,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5699
4390
  range_start, range_end = range
5700
4391
 
5701
4392
  entries = get_codex_entries(range_start, range_end)
5702
- sessions = _aggregate_codex_sessions(entries)
5703
- # Aggregator returns descending by last_activity; --order asc reverses.
4393
+ # Route through ``build_codex_session_view`` (issue #58). View rows
4394
+ # come descending by last_activity (aggregator default + upstream
4395
+ # parity); --order asc reverses.
4396
+ view = build_codex_session_view(
4397
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
4398
+ )
4399
+ sessions = list(view.rows)
5704
4400
  if args.order == "asc":
5705
4401
  sessions = list(reversed(sessions))
5706
4402
 
@@ -5714,7 +4410,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5714
4410
  print(_codex_sessions_to_json(sessions))
5715
4411
  return 0
5716
4412
 
5717
- tz_label = tz_name or _local_tz_name()
4413
+ tz_label = view.display_tz_label
5718
4414
  # Upstream uses "Sessions" (plural) in the session banner title.
5719
4415
  title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
5720
4416
  print(_render_codex_session_table(
@@ -6423,7 +5119,6 @@ _diff_render_json = _lib_diff_kernel._diff_render_json
6423
5119
  _diff_resolve_anchor = _lib_diff_kernel._diff_resolve_anchor
6424
5120
 
6425
5121
 
6426
-
6427
5122
  def cmd_diff(args: argparse.Namespace) -> int:
6428
5123
  """Compare Claude usage between two windows."""
6429
5124
  now_utc = _command_as_of()
@@ -8270,7 +6965,16 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8270
6965
  # sync_cache(conn) here — that prior call ran on the stats DB connection
8271
6966
  # (wrong conn) and was a no-op for the real cache anyway.
8272
6967
 
8273
- inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
6968
+ # Route through ``build_forecast_view`` (issue #57). The View is the
6969
+ # kernel-pattern wrapper; ``view.output`` carries the existing
6970
+ # ``ForecastOutput`` math result so every downstream renderer here
6971
+ # (text / JSON / status-line / share) reuses the same projection,
6972
+ # verdict, budgets, and per-method rate fields without recomputing.
6973
+ view = build_forecast_view(
6974
+ conn, now_utc=now_utc, targets=tuple(targets),
6975
+ skip_sync=args.no_sync, display_tz=args._resolved_tz,
6976
+ )
6977
+ inputs = view.output.inputs if view.output is not None else None
8274
6978
  if inputs is None:
8275
6979
  # No snapshot for the current week.
8276
6980
  if getattr(args, "format", None):
@@ -8336,12 +7040,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8336
7040
  print("forecast: no data for current week yet")
8337
7041
  return 0
8338
7042
 
8339
- output = _compute_forecast(inputs, targets)
7043
+ output = view.output
8340
7044
 
8341
7045
  # Shareable-reports gate: --format short-circuits the JSON / status-line /
8342
7046
  # terminal dispatch via `_share_render_and_emit`. The mutex in
8343
7047
  # `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
8344
- # `--status-line` from coexisting. The gate fires AFTER `_compute_forecast`
7048
+ # `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
8345
7049
  # so the snapshot reuses the same projection math as the terminal/JSON
8346
7050
  # paths — no parallel computation.
8347
7051
  if getattr(args, "format", None):
@@ -8886,10 +7590,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
8886
7590
  )
8887
7591
  else:
8888
7592
  period_end = _share_now_utc()
8889
- snap = _build_five_hour_blocks_snapshot(
7593
+ # Build a BlocksView from the API-anchored table rows
7594
+ # (issue #56). Reset-aware totals come from the table's
7595
+ # per-block columns (CLAUDE.md 5-hour gotcha block) so the
7596
+ # share snapshot's footer reads from the single typed
7597
+ # source rather than re-summing inline.
7598
+ view = build_blocks_view_from_table_rows(
8890
7599
  block_dicts,
8891
7600
  period_start=period_start,
8892
7601
  period_end=period_end,
7602
+ display_tz=args._resolved_tz,
7603
+ )
7604
+ snap = _build_five_hour_blocks_snapshot(
7605
+ view,
7606
+ period_start=period_start,
7607
+ period_end=period_end,
8893
7608
  display_tz=display_tz_str,
8894
7609
  version=_share_resolve_version(),
8895
7610
  theme=args.theme,
@@ -10137,7 +8852,7 @@ def doctor_gather_state(
10137
8852
  )
10138
8853
 
10139
8854
  # ── Meta ─────────────────────────────────────────────────────────
10140
- cctally_version_tuple = _release_read_latest_release_version()
8855
+ cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
10141
8856
  cctally_version = (
10142
8857
  cctally_version_tuple[0] if cctally_version_tuple else "unknown"
10143
8858
  )
@@ -11857,90 +10572,9 @@ def build_parser() -> argparse.ArgumentParser:
11857
10572
  )
11858
10573
  doctor_p.set_defaults(func=cmd_doctor)
11859
10574
 
11860
- # ---- release (issue #24 release automation) ----
11861
- p_release = sub.add_parser(
11862
- "release",
11863
- help="Stamp CHANGELOG, cut SemVer tag, mirror, and create GitHub Release",
11864
- formatter_class=CLIHelpFormatter,
11865
- description=textwrap.dedent(
11866
- """\
11867
- Issue #24 release automation. Four idempotent phases:
11868
- 1. Stamp CHANGELOG.md (move [Unreleased] entries under [X.Y.Z]).
11869
- 2. Annotated tag vX.Y.Z + push --follow-tags.
11870
- 3. Mirror push (replay private → push public branch + tag).
11871
- 4. GitHub Release create on the public mirror.
11872
-
11873
- See docs/commands/release.md for full reference.
11874
- """
11875
- ),
11876
- )
11877
- p_release.add_argument(
11878
- "kind",
11879
- nargs="?",
11880
- choices=["patch", "minor", "major", "prerelease", "finalize"],
11881
- help="Bump kind (omit only with --resume)",
11882
- )
11883
- p_release.add_argument(
11884
- "--resume",
11885
- action="store_true",
11886
- help="Continue an in-progress release (infers vX.Y.Z from latest CHANGELOG header)",
11887
- )
11888
- p_release.add_argument(
11889
- "--dry-run",
11890
- action="store_true",
11891
- help="Print phase plan with diff/tag/release; mutate nothing; exit 0",
11892
- )
11893
- p_release.add_argument(
11894
- "--no-publish",
11895
- action="store_true",
11896
- help="Stop after Phase 2 (tag); skip Phases 3-6 (mirror, gh release, npm, brew)",
11897
- )
11898
- p_release.add_argument(
11899
- "--prerelease-id",
11900
- default="rc",
11901
- help="Override 'rc' for prerelease bumps (default 'rc')",
11902
- )
11903
- p_release.add_argument(
11904
- "--bump",
11905
- choices=["patch", "minor", "major"],
11906
- help="REQUIRED with `prerelease` when current is stable; REFUSED when current is prerelease",
11907
- )
11908
- p_release.add_argument(
11909
- "--remote",
11910
- default="origin",
11911
- help="Default 'origin' (private remote)",
11912
- )
11913
- p_release.add_argument(
11914
- "--allow-branch",
11915
- default=None,
11916
- help="Override the on-main refusal (escape hatch)",
11917
- )
11918
- p_release.add_argument(
11919
- "--public-clone",
11920
- default=None,
11921
- help="Override public-clone discovery (default: git config 'release.publicClone' or marker file)",
11922
- )
11923
- p_release.add_argument(
11924
- "--skip-npm",
11925
- action="store_true",
11926
- help="Skip Phase 5 (npm publish). Idempotent: re-running --resume without it picks the channel back up.",
11927
- )
11928
- p_release.add_argument(
11929
- "--brew-clone",
11930
- default=None,
11931
- help="Override brew tap discovery (default: git config 'release.brewClone' or marker file)",
11932
- )
11933
- p_release.add_argument(
11934
- "--skip-brew",
11935
- action="store_true",
11936
- help="Skip Phase 6 (brew formula bump). Idempotent: re-running --resume without it picks the channel back up.",
11937
- )
11938
- p_release.add_argument(
11939
- "--allow-formula-downgrade",
11940
- action="store_true",
11941
- help="Override Phase 6's monotonic-version gate (issue #30). Use only for intentional yank/revert cases — Phase 6 normally refuses to write a formula whose URL pins a lower SemVer than the on-disk formula.",
11942
- )
11943
- p_release.set_defaults(func=cmd_release)
10575
+ # `release` is its own standalone entry-point (bin/cctally-release);
10576
+ # no `release` subparser is registered on the main `cctally` CLI.
10577
+ # See docs/RELEASE.md.
11944
10578
 
11945
10579
  # ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
11946
10580
  ht = sub.add_parser(
@@ -12287,12 +10921,13 @@ def _share_history_recipe_id() -> str:
12287
10921
 
12288
10922
 
12289
10923
  def _share_resolve_version() -> str:
12290
- """Source from CHANGELOG via the existing release helper. Empty string if unset.
10924
+ """Source from CHANGELOG via the public helper. Empty string if unset.
12291
10925
 
12292
- `_release_read_latest_release_version` returns `(version, date) | None`;
12293
- the snapshot's `version` field carries the version string only.
10926
+ `_lib_changelog._read_latest_changelog_version` returns
10927
+ `(version, date) | None`; the snapshot's `version` field carries
10928
+ the version string only.
12294
10929
  """
12295
- info = _release_read_latest_release_version()
10930
+ info = _lib_changelog._read_latest_changelog_version()
12296
10931
  return info[0] if info else ""
12297
10932
 
12298
10933
 
@@ -13211,7 +11846,7 @@ def _build_project_snapshot(
13211
11846
 
13212
11847
 
13213
11848
  def _build_five_hour_blocks_snapshot(
13214
- rows: list[dict],
11849
+ view: "BlocksView",
13215
11850
  *,
13216
11851
  period_start: dt.datetime,
13217
11852
  period_end: dt.datetime,
@@ -13223,13 +11858,18 @@ def _build_five_hour_blocks_snapshot(
13223
11858
  ) -> "ShareSnapshot":
13224
11859
  """Build a ShareSnapshot for `cctally five-hour-blocks`.
13225
11860
 
13226
- `rows` is the list of per-block dicts produced inside
13227
- `cmd_five_hour_blocks` (sqlite Row converted to dict, with the
13228
- `__is_active` side-channel attached). Schema fields used:
13229
- `block_start_at` (ISO timestamp), `total_cost_usd`,
13230
- `final_five_hour_percent`, `crossed_seven_day_reset` (0/1 int),
13231
- `seven_day_pct_at_block_start`, `seven_day_pct_at_block_end`, plus
13232
- the synthetic `__is_active` flag.
11861
+ `view` is the ``BlocksView`` produced by
11862
+ ``build_blocks_view_from_table_rows`` (issue #56). The
11863
+ API-anchored block dicts (sqlite Row → dict with the
11864
+ ``__is_active`` / ``__credits`` side-channels attached) live on
11865
+ ``view.aggregated``; reset-aware totals come from
11866
+ ``view.total_cost_usd`` so the share footer reads from the typed
11867
+ single source rather than re-summing inline. Schema fields used
11868
+ from each dict: ``block_start_at`` (ISO timestamp),
11869
+ ``total_cost_usd``, ``final_five_hour_percent``,
11870
+ ``crossed_seven_day_reset`` (0/1 int),
11871
+ ``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
11872
+ plus the synthetic ``__is_active`` flag.
13233
11873
 
13234
11874
  Deviations from the plan sketch (which assumed dict rows with keys
13235
11875
  `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
@@ -13261,11 +11901,12 @@ def _build_five_hour_blocks_snapshot(
13261
11901
  the builder owns the canonical subtitle shape — no post-build
13262
11902
  re-stamp at the gate site.
13263
11903
 
13264
- Caller MUST pass `rows` already in the desired chronological order
13265
- (cmd_five_hour_blocks pulls newest-first; we reverse here so the
13266
- BarChart bars line up oldest→newest left-to-right). Tabular row
13267
- order in the snapshot is irrelevant because the snapshot is what
13268
- gets rendered (the gate site short-circuits the table renderer).
11904
+ Caller MUST pass a view whose ``aggregated`` block dicts are
11905
+ already in the desired chronological order (cmd_five_hour_blocks
11906
+ pulls newest-first; we reverse here so the BarChart bars line up
11907
+ oldest→newest left-to-right). Tabular row order in the snapshot is
11908
+ irrelevant because the snapshot is what gets rendered (the gate
11909
+ site short-circuits the table renderer).
13269
11910
  """
13270
11911
  _lib_share = _share_load_lib()
13271
11912
  columns = (
@@ -13278,9 +11919,11 @@ def _build_five_hour_blocks_snapshot(
13278
11919
  _lib_share.ColumnSpec(key="cross_reset", label="Reset",
13279
11920
  align="left"),
13280
11921
  )
13281
- # Reverse so BarChart x-axis runs oldest→newest (cmd_five_hour_blocks
13282
- # produces newest-first DESC); table-row order in the snapshot tracks
13283
- # chart order so consumer expectations align.
11922
+ # `view.aggregated` carries the newest-first DESC block dicts the
11923
+ # caller built from the SELECT. Reverse so BarChart x-axis runs
11924
+ # oldest→newest; table-row order tracks chart order so consumer
11925
+ # expectations align.
11926
+ rows = list(view.aggregated)
13284
11927
  chrono_rows = list(reversed(rows))
13285
11928
  snap_rows: list = []
13286
11929
  chart_pts: list = []
@@ -13336,7 +11979,11 @@ def _build_five_hour_blocks_snapshot(
13336
11979
  _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
13337
11980
  if chart_pts else None
13338
11981
  )
13339
- sum_cost = sum(p.y_value for p in chart_pts)
11982
+ # Reset-aware total comes from the BlocksView (issue #56); avg
11983
+ # divides by `chart_pts` count so the share footer "Sum" totalled
11984
+ # and the per-block `chart_pts` cost values share a single source-
11985
+ # of-truth at `view.total_cost_usd`.
11986
+ sum_cost = view.total_cost_usd
13340
11987
  avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
13341
11988
  crossed_count = sum(
13342
11989
  1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
@@ -13844,8 +12491,6 @@ cmd_tui = _cctally_tui.cmd_tui
13844
12491
  _tui_render_once = _cctally_tui._tui_render_once
13845
12492
 
13846
12493
 
13847
-
13848
-
13849
12494
  def main(argv: list[str] | None = None) -> int:
13850
12495
  _migrate_legacy_data_dir()
13851
12496
  parser = build_parser()
@@ -13854,7 +12499,7 @@ def main(argv: list[str] | None = None) -> int:
13854
12499
  # release header (issue #24); handle BEFORE subcommand dispatch so it
13855
12500
  # works without a subcommand (`cctally --version`).
13856
12501
  if getattr(args, "version", False):
13857
- v = _release_read_latest_release_version()
12502
+ v = _lib_changelog._read_latest_changelog_version()
13858
12503
  if v is None:
13859
12504
  print("cctally unknown")
13860
12505
  else: