@matt82198/aesop 0.2.0 → 0.3.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +50 -260
  3. package/bin/cli.js +7 -3
  4. package/docs/HOOK-INSTALL.md +15 -56
  5. package/docs/INSTALL.md +4 -3
  6. package/docs/PORTING.md +166 -0
  7. package/docs/TEAM-STATE.md +16 -184
  8. package/docs/reproduce.md +33 -3
  9. package/driver/CLAUDE.md +50 -48
  10. package/driver/codex_driver.py +59 -12
  11. package/driver/openai_transport.py +33 -0
  12. package/driver/wave_bridge.py +9 -1
  13. package/driver/wave_loop.py +437 -70
  14. package/driver/wave_scheduler.py +890 -0
  15. package/hooks/pre-push-policy.sh +22 -5
  16. package/monitor/collect-signals.mjs +69 -0
  17. package/package.json +4 -4
  18. package/state_store/__init__.py +2 -1
  19. package/state_store/projections.py +63 -0
  20. package/state_store/read_api.py +156 -0
  21. package/state_store/write_api.py +462 -0
  22. package/tools/cost_ceiling.py +106 -15
  23. package/tools/cost_projection.py +559 -0
  24. package/tools/crossos_drift.py +394 -0
  25. package/tools/eod_sweep.py +137 -33
  26. package/tools/mutation_test.py +130 -8
  27. package/tools/proposals.mjs +47 -2
  28. package/tools/reproduce.js +405 -0
  29. package/tools/secret_scan.py +73 -18
  30. package/tools/stall_check.py +247 -16
  31. package/tools/stateapi_lint.py +325 -0
  32. package/tools/test_battery.py +173 -0
  33. package/tools/verify_cost_panel.py +345 -0
  34. package/tools/wave_preflight.py +296 -29
  35. package/tools/wave_templates.py +170 -45
  36. package/ui/collectors.py +81 -0
  37. package/ui/handler.py +230 -85
  38. package/ui/sse.py +3 -3
  39. package/ui/wave_telemetry.py +24 -18
  40. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  41. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  42. package/ui/web/dist/index.html +2 -2
  43. package/docs/QUICKSTART.md +0 -80
  44. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cost Projection & Threshold Alerting — Live burn-rate observability for Aesop.
