cctally 1.65.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -177,6 +177,23 @@ class DoctorState:
177
177
  # valid and default to the enabled (opt-out) posture.
178
178
  telemetry_enabled: bool = True
179
179
  telemetry_reason: str = "enabled"
180
+ # #279 S2 (F5a): rolling ingest parse-health records read from
181
+ # cache_meta keys parse_health_claude / parse_health_codex (JSON
182
+ # dicts; see _cctally_cache._update_parse_health_meta). None = key
183
+ # absent (pre-first-sync) or cache unreadable — check degrades OK.
184
+ parse_health_claude: Optional[dict] = None
185
+ parse_health_codex: Optional[dict] = None
186
+ # #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
187
+ # doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
188
+ # rebuild loop calls the gather every rebuild and quick_check on a
189
+ # large cache.db costs seconds. "ok" | first error line |
190
+ # "open failed: ..." | None = not run.
191
+ stats_db_quick_check: Optional[str] = None
192
+ cache_db_quick_check: Optional[str] = None
193
+ # #279 S2 (F5c): non-blocking flock probes on the two sync lock files
194
+ # (name -> True held / False free / None unreadable). Probe never
195
+ # creates files (doctor read-only contract). None = probe errored.
196
+ locks_held: Optional[dict] = None
180
197
 
181
198
 
182
199
  @dataclasses.dataclass(frozen=True)
@@ -1207,6 +1224,122 @@ def _check_telemetry(s: DoctorState) -> CheckResult:
1207
1224
  )
1208
1225
 
1209
1226
 
