cctally 1.78.0 → 1.79.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.
@@ -1066,6 +1066,13 @@ class DataSnapshot:
1066
1066
  # at sync-thread time so ``snapshot_to_envelope`` stays a pure
1067
1067
  # renderer; empty list when no current 5h block is bound.
1068
1068
  five_hour_milestones: list[dict] = field(default_factory=list)
1069
+ # ---- hero-modal historical milestones (spec §1a/§3) ----
1070
+ # Compact per-week navigation index for the CurrentWeekModal's week
1071
+ # chip (build_claude_week_index). Built ONLY on the non-idle rebuild
1072
+ # and stored here so ``snapshot_to_envelope`` stays a pure serializer;
1073
+ # the idle path carries it forward via ``dataclasses.replace``. Empty
1074
+ # list on first paint / when the DB read fails.
1075
+ week_index: list[dict] = field(default_factory=list)
1069
1076
  # ---- view-model unification (Bundle 1): pre-computed totals ----
1070
1077
  # Populated by the sync thread as sum-over-visible-rows over the
1071
1078
  # panel rows ``_dashboard_build_{daily,monthly,weekly}_periods``
@@ -3180,6 +3187,18 @@ def _tui_build_snapshot(
3180
3187
  fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
3181
3188
  except Exception as exc:
3182
3189
  errors.append(f"five-hour-milestones: {exc}")
3190
+ # ---- hero-modal historical milestones week index (spec §1a/§3) ----
3191
+ # Built ONLY here on the non-idle rebuild (the idle short-circuit
3192
+ # returns before this phase and carries the prior index forward via
3193
+ # ``dataclasses.replace``), so an idle dashboard issues NONE of the
3194
+ # index queries. Reached via the ``cctally`` namespace so tests can
3195
+ # monkeypatch it (a call-counter drives the hot-path guard).
3196
+ week_index: list[dict] = []
3197
+ with _perf.phase("build.week_index"):
3198
+ try:
3199
+ week_index = sys.modules["cctally"].build_claude_week_index(conn)
3200
+ except Exception as exc:
3201
+ errors.append(f"week-index: {exc}")
3183
3202
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
3184
3203
  # Per-tick aggregation lives on the sync thread; the dashboard's
3185
3204
  # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
@@ -3376,6 +3395,7 @@ def _tui_build_snapshot(
3376
3395
  daily_panel=daily_panel,
3377
3396
  alerts=alerts,
3378
3397
  five_hour_milestones=fh_milestones,
3398
+ week_index=week_index,
3379
3399
  daily_total_cost_usd=daily_total_cost_usd,
3380
3400
  daily_total_tokens=daily_total_tokens,
3381
3401
  monthly_total_cost_usd=monthly_total_cost_usd,
@@ -4165,6 +4185,9 @@ class _TuiSyncThread:
4165
4185
  last_sync_error=f"sync crashed: {exc}",
4166
4186
  generated_at=dt.datetime.now(dt.timezone.utc),
4167
4187
  percent_milestones=prev.percent_milestones,
4188
+ # Carry the navigation index forward on a sync crash so the
4189
+ # hero modal's week chip stays usable (spec §3 idle-carry).
4190
+ week_index=prev.week_index,
4168
4191
  weekly_history=prev.weekly_history,
4169
4192
  weekly_periods=prev.weekly_periods,
4170
4193
  monthly_periods=prev.monthly_periods,
@@ -316,7 +316,16 @@ class UpdateCheckRateLimited(UpdateError):
316
316
 
317
317
 
318
318
  class UpdateCheckHTTPError(UpdateError):
319
- """Non-200, non-429 HTTP status from a version-check endpoint."""
319
+ """Non-200, non-429 HTTP status from a version-check endpoint.
320
+
321
+ Carries the numeric ``status_code`` (when known) so the channel-aware
322
+ dist-tag fetch can distinguish an exact 404 (dist-tag absent →
323
+ transition-window fallback) from any other HTTP failure (which fails
324
+ the whole resolution, preserving last-known-good)."""
325
+
326
+ def __init__(self, message: str, *, status_code: "int | None" = None) -> None:
327
+ super().__init__(message)
328
+ self.status_code = status_code
320
329
 
321
330
 
322
331
  class UpdateCheckParseError(UpdateError):
@@ -387,6 +396,58 @@ def _validate_update_check_ttl_hours_value(raw) -> int:
387
396
  return n
388
397
 
389
398
 
399
+ # === update.channel config (beta-channel opt-in, spec 2026-07-21 §3) ======
400
+ # Distinct from the preview `channel` (prod|preview) in doctor / install.mode:
401
+ # this is the RELEASE channel the client tracks (stable|latest dist-tag vs the
402
+ # beta dist-tag). Internal name is `update_channel`; the config leaf is
403
+ # `update.channel`; API/SSE payloads expose `configured_channel`.
404
+ UPDATE_CHANNELS = ("stable", "beta")
405
+ UPDATE_CHANNEL_DEFAULT = "stable"
406
+
407
+
408
+ def _validate_update_channel_value(raw) -> str:
409
+ """Validate the `update.channel` config value.
410
+
411
+ Accepts a string (whitespace-trimmed, case-insensitive) in
412
+ :data:`UPDATE_CHANNELS`; returns the canonical lower-case form. Any
413
+ other value raises :class:`ValueError` so the CLI (`_cmd_config_set`)
414
+ and the dashboard (`_handle_post_settings`) can map it to their own
415
+ exit-code / HTTP-status semantics — mirrors the update.check
416
+ validators above.
417
+ """
418
+ if isinstance(raw, str):
419
+ v = raw.strip().lower()
420
+ if v in UPDATE_CHANNELS:
421
+ return v
422
+ raise ValueError(
423
+ f"invalid update.channel: {raw!r} "
424
+ f"(expected {'|'.join(UPDATE_CHANNELS)})"
425
+ )
426
+
427
+
428
+ def resolve_update_channel(config: dict) -> str:
429
+ """Return the configured release channel from `config`, defaulting to
430
+ ``"stable"``.
431
+
432
+ Fail-soft: a missing/non-dict `update` block, an absent `channel`
433
+ leaf, or a hand-edited junk value all surface the default (mirrors
434
+ the `dashboard.bind` / `update.check.*` read posture in
435
+ `_config_known_value`). The single source of truth for "which channel
436
+ is configured" across the check pipeline, install path, banner,
437
+ doctor, and the dashboard envelope.
438
+ """
439
+ block = config.get("update") if isinstance(config, dict) else None
440
+ if not isinstance(block, dict):
441
+ return UPDATE_CHANNEL_DEFAULT
442
+ stored = block.get("channel")
443
+ if stored is None:
444
+ return UPDATE_CHANNEL_DEFAULT
445
+ try:
446
+ return _validate_update_channel_value(stored)
447
+ except ValueError:
448
+ return UPDATE_CHANNEL_DEFAULT
449
+
450
+
390
451
  # === update-subcommand state-file / lock / log helpers (spec §1) =========
391
452
  # These live next to load_config / save_config because they share the
392
453
  # atomic-write idiom (PID-suffixed tmp + os.replace) and the schema-
@@ -973,7 +1034,7 @@ def _fetch_url(url: str, *, timeout: float | None = None) -> tuple[int, bytes]:
973
1034
  except urllib.error.HTTPError as e:
974
1035
  if e.code == 429:
975
1036
  raise UpdateCheckRateLimited(str(e))
976
- raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}")
1037
+ raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}", status_code=e.code)
977
1038
  except (urllib.error.URLError, TimeoutError) as e:
