cctally 1.64.0 → 1.66.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 (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +262 -40
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +827 -4911
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +487 -254
  24. package/bin/_cctally_update.py +5 -0
  25. package/bin/_lib_alert_dispatch.py +1 -1
  26. package/bin/_lib_blocks.py +6 -1
  27. package/bin/_lib_conversation_query.py +349 -36
  28. package/bin/_lib_credit.py +133 -0
  29. package/bin/_lib_doctor.py +150 -1
  30. package/bin/_lib_five_hour.py +34 -0
  31. package/bin/_lib_forecast.py +144 -0
  32. package/bin/_lib_json_envelope.py +38 -0
  33. package/bin/_lib_jsonl.py +115 -73
  34. package/bin/_lib_log.py +96 -0
  35. package/bin/_lib_perf.py +180 -0
  36. package/bin/_lib_pricing.py +47 -1
  37. package/bin/_lib_pricing_check.py +25 -3
  38. package/bin/_lib_record.py +179 -0
  39. package/bin/_lib_render.py +18 -10
  40. package/bin/_lib_snapshot_cache.py +61 -0
  41. package/bin/_lib_transcript_access.py +23 -0
  42. package/bin/cctally +51 -7
  43. package/bin/cctally-dashboard +2 -2
  44. package/bin/cctally-statusline +1 -0
  45. package/bin/cctally-tui +2 -2
  46. package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
  47. package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
  48. package/dashboard/static/dashboard.html +2 -2
  49. package/package.json +11 -1
  50. package/dashboard/static/assets/index-0jzYm75p.css +0 -1
  51. package/dashboard/static/assets/index-D_Ylyqsf.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:
@@ -519,99 +538,7 @@ def _load_forecast_inputs(
519
538
  )
520
539
 
521
540
 
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
- )
541
+ # (moved to bin/_lib_forecast.py, #279 S4 F2 — honest-imported at module top)
615
542
 
616
543
 
617
544
  def _parse_forecast_targets(raw: str) -> list[int]:
@@ -636,10 +563,6 @@ def _parse_forecast_targets(raw: str) -> list[int]:
636
563
  TOOL_VERSION = "forecast-v1" # Bumped on material JSON-schema changes.
637
564
 
638
565
 
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
566
  def _build_forecast_json_payload(out: ForecastOutput) -> dict:
644
567
  """Dict shape for the forecast JSON endpoint and for the dashboard
645
568
  envelope's ``forecast.explain`` subtree (design spec §2.2).
@@ -701,7 +624,11 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
701
624
 
702
625
 
703
626
  def _emit_forecast_json(out: ForecastOutput) -> str:
704
- return json.dumps(_build_forecast_json_payload(out), indent=2)
627
+ # Stamp at the CLI boundary ONLY — never in _build_forecast_json_payload,
628
+ # which also feeds the dashboard `explain` subtree (spec gate F3).
629
+ return json.dumps(
630
+ _cctally().stamp_schema_version(_build_forecast_json_payload(out)),
631
+ indent=2)
705
632
 
706
633
 
707
634
  def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
@@ -1070,7 +997,9 @@ def cmd_report(args: argparse.Namespace) -> int:
1070
997
  c._share_render_and_emit(snap, args)
1071
998
  return 0
1072
999
  if args.json:
1073
- print(json.dumps({"current": None, "trend": []}, indent=2))
1000
+ print(json.dumps(
1001
+ c.stamp_schema_version({"current": None, "trend": []}),
1002
+ indent=2))
1074
1003
  else:
1075
1004
  print("No data yet. Add record-usage to your status line script (see record-usage --help).")
1076
1005
  return 0
@@ -1192,7 +1121,12 @@ def cmd_report(args: argparse.Namespace) -> int:
1192
1121
  "current": current_row,
1193
1122
  "trend": trend,
1194
1123
  "weekStartRule": week_start_name,
1195
- "generatedAt": now_utc_iso(),
1124
+ # Honor the CCTALLY_AS_OF testing hook (matches cache-report's
1125
+ # generatedAt, #279 S7 W6) — real behavior is unchanged since
1126
+ # _command_as_of() returns wall-clock when the hook is unset, but the
1127
+ # field becomes deterministic under the hook so report --json is
1128
+ # golden-testable.
1129
+ "generatedAt": now_utc_iso(now_utc=_command_as_of()),
1196
1130
  "currentWeek": {
1197
1131
  "weekStartDate": current_ref.week_start.isoformat(),
1198
1132
  "weekEndDate": current_ref.week_end.isoformat() if current_ref.week_end else None,
@@ -1258,7 +1192,7 @@ def cmd_report(args: argparse.Namespace) -> int:
1258
1192
  return 0
1259
1193
 
1260
1194
  if args.json:
1261
- print(json.dumps(output, indent=2))
1195
+ print(json.dumps(c.stamp_schema_version(output), indent=2))
1262
1196
  return 0
1263
1197
 
1264
1198
  if current_row is not None:
@@ -1373,7 +1307,8 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1373
1307
  if args.json and args.status_line:
1374
1308
  print("forecast: --json and --status-line are mutually exclusive",
1375
1309
  file=sys.stderr)
1376
- return 1
1310
+ # cctally-native usage error → exit 2 (docs/cli-contract.md; #279 S6 W2).
1311
+ return 2
1377
1312
 
1378
1313
  # Resolve display tz via the unified --tz / config.display.tz pipeline.
1379
1314
  # The renderer reads it back from args._resolved_tz.
@@ -1384,7 +1319,8 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1384
1319
  targets = _parse_forecast_targets(args.targets)
1385
1320
  except ValueError as exc:
1386
1321
  print(f"forecast: {exc}", file=sys.stderr)
1387
- return 1
1322
+ # cctally-native usage error → exit 2 (docs/cli-contract.md; #279 S6 W2).
1323
+ return 2
1388
1324
 
1389
1325
  now_utc = _resolve_forecast_now(args.as_of)
1390
1326
  conn = open_db()
@@ -1457,10 +1393,10 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1457
1393
  c._share_render_and_emit(snap, args)
1458
1394
  return 0
1459
1395
  if args.json:
1460
- print(json.dumps({
1396
+ print(json.dumps(c.stamp_schema_version({
1461
1397
  "error": "no_current_week_data",
1462
1398
  "meta": {"generated_at": _iso_z(now_utc), "tool_version": TOOL_VERSION},
1463
- }, indent=2))
1399
+ }), indent=2))
1464
1400
  elif args.status_line:
1465
1401
  pass # silent segment
1466
1402
  else:
@@ -2168,6 +2104,7 @@ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
2168
2104
  basename = os.path.basename(root) or root
2169
2105
  if getattr(args, "json", False):
2170
2106
  print(json.dumps({
2107
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2171
2108
  "status": "set",
2172
2109
  "project_key": root,
2173
2110
  "budget_usd": amount,
@@ -2227,6 +2164,7 @@ def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
2227
2164
  basename = os.path.basename(root) or root
2228
2165
  if getattr(args, "json", False):
2229
2166
  print(json.dumps({
2167
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2230
2168
  "status": "unset" if removed else "noop",
2231
2169
  "project_key": root,
2232
2170
  }))
@@ -2301,6 +2239,7 @@ def _cmd_budget_set(args: argparse.Namespace, period=None) -> int:
2301
2239
  c._reconcile_budget_on_config_write(validated)
2302
2240
  if getattr(args, "json", False):
2303
2241
  print(json.dumps({
2242
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2304
2243
  "status": "set",
2305
2244
  "weekly_usd": weekly_usd,
2306
2245
  "period": stored_period,
@@ -2339,7 +2278,9 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
2339
2278
  c.save_config(config)
2340
2279
 
2341
2280
  if getattr(args, "json", False):
2342
- print(json.dumps({"status": "unset", "weekly_usd": None}))
2281
+ print(json.dumps({
2282
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2283
+ "status": "unset", "weekly_usd": None}))
2343
2284
  return 0
2344
2285
  print("Weekly budget cleared")
2345
2286
  return 0
@@ -2409,6 +2350,7 @@ def _cmd_budget_set_codex(args: argparse.Namespace, period=None) -> int:
2409
2350
  thresholds = codex["alert_thresholds"]
2410
2351
  if getattr(args, "json", False):
2411
2352
  print(json.dumps({
2353
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2412
2354
  "status": "set",
2413
2355
  "vendor": "codex",
2414
2356
  "amount_usd": amount_usd,
@@ -2449,6 +2391,7 @@ def _cmd_budget_unset_codex(args: argparse.Namespace) -> int:
2449
2391
 
2450
2392
  if getattr(args, "json", False):
2451
2393
  print(json.dumps({
2394
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
2452
2395
  "status": "unset" if removed else "noop", "vendor": "codex",
2453
2396
  }))
2454
2397
  return 0