1227
+ _PARSE_HEALTH_RECENCY_DAYS = 7
1228
+
1229
+
1230
+ def _check_data_parse_health(s: DoctorState) -> CheckResult:
1231
+ details = {"claude": s.parse_health_claude, "codex": s.parse_health_codex}
1232
+ recent, historical_total = [], 0
1233
+ for label, ph in (("claude", s.parse_health_claude),
1234
+ ("codex", s.parse_health_codex)):
1235
+ if not isinstance(ph, dict):
1236
+ continue
1237
+ malformed = int(ph.get("lines_malformed", 0) or 0)
1238
+ skipped = int(ph.get("lines_skipped", 0) or 0)
1239
+ historical_total += malformed + skipped
1240
+ last_anomaly = ph.get("last_anomaly_at")
1241
+ if not last_anomaly or (malformed + skipped) == 0:
1242
+ continue
1243
+ try:
1244
+ at = dt.datetime.fromisoformat(
1245
+ str(last_anomaly).replace("Z", "+00:00"))
1246
+ if at.tzinfo is None:
1247
+ at = at.replace(tzinfo=dt.timezone.utc)
1248
+ except ValueError:
1249
+ continue
1250
+ if (s.now_utc - at).total_seconds() <= \
1251
+ _PARSE_HEALTH_RECENCY_DAYS * 86400.0:
1252
+ reasons = ph.get("reasons") \
1253
+ if isinstance(ph.get("reasons"), dict) else {}
1254
+ top = max(reasons.items(), key=lambda kv: kv[1])[0] \
1255
+ if reasons else None
1256
+ recent.append((label, malformed, skipped, top))
1257
+ if recent:
1258
+ parts = []
1259
+ for label, malformed, skipped, top in recent:
1260
+ frag = f"{label}: {malformed} malformed / {skipped} drift-skipped"
1261
+ if top:
1262
+ frag += f" (top reason: {top})"
1263
+ parts.append(frag)
1264
+ return CheckResult(
1265
+ id="data.parse_health", title="Ingest parse health",
1266
+ severity="warn",
1267
+ summary="; ".join(parts)
1268
+ + f" within {_PARSE_HEALTH_RECENCY_DAYS}d",
1269
+ remediation=(
1270
+ "JSONL lines are failing to parse — a Claude Code / Codex "
1271
+ "update may have changed the session format. Check for a "
1272
+ "cctally update or file an issue; `cctally cache-sync "
1273
+ "--rebuild` re-baselines the counters."),
1274
+ details=details,
1275
+ )
1276
+ if s.parse_health_claude is None and s.parse_health_codex is None:
1277
+ summary = "no parse-health data yet (pre-first-sync)"
1278
+ elif historical_total:
1279
+ summary = (f"no recent anomalies ({historical_total} historical; "
1280
+ f"see details)")
1281
+ else:
1282
+ summary = "no parse anomalies"
1283
+ return CheckResult(
1284
+ id="data.parse_health", title="Ingest parse health",
1285
+ severity="ok", summary=summary, remediation=None, details=details,
1286
+ )
1287
+
1288
+
1289
+ def _check_db_integrity(s: DoctorState) -> CheckResult:
1290
+ details = {"stats_quick_check": s.stats_db_quick_check,
1291
+ "cache_quick_check": s.cache_db_quick_check}
1292
+ if s.stats_db_quick_check is not None and s.stats_db_quick_check != "ok":
1293
+ return CheckResult(
1294
+ id="db.integrity", title="Integrity", severity="fail",
1295
+ summary=f"stats.db quick_check: {s.stats_db_quick_check}",
1296
+ remediation=(
1297
+ "stats.db (the non-re-derivable DB) reports corruption. "
1298
+ "Back up the file FIRST, then try "
1299
+ "`sqlite3 <path> \".recover\"` into a fresh file; "
1300
+ "`cctally db recover` does not repair corruption. Please "
1301
+ "file a bug. Do not delete the file."),
1302
+ details=details,
1303
+ )
1304
+ if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
1305
+ return CheckResult(
1306
+ id="db.integrity", title="Integrity", severity="warn",
1307
+ summary=f"cache.db quick_check: {s.cache_db_quick_check}",
1308
+ remediation=("cache.db is re-derivable — run "
1309
+ "`cctally cache-sync --rebuild`."),
1310
+ details=details,
1311
+ )
1312
+ if s.stats_db_quick_check is None and s.cache_db_quick_check is None:
1313
+ return CheckResult(
1314
+ id="db.integrity", title="Integrity", severity="ok",
1315
+ summary="not checked (fast gather — run `cctally doctor`)",
1316
+ remediation=None, details=details,
1317
+ )
1318
+ return CheckResult(
1319
+ id="db.integrity", title="Integrity", severity="ok",
1320
+ summary="quick_check ok", remediation=None, details=details,
1321
+ )
1322
+
1323
+
1324
+ def _check_db_lock_state(s: DoctorState) -> CheckResult:
1325
+ locks = s.locks_held if isinstance(s.locks_held, dict) else {}
1326
+ held = sorted(k for k, v in locks.items() if v is True)
1327
+ details = {"locks": s.locks_held}
1328
+ if held:
1329
+ return CheckResult(
1330
+ id="db.lock_state", title="Locks", severity="ok",
1331
+ summary=(", ".join(held) + " held — an active sync or "
1332
+ "dashboard is running; a hold persisting across "
1333
+ "repeated doctor runs may indicate a wedged process"),
1334
+ remediation=None, details=details,
1335
+ )
1336
+ return CheckResult(
1337
+ id="db.lock_state", title="Locks", severity="ok",
1338
+ summary="free" if locks else "no lock files present",
1339
+ remediation=None, details=details,
1340
+ )
1341
+
1342
+
1210
1343
  # Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
1211
1344
  # The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
1212
1345
  # fingerprint identity-slice key (spec §5.5). When an evaluator raises,
@@ -1232,14 +1365,17 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1232
1365
  ("db", "Database", (
1233
1366
  ("db.stats.file", "_check_db_stats_file"),
1234
1367
  ("db.cache.file", "_check_db_cache_file"),
1368
+ ("db.integrity", "_check_db_integrity"),
1235
1369
  ("db.version_ahead", "_check_db_version_ahead"),
1236
1370
  ("db.migrations.applied", "_check_db_migrations_applied"),
1237
1371
  ("db.migrations.pending", "_check_db_migrations_pending"),
1372
+ ("db.lock_state", "_check_db_lock_state"),
1238
1373
  )),