978
1039
  # URLError covers connection-setup failures; TimeoutError
979
1040
  # (socket.timeout's alias since 3.10) covers stalls during
@@ -1001,6 +1062,98 @@ def _check_npm_latest_version() -> str:
1001
1062
  raise UpdateCheckParseError(f"npm registry parse failed: {e}")
1002
1063
 
1003
1064
 
1065
+ def _fetch_npm_dist_tag_version(tag: str) -> "str | None":
1066
+ """Fetch the version bound to an npm dist-tag (`latest`/`beta`).
1067
+
1068
+ Returns the manifest's ``version`` field, or ``None`` **iff** the
1069
+ registry returns an exact HTTP 404 for the tag — i.e. the dist-tag
1070
+ does not exist (the beta-channel transition window). Any OTHER failure
1071
+ (5xx, 429, timeout, network, malformed JSON, missing `version`) raises
1072
+ the matching ``UpdateCheck*`` exception so the caller preserves the
1073
+ prior cached `latest_version` (last-known-good).
1074
+
1075
+ Endpoints: `latest` → :data:`UPDATE_NPM_REGISTRY_URL`; any other tag →
1076
+ :data:`UPDATE_NPM_BETA_REGISTRY_URL`. The per-tag env hook
1077
+ ``CCTALLY_TEST_UPDATE_NPM_<TAG>_STATUS`` (e.g. ``..._BETA_STATUS=404``)
1078
+ simulates a bare HTTP status for the golden harness, which serves
1079
+ `file://` fixtures and therefore cannot emit a genuine 404/5xx —
1080
+ mirrors the ``CCTALLY_TEST_UPDATE_NPM_URL`` fixture-URL hook.
1081
+ """
1082
+ c = _cctally()
1083
+ forced = os.environ.get(f"CCTALLY_TEST_UPDATE_NPM_{tag.upper()}_STATUS")
1084
+ if forced:
1085
+ try:
1086
+ code = int(forced.strip())
1087
+ except ValueError:
1088
+ code = 500
1089
+ if code == 404:
1090
+ return None
1091
+ if code == 429:
1092
+ raise UpdateCheckRateLimited(f"HTTP 429 (forced): dist-tag {tag}")
1093
+ raise UpdateCheckHTTPError(
1094
+ f"HTTP {code} (forced): dist-tag {tag}", status_code=code
1095
+ )
1096
+ url = (
1097
+ c.UPDATE_NPM_REGISTRY_URL if tag == "latest"
1098
+ else c.UPDATE_NPM_BETA_REGISTRY_URL
1099
+ )
1100
+ try:
1101
+ status, body = c._fetch_url(url)
1102
+ except UpdateCheckHTTPError as e:
1103
+ if getattr(e, "status_code", None) == 404:
1104
+ return None
1105
+ raise
1106
+ try:
1107
+ data = json.loads(body.decode("utf-8"))
1108
+ return data["version"]
1109
+ except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
1110
+ raise UpdateCheckParseError(f"npm dist-tag {tag} parse failed: {e}")
1111
+
1112
+
1113
+ def _resolve_npm_channel_target(channel: str) -> "tuple[str, str]":
1114
+ """Resolve the npm install target for ``channel`` → ``(version, dist_tag)``.
1115
+
1116
+ Stable: the `latest` dist-tag verbatim (byte-identical to today).
1117
+ Beta: **SemVer-max(dist-tags.beta, dist-tags.latest)** — an absent
1118
+ `beta` tag (exact 404) falls back to `latest` (transition window);
1119
+ any other beta-leg failure propagates and fails the whole resolution.
1120
+ Both legs are SemVer-validated before the max so a malformed/non-SemVer
1121
+ payload raises :class:`UpdateCheckParseError` (preserving last-known-
1122
+ good), never a silent bad target. ``dist_tag`` is which tag actually
1123
+ produced the returned version (`beta` only when beta strictly wins).
1124
+ """
1125
+ c = _cctally()
1126
+ latest = c._fetch_npm_dist_tag_version("latest")
1127
+ if latest is None:
1128
+ # `latest` should always exist; a 404 here is a real failure.
1129
+ raise UpdateCheckHTTPError(
1130
+ "npm dist-tag latest returned 404", status_code=404
1131
+ )
1132
+ c._semver_or_parse_error(latest)
1133
+ if channel != "beta":
1134
+ return (latest, "latest")
1135
+ beta = c._fetch_npm_dist_tag_version("beta")
1136
+ if beta is None:
1137
+ # Transition window: beta dist-tag not yet published → track latest.
1138
+ return (latest, "latest")
1139
+ c._semver_or_parse_error(beta)
1140
+ if c._semver_gt(beta, latest):
1141
+ return (beta, "beta")
1142
+ return (latest, "latest")
1143
+
1144
+
1145
+ def _semver_or_parse_error(version: str) -> str:
1146
+ """Validate ``version`` as SemVer, mapping a parse failure to
1147
+ :class:`UpdateCheckParseError` so it flows through the check
1148
+ pipeline's last-known-good preservation instead of escaping as a bare
1149
+ ``ValueError``."""
1150
+ try:
1151
+ _release_parse_semver(version)
1152
+ except ValueError:
1153
+ raise UpdateCheckParseError(f"non-SemVer version from npm: {version!r}")
1154
+ return version
1155
+
1156
+
1004
1157
  def _check_brew_latest_version() -> str:
