cctally 1.23.0 → 1.25.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 CHANGED
@@ -5,6 +5,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.25.0] - 2026-06-03
9
+
10
+ ### Added
11
+ - **Per-project weekly budgets with their own actual-spend alerts (a new `project_budget` alert axis).** Set a dollar budget for any repo with `cctally budget set 25 --project` (resolves the current directory's git-root) or `--project /abs/path`, clear it with `cctally budget unset --project`, and `cctally budget` now renders a per-project section below the global status (budget / spent / used% / verdict / `LOW CONF`, sorted by used% desc) — present even when no global `budget.weekly_usd` is set, additive in `--json` (a `projects[]` array, no schema bump) and in share-output (names anonymized unless `--reveal-projects`). Turn on push alerts (opt-in, default off) with `cctally config set budget.project_alerts_enabled true`: when a project crosses one of your `budget.alert_thresholds` of its own budget, `record-usage` fires one cross-platform notification per `(project, threshold)` per week with project-specific text (e.g. *"Project foo - $26.00 of $25.00 (104% of budget)"*), severity from the shared 3-tier model. Setting a project budget mid-week when you're already over a threshold records that crossing silently (no retroactive popup) and only later crossings fire, and a mid-week budget change never re-alerts an already-fired threshold. Preview the notification without any real config via `cctally alerts test --axis project-budget --threshold 100`. Thresholds reuse the global `budget.alert_thresholds`; projects are keyed by canonical git-root so same-basename repos stay distinct. In the local web dashboard, fired project alerts now show in the existing "Recent alerts" panel/modal with a distinct **PROJECT** chip (vs the global **BUDGET** chip) and the project basename + `$spent of $budget` context, the Settings overlay gains a **Per-project budget alerts** on/off toggle that persists `budget.project_alerts_enabled` (enabling it mid-week when already over latches the crossed thresholds without storming retroactive popups), and the Settings "Send test alert" picker can fire the synthetic project-budget example end-to-end; editing per-project budget *amounts* stays CLI-only.
12
+
13
+ ## [1.24.0] - 2026-06-02
14
+
15
+ ### Added
16
+ - **Threshold alerts now dispatch cross-platform, not just on macOS.** Alongside the existing macOS `osascript` Notification Center popup, alerts can now fire via Linux `notify-send` (with the severity mapped to a `-u low|normal|critical` urgency token and a `--` end-of-options guard against option-injection) or via a fully custom command you configure — so Windows / WSL / headless setups can finally wire up a real popup. The backend is picked automatically per host (`auto`: a custom command if you've set one, else `osascript` on macOS, else `notify-send` on Linux, else no popup), and `cctally alerts test` now prints a `notifier: <resolved>` line so you can see exactly which backend will fire before relying on a real crossing. The dispatch decision lives in a new pure, fully-unit-tested kernel (`bin/_lib_alert_dispatch.py`) and every spawn is `shell=False` with the arg-list form, so alert text containing `$(...)`, `;`, or `&&` is passed as one literal argument and can never inject a shell command.
17
+ - **A new `alerts.command_template` config key lets you run any command on an alert.** Set it to a JSON argv list — e.g. `cctally config set alerts.command_template '["notify-send","-u","{urgency}","{title}","{body}"]'` — and cctally spawns that command (shell-free) on every crossing, substituting the documented `{title}`/`{subtitle}`/`{body}`/`{severity}`/`{urgency}`/`{axis}`/`{threshold}`/`{metric}` tokens (unmatched braces stay literal; a missing value substitutes as empty). This is trusted local execution (you own your config), and under the default `auto` notifier a set template takes over dispatch on every OS, so you can route alerts to a webhook, a logger, a phone push, or any tool you like. The companion `alerts.notifier` key (`auto` / `osascript` / `notify-send` / `command` / `none`) pins the backend explicitly when you don't want auto-detection, and both keys are editable from the dashboard Settings overlay (notifier dropdown) as well as `cctally config set`.
18
+
19
+ ### Changed
20
+ - **Alert severity is now a 3-tier model (info / warn / critical) instead of the previous two-color split.** A crossed threshold below 90% is `info` (indigo), 90–99% is `warn` (amber), and 100%+ is `critical` (red); this single mapping drives the dashboard toast color, the "Recent alerts" panel chip, and the Linux `notify-send` urgency token, and it is kept byte-identical between the Python authority (`bin/_lib_alert_axes.py::severity_for`) and the dashboard (`alertSeverity` in `dashboard/web/src/lib/alertAxis.ts`), with legacy `amber`/`red` tokens from an older backend normalizing to `warn`/`critical` on read. The `alerts.log` audit file gains a 7th tab-delimited column carrying this severity on every dispatch line. No action needed on upgrade; existing alert config and thresholds are unchanged.
21
+
8
22
  ## [1.23.0] - 2026-06-02
9
23
 
10
24
  ### Added
@@ -47,6 +47,7 @@ import datetime as dt
47
47
  import importlib.util as _ilu
48
48
  import os
49
49
  import pathlib
50
+ import shutil
50
51
  import subprocess
51
52
  import sys
52
53
 
@@ -69,13 +70,33 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
69
70
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
70
71
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
71
72
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
73
+ _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
72
74
  _alert_text_projected = _lib_alerts_payload._alert_text_projected
73
75
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
74
76
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
75
77
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
76
78
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
79
+ _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
77
80
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
78
81
 
82
+ # Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
83
+ # pure (parameterized on platform + which_on_path); this module is the I/O glue
84
+ # that injects the real sys.platform / shutil.which and spawns with shell=False.
85
+ _lib_alert_axes = _load_lib("_lib_alert_axes")
86
+ severity_for = _lib_alert_axes.severity_for
87
+ _lib_alert_dispatch = _load_lib("_lib_alert_dispatch")
88
+ resolve_notifier = _lib_alert_dispatch.resolve_notifier
89
+ build_command = _lib_alert_dispatch.build_command
90
+ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
91
+
92
+
93
+ # `load_config` STAYS a shim that bounces through cctally's namespace (mirrors
94
+ # bin/_cctally_record.py): production monkeypatches `cctally.load_config`, and
95
+ # the dispatch tests patch this module-level name directly. Its natural home is
96
+ # _cctally_config; a direct import would silently bypass those patches.
97
+ def load_config(*args, **kwargs):
98
+ return sys.modules["cctally"].load_config(*args, **kwargs)
99
+
79
100
 
80
101
  # === Honest imports from extracted homes ===================================
81
102
  # Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
@@ -103,14 +124,26 @@ def _dispatch_alert_notification(
103
124
  popen_factory=subprocess.Popen,
104
125
  mode: str = "real",
105
126
  tz: "object | None" = None,
127
+ platform: "str | None" = None,
128
+ which_on_path=None,
106
129
  ) -> str:
107
- """Spawn osascript to display a macOS notification (non-blocking, best-effort).
130
+ """Dispatch a notification for a crossed threshold (non-blocking, best-effort).
108
131
 
109
- Returns ``"queued"`` on successful Popen, ``"spawn_error: <ExcType>: <msg>"``
110
- on failure. Writes EXACTLY ONE line to ``alerts.log`` with the terminal
111
- status (no contradictory pre-/post-Popen log pair). Never raises:
112
- Popen-spawn failures and log-write failures are both swallowed so the
113
- dispatch contract stays independent of the OS / FS state.
132
+ Picks the active notifier (osascript / notify-send / a config-driven
133
+ command_template / none) via the pure ``_lib_alert_dispatch`` kernel, builds
134
+ its exact arg-list, and spawns it with ``shell=False``. Returns one of:
135
+ ``"queued"`` Popen succeeded
136
+ ``"no_notifier:none"`` auto/none resolved to no popup on this host
137
+ ``"no_notifier:unavailable"`` an explicit osascript/notify-send is missing
138
+ ``"spawn_error: <ExcType>: <msg>"`` Popen raised
139
+ Writes EXACTLY ONE line to ``alerts.log`` with the terminal status PLUS the
140
+ crossing's 3-tier severity as a trailing column. Never raises: the config
141
+ read, Popen-spawn failures, and log-write failures are all swallowed so the
142
+ dispatch contract stays independent of the OS / FS / user-config state.
143
+
144
+ ``platform`` (sys.platform-style) and ``which_on_path`` (name -> bool) are
145
+ injectable so every OS branch + the no-notifier paths are testable from any
146
+ host; both default to the real ``sys.platform`` / ``shutil.which``.
114
147
 
115
148
  Production callers ignore the return value (fire-and-forget); test
116
149
  callers assert on it via an injected ``popen_factory``.
@@ -140,6 +173,8 @@ def _dispatch_alert_notification(
140
173
  title, subtitle, body = _alert_text_five_hour(payload, tz)
141
174
  elif axis == "budget":
142
175
  title, subtitle, body = _alert_text_budget(payload, tz)
176
+ elif axis == "project_budget":
177
+ title, subtitle, body = _alert_text_project_budget(payload, tz)
143
178
  elif axis == "projected":
144
179
  title, subtitle, body = _alert_text_projected(payload, tz)
145
180
  else:
@@ -149,27 +184,64 @@ def _dispatch_alert_notification(
149
184
  f"axis={axis} threshold={payload.get('threshold')}",
150
185
  )
151
186
 
152
- script = (
153
- f'display notification "{_escape_applescript_string(body)}"'
154
- f' with title "{_escape_applescript_string(title)}"'
155
- f' subtitle "{_escape_applescript_string(subtitle)}"'
187
+ # Severity (3-tier) drives both the notify-send urgency token and the
188
+ # trailing log column. A missing threshold (defensive — shouldn't happen for
189
+ # a real crossing) floors at "info".
190
+ threshold = payload.get("threshold")
191
+ try:
192
+ severity = severity_for(int(threshold)) if threshold is not None else "info"
193
+ except (TypeError, ValueError):
194
+ severity = "info"
195
+ urgency = severity_to_urgency(severity)
196
+
197
+ if platform is None:
198
+ platform = sys.platform
199
+ if which_on_path is None:
200
+ which_on_path = lambda name: shutil.which(name) is not None
201
+
202
+ # Guarded so a malformed user config (or a load_config raise) never breaks
203
+ # the never-raise contract: fall back to auto-detect / no custom command.
204
+ try:
205
+ alerts_cfg = _cctally_core._get_alerts_config(load_config())
206
+ except Exception:
207
+ alerts_cfg = {"notifier": "auto", "command_template": None}
208
+
209
+ notifier = resolve_notifier(
210
+ alerts_cfg, platform=platform, which_on_path=which_on_path
211
+ )
212
+ args = build_command(
213
+ notifier,
214
+ title=title,
215
+ subtitle=subtitle,
216
+ body=body,
217
+ severity=severity,
218
+ urgency=urgency,
219
+ payload=payload,
220
+ command_template=alerts_cfg.get("command_template"),
156
221
  )
157
222
 
158
223
  status: str
159
- try:
160
- popen_factory(
161
- ["osascript", "-e", script],
162
- stdout=subprocess.DEVNULL,
163
- stderr=subprocess.DEVNULL,
164
- close_fds=True,
165
- start_new_session=True,
166
- )
167
- status = "queued"
168
- except (FileNotFoundError, PermissionError, OSError) as exc:
169
- status = f"spawn_error: {exc.__class__.__name__}: {exc}"
224
+ if args is None:
225
+ # 'none' (auto resolved to no popup, or notifier='none') vs an
226
+ # explicitly-selected native backend that is unavailable on this host.
227
+ selector = alerts_cfg.get("notifier", "auto")
228
+ reason = "unavailable" if selector in ("osascript", "notify-send") else "none"
229
+ status = f"no_notifier:{reason}"
230
+ else:
231
+ try:
232
+ popen_factory(
233
+ args,
234
+ stdout=subprocess.DEVNULL,
235
+ stderr=subprocess.DEVNULL,
236
+ close_fds=True,
237
+ start_new_session=True,
238
+ )
239
+ status = "queued"
240
+ except (FileNotFoundError, PermissionError, OSError) as exc:
241
+ status = f"spawn_error: {exc.__class__.__name__}: {exc}"
170
242
 
171
- # SINGLE log line per dispatch attempt (Codex P1#2 fix: no
172
- # contradictory "queued" + "spawn_error" pair for the same call).
243
+ # SINGLE log line per dispatch attempt (Codex P1#2 fix: no contradictory
244
+ # "queued" + "spawn_error" pair). Severity is appended as the 7th column.
173
245
  try:
174
246
  log_path = _alerts_log_path()
175
247
  ctx = payload.get("context") or {}
@@ -181,7 +253,7 @@ def _dispatch_alert_notification(
181
253
  )
182
254
  line = (
183
255
  f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
184
- f"\t{mode}\t{status}\n"
256
+ f"\t{mode}\t{status}\t{severity}\n"
185
257
  )
186
258
  with open(log_path, "a") as f:
187
259
  f.write(line)
@@ -211,6 +283,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
211
283
  axis = "weekly"
212
284
  elif args.axis == "budget":
213
285
  axis = "budget"
286
+ elif args.axis == "project-budget":
287
+ axis = "project_budget"
214
288
  elif args.axis == "projected":
215
289
  axis = "projected"
216
290
  else:
@@ -245,6 +319,22 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
245
319
  spent_usd=300.0 * threshold / 100.0,
246
320
  consumption_pct=float(threshold),
247
321
  )
322
+ elif axis == "project_budget":
323
+ # Synthetic per-project budget payload — NO DB writes (test/real
324
+ # divergence contract), NO real budget.projects entry required. A small
325
+ # $25 budget at $26 spent (104%) reads plausibly regardless of the
326
+ # --threshold (the body line shows the at-crossing snapshot the dashboard
327
+ # would render).
328
+ payload = _build_alert_payload_project_budget(
329
+ threshold=threshold,
330
+ crossed_at_utc=now_utc_iso(),
331
+ week_start_at=dt.date.today().isoformat(),
332
+ project="example-project",
333
+ project_key="/example/repos/example-project",
334
+ budget_usd=25.0,
335
+ spent_usd=26.0,
336
+ consumption_pct=104.0,
337
+ )
248
338
  elif axis == "projected":
249
339
  # Synthetic projected-pace payload — NO DB writes (test/real divergence
250
340
  # contract). The metric discriminator picks the wiring; projected_value
@@ -275,6 +365,20 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
275
365
  block_cost_usd=1.23,
276
366
  primary_model="claude-sonnet-4-6",
277
367
  )
368
+ # Resolve and report the active notifier for display BEFORE dispatch — the
369
+ # config read is guarded the same way `_dispatch_alert_notification` guards
370
+ # its own (so a malformed config never crashes `alerts test`). This is
371
+ # purely informational; the dispatch path re-resolves independently.
372
+ try:
373
+ alerts_cfg = _cctally_core._get_alerts_config(load_config())
374
+ except Exception:
375
+ alerts_cfg = {"notifier": "auto", "command_template": None}
376
+ notifier = resolve_notifier(
377
+ alerts_cfg,
378
+ platform=sys.platform,
379
+ which_on_path=lambda name: shutil.which(name) is not None,
380
+ )
381
+ print(f"notifier: {notifier}")
278
382
  status = _dispatch_alert_notification(payload, mode="test")
279
383
  if status == "queued":
280
384
  print("Test alert dispatched (mode=test). Check Notification Center.")
@@ -298,6 +298,8 @@ ALLOWED_CONFIG_KEYS = (
298
298
  "display.tz",
299
299
  "alerts.enabled",
300
300
  "alerts.projected_enabled",
301
+ "alerts.notifier",
302
+ "alerts.command_template",
301
303
  "dashboard.bind",
302
304
  "update.check.enabled",
303
305
  "update.check.ttl_hours",
@@ -308,6 +310,8 @@ ALLOWED_CONFIG_KEYS = (
308
310
  "budget.alerts_enabled",
309
311
  "budget.alert_thresholds",
310
312
  "budget.projected_enabled",
313
+ "budget.projects",
314
+ "budget.project_alerts_enabled",
311
315
  )
312
316
 
313
317
 
@@ -414,6 +418,21 @@ def _config_known_value(config: dict, key: str) -> "object":
414
418
  return bool(_get_alerts_config(config)["projected_enabled"])
415
419
  except c._AlertsConfigError:
416
420
  return False
421
+ if key == "alerts.notifier":
422
+ # Validated dispatch backend (defaults to 'auto' when unset). A corrupt
423
+ # alerts block surfaces the default — mirrors alerts.enabled.
424
+ try:
425
+ return _get_alerts_config(config)["notifier"]
426
+ except c._AlertsConfigError:
427
+ return "auto"
428
+ if key == "alerts.command_template":
429
+ # Validated argv list or None (defaults to None when unset). A corrupt
430
+ # alerts block surfaces the default. The plain-text render path JSON-
431
+ # encodes this so `config get` round-trips through `config set`.
432
+ try:
433
+ return _get_alerts_config(config)["command_template"]
434
+ except c._AlertsConfigError:
435
+ return None
417
436
  if key == "dashboard.bind":
418
437
  # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
419
438
  # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
@@ -482,6 +501,8 @@ def _config_known_value(config: dict, key: str) -> "object":
482
501
  "budget.alerts_enabled",
483
502
  "budget.alert_thresholds",
484
503
  "budget.projected_enabled",
504
+ "budget.projects",
505
+ "budget.project_alerts_enabled",
485
506
  ):
486
507
  inner = key.split(".", 1)[1]
487
508
  # Read the validated, defaults-filled block. A corrupt block falls
@@ -496,6 +517,8 @@ def _config_known_value(config: dict, key: str) -> "object":
496
517
  default = _BUDGET_DEFAULTS[inner]
497
518
  if isinstance(default, list):
498
519
  return list(default)
520
+ if isinstance(default, dict):
521
+ return dict(default)
499
522
  return default
500
523
  return None
501
524
 
@@ -505,14 +528,21 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
505
528
  if key is not None and key not in ALLOWED_CONFIG_KEYS:
506
529
  eprint(f"cctally config: unknown config key {key!r}")
507
530
  return 2
531
+ # `alerts.command_template` is JSON-shaped (a list of strings or null), and
532
+ # `budget.projects` is JSON-shaped (an object), so their real values
533
+ # (including None) must survive into the render layer — the generic
534
+ # None->"" coercion below would break the JSON shape / round-trip.
535
+ def _coerce(k: str, v: "object") -> "object":
536
+ if k in ("alerts.command_template", "budget.projects"):
537
+ return v
538
+ return v if v is not None else ""
539
+
508
540
  pairs: "list[tuple[str, object]]" = []
509
541
  if key is None:
510
542
  for k in ALLOWED_CONFIG_KEYS:
511
- v = _config_known_value(config, k)
512
- pairs.append((k, v if v is not None else ""))
543
+ pairs.append((k, _coerce(k, _config_known_value(config, k))))
513
544
  else:
514
- v = _config_known_value(config, key)
515
- pairs.append((key, v if v is not None else ""))
545
+ pairs.append((key, _coerce(key, _config_known_value(config, key))))
516
546
 
517
547
  if getattr(args, "emit_json", False):
518
548
  # Walk every dot-delimited segment so keys deeper than two
@@ -532,7 +562,13 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
532
562
  for k, v in pairs:
533
563
  # Preserve canonical bool stringification (true/false) so
534
564
  # round-trips via `config set alerts.enabled <plain-text>` work.
535
- if isinstance(v, bool):
565
+ if k in ("alerts.command_template", "budget.projects"):
566
+ # JSON-encoded so `config get` output round-trips through the
567
+ # matching `config set` branch (both JSON-parse their value).
568
+ # `alerts.command_template` is a list-of-strings|null;
569
+ # `budget.projects` is an object {git-root: usd}.
570
+ rendered = json.dumps(v)
571
+ elif isinstance(v, bool):
536
572
  rendered = "true" if v else "false"
537
573
  elif isinstance(v, list):
538
574
  # Comma-joined so `config get budget.alert_thresholds` output
@@ -653,6 +689,76 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
653
689
  f"alerts.projected_enabled={'true' if normalized else 'false'}"
654
690
  )
655
691
  return 0
692
+ if key == "alerts.notifier":
693
+ # Dispatch backend (Phase B). Plain string; the enum constraint is
694
+ # enforced by the pre-persist _get_alerts_config validation (so we never
695
+ # write a config that fails subsequent reads). Same read-modify-write
696
+ # posture as alerts.enabled (preserves sibling alerts.* keys).
697
+ normalized = raw.strip()
698
+ with config_writer_lock():
699
+ config = _load_config_unlocked()
700
+ existing_alerts = config.get("alerts")
701
+ if existing_alerts is not None and not isinstance(
702
+ existing_alerts, dict
703
+ ):
704
+ print(
705
+ "cctally: alerts config error: alerts must be an object",
706
+ file=sys.stderr,
707
+ )
708
+ return 2
709
+ alerts_block = dict(existing_alerts or {})
710
+ alerts_block["notifier"] = normalized
711
+ try:
712
+ _get_alerts_config({**config, "alerts": alerts_block})
713
+ except _AlertsConfigError as exc:
714
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
715
+ return 2
716
+ config["alerts"] = alerts_block
717
+ save_config(config)
718
+ if getattr(args, "emit_json", False):
719
+ print(json.dumps({"alerts": {"notifier": normalized}}, indent=2))
720
+ else:
721
+ print(f"alerts.notifier={normalized}")
722
+ return 0
723
+ if key == "alerts.command_template":
724
+ # Dispatch argv template (Phase B). JSON-parsed value (a list of strings
725
+ # or null to clear it); the shape + cross-field constraints are enforced
726
+ # by the pre-persist _get_alerts_config validation. Same read-modify-
727
+ # write posture as alerts.enabled (preserves sibling alerts.* keys).
728
+ try:
729
+ parsed = json.loads(raw)
730
+ except (ValueError, TypeError) as exc:
731
+ print(
732
+ f"cctally: alerts.command_template must be JSON (a list of "
733
+ f"strings or null): {exc}",
734
+ file=sys.stderr,
735
+ )
736
+ return 2
737
+ with config_writer_lock():
738
+ config = _load_config_unlocked()
739
+ existing_alerts = config.get("alerts")
740
+ if existing_alerts is not None and not isinstance(
741
+ existing_alerts, dict
742
+ ):
743
+ print(
744
+ "cctally: alerts config error: alerts must be an object",
745
+ file=sys.stderr,
746
+ )
747
+ return 2
748
+ alerts_block = dict(existing_alerts or {})
749
+ alerts_block["command_template"] = parsed
750
+ try:
751
+ _get_alerts_config({**config, "alerts": alerts_block})
752
+ except _AlertsConfigError as exc:
753
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
754
+ return 2
755
+ config["alerts"] = alerts_block
756
+ save_config(config)
757
+ if getattr(args, "emit_json", False):
758
+ print(json.dumps({"alerts": {"command_template": parsed}}, indent=2))
759
+ else:
760
+ print(f"alerts.command_template={json.dumps(parsed)}")
761
+ return 0
656
762
  if key == "dashboard.bind":
657
763
  # Validation rejects whitespace / empty / non-string up front;
658
764
  # write proceeds under config_writer_lock with _load_config_unlocked
@@ -771,6 +877,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
771
877
  "budget.alerts_enabled",
772
878
  "budget.alert_thresholds",
773
879
  "budget.projected_enabled",
880
+ "budget.projects",
881
+ "budget.project_alerts_enabled",
774
882
  ):