4
+
5
+ Calculates burn rate (tokens/min) from recent ledger entries, projects end-of-wave
6
+ spend, and fires threshold alerts at 70% and 90% of the configured cost ceiling.
7
+
8
+ API:
9
+ project(window_minutes, ceiling, config, horizon_minutes=60) -> dict
10
+ Calculate current spend, burn rate, and projected end-of-wave total.
11
+ Returns:
12
+ {
13
+ "current": int, # tokens spent in window
14
+ "burn_rate_per_min": float, # avg tokens/min in window
15
+ "projected": int, # projected spend at horizon
16
+ "ceiling": int or None, # configured ceiling (or None if unconfigured)
17
+ "pct_of_ceiling": float or None, # % of ceiling (or None if no ceiling)
18
+ "minutes_to_ceiling": float or None, # minutes until ceiling hit at current burn rate (None-safe)
19
+ "is_thin_window": bool, # warn if window has < 3 entries
20
+ "by_role": {role: {...}, ...}, # breakdown by model/role (haiku/sonnet/opus)
21
+ "reason": str or None # degradation reason if applicable
22
+ }
23
+
24
+ check_and_alert(window_minutes, ceiling, wave, config, horizon_minutes=60) -> dict
25
+ Run projection + fire threshold alerts at 70% and 90% of ceiling.
26
+ Writes to SECURITY-ALERTS.log and creates flag files for idempotency.
27
+ Single-writer-per-wave assumption: only one process calls this per wave.
28
+ Returns:
29
+ {
30
+ "alert_level": str or None, # "70", "90", or None if below threshold
31
+ "current": int,
32
+ "ceiling": int,
33
+ "pct_of_ceiling": float,
34
+ "minutes_to_ceiling": float or None, # minutes to ceiling breach at current burn rate
35
+ "fired_alert": bool # True if new alert was written (not already fired)
36
+ }
37
+
38
+ CLI:
39
+ python tools/cost_projection.py --projection [--window 30] [--horizon 60] [--ceiling N] [--json] [--config <path>]
40
+ Print projection result (human or JSON).
41
+ python tools/cost_projection.py --check-alerts --wave <id> [--horizon 60] [--ceiling N] [--json] [--config <path>]
42
+ Check thresholds and fire alerts; print result. Requires --wave or AESOP_WAVE env var.
43
+
44
+ Configuration (aesop.config.json):
45
+ "limits": {
46
+ "max_wave_tokens": 50000, # Ceiling for projection/alerts
47
+ "max_daily_tokens": null,
48
+ "wave_duration_minutes": 60 # Horizon for projection (optional; default 60)
49
+ }
50
+
51
+ Ledger Source:
52
+ Reads from state/ledger/OUTCOMES-LEDGER.md (markdown table with ISO timestamps).
53
+ Filters entries within window_minutes of NOW (UTC).
54
+ No naive datetimes; all UTC epoch-based.
55
+
56
+ WINDOW CONTRACT (shared with cost_ceiling.py):
57
+ Both cost_projection.py and cost_ceiling.py import and use the shared
58
+ calculate_window_bounds() helper to ensure identical window calculations.
59
+ When both tools are called with the same window_minutes value, they will
60
+ compute identical spend figures from the ledger.
61
+
62
+ This prevents disagreement between projection and ceiling checks that use
63
+ different window calculations.
64
+
65
+ Alert Idempotency:
66
+ Flag files: state/.cost-alert-{70,90}-w{wave}
67
+ Once fired for a (threshold, wave) pair, no duplicate log entries until next wave.
68
+ Single-writer guarantee: only the monitor/orchestrator calls this at the same cadence.
69
+
70
+ Write Integrity:
71
+ append_alert_log uses open('a')+write+flush+os.fsync for crash-safe appends.
72
+ mark_alert_fired called only after successful append (checked via return value).
73
+ Failures print CRITICAL to stderr; flag file not created on append failure.
74
+
75
+ Stdlib-only (json, sys, os, pathlib, datetime, collections).
76
+ """
77
+
78
+ import argparse
79
+ import json
80
+ import os
81
+ import sys
82
+ from pathlib import Path
83
+ from datetime import datetime, timezone, timedelta
84
+ from collections import defaultdict
85
+
86
+ try:
87
+ import fleet_ledger
88
+ import common
89
+ import cost_ceiling
90
+ except ImportError:
91
+ from tools import fleet_ledger
92
+ from tools import common
93
+ from tools import cost_ceiling
94
+
95
+
96
+ def get_state_dir(config=None):
97
+ """Resolve state directory from config or environment."""
98
+ if config and config.get("state_root"):
99
+ return Path(config["state_root"])
100
+ if os.environ.get("AESOP_STATE_ROOT"):
101
+ return Path(os.environ["AESOP_STATE_ROOT"])
102
+ return Path.cwd() / "state"
103
+
104
+
105
+ def get_ceiling(config):
106
+ """Extract max_wave_tokens ceiling from config, or None if unconfigured."""
107
+ if not config:
108
+ return None
109
+ limits = config.get("limits", {})
110
+ if not isinstance(limits, dict):
111
+ return None
112
+ return limits.get("max_wave_tokens")
113
+
114
+
115
+ def get_horizon_minutes(config):
116
+ """Extract wave_duration_minutes from config, or 60 if unconfigured."""
117
+ if not config:
118
+ return 60
119
+ limits = config.get("limits", {})
120
+ if not isinstance(limits, dict):
121
+ return 60
122
+ return limits.get("wave_duration_minutes", 60)
123
+
124
+
125
+ def filter_ledger_by_window(rows, window_minutes):
126
+ """Filter ledger rows to those within the last window_minutes (UTC).
127
+
128
+ Uses the shared calculate_window_bounds() helper from cost_ceiling.py
129
+ to ensure consistent window calculations with cost_ceiling.check().
130
+
131
+ Args:
132
+ rows: List of dicts from fleet_ledger.parse_ledger_rows()
133
+ window_minutes: Time window in minutes
134
+
135
+ Returns:
136
+ Filtered list of rows within the window
137
+
138
+ Window contract:
139
+ When cost_projection.project(window_minutes=W) and
140
+ cost_ceiling.check(window_minutes=W) both use the same W value,
141
+ they produce identical spend calculations.
142
+ """
143
+ if not rows:
144
+ return []
145
+
146
+ # Use shared window bounds helper from cost_ceiling
147
+ window_start, window_end = cost_ceiling.calculate_window_bounds(window_minutes)
148
+
149
+ filtered = []
150
+ for row in rows:
151
+ try:
152
+ iso_ts = row['iso_ts']
153
+ # Parse ISO timestamp (format: YYYY-MM-DDTHH:MM:SS[Z] or with offset)
154
+ ts_clean = iso_ts.replace('Z', '+00:00') if 'Z' in iso_ts else iso_ts
155
+ row_dt = datetime.fromisoformat(ts_clean)
156
+ # Ensure UTC
157
+ if row_dt.tzinfo is None:
158
+ row_dt = row_dt.replace(tzinfo=timezone.utc)
159
+
160
+ if row_dt >= window_start:
161
+ filtered.append(row)
162
+ except (ValueError, AttributeError):
163
+ # Skip malformed timestamps
164
+ continue
165
+
166
+ return filtered
167
+
168
+
169
+ def project(window_minutes=30, ceiling=None, config=None, horizon_minutes=None):
170
+ """Calculate current spend, burn rate, and end-of-wave projection.
171
+
172
+ Args:
173
+ window_minutes: Time window for burn rate calculation (default 30)
174
+ ceiling: Optional cost ceiling (for percentage calculation)
175
+ config: aesop.config.json dict or None (will use env var / defaults)
176
+ horizon_minutes: Minutes to project spend to (default from config or 60)
177
+
178
+ Returns:
179
+ dict with keys: current, burn_rate_per_min, projected, ceiling,
180
+ pct_of_ceiling, minutes_to_ceiling, is_thin_window, by_role, reason
181
+ """
182
+ if config is None:
183
+ config = {}
184
+
185
+ state_dir = get_state_dir(config)
186
+ if ceiling is None:
187
+ ceiling = get_ceiling(config)
188
+ if horizon_minutes is None:
189
+ horizon_minutes = get_horizon_minutes(config)
190
+
191
+ # Parse ledger
192
+ try:
193
+ rows = fleet_ledger.parse_ledger_rows()
194
+ except Exception as e:
195
+ return {
196
+ "current": 0,
197
+ "burn_rate_per_min": 0.0,
198
+ "projected": 0,
199
+ "ceiling": ceiling,
200
+ "pct_of_ceiling": None,
201
+ "is_thin_window": True,
202
+ "by_role": {},
203
+ "reason": f"Failed to read ledger: {str(e)[:100]}"
204
+ }
205
+
206
+ # Filter to window
207
+ windowed_rows = filter_ledger_by_window(rows, window_minutes)
208
+
209
+ # Calculate current spend and breakdown by role
210
+ current_total = 0
211
+ by_role = defaultdict(lambda: {"tokens_in": 0, "tokens_out": 0, "total": 0, "entries": 0})
212
+
213
+ for row in windowed_rows:
214
+ ti = row.get('tokens_in', 0)
215
+ to = row.get('tokens_out', 0)
216
+ total = ti + to
217
+ current_total += total
218
+
219
+ role = row.get('model', 'unknown')
220
+ by_role[role]['tokens_in'] += ti
221
+ by_role[role]['tokens_out'] += to
222
+ by_role[role]['total'] += total
223
+ by_role[role]['entries'] += 1
224
+
225
+ # Convert defaultdict to plain dict for JSON
226
+ by_role_dict = {k: dict(v) for k, v in by_role.items()}
227
+
228
+ # Calculate burn rate
229
+ if len(windowed_rows) == 0:
230
+ burn_rate_per_min = 0.0
231
+ is_thin_window = True
232
+ elif len(windowed_rows) < 3:
233
+ # Thin window: estimate from available data (but flag it)
234
+ # Use actual time span, not window size
235
+ try:
236
+ first_row = windowed_rows[0]
237
+ last_row = windowed_rows[-1]
238
+ first_ts = first_row['iso_ts'].replace('Z', '+00:00')
239
+ last_ts = last_row['iso_ts'].replace('Z', '+00:00')
240
+ first_dt = datetime.fromisoformat(first_ts)
241
+ last_dt = datetime.fromisoformat(last_ts)
242
+ if first_dt.tzinfo is None:
243
+ first_dt = first_dt.replace(tzinfo=timezone.utc)
244
+ if last_dt.tzinfo is None:
245
+ last_dt = last_dt.replace(tzinfo=timezone.utc)
246
+
247
+ time_span_min = (last_dt - first_dt).total_seconds() / 60.0
248
+ if time_span_min <= 0:
249
+ time_span_min = 1.0 # Avoid division by zero
250
+ burn_rate_per_min = current_total / time_span_min
251
+ except (ValueError, AttributeError):
252
+ burn_rate_per_min = 0.0
253
+ is_thin_window = True
254
+ else:
255
+ # Sufficient data: use window minutes
256
+ burn_rate_per_min = current_total / window_minutes if window_minutes > 0 else 0.0
257
+ is_thin_window = False
258
+
259
+ # Project to end-of-horizon (using horizon_minutes parameter)
260
+ projected = int(burn_rate_per_min * horizon_minutes)
261
+
262
+ # Calculate percentage of ceiling
263
+ pct_of_ceiling = None
264
+ if ceiling is not None and ceiling > 0:
265
+ pct_of_ceiling = (current_total / ceiling) * 100.0
266
+
267
+ # Calculate minutes until ceiling is hit at current burn rate (None-safe)
268
+ minutes_to_ceiling = None
269
+ if ceiling is not None and ceiling > 0 and burn_rate_per_min > 0:
270
+ remaining_tokens = ceiling - current_total
271
+ if remaining_tokens > 0:
272
+ minutes_to_ceiling = remaining_tokens / burn_rate_per_min
273
+ else:
274
+ minutes_to_ceiling = 0.0 # Already at or past ceiling
275
+
276
+ return {
277
+ "current": current_total,
278
+ "burn_rate_per_min": round(burn_rate_per_min, 2),
279
+ "projected": projected,
280
+ "ceiling": ceiling,
281
+ "pct_of_ceiling": pct_of_ceiling,
282
+ "minutes_to_ceiling": round(minutes_to_ceiling, 2) if minutes_to_ceiling is not None else None,
283
+ "is_thin_window": is_thin_window,
284
+ "by_role": by_role_dict,
285
+ "reason": None
286
+ }
287
+
288
+
289
+ def get_alert_flag_path(state_dir, threshold, wave):
290
+ """Get the flag file path for a (threshold, wave) pair."""
291
+ return Path(state_dir) / f".cost-alert-{threshold}-w{wave}"
292
+
293
+
294
+ def check_alert_already_fired(state_dir, threshold, wave):
295
+ """Check if alert has already been fired for this (threshold, wave) pair."""
296
+ flag = get_alert_flag_path(state_dir, threshold, wave)
297
+ return flag.exists()
298
+
299
+
300
+ def mark_alert_fired(state_dir, threshold, wave):
301
+ """Mark an alert as fired by creating a flag file."""
302
+ flag = get_alert_flag_path(state_dir, threshold, wave)
303
+ state_dir_path = Path(state_dir)
304
+ state_dir_path.mkdir(parents=True, exist_ok=True)
305
+ try:
306
+ flag.write_text(
307
+ datetime.now(timezone.utc).isoformat(),
308
+ encoding='utf-8'
309
+ )
310
+ except IOError as e:
311
+ print(f"[cost_projection] Failed to write flag file: {e}", file=sys.stderr)
312
+
313
+
314
+ def append_alert_log(state_dir, alert_line):
315
+ """Append one line to SECURITY-ALERTS.log (idempotent per flag).
316
+
317
+ Uses open('a')+write+flush+os.fsync for crash-safe appends (Windows-portable).
318
+
319
+ Returns:
320
+ bool: True if append succeeded, False on IOError
321
+ """
322
+ alert_file = Path(state_dir) / "SECURITY-ALERTS.log"
323
+ state_dir_path = Path(state_dir)
324
+ state_dir_path.mkdir(parents=True, exist_ok=True)
325
+
326
+ try:
327
+ with open(alert_file, 'a', encoding='utf-8') as f:
328
+ f.write(alert_line + '\n')
329
+ f.flush()
330
+ os.fsync(f.fileno())
331
+ return True
332
+ except IOError as e:
333
+ print(f"[cost_projection] CRITICAL: Failed to write alert: {e}", file=sys.stderr)
334
+ return False
335
+
336
+
337
+ def check_and_alert(window_minutes=30, ceiling=None, wave=None, config=None, horizon_minutes=None):
338
+ """Run projection + fire threshold alerts at 70% and 90% of ceiling.
339
+
340
+ Fires BOTH 70% and 90% alerts independently if a spike breaches 90%.
341
+ Only calls mark_alert_fired AFTER append succeeds.
342
+
343
+ Args:
344
+ window_minutes: Time window for burn rate (default 30)
345
+ ceiling: Cost ceiling (or None to use config)
346
+ wave: Wave number for alert flag files (required or from AESOP_WAVE env)
347
+ config: aesop.config.json dict
348
+ horizon_minutes: Minutes to project spend to (default from config or 60)
349
+
350
+ Returns:
351
+ dict with alert info:
352
+ {
353
+ "alert_level": "70"|"90"|None,
354
+ "current": int,
355
+ "ceiling": int,
356
+ "pct_of_ceiling": float,
357
+ "minutes_to_ceiling": float or None,
358
+ "fired_alert": bool
359
+ }
360
+
361
+ Raises:
362
+ ValueError: If wave is not provided and AESOP_WAVE env var is not set
363
+ """
364
+ # Resolve wave from argument or environment
365
+ if wave is None:
366
+ wave = os.environ.get("AESOP_WAVE")
367
+ if wave is None:
368
+ raise ValueError(
369
+ "wave parameter required: pass --wave <id> or set AESOP_WAVE env var"
370
+ )
371
+ try:
372
+ wave = int(wave)
373
+ except ValueError:
374
+ raise ValueError(f"AESOP_WAVE must be an integer, got: {wave}")
375
+
376
+ if config is None:
377
+ config = {}
378
+
379
+ state_dir = get_state_dir(config)
380
+ if ceiling is None:
381
+ ceiling = get_ceiling(config)
382
+ if horizon_minutes is None:
383
+ horizon_minutes = get_horizon_minutes(config)
384
+
385
+ proj = project(window_minutes, ceiling, config, horizon_minutes)
386
+ current = proj["current"]
387
+ pct = proj["pct_of_ceiling"]
388
+ minutes_to_ceiling = proj.get("minutes_to_ceiling")
389
+
390
+ alert_level = None
391
+ fired = False
392
+
393
+ if pct is not None and ceiling is not None:
394
+ # Check both thresholds independently (spike past 90% fires BOTH)
395
+ # 90% alert (highest priority, always checked)
396
+ if pct >= 90.0:
397
+ alert_level = "90"
398
+ if not check_alert_already_fired(state_dir, "90", wave):
399
+ timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
400
+ alert_line = (
401
+ f"[{timestamp}] CRITICAL: Cost projection at 90% of ceiling "
402
+ f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
403
+ )
404
+ if append_alert_log(state_dir, alert_line):
405
+ mark_alert_fired(state_dir, "90", wave)
406
+ fired = True
407
+ # Also check 70% when at 90% (fire BOTH independently)
408
+ if not check_alert_already_fired(state_dir, "70", wave):
409
+ timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
410
+ alert_line_70 = (
411
+ f"[{timestamp}] HIGH: Cost projection at 70% of ceiling "
412
+ f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
413
+ )
414
+ if append_alert_log(state_dir, alert_line_70):
415
+ mark_alert_fired(state_dir, "70", wave)
416
+ fired = True
417
+ # 70% alert (if not at 90%)
418
+ elif pct >= 70.0:
419
+ alert_level = "70"
420
+ if not check_alert_already_fired(state_dir, "70", wave):
421
+ timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
422
+ alert_line = (
423
+ f"[{timestamp}] HIGH: Cost projection at 70% of ceiling "
424
+ f"({current}/{ceiling} tokens, {pct:.1f}%); wave {wave}"
425
+ )
426
+ if append_alert_log(state_dir, alert_line):
427
+ mark_alert_fired(state_dir, "70", wave)
428
+ fired = True
429
+
430
+ return {
431
+ "alert_level": alert_level,
432
+ "current": current,
433
+ "ceiling": ceiling,
434
+ "pct_of_ceiling": pct,
435
+ "minutes_to_ceiling": minutes_to_ceiling,
436
+ "fired_alert": fired
437
+ }
438
+
439
+
440
+ def load_config_file(config_path=None):
441
+ """Load aesop.config.json from specified path or current directory."""
442
+ if config_path:
443
+ path = Path(config_path)
444
+ else:
445
+ path = Path.cwd() / "aesop.config.json"
446
+
447
+ if not path.exists():
448
+ return {}
449
+
450
+ try:
451
+ with open(path, 'r', encoding='utf-8') as f:
452
+ return json.load(f)
453
+ except (json.JSONDecodeError, IOError) as e:
454
+ print(f"[cost_projection] Failed to load config: {e}", file=sys.stderr)
455
+ return {}
456
+
457
+
458
+ def main(argv=None):
459
+ """CLI entry point."""
460
+ parser = argparse.ArgumentParser(
461
+ description="Cost projection and threshold alerting for Aesop."
462
+ )
463
+ parser.add_argument(
464
+ "--projection",
465
+ action="store_true",
466
+ help="Calculate and print projection (default mode)"
467
+ )
468
+ parser.add_argument(
469
+ "--check-alerts",
470
+ action="store_true",
471
+ help="Check thresholds and fire alerts"
472
+ )
473
+ parser.add_argument(
474
+ "--window",
475
+ type=int,
476
+ default=30,
477
+ help="Time window in minutes for burn rate (default 30)"
478
+ )
479
+ parser.add_argument(
480
+ "--horizon",
481
+ type=int,
482
+ default=None,
483
+ help="Minutes to project spend to (default from config or 60)"
484
+ )
485
+ parser.add_argument(
486
+ "--ceiling",
487
+ type=int,
488
+ default=None,
489
+ help="Cost ceiling in tokens (overrides config)"
490
+ )
491
+ parser.add_argument(
492
+ "--wave",
493
+ type=int,
494
+ default=None,
495
+ help="Wave number (for alert idempotency; required for --check-alerts or from AESOP_WAVE env)"
496
+ )
497
+ parser.add_argument(
498
+ "--json",
499
+ action="store_true",
500
+ help="Output as JSON"
501
+ )
502
+ parser.add_argument(
503
+ "--config",
504
+ type=str,
505
+ default=None,
506
+ help="Path to aesop.config.json (default: ./aesop.config.json)"
507
+ )
508
+
509
+ args = parser.parse_args(argv)
510
+
511
+ config = load_config_file(args.config)
512
+
513
+ if args.check_alerts:
514
+ try:
515
+ result = check_and_alert(
516
+ window_minutes=args.window,
517
+ ceiling=args.ceiling,
518
+ wave=args.wave,
519
+ config=config,
520
+ horizon_minutes=args.horizon
521
+ )
522
+ if args.json:
523
+ print(json.dumps(result, indent=2))
524
+ else:
525
+ pct_str = f"{result['pct_of_ceiling']:.1f}%" if result['pct_of_ceiling'] is not None else "N/A"
526
+ mtc_str = f"{result['minutes_to_ceiling']:.1f}min" if result['minutes_to_ceiling'] is not None else "N/A"
527
+ print(f"[cost_projection] alert_level={result['alert_level']}, "
528
+ f"current={result['current']}/{result['ceiling']}, "
529
+ f"pct={pct_str}, "
530
+ f"mtc={mtc_str}, "
531
+ f"fired={result['fired_alert']}")
532
+ return 0
533
+ except ValueError as e:
534
+ print(f"[cost_projection] ERROR: {e}", file=sys.stderr)
535
+ return 1
536
+ else:
537
+ # Default: --projection
538
+ result = project(
539
+ window_minutes=args.window,
540
+ ceiling=args.ceiling,
541
+ config=config,
542
+ horizon_minutes=args.horizon
543
+ )
544
+ if args.json:
545
+ print(json.dumps(result, indent=2))
546
+ else:
547
+ mtc_str = f"{result['minutes_to_ceiling']:.1f}min" if result['minutes_to_ceiling'] is not None else "N/A"
548
+ print(f"[cost_projection] current={result['current']}, "
549
+ f"burn_rate={result['burn_rate_per_min']}/min, "
550
+ f"projected={result['projected']}, "
551
+ f"ceiling={result['ceiling']}, "
552
+ f"pct={result['pct_of_ceiling']}, "
553
+ f"mtc={mtc_str}, "
554
+ f"thin_window={result['is_thin_window']}")
555
+ return 0
556
+
557
+
558
+ if __name__ == "__main__":
559
+ sys.exit(main())