1005
1158
  """Fetch the brew formula raw blob and extract the version.
1006
1159
 
@@ -1035,6 +1188,25 @@ def _is_update_check_due(config: dict) -> bool:
1035
1188
  enabled = check_cfg.get("enabled", True)
1036
1189
  if not enabled:
1037
1190
  return False
1191
+ # Channel-flip invalidation (beta-channel, spec 2026-07-21 §3): a
1192
+ # configured channel that differs from the last-attempted channel means
1193
+ # the cached `latest_version` was resolved for the wrong channel — treat
1194
+ # the TTL as expired and refresh immediately. `last_attempt_channel` is
1195
+ # stamped from config BEFORE the marker touch in `_do_update_check`, so
1196
+ # after one refresh it matches config again — no retry storm. Absent
1197
+ # field defaults to "stable" (back-compat with pre-feature state files).
1198
+ try:
1199
+ configured_channel = c.resolve_update_channel(config)
1200
+ prior_state = c._load_update_state()
1201
+ last_attempt = (prior_state or {}).get(
1202
+ "last_attempt_channel", "stable"
1203
+ )
1204
+ if configured_channel != last_attempt:
1205
+ return True
1206
+ except Exception:
1207
+ # Never let a state-read hiccup turn the TTL gate into a hard error;
1208
+ # fall through to the marker check.
1209
+ pass
1038
1210
  ttl_hours = check_cfg.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
1039
1211
  try:
1040
1212
  mtime = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
@@ -1056,8 +1228,30 @@ def _do_update_check() -> None:
1056
1228
  typed exception to a `check_status` enum (`rate_limited` /
1057
1229
  `fetch_failed` / `parse_failed`); never lose the prior
1058
1230
  `latest_version`. State is saved unconditionally on the way out.
1231
+
1232
+ Channel-aware (beta-channel, spec 2026-07-21 §3): reads the configured
1233
+ release channel and stamps ``last_attempt_channel`` from config BEFORE
1234
+ the marker touch + fetch (so a failed channel-switch fetch neither
1235
+ retry-storms — marker-first holds — nor mis-attributes stale data). On
1236
+ the npm vector, ``channel="beta"`` resolves SemVer-max(beta, latest);
1237
+ on a successful complete resolution ``latest_version_channel`` +
1238
+ ``resolved_dist_tag`` record what produced ``latest_version``. Brew is
1239
+ always stable (Q2). The stable-npm path is byte-identical to before.
1059
1240
  """
