agentpace 0.9.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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
agentpace/cli.py
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from agentpace import (
|
|
15
|
+
__version__,
|
|
16
|
+
backtest,
|
|
17
|
+
claims,
|
|
18
|
+
estimate,
|
|
19
|
+
forecasts,
|
|
20
|
+
ingest,
|
|
21
|
+
paths,
|
|
22
|
+
pseudonym,
|
|
23
|
+
render,
|
|
24
|
+
report,
|
|
25
|
+
schema,
|
|
26
|
+
share,
|
|
27
|
+
site,
|
|
28
|
+
turns,
|
|
29
|
+
validate,
|
|
30
|
+
)
|
|
31
|
+
from agentpace.adapters import ADAPTERS, EXPERIMENTAL, get_adapter
|
|
32
|
+
from agentpace.cache import RunCache
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _kind_map(pairs: Sequence[str]) -> dict[str, str]:
|
|
36
|
+
allowed = schema.enum("task_kind")
|
|
37
|
+
mapping: dict[str, str] = {}
|
|
38
|
+
for pair in pairs:
|
|
39
|
+
name, separator, kind = pair.partition("=")
|
|
40
|
+
if not separator or kind not in allowed:
|
|
41
|
+
raise ValueError(f"--kind-map expects name=kind, with kind one of {allowed}")
|
|
42
|
+
mapping[name] = kind
|
|
43
|
+
return mapping
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ingest_into_store(
|
|
47
|
+
args: argparse.Namespace, path: Path, wait_s: float
|
|
48
|
+
) -> tuple[ingest.IngestReport, int]:
|
|
49
|
+
adapter = get_adapter(args.source)
|
|
50
|
+
out = Path(args.out) if args.out else paths.local_runs()
|
|
51
|
+
with ingest.store_lock(out, wait_s=wait_s):
|
|
52
|
+
secret = pseudonym.load_secret(paths.home())
|
|
53
|
+
labels = ingest.load_labels(paths.labels_file())
|
|
54
|
+
# A non-default idle cap changes durations and keys, so it neither reads nor feeds the
|
|
55
|
+
# cache.
|
|
56
|
+
cache = (
|
|
57
|
+
RunCache(paths.cache_file(adapter.name), adapter.name, adapter.version, args.idle_cap)
|
|
58
|
+
if args.idle_cap == ingest.DEFAULT_IDLE_CAP_S
|
|
59
|
+
else None
|
|
60
|
+
)
|
|
61
|
+
rows, report = ingest.ingest(
|
|
62
|
+
adapter, path, secret, args.idle_cap, _kind_map(args.kind_map), labels, cache=cache
|
|
63
|
+
)
|
|
64
|
+
return report, ingest.merge_into(out, rows)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def hook_session(payload_text: str) -> Path | None:
|
|
68
|
+
"""The session folder a SubagentStop payload points at, or None.
|
|
69
|
+
|
|
70
|
+
Only `agent_transcript_path` is taken from the payload. Other fields, such as the agent's
|
|
71
|
+
last message, are content and are dropped unread. The whole session is ingested, not the one
|
|
72
|
+
file, because a run's offset comes from every sibling run in its session.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(payload_text)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
value = payload.get("agent_transcript_path") if isinstance(payload, dict) else None
|
|
79
|
+
del payload
|
|
80
|
+
if not isinstance(value, str) or not value:
|
|
81
|
+
return None
|
|
82
|
+
parts = Path(value).parts
|
|
83
|
+
if "subagents" not in parts:
|
|
84
|
+
return None
|
|
85
|
+
anchor = parts.index("subagents")
|
|
86
|
+
if anchor < 3: # needs a root, a project and a session above it
|
|
87
|
+
return None
|
|
88
|
+
folder = Path(*parts[:anchor])
|
|
89
|
+
return folder if folder.is_dir() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ingest_hook(args: argparse.Namespace) -> int:
|
|
93
|
+
"""Run from a SubagentStop hook. It must never block or fail the agent it runs after.
|
|
94
|
+
|
|
95
|
+
So it always exits 0, prints nothing, and gives up quietly when the store is busy: the next
|
|
96
|
+
ingest picks the run up, since the cache re-reads anything that changed.
|
|
97
|
+
"""
|
|
98
|
+
with suppress(Exception): # a hook that raised would show its error in the user's session
|
|
99
|
+
folder = hook_session(sys.stdin.read())
|
|
100
|
+
if folder is not None:
|
|
101
|
+
_ingest_into_store(args, folder, wait_s=5.0)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
EXPERIMENTAL_NOTE = (
|
|
106
|
+
"note: the {name} adapter is experimental: built from the tool's source, not yet verified on "
|
|
107
|
+
"real sessions. Check the rows before sharing them, and report problems at "
|
|
108
|
+
"https://github.com/mohamedzhioua/agentpace/issues"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_ingest(args: argparse.Namespace) -> int:
|
|
113
|
+
if args.hook:
|
|
114
|
+
return _ingest_hook(args)
|
|
115
|
+
adapter = get_adapter(args.source)
|
|
116
|
+
path = Path(args.path) if args.path else adapter.default_path()
|
|
117
|
+
if not path.exists():
|
|
118
|
+
raise ValueError("the transcript path does not exist")
|
|
119
|
+
try:
|
|
120
|
+
report, total = _ingest_into_store(args, path, wait_s=30.0)
|
|
121
|
+
except ingest.StoreBusy as busy:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"another ingest is writing the store. Try again, or delete {busy} if none is running"
|
|
124
|
+
) from None
|
|
125
|
+
if adapter.name in EXPERIMENTAL:
|
|
126
|
+
print(EXPERIMENTAL_NOTE.format(name=adapter.name), file=sys.stderr)
|
|
127
|
+
for line in report.lines():
|
|
128
|
+
print(line, file=sys.stderr)
|
|
129
|
+
print(f"store now holds: {total} rows", file=sys.stderr)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cached_starts() -> dict[str, float]:
|
|
134
|
+
"""Each session's earliest raw run start, by batch, from the local ingest caches.
|
|
135
|
+
|
|
136
|
+
A row holds no clock time, and its batch is an HMAC, so the caches are the only record of
|
|
137
|
+
which session came first. They are read in memory at the default idle cap, the only cap that
|
|
138
|
+
feeds them; sessions ingested otherwise, or before a cache was rebuilt, are simply missing.
|
|
139
|
+
Nothing is written, and no secret is created: without one, no batch could match.
|
|
140
|
+
"""
|
|
141
|
+
secret_file = paths.home() / "secret"
|
|
142
|
+
files = {name: paths.cache_file(name) for name in ADAPTERS}
|
|
143
|
+
if not secret_file.exists() or not any(path.exists() for path in files.values()):
|
|
144
|
+
return {}
|
|
145
|
+
secret = pseudonym.load_secret(paths.home())
|
|
146
|
+
starts: dict[str, float] = {}
|
|
147
|
+
for adapter in ADAPTERS.values():
|
|
148
|
+
cache = RunCache(
|
|
149
|
+
files[adapter.name], adapter.name, adapter.version, ingest.DEFAULT_IDLE_CAP_S
|
|
150
|
+
)
|
|
151
|
+
for run in cache.runs():
|
|
152
|
+
batch = pseudonym.batch(secret, run.session)
|
|
153
|
+
starts[batch] = min(run.start, starts.get(batch, run.start))
|
|
154
|
+
return starts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
NO_CHRONOLOGY = (
|
|
158
|
+
"{count} sessions have no local chronology (no cache entry, or ingested with another idle "
|
|
159
|
+
"cap); they are listed by week only. Run agentpace ingest to refresh the cache."
|
|
160
|
+
)
|
|
161
|
+
CANNOT_TELL_LAST = (
|
|
162
|
+
"cannot tell which run is last: {count} sessions have no local chronology. "
|
|
163
|
+
"Run agentpace ingest, or give a reference from agentpace runs"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_runs(args: argparse.Namespace) -> int:
|
|
168
|
+
rows = _local_rows()
|
|
169
|
+
if not rows:
|
|
170
|
+
raise ValueError("no local rows yet; run `agentpace ingest` first")
|
|
171
|
+
starts = _cached_starts()
|
|
172
|
+
|
|
173
|
+
def order(row: schema.Row) -> tuple[str, int, float, str, int]:
|
|
174
|
+
# Within a week, sessions without a cached start come first, so --last N keeps the
|
|
175
|
+
# sessions known to be recent.
|
|
176
|
+
start = starts.get(row["batch"])
|
|
177
|
+
known = 0 if start is None else 1
|
|
178
|
+
return (row["week"], known, start or 0.0, row["batch"], row["offset_s"])
|
|
179
|
+
|
|
180
|
+
listed = sorted(rows, key=order)[-args.last :]
|
|
181
|
+
for row in listed:
|
|
182
|
+
print(
|
|
183
|
+
f"{ingest.run_ref(row)} {row['week']} {row['model']:<20} {row['task_kind']:<10}"
|
|
184
|
+
f"{row['risk']:<7}{row['size']:<3} {row['duration_s']:>6}s {row['outcome']}"
|
|
185
|
+
)
|
|
186
|
+
unknown = {row["batch"] for row in listed if row["batch"] not in starts}
|
|
187
|
+
if unknown:
|
|
188
|
+
print(NO_CHRONOLOGY.format(count=len(unknown)), file=sys.stderr)
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _last_ref(rows: Sequence[schema.Row], starts: dict[str, float]) -> str:
|
|
193
|
+
"""The last run of the session with the latest cached start, or a refusal.
|
|
194
|
+
|
|
195
|
+
It refuses rather than guesses when a session without chronology could be the later one:
|
|
196
|
+
one with a row in the chosen session's latest week or after it.
|
|
197
|
+
"""
|
|
198
|
+
known = {row["batch"] for row in rows if row["batch"] in starts}
|
|
199
|
+
if not known:
|
|
200
|
+
unknown = {row["batch"] for row in rows}
|
|
201
|
+
raise ValueError(CANNOT_TELL_LAST.format(count=len(unknown)))
|
|
202
|
+
chosen = max(known, key=lambda batch: (starts[batch], batch))
|
|
203
|
+
members = [row for row in rows if row["batch"] == chosen]
|
|
204
|
+
latest_week = max(row["week"] for row in members)
|
|
205
|
+
rivals = {
|
|
206
|
+
row["batch"] for row in rows if row["batch"] not in starts and row["week"] >= latest_week
|
|
207
|
+
}
|
|
208
|
+
if rivals:
|
|
209
|
+
raise ValueError(CANNOT_TELL_LAST.format(count=len(rivals)))
|
|
210
|
+
return ingest.run_ref(max(members, key=lambda row: row["offset_s"]))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _label_store(store: Path, args: argparse.Namespace) -> tuple[str, schema.Row]:
|
|
214
|
+
"""Label one run in the labels file and the store. The caller holds the store lock."""
|
|
215
|
+
rows = schema.read_rows(store) if store.exists() else []
|
|
216
|
+
ref = _last_ref(rows, _cached_starts()) if args.ref == "last" and rows else args.ref
|
|
217
|
+
matches = [row for row in rows if ingest.run_ref(row) == ref]
|
|
218
|
+
if not matches:
|
|
219
|
+
raise ValueError("no local run has that reference; see `agentpace runs`")
|
|
220
|
+
labels = ingest.load_labels(paths.labels_file())
|
|
221
|
+
label = labels.setdefault(ref, {})
|
|
222
|
+
if args.kind:
|
|
223
|
+
label["kind"] = args.kind
|
|
224
|
+
if args.risk:
|
|
225
|
+
label["risk"] = args.risk
|
|
226
|
+
paths.labels_file().write_text(json.dumps(labels, indent=2) + "\n", encoding="utf-8")
|
|
227
|
+
for row in matches:
|
|
228
|
+
ingest.apply_labels(row, labels)
|
|
229
|
+
schema.write_rows(store, rows)
|
|
230
|
+
return ref, matches[0]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_label(args: argparse.Namespace) -> int:
|
|
234
|
+
if args.kind is None and args.risk is None:
|
|
235
|
+
raise ValueError("give --kind, --risk or both")
|
|
236
|
+
store = paths.local_runs()
|
|
237
|
+
# A SubagentStop hook can ingest at any moment; without the lock, one side's write is lost.
|
|
238
|
+
try:
|
|
239
|
+
with ingest.store_lock(store, wait_s=5.0):
|
|
240
|
+
ref, labeled = _label_store(store, args)
|
|
241
|
+
except ingest.StoreBusy:
|
|
242
|
+
raise ValueError("another ingest is writing the store; try again") from None
|
|
243
|
+
print(f"labeled {ref}: {labeled['task_kind']}, {labeled['risk']} risk")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _dataset(sources: Sequence[str]) -> list[schema.Row]:
|
|
248
|
+
"""Explicit sources, or else the bundled seed rows plus your own ingested rows."""
|
|
249
|
+
if sources:
|
|
250
|
+
chosen = [Path(source) for source in sources]
|
|
251
|
+
for path in chosen:
|
|
252
|
+
if not path.exists():
|
|
253
|
+
raise ValueError("a --data path does not exist")
|
|
254
|
+
else:
|
|
255
|
+
chosen = [path for path in (paths.seed_dir(), paths.local_runs()) if path.exists()]
|
|
256
|
+
rows = schema.read_dataset(chosen)
|
|
257
|
+
if not rows:
|
|
258
|
+
raise ValueError("no runs found; run `agentpace ingest` or pass --data")
|
|
259
|
+
return rows
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _plan_text(args: argparse.Namespace) -> str:
|
|
263
|
+
if args.example and args.plan is not None:
|
|
264
|
+
raise ValueError("give a plan or --example, not both")
|
|
265
|
+
if args.example:
|
|
266
|
+
return paths.example_plan().read_text(encoding="utf-8")
|
|
267
|
+
if args.plan is None:
|
|
268
|
+
raise ValueError("give a plan file, - for stdin, or --example")
|
|
269
|
+
if args.plan == "-":
|
|
270
|
+
return sys.stdin.read()
|
|
271
|
+
plan = Path(args.plan)
|
|
272
|
+
if not plan.is_file():
|
|
273
|
+
raise ValueError("no plan file at that path")
|
|
274
|
+
return plan.read_text(encoding="utf-8")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _experimental_rows_note(rows: Sequence[schema.Row]) -> str | None:
|
|
278
|
+
"""Rows from unverified adapters in the estimate's data. They stay in; the user is told."""
|
|
279
|
+
harnesses = [row["harness"] for row in rows if row["harness"] in EXPERIMENTAL]
|
|
280
|
+
if not harnesses:
|
|
281
|
+
return None
|
|
282
|
+
names = ", ".join(sorted(set(harnesses)))
|
|
283
|
+
return (
|
|
284
|
+
f"note: {len(harnesses)} of {len(rows)} rows come from experimental adapters ({names}); "
|
|
285
|
+
"they take part in this estimate because they are in your local store. "
|
|
286
|
+
"Pass --data to choose the rows."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_estimate(args: argparse.Namespace) -> int:
|
|
291
|
+
text = _plan_text(args)
|
|
292
|
+
try:
|
|
293
|
+
document = json.loads(text)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
raise ValueError("the plan is not valid JSON") from None
|
|
296
|
+
tasks, cap = estimate.tasks_from_json(document)
|
|
297
|
+
rows = _dataset(args.data)
|
|
298
|
+
# Only the default pool takes in rows unasked; rows passed with --data were chosen.
|
|
299
|
+
experimental = None if args.data else _experimental_rows_note(rows)
|
|
300
|
+
if experimental:
|
|
301
|
+
print(experimental, file=sys.stderr)
|
|
302
|
+
model = estimate.fit(rows, args.method, args.use_size)
|
|
303
|
+
if args.turns and not Path(args.turns).exists():
|
|
304
|
+
raise ValueError("the turns file does not exist")
|
|
305
|
+
human = _human_part(Path(args.turns) if args.turns else paths.turns_file())
|
|
306
|
+
plan = estimate.estimate(model, tasks, cap, args.draws, args.seed, human)
|
|
307
|
+
print(render.plan_json(plan) if args.json else render.plan_text(plan, args.quoted))
|
|
308
|
+
if args.record:
|
|
309
|
+
record = forecasts.forecast_record(
|
|
310
|
+
issued_at=forecasts.utc_now(),
|
|
311
|
+
version=__version__,
|
|
312
|
+
plan_document=document,
|
|
313
|
+
result=render.plan_dict(plan),
|
|
314
|
+
quoted=args.quoted,
|
|
315
|
+
)
|
|
316
|
+
forecasts.append(paths.forecasts_file(), record)
|
|
317
|
+
note = f"recorded forecast {record['id']} in {paths.forecasts_file()}"
|
|
318
|
+
if args.quoted is not None:
|
|
319
|
+
note += (
|
|
320
|
+
"; quote stored as calendar time, as quoted"
|
|
321
|
+
if "quoted_s" in record
|
|
322
|
+
else "; quote not parseable, stored as text"
|
|
323
|
+
)
|
|
324
|
+
print(note, file=sys.stderr)
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _first_cached_start(batch: str) -> float | None:
|
|
329
|
+
"""The earliest raw start of a session's runs, from the local ingest caches."""
|
|
330
|
+
return _cached_starts().get(batch)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _session_of(ref: str) -> str:
|
|
334
|
+
"""The session a reference names: a bare batch, or the batch of a `<batch>:<offset_s>` ref."""
|
|
335
|
+
return ref.split(":", 1)[0]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _score_one(args: argparse.Namespace, ledger: forecasts.Ledger) -> int:
|
|
339
|
+
forecast = ledger.forecasts.get(args.id)
|
|
340
|
+
if forecast is None:
|
|
341
|
+
raise ValueError("no recorded forecast has that id; see ~/.agentpace/forecasts.jsonl")
|
|
342
|
+
if (args.session is None) == (args.actual is None):
|
|
343
|
+
raise ValueError("give --session <ref> or --actual <duration>, not both")
|
|
344
|
+
if args.actual is not None:
|
|
345
|
+
actual = forecasts.parse_duration(args.actual)
|
|
346
|
+
if actual is None:
|
|
347
|
+
raise ValueError('--actual needs "<number> <unit>", such as "2h" or "3 days"')
|
|
348
|
+
record = forecasts.score_record(
|
|
349
|
+
forecast,
|
|
350
|
+
actual_s=actual,
|
|
351
|
+
source="actual",
|
|
352
|
+
verified=False,
|
|
353
|
+
scored_at=forecasts.utc_now(),
|
|
354
|
+
)
|
|
355
|
+
source = "attested by you"
|
|
356
|
+
else:
|
|
357
|
+
batch = _session_of(args.session)
|
|
358
|
+
rows = [r for r in _local_rows() if r["batch"] == batch and r["outcome"] == "completed"]
|
|
359
|
+
try:
|
|
360
|
+
actual = forecasts.check_session(forecast, rows, _first_cached_start(batch))
|
|
361
|
+
except forecasts.ScoreRefused as refused:
|
|
362
|
+
raise ValueError(str(refused)) from None
|
|
363
|
+
record = forecasts.score_record(
|
|
364
|
+
forecast,
|
|
365
|
+
actual_s=actual,
|
|
366
|
+
source="session",
|
|
367
|
+
verified=True,
|
|
368
|
+
scored_at=forecasts.utc_now(),
|
|
369
|
+
session=batch,
|
|
370
|
+
)
|
|
371
|
+
source = (
|
|
372
|
+
"that session's span on its own clock: agent runs plus the gaps between them, "
|
|
373
|
+
"each gap up to the idle cap"
|
|
374
|
+
)
|
|
375
|
+
forecasts.append(paths.forecasts_file(), record)
|
|
376
|
+
print(render.score_text(forecast, record, source))
|
|
377
|
+
human = _human_part(paths.turns_file())
|
|
378
|
+
if human.status == "measured":
|
|
379
|
+
print(render.human_text(human))
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _local_rows() -> list[schema.Row]:
|
|
384
|
+
store = paths.local_runs()
|
|
385
|
+
return schema.read_rows(store) if store.exists() else []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_score(args: argparse.Namespace) -> int:
|
|
389
|
+
ledger = forecasts.read(paths.forecasts_file())
|
|
390
|
+
if ledger.skipped:
|
|
391
|
+
print(f"skipped {ledger.skipped} damaged lines in the ledger", file=sys.stderr)
|
|
392
|
+
if args.id:
|
|
393
|
+
return _score_one(args, ledger)
|
|
394
|
+
if args.session or args.actual:
|
|
395
|
+
raise ValueError("give the forecast id to score")
|
|
396
|
+
print(render.ledger_summary_text(forecasts.summarize(ledger)))
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _human_part(store: Path) -> estimate.HumanPart:
|
|
401
|
+
"""The user's own waits per human turn, if `agentpace turns` has measured them."""
|
|
402
|
+
summary = turns.summarize(turns.read_turns(store))
|
|
403
|
+
if summary is None:
|
|
404
|
+
return estimate.HumanPart()
|
|
405
|
+
return estimate.HumanPart(
|
|
406
|
+
status="measured",
|
|
407
|
+
source="your history, local",
|
|
408
|
+
per_turn_p50=summary.p50,
|
|
409
|
+
per_turn_p80=summary.p80,
|
|
410
|
+
per_turn_p90=summary.p90,
|
|
411
|
+
gaps=summary.gaps,
|
|
412
|
+
sessions=summary.sessions,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_turns(args: argparse.Namespace) -> int:
|
|
417
|
+
path = Path(args.path) if args.path else get_adapter("claude-code").default_path()
|
|
418
|
+
if not path.exists():
|
|
419
|
+
raise ValueError("the transcript path does not exist")
|
|
420
|
+
store = Path(args.out) if args.out else paths.turns_file()
|
|
421
|
+
report_lines = turns.update(path, store, pseudonym.load_secret(paths.home())).lines()
|
|
422
|
+
for line in report_lines:
|
|
423
|
+
print(line, file=sys.stderr)
|
|
424
|
+
summary = turns.summarize(turns.read_turns(store))
|
|
425
|
+
if summary is None:
|
|
426
|
+
print("No waits on you were found, so the human part stays not measured.")
|
|
427
|
+
return 0
|
|
428
|
+
print(
|
|
429
|
+
f"Waiting on you, from your history (local): {summary.gaps} gaps between agent turns "
|
|
430
|
+
f"in {summary.sessions} sessions."
|
|
431
|
+
)
|
|
432
|
+
print(
|
|
433
|
+
f"Per human turn: P50 {render.duration(summary.p50)}, P80 {render.duration(summary.p80)}, "
|
|
434
|
+
f"P90 {render.duration(summary.p90)}. {summary.over_hour:.0%} of gaps were over 1 h."
|
|
435
|
+
)
|
|
436
|
+
print(
|
|
437
|
+
"Median share of a session's elapsed time spent waiting on you: "
|
|
438
|
+
f"{summary.waiting_share:.0%}."
|
|
439
|
+
)
|
|
440
|
+
print(
|
|
441
|
+
"A gap runs from the agent's last record to the next prompt you typed, so it holds "
|
|
442
|
+
"review, testing, decisions and everything else between turns, breaks included. "
|
|
443
|
+
"It is elapsed time, not effort. It is kept on this machine and never shared."
|
|
444
|
+
)
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_backtest(args: argparse.Namespace) -> int:
|
|
449
|
+
rows = _dataset(args.data or [str(paths.seed_dir())])
|
|
450
|
+
results = backtest.run(rows, args.seed)
|
|
451
|
+
out = Path(args.out)
|
|
452
|
+
if args.check:
|
|
453
|
+
if report.is_current(results, out):
|
|
454
|
+
print("backtest numbers match the published report")
|
|
455
|
+
return 0
|
|
456
|
+
print("backtest numbers differ from the published report", file=sys.stderr)
|
|
457
|
+
return 1
|
|
458
|
+
report.write(results, rows, out)
|
|
459
|
+
selection = results["selection"]
|
|
460
|
+
print(f"wrote {out / 'backtest.md'}; winner: {selection['winner']}")
|
|
461
|
+
if selection["winner"] != estimate.DEFAULT_METHOD:
|
|
462
|
+
print(
|
|
463
|
+
f"note: the default method stays {estimate.DEFAULT_METHOD}. Selection is frozen at "
|
|
464
|
+
"2026-W45 and a pin test fails CI on a flip; a flip needs re-registration in "
|
|
465
|
+
"CHANGELOG (DESIGN section 6).",
|
|
466
|
+
file=sys.stderr,
|
|
467
|
+
)
|
|
468
|
+
return 0
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def cmd_share(args: argparse.Namespace) -> int:
|
|
472
|
+
store = paths.local_runs()
|
|
473
|
+
rows = schema.read_rows(store) if store.exists() else []
|
|
474
|
+
if not rows:
|
|
475
|
+
raise ValueError("no local rows yet; run `agentpace ingest` first")
|
|
476
|
+
out = Path(args.out_dir)
|
|
477
|
+
published = schema.read_dataset([out]) if out.exists() else []
|
|
478
|
+
sessions = [_session_of(ref) for ref in args.drop_session]
|
|
479
|
+
chosen, skipped, dropped = share.select(
|
|
480
|
+
rows, args.since, args.drop_project, published, sessions
|
|
481
|
+
)
|
|
482
|
+
print(share.NOTICE, file=sys.stderr)
|
|
483
|
+
if dropped:
|
|
484
|
+
print(
|
|
485
|
+
f"{sum(dropped.values())} rows from {len(dropped)} dropped sessions left out.",
|
|
486
|
+
file=sys.stderr,
|
|
487
|
+
)
|
|
488
|
+
if skipped:
|
|
489
|
+
print(f"{skipped} rows are already published and left out.", file=sys.stderr)
|
|
490
|
+
if not chosen:
|
|
491
|
+
print("nothing new to share.", file=sys.stderr)
|
|
492
|
+
return 0
|
|
493
|
+
note = share.experimental_note(chosen)
|
|
494
|
+
if note:
|
|
495
|
+
print(note, file=sys.stderr)
|
|
496
|
+
path = out / share.file_name(chosen)
|
|
497
|
+
if not args.write:
|
|
498
|
+
held = share.held_rows(path, chosen)
|
|
499
|
+
for row in chosen:
|
|
500
|
+
print(schema.canonical(row))
|
|
501
|
+
target = (
|
|
502
|
+
f"--write would create {path}."
|
|
503
|
+
if held is None
|
|
504
|
+
else f"--write would add them to {path}, which holds {held} rows."
|
|
505
|
+
)
|
|
506
|
+
print(
|
|
507
|
+
f"dry run: {len(chosen)} rows shown above, nothing written. {target}",
|
|
508
|
+
file=sys.stderr,
|
|
509
|
+
)
|
|
510
|
+
return 0
|
|
511
|
+
added, existing = share.write_contribution(path, chosen)
|
|
512
|
+
if existing:
|
|
513
|
+
print(
|
|
514
|
+
f"added {added} rows to {path}, which already held {existing}. "
|
|
515
|
+
"Nothing has been sent. To submit:",
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
print(f"wrote {added} rows to {path}. Nothing has been sent. To submit:", file=sys.stderr)
|
|
520
|
+
for command in share.git_commands(path):
|
|
521
|
+
print(f" {command}", file=sys.stderr)
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
526
|
+
sources = [Path(path) for path in args.paths] or [paths.seed_dir()]
|
|
527
|
+
if not all(source.exists() for source in sources):
|
|
528
|
+
raise ValueError("a path does not exist")
|
|
529
|
+
findings = validate.validate(sources)
|
|
530
|
+
for finding in findings:
|
|
531
|
+
print(finding)
|
|
532
|
+
if findings:
|
|
533
|
+
print(f"{len(findings)} problems", file=sys.stderr)
|
|
534
|
+
return 1
|
|
535
|
+
print("all rows valid")
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def cmd_site(args: argparse.Namespace) -> int:
|
|
540
|
+
rows = _dataset(args.data or [str(paths.seed_dir())])
|
|
541
|
+
page = Path(args.out)
|
|
542
|
+
if not page.exists():
|
|
543
|
+
raise ValueError("the page does not exist")
|
|
544
|
+
stored = page.parent / "backtest.json"
|
|
545
|
+
if not stored.exists():
|
|
546
|
+
raise ValueError("run `agentpace backtest` first: the page quotes its results")
|
|
547
|
+
results = json.loads(stored.read_text(encoding="utf-8"))
|
|
548
|
+
current = page.read_text(encoding="utf-8")
|
|
549
|
+
updated = site.build(current, site.payload(rows, results))
|
|
550
|
+
if args.check:
|
|
551
|
+
if updated == current:
|
|
552
|
+
print("the page's data is current")
|
|
553
|
+
return 0
|
|
554
|
+
print("the page's data is out of date; run `agentpace site`", file=sys.stderr)
|
|
555
|
+
return 1
|
|
556
|
+
page.write_text(updated, encoding="utf-8", newline="\n")
|
|
557
|
+
print(f"updated {page}")
|
|
558
|
+
return 0
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def cmd_claims(args: argparse.Namespace) -> int:
|
|
562
|
+
stored = Path(args.report)
|
|
563
|
+
if not stored.exists():
|
|
564
|
+
raise ValueError("run `agentpace backtest` first: claims come from its report")
|
|
565
|
+
rows = _dataset(args.data or [str(paths.seed_dir())])
|
|
566
|
+
report_data = json.loads(stored.read_text(encoding="utf-8"))
|
|
567
|
+
known = claims.values(
|
|
568
|
+
rows, report_data, Path("examples/plan.json"), Path("examples/quote-plan.json")
|
|
569
|
+
)
|
|
570
|
+
stale = []
|
|
571
|
+
for name in args.files:
|
|
572
|
+
path = Path(name)
|
|
573
|
+
current = path.read_text(encoding="utf-8")
|
|
574
|
+
updated = claims.apply(current, known)
|
|
575
|
+
if updated == current:
|
|
576
|
+
continue
|
|
577
|
+
stale.append(name)
|
|
578
|
+
if not args.check:
|
|
579
|
+
path.write_text(updated, encoding="utf-8", newline="\n")
|
|
580
|
+
if args.check and stale:
|
|
581
|
+
print(f"out of date: {', '.join(stale)}. Run `agentpace claims`.", file=sys.stderr)
|
|
582
|
+
return 1
|
|
583
|
+
print("claims match the data" if args.check else f"updated {len(stale)} files")
|
|
584
|
+
return 0
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _reader_went_away(error: OSError) -> bool:
|
|
588
|
+
return isinstance(error, BrokenPipeError) or (os.name == "nt" and error.errno == errno.EINVAL)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
592
|
+
parser = argparse.ArgumentParser(
|
|
593
|
+
prog="agentpace",
|
|
594
|
+
description="Real run times for coding agents, as P50/P80/P90 ranges.",
|
|
595
|
+
)
|
|
596
|
+
parser.add_argument("--version", action="version", version=f"agentpace {__version__}")
|
|
597
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
598
|
+
|
|
599
|
+
ingest_cmd = commands.add_parser("ingest", help="read local agent history into rows")
|
|
600
|
+
ingest_cmd.add_argument("path", nargs="?", help="history folder (default: the harness's own)")
|
|
601
|
+
ingest_cmd.add_argument("--source", default="claude-code", choices=sorted(ADAPTERS))
|
|
602
|
+
ingest_cmd.add_argument("--out", help="rows file (default: ~/.agentpace/runs.jsonl)")
|
|
603
|
+
ingest_cmd.add_argument(
|
|
604
|
+
"--idle-cap",
|
|
605
|
+
type=int,
|
|
606
|
+
default=ingest.DEFAULT_IDLE_CAP_S,
|
|
607
|
+
help="seconds an idle gap may contribute (default: %(default)s)",
|
|
608
|
+
)
|
|
609
|
+
ingest_cmd.add_argument(
|
|
610
|
+
"--kind-map",
|
|
611
|
+
action="append",
|
|
612
|
+
default=[],
|
|
613
|
+
metavar="NAME=KIND",
|
|
614
|
+
help="map one of your agent types to a task kind (repeatable)",
|
|
615
|
+
)
|
|
616
|
+
ingest_cmd.add_argument(
|
|
617
|
+
"--hook",
|
|
618
|
+
action="store_true",
|
|
619
|
+
help="read a Claude Code SubagentStop payload on stdin and ingest that session quietly",
|
|
620
|
+
)
|
|
621
|
+
ingest_cmd.set_defaults(handler=cmd_ingest)
|
|
622
|
+
|
|
623
|
+
runs_cmd = commands.add_parser("runs", help="list recent local runs and their references")
|
|
624
|
+
runs_cmd.add_argument("--last", type=int, default=20, help="how many (default: %(default)s)")
|
|
625
|
+
runs_cmd.set_defaults(handler=cmd_runs)
|
|
626
|
+
|
|
627
|
+
label_cmd = commands.add_parser("label", help="correct the kind or risk of one run")
|
|
628
|
+
label_cmd.add_argument("ref", help="a reference from `agentpace runs`, or 'last'")
|
|
629
|
+
label_cmd.add_argument("--kind", choices=schema.enum("task_kind"))
|
|
630
|
+
label_cmd.add_argument("--risk", choices=schema.enum("risk"))
|
|
631
|
+
label_cmd.set_defaults(handler=cmd_label)
|
|
632
|
+
|
|
633
|
+
estimate_cmd = commands.add_parser("estimate", help="P50/P80/P90 for a plan of tasks")
|
|
634
|
+
estimate_cmd.add_argument("plan", nargs="?", help="tasks JSON file, or - for stdin")
|
|
635
|
+
estimate_cmd.add_argument(
|
|
636
|
+
"--example",
|
|
637
|
+
action="store_true",
|
|
638
|
+
help="estimate the example plan that ships with agentpace (examples/plan.json)",
|
|
639
|
+
)
|
|
640
|
+
estimate_cmd.add_argument("--data", action="append", default=[], help="rows file or folder")
|
|
641
|
+
estimate_cmd.add_argument(
|
|
642
|
+
"--method", default=estimate.DEFAULT_METHOD, choices=[m.name for m in estimate.METHODS]
|
|
643
|
+
)
|
|
644
|
+
estimate_cmd.add_argument(
|
|
645
|
+
"--use-size",
|
|
646
|
+
action="store_true",
|
|
647
|
+
help="use size: an oracle track, because stored size is measured after a run",
|
|
648
|
+
)
|
|
649
|
+
estimate_cmd.add_argument("--draws", type=int, default=estimate.DEFAULT_DRAWS)
|
|
650
|
+
estimate_cmd.add_argument("--seed", type=int, default=estimate.DEFAULT_SEED)
|
|
651
|
+
estimate_cmd.add_argument("--json", action="store_true", help="machine-readable output")
|
|
652
|
+
estimate_cmd.add_argument(
|
|
653
|
+
"--turns", help="waits measured by `agentpace turns` (default: ~/.agentpace/turns.jsonl)"
|
|
654
|
+
)
|
|
655
|
+
estimate_cmd.add_argument(
|
|
656
|
+
"--record",
|
|
657
|
+
action="store_true",
|
|
658
|
+
help="keep this forecast in ~/.agentpace/forecasts.jsonl to score later",
|
|
659
|
+
)
|
|
660
|
+
estimate_cmd.add_argument(
|
|
661
|
+
"--quoted",
|
|
662
|
+
metavar="TEXT",
|
|
663
|
+
help='a timeline someone already gave, such as "3 weeks": shown beside the range as '
|
|
664
|
+
"quoted, and stored with the forecast when --record is given",
|
|
665
|
+
)
|
|
666
|
+
estimate_cmd.set_defaults(handler=cmd_estimate)
|
|
667
|
+
|
|
668
|
+
score_cmd = commands.add_parser(
|
|
669
|
+
"score", help="score a recorded forecast, or show how your forecasts held up"
|
|
670
|
+
)
|
|
671
|
+
score_cmd.add_argument("id", nargs="?", help="forecast id from `estimate --record`")
|
|
672
|
+
score_cmd.add_argument(
|
|
673
|
+
"--session",
|
|
674
|
+
metavar="REF",
|
|
675
|
+
help="a session, or any run ref from `runs` (its session is used)",
|
|
676
|
+
)
|
|
677
|
+
score_cmd.add_argument("--actual", metavar="DURATION", help='your own figure, such as "2h"')
|
|
678
|
+
score_cmd.set_defaults(handler=cmd_score)
|
|
679
|
+
|
|
680
|
+
turns_cmd = commands.add_parser(
|
|
681
|
+
"turns", help="measure how long you wait between agent turns, kept local"
|
|
682
|
+
)
|
|
683
|
+
turns_cmd.add_argument("path", nargs="?", help="Claude Code projects folder (default: its own)")
|
|
684
|
+
turns_cmd.add_argument("--out", help="turns file (default: ~/.agentpace/turns.jsonl)")
|
|
685
|
+
turns_cmd.set_defaults(handler=cmd_turns)
|
|
686
|
+
|
|
687
|
+
backtest_cmd = commands.add_parser("backtest", help="evaluate on held-out runs")
|
|
688
|
+
backtest_cmd.add_argument("--data", action="append", default=[], help="rows file or folder")
|
|
689
|
+
backtest_cmd.add_argument("--out", default="docs", help="report folder (default: %(default)s)")
|
|
690
|
+
backtest_cmd.add_argument("--seed", type=int, default=backtest.SEED)
|
|
691
|
+
backtest_cmd.add_argument(
|
|
692
|
+
"--check", action="store_true", help="fail if results differ from the published report"
|
|
693
|
+
)
|
|
694
|
+
backtest_cmd.set_defaults(handler=cmd_backtest)
|
|
695
|
+
|
|
696
|
+
share_cmd = commands.add_parser(
|
|
697
|
+
"share", help="preview your rows, or write a file to contribute"
|
|
698
|
+
)
|
|
699
|
+
mode = share_cmd.add_mutually_exclusive_group()
|
|
700
|
+
mode.add_argument("--dry-run", action="store_true", help="print exact rows (the default)")
|
|
701
|
+
mode.add_argument("--write", action="store_true", help="create the contribution file")
|
|
702
|
+
share_cmd.add_argument("--out-dir", default="data/runs", help="default: %(default)s")
|
|
703
|
+
share_cmd.add_argument("--since", metavar="YYYY-Www", help="only this ISO week onward")
|
|
704
|
+
share_cmd.add_argument("--drop-project", action="store_true", help="omit the project field")
|
|
705
|
+
share_cmd.add_argument(
|
|
706
|
+
"--drop-session",
|
|
707
|
+
action="append",
|
|
708
|
+
default=[],
|
|
709
|
+
metavar="REF",
|
|
710
|
+
help="leave out a whole session: a session, or any run ref from `runs`. Repeatable",
|
|
711
|
+
)
|
|
712
|
+
share_cmd.set_defaults(handler=cmd_share)
|
|
713
|
+
|
|
714
|
+
validate_cmd = commands.add_parser("validate", help="check data files")
|
|
715
|
+
validate_cmd.add_argument(
|
|
716
|
+
"paths",
|
|
717
|
+
nargs="*",
|
|
718
|
+
help="files or folders (default: the seed data shipped with agentpace; "
|
|
719
|
+
"contributors pass data/runs)",
|
|
720
|
+
)
|
|
721
|
+
validate_cmd.set_defaults(handler=cmd_validate)
|
|
722
|
+
|
|
723
|
+
site_cmd = commands.add_parser("site", help="refresh the data inside the static page")
|
|
724
|
+
site_cmd.add_argument("--data", action="append", default=[], help="rows file or folder")
|
|
725
|
+
site_cmd.add_argument("--out", default="docs/index.html", help="default: %(default)s")
|
|
726
|
+
site_cmd.add_argument("--check", action="store_true", help="fail if the page is out of date")
|
|
727
|
+
site_cmd.set_defaults(handler=cmd_site)
|
|
728
|
+
|
|
729
|
+
claims_cmd = commands.add_parser("claims", help="tie numbers in documents to the data")
|
|
730
|
+
claims_cmd.add_argument(
|
|
731
|
+
"files", nargs="*", default=["README.md", "skills/agentpace/assets/portable-prompt.md"]
|
|
732
|
+
)
|
|
733
|
+
claims_cmd.add_argument("--data", action="append", default=[], help="rows file or folder")
|
|
734
|
+
claims_cmd.add_argument("--report", default="docs/backtest.json")
|
|
735
|
+
claims_cmd.add_argument("--check", action="store_true", help="fail if any number is stale")
|
|
736
|
+
claims_cmd.set_defaults(handler=cmd_claims)
|
|
737
|
+
return parser
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
741
|
+
args = build_parser().parse_args(argv)
|
|
742
|
+
try:
|
|
743
|
+
result: int = args.handler(args)
|
|
744
|
+
sys.stdout.flush()
|
|
745
|
+
except ValueError as error:
|
|
746
|
+
print(f"agentpace: {error}", file=sys.stderr)
|
|
747
|
+
return 2
|
|
748
|
+
except OSError as error:
|
|
749
|
+
if not _reader_went_away(error):
|
|
750
|
+
raise
|
|
751
|
+
# Piping into `head` is a normal way to preview rows. Stop quietly.
|
|
752
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
753
|
+
return 0
|
|
754
|
+
return result
|