cctally 1.22.3 → 1.23.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 +19 -0
- package/bin/_cctally_alerts.py +27 -0
- package/bin/_cctally_config.py +68 -9
- package/bin/_cctally_core.py +60 -2
- package/bin/_cctally_dashboard.py +225 -60
- package/bin/_cctally_forecast.py +25 -3
- package/bin/_cctally_milestones.py +68 -0
- package/bin/_cctally_parser.py +10 -2
- package/bin/_cctally_record.py +472 -138
- package/bin/_cctally_tui.py +1 -0
- package/bin/_cctally_weekrefs.py +36 -2
- package/bin/_lib_alert_axes.py +44 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/_lib_diff_kernel.py +5 -8
- package/bin/_lib_fmt.py +325 -0
- package/bin/_lib_render.py +9 -24
- package/bin/cctally +33 -273
- package/dashboard/static/assets/index-CXZDQrV3.js +18 -0
- package/dashboard/static/assets/{index-BxmaYT1y.css → index-ZHOC14y-.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CLcd-Tnm.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.23.0] - 2026-06-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Projected-pace alerts: a new opt-in `projected` alert axis that warns you *before* you cross a ceiling, firing on your week-average pace rather than waiting for the actual crossing.** It tracks two metrics — `weekly_pct` (projected to reach 90% / 100% of your subscription cap by the week's reset) and `budget_usd` (projected to reach your `budget.alert_thresholds` of the weekly $ budget) — using the smooth week-average projection (`now + average-rate × time-remaining`), the same conservative number `forecast`/`budget` already display; it deliberately ignores the hotter trailing-24h estimate so a brief spike doesn't trigger a false alarm. Each level fires once per week (no re-fire, no recovery alert), is suppressed while the forecast is `LOW CONF` (too early in the week / too few samples), and re-anchors cleanly across a mid-week reset.
|
|
12
|
+
- **Both projected toggles default OFF, gated behind their parent axis** — enable weekly-% projected alerts with `cctally config set alerts.projected_enabled true` (requires `alerts.enabled`) and budget-$ projected alerts with `cctally config set budget.projected_enabled true` (requires a configured `budget.weekly_usd` + `budget.alerts_enabled`); preview either without writing any data via `cctally alerts test --axis projected --metric weekly_pct` (or `--metric budget_usd`). The local web dashboard surfaces fired projected alerts with a dedicated **Projected** chip and forecast-aware context, and exposes both enable/disable toggles in its settings panel.
|
|
13
|
+
- **`forecast --json` and `budget --json` now expose an additive `week_avg_projection_pct` / `week_avg_projection_usd` field** — the exact week-average end-of-week projection the projected-pace alert axis fires on, surfaced for scripting and reconciliation (additive only; no schema-version bump, existing keys unchanged).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **The mid-week reset-to-zero detector now waits for a corroborating second reading before segmenting the week (#128).** When the Anthropic usage API reports a clean drop to ~0% against non-trivial prior usage (below the 25pp goodwill-credit threshold), `record-usage` no longer writes a `week_reset_events` row on the very first zero: it arms a one-tick debounce and fires only if the next reading stays low (at or below half the pre-zero level), treating a zero that bounces back toward the prior percentage as a transient API/replica glitch rather than a real reset. A genuine reset still surfaces — one status-line tick later — and the ≥25pp credit path, the boundary-advance path, and the 5-hour detector are unchanged. This is belt-and-suspenders hardening on the 2026-06-01 surprise-reset fix; the debounce is best-effort under concurrent `record-usage` runs (a race degrades to the previous single-zero behavior, never worse).
|
|
17
|
+
- **Test-suite isolation (no shipped-code change): the `*_ns_patch.py` binding-regression tests no longer read the developer's real `~/.local/share/cctally` database (#127).** Their shared `cctally_mod` fixture loaded `bin/cctally` with a bespoke loader that only set `HOME` and relied on `_cctally_core`'s import-time path derivation — which silently no-ops once any earlier test has cached `_cctally_core` in `sys.modules` (every `load_script()` user does), so the handler under test fell back to the real prod DB. That stayed invisible until the prod DB happened to hold a `week_reset_events` row for the current week, at which point `test_percent_breakdown_md_reaches_all_accessors_via_ns` flipped to a failure that only reproduced under certain test orderings (it filters milestones by the DB's active segment, so the seeded fake milestone was dropped and the table never rendered). The five `*_ns_patch.py` fixtures now route through a shared `conftest.load_isolated_cctally_module()` helper built on `load_script()` + `redirect_paths()`, pinning `_cctally_core`'s path constants to the per-test tmp dir deterministically regardless of import order. A new order-independent regression (`test_ns_patch_loader_isolates_db_when_core_cached`) asserts the loader re-isolates `DB_PATH` even when `_cctally_core` is pre-cached pointing at a real prod path.
|
|
18
|
+
|
|
19
|
+
## [1.22.4] - 2026-06-01
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **A surprise mid-week Anthropic usage reset is now reflected in the 7d percentage even when you were below ~25% usage.** When Anthropic zeroes the weekly counter mid-window (same reset timestamp, usage drops to ~0 — e.g. the 2026-06-01 incident), the reset detector previously only fired when the drop was at least 25 percentage points, so any account reset from a lower base (the observed 14% → 0%) slipped through: no `week_reset_events` row was written, the monotonic high-water-mark clamp kept reporting the stale pre-reset percentage across the statusline, `weekly`/`report`, and the dashboard, and post-reset 0% reads were silently dropped at the write site. The detector now ALSO fires on a reset-to-zero — when the post-reset value collapses to ~0 (≤ 1%) with at least a 3pp drop — independently of the 25pp magnitude gate, since a clean drop to zero is an unambiguous reset regardless of size (a lagging API replica reports a slightly-lower number, never a clean 0 against real usage), while the 3pp floor rejects 1%→0% replica jitter that would otherwise spuriously segment the week. The new discriminator is a single shared `_is_reset_drop()` helper wired into all four 7d detection sites (live + backfill, boundary-advance + in-place-credit branches) so they stay byte-identical; the 25pp partial-credit path and the separate 5h detector are unchanged. Regression: three new cases in `tests/test_in_place_credit_detection.py` (live reset-to-zero fires below 25pp, the 3pp floor rejects 1%→0% jitter, and backfill parity).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **Internal refactor (no user-facing change): extracted the shared formatting/color/table render primitives — the boxed-table renderer, the color/unicode capability detectors, the ANSI styling + display-width helpers, the integer/width-budget number formatters, the compact timestamp/week-window formatters, and the `_ANSI_ESC_RE` regex (plus the small `_parse_iso_datetime_optional` helper they depend on) — out of the `bin/cctally` single-file program into a new stdlib-only pure-function sibling module, `bin/_lib_fmt.py` (#126, the final tranche of the `bin/cctally` split).** This trims the main script by ~250 lines (2,969 → 2,715), and additionally converts the eight back-references these primitives had in `bin/_lib_render.py` and `bin/_lib_diff_kernel.py` from `cctally`-namespace shims into honest direct imports of the new kernel — with no change to any command's behavior, flags, output, or exit codes: every reporting/`diff`/`project`/`cache-report`/`forecast`/`blocks`/`percent-breakdown`/`five-hour-blocks` golden test is byte-identical and the full harness + pytest suite (1,329 checks) passes, a new `tests/test_lib_fmt_extraction_invariants.py` locks the extraction's import discipline, and the new module ships in the npm/brew/public packages (promoted to the mirror allowlist). Purely a maintainability / code-organization change; nothing to do on upgrade.
|
|
26
|
+
|
|
8
27
|
## [1.22.3] - 2026-06-01
|
|
9
28
|
|
|
10
29
|
### Changed
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -69,10 +69,12 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
|
|
|
69
69
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
70
70
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
71
71
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
72
|
+
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
72
73
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
73
74
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
74
75
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
75
76
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
77
|
+
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
76
78
|
|
|
77
79
|
|
|
78
80
|
# === Honest imports from extracted homes ===================================
|
|
@@ -138,6 +140,8 @@ def _dispatch_alert_notification(
|
|
|
138
140
|
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
139
141
|
elif axis == "budget":
|
|
140
142
|
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
143
|
+
elif axis == "projected":
|
|
144
|
+
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
141
145
|
else:
|
|
142
146
|
title, subtitle, body = (
|
|
143
147
|
"cctally - alert",
|
|
@@ -207,6 +211,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
207
211
|
axis = "weekly"
|
|
208
212
|
elif args.axis == "budget":
|
|
209
213
|
axis = "budget"
|
|
214
|
+
elif args.axis == "projected":
|
|
215
|
+
axis = "projected"
|
|
210
216
|
else:
|
|
211
217
|
axis = "five_hour"
|
|
212
218
|
threshold = int(args.threshold)
|
|
@@ -239,6 +245,27 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
239
245
|
spent_usd=300.0 * threshold / 100.0,
|
|
240
246
|
consumption_pct=float(threshold),
|
|
241
247
|
)
|
|
248
|
+
elif axis == "projected":
|
|
249
|
+
# Synthetic projected-pace payload — NO DB writes (test/real divergence
|
|
250
|
+
# contract). The metric discriminator picks the wiring; projected_value
|
|
251
|
+
# is the threshold's denominator-relative value (so the body reads
|
|
252
|
+
# plausibly, e.g. weekly 100% → "~100% of cap", budget 100% → "$300 of
|
|
253
|
+
# $300"). denominator is the at-crossing target the row would carry
|
|
254
|
+
# (Codex P0-4): 100.0 for weekly_pct, $300 for budget_usd.
|
|
255
|
+
metric = getattr(args, "metric", "weekly_pct")
|
|
256
|
+
if metric == "budget_usd":
|
|
257
|
+
denominator = 300.0
|
|
258
|
+
projected_value = 300.0 * threshold / 100.0
|
|
259
|
+
else: # weekly_pct
|
|
260
|
+
denominator = 100.0
|
|
261
|
+
projected_value = float(threshold)
|
|
262
|
+
payload = _build_alert_payload_projected(
|
|
263
|
+
metric=metric,
|
|
264
|
+
threshold=threshold,
|
|
265
|
+
projected_value=projected_value,
|
|
266
|
+
denominator=denominator,
|
|
267
|
+
week_start_at=dt.date.today().isoformat(),
|
|
268
|
+
)
|
|
242
269
|
else:
|
|
243
270
|
payload = _build_alert_payload_five_hour(
|
|
244
271
|
threshold=threshold,
|
package/bin/_cctally_config.py
CHANGED
|
@@ -297,6 +297,7 @@ def save_config(data: dict[str, Any]) -> None:
|
|
|
297
297
|
ALLOWED_CONFIG_KEYS = (
|
|
298
298
|
"display.tz",
|
|
299
299
|
"alerts.enabled",
|
|
300
|
+
"alerts.projected_enabled",
|
|
300
301
|
"dashboard.bind",
|
|
301
302
|
"update.check.enabled",
|
|
302
303
|
"update.check.ttl_hours",
|
|
@@ -306,6 +307,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
306
307
|
"budget.weekly_usd",
|
|
307
308
|
"budget.alerts_enabled",
|
|
308
309
|
"budget.alert_thresholds",
|
|
310
|
+
"budget.projected_enabled",
|
|
309
311
|
)
|
|
310
312
|
|
|
311
313
|
|
|
@@ -405,6 +407,13 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
405
407
|
return c.get_display_tz_pref(config)
|
|
406
408
|
if key == "alerts.enabled":
|
|
407
409
|
return bool(_get_alerts_config(config)["enabled"])
|
|
410
|
+
if key == "alerts.projected_enabled":
|
|
411
|
+
# Validated boolean (defaults to False when unset). A corrupt alerts
|
|
412
|
+
# block surfaces the default — mirrors alerts.enabled.
|
|
413
|
+
try:
|
|
414
|
+
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
415
|
+
except c._AlertsConfigError:
|
|
416
|
+
return False
|
|
408
417
|
if key == "dashboard.bind":
|
|
409
418
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
410
419
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -472,6 +481,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
472
481
|
"budget.weekly_usd",
|
|
473
482
|
"budget.alerts_enabled",
|
|
474
483
|
"budget.alert_thresholds",
|
|
484
|
+
"budget.projected_enabled",
|
|
475
485
|
):
|
|
476
486
|
inner = key.split(".", 1)[1]
|
|
477
487
|
# Read the validated, defaults-filled block. A corrupt block falls
|
|
@@ -598,6 +608,51 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
598
608
|
else:
|
|
599
609
|
print(f"alerts.enabled={'true' if normalized else 'false'}")
|
|
600
610
|
return 0
|
|
611
|
+
if key == "alerts.projected_enabled":
|
|
612
|
+
# Projected-pace opt-in (#121). Same bool-normalizer + read-modify-write
|
|
613
|
+
# posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
614
|
+
# _normalize_alerts_enabled_value hardcodes "alerts.enabled" in its
|
|
615
|
+
# ValueError text, so catch + re-message with the actual key name
|
|
616
|
+
# (mirrors _normalize_update_check_enabled_value's precedent) — the
|
|
617
|
+
# budget side already names its own key correctly.
|
|
618
|
+
try:
|
|
619
|
+
normalized = c._normalize_alerts_enabled_value(raw)
|
|
620
|
+
except ValueError:
|
|
621
|
+
print(
|
|
622
|
+
f"cctally: invalid boolean value for alerts.projected_enabled: "
|
|
623
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
624
|
+
file=sys.stderr,
|
|
625
|
+
)
|
|
626
|
+
return 2
|
|
627
|
+
with config_writer_lock():
|
|
628
|
+
config = _load_config_unlocked()
|
|
629
|
+
existing_alerts = config.get("alerts")
|
|
630
|
+
if existing_alerts is not None and not isinstance(
|
|
631
|
+
existing_alerts, dict
|
|
632
|
+
):
|
|
633
|
+
print(
|
|
634
|
+
"cctally: alerts config error: alerts must be an object",
|
|
635
|
+
file=sys.stderr,
|
|
636
|
+
)
|
|
637
|
+
return 2
|
|
638
|
+
alerts_block = dict(existing_alerts or {})
|
|
639
|
+
alerts_block["projected_enabled"] = normalized
|
|
640
|
+
try:
|
|
641
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
642
|
+
except _AlertsConfigError as exc:
|
|
643
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
644
|
+
return 2
|
|
645
|
+
config["alerts"] = alerts_block
|
|
646
|
+
save_config(config)
|
|
647
|
+
if getattr(args, "emit_json", False):
|
|
648
|
+
print(
|
|
649
|
+
json.dumps({"alerts": {"projected_enabled": normalized}}, indent=2)
|
|
650
|
+
)
|
|
651
|
+
else:
|
|
652
|
+
print(
|
|
653
|
+
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
654
|
+
)
|
|
655
|
+
return 0
|
|
601
656
|
if key == "dashboard.bind":
|
|
602
657
|
# Validation rejects whitespace / empty / non-string up front;
|
|
603
658
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -715,6 +770,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
715
770
|
"budget.weekly_usd",
|
|
716
771
|
"budget.alerts_enabled",
|
|
717
772
|
"budget.alert_thresholds",
|
|
773
|
+
"budget.projected_enabled",
|
|
718
774
|
):
|
|
719
775
|
inner_key = key.split(".", 1)[1]
|
|
720
776
|
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
@@ -734,7 +790,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
734
790
|
f"null, got {raw!r}"
|
|
735
791
|
)
|
|
736
792
|
return 2
|
|
737
|
-
elif inner_key
|
|
793
|
+
elif inner_key in ("alerts_enabled", "projected_enabled"):
|
|
738
794
|
lo = raw.strip().lower()
|
|
739
795
|
if lo in ("true", "yes", "on", "1"):
|
|
740
796
|
new_val = True
|
|
@@ -742,7 +798,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
742
798
|
new_val = False
|
|
743
799
|
else:
|
|
744
800
|
eprint(
|
|
745
|
-
"cctally config: budget.
|
|
801
|
+
f"cctally config: budget.{inner_key} must be a boolean, "
|
|
746
802
|
f"got {raw!r}"
|
|
747
803
|
)
|
|
748
804
|
return 2
|
|
@@ -817,19 +873,21 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
817
873
|
save_config(config)
|
|
818
874
|
# idempotent: silent on missing key
|
|
819
875
|
return 0
|
|
820
|
-
if key
|
|
876
|
+
if key in ("alerts.enabled", "alerts.projected_enabled"):
|
|
821
877
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
822
878
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
|
823
879
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
824
|
-
#
|
|
825
|
-
# (`weekly_thresholds`, `five_hour_thresholds`)
|
|
826
|
-
#
|
|
827
|
-
#
|
|
880
|
+
# named key preserves any user-customized threshold lists
|
|
881
|
+
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
882
|
+
# enabled/projected_enabled toggle; the read-time validator
|
|
883
|
+
# (`_get_alerts_config`) re-applies the canonical default of `False`
|
|
884
|
+
# for the missing key on next get.
|
|
885
|
+
inner_key = key.split(".", 1)[1]
|
|
828
886
|
with config_writer_lock():
|
|
829
887
|
config = _load_config_unlocked()
|
|
830
888
|
block = config.get("alerts")
|
|
831
|
-
if isinstance(block, dict) and
|
|
832
|
-
del block[
|
|
889
|
+
if isinstance(block, dict) and inner_key in block:
|
|
890
|
+
del block[inner_key]
|
|
833
891
|
if not block:
|
|
834
892
|
config.pop("alerts", None)
|
|
835
893
|
save_config(config)
|
|
@@ -889,6 +947,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
889
947
|
"budget.weekly_usd",
|
|
890
948
|
"budget.alerts_enabled",
|
|
891
949
|
"budget.alert_thresholds",
|
|
950
|
+
"budget.projected_enabled",
|
|
892
951
|
):
|
|
893
952
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
894
953
|
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|
package/bin/_cctally_core.py
CHANGED
|
@@ -427,7 +427,12 @@ class _AlertsConfigError(ValueError):
|
|
|
427
427
|
"""Raised by _get_alerts_config on invalid alerts block."""
|
|
428
428
|
|
|
429
429
|
|
|
430
|
-
_ALERTS_CONFIG_VALID_KEYS = {
|
|
430
|
+
_ALERTS_CONFIG_VALID_KEYS = {
|
|
431
|
+
"enabled",
|
|
432
|
+
"weekly_thresholds",
|
|
433
|
+
"five_hour_thresholds",
|
|
434
|
+
"projected_enabled",
|
|
435
|
+
}
|
|
431
436
|
|
|
432
437
|
|
|
433
438
|
def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
@@ -499,10 +504,20 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
499
504
|
five_hour = _validate_threshold_list(
|
|
500
505
|
"five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
|
|
501
506
|
)
|
|
507
|
+
# projected-pace opt-in (#121); default OFF so upgrades fire no surprise
|
|
508
|
+
# notifications. Bool-validated (NOT coerced) so a non-bool is a config
|
|
509
|
+
# error, not silently truthy.
|
|
510
|
+
projected_enabled = block.get("projected_enabled", False)
|
|
511
|
+
if not isinstance(projected_enabled, bool):
|
|
512
|
+
raise _AlertsConfigError(
|
|
513
|
+
f"alerts.projected_enabled must be a JSON boolean, got "
|
|
514
|
+
f"{type(projected_enabled).__name__}: {projected_enabled!r}"
|
|
515
|
+
)
|
|
502
516
|
return {
|
|
503
517
|
"enabled": enabled,
|
|
504
518
|
"weekly_thresholds": weekly,
|
|
505
519
|
"five_hour_thresholds": five_hour,
|
|
520
|
+
"projected_enabled": projected_enabled,
|
|
506
521
|
}
|
|
507
522
|
|
|
508
523
|
|
|
@@ -517,8 +532,14 @@ _BUDGET_DEFAULTS = {
|
|
|
517
532
|
"weekly_usd": None, # None = no budget (default)
|
|
518
533
|
"alerts_enabled": True, # "on when set"
|
|
519
534
|
"alert_thresholds": [90, 100],
|
|
535
|
+
"projected_enabled": False, # projected-pace opt-in (#121); default OFF
|
|
536
|
+
}
|
|
537
|
+
_BUDGET_CONFIG_VALID_KEYS = {
|
|
538
|
+
"weekly_usd",
|
|
539
|
+
"alerts_enabled",
|
|
540
|
+
"alert_thresholds",
|
|
541
|
+
"projected_enabled",
|
|
520
542
|
}
|
|
521
|
-
_BUDGET_CONFIG_VALID_KEYS = {"weekly_usd", "alerts_enabled", "alert_thresholds"}
|
|
522
543
|
|
|
523
544
|
|
|
524
545
|
def _get_budget_config(cfg: dict) -> dict:
|
|
@@ -579,6 +600,12 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
579
600
|
cleaned.append(t)
|
|
580
601
|
out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
|
|
581
602
|
|
|
603
|
+
if "projected_enabled" in block:
|
|
604
|
+
v = block["projected_enabled"]
|
|
605
|
+
if not isinstance(v, bool):
|
|
606
|
+
raise _BudgetConfigError("budget.projected_enabled must be a boolean")
|
|
607
|
+
out["projected_enabled"] = v
|
|
608
|
+
|
|
582
609
|
return out
|
|
583
610
|
|
|
584
611
|
|
|
@@ -1027,6 +1054,37 @@ def open_db() -> sqlite3.Connection:
|
|
|
1027
1054
|
"""
|
|
1028
1055
|
)
|
|
1029
1056
|
|
|
1057
|
+
# ── projected_milestones (week-average-pace projection crossings — #121) ──
|
|
1058
|
+
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — same
|
|
1059
|
+
# posture as `budget_milestones` (write-once, forward-only, no
|
|
1060
|
+
# `reset_event_id` segment column). Two metrics share the table, keyed by
|
|
1061
|
+
# `metric` ('weekly_pct' | 'budget_usd'); a level fires once the
|
|
1062
|
+
# WEEK-AVERAGE projection (not the displayed high-end verdict) crosses
|
|
1063
|
+
# `threshold`. `denominator` snapshots the target AT crossing (target_usd
|
|
1064
|
+
# for budget_usd, 100.0 for weekly_pct) so the dashboard envelope renders
|
|
1065
|
+
# context "$312 of $300" / "102% of cap" from the ROW, not from live config
|
|
1066
|
+
# that may have changed since (Codex P0-4). A mid-week reset re-anchors
|
|
1067
|
+
# `week_start_at` (new window → fresh rows under the UNIQUE key), the
|
|
1068
|
+
# budget-pattern reset handling — hence NO `reset_event_id` column.
|
|
1069
|
+
# `alerted_at` is stamped BEFORE the osascript Popen (set-then-dispatch).
|
|
1070
|
+
# Lives BEFORE the migration dispatcher: a plain CREATE on a
|
|
1071
|
+
# framework-untracked table never touches `schema_migrations`.
|
|
1072
|
+
conn.execute(
|
|
1073
|
+
"""
|
|
1074
|
+
CREATE TABLE IF NOT EXISTS projected_milestones (
|
|
1075
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1076
|
+
week_start_at TEXT NOT NULL,
|
|
1077
|
+
metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd'
|
|
1078
|
+
threshold INTEGER NOT NULL, -- 90 | 100
|
|
1079
|
+
projected_value REAL NOT NULL,
|
|
1080
|
+
denominator REAL NOT NULL, -- target_usd (budget) | 100.0 (weekly)
|
|
1081
|
+
crossed_at_utc TEXT NOT NULL,
|
|
1082
|
+
alerted_at TEXT,
|
|
1083
|
+
UNIQUE(week_start_at, metric, threshold)
|
|
1084
|
+
)
|
|
1085
|
+
"""
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1030
1088
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1031
1089
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1032
1090
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|