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.
- package/CHANGELOG.md +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
package/bin/_cctally_forecast.py
CHANGED
|
@@ -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
|
|
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
|
|
22
|
-
|
|
23
|
-
(the
|
|
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
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
"
|
|
352
|
-
"
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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,
|
|
370
|
-
for ws
|
|
371
|
-
if ws
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|