1239
1374
  ("data", "Data", (
1240
1375
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
1241
1376
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
1242
1377
  ("data.codex_cache", "_check_data_codex_cache"),
1378
+ ("data.parse_health", "_check_data_parse_health"),
1243
1379
  ("data.forked_buckets", "_check_data_forked_buckets"),
1244
1380
  ("data.post_credit_milestones", "_check_data_post_credit_milestones"),
1245
1381
  ("data.conversation_sessions_rollup",
@@ -1320,7 +1456,20 @@ def run_checks(state: DoctorState) -> DoctorReport:
1320
1456
 
1321
1457
 
1322
1458
  def _iso_z(d: dt.datetime) -> str:
1323
- """Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention)."""
1459
+ """Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention).
1460
+
1461
+ DELIBERATELY DIVERGES from the canonical ``_lib_json_envelope._iso_z``
1462
+ (#279 S6 W4) on two axes and therefore KEEPS this local name (gate F11):
1463
+ (1) a NAIVE datetime is treated as UTC via ``replace(tzinfo=utc)`` — the
1464
+ canonical's ``astimezone(utc)`` would reinterpret it as host-local; and
1465
+ (2) microseconds are preserved via ``isoformat()`` — the canonical floors
1466
+ to whole seconds via ``%S``. Renaming this to the canonical would silently
1467
+ degrade the doctor block for the cross-module consumers that resolve it by
1468
+ name through the sibling loader (``_cctally_tui`` and
1469
+ ``_cctally_dashboard_envelope``'s doctor-envelope builders, whose
1470
+ try/except swallows a rename). Divergence pinned by
1471
+ ``tests/test_lib_doctor.py::test_doctor_iso_z_naive_means_utc_and_keeps_microseconds``.
1472
+ """
1324
1473
  if d.tzinfo is None:
1325
1474
  d = d.replace(tzinfo=dt.timezone.utc)
1326
1475
  s = d.astimezone(dt.timezone.utc).isoformat()
@@ -23,11 +23,45 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
23
23
  from __future__ import annotations
24
24
 
25
25
  import datetime as dt
26
+ import math
26
27
 
27
28
 
28
29
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = 600 # 10 minutes; tolerance band for resets_at jitter
29
30
 
30
31
 
32
+ def five_hour_milestone_range(pct: float, max_existing: "int | None") -> range:
33
+ """Which integer 5h-% thresholds ``maybe_update_five_hour_block`` should
34
+ attempt to record for ``pct``, given the ACTIVE segment's highest already-
35
+ recorded threshold ``max_existing`` (#279 S4 F4).
36
+
37
+ Mirrors the milestone-detection loop's fencing exactly (glue call site:
38
+ ``maybe_update_five_hour_block``'s 5h-% milestone loop):
39
+
40
+ - ``current_floor = math.floor(pct + 1e-9)`` — the 1e-9 snap flushes the
41
+ ``0.50 * 100 == 49.99…`` ULP so the N threshold is not missed.
42
+ - ``current_floor < 1`` → empty range (the ``if current_floor >= 1``
43
+ glue guard).
44
+ - ``start_threshold = current_floor`` when ``max_existing is None``
45
+ (first observation records ONLY the current floor — no synthetic
46
+ 1..floor-1 backfill), else ``int(max_existing) + 1``.
47
+ - result = ``range(start_threshold, current_floor + 1)`` — empty when
48
+ ``start_threshold > current_floor`` (already at/above the floor),
49
+ which the glue reads as ``if milestone_range:``.
50
+
51
+ Pure: the MAX(percent_threshold) probe that yields ``max_existing``, the
52
+ marginal-cost lookup, the per-threshold INSERT, and the alert plumbing all
53
+ stay in glue. (The weekly ``maybe_record_milestone`` loop shares this
54
+ fencing formula but keeps its own range in glue — its crossing decision
55
+ early-returns BEFORE the cost sync when covered, a structure this
56
+ range-only kernel does not encapsulate; spec §6 defers it.)
57
+ """
58
+ current_floor = math.floor(pct + 1e-9)
59
+ if current_floor < 1:
60
+ return range(0)
61
+ start_threshold = current_floor if max_existing is None else int(max_existing) + 1
62
+ return range(start_threshold, current_floor + 1)
63
+
64
+
31
65
  def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
32
66
  """Floor a datetime to the previous 10-minute boundary.
33
67
 
@@ -47,6 +81,33 @@ def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
47
81
  )
48
82
 
49
83
 
84
+ def _round_to_ten_minutes(d: dt.datetime) -> dt.datetime:
85
+ """Round a datetime to the NEAREST 10-minute boundary (half up).
86
+
87
+ The *display*-side companion to ``_floor_to_ten_minutes``. Anthropic
88
+ ``rate_limits.5h.resets_at`` (and the derived ``block_start_at``,
89
+ ``block_start = reset - 5h``) carries sub-10-minute capture jitter, so
90
+ a reset that truly lands on ``:40`` can be recorded as ``:39``.
91
+ Flooring that for display shows ``:30`` — off by a full bucket — so
92
+ every user-facing 5h-block clock time rounds to the nearest boundary
93
+ instead.
94
+
95
+ Rounds the ABSOLUTE instant (epoch, tz-independent) rather than the
96
+ local minute, so it is correct regardless of the display zone's offset
97
+ (the reset is a fixed UTC instant). Returns a UTC-aware datetime;
98
+ callers hand it to ``format_display_dt`` for zone conversion.
99
+
100
+ NEVER use this for keys / partitioning / lookups — those require the
101
+ exact stored timestamp (issue #76). This is a render-only normalizer.
102
+ """
103
+ if d.tzinfo is None:
104
+ d = d.replace(tzinfo=dt.timezone.utc)
105
+ bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS # 600s = 10 min
106
+ # floor(x + 0.5) is predictable half-UP (Python's round() is banker's).
107
+ rounded = math.floor(d.timestamp() / bucket + 0.5) * bucket
108
+ return dt.datetime.fromtimestamp(rounded, dt.timezone.utc)
109
+
110
+
50
111
  def _canonical_5h_window_key(
51
112
  resets_at_epoch: int,
52
113
  prior_epoch: int | None = None,
@@ -0,0 +1,144 @@
1
+ """Forecast decision kernel for cctally.
2
+
3
+ Pure-fn layer (no I/O at import time): the forecast inputs/output/budget
4
+ dataclasses plus `_compute_forecast` — the projection + budget-headroom
5
+ math. Values in (`ForecastInputs`), a `ForecastOutput` out. No DB reads,
6
+ no config loads, no `_cctally()` accessor: the projection routes through
7
+ `project_linear` (the shared, pure primitive whose real home is
8
+ `_lib_budget`).
9
+
10
+ Imported by `_cctally_forecast.py` (and `_cctally_tui.py`) and re-exported
11
+ on the `cctally` namespace via `bin/cctally`, so the existing
12
+ `cctally.ForecastInputs` / `mod.ForecastOutput` / `inspect.getsource(
13
+ cctally._compute_forecast)` read paths resolve unchanged (re-export
14
+ continuity, spec §2). Single definition of each dataclass lives here;
15
+ everything else imports it, so class identity stays unique.
16
+
17
+ Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import datetime as dt
22
+ from dataclasses import dataclass
23
+
24
+ from _lib_budget import project_linear
25
+
26
+
27
+ @dataclass
28
+ class ForecastInputs:
29
+ now_utc: dt.datetime
30
+ week_start_at: dt.datetime
31
+ week_end_at: dt.datetime
32
+ elapsed_hours: float
33
+ elapsed_fraction: float
34
+ remaining_hours: float
35
+ remaining_days: float
36
+ # Current state
37
+ p_now: float
38
+ five_hour_percent: float | None
39
+ spent_usd: float
40
+ snapshot_count: int
41
+ latest_snapshot_at: dt.datetime
42
+ # Rate inputs
43
+ p_24h_ago: float | None
44
+ t_24h_actual_hours: float | None
45
+ # $/1% selection
46
+ dollars_per_percent: float
47
+ dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
48
+ # Confidence
49
+ confidence: str # "high" | "low"
50
+ low_confidence_reasons: list[str]
51
+
52
+
53
+ @dataclass
54
+ class BudgetRow:
55
+ target_percent: int
56
+ pct_headroom: float | None # None when already past target
57
+ dollars_per_day: float | None
58
+ percent_per_day: float | None
59
+
60
+
61
+ @dataclass
62
+ class ForecastOutput:
63
+ inputs: ForecastInputs
64
+ r_avg: float # pct per hour, week-avg
65
+ r_recent: float | None # pct per hour, 24h recent; None if no prior sample
66
+ final_percent_low: float
67
+ final_percent_high: float
68
+ week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
69
+ projected_cap: bool
70
+ already_capped: bool
71
+ cap_at: dt.datetime | None
72
+ budgets: list[BudgetRow]
73
+
74
+
75
+ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
76
+ """Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
77
+ # Rate methods
78
+ r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
79
+ if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
80
+ r_recent: float | None = max(
81
+ 0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
82
+ )
83
+ else:
84
+ r_recent = None
85
+
86
+ # Projected final % — routed through the shared project_linear primitive
87
+ # (spec F1). r_recent is None ⇒ collapse to the average projection.
88
+ if r_recent is None:
89
+ final_low, final_high = project_linear(
90
+ inputs.p_now, inputs.remaining_hours, r_avg, r_avg
91
+ )
92
+ else:
93
+ a, b = project_linear(
94
+ inputs.p_now, inputs.remaining_hours, r_avg, r_recent
95
+ )
96
+ final_low, final_high = min(a, b), max(a, b)
97
+
98
+ # Smooth week-average projection (additive surface field). Distinct from
99
+ # the displayed band (which keys off final_high): this is the conservative
100
+ # week-average value the projected-pace alert axis fires on.
101
+ # p_now + r_avg*remaining (== project_linear collapsed to the single rate).
102
+ week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
103
+
104
+ already_capped = inputs.p_now >= 100.0
105
+ projected_cap = already_capped or final_high >= 100.0
106
+
107
+ cap_at: dt.datetime | None = None
108
+ if not already_capped and projected_cap:
109
+ r_pessimistic = max(r_avg, r_recent or 0.0)
110
+ if r_pessimistic > 0:
111
+ hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
112
+ if hours_to_cap < inputs.remaining_hours:
113
+ cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
114
+
115
+ # Budgets
116
+ budgets: list[BudgetRow] = []
117
+ if not already_capped:
118
+ for t in sorted(targets, reverse=True):
119
+ headroom = t - inputs.p_now
120
+ if headroom <= 0 or inputs.remaining_days <= 0:
121
+ budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
122
+ dollars_per_day=None, percent_per_day=None))
123
+ continue
124
+ dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
125
+ pct_day = headroom / inputs.remaining_days
126
+ budgets.append(BudgetRow(
127
+ target_percent=t,
128
+ pct_headroom=headroom,
129
+ dollars_per_day=dollars_day,
130
+ percent_per_day=pct_day,
131
+ ))
132
+
133
+ return ForecastOutput(
134
+ inputs=inputs,
135
+ r_avg=r_avg,
136
+ r_recent=r_recent,
137
+ final_percent_low=final_low,
138
+ final_percent_high=final_high,
139
+ week_avg_projection_pct=week_avg_projection_pct,
140
+ projected_cap=projected_cap,
141
+ already_capped=already_capped,
142
+ cap_at=cap_at,
143
+ budgets=budgets,
144
+ )
@@ -0,0 +1,38 @@
1
+ """Pure JSON wire-format helpers: schemaVersion envelope + canonical UTC-Z serializer.
2
+
3
+ stamp_schema_version() is the single chokepoint for the additive camelCase
4
+ ``schemaVersion: 1`` envelope adopted across reporting ``--json`` surfaces
5
+ (#279 S6 W1; convention: docs/cli-contract.md). _iso_z() is the canonical
6
+ None-safe seconds-precision UTC-Z serializer (the forecast/dashboard-envelope
7
+ behavior; _lib_doctor._iso_z deliberately diverges — see its docstring).
8
+
9
+ Pure kernel: stdlib-only, no cctally back-import (split design §5.3).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+
15
+ SCHEMA_VERSION_KEY = "schemaVersion"
16
+
17
+
18
+ def stamp_schema_version(payload: dict, *, version: int = 1,
19
+ key: str = SCHEMA_VERSION_KEY) -> dict:
20
+ """Return a new dict with ``key: version`` first.
21
+
22
+ Always returns a shallow copy; never mutates ``payload``. If ``key`` is
23
+ already present the copy preserves the existing value AND key order
24
+ (value- and order-idempotent no-op). Formatting is the caller's concern:
25
+ this returns a dict, each emitter keeps its own json.dumps arguments.
26
+ """
27
+ if key in payload:
28
+ return dict(payload)
29
+ out = {key: version}
30
+ out.update(payload)
31
+ return out
32
+
33
+
34
+ def _iso_z(d: "dt.datetime | None") -> "str | None":
35
+ """None-safe UTC ISO-8601 with Z suffix, seconds precision."""
36
+ if d is None:
37
+ return None
38
+ return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")