1060
1241
  c = _cctally()
1242
+ config = load_config()
1243
+ channel = c.resolve_update_channel(config)
1244
+
1245
+ # Stamp `last_attempt_channel` from config and PERSIST it BEFORE the
1246
+ # marker touch + network fetch. Even if the fetch then crashes, the
1247
+ # attempted channel is on disk, so `_is_update_check_due`'s channel-flip
1248
+ # bypass sees config==last_attempt and defers to the (now-touched)
1249
+ # marker — no retry storm; `latest_version_channel` (written only on
1250
+ # success) still names the OLD producer, so no mis-attribution.
1251
+ state = c._load_update_state() or {"_schema": 1}
1252
+ state["last_attempt_channel"] = channel
1253
+ c._save_update_state(state)
1254
+
1061
1255
  # Touch marker FIRST — crash safety: a dead process mid-fetch must
1062
1256
  # not trigger another fetch within the TTL window.
1063
1257
  _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -1066,6 +1260,9 @@ def _do_update_check() -> None:
1066
1260
  method = c._detect_install_method(mutate=True)
1067
1261
 
1068
1262
  state = c._load_update_state() or {"_schema": 1}
1263
+ # Belt-and-suspenders: detection preserves other fields, but keep the
1264
+ # attempted channel authoritative on the object we save at the end.
1265
+ state["last_attempt_channel"] = channel
1069
1266
  cur = _release_read_latest_release_version()