775
883
  inner_key = key.split(".", 1)[1]
776
884
  # Parse + normalize the raw value per key BEFORE acquiring the lock so
@@ -790,7 +898,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
790
898
  f"null, got {raw!r}"
791
899
  )
792
900
  return 2
793
- elif inner_key in ("alerts_enabled", "projected_enabled"):
901
+ elif inner_key in (
902
+ "alerts_enabled", "projected_enabled", "project_alerts_enabled"
903
+ ):
794
904
  lo = raw.strip().lower()
795
905
  if lo in ("true", "yes", "on", "1"):
796
906
  new_val = True
@@ -802,6 +912,42 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
802
912
  f"got {raw!r}"
803
913
  )
804
914
  return 2
915
+ elif inner_key == "projects":
916
+ # `budget.projects` is a dict {git-root: usd}, which the plain
917
+ # number/bool/list leaves can't round-trip — JSON-parse it (mirrors
918
+ # the alerts.command_template branch). The per-value numeric rule is
919
+ # enforced by _get_budget_config under the lock below; here we only
920
+ # reject non-JSON / non-object shape.
921
+ try:
922
+ parsed_obj = json.loads(raw)
923
+ except (json.JSONDecodeError, ValueError):
924
+ eprint(
925
+ "cctally config: budget.projects must be a JSON object, "
926
+ f"got {raw!r}"
927
+ )
928
+ return 2
929
+ if not isinstance(parsed_obj, dict):
930
+ eprint("cctally config: budget.projects must be a JSON object")
931
+ return 2
932
+ # Canonicalize each project key to its resolved git-root, mirroring
933
+ # the `budget set --project` CLI path (`_resolve_project_budget_-
934
+ # target`). `_sum_cost_by_project` buckets spend under the realpath'd
935
+ # `ProjectKey.bucket_path`, so a `~`/relative/sub-dir/trailing-slash
936
+ # key stored verbatim would NEVER match → a permanent $0 row that
937
+ # silently never alerts. Resolving here makes the JSON-object surface
938
+ # match the per-project CLI surface. Non-string keys (impossible from
939
+ # json.loads, defensive) and the `__CWD__`-non-git None case fall
940
+ # back to the raw key for `_get_budget_config` to handle.
941
+ c = _cctally()
942
+ new_val = {
943
+ (
944
+ c._resolve_project_budget_target(pk)
945
+ if isinstance(pk, str)
946
+ else pk
947
+ )
948
+ or pk: pv
949
+ for pk, pv in parsed_obj.items()
950
+ }
805
951
  else: # alert_thresholds — comma-separated int list (empty = silenced)
