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.
- package/CHANGELOG.md +20 -0
- package/bin/_cctally_alerts.py +133 -24
- package/bin/_cctally_config.py +195 -14
- package/bin/_cctally_core.py +102 -2
- package/bin/_cctally_dashboard.py +277 -62
- 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 +470 -137
- package/bin/_cctally_tui.py +1 -0
- package/bin/_lib_alert_axes.py +53 -0
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/cctally +17 -0
- package/dashboard/static/assets/{index-BxmaYT1y.css → index-CsqqtRBB.css} +1 -1
- package/dashboard/static/assets/index-DwuW39Tv.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CLcd-Tnm.js +0 -18
package/bin/_cctally_core.py
CHANGED
|
@@ -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 = {
|
|
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
|