1070
1267
  if cur:
1071
1268
  state["current_version"] = cur[0]
@@ -1077,12 +1274,25 @@ def _do_update_check() -> None:
1077
1274
 
1078
1275
  try:
1079
1276
  if method.method == "npm":
1080
- latest = c._check_npm_latest_version()
1081
- state["latest_version"] = latest
1277
+ if channel == "beta":
1278
+ latest, dist_tag = c._resolve_npm_channel_target("beta")
1279
+ state["latest_version"] = latest
1280
+ state["latest_version_channel"] = "beta"
1281
+ state["resolved_dist_tag"] = dist_tag
1282
+ else:
1283
+ latest = c._check_npm_latest_version()
1284
+ state["latest_version"] = latest
1285
+ state["latest_version_channel"] = "stable"
1286
+ state["resolved_dist_tag"] = "latest"
1082
1287
  state["source"] = "npm-registry"
1083
1288
  elif method.method == "brew":
1289
+ # Brew tracks stable only (Q2) — no dist-tag concept. The
1290
+ # beta+brew advisory rides on the CLI --check / doctor surfaces,
1291
+ # not here.
1084
1292
  latest = c._check_brew_latest_version()
1085
1293
  state["latest_version"] = latest
1294
+ state["latest_version_channel"] = "stable"
1295
+ state["resolved_dist_tag"] = None
1086
1296
  state["source"] = "github-formula"
1087
1297
  else:
1088
1298
  # Unknown install method — no remote check possible
@@ -1233,6 +1443,48 @@ def _format_update_command(method: str, version: str | None) -> str:
1233
1443
  return ""
1234
1444
 
1235
1445
 
1446
+ # Advisory shown on the beta channel for a brew install: brew IS the stable
1447
+ # channel (Q2), so a beta opt-in on brew resolves stable + this line.
1448
+ BREW_BETA_ADVISORY = (
1449
+ "beta channel unavailable for brew installs — use npm or source"
1450
+ )
1451
+
1452
+
1453
+ def _resolved_install_target(state: dict, channel: str) -> "str | None":
1454
+ """The exact version a bare (unpinned) beta-channel npm install targets.
1455
+
1456
+ On the beta channel, npm installs the EXACT resolved version
1457
+ (`npm install -g cctally@X.Y.Z`), never bare `@beta` — whose registry
1458
+ target can disagree with the advertised SemVer-max(beta, latest) and
1459
+ which doesn't exist in the transition window. Returns the cached
1460
+ `latest_version` (the resolved max, written by the check pipeline) for
1461
+ an npm beta channel, else ``None`` (stable → `@latest`; brew has no
1462
+ versioned formulae). Callers pass the explicit `--version` pin ahead of
1463
+ this — the pin always wins.
1464
+ """
1465
+ method = (state.get("install") or {}).get("method", "unknown")
1466
+ if method == "npm" and channel == "beta":
1467
+ return state.get("latest_version")
1468
+ return None
1469
+
1470
+
1471
+ def _resolved_update_command(state: dict, config: "dict | None") -> str:
1472
+ """Channel-aware install command for the `--check` renderers / envelope.
1473
+
1474
+ Stable and brew are byte-identical to `_format_update_command(method,
1475
+ None)`. On an npm beta channel it pins the exact resolved version
1476
+ (`cctally@X.Y.Z`) so every surface — CLI `--check`, `--json`, dashboard
1477
+ badge/modal — advertises the same target and command.
1478
+ """
1479
+ c = _cctally()
1480
+ method = (state.get("install") or {}).get("method", "unknown")
1481
+ channel = c.resolve_update_channel(config or {})
1482
+ target = _resolved_install_target(state, channel)
1483
+ if target:
1484
+ return c._format_update_command(method, target)
1485
+ return c._format_update_command(method, None)
1486
+
1487
+
1236
1488
  def _prerelease_note(current: str) -> str | None:
1237
1489
  """Spec §1.8 — prerelease users get a one-shot informational note in
1238
1490
  `--check` output. Returns the canned two-line message verbatim per
@@ -1247,9 +1499,15 @@ def _prerelease_note(current: str) -> str | None:
1247
1499
 
1248
1500
 
1249
1501
  def _format_update_check_json(
1250
- state: dict[str, Any], suppress: dict[str, Any]
1502
+ state: dict[str, Any], suppress: dict[str, Any],
1503
+ config: "dict | None" = None,
1251
1504
  ) -> dict[str, Any]:
1252
- """JSON shape for `cctally update --check --json` (spec §4.4)."""
1505
+ """JSON shape for `cctally update --check --json` (spec §4.4).
1506
+
1507
+ ``config`` is optional and defaults to stable — passing ``None`` keeps
1508
+ the stable envelope byte-identical to before. On an npm beta channel,
1509
+ ``update_command`` pins the exact resolved version (spec 2026-07-21 §3).
1510
+ """
1253
1511
  c = _cctally()
1254
1512
  cur = state.get("current_version")
1255
1513
  lat = state.get("latest_version")
@@ -1281,7 +1539,7 @@ def _format_update_check_json(
1281
1539
  "latest_version": lat,
1282
1540
  "available": available,
1283
1541
  "method": method,
1284
- "update_command": c._format_update_command(method, None),
1542
+ "update_command": c._resolved_update_command(state, config),
1285
1543
  "release_notes_url": state.get("latest_version_url"),
1286
1544
  "check_status": state.get("check_status"),
1287
1545
  "check_error": state.get("check_error"),
@@ -1304,7 +1562,8 @@ _UPDATE_METHOD_HUMAN_LABEL = {
1304
1562
 
1305
1563
 
1306
1564
  def _format_update_check_human(
1307
- state: dict[str, Any], suppress: dict[str, Any]
1565
+ state: dict[str, Any], suppress: dict[str, Any],
1566
+ config: "dict | None" = None,
1308
1567
  ) -> str:
1309
1568
  """Multi-line plaintext block for `cctally update --check` (spec §4.4).
1310
1569
 
