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/calibrate.py ADDED
@@ -0,0 +1,98 @@
1
+ """Calibrate: an agent's pass rate on a task, before Cliff or Swap spend tails on it (epic Calibrate).
2
+
3
+ Each side (an agent with its arguments, e.g. `claude --model haiku`) gets N fresh starts of
4
+ the task in rooms, scored by the check. The report gives each rate with its interval, so a
5
+ task can be chosen where one side mostly fails and the other mostly passes.
6
+
7
+ A side may have its own prompt, so two skills invoked differently compare in one call. Without
8
+ a check every run that ends counts as finished, and the workspaces are kept for scoring
9
+ elsewhere.
10
+ """
11
+ import re
12
+ import shutil
13
+ import statistics
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from . import capsule as caps
20
+ from . import usage as usages
21
+ from .cliff import wilson
22
+ from .journal import Journal
23
+
24
+
25
+ @dataclass
26
+ class Side:
27
+ key: str
28
+ agent: object
29
+ real: object
30
+ args: list[str]
31
+ prompt: str | None = None # the side's own prompt; None: the shared one
32
+ results: list[bool] = field(default_factory=list)
33
+ scores: list[int] = field(default_factory=list) # judge scores, where the check is a judge
34
+ times: list[float] = field(default_factory=list) # seconds per run
35
+
36
+ def label(self) -> str:
37
+ return " ".join([self.agent.name, *self.args])
38
+
39
+
40
+ def _minutes(seconds: float) -> str:
41
+ return f"{int(seconds // 60)}:{int(seconds % 60):02d}"
42
+
43
+
44
+ def calibrate(sides: list[Side], project: Path, prompt: str | None, check: str | None, out: Path, *, runs: int = 10,
45
+ jobs: int = 3, env: dict[str, str] | None = None, journal: Journal | None = None,
46
+ keep: bool = False) -> str:
47
+ journal = journal or Journal(None)
48
+ keep = keep or check is None # without a check the workspaces are what is scored
49
+ journal("start", kind="calibrate", task=prompt, check=check, project=str(project), tails=runs,
50
+ sides={s.key: {"agent": s.agent.name, "args": s.args, "dir": ".", "prompt": s.prompt,
51
+ "billing": usages.billing(s.agent, s.real)} for s in sides})
52
+ work = out / "project"
53
+ shutil.copytree(project, work, symlinks=True)
54
+ caps.archive(work, out / "start.tar")
55
+ shutil.rmtree(work)
56
+ start = caps.Capsule(0, None, out / "start.tar", 0, 0, "start")
57
+ journal("phase", phase="runs")
58
+
59
+ def run(job: tuple[Side, int]) -> tuple[Side, caps.Tail]:
60
+ side, i = job
61
+ name = f"{side.key}-{i}"
62
+ journal("tail", id=name, status="running", side=side.key, agent=side.key, agent_name=side.agent.name,
63
+ kind="fresh", dir=f"calibrate/{name}")
64
+ began = time.monotonic()
65
+ tail = caps.run_tail(side.agent, side.real, out, start, side.prompt or prompt, side.args, check, i, env,
66
+ tail_dir=out / "calibrate" / name, keep=keep)
67
+ journal("tail", id=name, status="passed" if tail.passed else "failed", detail=tail.detail,
68
+ usage=tail.usage, check_usage=tail.check_usage)
69
+ return side, tail, time.monotonic() - began
70
+
71
+ with ThreadPoolExecutor(max_workers=jobs) as pool:
72
+ for side, tail, seconds in pool.map(run, [(s, i) for i in range(runs) for s in sides]):
73
+ side.results.append(tail.passed)
74
+ side.times.append(seconds)
75
+ if score := re.search(r"score (\d+)", tail.detail):
76
+ side.scores.append(int(score.group(1)))
77
+
78
+ width = max(len(s.label()) for s in sides) + 2
79
+ lines = [f"tare calibrate · {project}"]
80
+ if prompt and not all(s.prompt for s in sides):
81
+ lines.append(f" task {prompt}")
82
+ lines += [f" task {s.key:<4} {s.prompt}" for s in sides if s.prompt]
83
+ lines += [f" check {check or 'none. A run counts as finished when its agent exits 0. Its workspace is kept for scoring elsewhere.'}",
84
+ "", f" side {'agent':<{width}}" + ("pass rate 95% interval" if check else "finished time (median, range)")]
85
+ for s in sides:
86
+ x, n = sum(s.results), len(s.results)
87
+ if check is None:
88
+ lines.append(f" {s.key:<4} {s.label():<{width}}{x:>4}/{n:<4} {_minutes(statistics.median(s.times))}"
89
+ f" ({_minutes(min(s.times))}-{_minutes(max(s.times))})")
90
+ continue
91
+ lo, hi = wilson(x, n)
92
+ lines.append(f" {s.key:<4} {s.label():<{width}}{x:>2}/{n:<2} {x / n:.2f} {lo:.2f}-{hi:.2f}"
93
+ + (f" scores {' '.join(map(str, sorted(s.scores)))}" if s.scores else ""))
94
+ text = "\n".join(lines + usages.report(out)) + "\n"
95
+ (out / "report.txt").write_text(text)
96
+ journal("report", text=text)
97
+ journal("phase", phase="done")
98
+ return text
tare/capsule.py ADDED
@@ -0,0 +1,246 @@
1
+ """Capsules: a run in the room, resumable at every step (epics Cliff and Swap).
2
+
3
+ During the original run a PostToolUse hook archives /work after every tool call into a
4
+ run directory outside the room. A step is one model tool call, or several issued together;
5
+ its capsule is the archive taken after its last tool plus the session up to its results.
6
+ Capsule 0 is the project before the run. A capsule is continued either natively (the
7
+ agent's own session, cut after the step; Claude Code) or by handoff (a fresh session
8
+ started with the trail rendered up to the step; every agent).
9
+ """
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import tarfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from . import trail as trails
20
+ from . import usage as usages
21
+ from .room import ROOM_HOME, bwrap, room_home
22
+
23
+ RUN_MOUNT = "/tare-run"
24
+ HOOK_COMMAND = f"/bin/sh {RUN_MOUNT}/hook.sh"
25
+ # Archives /work after every tool call, numbered, named by the call's tool_use_id.
26
+ # The hook input is JSON on stdin; its top-level tool_use_id is the first one in it.
27
+ HOOK = """#!/bin/sh
28
+ id=$(grep -o '"tool_use_id": *"[^"]*"' | head -1 | sed 's/.*"\\([^"]*\\)"$/\\1/')
29
+ n=$(ls /tare-run/capsules | wc -l)
30
+ tar -C /work -cf "/tare-run/capsules/$(printf %04d "$n")-$id.tar" .
31
+ """
32
+ CONTINUE = "Continue."
33
+
34
+
35
+ @dataclass
36
+ class Capsule:
37
+ step: int
38
+ tool_use_id: str | None # None for step 0
39
+ archive: Path # tar of /work; identical workspaces share one archive
40
+ cut: int # native session lines kept (0 for step 0, or when there is no native resume)
41
+ trail_cut: int # trail events up to this step
42
+ tool: str # what the step did, for reports
43
+
44
+
45
+ @dataclass
46
+ class Tail:
47
+ step: int
48
+ passed: bool
49
+ detail: str
50
+ usage: dict | None = None # the agent's tokens and cost
51
+ check_usage: dict | None = None # what the check spent, for example a judge agent
52
+
53
+
54
+ def _contents(path: Path) -> dict[str, str]:
55
+ """Path -> content digest of every file in a capsule archive (not its timestamps)."""
56
+ with tarfile.open(path) as tar:
57
+ return {m.name.removeprefix("./"): hashlib.sha256(tar.extractfile(m).read()).hexdigest()
58
+ for m in tar.getmembers() if m.isfile()}
59
+
60
+
61
+ def _digest(path: Path) -> str:
62
+ return hashlib.sha256(json.dumps(sorted(_contents(path).items())).encode()).hexdigest()
63
+
64
+
65
+ def archive(directory: Path, target: Path):
66
+ with tarfile.open(target, "w") as tar:
67
+ tar.add(directory, arcname=".")
68
+
69
+
70
+ def unpack(source: Path, directory: Path):
71
+ with tarfile.open(source) as tar:
72
+ tar.extractall(directory, filter="tar")
73
+
74
+
75
+ def _config(agent, home: Path) -> Path:
76
+ return home / Path(agent.room_config).relative_to(ROOM_HOME)
77
+
78
+
79
+ def record(agent, real, project: Path, out: Path, prompt: str, args: list[str],
80
+ env: dict[str, str] | None = None) -> int:
81
+ """The original run, in a room, on a copy of the project, with a capsule per tool call.
82
+ Returns the agent's exit code."""
83
+ store = out / "store"
84
+ (store / "capsules").mkdir(parents=True)
85
+ (store / "hook.sh").write_text(HOOK)
86
+ work = out / "original" / "work"
87
+ shutil.copytree(project, work, symlinks=True)
88
+ archive(work, store / "capsules" / "0000-start.tar")
89
+ (out / "original" / "task.txt").write_text(prompt)
90
+ with room_home(agent, real) as home:
91
+ agent.prepare_hook(_config(agent, home), HOOK_COMMAND)
92
+ command = [agent.name, *agent.room_flags, *agent.run_args(prompt, args, hook=HOOK_COMMAND)]
93
+ code = _stream(bwrap(agent, real, home, work, command, env, ["--bind", str(store), RUN_MOUNT]),
94
+ out / "original", None)
95
+ session = agent.session_file(home)
96
+ if session:
97
+ shutil.copyfile(session, out / "original" / "session.jsonl")
98
+ return code
99
+
100
+
101
+ def _stream(argv: list[str], log_dir: Path, timeout: float | None) -> int:
102
+ """Run a room with the agent's stdout and stderr written to log_dir as they come (the dashboard reads them)."""
103
+ with (log_dir / "stdout.jsonl").open("w") as out, (log_dir / "stderr.log").open("w") as err:
104
+ proc = subprocess.Popen(argv, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
105
+ try:
106
+ return proc.wait(timeout=timeout)
107
+ except subprocess.TimeoutExpired:
108
+ proc.kill()
109
+ proc.wait()
110
+ raise
111
+
112
+
113
+ def session_lines(out: Path) -> list[str]:
114
+ path = out / "original" / "session.jsonl"
115
+ return path.read_text().splitlines() if path.exists() else []
116
+
117
+
118
+ def capsules(out: Path, agent) -> list[Capsule]:
119
+ """The resumable steps of the original run, in order."""
120
+ archives = sorted((out / "store" / "capsules").glob("*.tar"))
121
+ by_id = {path.stem.split("-", 1)[1]: path for path in archives[1:]}
122
+ result = [Capsule(0, None, archives[0], 0, 0, "start")]
123
+ seen = {_digest(archives[0]): archives[0]}
124
+ previous = archives[0]
125
+ for step in agent.trail(session_lines(out)).steps:
126
+ taken = [by_id[i] for i in step.ids if i in by_id]
127
+ # the last snapshot of the step; a step whose tools the hook never saw left the workspace as it was
128
+ path = max(taken) if taken else previous
129
+ shared = seen.setdefault(_digest(path), path)
130
+ if shared != path:
131
+ path.unlink() # same workspace as an earlier capsule: keep one archive
132
+ result.append(Capsule(len(result), step.ids[-1], shared, step.native_cut or 0, step.trail_cut, step.tool))
133
+ previous = shared
134
+ return result
135
+
136
+
137
+ def session_id(out: Path) -> str:
138
+ for line in session_lines(out):
139
+ entry = json.loads(line)
140
+ # Claude Code: sessionId on every entry; Codex: the session_meta payload; Pi: the session header
141
+ sid = (entry.get("sessionId") or (entry.get("payload") or {}).get("id")
142
+ or (entry.get("id") if entry.get("type") == "session" else None))
143
+ if sid:
144
+ return sid
145
+ raise ValueError("no session id in the session file")
146
+
147
+
148
+ def _run_in(agent, real, home: Path, work: Path, command: list[str], env, timeout: float, log_dir: Path) -> str:
149
+ try:
150
+ return f"agent exit {_stream(bwrap(agent, real, home, work, command, env), log_dir, timeout)}"
151
+ except subprocess.TimeoutExpired:
152
+ return f"agent stopped after {int(timeout)} s"
153
+
154
+
155
+ def read_usage(agent, log_dir: Path) -> dict | None:
156
+ """The tokens and cost of the run whose stream is in log_dir (None for an agent that cannot tell)."""
157
+ stream = log_dir / "stdout.jsonl"
158
+ if not hasattr(agent, "usage"):
159
+ return None
160
+ return agent.usage(stream.read_text(errors="replace") if stream.exists() else "").to_dict()
161
+
162
+
163
+ def read_check_usage(path: Path) -> dict | None:
164
+ if not path.exists():
165
+ return None
166
+ found = usages.total(json.loads(line) for line in path.read_text().splitlines() if line.strip())
167
+ return found.to_dict() if found else None
168
+
169
+
170
+ def _finish(tail_dir: Path, work: Path, step: int, ran: str, check: str, extra: dict | None = None,
171
+ keep: bool = False, usage: dict | None = None) -> Tail:
172
+ # without a check (calibrate only) a run counts when its agent ended normally
173
+ log = tail_dir / "check-usage.jsonl"
174
+ passed, detail = run_check(check, work, usage_log=log) if check else (ran == "agent exit 0", "no check")
175
+ check_usage = read_check_usage(log)
176
+ (tail_dir / "result.json").write_text(json.dumps({"step": step, "passed": passed, "agent": ran, "check": detail,
177
+ "usage": usage, "check_usage": check_usage, **(extra or {})}))
178
+ if not keep:
179
+ shutil.rmtree(work, ignore_errors=True)
180
+ return Tail(step, passed, f"{ran}; {detail}", usage, check_usage)
181
+
182
+
183
+ def run_tail(agent, real, out: Path, capsule: Capsule, prompt: str, args: list[str], check: str,
184
+ index: int, env: dict[str, str] | None = None, timeout: float = 1800, tail_dir: Path | None = None,
185
+ keep: bool = False) -> Tail:
186
+ """Continue a capsule natively in a fresh room (the agent's own session, cut after the step),
187
+ run it to the end, score the workspace with the check. Step 0 is a fresh start."""
188
+ tail_dir = tail_dir or out / "tails" / f"{capsule.step:04d}-{index}"
189
+ work = tail_dir / "work"
190
+ work.mkdir(parents=True)
191
+ unpack(capsule.archive, work)
192
+ with room_home(agent, real) as home:
193
+ if capsule.step == 0:
194
+ command = [agent.name, *agent.room_flags, *agent.run_args(prompt, args)]
195
+ else:
196
+ sid = session_id(out)
197
+ agent.place_session(home, sid, session_lines(out)[:capsule.cut])
198
+ command = [agent.name, *agent.room_flags, *agent.resume_args(sid, CONTINUE, args)]
199
+ ran = _run_in(agent, real, home, work, command, env, timeout, tail_dir)
200
+ return _finish(tail_dir, work, capsule.step, ran, check, keep=keep, usage=read_usage(agent, tail_dir))
201
+
202
+
203
+ def check_original(agent, out: Path, check: str) -> tuple[bool, str, dict]:
204
+ """The check on the original run's workspace, and the usage of that run and of its check."""
205
+ log = out / "original" / "check-usage.jsonl"
206
+ passed, detail = run_check(check, out / "original" / "work", usage_log=log)
207
+ return passed, detail, {"usage": read_usage(agent, out / "original"), "check_usage": read_check_usage(log)}
208
+
209
+
210
+ def run_handoff(agent, real, tail_dir: Path, capsule: Capsule, events: list[trails.Event], task: str,
211
+ args: list[str], check: str, env: dict[str, str] | None = None, workspace_only: bool = False,
212
+ timeout: float = 1800) -> Tail:
213
+ """Continue a capsule by handoff: a fresh session of any agent, in the capsule's workspace,
214
+ started with the trail rendered up to the step."""
215
+ work = tail_dir / "work"
216
+ work.mkdir(parents=True)
217
+ unpack(capsule.archive, work)
218
+ handoff = trails.render(task, events[:capsule.trail_cut], workspace_only)
219
+ (tail_dir / "handoff.txt").write_text(handoff)
220
+ with room_home(agent, real) as home:
221
+ ran = _run_in(agent, real, home, work, [agent.name, *agent.room_flags, *agent.run_args(handoff, args)],
222
+ env, timeout, tail_dir)
223
+ return _finish(tail_dir, work, capsule.step, ran, check, {"agent_name": agent.name},
224
+ usage=read_usage(agent, tail_dir))
225
+
226
+
227
+ # a check that runs an agent itself (tare judge) appends that agent's usage to this file
228
+ USAGE_LOG = "TARE_USAGE_LOG"
229
+
230
+
231
+ def run_check(check: str, work: Path, timeout: float = 600, usage_log: Path | None = None) -> tuple[bool, str]:
232
+ """The user's check, outside the room: exit code 0 passes."""
233
+ env = {**os.environ, USAGE_LOG: str(usage_log)} if usage_log else None
234
+ try:
235
+ proc = subprocess.run(check, shell=True, cwd=work, capture_output=True, text=True, timeout=timeout,
236
+ stdin=subprocess.DEVNULL, env=env)
237
+ except subprocess.TimeoutExpired:
238
+ return False, f"check timed out after {int(timeout)} s"
239
+ last = next((line.strip() for line in reversed(proc.stdout.splitlines()) if line.strip()), "")
240
+ return proc.returncode == 0, f"check exit {proc.returncode}" + (f": {last[:120]}" if last else "")
241
+
242
+
243
+ def changed_files(before: Path, after: Path) -> list[str]:
244
+ """Paths whose content differs between two capsule archives."""
245
+ a, b = _contents(before), _contents(after)
246
+ return sorted(name for name in a.keys() | b.keys() if a.get(name) != b.get(name))
tare/cli.py ADDED
@@ -0,0 +1,312 @@
1
+ """tare: zero the scale before you weigh.
2
+
3
+ tare probe {claude,codex,pi,agy} [--project DIR]
4
+ tare {claude,codex,pi,agy} [--project DIR] [--allow-dirty] [--yolo] [-- AGENT_ARGS...]
5
+ tare cliff {claude,codex,pi,agy} PROMPT --check CMD [--tails N] [--budget N] [--jobs N] [-- AGENT_ARGS...]
6
+ tare swap PROMPT --check CMD [--a claude] [--b codex] [--a-args ARGS] [--b-args ARGS] [--cuts 0,0.5,1]
7
+ tare calibrate [PROMPT] [--check CMD] --side "claude --model haiku" --side "codex" [--side-prompt b=TEXT] [--runs N]
8
+ tare judge [DIR] --rubric FILE --threshold N [--judge "claude --model sonnet"] a check: exit 0 at or above N
9
+ tare judge-noise DIR... --rubric FILE --times K [--threshold N] how far the judge's scores vary
10
+ tare watch DIR... [--port N] the dashboard of a run, live or finished (several runs on one page)
11
+ tare rerun DIR [--out DIR] repeat a run from its recipe
12
+ tare prices [update] the price tables for costs that an agent does not report
13
+ """
14
+ import argparse
15
+ import json
16
+ import shlex
17
+ import threading
18
+ import signal
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from . import dashboard, recipe
25
+ from . import usage as usages
26
+ from .agents import AGENTS
27
+ from . import calibrate as calibration
28
+ from .cliff import cliff
29
+ from .journal import Journal
30
+ from .swap import Side, swap
31
+ from .probe import probe, render
32
+ from .room import TareError, bwrap, room_home
33
+
34
+
35
+ def _exit_on(signum, _frame):
36
+ # SystemExit unwinds through room_home, so the login copy is removed
37
+ raise SystemExit(128 + signum)
38
+
39
+
40
+ def _run_attached(argv: list[str]) -> int:
41
+ """Run the room in the foreground. Ctrl-C belongs to the agent (it interrupts a
42
+ turn there), so tare ignores it instead of dying and removing the room under it."""
43
+ previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
44
+ try:
45
+ return subprocess.call(argv)
46
+ finally:
47
+ signal.signal(signal.SIGINT, previous)
48
+
49
+
50
+ def _serve(out: Path | list[Path], port: int) -> str:
51
+ try:
52
+ return dashboard.serve(out, port)[1]
53
+ except OSError:
54
+ return dashboard.serve(out, 0)[1] # the usual port is taken
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ argv = list(sys.argv[1:] if argv is None else argv)
59
+ original_argv = list(argv)
60
+ if argv[:1] == ["watch"]:
61
+ return _watch(argv[1:])
62
+ if argv[:1] == ["rerun"]:
63
+ return _rerun(argv[1:])
64
+ if argv[:1] == ["prices"]:
65
+ return _prices(argv[1:])
66
+ if argv[:1] in (["judge"], ["judge-noise"]):
67
+ return _judge(argv[0], argv[1:])
68
+ passthrough: list[str] = []
69
+ if "--" in argv:
70
+ at = argv.index("--")
71
+ argv, passthrough = argv[:at], argv[at + 1:]
72
+
73
+ ap = argparse.ArgumentParser(prog="tare", description="Start a coding agent in an isolated room without your "
74
+ "personal setup, and prove the room clean before the run.")
75
+ ap.add_argument("--version", action="version", version=f"tare {recipe.tare_version()}")
76
+ sub = ap.add_subparsers(dest="command", required=True)
77
+ p = sub.add_parser("probe", help="probe the room: tare: 0.00, the leaks and their sources, or not proven (blind)")
78
+ p.add_argument("agent", choices=sorted(AGENTS))
79
+ p.add_argument("--project", type=Path, default=Path.cwd())
80
+ for name in sorted(AGENTS):
81
+ c = sub.add_parser(name, help=f"probe, then start {name} in the room (args after --)")
82
+ c.add_argument("--project", type=Path, default=Path.cwd())
83
+ c.add_argument("--allow-dirty", action="store_true", help="start even if the reading is not tare: 0.00")
84
+ c.add_argument("--yolo", action="store_true", help="skip the agent's permission prompts (for Codex also its own sandbox). The room stays.")
85
+ k = sub.add_parser("cliff", help="find where a failed run became lost (args after -- go to every agent run)")
86
+ k.add_argument("agent", choices=sorted(AGENTS))
87
+ k.add_argument("prompt", help="the task, as given to the agent")
88
+ k.add_argument("--check", required=True, help="shell command run in the finished workspace (exit 0 passes)")
89
+ k.add_argument("--project", type=Path, default=Path.cwd())
90
+ k.add_argument("--tails", type=int, default=3, help="tails (continuations from a saved step) per step tried (default 3)")
91
+ k.add_argument("--budget", type=int, default=30, help="tails in total, baseline included (default 30)")
92
+ k.add_argument("--jobs", type=int, default=3, help="tails run at the same time (default 3)")
93
+ k.add_argument("--gap-below", type=float, default=0.2,
94
+ help="report a model gap (the task is too hard for the model) only when the upper end of the "
95
+ "baseline's 95%% interval is below this (default 0.2)")
96
+ k.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/cliff/<project>-<time>)")
97
+ k.add_argument("--allow-dirty", action="store_true", help="run even if the reading is not tare: 0.00")
98
+ w = sub.add_parser("swap", help="each agent continues both workspaces: was it the workspace or the agent")
99
+ w.add_argument("prompt", help="the task, as given to both agents")
100
+ w.add_argument("--check", required=True, help="shell command run in the finished workspace (exit 0 passes)")
101
+ w.add_argument("--a", default="claude", choices=sorted(AGENTS), help="agent a (default claude)")
102
+ w.add_argument("--b", default="codex", choices=sorted(AGENTS), help="agent b (default codex)")
103
+ w.add_argument("--a-args", default="", help="arguments for every run of agent a, e.g. '--model sonnet'")
104
+ w.add_argument("--b-args", default="", help="arguments for every run of agent b, e.g. '-m MODEL'")
105
+ w.add_argument("--cuts", default="0,0.5,1", help="where to cut each run, as fractions of its steps (default 0,0.5,1)")
106
+ w.add_argument("--tails", type=int, default=3, help="tails (continuations) per cut, workspace and agent (default 3)")
107
+ w.add_argument("--jobs", type=int, default=3, help="tails run at the same time (default 3)")
108
+ w.add_argument("--handoff", choices=["trail", "workspace"], default="trail",
109
+ help="what a continuing agent gets besides the workspace: a written summary of the steps so far "
110
+ "(trail, the default) or nothing (workspace)")
111
+ w.add_argument("--project", type=Path, default=Path.cwd())
112
+ w.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/swap/<project>-<time>)")
113
+ w.add_argument("--allow-dirty", action="store_true", help="run even if a reading is not tare: 0.00")
114
+ c = sub.add_parser("calibrate", help="how often each side (an agent with its arguments) passes a task")
115
+ c.add_argument("prompt", nargs="?", help="the task, as given to every side without a prompt of its own")
116
+ c.add_argument("--check", help="shell command run in the finished workspace (exit 0 passes). Without it, a run "
117
+ "counts as finished when its agent exits 0, and its workspace is kept for scoring elsewhere")
118
+ c.add_argument("--side", action="append", required=True,
119
+ help="an agent and its arguments, e.g. 'claude --model haiku' (repeat for each side)")
120
+ c.add_argument("--side-prompt", action="append", default=[], metavar="KEY=PROMPT",
121
+ help="a side's own prompt. Sides are a, b, c... in --side order, e.g. 'b=/b:ideate a todo app'")
122
+ c.add_argument("--runs", type=int, default=10, help="fresh starts per side (default 10)")
123
+ c.add_argument("--jobs", type=int, default=3, help="runs at the same time (default 3)")
124
+ c.add_argument("--project", type=Path, default=Path.cwd())
125
+ c.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/calibrate/<project>-<time>)")
126
+ c.add_argument("--allow-dirty", action="store_true", help="run even if a reading is not tare: 0.00")
127
+ c.add_argument("--keep", action="store_true", help="keep every run's finished workspace (e.g. for judge-noise)")
128
+ for parser in (k, w, c):
129
+ parser.add_argument("--port", type=int, default=8777, help="dashboard port on localhost (default 8777)")
130
+ sub.add_parser("watch", help="the live dashboard of one or more run directories (tare watch DIR...)")
131
+ sub.add_parser("judge", help="score a page by a rubric, in a room without agent or model names so the judge cannot tell who "
132
+ "built it (usable as --check)")
133
+ sub.add_parser("judge-noise", help="score the same pages repeatedly, to see how far the judge's scores vary")
134
+ sub.add_parser("rerun", help="repeat a run from its recipe (tare rerun DIR)")
135
+ sub.add_parser("prices", help="the price tables for costs that an agent does not report (tare prices update)")
136
+ args = ap.parse_args(argv)
137
+ if args.command == "swap":
138
+ names = [args.a, args.b]
139
+ elif args.command == "calibrate":
140
+ sides = [shlex.split(side) for side in args.side]
141
+ unknown = [side[0] for side in sides if side[0] not in AGENTS]
142
+ if unknown:
143
+ ap.error(f"unknown agent: {', '.join(unknown)} (known: {', '.join(sorted(AGENTS))})")
144
+ names = [side[0] for side in sides]
145
+ keys = [chr(97 + i) for i in range(len(sides))]
146
+ prompts = dict(item.split("=", 1) for item in args.side_prompt if "=" in item)
147
+ bad = [item for item in args.side_prompt if "=" not in item or item.split("=", 1)[0] not in keys]
148
+ if bad:
149
+ ap.error(f"--side-prompt wants KEY=PROMPT with KEY one of {', '.join(keys)}: {', '.join(bad)}")
150
+ if not args.prompt and len(prompts) < len(keys):
151
+ ap.error("give a prompt, or a --side-prompt for every side")
152
+ else:
153
+ names = [args.agent if args.command in ("probe", "cliff") else args.command]
154
+ project = args.project.resolve()
155
+ for signum in (signal.SIGTERM, signal.SIGHUP):
156
+ signal.signal(signum, _exit_on)
157
+
158
+ try:
159
+ reals = {name: AGENTS[name].discover() for name in dict.fromkeys(names)}
160
+ journal = Journal(None)
161
+ if args.command in ("cliff", "swap", "calibrate"):
162
+ out = args.out or (Path.home() / ".local/state/tare" / args.command
163
+ / f"{project.name}-{time.strftime('%Y%m%d-%H%M%S')}")
164
+ out.mkdir(parents=True, exist_ok=False)
165
+ journal = Journal(out)
166
+ if args.command == "cliff":
167
+ agents = {"a": (AGENTS[args.agent], reals[args.agent], passthrough)}
168
+ params = {"tails": args.tails, "budget": args.budget, "jobs": args.jobs, "gap_below": args.gap_below}
169
+ elif args.command == "calibrate":
170
+ agents = {chr(97 + i): (AGENTS[side[0]], reals[side[0]], side[1:]) for i, side in enumerate(sides)}
171
+ params = {"runs": args.runs, "jobs": args.jobs, **({"prompts": prompts} if prompts else {})}
172
+ else:
173
+ agents = {"a": (AGENTS[args.a], reals[args.a], shlex.split(args.a_args)),
174
+ "b": (AGENTS[args.b], reals[args.b], shlex.split(args.b_args))}
175
+ params = {"cuts": args.cuts, "tails": args.tails, "jobs": args.jobs, "handoff": args.handoff}
176
+ recipe.write(out, original_argv, args.command, project, args.prompt, args.check, agents, params)
177
+ print(f"tare {args.command}: run directory {out}", file=sys.stderr)
178
+ print(f"tare {args.command}: live dashboard {_serve(out, args.port)}", file=sys.stderr)
179
+ for name in dict.fromkeys(names):
180
+ agent = AGENTS[name]
181
+ reading = probe(agent, reals[name], project)
182
+ print(render(reading, project), file=sys.stderr)
183
+ journal("probe", agent=name, zero=reading.zero, text=render(reading, project))
184
+ if args.command == "probe":
185
+ return 0 if reading.zero else 1
186
+ if not reading.zero:
187
+ if not args.allow_dirty:
188
+ print("tare: not starting, because the room is not proven clean. Fix what the reading names, "
189
+ "or pass --allow-dirty.", file=sys.stderr)
190
+ return 1
191
+ print(f"tare: --allow-dirty given, running {name} in a room whose reading is not tare: 0.00",
192
+ file=sys.stderr)
193
+ agent, real = AGENTS[names[0]], reals[names[0]]
194
+ if args.command == "cliff":
195
+ print(cliff(agent, real, project, args.prompt, args.check, out, tails=args.tails, budget=args.budget,
196
+ jobs=args.jobs, claude_args=passthrough, journal=journal, gap_below=args.gap_below), end="")
197
+ print(f"tare cliff: see it again with tare watch {out}", file=sys.stderr)
198
+ return 0
199
+ if args.command == "calibrate":
200
+ runs = [calibration.Side(key, agent_, real_, list(extra), prompts.get(key))
201
+ for key, (agent_, real_, extra) in agents.items()]
202
+ print(calibration.calibrate(runs, project, args.prompt, args.check, out, runs=args.runs, jobs=args.jobs,
203
+ journal=journal, keep=args.keep), end="")
204
+ print(f"tare calibrate: see it again with tare watch {out}", file=sys.stderr)
205
+ return 0
206
+ if args.command == "swap":
207
+ a = Side("a", AGENTS[args.a], reals[args.a], shlex.split(args.a_args), out / "a")
208
+ b = Side("b", AGENTS[args.b], reals[args.b], shlex.split(args.b_args), out / "b")
209
+ cuts = [float(c) for c in args.cuts.split(",")]
210
+ print(swap(a, b, project, args.prompt, args.check, out, cuts=cuts, tails=args.tails, jobs=args.jobs,
211
+ workspace_only=args.handoff == "workspace", journal=journal), end="")
212
+ print(f"tare swap: see it again with tare watch {out}", file=sys.stderr)
213
+ return 0
214
+ flags = agent.room_flags + (agent.yolo if args.yolo else [])
215
+ with room_home(agent, real) as home:
216
+ return _run_attached(bwrap(agent, real, home, project, [agent.name, *flags, *passthrough]))
217
+ except TareError as err:
218
+ print(f"tare: {err}", file=sys.stderr)
219
+ return 2
220
+
221
+
222
+ def _watch(argv: list[str]) -> int:
223
+ ap = argparse.ArgumentParser(prog="tare watch", description="the live dashboard of a run, or of several runs on one page")
224
+ ap.add_argument("dirs", type=Path, nargs="+")
225
+ ap.add_argument("--port", type=int, default=8777)
226
+ args = ap.parse_args(argv)
227
+ missing = [str(d) for d in args.dirs if not (d / "journal.jsonl").exists()]
228
+ if missing:
229
+ print(f"tare watch: not a tare run directory (no journal.jsonl): {', '.join(missing)}", file=sys.stderr)
230
+ return 2
231
+ dirs = [d.resolve() for d in args.dirs]
232
+ print(f"tare watch: {_serve(dirs[0] if len(dirs) == 1 else dirs, args.port)} (Ctrl-C to stop)", file=sys.stderr)
233
+ try:
234
+ threading.Event().wait()
235
+ except KeyboardInterrupt:
236
+ pass
237
+ return 0
238
+
239
+
240
+ def _judge(command: str, argv: list[str]) -> int:
241
+ from . import judge as judging
242
+ ap = argparse.ArgumentParser(prog=f"tare {command}")
243
+ ap.add_argument("dirs", type=Path, nargs="*" if command == "judge" else "+", default=[Path.cwd()])
244
+ ap.add_argument("--rubric", type=Path, required=True)
245
+ ap.add_argument("--threshold", type=float, required=command == "judge")
246
+ ap.add_argument("--times", type=int, default=10, help="scores per page (judge-noise, default 10)")
247
+ ap.add_argument("--page", default="index.html", help="the page to render (default index.html)")
248
+ ap.add_argument("--judge", default="claude --model sonnet", help="the judge agent and its arguments")
249
+ args = ap.parse_args(argv)
250
+ name, *extra = shlex.split(args.judge)
251
+ try:
252
+ if command == "judge":
253
+ score, reason = judging.judge(args.dirs[0].resolve(), args.rubric.resolve(), args.page, name, extra)
254
+ print(f"score {score} (threshold {args.threshold:g}): {reason}")
255
+ return 0 if score >= args.threshold else 1
256
+ text, clear = judging.noise([d.resolve() for d in args.dirs], args.rubric.resolve(), args.times,
257
+ args.threshold, args.page, name, extra)
258
+ print(text, end="")
259
+ return 0 if clear else 1
260
+ except (RuntimeError, TareError) as err:
261
+ print(f"tare {command}: {err}")
262
+ return 2
263
+
264
+
265
+ def _prices(argv: list[str]) -> int:
266
+ ap = argparse.ArgumentParser(prog="tare prices", description="the price tables for costs that an agent does not "
267
+ f"report. Your own file {usages.config_dir() / 'prices.json'} overrides single models.")
268
+ ap.add_argument("action", nargs="?", choices=["show", "update"], default="show")
269
+ ap.add_argument("--url", default=usages.LITELLM_URL, help="where to fetch the table from (default: LiteLLM)")
270
+ args = ap.parse_args(argv)
271
+ if args.action == "update":
272
+ try:
273
+ print(f"tare prices: {usages.update_prices(args.url)}")
274
+ except (OSError, ValueError) as err:
275
+ print(f"tare prices: could not fetch {args.url}: {err}", file=sys.stderr)
276
+ return 2
277
+ return 0
278
+ tables = usages.tables_summary()
279
+ print("\n".join(f"tare prices: {line}" for line in tables) if tables else
280
+ "tare prices: no price table yet. Run `tare prices update` to fetch LiteLLM's.")
281
+ return 0
282
+
283
+
284
+ def _rerun(argv: list[str]) -> int:
285
+ ap = argparse.ArgumentParser(prog="tare rerun", description="repeat a Calibrate, Cliff or Swap run from its recipe")
286
+ ap.add_argument("dir", type=Path)
287
+ ap.add_argument("--out", type=Path, help="run directory for the repeat (default: a new one)")
288
+ args = ap.parse_args(argv)
289
+ try:
290
+ r = recipe.read(args.dir)
291
+ except OSError:
292
+ print(f"tare rerun: no recipe.json in {args.dir}", file=sys.stderr)
293
+ return 2
294
+ reals = {}
295
+ for agent in r["agents"].values():
296
+ try:
297
+ reals[agent["name"]] = AGENTS[agent["name"]].discover()
298
+ except TareError:
299
+ pass
300
+ for note in recipe.drift(r, reals):
301
+ print(f"tare rerun: note: {note}", file=sys.stderr)
302
+ spent = args.dir / "usage.json"
303
+ if spent.exists():
304
+ before = usages.Usage.from_dict(json.loads(spent.read_text()).get("total"))
305
+ print(f"tare rerun: the run being repeated used {usages.describe(before)}", file=sys.stderr)
306
+ command = recipe.with_out(r["argv"], args.out) if args.out else r["argv"]
307
+ print(f"tare rerun: tare {shlex.join(command)}", file=sys.stderr)
308
+ return main(command)
309
+
310
+
311
+ if __name__ == "__main__":
312
+ sys.exit(main())