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/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