@@ -1312,8 +1571,14 @@ def _format_update_check_human(
1312
1571
  (`Will run` is the longest at 8 chars + 2-space gutter). Method row
1313
1572
  appends ` (auto-detected)` per spec example. Up-to-date / unknown
1314
1573
  variants append a fallback line below the table.
1574
+
1575
+ ``config`` is optional and defaults to stable — passing ``None`` keeps
1576
+ the stable output byte-identical. On an npm beta channel the `Will run`
1577
+ line pins the exact resolved version; a beta opt-in on brew appends the
1578
+ ``BREW_BETA_ADVISORY`` line (brew tracks stable only, Q2).
1315
1579
  """
1316
1580
  c = _cctally()
1581
+ channel = c.resolve_update_channel(config or {})
1317
1582
  cur = state.get("current_version") or "unknown"
1318
1583
  lat = state.get("latest_version") or "unknown"
1319
1584
  method = (state.get("install") or {}).get("method", "unknown")
@@ -1334,7 +1599,7 @@ def _format_update_check_human(
1334
1599
  f"{'Latest':<10}{lat}",
1335
1600
  f"{'Method':<10}{method_label} (auto-detected)",
1336
1601
  ]
1337
- will_run = c._format_update_command(method, None)
1602
+ will_run = c._resolved_update_command(state, config)
1338
1603
  if will_run:
1339
1604
  lines.append(f"{'Will run':<10}{will_run}")
1340
1605
  if url:
@@ -1342,6 +1607,10 @@ def _format_update_check_human(
1342
1607
  if status and status != "ok":
1343
1608
  status_value = status + (f" ({err})" if err else "")
1344
1609
  lines.append(f"{'Status':<10}{status_value}")
1610
+ if channel == "beta" and method == "brew":
1611
+ # Brew IS the stable channel (Q2). Surface the advisory so a beta
1612
+ # opt-in on brew explains why it resolves stable.
1613
+ lines.append(f"{'Channel':<10}{c.BREW_BETA_ADVISORY}")
1345
1614
  lines.append("")
1346
1615
  if method == "unknown":
1347
1616
  # No remote check is possible for source / dev installs; render
@@ -1486,9 +1755,11 @@ def _do_update_check_user(*, force: bool, output_json: bool) -> int:
1486
1755
  return 0
1487
1756
  suppress = c._load_update_suppress()
1488
1757
  if output_json:
1489
- print(json.dumps(c._format_update_check_json(state, suppress), indent=2))
1758
+ print(json.dumps(
1759
+ c._format_update_check_json(state, suppress, config), indent=2
1760
+ ))
1490
1761
  else:
1491
- print(c._format_update_check_human(state, suppress))
1762
+ print(c._format_update_check_human(state, suppress, config))
1492
1763
  return 0
1493
1764
 
1494
1765
 
@@ -1649,11 +1920,54 @@ def _do_update_install(
1649
1920
  / write-perm-denied; :class:`UpdateValidationError` (rc=2) for
1650
1921
  invalid --version / --version+brew. The boundary distinction is
1651
1922
  enforced by :func:`cmd_update`'s try/except below.
1923
+
1924
+ Channel-aware (beta-channel, spec 2026-07-21 §3): a bare (unpinned)
1925
+ npm install on the beta channel targets the EXACT resolved version
1926
+ (`cctally@X.Y.Z`), never bare `@beta`; a bare install (either channel)
1927
+ refuses as a no-op (exit 0) when the resolved target is not SemVer-newer
1928
+ than the installed version — this makes a beta→stable flip-back safe
1929
+ (no silent downgrade to `@latest`). An explicit `--version` pin always
1930
+ overrides both. Brew tracks stable only (Q2): a beta opt-in on brew
1931
+ prints the advisory and proceeds as a stable upgrade.
1652
1932
  """
1653
1933
  c = _cctally()
1654
1934
  method = c._detect_install_method(mutate=not dry_run)
1655
- c._preflight_install(method, version)
1656
- steps = c._build_update_steps(method, version)
1935
+ config = load_config()
1936
+ channel = c.resolve_update_channel(config)
1937
+ state = c._load_update_state() or {}
1938
+
1939
+ # Beta resolves the exact target from the cached max(beta, latest); an
1940
+ # explicit --version pin always wins (the deliberate override).
1941
+ resolved_version = version
1942
+ if resolved_version is None:
1943
+ resolved_version = c._resolved_install_target(state, channel)
1944
+
1945
+ # Downgrade refusal for a BARE (unpinned) install: no-op + exit 0 when
1946
+ # the resolved target is not SemVer-newer than the installed version.
1947
+ if version is None and method.method in ("npm", "brew"):
1948
+ target = resolved_version or state.get("latest_version")
1949
+ current = state.get("current_version")
1950
+ if target and current:
1951
+ try:
1952
+ newer = c._semver_gt(target, current)
1953
+ except ValueError:
1954
+ newer = True # can't compare → don't block a real upgrade
1955
+ if not newer:
1956
+ print(
1957
+ f"cctally is already up to date on the {channel} channel "
1958
+ f"(installed {current}, latest {target}). Nothing to do.\n"
1959
+ "To install a specific version anyway, run "
1960
+ "`cctally update --version X.Y.Z`."
1961
+ )
1962
+ return 0
1963
+
1964
+ # Brew tracks stable only (Q2): surface the advisory, then proceed as a
1965
+ # normal (stable) brew upgrade.
1966
+ if channel == "beta" and method.method == "brew":
1967
+ print(f"Note: {c.BREW_BETA_ADVISORY}.")
1968
+
1969
+ c._preflight_install(method, resolved_version)
1970
+ steps = c._build_update_steps(method, resolved_version)
1657
1971
  if dry_run:
1658
1972
  for name, cmd in steps:
1659
1973
  if output_json:
@@ -1679,7 +1993,9 @@ def _do_update_install(
1679
1993
  if rc != 0:
1680
1994
  return 1
1681
1995
  _log_update_event(log_fd, "INSTALL_SUCCESS")
1682
- c._stamp_install_success_to_state(version, method)
1996
+ # Stamp the EXACT installed version (the beta-resolved target
1997
+ # when unpinned on beta; else the pin / fresh-CHANGELOG fallback).
1998
+ c._stamp_install_success_to_state(resolved_version, method)
1683
1999
  return 0
1684
2000
  finally:
1685
2001
  c._release_update_lock(lock_fd)
@@ -2234,10 +2550,17 @@ def _format_update_banner(state: dict[str, Any]) -> str:
2234
2550
 
2235
2551
  Includes the dismissal recipe inline so the user never has to
2236
2552
  consult docs to silence it.
2553
+
2554
+ Channel marker (beta-channel, spec 2026-07-21 §3): a ``(beta)`` marker
2555
+ lands after the version when the last-attempted channel was beta —
2556
+ ``↑ cctally 1.77.0 (beta) available…``. Gating stays in
2557
+ ``_should_show_update_banner`` (unchanged); this formatter only adds the
2558
+ marker, so the stable banner is byte-identical.
2237
2559
  """
2238
2560
  cur = state["current_version"]
2239
2561
  lat = state["latest_version"]
2562
+ marker = " (beta)" if state.get("last_attempt_channel") == "beta" else ""
2240
2563
  return (
2241
- f"↑ cctally {lat} available (you're on {cur}). "
2564
+ f"↑ cctally {lat}{marker} available (you're on {cur}). "
2242
2565
  f"Run `cctally update`. Skip: cctally update --skip {lat}"
2243
2566
  )
@@ -72,7 +72,11 @@ def _get_canonical_boundary_for_date(
72
72
  return None, None
73
73
 
74
74
 
75
- def get_recent_weeks(conn: sqlite3.Connection, limit: int) -> list[WeekRef]:
75
+ def get_recent_weeks(conn: sqlite3.Connection, limit: "int | None") -> list[WeekRef]:
76
+ # ``limit=None`` → SQL ``LIMIT -1`` (unbounded): the milestone-history
77
+ # index (#hero-milestone-history) enumerates EVERY navigable week with no
78
+ # depth cap. Existing callers pass a concrete int and are unaffected.
79
+ limit_sql = -1 if limit is None else int(limit)
76
80
  rows = conn.execute(
77
81
  """
78
82
  SELECT week_start_date, MAX(week_end_date) AS week_end_date
@@ -85,7 +89,7 @@ def get_recent_weeks(conn: sqlite3.Connection, limit: int) -> list[WeekRef]:
85
89
  ORDER BY week_start_date DESC
86
90
  LIMIT ?
87
91
  """,
88
- (limit,),
92
+ (limit_sql,),
89
93
  ).fetchall()
90
94
 
91
95
  refs: list[WeekRef] = []