806
952
  stripped = raw.strip()
807
953
  parsed: "list[int]" = []
@@ -843,13 +989,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
843
989
  # helper opens stats.db and must not nest under the config lock
844
990
  # (fcntl.flock is per-fd; the helper has its own open_db locking).
845
991
  c = _cctally()
846
- c._reconcile_budget_on_config_write(validated)
992
+ # Gate each forward-only reconcile (spec §6.8) on the keys it actually
993
+ # consumes. Running unconditionally on an UNRELATED write — e.g. the
994
+ # global axis on `config set budget.projects`, or the per-project axis
995
+ # on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
996
+ # dispatched threshold as already-alerted, permanently suppressing the
997
+ # next record-usage tick's dispatch. The global axis feeds on
998
+ # weekly_usd/alerts_enabled/alert_thresholds; the per-project axis on
999
+ # projects/project_alerts_enabled/alert_thresholds (alert_thresholds is
1000
+ # shared; projected_enabled belongs to neither reconcile). Both run
1001
+ # OUTSIDE config_writer_lock (each helper has its own open_db lock).
1002
+ if inner_key in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
1003
+ c._reconcile_budget_on_config_write(validated)
1004
+ if inner_key in (
1005
+ "projects", "project_alerts_enabled", "alert_thresholds"
1006
+ ):
1007
+ c._reconcile_project_budget_milestones_on_write(validated)
847
1008
  out_val = validated[inner_key]
