@caddis/cli 0.4.52 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """caddis_spend - one global spend cap for agy, in EUR per rolling 24 hours, on this machine.
3
+
4
+ Why: on 7 Oct 2026 two unsupervised overnight agy runs cost EUR 793 in one billing day. Nothing in
5
+ caddis capped money (caddis_governor caps CPU and memory only). Every agy launch that caddis makes
6
+ through a lane (`caddis_lanes.run_lane`, which ci-watch also uses) or a plan Lane line
7
+ (`caddis_spend.py run -- agy ...`) goes through `reserve()` before it starts and `settle()` after it ends.
8
+ The roadmap/plan runner (`caddis_roadmap_run.py --engine agy`) reserves before each agy session and settles after it.
9
+
10
+ Config `~/.caddis/spend-cap.toml` (the owner writes it; caddis never does):
11
+
12
+ [agy]
13
+ eur_per_24h = 200
14
+ reserve_eur_per_call = 10 # what a call still running, or killed, is assumed to cost
15
+ eur_per_million_input_tokens = 0 # from Google Cloud billing; 0 = unset
16
+ eur_per_million_output_tokens = 0
17
+
18
+ Every value must be a finite number above 0. Anything else means NOT CONFIGURED, and every agy launch is
19
+ refused (fail closed).
20
+
21
+ Log `~/.caddis/spend/agy.jsonl`, append-only. A `pending` line is written under a file lock before the
22
+ launch and a `settled` line with the same id after it. A pending line with no valid settled line keeps
23
+ counting its reserve for 24 h, so a killed or crashed call is never free, and two callers cannot both
24
+ pass the check with the last EUR of the cap.
25
+
26
+ Limits (by design, see .caddis/plans/caddis-next.md Phase 4):
27
+ - The cap is per machine. The only cap across machines is a Google Cloud budget alert.
28
+ - agy started by hand, or by a script outside caddis, is not covered.
29
+ - The cap file and the log are plain files of this user: any process of this user, including an agy
30
+ session started with --dangerously-skip-permissions, can edit them. The cap stops accidents and
31
+ runaway loops; it is not a security boundary against a hostile local process.
32
+ - The check runs at launch. A long call can cost more than its reserve before it is settled; an
33
+ estimated settle records its duration so the overshoot is visible.
34
+ - EUR is an estimate from token counts and the owner's prices, not the real bill.
35
+
36
+ CLI:
37
+ python caddis_spend.py check exit 0 allowed, 1 over cap, 3 not configured
38
+ python caddis_spend.py status prints the committed spend of the last 24 h
39
+ python caddis_spend.py run --caller X -- agy ...
40
+ reserve, run, settle. Returns agy's own exit code, or 75 over the cap / could not lock,
41
+ or 78 not configured. 75 and 78 are never agy's own codes for a refusal, so a caller can
42
+ tell "the cap refused" from "agy failed". Each refusal also prints a `caddis_spend:` line.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import math
50
+ import os
51
+ import subprocess
52
+ import sys
53
+ import time
54
+ import uuid
55
+ from contextlib import contextmanager
56
+ from dataclasses import dataclass
57
+ from datetime import datetime, timedelta, timezone
58
+ from pathlib import Path
59
+
60
+ EXIT_OK = 0
61
+ EXIT_OVER_CAP = 1 # `check` only
62
+ EXIT_NOT_CONFIGURED = 3 # `check` only
63
+ RUN_REFUSED = 75 # `run`: over the cap, or could not lock (EX_TEMPFAIL)
64
+ RUN_NOT_CONFIGURED = 78 # `run`: not configured (EX_CONFIG)
65
+ LOCK_TIMEOUT_S = 10.0
66
+ NOT_CONFIGURED_MESSAGE = ("caddis_spend: spend cap not configured: set eur_per_24h, reserve_eur_per_call and both "
67
+ "token prices (finite, above 0) in ~/.caddis/spend-cap.toml; every agy launch is refused "
68
+ "until then")
69
+
70
+
71
+ class NotConfigured(Exception):
72
+ """The cap file is missing, unreadable, or holds a value that is not a finite number above 0."""
73
+
74
+
75
+ @dataclass
76
+ class Cap:
77
+ eur_per_24h: float
78
+ reserve: float
79
+ price_in: float
80
+ price_out: float
81
+
82
+
83
+ def _cap_path(home: Path) -> Path:
84
+ return home / ".caddis" / "spend-cap.toml"
85
+
86
+
87
+ def _log_path(home: Path) -> Path:
88
+ return home / ".caddis" / "spend" / "agy.jsonl"
89
+
90
+
91
+ def _positive(value) -> float:
92
+ """A finite float above 0, or ValueError (bool and NaN/inf rejected)."""
93
+ if isinstance(value, bool):
94
+ raise ValueError(f"not a number: {value!r}")
95
+ number = float(value)
96
+ if not math.isfinite(number) or number <= 0:
97
+ raise ValueError(f"must be a finite number above 0: {value!r}")
98
+ return number
99
+
100
+
101
+ def load_cap(home: Path) -> Cap:
102
+ import tomllib
103
+
104
+ path = _cap_path(home)
105
+ try:
106
+ data = tomllib.loads(path.read_text(encoding="utf-8")).get("agy", {})
107
+ return Cap(_positive(data["eur_per_24h"]), _positive(data.get("reserve_eur_per_call", 10)),
108
+ _positive(data["eur_per_million_input_tokens"]), _positive(data["eur_per_million_output_tokens"]))
109
+ except (OSError, ValueError, KeyError, TypeError, AttributeError, tomllib.TOMLDecodeError) as exc:
110
+ raise NotConfigured(f"{path}: {exc}") from exc
111
+
112
+
113
+ @contextmanager
114
+ def _locked(home: Path):
115
+ lock = _log_path(home).with_name("agy.lock")
116
+ lock.parent.mkdir(parents=True, exist_ok=True)
117
+ fh = open(lock, "a+b")
118
+ deadline = time.monotonic() + LOCK_TIMEOUT_S
119
+ try:
120
+ while True:
121
+ try:
122
+ if sys.platform == "win32":
123
+ import msvcrt
124
+
125
+ fh.seek(0)
126
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
127
+ else:
128
+ import fcntl
129
+
130
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
131
+ break
132
+ except OSError:
133
+ if time.monotonic() >= deadline:
134
+ raise TimeoutError(f"caddis_spend: could not lock {lock} within {LOCK_TIMEOUT_S:g} s")
135
+ time.sleep(0.05)
136
+ yield
137
+ finally:
138
+ try:
139
+ if sys.platform == "win32":
140
+ import msvcrt
141
+
142
+ fh.seek(0)
143
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
144
+ else:
145
+ import fcntl
146
+
147
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
148
+ except OSError:
149
+ pass
150
+ fh.close()
151
+
152
+
153
+ def json_mode(argv: list[str]) -> bool:
154
+ """True only when the command has exactly ONE output-format flag and its value is `json`
155
+ (`--output-format json` or `--output-format=json`). A second flag could switch agy to text, where the
156
+ model's own lines could pose as a usage document."""
157
+ values = []
158
+ for i, arg in enumerate(argv):
159
+ if arg == "--output-format":
160
+ values.append(argv[i + 1] if i + 1 < len(argv) else "")
161
+ elif arg.startswith("--output-format="):
162
+ values.append(arg.split("=", 1)[1])
163
+ return values == ["json"]
164
+
165
+
166
+ def _read(home: Path) -> list[dict]:
167
+ try:
168
+ text = _log_path(home).read_text(encoding="utf-8", errors="replace")
169
+ except FileNotFoundError:
170
+ return []
171
+ out = []
172
+ for line in text.splitlines():
173
+ try:
174
+ record = json.loads(line)
175
+ except json.JSONDecodeError:
176
+ continue # a torn line never hides the rest of the log
177
+ if isinstance(record, dict) and isinstance(record.get("id", ""), str):
178
+ out.append(record) # a non-string id (hand edit) is skipped, never a crash
179
+ return out
180
+
181
+
182
+ def _append(home: Path, record: dict) -> None:
183
+ path = _log_path(home)
184
+ path.parent.mkdir(parents=True, exist_ok=True)
185
+ with path.open("a", encoding="utf-8") as fh:
186
+ fh.write(json.dumps(record) + "\n")
187
+
188
+
189
+ def _when(record: dict) -> datetime | None:
190
+ try:
191
+ when = datetime.fromisoformat(record["ts"])
192
+ except (KeyError, TypeError, ValueError):
193
+ return None
194
+ return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
195
+
196
+
197
+ def _amount(value) -> float | None:
198
+ """A finite EUR amount of 0 or more, else None (NaN, inf, negative, text and null are not money)."""
199
+ if isinstance(value, bool):
200
+ return None
201
+ try:
202
+ number = float(value)
203
+ except (TypeError, ValueError):
204
+ return None
205
+ return number if math.isfinite(number) and number >= 0 else None
206
+
207
+
208
+ def committed_last_24h(home: Path, now: datetime) -> float:
209
+ """Valid settled EUR, plus the reserve of every pending call with no valid settled line, newer than 24 h.
210
+
211
+ A settled line with a bad `eur` does not count as settled, so its pending reserve keeps counting.
212
+ A pending line with a bad reserve (only a hand edit can write one; reserve() always writes a valid
213
+ value) counts as 0."""
214
+ since = now - timedelta(hours=24)
215
+ records = _read(home)
216
+ settled: dict[str, float] = {}
217
+ for r in records:
218
+ if r.get("kind") == "settled":
219
+ amount = _amount(r.get("eur"))
220
+ when = _when(r)
221
+ if amount is not None and when is not None and isinstance(r.get("id"), str):
222
+ settled[r["id"]] = settled.get(r["id"], 0.0) + (amount if when > since else 0.0)
223
+ total = sum(settled.values())
224
+ for r in records:
225
+ if r.get("kind") != "pending" or r.get("id") in settled:
226
+ continue
227
+ when = _when(r)
228
+ if when is None or when <= since:
229
+ continue
230
+ total += _amount(r.get("reserve_eur")) or 0.0
231
+ return total
232
+
233
+
234
+ def reserve(home: Path, caller: str, now: datetime) -> str | None:
235
+ """Book a pending call. None when it would go over the cap. Raises NotConfigured or TimeoutError."""
236
+ cap = load_cap(home)
237
+ with _locked(home):
238
+ if committed_last_24h(home, now) + cap.reserve > cap.eur_per_24h:
239
+ return None
240
+ rid = uuid.uuid4().hex
241
+ _append(home, {"kind": "pending", "id": rid, "ts": now.isoformat(), "caller": caller,
242
+ "pid": os.getpid(), "reserve_eur": cap.reserve})
243
+ return rid
244
+
245
+
246
+ def settle(home: Path, rid: str, tokens: tuple[int, int] | None, now: datetime,
247
+ duration_s: float | None = None) -> float:
248
+ """Record the real cost of a reserved call; with no token counts, its reserve (estimated, with duration)."""
249
+ try:
250
+ cap = load_cap(home)
251
+ except NotConfigured:
252
+ cap = None
253
+ record: dict = {"kind": "settled", "id": rid, "ts": now.isoformat()}
254
+ if tokens is None or cap is None:
255
+ pending = next((r for r in _read(home) if r.get("kind") == "pending" and r.get("id") == rid), {})
256
+ eur = _amount(pending.get("reserve_eur"))
257
+ if eur is None:
258
+ eur = cap.reserve if cap else 0.0
259
+ record.update({"tokens_in": 0, "tokens_out": 0, "eur": round(eur, 4), "estimated": True})
260
+ else:
261
+ t_in, t_out = tokens
262
+ eur = t_in / 1e6 * cap.price_in + t_out / 1e6 * cap.price_out
263
+ record.update({"tokens_in": t_in, "tokens_out": t_out, "eur": round(eur, 4), "estimated": False})
264
+ if duration_s is not None:
265
+ record["duration_s"] = round(duration_s, 1)
266
+ with _locked(home):
267
+ _append(home, record)
268
+ return eur
269
+
270
+
271
+ def _agy_document(stdout: str) -> dict | None:
272
+ """agy's own JSON document from `agy --output-format json` (sample: scripts/tests/fixtures/agy-json-sample.json).
273
+
274
+ The document may follow other lines (on Windows the governor writes `[caddis-governor] scope=...` first,
275
+ and run_lane merges stderr into stdout), and agy pretty-prints it over several lines. So: try every `{`
276
+ that starts a line, decode one JSON value from there, and keep the LAST dict that has agy's top-level
277
+ keys (`status`, `response`, `usage`). The model's reply is inside the `response` string, where a `{`
278
+ never starts a line of the raw output, so text the model prints cannot pose as the usage document."""
279
+ decoder = json.JSONDecoder()
280
+ text = stdout or ""
281
+ found = None
282
+ starts = [0] if text.startswith("{") else []
283
+ starts += [i + 1 for i, ch in enumerate(text) if ch == "\n" and text[i + 1:i + 2] == "{"]
284
+ for start in starts:
285
+ try:
286
+ obj, _end = decoder.raw_decode(text, start)
287
+ except json.JSONDecodeError:
288
+ continue
289
+ if isinstance(obj, dict) and {"status", "response", "usage"} <= obj.keys():
290
+ found = obj
291
+ return found
292
+
293
+
294
+ def _count(value) -> int | None:
295
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
296
+ return None
297
+ return value
298
+
299
+
300
+ def tokens_from_output(stdout: str) -> tuple[int, int] | None:
301
+ """(usage.input_tokens, usage.output_tokens) from agy's JSON document; output_tokens includes thinking
302
+ tokens (sample: 27249 in, 85 out of which 84 thinking, total 27334). None when not readable, or when a
303
+ count is not a non-negative integer (then the call settles at its reserve)."""
304
+ doc = _agy_document(stdout)
305
+ usage = doc.get("usage") if doc else None
306
+ if not isinstance(usage, dict):
307
+ return None
308
+ t_in, t_out = _count(usage.get("input_tokens")), _count(usage.get("output_tokens"))
309
+ return (t_in, t_out) if t_in is not None and t_out is not None else None
310
+
311
+
312
+ def result_text(stdout: str) -> str:
313
+ """The `response` text from agy's JSON document; the raw text when there is no such document."""
314
+ doc = _agy_document(stdout)
315
+ if doc and isinstance(doc.get("response"), str):
316
+ return doc["response"]
317
+ return stdout
318
+
319
+
320
+ def run(argv: list[str], caller: str, home: Path) -> int:
321
+ """Reserve, run agy, settle. Returns agy's exit code, or RUN_REFUSED (75) / RUN_NOT_CONFIGURED (78)."""
322
+ now = lambda: datetime.now(timezone.utc) # noqa: E731
323
+ try:
324
+ rid = reserve(home, caller, now())
325
+ except NotConfigured:
326
+ print(NOT_CONFIGURED_MESSAGE, file=sys.stderr)
327
+ return RUN_NOT_CONFIGURED
328
+ except TimeoutError as exc:
329
+ print(str(exc), file=sys.stderr)
330
+ return RUN_REFUSED
331
+ except Exception as exc: # an unreadable log or file error: refuse with the refusal code, never exit 1
332
+ print(f"caddis_spend: could not check the spend cap: {exc!r}", file=sys.stderr)
333
+ return RUN_REFUSED
334
+ if rid is None:
335
+ print(f"caddis_spend: over cap: agy refused ({status_line(home, now())})", file=sys.stderr)
336
+ return RUN_REFUSED
337
+ env = {**os.environ, "CADDIS_HEADLESS": "1"}
338
+ out = ""
339
+ counts: tuple[int, int] | None = None
340
+ started = time.monotonic()
341
+ try:
342
+ done = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
343
+ out = done.stdout or ""
344
+ # Tokens only from JSON mode: in text mode the model's own lines could pose as a usage document.
345
+ if json_mode(argv):
346
+ counts = tokens_from_output(out)
347
+ sys.stdout.write(out)
348
+ sys.stderr.write(done.stderr or "")
349
+ return done.returncode
350
+ except FileNotFoundError:
351
+ counts = (0, 0) # nothing ran, nothing was spent
352
+ print(f"caddis_spend: command not found: {argv[0]}", file=sys.stderr)
353
+ return 127
354
+ finally:
355
+ try:
356
+ settle(home, rid, counts, now(), duration_s=time.monotonic() - started)
357
+ except Exception as exc: # the pending reserve keeps counting; never lose agy's own result
358
+ print(f"caddis_spend: could not settle {rid}: {exc}", file=sys.stderr)
359
+
360
+
361
+ def status_line(home: Path, now: datetime) -> str:
362
+ try:
363
+ cap = f"{load_cap(home).eur_per_24h:g}"
364
+ except NotConfigured:
365
+ cap = "NOT CONFIGURED"
366
+ return f"agy: EUR {committed_last_24h(home, now):.2f} of {cap} in the last 24 h on this machine"
367
+
368
+
369
+ def main(argv: list[str] | None = None) -> int:
370
+ if hasattr(sys.stdout, "reconfigure"):
371
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
372
+ raw = list(sys.argv[1:] if argv is None else argv)
373
+ tail: list[str] = []
374
+ if "--" in raw:
375
+ i = raw.index("--")
376
+ raw, tail = raw[:i], raw[i + 1:]
377
+ parser = argparse.ArgumentParser(description="global agy spend cap (EUR per rolling 24 h, this machine)")
378
+ sub = parser.add_subparsers(dest="cmd", required=True)
379
+ sub.add_parser("check")
380
+ sub.add_parser("status")
381
+ p_run = sub.add_parser("run")
382
+ p_run.add_argument("--caller", required=True)
383
+ args = parser.parse_args(raw)
384
+ home = Path.home()
385
+ now = datetime.now(timezone.utc)
386
+ if args.cmd == "status":
387
+ try:
388
+ print(status_line(home, now))
389
+ except Exception as exc:
390
+ print(f"caddis_spend: could not read the spend log: {exc!r}", file=sys.stderr)
391
+ return EXIT_NOT_CONFIGURED
392
+ return EXIT_OK
393
+ if args.cmd == "check":
394
+ try:
395
+ cap = load_cap(home)
396
+ except NotConfigured:
397
+ print(NOT_CONFIGURED_MESSAGE, file=sys.stderr)
398
+ return EXIT_NOT_CONFIGURED
399
+ try:
400
+ committed = committed_last_24h(home, now)
401
+ except Exception as exc:
402
+ print(f"caddis_spend: could not read the spend log: {exc!r}", file=sys.stderr)
403
+ return EXIT_NOT_CONFIGURED
404
+ if committed + cap.reserve > cap.eur_per_24h:
405
+ print(f"over cap: {status_line(home, now)}")
406
+ return EXIT_OVER_CAP
407
+ print(f"ok: {status_line(home, now)}")
408
+ return EXIT_OK
409
+ if not tail:
410
+ parser.error("run needs the agy command after --")
411
+ return run(tail, args.caller, home)
412
+
413
+
414
+ if __name__ == "__main__":
415
+ sys.exit(main())