loopagent-gate 0.4.2__py3-none-any.whl

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 (40) hide show
  1. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env python3
2
+ """Stop a card that costs more than it is worth.
3
+
4
+ A loop without a budget is not a loop: it is a process that can spend a five-hour subscription on one
5
+ stuck card and still hand back nothing. The dispatcher already enforces a wall-clock cap per run
6
+ (`--max-runtime`); this script covers the two things a clock cannot see — money spent (tokens) and
7
+ progress made (new commits in the run's workspace).
8
+
9
+ python3 -m loopgate.gates.guard_card # report only, database source
10
+ python3 -m loopgate.gates.guard_card --json
11
+ python3 -m loopgate.gates.guard_card --enforce # SIGTERM + block + comment
12
+ python3 -m loopgate.gates.guard_card --snapshot queue.json # no database, no runtime
13
+ python3 -m loopgate.gates.guard_card --snapshot queue.json --json
14
+ python3 -m loopgate.gates.guard_card --selftest # prove every rule on fixtures
15
+
16
+ Verdict: CARD_BUDGET_STOP_0001 (exit 1) names every offending card and the rule it broke;
17
+ CARD_BUDGET_PASS_0001 (exit 0) means every active run is inside its budget.
18
+
19
+ Sources of truth (exactly one of them per run)
20
+ ----------------------------------------------
21
+ Database source, both read-only here, and the default because existing users have it:
22
+ ~/.hermes/kanban.db — tasks (assignee, status) and task_runs (worker_pid, started_at, metadata)
23
+ ~/.hermes/state.db — session_model_usage keyed by the worker session recorded in run metadata
24
+
25
+ Snapshot source (`--snapshot`): a JSON document produced by whoever owns the queue, so the budget rule
26
+ works without one particular agent runtime and can be tested in CI. Shape, kept minimal on purpose:
27
+
28
+ {"runs": [{"task_id": "t_1", "run_id": 1, "assignee": "dev", "status": "running",
29
+ "started_at": 1792512986, "worker_pid": null, "workspace": "/path/to/worktree",
30
+ "session_tokens": {"input": 180000, "output": 12000, "cache_read": 90000,
31
+ "cache_write": 0, "api_calls": 40}}]}
32
+
33
+ `task_id`, `run_id`, `status`, `started_at` and `session_tokens` are required; `assignee`, `worker_pid`,
34
+ `workspace` and a card `title` are optional. `max_runs` is counted from the snapshot's own entries: a
35
+ snapshot knows the runs it was exported with, and nothing else.
36
+
37
+ Limits of the measurement, stated so nobody over-reads the number
38
+ -----------------------------------------------------------------
39
+ * Tokens are counted for the worker session only (not the reviewer's). In the database source a session
40
+ that is absent from the usage table is a measurement limit and reports `unknown`; in a snapshot the
41
+ token block is required input, so a missing key is a *data* defect and the card cannot be certified —
42
+ `unknown` is never rendered as `0` and never counts as a pass. A rule that cannot see the spend must
43
+ not certify it.
44
+ * A run whose workspace is not a git repository reports progress as `unknown` rather than "none", so a
45
+ missing workspace never manufactures a "no progress" stop.
46
+ * The budget values are a starting point to be re-measured from finished runs
47
+ (`loopgate/gates/estimate_task.py` does the same for time).
48
+ * `--enforce` against a snapshot can only signal a live `worker_pid`: there is no queue CLI to comment or
49
+ block on, so the board is deliberately left alone and the action list says so. Signaling is refused
50
+ (`EPERM`/`ESRCH`) rather than escalated.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from loopgate.paths import project_root, gate_file
56
+
57
+ import argparse
58
+ import contextlib
59
+ import io
60
+ import json
61
+ import os
62
+ import signal
63
+ import sqlite3
64
+ import subprocess
65
+ import sys
66
+ import tempfile
67
+ import time
68
+
69
+ ROOT = project_root()
70
+ PASS_MARKER = "CARD_BUDGET_PASS_0001"
71
+ STOP_MARKER = "CARD_BUDGET_STOP_0001"
72
+ SELFTEST_MARKER = "CARD_BUDGET_SELFTEST_PASS_0001"
73
+ DEFAULT_CONFIG = os.path.join(ROOT, "configs", "card_budget.json")
74
+ KANBAN_DB = os.path.expanduser("~/.hermes/kanban.db")
75
+ STATE_DB = os.path.expanduser("~/.hermes/state.db")
76
+ ACTIVE_STATUSES = ("running", "claimed", "in_progress", "review")
77
+ SNAPSHOT_REQUIRED = ("task_id", "run_id", "status", "started_at", "session_tokens")
78
+ TOKEN_KEYS = (("input", "input"), ("output", "output"), ("cache_read", "cache_read"),
79
+ ("cache_write", "cache_write"), ("api_calls", "api_calls"))
80
+
81
+
82
+ class SnapshotError(ValueError):
83
+ """The snapshot cannot be read as a queue: named, because a silent empty queue looks like a pass."""
84
+
85
+
86
+ def load_config(path: str) -> dict:
87
+ try:
88
+ with open(path, encoding="utf-8") as fh:
89
+ raw = json.load(fh)
90
+ except FileNotFoundError:
91
+ raw = {}
92
+ base = {"max_tokens": 250_000, "max_runs": 3, "max_runtime_minutes": 30, "no_progress_minutes": 20}
93
+ base.update(raw.get("default") or {})
94
+ return {"default": base, "profiles": raw.get("profiles") or {}}
95
+
96
+
97
+ def budget_for(cfg: dict, assignee: str | None) -> dict:
98
+ merged = dict(cfg["default"])
99
+ merged.update(cfg["profiles"].get(assignee or "", {}) or {})
100
+ return merged
101
+
102
+
103
+ def worker_session(metadata: str | None) -> str | None:
104
+ if not metadata:
105
+ return None
106
+ try:
107
+ data = json.loads(metadata)
108
+ except (TypeError, ValueError):
109
+ return None
110
+ for key in ("worker_session_id", "session_id"):
111
+ value = data.get(key)
112
+ if value:
113
+ return str(value)
114
+ return None
115
+
116
+
117
+ def session_tokens(state_db: str, session_id: str | None) -> dict | None:
118
+ """Tokens billed to this worker session, or None when the session is not in the usage table."""
119
+ if not session_id or not os.path.exists(state_db):
120
+ return None
121
+ try:
122
+ conn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True)
123
+ rows = conn.execute(
124
+ "select coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), "
125
+ "coalesce(sum(cache_read_tokens),0), coalesce(sum(cache_write_tokens),0), "
126
+ "coalesce(sum(api_call_count),0) from session_model_usage where session_id = ?",
127
+ (session_id,),
128
+ ).fetchall()
129
+ conn.close()
130
+ except sqlite3.Error:
131
+ return None
132
+ input_t, output_t, read_t, write_t, calls = rows[0]
133
+ if not calls:
134
+ return None
135
+ return {"input": input_t, "output": output_t, "cache_read": read_t, "cache_write": write_t,
136
+ "api_calls": calls, "total": input_t + output_t + read_t + write_t}
137
+
138
+
139
+ def snapshot_tokens(block) -> dict | None:
140
+ """Token block from a snapshot entry; None when it is absent or unusable (never a silent zero)."""
141
+ if not isinstance(block, dict):
142
+ return None
143
+ numbers = {}
144
+ for name, key in TOKEN_KEYS:
145
+ value = block.get(key)
146
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
147
+ return None
148
+ numbers[name] = value
149
+ numbers["total"] = numbers["input"] + numbers["output"] + numbers["cache_read"] + numbers["cache_write"]
150
+ return numbers
151
+
152
+
153
+ def commits_since(workspace: str | None, started_at: float | None) -> int | None:
154
+ """Commits made in the run's workspace after it started; None when that cannot be known."""
155
+ if not workspace or not started_at or not os.path.isdir(workspace):
156
+ return None
157
+ if not os.path.isdir(os.path.join(workspace, ".git")) and not os.path.exists(os.path.join(workspace, ".git")):
158
+ return None
159
+ try:
160
+ proc = subprocess.run(["git", "-C", workspace, "log", f"--since=@{int(started_at)}", "--oneline"],
161
+ capture_output=True, text=True, timeout=30)
162
+ except (OSError, subprocess.SubprocessError):
163
+ return None
164
+ if proc.returncode != 0:
165
+ return None
166
+ return len([line for line in proc.stdout.splitlines() if line.strip()])
167
+
168
+
169
+ def active_runs(db: str) -> list[dict]:
170
+ if not os.path.exists(db):
171
+ return []
172
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
173
+ conn.row_factory = sqlite3.Row
174
+ placeholders = ",".join("?" for _ in ACTIVE_STATUSES)
175
+ rows = conn.execute(
176
+ f"""select r.id as run_id, r.task_id, r.status, r.worker_pid, r.started_at, r.last_heartbeat_at,
177
+ r.metadata, r.max_runtime_seconds,
178
+ t.title, t.assignee, t.workspace_path, t.priority
179
+ from task_runs r join tasks t on t.id = r.task_id
180
+ where r.ended_at is null and r.status in ({placeholders})
181
+ order by r.started_at""",
182
+ ACTIVE_STATUSES,
183
+ ).fetchall()
184
+ conn.close()
185
+ return [dict(r) for r in rows]
186
+
187
+
188
+ def snapshot_runs(path: str) -> list[dict]:
189
+ """Read a queue snapshot into the same row shape `active_runs` returns.
190
+
191
+ Required keys are validated here rather than defaulted: an entry missing `started_at` or
192
+ `session_tokens` is reported to the caller (through `_missing`) so the card is refused, not measured
193
+ as zero. Shape errors abort with a named reason — "no active runs" and "I could not read the queue"
194
+ must never look the same.
195
+ """
196
+ try:
197
+ with open(path, encoding="utf-8") as fh:
198
+ payload = json.load(fh)
199
+ except OSError as exc:
200
+ raise SnapshotError(f"{path}: {exc.__class__.__name__}: {exc}") from exc
201
+ except json.JSONDecodeError as exc:
202
+ raise SnapshotError(f"{path}: not JSON ({exc})") from exc
203
+ if isinstance(payload, dict):
204
+ entries = payload.get("runs")
205
+ if not isinstance(entries, list):
206
+ raise SnapshotError(f"{path}: expected an object with a 'runs' list")
207
+ elif isinstance(payload, list):
208
+ entries = payload
209
+ else:
210
+ raise SnapshotError(f"{path}: expected an object with a 'runs' list or a bare list of runs")
211
+ runs: list[dict] = []
212
+ for index, entry in enumerate(entries):
213
+ if not isinstance(entry, dict):
214
+ raise SnapshotError(f"{path}: runs[{index}] is not an object")
215
+ missing = [key for key in SNAPSHOT_REQUIRED if entry.get(key) in (None, "")]
216
+ tokens = snapshot_tokens(entry.get("session_tokens"))
217
+ if "session_tokens" not in missing and tokens is None:
218
+ missing.append("session_tokens")
219
+ runs.append({
220
+ "run_id": entry.get("run_id"),
221
+ "task_id": entry.get("task_id"),
222
+ "status": entry.get("status"),
223
+ "worker_pid": entry.get("worker_pid"),
224
+ "started_at": entry.get("started_at"),
225
+ "last_heartbeat_at": entry.get("last_heartbeat_at"),
226
+ "metadata": None,
227
+ "max_runtime_seconds": entry.get("max_runtime_seconds"),
228
+ "title": entry.get("title") or entry.get("task_title") or "",
229
+ "assignee": entry.get("assignee"),
230
+ "workspace_path": entry.get("workspace") or entry.get("workspace_path"),
231
+ "priority": entry.get("priority"),
232
+ "_origin": "snapshot",
233
+ "_tokens": tokens,
234
+ "_missing": missing,
235
+ })
236
+ return runs
237
+
238
+
239
+ def run_count(db: str, task_id: str) -> int:
240
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
241
+ total = conn.execute("select count(*) from task_runs where task_id = ?", (task_id,)).fetchone()[0]
242
+ conn.close()
243
+ return total
244
+
245
+
246
+ def evaluate(run: dict, cfg: dict, state_db: str, db: str, now: float,
247
+ runs_used: int | None = None) -> dict:
248
+ budget = budget_for(cfg, run.get("assignee"))
249
+ started = run.get("started_at")
250
+ runtime_min = (now - started) / 60.0 if started else None
251
+ origin = run.get("_origin") or "database"
252
+ if origin == "snapshot":
253
+ tokens = run.get("_tokens")
254
+ else:
255
+ tokens = session_tokens(state_db, worker_session(run.get("metadata")))
256
+ commits = commits_since(run.get("workspace_path"), started)
257
+ if runs_used is None:
258
+ runs_used = run_count(db, run["task_id"])
259
+
260
+ offences: list[str] = []
261
+ unverifiable: list[str] = []
262
+ for key in run.get("_missing") or []:
263
+ if key == "session_tokens":
264
+ # Input the exporter was required to provide: unknown is not an excuse, it is a missing fact.
265
+ unverifiable.append(
266
+ f"spent an unknown number of tokens (the snapshot entry has no usable `session_tokens`), "
267
+ f"so the {budget['max_tokens']:,} token cap cannot be certified")
268
+ elif key == "started_at":
269
+ unverifiable.append("no `started_at` in the snapshot entry, so wall clock and the progress "
270
+ "clock cannot be judged")
271
+ else:
272
+ unverifiable.append(f"the snapshot entry is missing `{key}`, so the run cannot be judged")
273
+ offences.extend(unverifiable)
274
+ if tokens and tokens["total"] > budget["max_tokens"]:
275
+ offences.append(f"spent {tokens['total']:,} tokens (cap {budget['max_tokens']:,})")
276
+ if runs_used > budget["max_runs"]:
277
+ offences.append(f"{runs_used} runs on one card (cap {budget['max_runs']})")
278
+ if runtime_min is not None and runtime_min > budget["max_runtime_minutes"]:
279
+ offences.append(f"{runtime_min:.0f} min of wall clock (cap {budget['max_runtime_minutes']})")
280
+ if (runtime_min is not None and runtime_min > budget["no_progress_minutes"]
281
+ and commits == 0):
282
+ offences.append(f"no commit in {runtime_min:.0f} min (progress clock {budget['no_progress_minutes']} min)")
283
+
284
+ return {
285
+ "task_id": run["task_id"],
286
+ "run_id": run["run_id"],
287
+ "assignee": run.get("assignee"),
288
+ "title": (run.get("title") or "")[:70],
289
+ "worker_pid": run.get("worker_pid"),
290
+ "origin": origin,
291
+ "runtime_minutes": None if runtime_min is None else round(runtime_min, 1),
292
+ "runs_used": runs_used,
293
+ "tokens": tokens["total"] if tokens else None,
294
+ "token_split": tokens,
295
+ "commits": commits,
296
+ "budget": budget,
297
+ "unverifiable": unverifiable,
298
+ "offences": offences,
299
+ }
300
+
301
+
302
+ def enforce(card: dict, db: str, hermes: str | None) -> list[str]:
303
+ """Stop the worker, then let the board know through its own CLI (never by hand-writing the DB)."""
304
+ actions: list[str] = []
305
+ pid = card.get("worker_pid")
306
+ if pid:
307
+ try:
308
+ os.kill(int(pid), signal.SIGTERM)
309
+ actions.append(f"SIGTERM -> pid {pid}")
310
+ except ProcessLookupError:
311
+ actions.append(f"pid {pid} already gone")
312
+ except OSError as exc:
313
+ actions.append(f"SIGTERM failed for pid {pid}: {exc}")
314
+ else:
315
+ actions.append("no worker_pid recorded (the run was never started locally)")
316
+ if card.get("origin") == "snapshot":
317
+ actions.append("snapshot source: the queue itself was not touched (no block, no comment) — "
318
+ "a snapshot has no board to write to; update the exporter's queue instead")
319
+ return actions
320
+ reason = "; ".join(card["offences"]) or "budget exceeded"
321
+ if hermes:
322
+ for args in (["kanban", "comment", card["task_id"], "--author", "guard_card",
323
+ f"Stopped by guard_card.py: {reason}"],
324
+ ["kanban", "block", card["task_id"], "--reason", f"card budget: {reason}"]):
325
+ try:
326
+ proc = subprocess.run([hermes, *args], capture_output=True, text=True, timeout=60)
327
+ actions.append(("ok: " if proc.returncode == 0 else "failed: ") + " ".join(args[:3])
328
+ + ("" if proc.returncode == 0 else f" — {proc.stderr.strip()[:120]}"))
329
+ except (OSError, subprocess.SubprocessError) as exc:
330
+ actions.append(f"failed: {' '.join(args[:3])} — {exc}")
331
+ else:
332
+ actions.append("`hermes` not on PATH: the board was not updated, only the process was stopped")
333
+ return actions
334
+
335
+
336
+ def find_hermes() -> str | None:
337
+ for candidate in (os.environ.get("HERMES_BIN"), os.path.expanduser("~/.local/bin/hermes"),
338
+ "/opt/homebrew/bin/hermes"):
339
+ if candidate and os.path.exists(candidate):
340
+ return candidate
341
+ from shutil import which
342
+ return which("hermes")
343
+
344
+
345
+ def main(argv=None) -> int:
346
+ ap = argparse.ArgumentParser(description="Stop cards that exceed their token/run/progress budget.")
347
+ ap.add_argument("--db", default=KANBAN_DB)
348
+ ap.add_argument("--state-db", default=STATE_DB)
349
+ ap.add_argument("--config", default=DEFAULT_CONFIG)
350
+ ap.add_argument("--snapshot", help="read the queue from a JSON snapshot instead of the kanban "
351
+ "database (no runtime, no ~/.hermes)")
352
+ ap.add_argument("--enforce", action="store_true", help="SIGTERM the worker and block the card")
353
+ ap.add_argument("--json", action="store_true")
354
+ ap.add_argument("--selftest", action="store_true", help="prove every budget rule on fixtures")
355
+ args = ap.parse_args(argv)
356
+
357
+ if args.selftest:
358
+ rc = selftest()
359
+ print(SELFTEST_MARKER if rc == 0 else STOP_MARKER)
360
+ return rc
361
+
362
+ cfg = load_config(args.config)
363
+ if args.snapshot:
364
+ try:
365
+ runs = snapshot_runs(args.snapshot)
366
+ except SnapshotError as exc:
367
+ print(f"snapshot error: {exc}", file=sys.stderr)
368
+ print(STOP_MARKER)
369
+ return 1
370
+ else:
371
+ runs = active_runs(args.db)
372
+ now = time.time()
373
+ per_task: dict[str, int] = {}
374
+ for run in runs:
375
+ per_task[run["task_id"]] = per_task.get(run["task_id"], 0) + 1
376
+ cards = [evaluate(run, cfg, args.state_db, args.db, now,
377
+ runs_used=per_task.get(run["task_id"]) if args.snapshot else None)
378
+ for run in runs]
379
+ offenders = [c for c in cards if c["offences"]]
380
+
381
+ actions: dict[str, list[str]] = {}
382
+ if args.enforce:
383
+ hermes = None if args.snapshot else find_hermes()
384
+ for card in offenders:
385
+ actions[card["task_id"]] = enforce(card, args.db, hermes)
386
+
387
+ if args.json:
388
+ print(json.dumps({"marker": STOP_MARKER if offenders else PASS_MARKER,
389
+ "source": "snapshot" if args.snapshot else "database",
390
+ "budgets": cfg["default"], "cards": cards, "actions": actions},
391
+ ensure_ascii=False, indent=2))
392
+ else:
393
+ if not cards:
394
+ print("no active runs: nothing is spending")
395
+ if args.snapshot and cards:
396
+ print(f"source: snapshot {args.snapshot}")
397
+ for card in cards:
398
+ state = "OVER" if card["offences"] else "ok"
399
+ tokens = "unknown" if card["tokens"] is None else f"{card['tokens']:,}"
400
+ commits = "unknown" if card["commits"] is None else str(card["commits"])
401
+ print(f" {card['task_id']} [{state}] {card['assignee']} · {card['runtime_minutes']} min · "
402
+ f"{tokens} tokens · {commits} commit(s) · {card['title']}")
403
+ for offence in card["offences"]:
404
+ print(f" - {offence}")
405
+ for action in actions.get(card["task_id"], []):
406
+ print(f" > {action}")
407
+ if offenders:
408
+ print(f"cards over budget: {len(offenders)} of {len(cards)} active")
409
+ print(STOP_MARKER if offenders else PASS_MARKER)
410
+ return 1 if offenders else 0
411
+
412
+
413
+ # ---------------------------------------------------------------- selftest
414
+
415
+ def _run(argv: list[str]) -> tuple[int, str]:
416
+ """Run one verdict in-process, capturing both streams: a refusal explains itself on stderr."""
417
+ out, err = io.StringIO(), io.StringIO()
418
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
419
+ code = main(argv)
420
+ return code, out.getvalue() + err.getvalue()
421
+
422
+
423
+ def selftest() -> int:
424
+ """Prove each rule on fixtures — the snapshot path exists so this can run in CI, so prove it there."""
425
+ ok = True
426
+ tmp = tempfile.mkdtemp(prefix="guard_card_selftest_")
427
+ now = int(time.time())
428
+ cfg_path = os.path.join(tmp, "card_budget.json")
429
+ with open(cfg_path, "w", encoding="utf-8") as fh:
430
+ json.dump({"default": {"max_tokens": 100_000, "max_runs": 2, "max_runtime_minutes": 30,
431
+ "no_progress_minutes": 20}}, fh)
432
+ tokens_ok = {"input": 1000, "output": 100, "cache_read": 0, "cache_write": 0, "api_calls": 3}
433
+ tokens_over = {"input": 200_000, "output": 10_000, "cache_read": 0, "cache_write": 0, "api_calls": 40}
434
+
435
+ def snapshot(name: str, runs: list[dict]) -> str:
436
+ path = os.path.join(tmp, name)
437
+ with open(path, "w", encoding="utf-8") as fh:
438
+ json.dump({"runs": runs}, fh)
439
+ return path
440
+
441
+ def check(label: str, code: int, out: str, want_code: int, want: str, forbid: str | None = None) -> None:
442
+ nonlocal ok
443
+ good = code == want_code and want in out and (forbid is None or forbid not in out)
444
+ print(f"[{'ok ' if good else 'FAIL'}] {label}")
445
+ if not good:
446
+ ok = False
447
+ print(f" exit={code} (want {want_code}); marker/needle {want!r} "
448
+ f"{'found' if want in out else 'MISSING'}"
449
+ + (f"; forbidden {forbid!r} {'PRESENT' if forbid in out else 'absent'}" if forbid else ""))
450
+ print(" output: " + out.strip().replace("\n", " | ")[:400])
451
+
452
+ base = {"task_id": "t_1", "run_id": 1, "assignee": "dev", "status": "running",
453
+ "started_at": now - 60, "worker_pid": None, "workspace": None}
454
+
455
+ inside = snapshot("inside.json", [dict(base, session_tokens=tokens_ok)])
456
+ code, out = _run(["--snapshot", inside, "--config", cfg_path])
457
+ check("inside budget -> PASS", code, out, 0, PASS_MARKER)
458
+
459
+ over = snapshot("over.json", [dict(base, session_tokens=tokens_over)])
460
+ code, out = _run(["--snapshot", over, "--config", cfg_path])
461
+ check("tokens over cap -> STOP naming the card and the rule", code, out, 1, STOP_MARKER)
462
+ check("tokens over cap names the token rule", code, out, 1, "cap 100,000")
463
+
464
+ missing = {k: v for k, v in base.items()}
465
+ no_tokens = snapshot("no_tokens.json", [dict(missing)])
466
+ code, out = _run(["--snapshot", no_tokens, "--config", cfg_path])
467
+ check("missing session_tokens -> STOP, prints unknown", code, out, 1, "unknown tokens", "0 tokens")
468
+
469
+ thirty = now - 40 * 60
470
+ clock = snapshot("clock.json", [dict(base, started_at=thirty, session_tokens=tokens_ok)])
471
+ code, out = _run(["--snapshot", clock, "--config", cfg_path])
472
+ check("wall clock over cap -> STOP", code, out, 1, "min of wall clock (cap 30)")
473
+
474
+ many = snapshot("many.json", [dict(base, run_id=i, session_tokens=tokens_ok) for i in range(1, 4)])
475
+ code, out = _run(["--snapshot", many, "--config", cfg_path])
476
+ check("max_runs over cap -> STOP", code, out, 1, "runs on one card (cap 2)")
477
+
478
+ # A real repository with no commit after the run started: progress is measured, not assumed.
479
+ repo = os.path.join(tmp, "repo")
480
+ os.makedirs(repo)
481
+ # The commit has to be dated before the run starts, otherwise it counts as progress made by the run.
482
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(now - 3600))
483
+ env = {**os.environ, "GIT_AUTHOR_NAME": "selftest", "GIT_AUTHOR_EMAIL": "selftest@example.org",
484
+ "GIT_COMMITTER_NAME": "selftest", "GIT_COMMITTER_EMAIL": "selftest@example.org",
485
+ "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
486
+ git = ["git", "-C", repo]
487
+ made = True
488
+ for cmd in ([*git, "init", "-q"], [*git, "commit", "-q", "--allow-empty", "-m", "before"]):
489
+ proc = subprocess.run(cmd, capture_output=True, text=True, env=env)
490
+ made = made and proc.returncode == 0
491
+ if made:
492
+ stale = snapshot("stale.json", [dict(base, started_at=now - 25 * 60, workspace=repo,
493
+ session_tokens=tokens_ok)])
494
+ code, out = _run(["--snapshot", stale, "--config", cfg_path])
495
+ check("no commit past the progress clock -> STOP", code, out, 1, "no commit in")
496
+ else:
497
+ print("[skip] no-commit rule: `git` unavailable, the rule cannot be exercised here")
498
+ ok = False
499
+
500
+ # The enforce path must not pretend to own a board it cannot see.
501
+ code, out = _run(["--snapshot", over, "--config", cfg_path, "--enforce"])
502
+ check("--enforce on a snapshot touches no queue", code, out, 1, "queue itself was not touched")
503
+
504
+ unreadable = os.path.join(tmp, "broken.json")
505
+ with open(unreadable, "w", encoding="utf-8") as fh:
506
+ fh.write("{not json")
507
+ code, out = _run(["--snapshot", unreadable, "--config", cfg_path])
508
+ check("unreadable snapshot -> STOP naming the file", code, out, 1, "snapshot error")
509
+
510
+ empty_db = os.path.join(tmp, "missing.db")
511
+ code, out = _run(["--db", empty_db, "--state-db", empty_db, "--config", cfg_path])
512
+ check("no database present -> PASS (nothing is spending)", code, out, 0, PASS_MARKER)
513
+
514
+ print(f"selftest: {'all rules fired' if ok else 'a rule did not fire'}")
515
+ return 0 if ok else 1
516
+
517
+
518
+ if __name__ == "__main__":
519
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """Change detector for the loop gate (the "report only on meaningful change" piece of stage 3).
3
+
4
+ Runs ``loopgate/gates/check_loop.py`` and reduces the run to a small deterministic state fingerprint:
5
+ verdict marker, per-dimension statuses, the failure list, the loop metrics and the round. Compares it
6
+ with the previous fingerprint stored in ``artifacts/loop_watch_last.json``:
7
+
8
+ * first ever run -> prints one baseline line and stores the fingerprint;
9
+ * unchanged -> prints **nothing** and exits 0 (a cron `monitor` script than skips the agent run
10
+ entirely, so a quiet project costs no tokens);
11
+ * changed -> prints a compact before -> after diff and stores the new fingerprint.
12
+
13
+ The output contains no timestamps or paths that vary between runs, so identical states always produce
14
+ identical output (required for the cron monitor contract).
15
+
16
+ Usage:
17
+ python3 -m loopgate.gates.loop_watch # print only when the state changed
18
+ python3 -m loopgate.gates.loop_watch --force # always print the current fingerprint
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from loopgate.paths import project_root, gate_file
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+
31
+ ROOT = project_root()
32
+ REPORT = os.path.join(ROOT, "artifacts", "loop_watch_report.json")
33
+ LAST = os.path.join(ROOT, "artifacts", "loop_watch_last.json")
34
+
35
+
36
+ def fingerprint() -> dict:
37
+ """Run the gate and reduce it to a deterministic state fingerprint."""
38
+ subprocess.run(
39
+ [sys.executable, "-m", "loopgate.gates.check_loop", "--report", REPORT],
40
+ cwd=ROOT,
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ report = json.load(open(REPORT, encoding="utf-8"))
45
+ state = json.load(open(os.path.join(ROOT, "artifacts", "loop_state.json"), encoding="utf-8"))
46
+ dims = {d["id"]: d["status"] for d in report.get("dimensions", [])}
47
+ return {
48
+ "marker": report.get("marker"),
49
+ "acceptance": "strict-complete"
50
+ if report.get("marker") == "LOOP_ENGINEERING_PASS_0001" and not report.get("failures")
51
+ else ("stage-1" if report.get("marker") == "LOOP_ENGINEERING_PASS_0001" else "not-accepted"),
52
+ "dims": dims,
53
+ "failures": report.get("failures") or [],
54
+ "round": state.get("round"),
55
+ "readiness": state.get("readiness"),
56
+ "evidence": state.get("evidence"),
57
+ "risk": state.get("risk"),
58
+ "blockers": len(state.get("blockers") or []),
59
+ }
60
+
61
+
62
+ def render_fp(fp: dict) -> str:
63
+ dims = " ".join(f"{k}={v}" for k, v in sorted(fp["dims"].items()))
64
+ return (
65
+ f"{fp['marker']} ({fp['acceptance']}) round={fp['round']} "
66
+ f"readiness={fp['readiness']} evidence={fp['evidence']} risk={fp['risk']} "
67
+ f"blockers={fp['blockers']}\n {dims}"
68
+ )
69
+
70
+
71
+ def diff(prev: dict, cur: dict) -> list[str]:
72
+ lines: list[str] = []
73
+ for key in ("marker", "acceptance", "round", "readiness", "evidence", "risk", "blockers"):
74
+ if prev.get(key) != cur.get(key):
75
+ lines.append(f" {key}: {prev.get(key)} -> {cur.get(key)}")
76
+ for dim in sorted(set(prev.get("dims", {})) | set(cur.get("dims", {}))):
77
+ if prev.get("dims", {}).get(dim) != cur.get("dims", {}).get(dim):
78
+ lines.append(f" {dim}: {prev.get('dims', {}).get(dim)} -> {cur.get('dims', {}).get(dim)}")
79
+ if prev.get("failures") != cur.get("failures"):
80
+ lines.append(f" failures: {prev.get('failures')} -> {cur.get('failures')}")
81
+ return lines
82
+
83
+
84
+ def main() -> int:
85
+ ap = argparse.ArgumentParser(description="Loop gate change detector (deterministic output).")
86
+ ap.add_argument("--force", action="store_true", help="print the current fingerprint even if unchanged")
87
+ args = ap.parse_args()
88
+
89
+ cur = fingerprint()
90
+ prev = json.load(open(LAST, encoding="utf-8")) if os.path.exists(LAST) else None
91
+
92
+ if args.force:
93
+ print("loop gate state:")
94
+ print(render_fp(cur))
95
+ json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
96
+ return 0
97
+
98
+ if prev is None:
99
+ print("loop gate baseline established:")
100
+ print(render_fp(cur))
101
+ json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
102
+ return 0
103
+
104
+ if prev == cur:
105
+ json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
106
+ return 0
107
+
108
+ print("loop gate state changed:")
109
+ for line in diff(prev, cur):
110
+ print(line)
111
+ print("now:")
112
+ print(render_fp(cur))
113
+ json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())