848
1009
  if getattr(args, "emit_json", False):
849
1010
  print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
850
1011
  else:
851
1012
  if isinstance(out_val, bool):
852
1013
  rendered = "true" if out_val else "false"
1014
+ elif inner_key == "projects":
1015
+ # JSON so `config get budget.projects` round-trips back through
1016
+ # this branch (str(dict) is not valid JSON).
1017
+ rendered = json.dumps(out_val)
853
1018
  else:
854
1019
  rendered = str(out_val)
855
1020
  print(f"{key}={rendered}")
@@ -873,15 +1038,25 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
873
1038
  save_config(config)
874
1039
  # idempotent: silent on missing key
875
1040
  return 0
876
- if key in ("alerts.enabled", "alerts.projected_enabled"):
1041
+ if key in (
1042
+ "alerts.enabled",
1043
+ "alerts.projected_enabled",
1044
+ "alerts.notifier",
1045
+ "alerts.command_template",
1046
+ ):
877
1047
  # Mirror the display.tz branch: writer-lock + _load_config_unlocked
878
1048
  # (NOT load_config — fcntl.flock is per-fd so re-entry would
879
1049
  # self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
880
1050
  # named key preserves any user-customized threshold lists
881
1051
  # (`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.
1052
+ # enabled/projected_enabled/notifier/command_template keys. For
1053
+ # enabled/projected_enabled/notifier the read-time validator
1054
+ # (`_get_alerts_config`) re-applies the canonical default
1055
+ # (`False` / `"auto"`) for the missing key on next get. NOT so for
1056
+ # command_template when notifier == "command": the cross-field
1057
+ # constraint makes notifier="command" REQUIRE a template, so dropping
1058
+ # the template would leave a config that _get_alerts_config REJECTS on
1059
+ # the next read. The pre-persist guard below catches exactly that case.
885
1060
  inner_key = key.split(".", 1)[1]
