tare-cli 0.2.0__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.
tare/dashboard.py ADDED
@@ -0,0 +1,216 @@
1
+ """The live dashboard: a local page that shows a Cliff or Swap run as it happens (epic Dashboard).
2
+
3
+ Everything comes from the run directory: the recipe, the journal, and the live output each
4
+ original run and tail writes. A finished run looks the same as a live one, so `tare watch`
5
+ can show any run directory. Given several, it serves an overview with one row per run, and each
6
+ run's own dashboard under run/<i>/.
7
+ """
8
+ import json
9
+ import re
10
+ import threading
11
+ import time
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ from . import journal as journals
17
+ from . import usage as usages
18
+ from .agents import AGENTS
19
+ from .cliff import wilson
20
+ from .swap import Cut
21
+
22
+
23
+ def _activity(agent_name: str | None, stdout: Path) -> str | None:
24
+ """The latest thing the agent did, from the tail of its live output."""
25
+ agent = AGENTS.get(agent_name or "")
26
+ if not agent or not stdout.exists():
27
+ return None
28
+ with stdout.open("rb") as f:
29
+ f.seek(max(0, stdout.stat().st_size - 64 * 1024))
30
+ lines = f.read().decode(errors="replace").splitlines()
31
+ for line in reversed(lines):
32
+ try:
33
+ described = agent.activity(json.loads(line))
34
+ except ValueError:
35
+ continue
36
+ if described:
37
+ return described
38
+ return None
39
+
40
+
41
+ def _capsule_count(store: Path) -> int:
42
+ return max(0, len(list((store / "capsules").glob("*.tar"))) - 1) if store.exists() else 0
43
+
44
+
45
+ def state(out: Path) -> dict:
46
+ events = journals.read(out)
47
+ recipe = out / "recipe.json"
48
+ s = {"recipe": json.loads(recipe.read_text()) if recipe.exists() else None, "kind": None, "task": None,
49
+ "check": None, "project": None, "sides": {}, "params": {}, "phase": "starting", "probes": [], "originals": {},
50
+ "tails": [], "plan": [], "report": None, "started": events[0]["t"] if events else None,
51
+ "updated": events[-1]["t"] if events else None, "now": time.time()}
52
+ tails: dict[str, dict] = {}
53
+ for e in events:
54
+ kind = e["event"]
55
+ if kind == "start":
56
+ s.update({k: e.get(k) for k in ("kind", "task", "check", "project", "sides")})
57
+ s["params"] = {k: e.get(k) for k in ("cuts", "tails", "budget")}
58
+ if s["kind"] == "calibrate":
59
+ s["originals"] = {k: {"passed": None, "detail": "", "steps": []} for k in s["sides"]} # no original runs
60
+ elif kind == "phase":
61
+ s["phase"] = e["phase"]
62
+ elif kind == "probe":
63
+ s["probes"].append({k: e.get(k) for k in ("agent", "zero", "text")})
64
+ elif kind == "original":
65
+ s["originals"][e["side"]] = {"passed": e["passed"], "detail": e["detail"], "steps": e["steps"]}
66
+ elif kind == "plan":
67
+ s["plan"] = e["cuts"]
68
+ elif kind == "tail":
69
+ tail = tails.setdefault(e["id"], {"id": e["id"], "started": e["t"]})
70
+ tail.update({k: v for k, v in e.items() if k not in ("t", "event")})
71
+ if e["status"] != "running":
72
+ tail["ended"] = e["t"]
73
+ elif kind == "report":
74
+ s["report"] = e["text"]
75
+
76
+ # originals still running: their capsules so far and what the agent does right now
77
+ for key, side in (s["sides"] or {}).items():
78
+ if key not in s["originals"]:
79
+ base = out / side.get("dir", ".")
80
+ s["originals"][key] = {"running": True, "count": _capsule_count(base / "store"),
81
+ "activity": _activity(side.get("agent"), base / "original" / "stdout.jsonl")}
82
+ for tail in tails.values():
83
+ if tail["status"] == "running":
84
+ tail["activity"] = _activity(tail.get("agent_name"), out / tail["dir"] / "stdout.jsonl")
85
+ s["tails"] = sorted(tails.values(), key=lambda t: (t["status"] != "running", -t["started"]))
86
+
87
+ if s["kind"] == "cliff":
88
+ s["steps"] = _cliff_steps(tails.values())
89
+ elif s["kind"] == "swap":
90
+ s["cells"] = _swap_cells(tails.values(), s["plan"])
91
+ elif s["kind"] == "calibrate":
92
+ s["rates"] = _rates(tails.values(), s["sides"])
93
+ s["usage"] = usages.of_run(events)
94
+ return s
95
+
96
+
97
+ def _cliff_steps(tails) -> list[dict]:
98
+ steps: dict[int, list] = {}
99
+ for t in tails:
100
+ steps.setdefault(t["step"], []).append(t)
101
+ rows = []
102
+ for step, ts in sorted(steps.items()):
103
+ done = [t["status"] == "passed" for t in ts if t["status"] != "running"]
104
+ lo, hi = wilson(sum(done), len(done))
105
+ rows.append({"step": step, "passes": sum(done), "n": len(done), "running": sum(t["status"] == "running" for t in ts),
106
+ "rate": sum(done) / len(done) if done else None, "lo": lo, "hi": hi})
107
+ return rows
108
+
109
+
110
+ def _rates(tails, sides) -> dict:
111
+ rates = {}
112
+ for key in sides:
113
+ ts = [t for t in tails if t.get("side") == key]
114
+ done = [t["status"] == "passed" for t in ts if t["status"] != "running"]
115
+ lo, hi = wilson(sum(done), len(done))
116
+ # a judge check reports "score N" in its detail line
117
+ scores = [int(m.group(1)) for t in ts if t["status"] != "running"
118
+ and (m := re.search(r"score (\d+)", t.get("detail", "")))]
119
+ rates[key] = {"passes": sum(done), "n": len(done), "running": sum(t["status"] == "running" for t in ts),
120
+ "rate": sum(done) / len(done) if done else None, "lo": lo, "hi": hi, "scores": scores,
121
+ "mean": sum(scores) / len(scores) if scores else None,
122
+ "times": [t["ended"] - t["started"] for t in ts if "ended" in t]}
123
+ return rates
124
+
125
+
126
+ def _swap_cells(tails, plan) -> list[dict]:
127
+ cuts = []
128
+ for i, p in enumerate(plan):
129
+ cut = Cut(p["fraction"], p["steps"])
130
+ running: dict[tuple[str, str], int] = {}
131
+ for t in tails:
132
+ if t.get("cut") != i:
133
+ continue
134
+ native = t["kind"] == "native"
135
+ key = ("native" if native else t["room"], t["room"] if native else t["agent"])
136
+ target = cut.native.setdefault(t["room"], []) if native else cut.cells.setdefault(key, [])
137
+ if t["status"] == "running":
138
+ running[key] = running.get(key, 0) + 1
139
+ else:
140
+ target.append(t["status"] == "passed")
141
+ complete = all(cut.cells.get((r, m)) for r in "ab" for m in "ab")
142
+ cuts.append({
143
+ "fraction": p["fraction"], "steps": p["steps"],
144
+ "cells": {f"{r}{m}": {"passes": sum(cut.cells.get((r, m), [])), "n": len(cut.cells.get((r, m), [])),
145
+ "running": running.get((r, m), 0)} for r in "ab" for m in "ab"},
146
+ "native": {r: {"passes": sum(v), "n": len(v), "running": running.get(("native", r), 0)}
147
+ for r, v in cut.native.items()},
148
+ "state": cut.state() if complete else None, "model": cut.model() if complete else None,
149
+ })
150
+ return cuts
151
+
152
+
153
+ def overview(out: Path) -> dict:
154
+ """One row of the overview: what a run is and how far it got."""
155
+ s = state(out)
156
+ verdict = next((line.strip()[len("verdict"):].strip() for line in (s["report"] or "").splitlines()
157
+ if line.strip().startswith("verdict")), None)
158
+ running = sum(t["status"] == "running" for t in s["tails"])
159
+ return {"name": out.name, "kind": s["kind"], "task": s["task"], "check": s["check"], "sides": s["sides"],
160
+ "phase": s["phase"], "running": running, "done": len(s["tails"]) - running, "rates": s.get("rates"),
161
+ "verdict": verdict}
162
+
163
+
164
+ def serve(out: Path | list[Path], port: int = 0) -> tuple[ThreadingHTTPServer, str]:
165
+ """Serve the dashboard for a run directory, or an overview of several, on 127.0.0.1;
166
+ returns the server and its URL."""
167
+ runs = [out] if isinstance(out, Path) else list(out)
168
+ page = resources.files("tare").joinpath("dashboard.html").read_bytes()
169
+ index = resources.files("tare").joinpath("overview.html").read_bytes()
170
+ html = "text/html; charset=utf-8"
171
+
172
+ def route(path: str) -> tuple[bytes, str] | str | None:
173
+ """The body and its type, a path to redirect to, or None."""
174
+ if len(runs) == 1:
175
+ run, rest = runs[0], path
176
+ else:
177
+ if path in ("/", "/index.html"):
178
+ return index, html
179
+ if path == "/runs":
180
+ return json.dumps([{"i": i, **overview(r)} for i, r in enumerate(runs)]).encode(), "application/json"
181
+ m = re.fullmatch(r"/run/(\d+)(/.*)?", path)
182
+ if not m or int(m.group(1)) >= len(runs):
183
+ return None
184
+ if not m.group(2):
185
+ return f"{path}/"
186
+ run, rest = runs[int(m.group(1))], m.group(2)
187
+ if rest == "/state":
188
+ return json.dumps(state(run)).encode(), "application/json"
189
+ if rest in ("/", "/index.html"):
190
+ return page, html
191
+ return None
192
+
193
+ class Handler(BaseHTTPRequestHandler):
194
+ def log_message(self, *args):
195
+ pass
196
+
197
+ def do_GET(self):
198
+ found = route(self.path.split("?")[0])
199
+ if found is None or isinstance(found, str):
200
+ self.send_response(404 if found is None else 301)
201
+ if found:
202
+ self.send_header("location", found)
203
+ self.send_header("content-length", "0")
204
+ self.end_headers()
205
+ return
206
+ body, kind = found
207
+ self.send_response(200)
208
+ self.send_header("content-type", kind)
209
+ self.send_header("cache-control", "no-store")
210
+ self.send_header("content-length", str(len(body)))
211
+ self.end_headers()
212
+ self.wfile.write(body)
213
+
214
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
215
+ threading.Thread(target=server.serve_forever, daemon=True).start()
216
+ return server, f"http://localhost:{server.server_address[1]}/"
tare/fake.py ADDED
@@ -0,0 +1,183 @@
1
+ """A fake model endpoint that keeps what the CLI sends.
2
+
3
+ The CLI under test is pointed at it: Claude Code with ANTHROPIC_BASE_URL (Anthropic
4
+ Messages API), Codex with `-c openai_base_url=...` (OpenAI Responses API), the Antigravity
5
+ CLI with CLOUD_CODE_URL (Google's Cloud Code `v1internal` API). Every request
6
+ body is kept in memory; headers are never stored, so the login token never leaves the
7
+ request. By default each turn is answered with the text "ok", so the CLI ends its loop at
8
+ once; a `respond` policy can script tool calls instead (Anthropic Messages only).
9
+ """
10
+ import json
11
+ import sys
12
+ import threading
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+
15
+
16
+ OK = [{"type": "text", "text": "ok"}]
17
+
18
+ # Cloud Code (Antigravity CLI): the model the fake offers, and the answers to the calls
19
+ # around a turn. The eligibility check needs a tier and a project; a model must be listed
20
+ # to be selectable. Everything else is answered with {}.
21
+ FAKE_MODEL = "tare-fake"
22
+ _GEMINI_OK = {"candidates": [{"content": {"role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP"}],
23
+ "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}}
24
+ _CLOUD_CODE = {
25
+ "loadCodeAssist": {"currentTier": {"id": "free-tier", "name": "Free"}, "cloudaicompanionProject": "tare-fake",
26
+ "allowedTiers": [{"id": "free-tier", "name": "Free", "isDefault": True}]},
27
+ "fetchAvailableModels": {
28
+ "defaultAgentModelId": FAKE_MODEL,
29
+ "agentModelSorts": [{"displayName": "Recommended", "groups": [{"modelIds": [FAKE_MODEL]}]}],
30
+ "models": {FAKE_MODEL: {"displayName": "tare fake", "recommended": True, "model": "MODEL_PLACEHOLDER_M318",
31
+ "apiProvider": "API_PROVIDER_GOOGLE_GEMINI", "modelProvider": "MODEL_PROVIDER_GOOGLE",
32
+ "maxTokens": 1048576, "maxOutputTokens": 65536}}},
33
+ "countTokens": {"totalTokens": 1},
34
+ "generateContent": {"response": _GEMINI_OK},
35
+ }
36
+
37
+
38
+ def _stop_reason(blocks: list[dict]) -> str:
39
+ return "tool_use" if any(b["type"] == "tool_use" for b in blocks) else "end_turn"
40
+
41
+
42
+ def _messages_events(model: str, blocks: list[dict]) -> list[tuple[str, dict]]:
43
+ message = {"id": "msg_tare", "type": "message", "role": "assistant", "model": model, "content": [],
44
+ "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}}
45
+ events = [("message_start", {"type": "message_start", "message": message})]
46
+ for i, block in enumerate(blocks):
47
+ if block["type"] == "text":
48
+ start, delta = {"type": "text", "text": ""}, {"type": "text_delta", "text": block["text"]}
49
+ else:
50
+ start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
51
+ delta = {"type": "input_json_delta", "partial_json": json.dumps(block["input"])}
52
+ events += [("content_block_start", {"type": "content_block_start", "index": i, "content_block": start}),
53
+ ("content_block_delta", {"type": "content_block_delta", "index": i, "delta": delta}),
54
+ ("content_block_stop", {"type": "content_block_stop", "index": i})]
55
+ return events + [
56
+ ("message_delta", {"type": "message_delta", "delta": {"stop_reason": _stop_reason(blocks), "stop_sequence": None},
57
+ "usage": {"output_tokens": 1}}),
58
+ ("message_stop", {"type": "message_stop"}),
59
+ ]
60
+
61
+
62
+ def _responses_events() -> list[tuple[str, dict]]:
63
+ item = {"type": "message", "role": "assistant", "id": "msg_tare",
64
+ "content": [{"type": "output_text", "text": "ok", "annotations": []}]}
65
+ usage = {"input_tokens": 1, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 1,
66
+ "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 2}
67
+ return [
68
+ ("response.created", {"type": "response.created", "response": {"id": "resp_tare"}}),
69
+ ("response.output_item.added", {"type": "response.output_item.added", "output_index": 0, "item": item}),
70
+ ("response.output_text.delta", {"type": "response.output_text.delta", "item_id": "msg_tare",
71
+ "output_index": 0, "content_index": 0, "delta": "ok"}),
72
+ ("response.output_item.done", {"type": "response.output_item.done", "output_index": 0, "item": item}),
73
+ ("response.completed", {"type": "response.completed", "response": {"id": "resp_tare", "usage": usage}}),
74
+ ]
75
+
76
+
77
+ class _QuietServer(ThreadingHTTPServer):
78
+ def handle_error(self, request, client_address):
79
+ # The CLIs drop connections at will (a refused websocket, the end of a run);
80
+ # that is not worth a traceback in the user's terminal.
81
+ if not isinstance(sys.exc_info()[1], ConnectionError):
82
+ super().handle_error(request, client_address)
83
+
84
+
85
+ class Fake:
86
+ """Context manager: serves on a free port of 127.0.0.1 and collects request bodies."""
87
+
88
+ def __init__(self, respond=None):
89
+ # respond(body) -> content blocks of the answer (text and tool_use), for scripted runs
90
+ self.respond = respond or (lambda body: OK)
91
+ self.requests: list[dict] = []
92
+ self.other_paths: list[str] = []
93
+ fake = self
94
+
95
+ class Handler(BaseHTTPRequestHandler):
96
+ # HTTP/1.1, so a refused websocket upgrade reads as a plain 404 and Codex
97
+ # falls back to HTTPS instead of retrying.
98
+ protocol_version = "HTTP/1.1"
99
+
100
+ def log_message(self, *args):
101
+ pass
102
+
103
+ def _json(self, status, payload):
104
+ data = json.dumps(payload).encode()
105
+ self.send_response(status)
106
+ self.send_header("content-type", "application/json")
107
+ self.send_header("content-length", str(len(data)))
108
+ self.end_headers()
109
+ self.wfile.write(data)
110
+
111
+ def _not_found(self):
112
+ fake.other_paths.append(f"{self.command} {self.path.split('?')[0]}")
113
+ self._json(404, {"type": "error", "error": {"type": "not_found_error", "message": "tare fake"}})
114
+
115
+ def _stream(self, events):
116
+ self.send_response(200)
117
+ self.send_header("content-type", "text/event-stream")
118
+ self.send_header("cache-control", "no-cache")
119
+ self.send_header("connection", "close")
120
+ self.end_headers()
121
+ for name, data in events:
122
+ self.wfile.write(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode())
123
+ self.wfile.flush()
124
+ self.close_connection = True
125
+
126
+ def do_GET(self):
127
+ self._not_found()
128
+
129
+ def _body(self) -> bytes:
130
+ if "chunked" not in (self.headers.get("transfer-encoding") or ""):
131
+ return self.rfile.read(int(self.headers.get("content-length") or 0))
132
+ parts = [] # the Antigravity CLI sends its model requests chunked
133
+ while size := int(self.rfile.readline().split(b";")[0].strip() or b"0", 16):
134
+ parts.append(self.rfile.read(size))
135
+ self.rfile.readline()
136
+ self.rfile.readline()
137
+ return b"".join(parts)
138
+
139
+ def _cloud_code(self, method: str, raw: bytes):
140
+ if method != "streamGenerateContent":
141
+ return self._json(200, _CLOUD_CODE.get(method, {}))
142
+ fake.requests.append(json.loads(raw))
143
+ data = f"data: {json.dumps({'response': _GEMINI_OK})}\r\n\r\n".encode()
144
+ self.send_response(200)
145
+ self.send_header("content-type", "text/event-stream")
146
+ self.send_header("content-length", str(len(data)))
147
+ self.end_headers()
148
+ self.wfile.write(data)
149
+
150
+ def do_POST(self):
151
+ raw = self._body()
152
+ path = self.path.split("?")[0]
153
+ if path.startswith("/v1internal:"):
154
+ return self._cloud_code(path.split(":", 1)[1], raw)
155
+ if path.endswith("/messages/count_tokens"):
156
+ return self._json(200, {"input_tokens": 1})
157
+ if not path.endswith(("/v1/messages", "/responses")):
158
+ return self._not_found()
159
+ if self.headers.get("content-encoding"):
160
+ # Codex compresses unless told not to (--disable enable_request_compression)
161
+ return self._json(415, {"error": {"message": "tare fake: send uncompressed bodies"}})
162
+ body = json.loads(raw)
163
+ fake.requests.append(body)
164
+ if path.endswith("/responses"):
165
+ return self._stream(_responses_events())
166
+ model = body.get("model", "fake")
167
+ blocks = fake.respond(body) if body.get("tools") else OK
168
+ if body.get("stream"):
169
+ return self._stream(_messages_events(model, blocks))
170
+ self._json(200, {"id": "msg_tare", "type": "message", "role": "assistant", "model": model,
171
+ "content": blocks, "stop_reason": _stop_reason(blocks),
172
+ "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}})
173
+
174
+ self._server = _QuietServer(("127.0.0.1", 0), Handler)
175
+ self.url = f"http://127.0.0.1:{self._server.server_address[1]}"
176
+
177
+ def __enter__(self):
178
+ threading.Thread(target=self._server.serve_forever, daemon=True).start()
179
+ return self
180
+
181
+ def __exit__(self, *exc):
182
+ self._server.shutdown()
183
+ self._server.server_close()
tare/journal.py ADDED
@@ -0,0 +1,35 @@
1
+ """The run journal: everything a Cliff or Swap run did, one JSON line per event.
2
+
3
+ The dashboard derives its whole state from the run directory, and the journal is its spine:
4
+ phases, probe readings, original runs, tails started and finished, search steps, the report.
5
+ """
6
+ import json
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ class Journal:
13
+ def __init__(self, out: Path | None):
14
+ self.path = out / "journal.jsonl" if out else None
15
+ self._lock = threading.Lock()
16
+
17
+ def __call__(self, event: str, **data):
18
+ if not self.path:
19
+ return
20
+ line = json.dumps({"t": round(time.time(), 3), "event": event, **data}, ensure_ascii=False)
21
+ with self._lock, self.path.open("a") as f:
22
+ f.write(line + "\n")
23
+
24
+
25
+ def read(out: Path) -> list[dict]:
26
+ path = out / "journal.jsonl"
27
+ if not path.exists():
28
+ return []
29
+ events = []
30
+ for line in path.read_text().splitlines():
31
+ try:
32
+ events.append(json.loads(line))
33
+ except ValueError:
34
+ continue # a line being written
35
+ return events
tare/judge.py ADDED
@@ -0,0 +1,108 @@
1
+ """Judge: a check for work that tests cannot settle, such as the quality of a page (epic Judge).
2
+
3
+ The finished page is rendered headless in a room of its own (no home directory, the
4
+ workspace read-only) and photographed. A judge agent then gets the screenshot, the source
5
+ and the rubric in a fresh tare room, without any agent or model names, and answers with a
6
+ score. The check passes at or above a threshold. Before a threshold is used, `noise` scores
7
+ the same pages repeatedly: a threshold inside a page's spread would measure the judge's
8
+ dice, not the agent.
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import statistics
15
+ import subprocess
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from . import room as rooms
20
+ from .agents import AGENTS, events, strings
21
+
22
+ VIEWPORT = "1280,900"
23
+ PROMPT = ("You are a strict, careful judge. Read rubric.md. Look at page.png, a screenshot of the page, and at the "
24
+ "source in the source/ directory. Score the page from 0 to 100 exactly as the rubric says. Reply with "
25
+ 'nothing but one line of JSON: {"score": <0-100>, "reason": "<one sentence>"}')
26
+
27
+
28
+ def render(work: Path, page: str, target: Path, wait_ms: int = 2000):
29
+ """Screenshot of work/page, rendered headless in a room: the workspace read-only, no home directory."""
30
+ target.parent.mkdir(parents=True, exist_ok=True)
31
+ shot = Path(tempfile.mkdtemp(prefix="tare-render-"))
32
+ try:
33
+ argv = ["bwrap", "--ro-bind", "/usr", "/usr", "--ro-bind", "/etc", "/etc", "--ro-bind", "/opt", "/opt",
34
+ "--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64",
35
+ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/home",
36
+ "--ro-bind", str(work), "/work", "--bind", str(shot), "/shot",
37
+ "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent",
38
+ "--clearenv", "--setenv", "HOME", "/tmp", "--setenv", "PATH", "/usr/bin:/bin", "--",
39
+ "google-chrome", "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
40
+ f"--window-size={VIEWPORT}", f"--virtual-time-budget={wait_ms}", "--screenshot=/shot/page.png",
41
+ f"file:///work/{page}"]
42
+ subprocess.run(argv, capture_output=True, timeout=120, stdin=subprocess.DEVNULL)
43
+ if not (shot / "page.png").exists():
44
+ raise RuntimeError(f"the page {page} did not render")
45
+ shutil.copyfile(shot / "page.png", target)
46
+ finally:
47
+ shutil.rmtree(shot, ignore_errors=True)
48
+
49
+
50
+ def _score(stdout: str) -> tuple[int, str]:
51
+ """The last {"score": ...} the judge wrote, in any of its event stream's strings or in plain text."""
52
+ texts = [s for e in events(stdout) for s in strings(e)] or [stdout]
53
+ for text in reversed(texts):
54
+ for match in reversed(list(re.finditer(r'\{[^{}]*"score"\s*:\s*\d+[^{}]*\}', text))):
55
+ try:
56
+ data = json.loads(match.group(0))
57
+ return int(data["score"]), str(data.get("reason", ""))
58
+ except (ValueError, KeyError):
59
+ continue
60
+ raise RuntimeError("the judge gave no score")
61
+
62
+
63
+ def judge(work: Path, rubric: Path, page: str = "index.html", agent_name: str = "claude",
64
+ agent_args: list[str] | None = None, timeout: float = 600) -> tuple[int, str]:
65
+ """Score the page in work/ by the rubric; the judge agent runs in a fresh, blind room."""
66
+ agent = AGENTS[agent_name]
67
+ real = agent.discover()
68
+ with tempfile.TemporaryDirectory(prefix="tare-judge-") as tmp:
69
+ bench = Path(tmp)
70
+ render(work, page, bench / "page.png")
71
+ shutil.copyfile(rubric, bench / "rubric.md")
72
+ # the source the agent wrote, without anything that names an agent or its setup
73
+ shutil.copytree(work, bench / "source", ignore=shutil.ignore_patterns(
74
+ ".git", ".claude", ".codex", ".pi", "AGENTS.md", "CLAUDE.md", "node_modules"))
75
+ with rooms.room_home(agent, real) as home:
76
+ argv = rooms.bwrap(agent, real, home, bench, [agent.name, *agent.room_flags,
77
+ *agent.run_args(PROMPT, agent_args or [])])
78
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
79
+ _log_usage(agent, proc.stdout)
80
+ return _score(proc.stdout)
81
+
82
+
83
+ def _log_usage(agent, stdout: str):
84
+ """When tare runs this judge as a check, it names a file for the judge agent's usage."""
85
+ log = os.environ.get("TARE_USAGE_LOG")
86
+ if log:
87
+ with open(log, "a") as f:
88
+ f.write(json.dumps(agent.usage(stdout).to_dict()) + "\n")
89
+
90
+
91
+ def noise(pages: list[Path], rubric: Path, times: int, threshold: float | None, page: str = "index.html",
92
+ agent_name: str = "claude", agent_args: list[str] | None = None) -> tuple[str, bool]:
93
+ """Score each page `times` times. Returns the report and whether the threshold sits outside every spread."""
94
+ lines = [f"tare judge-noise · {agent_name} {' '.join(agent_args or [])}".rstrip(), f" rubric {rubric}",
95
+ "", " page scores mean sd min-max"]
96
+ clear = True
97
+ for p in pages:
98
+ scores = [judge(p, rubric, page, agent_name, agent_args)[0] for _ in range(times)]
99
+ mean, sd = statistics.mean(scores), statistics.pstdev(scores)
100
+ inside = threshold is not None and min(scores) <= threshold <= max(scores)
101
+ clear = clear and not inside
102
+ lines.append(f" {str(p)[-32:]:<32} {' '.join(f'{x:>3}' for x in scores):<30} {mean:5.1f} {sd:4.1f} "
103
+ f"{min(scores)}-{max(scores)}" + (" <- threshold inside this page's range" if inside else ""))
104
+ if threshold is not None:
105
+ lines += ["", f" threshold {threshold}: " + ("outside every page's range of scores" if clear else
106
+ "INSIDE a page's range of scores. There, chance decides pass or "
107
+ "fail.")]
108
+ return "\n".join(lines) + "\n", clear
tare/overview.html ADDED
@@ -0,0 +1,78 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tare runs</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0e1116; --panel: #161b22; --panel-2: #1c222b; --line: #2a313c; --text: #e6edf3; --muted: #8b96a5;
10
+ --pass: #3fb950; --fail: #f85149; --run: #d29922; --accent: #79c0ff;
11
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12
+ }
13
+ @media (prefers-color-scheme: light) {
14
+ :root { --bg: #f6f8fa; --panel: #ffffff; --panel-2: #f0f3f6; --line: #d0d7de; --text: #1f2328; --muted: #59636e;
15
+ --pass: #1a7f37; --fail: #cf222e; --run: #9a6700; --accent: #0969da; }
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
19
+ main { max-width: 1180px; margin: 0 auto; padding: 20px 16px 60px; }
20
+ h1 { font: 600 22px/1 var(--mono); margin: 0 0 16px; letter-spacing: .5px; }
21
+ section.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; overflow-x: auto; }
22
+ table { width: 100%; border-collapse: collapse; }
23
+ th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
24
+ th { color: var(--muted); font-weight: 500; font-size: 12px; }
25
+ a { color: var(--accent); text-decoration: none; }
26
+ a:hover { text-decoration: underline; }
27
+ .mono { font-family: var(--mono); font-size: 12.5px; }
28
+ .muted { color: var(--muted); }
29
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; margin-right: 6px; }
30
+ .dot.pass { background: var(--pass); } .dot.fail { background: var(--fail); } .dot.run { background: var(--run); animation: pulse 1.2s infinite; }
31
+ @keyframes pulse { 50% { opacity: .35; } }
32
+ .task { max-width: 340px; overflow-wrap: anywhere; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <main>
37
+ <h1>tare runs</h1>
38
+ <section class="panel"><table>
39
+ <thead><tr><th>run</th><th>phase</th><th>task</th><th>sides</th><th>progress</th><th>result</th></tr></thead>
40
+ <tbody id="rows"><tr><td colspan="6" class="muted">loading…</td></tr></tbody>
41
+ </table></section>
42
+ </main>
43
+ <script>
44
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
45
+ const short = (t, n) => (t && t.length > n ? t.slice(0, n - 1) + "…" : t || "");
46
+
47
+ function result(r) {
48
+ if (r.verdict) return esc(r.verdict);
49
+ if (!r.rates) return `<span class="muted">–</span>`;
50
+ return Object.entries(r.rates).map(([k, x]) => {
51
+ const score = x.mean != null ? ` · mean ${x.mean.toFixed(1)}` : "";
52
+ return `<div class="mono">${esc(k)} ${x.passes}/${x.n} ${r.check ? "passed" : "finished"}${score}</div>`;
53
+ }).join("");
54
+ }
55
+
56
+ function row(r) {
57
+ const phase = r.phase === "done" ? "pass" : "run";
58
+ const sides = Object.entries(r.sides || {}).map(([k, s]) =>
59
+ `<div class="mono">${esc(k)} ${esc(s.agent)} ${esc((s.args || []).join(" "))}${s.prompt ? `<div class="muted">${esc(short(s.prompt, 60))}</div>` : ""}</div>`).join("");
60
+ return `<tr><td><a href="run/${r.i}/"><b>${esc(r.kind || "run")}</b></a><div class="muted mono">${esc(r.name)}</div></td>
61
+ <td><span class="dot ${phase}"></span>${esc(r.phase)}</td>
62
+ <td class="task">${esc(short(r.task, 140)) || `<span class="muted">a prompt per side</span>`}</td>
63
+ <td>${sides}</td><td class="mono">${r.done} ${r.kind === "calibrate" ? "runs" : "tails"} done${r.running ? ` · ${r.running} running` : ""}</td><td>${result(r)}</td></tr>`;
64
+ }
65
+
66
+ async function tick() {
67
+ try {
68
+ const runs = await (await fetch("runs", { cache: "no-store" })).json();
69
+ document.getElementById("rows").innerHTML = runs.map(row).join("");
70
+ } catch (e) {
71
+ document.getElementById("rows").innerHTML = `<tr><td colspan="6" class="muted">disconnected</td></tr>`;
72
+ }
73
+ setTimeout(tick, 2000);
74
+ }
75
+ tick();
76
+ </script>
77
+ </body>
78
+ </html>