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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Holds `cmd_report`, `cmd_forecast`, `cmd_budget`, the forecast core cluster
4
4
  (window resolvers, ForecastInputs/ForecastOutput/BudgetRow, _compute_forecast,
5
- the forecast render helpers, _iso_z), and the 10 budget render/snapshot helpers.
5
+ the forecast render helpers), and the 10 budget render/snapshot helpers.
6
6
 
7
7
  Honest *name* imports are KERNEL-ONLY (_cctally_core) + stdlib. Every library
8
8
  kernel + sibling helper this module needs (build_forecast_view, build_trend_view,
@@ -18,9 +18,10 @@ cluster (get_recent_weeks, _apply_reset_events_to_weekrefs,
18
18
  _get_canonical_boundary_for_date) lives in _cctally_weekrefs.py (re-exported on
19
19
  the cctally ns); both are reached via c.
20
20
 
21
- _iso_z is defined HERE (intra-module) and re-exported in bin/cctally AFTER the
22
- dashboard _iso_z binding so cctally._iso_z stays the forecast version
23
- (the dt-only variant _lib_diff_kernel + _cctally_five_hour depend on).
21
+ _iso_z is honest-imported from the canonical _lib_json_envelope (#279 S6 W4);
22
+ the former intra-module dt-only copy collapsed onto it, and bin/cctally binds
23
+ that single canonical (the old dashboard-then-forecast double-bind is gone).
24
+ _lib_diff_kernel + _cctally_five_hour reach it unchanged via the cctally ns.
24
25
 
25
26
  Spec: docs/superpowers/specs/2026-05-31-extract-forecast-budget-cmd-design.md
26
27
  """
@@ -51,37 +52,55 @@ from _cctally_core import (
51
52
  BUDGET_PERIODS as _CCTALLY_BUDGET_PERIODS,
52
53
  )
53
54
 
55
+ import importlib.util as _ilu
56
+
57
+
58
+ def _ensure_sibling_loaded(name: str) -> None:
59
+ """Register a NON-eager-loaded ``_lib_*`` sibling in ``sys.modules``.
60
+
61
+ ``_lib_forecast`` (#279 S4 F2) is a NEW consumer-only sibling —
62
+ deliberately kept out of ``bin/cctally``'s eager-load block so
63
+ ``bin/cctally`` stays byte-untouched (spec §2 re-export continuity).
64
+ Under the ``SourceFileLoader`` harness path (``bin/`` absent from
65
+ ``sys.path``) a bare ``from _lib_forecast import`` would miss, so this
66
+ pre-registers the sibling ``__file__``-relative when it is not already
67
+ importable (mirrors ``_cctally_cache._load_lib``). The honest import
68
+ that follows is a ``sys.modules`` hit in every load context.
69
+ """
70
+ if name in sys.modules:
71
+ return
72
+ try:
73
+ __import__(name) # bin/ on sys.path: prod script / conftest / pytest
74
+ return
75
+ except ModuleNotFoundError:
76
+ pass
77
+ _p = os.path.join(os.path.dirname(__file__), f"{name}.py")
78
+ _spec = _ilu.spec_from_file_location(name, _p)
79
+ _mod = _ilu.module_from_spec(_spec)
80
+ sys.modules[name] = _mod
81
+ _spec.loader.exec_module(_mod)
82
+
83
+
84
+ _ensure_sibling_loaded("_lib_forecast")
85
+ from _lib_forecast import ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast
86
+
87
+ # #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
88
+ # _iso_z (dt-only, no None guard) collapses to this single definition; the union
89
+ # behavior is exactly the canonical (forecast gains a None guard it never
90
+ # exercises). doctor's _iso_z deliberately keeps its own divergent copy.
91
+ _ensure_sibling_loaded("_lib_json_envelope")
92
+ from _lib_json_envelope import _iso_z
93
+
54
94
 
55
95
  def _cctally():
56
96
  """Resolve the current `cctally` module at call-time (§2)."""
57
97
  return sys.modules["cctally"]
58
98
 
59
99
 
60
- # === moved verbatim from bin/cctally (class ForecastInputs -> _build_budget_no_budget_snapshot) ===
61
- @dataclass
62
- class ForecastInputs:
63
- now_utc: dt.datetime
64
- week_start_at: dt.datetime
65
- week_end_at: dt.datetime
66
- elapsed_hours: float
67
- elapsed_fraction: float
68
- remaining_hours: float
69
- remaining_days: float
70
- # Current state
71
- p_now: float
72
- five_hour_percent: float | None
73
- spent_usd: float
74
- snapshot_count: int
75
- latest_snapshot_at: dt.datetime
76
- # Rate inputs
77
- p_24h_ago: float | None
78
- t_24h_actual_hours: float | None
79
- # $/1% selection
80
- dollars_per_percent: float
81
- dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
82
- # Confidence
83
- confidence: str # "high" | "low"
84
- low_confidence_reasons: list[str]
100
+ # ``ForecastInputs`` / ``BudgetRow`` / ``ForecastOutput`` / ``_compute_forecast``
101
+ # now live in ``bin/_lib_forecast.py`` (#279 S4 F2); honest-imported at module
102
+ # top so the ``bin/cctally`` re-exports and this module's own callers resolve
103
+ # them unchanged.
85
104
 
86
105
 
87
106
  def _resolve_forecast_now(as_of: str | None) -> dt.datetime:
@@ -340,40 +359,54 @@ def _select_dollars_per_percent(
340
359
  if p_now >= 10.0 and p_now > 0:
341
360
  return spent_usd / p_now, "this_week"
342
361
 
343
- # Path 2: trailing 4-week median.
344
- # Fetch all eligible prior weeks then Python-filter (legacy rows may carry
345
- # non-UTC offsets that break lexical compare against an ISO-UTC bound).
346
- rows = conn.execute(
347
- "SELECT week_start_at, week_end_at, MAX(weekly_percent) AS final_pct, "
348
- " MAX(captured_at_utc) AS latest_cap "
362
+ # Path 2: trailing 4-week median, reset-aware floored (#290).
363
+ import statistics
364
+ # Bounded candidate selection: the <=12 most-recent prior weeks. The median
365
+ # needs 4 eligible; 12 leaves margin so flooring a credited week below 1%
366
+ # can't starve it, while keeping this hot path (live forecast view +
367
+ # dashboard refresh) from materializing all history.
368
+ cand = conn.execute(
369
+ "SELECT DISTINCT week_start_at, week_end_at, week_start_date "
349
370
  "FROM weekly_usage_snapshots "
350
371
  "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
351
- "GROUP BY week_start_at "
352
- "HAVING MAX(weekly_percent) >= 1"
372
+ " AND datetime(week_start_at) < datetime(?) "
373
+ "ORDER BY datetime(week_start_at) DESC LIMIT 12",
374
+ (current_week_start.isoformat(),),
353
375
  ).fetchall()
354
- by_instant: dict[dt.datetime, dict] = {}
355
- for r in rows:
356
- try:
357
- ws = parse_iso_datetime(r[0], "week_start_at")
358
- we = parse_iso_datetime(r[1], "week_end_at")
359
- except ValueError:
360
- continue
361
- slot = by_instant.get(ws)
362
- final_pct = float(r[2])
363
- if slot is None:
364
- by_instant[ws] = {"we": we, "final_pct": final_pct}
365
- else:
366
- slot["final_pct"] = max(slot["final_pct"], final_pct)
367
-
376
+ we_by_ws: dict[dt.datetime, dt.datetime] = {}
377
+ rows_in: list = []
378
+ ws_iso_list = [row[0] for row in cand]
379
+ if ws_iso_list:
380
+ placeholders = ",".join("?" * len(ws_iso_list))
381
+ snap = conn.execute(
382
+ "SELECT week_start_date, week_start_at, week_end_at, "
383
+ " captured_at_utc, weekly_percent "
384
+ "FROM weekly_usage_snapshots "
385
+ "WHERE week_start_at IN (" + placeholders + ")",
386
+ ws_iso_list,
387
+ ).fetchall()
388
+ for wsd, ws_iso, we_iso, cap_iso, pct in snap:
389
+ if pct is None:
390
+ continue
391
+ try:
392
+ ws = parse_iso_datetime(ws_iso, "week_start_at")
393
+ we = parse_iso_datetime(we_iso, "week_end_at")
394
+ except ValueError:
395
+ continue
396
+ we_by_ws.setdefault(ws, we) # first-wins; all rows share one instant
397
+ rows_in.append((ws, wsd, ws_iso, we_iso, cap_iso, pct))
398
+ floored = c._floored_week_max(conn, rows_in) # {ws_instant -> floored max}
368
399
  eligible: list[tuple[dt.datetime, dt.datetime, float]] = [
369
- (ws, v["we"], v["final_pct"])
370
- for ws, v in by_instant.items()
371
- if ws < current_week_start and v["we"] < now_utc and v["final_pct"] >= 1.0
400
+ (ws, we_by_ws[ws], floored[ws])
401
+ for ws in floored
402
+ if ws in we_by_ws
403
+ and ws < current_week_start
404
+ and we_by_ws[ws] < now_utc
405
+ and floored[ws] >= 1.0
372
406
  ]
373
407
  eligible.sort(key=lambda x: x[0], reverse=True)
374
408
  prior = eligible[:4]
375
409
  if len(prior) >= 4:
376
- import statistics
377
410
  values: list[float] = []
378
411
  for ws, we, final_pct in prior:
379
412
  if use_weekref_cost_cache:
@@ -519,99 +552,7 @@ def _load_forecast_inputs(
519
552
  )
520
553
 
521
554
 
522
- @dataclass
523
- class BudgetRow:
524
- target_percent: int
525
- pct_headroom: float | None # None when already past target
526
- dollars_per_day: float | None
527
- percent_per_day: float | None
528
-
529
-
530
- @dataclass
531
- class ForecastOutput:
532
- inputs: ForecastInputs
533
- r_avg: float # pct per hour, week-avg
534
- r_recent: float | None # pct per hour, 24h recent; None if no prior sample
535
- final_percent_low: float
536
- final_percent_high: float
537
- week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
538
- projected_cap: bool
539
- already_capped: bool
540
- cap_at: dt.datetime | None
541
- budgets: list[BudgetRow]
542
-
543
-
544
- def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
545
- """Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
546
- c = _cctally()
547
- # Rate methods
548
- r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
549
- if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
550
- r_recent: float | None = max(
551
- 0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
552
- )
553
- else:
554
- r_recent = None
555
-
556
- # Projected final % — routed through the shared project_linear primitive
557
- # (spec F1). r_recent is None ⇒ collapse to the average projection.
558
- if r_recent is None:
559
- final_low, final_high = c.project_linear(
560
- inputs.p_now, inputs.remaining_hours, r_avg, r_avg
561
- )
562
- else:
563
- a, b = c.project_linear(
564
- inputs.p_now, inputs.remaining_hours, r_avg, r_recent
565
- )
566
- final_low, final_high = min(a, b), max(a, b)
567
-
568
- # Smooth week-average projection (additive surface field). Distinct from
569
- # the displayed band (which keys off final_high): this is the conservative
570
- # week-average value the projected-pace alert axis fires on.
571
- # p_now + r_avg*remaining (== project_linear collapsed to the single rate).
572
- week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
573
-
574
- already_capped = inputs.p_now >= 100.0
575
- projected_cap = already_capped or final_high >= 100.0
576
-
577
- cap_at: dt.datetime | None = None
578
- if not already_capped and projected_cap:
579
- r_pessimistic = max(r_avg, r_recent or 0.0)
580
- if r_pessimistic > 0:
581
- hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
582
- if hours_to_cap < inputs.remaining_hours:
583
- cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
584
-
585
- # Budgets
586
- budgets: list[BudgetRow] = []
587
- if not already_capped:
588
- for t in sorted(targets, reverse=True):
589
- headroom = t - inputs.p_now
590
- if headroom <= 0 or inputs.remaining_days <= 0:
591
- budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
592
- dollars_per_day=None, percent_per_day=None))
593
- continue
594
- dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
595
- pct_day = headroom / inputs.remaining_days
596
- budgets.append(BudgetRow(
597
- target_percent=t,
598
- pct_headroom=headroom,
599
- dollars_per_day=dollars_day,
600
- percent_per_day=pct_day,
601
- ))
602
-
603
- return ForecastOutput(
604
- inputs=inputs,
605
- r_avg=r_avg,
606
- r_recent=r_recent,
607
- final_percent_low=final_low,
608
- final_percent_high=final_high,
609
- week_avg_projection_pct=week_avg_projection_pct,
610
- projected_cap=projected_cap,
611
- already_capped=already_capped,
612
- cap_at=cap_at,
613
- budgets=budgets,
614
- )
555
+ # (moved to bin/_lib_forecast.py, #279 S4 F2 — honest-imported at module top)
615
556
 
616
557
 
617
558
  def _parse_forecast_targets(raw: str) -> list[int]:
@@ -636,10 +577,6 @@ def _parse_forecast_targets(raw: str) -> list[int]:
636
577
  TOOL_VERSION = "forecast-v1" # Bumped on material JSON-schema changes.
637
578
 
638
579
 
639
- def _iso_z(d: dt.datetime) -> str:
640
- return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
641
-
642
-
643
580
  def _build_forecast_json_payload(out: ForecastOutput) -> dict:
644
581
  """Dict shape for the forecast JSON endpoint and for the dashboard
645
582
  envelope's ``forecast.explain`` subtree (design spec §2.2).
@@ -701,7 +638,11 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
701
638
 
702
639
 
703
640
  def _emit_forecast_json(out: ForecastOutput) -> str:
704
- return json.dumps(_build_forecast_json_payload(out), indent=2)
641
+ # Stamp at the CLI boundary ONLY — never in _build_forecast_json_payload,
642
+ # which also feeds the dashboard `explain` subtree (spec gate F3).
643
+ return json.dumps(
644
+ _cctally().stamp_schema_version(_build_forecast_json_payload(out)),
645
+ indent=2)
705
646
 
706
647
 
707
648
  def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
@@ -1070,7 +1011,9 @@ def cmd_report(args: argparse.Namespace) -> int:
1070
1011
  c._share_render_and_emit(snap, args)
1071
1012
  return 0
1072
1013
  if args.json:
1073
- print(json.dumps({"current": None, "trend": []}, indent=2))
1014
+ print(json.dumps(
1015
+ c.stamp_schema_version({"current": None, "trend": []}),
1016
+ indent=2))
1074
1017
  else:
1075
1018
  print("No data yet. Add record-usage to your status line script (see record-usage --help).")
1076
1019
  return 0
@@ -1192,7 +1135,12 @@ def cmd_report(args: argparse.Namespace) -> int:
1192
1135
  "current": current_row,
1193
1136
  "trend": trend,
1194
1137
  "weekStartRule": week_start_name,
1195
- "generatedAt": now_utc_iso(),
1138
+ # Honor the CCTALLY_AS_OF testing hook (matches cache-report's
1139
+ # generatedAt, #279 S7 W6) — real behavior is unchanged since
1140
+ # _command_as_of() returns wall-clock when the hook is unset, but the
1141
+ # field becomes deterministic under the hook so report --json is
1142
+ # golden-testable.
1143
+ "generatedAt": now_utc_iso(now_utc=_command_as_of()),
1196
1144
  "currentWeek": {
1197
1145
  "weekStartDate": current_ref.week_start.isoformat(),
1198
1146
  "weekEndDate": current_ref.week_end.isoformat() if current_ref.week_end else None,
@@ -1258,7 +1206,7 @@ def cmd_report(args: argparse.Namespace) -> int:
1258
1206
  return 0
1259
1207
 
1260
1208
  if args.json:
1261
- print(json.dumps(output, indent=2))
1209
+ print(json.dumps(c.stamp_schema_version(output), indent=2))
1262
1210
  return 0
1263
1211
 
1264
1212
  if current_row is not None:
@@ -1373,7 +1321,8 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1373
1321
  if args.json and args.status_line:
1374
1322
  print("forecast: --json and --status-line are mutually exclusive",
1375
1323
  file=sys.stderr)
1376
- return 1
1324
+ # cctally-native usage error → exit 2 (docs/cli-contract.md; #279 S6 W2).
1325
+ return 2
1377
1326
 
1378
1327
  # Resolve display tz via the unified --tz / config.display.tz pipeline.
1379
1328
  # The renderer reads it back from args._resolved_tz.
@@ -1384,7 +1333,8 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1384
1333
  targets = _parse_forecast_targets(args.targets)
1385
1334
  except ValueError as exc:
1386
1335
  print(f"forecast: {exc}", file=sys.stderr)
1387
- return 1
1336
+ # cctally-native usage error → exit 2 (docs/cli-contract.md; #279 S6 W2).
1337
+ return 2
1388
1338
 
1389
1339
  now_utc = _resolve_forecast_now(args.as_of)
1390
1340
  conn = open_db()
@@ -1457,10 +1407,10 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1457
1407
  c._share_render_and_emit(snap, args)
1458
1408
  return 0
1459
1409
  if args.json:
1460
- print(json.dumps({
1410
+ print(json.dumps(c.stamp_schema_version({
1461
1411
  "error": "no_current_week_data",
1462
1412
  "meta": {"generated_at": _iso_z(now_utc), "tool_version": TOOL_VERSION},
1463
- }, indent=2))
1413
+ }), indent=2))
1464
1414
  elif args.status_line:
1465
1415
  pass # silent segment
1466
1416
  else:
@@ -2168,6 +2118,7 @@ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
2168
2118
  basename = os.path.basename(root) or root
2169
2119
  if getattr(args, "json", False):
2170
2120
  print(json.dumps({
2121
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2171
2122
  "status": "set",
2172
2123
  "project_key": root,
2173
2124
  "budget_usd": amount,
@@ -2227,6 +2178,7 @@ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
2227
2178
  basename = os.path.basename(root) or root
2228
2179
  if getattr(args, "json", False):
2229
2180
  print(json.dumps({
2181
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2230
2182
  "status": "unset" if removed else "noop",
2231
2183
  "project_key": root,
2232
2184
  }))
@@ -2301,6 +2253,7 @@ def _cmd_budget_set(args: argparse.Namespace, period=None) -> int:
2301
2253
  c._reconcile_budget_on_config_write(validated)
2302
2254
  if getattr(args, "json", False):
2303
2255
  print(json.dumps({
2256
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2304
2257
  "status": "set",
2305
2258
  "weekly_usd": weekly_usd,
2306
2259
  "period": stored_period,
@@ -2339,7 +2292,9 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
2339
2292
  c.save_config(config)
2340
2293
 
2341
2294
  if getattr(args, "json", False):
2342
- print(json.dumps({"status": "unset", "weekly_usd": None}))
2295
+ print(json.dumps({
2296
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2297
+ "status": "unset", "weekly_usd": None}))
2343
2298
  return 0
2344
2299
  print("Weekly budget cleared")
2345
2300
  return 0
@@ -2409,6 +2364,7 @@ def _cmd_budget_set_codex(args: argparse.Namespace, period=None) -> int:
2409
2364
  thresholds = codex["alert_thresholds"]
2410
2365
  if getattr(args, "json", False):
2411
2366
  print(json.dumps({
2367
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2412
2368
  "status": "set",
2413
2369
  "vendor": "codex",
2414
2370
  "amount_usd": amount_usd,
@@ -2449,6 +2405,7 @@ def _cmd_budget_unset_codex(args: argparse.Namespace) -> int:
2449
2405
 
2450
2406
  if getattr(args, "json", False):
2451
2407
  print(json.dumps({
2408
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2452
2409
  "status": "unset" if removed else "noop", "vendor": "codex",
2453
2410
  }))
2454
2411
  return 0