886
1061
  with config_writer_lock():
887
1062
  config = _load_config_unlocked()
@@ -890,6 +1065,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
890
1065
  del block[inner_key]
891
1066
  if not block:
892
1067
  config.pop("alerts", None)
1068
+ # Pre-persist guard (mirrors the set branches): unsetting a key
1069
+ # that participates in a cross-field constraint
1070
+ # (alerts.command_template while alerts.notifier == "command")
1071
+ # would leave a config that _get_alerts_config rejects on the
1072
+ # next read. Validate the TOP-LEVEL config (so a pruned/empty
1073
+ # alerts block correctly validates to defaults) and refuse
1074
+ # rather than persist an unreadable config.
1075
+ try:
1076
+ _get_alerts_config(config)
1077
+ except _AlertsConfigError as exc:
1078
+ print(
1079
+ f"cctally: alerts config error: {exc}", file=sys.stderr
1080
+ )
1081
+ return 2
893
1082
  save_config(config)
894
1083
  # idempotent: silent on missing key
895
1084
  return 0
@@ -948,6 +1137,8 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
948
1137
  "budget.alerts_enabled",
949
1138
  "budget.alert_thresholds",
950
1139
  "budget.projected_enabled",
1140
+ "budget.projects",
1141
+ "budget.project_alerts_enabled",
951
1142
  ):
952
1143
  # Drop only the named leaf; preserve sibling budget.* keys (e.g.
953
1144
  # unsetting weekly_usd keeps a customized alert_thresholds). If the