cctally 1.22.4 → 1.24.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.
@@ -427,7 +427,18 @@ class _AlertsConfigError(ValueError):
427
427
  """Raised by _get_alerts_config on invalid alerts block."""
428
428
 
429
429
 
430
- _ALERTS_CONFIG_VALID_KEYS = {"enabled", "weekly_thresholds", "five_hour_thresholds"}
430
+ _ALERTS_CONFIG_VALID_KEYS = {
431
+ "enabled",
432
+ "weekly_thresholds",
433
+ "five_hour_thresholds",
434
+ "projected_enabled",
435
+ "notifier",
436
+ "command_template",
437
+ }
438
+
439
+ # Dispatch backends (Phase B). "auto" picks a platform default; "command"
440
+ # routes through alerts.command_template (which it then requires).
441
+ _ALERTS_VALID_NOTIFIERS = ("auto", "osascript", "notify-send", "command", "none")
431
442
 
432
443
 
433
444
  def _validate_threshold_list(name: str, value: object) -> "list[int]":
@@ -499,10 +510,56 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
499
510
  five_hour = _validate_threshold_list(
500
511
  "five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
501
512
  )
513
+ # projected-pace opt-in (#121); default OFF so upgrades fire no surprise
514
+ # notifications. Bool-validated (NOT coerced) so a non-bool is a config
515
+ # error, not silently truthy.
516
+ projected_enabled = block.get("projected_enabled", False)
517
+ if not isinstance(projected_enabled, bool):
518
+ raise _AlertsConfigError(
519
+ f"alerts.projected_enabled must be a JSON boolean, got "
520
+ f"{type(projected_enabled).__name__}: {projected_enabled!r}"
521
+ )
522
+ # Dispatch-global keys (Phase B). `notifier` selects the backend;
523
+ # `command_template` is an argv list for the `command` backend (and may be
524
+ # set ahead of switching the backend). The cross-field constraint
525
+ # (notifier='command' requires a template) is enforced last.
526
+ notifier = block.get("notifier", "auto")
527
+ if notifier not in _ALERTS_VALID_NOTIFIERS:
528
+ raise _AlertsConfigError(
529
+ f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
530
+ f"got {notifier!r}"
531
+ )
532
+ command_template = block.get("command_template", None)
533
+ if command_template is not None:
534
+ if not isinstance(command_template, list) or not command_template:
535
+ raise _AlertsConfigError(
536
+ "alerts.command_template must be null or a non-empty list of strings"
537
+ )
538
+ for el in command_template:
539
+ if not isinstance(el, str):
540
+ raise _AlertsConfigError(
541
+ f"alerts.command_template elements must be strings, "
542
+ f"got {type(el).__name__}: {el!r}"
543
+ )
544
+ if "\x00" in el:
545
+ raise _AlertsConfigError(
546
+ "alerts.command_template elements must not contain a NUL byte"
547
+ )
548
+ if not command_template[0].strip():
549
+ raise _AlertsConfigError(
550
+ "alerts.command_template[0] (the program) must not be empty/whitespace"
551
+ )
552
+ if notifier == "command" and command_template is None:
553
+ raise _AlertsConfigError(
554
+ "alerts.notifier='command' requires alerts.command_template to be set"
555
+ )
502
556
  return {
503
557
  "enabled": enabled,
504
558
  "weekly_thresholds": weekly,
505
559
  "five_hour_thresholds": five_hour,
560
+ "projected_enabled": projected_enabled,
561
+ "notifier": notifier,
562
+ "command_template": command_template,
506
563
  }
507
564
 
508
565
 
@@ -517,8 +574,14 @@ _BUDGET_DEFAULTS = {
517
574
  "weekly_usd": None, # None = no budget (default)
518
575
  "alerts_enabled": True, # "on when set"
519
576
  "alert_thresholds": [90, 100],
577
+ "projected_enabled": False, # projected-pace opt-in (#121); default OFF
578
+ }
579
+ _BUDGET_CONFIG_VALID_KEYS = {
580
+ "weekly_usd",
581
+ "alerts_enabled",
582
+ "alert_thresholds",
583
+ "projected_enabled",
520
584
  }
521
- _BUDGET_CONFIG_VALID_KEYS = {"weekly_usd", "alerts_enabled", "alert_thresholds"}
522
585
 
523
586
 
524
587
  def _get_budget_config(cfg: dict) -> dict:
@@ -579,6 +642,12 @@ def _get_budget_config(cfg: dict) -> dict:
579
642
  cleaned.append(t)
580
643
  out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
581
644
 
645
+ if "projected_enabled" in block:
646
+ v = block["projected_enabled"]
647
+ if not isinstance(v, bool):
648
+ raise _BudgetConfigError("budget.projected_enabled must be a boolean")
649
+ out["projected_enabled"] = v
650
+
582
651
  return out
583
652
 
584
653
 
@@ -1027,6 +1096,37 @@ def open_db() -> sqlite3.Connection:
1027
1096
  """
1028
1097
  )
1029
1098
 
1099
+ # ── projected_milestones (week-average-pace projection crossings — #121) ──
1100
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — same
1101
+ # posture as `budget_milestones` (write-once, forward-only, no
1102
+ # `reset_event_id` segment column). Two metrics share the table, keyed by
1103
+ # `metric` ('weekly_pct' | 'budget_usd'); a level fires once the
1104
+ # WEEK-AVERAGE projection (not the displayed high-end verdict) crosses
1105
+ # `threshold`. `denominator` snapshots the target AT crossing (target_usd
1106
+ # for budget_usd, 100.0 for weekly_pct) so the dashboard envelope renders
1107
+ # context "$312 of $300" / "102% of cap" from the ROW, not from live config
1108
+ # that may have changed since (Codex P0-4). A mid-week reset re-anchors
1109
+ # `week_start_at` (new window → fresh rows under the UNIQUE key), the
1110
+ # budget-pattern reset handling — hence NO `reset_event_id` column.
1111
+ # `alerted_at` is stamped BEFORE the osascript Popen (set-then-dispatch).
1112
+ # Lives BEFORE the migration dispatcher: a plain CREATE on a
1113
+ # framework-untracked table never touches `schema_migrations`.
1114
+ conn.execute(
1115
+ """
1116
+ CREATE TABLE IF NOT EXISTS projected_milestones (
1117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1118
+ week_start_at TEXT NOT NULL,
1119
+ metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd'
1120
+ threshold INTEGER NOT NULL, -- 90 | 100
1121
+ projected_value REAL NOT NULL,
1122
+ denominator REAL NOT NULL, -- target_usd (budget) | 100.0 (weekly)
1123
+ crossed_at_utc TEXT NOT NULL,
1124
+ alerted_at TEXT,
1125
+ UNIQUE(week_start_at, metric, threshold)
1126
+ )
1127
+ """
1128
+ )
1129
+
1030
1130
  # Migration framework dispatcher. Replaces the prior inline gate stack
1031
1131
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1032
1132
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers