acumen 0.0.1.dev0__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.
acumen/cli.py ADDED
@@ -0,0 +1,626 @@
1
+ """Command-line entry point — a thin shell over the importable API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ import time
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+
12
+ from acumen.bench import build_matrix, pending, run_matrix, summarize
13
+ from acumen.config import ConfigError, load_config
14
+ from acumen.draft import DraftError, draft_skill
15
+ from acumen.env import DEFAULT_CACHE_ROOT, AuthMode, EnvError, prepare_target, resolve_auth_mode
16
+ from acumen.improve import ImproveError, improve_skill
17
+ from acumen.logs import LiveLog
18
+ from acumen.paths import SPLITS
19
+ from acumen.report import ReportError, build_report
20
+ from acumen.runner import RunOutcome, StderrFilter
21
+ from acumen.scaffold import InitError, scaffold
22
+ from acumen.ship import ShipError, ship_skill
23
+ from acumen.skills import SkillError, available_versions, latest_version, load_skill
24
+ from acumen.taskgen import TaskGenError, generate_tasks
25
+ from acumen.tasks import TaskError, load_tasks
26
+
27
+
28
+ def _add_bench_args(parser: argparse.ArgumentParser) -> None:
29
+ parser.add_argument("--config", type=Path, default=Path("config.yaml"), help="path to config.yaml")
30
+ parser.add_argument("--tasks", type=Path, default=Path("tasks.yaml"), help="path to tasks.yaml")
31
+ parser.add_argument("--runs", type=Path, default=Path("runs"), help="root of the run tree")
32
+ arm = parser.add_mutually_exclusive_group()
33
+ arm.add_argument("--no-skill", action="store_true", help="run the baseline arm (the default)")
34
+ arm.add_argument("--skill", metavar="VERSION", help="run with a skill version, e.g. v1")
35
+ parser.add_argument("--split", choices=SPLITS, action="append", help="restrict to a split (repeatable)")
36
+ parser.add_argument("--task", metavar="ID", action="append", help="restrict to a task id (repeatable)")
37
+ parser.add_argument("--max-concurrency", type=int, help="override config max_concurrency")
38
+ parser.add_argument("--replicates", type=int, help="override config n_replicates")
39
+ parser.add_argument("--no-resume", action="store_true", help="re-run runs that already completed")
40
+ parser.add_argument("--refresh-target", action="store_true", help="rebuild the target checkout and venv")
41
+ parser.add_argument("--keep-sandboxes", action="store_true", help="leave run sandboxes on disk")
42
+ parser.add_argument("--cache", type=Path, default=DEFAULT_CACHE_ROOT, help="target cache root")
43
+ parser.add_argument("--skills", type=Path, default=Path("skills"), help="root of the skill tree")
44
+ parser.add_argument("--dry-run", action="store_true", help="print the matrix and exit without running agents")
45
+
46
+
47
+ def _add_log_args(parser: argparse.ArgumentParser) -> None:
48
+ """Add the shared live-log flags to a meta-agent subcommand."""
49
+ parser.add_argument("--stream", action="store_true", help="mirror the agent's conversation to the terminal live")
50
+ parser.add_argument(
51
+ "--log-dir", type=Path, default=Path("logs"), dest="log_dir", help="directory for the run log (default: logs/)"
52
+ )
53
+
54
+
55
+ def _add_feedback_arg(parser: argparse.ArgumentParser, *, extra: str = "") -> None:
56
+ """Add the optional ``--feedback`` flag to an authoring subcommand.
57
+
58
+ The text is injected into the agent's prompt as a subordinated guidance block; it never
59
+ overrides the isolation or anti-overfit rules. ``extra`` appends a per-command note to the
60
+ help text.
61
+ """
62
+ help_text = "extra guidance for the agent, injected into its prompt as subordinate guidance"
63
+ parser.add_argument("--feedback", help=(help_text + extra) or None)
64
+
65
+
66
+ def _add_auth_arg(parser: argparse.ArgumentParser) -> None:
67
+ """Add the ``--auth`` flag to a single-agent subcommand.
68
+
69
+ These commands spawn one agent, so they default to the Claude subscription ("session")
70
+ when a subscription login is present and fall back to the API key otherwise. ``bench`` has
71
+ no such flag — it always bills the API so its recorded ``cost_usd`` is real.
72
+ """
73
+ parser.add_argument(
74
+ "--auth",
75
+ choices=("auto", "session", "api"),
76
+ default="auto",
77
+ help="which credential to bill: 'session' (Claude subscription), 'api' (Anthropic API), "
78
+ "or 'auto' (default: session if you're logged in, else the API)",
79
+ )
80
+
81
+
82
+ def _print_auth(mode: AuthMode) -> None:
83
+ """Report which credential the run will bill, so the choice is never silent."""
84
+ label = "Claude subscription (session)" if mode == "session" else "API key"
85
+ print(f"auth: {label}", flush=True)
86
+
87
+
88
+ def _print_log_result(log: LiveLog) -> None:
89
+ """Print where the rendered HTML log landed, once a run has finalized."""
90
+ if log.html_rendered:
91
+ print(f"log → {log.html_path}")
92
+ else:
93
+ print("note: HTML log not rendered (claude-code-log missing?) — the jsonl log is complete", file=sys.stderr)
94
+
95
+
96
+ def _fmt_secs(seconds: float) -> str:
97
+ """Compact wall-clock duration, e.g. ``9s`` / ``2m41s`` / ``1h04m``."""
98
+ seconds = int(seconds)
99
+ if seconds < 60:
100
+ return f"{seconds}s"
101
+ if seconds < 3600:
102
+ return f"{seconds // 60}m{seconds % 60:02d}s"
103
+ return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
104
+
105
+
106
+ def _key_label(key) -> str:
107
+ return f"{key.arm}/{key.split}/{key.model}/{key.task_id}/rep_{key.rep}"
108
+
109
+
110
+ class _Progress:
111
+ """Progress reporter for a concurrent bench pass.
112
+
113
+ Prints a line when each run starts and finishes, each stamped with the wall-clock
114
+ elapsed since the pass began, the number in flight, and a running pass tally — the
115
+ context a long, interleaved pass needs to be readable as it scrolls by.
116
+ """
117
+
118
+ def __init__(self, total: int) -> None:
119
+ self.total = total
120
+ self.started = 0
121
+ self.done = 0
122
+ self.passed = 0
123
+ self.running = 0
124
+ self._t0 = time.monotonic()
125
+
126
+ @property
127
+ def elapsed(self) -> float:
128
+ """Seconds since the pass began."""
129
+ return time.monotonic() - self._t0
130
+
131
+ def _stamp(self) -> str:
132
+ return f"+{_fmt_secs(self.elapsed):>6}"
133
+
134
+ def on_start(self, item) -> None:
135
+ self.started += 1
136
+ self.running += 1
137
+ print(
138
+ f"[{self._stamp()}] ▶ start {_key_label(item.key)}"
139
+ f" (running {self.running}, {self.started}/{self.total} started)",
140
+ flush=True,
141
+ )
142
+
143
+ def on_done(self, outcome: RunOutcome) -> None:
144
+ self.done += 1
145
+ self.running -= 1
146
+ if outcome.success:
147
+ self.passed += 1
148
+ mark = "✓ pass" if outcome.success else "✗ FAIL"
149
+ p = outcome.payload
150
+ toks = int(p.get("input_tokens", 0)) + int(p.get("output_tokens", 0))
151
+ cost = float(p.get("cost_usd", 0.0))
152
+ dur = _fmt_secs(float(p.get("duration_s", 0.0)))
153
+ stats = f"{_fmt_tokens(toks)}tok ${cost:.2f} {dur}"
154
+ print(
155
+ f"[{self._stamp()}] {mark} {_key_label(outcome.key)}"
156
+ f" ({outcome.reason}) {stats}"
157
+ f" [{self.done}/{self.total} done, {self.passed} passed]",
158
+ flush=True,
159
+ )
160
+
161
+
162
+ def _fmt_tokens(value: int) -> str:
163
+ """Compact token count, e.g. ``118k`` / ``1.2M``."""
164
+ if value >= 1_000_000:
165
+ return f"{value / 1_000_000:.1f}M"
166
+ if value >= 1000:
167
+ return f"{value / 1000:.0f}k"
168
+ return str(value)
169
+
170
+
171
+ def _cmd_bench(args: argparse.Namespace) -> int:
172
+ cfg = load_config(args.config)
173
+ tasks = load_tasks(args.tasks)
174
+ if args.max_concurrency:
175
+ cfg = replace(cfg, max_concurrency=args.max_concurrency)
176
+ if args.replicates:
177
+ cfg = replace(cfg, n_replicates=args.replicates)
178
+
179
+ version = args.skill # None => the noskill baseline
180
+ skill = None
181
+ if version is not None:
182
+ skill = load_skill(args.skills, version, expect_name=cfg.skill_name)
183
+
184
+ planned = build_matrix(cfg, tasks, skill=version, splits=args.split or SPLITS, task_ids=args.task)
185
+ todo = pending(planned, args.runs, resume=not args.no_resume)
186
+
187
+ arm = "noskill" if version is None else f"skill_{version}"
188
+ print(f"arm {arm}: {len(planned)} runs planned, {len(planned) - len(todo)} already complete, {len(todo)} to run")
189
+ if skill is not None:
190
+ print(f"skill {skill.version}: {skill.name} ({skill.hash[:19]}…)")
191
+ if args.dry_run:
192
+ for item in todo:
193
+ k = item.key
194
+ print(f" {k.arm}/{k.split}/{k.model}/{k.task_id}/rep_{k.rep}")
195
+ return 0
196
+ if not todo:
197
+ return 0
198
+
199
+ # bench records real per-run cost, so it must bill the API — never the subscription.
200
+ auth_mode = resolve_auth_mode("api", allow_session=False)
201
+ print(f"preparing target {cfg.repo}@{cfg.ref} ...", flush=True)
202
+ target = prepare_target(cfg, args.cache, refresh=args.refresh_target)
203
+ print(f"target ready: {target.fingerprint} @ {target.commit[:8]} (venv {target.venv_dir})", flush=True)
204
+
205
+ print(f"running {len(todo)} runs, up to {cfg.max_concurrency} at a time:", flush=True)
206
+ progress = _Progress(len(todo))
207
+ outcomes = asyncio.run(
208
+ run_matrix(
209
+ todo,
210
+ target=target,
211
+ runs_root=args.runs,
212
+ max_concurrency=cfg.max_concurrency,
213
+ auth_mode=auth_mode,
214
+ skill=skill,
215
+ skill_name=cfg.skill_name,
216
+ keep_sandbox=args.keep_sandboxes,
217
+ stderr=StderrFilter(),
218
+ on_start=progress.on_start,
219
+ on_done=progress.on_done,
220
+ env_passthrough=cfg.env_passthrough,
221
+ )
222
+ )
223
+
224
+ passed = sum(1 for o in outcomes if o.success)
225
+ counts = summarize(outcomes)
226
+ breakdown = ", ".join(f"{reason}={n}" for reason, n in sorted(counts.items()))
227
+ total_cost = sum(float(o.payload.get("cost_usd", 0.0)) for o in outcomes)
228
+ print(f"\n{passed}/{len(outcomes)} passed in {_fmt_secs(progress.elapsed)} (${total_cost:.2f}, {breakdown})")
229
+
230
+ # The comparison is only meaningful if the skill actually reached the agent, so say
231
+ # so rather than leaving it to be discovered later in the transcripts.
232
+ loaded = sum(1 for o in outcomes if o.payload.get("skill_loaded"))
233
+ if skill is not None:
234
+ print(f"skill loaded in {loaded}/{len(outcomes)} runs")
235
+ if loaded == 0:
236
+ print(
237
+ "warning: the skill never loaded — this arm is not measuring the skill",
238
+ file=sys.stderr,
239
+ )
240
+ elif loaded:
241
+ print(f"warning: {cfg.skill_name} loaded in {loaded} baseline runs", file=sys.stderr)
242
+ print(f"runs written to {args.runs.resolve()}")
243
+ return 0
244
+
245
+
246
+ def _cmd_draft(args: argparse.Namespace) -> int:
247
+ cfg = load_config(args.config)
248
+ if args.model:
249
+ cfg = replace(cfg, draft_model=args.model)
250
+
251
+ existing = available_versions(args.skills)
252
+ if existing and not args.force:
253
+ print(
254
+ f"skills already exist ({', '.join(existing)}) — drafting would add "
255
+ f"another version. Pass --force to draft anyway, or use `acumen improve` "
256
+ f"to build on {existing[-1]}.",
257
+ file=sys.stderr,
258
+ )
259
+ return 2
260
+
261
+ auth_mode = resolve_auth_mode(args.auth, allow_session=True)
262
+ _print_auth(auth_mode)
263
+ print(f"preparing target {cfg.repo}@{cfg.ref} ...", flush=True)
264
+ target = prepare_target(cfg, args.cache, refresh=args.refresh_target)
265
+ print(f"target ready: {target.fingerprint} @ {target.commit[:8]}", flush=True)
266
+ print(f"drafting with {cfg.draft_model} (this reads the package source) ...", flush=True)
267
+
268
+ log = LiveLog.open(args.log_dir, "draft", stream=args.stream)
269
+ print(f"log → {log.jsonl_path}", flush=True)
270
+ with log:
271
+ result = asyncio.run(
272
+ draft_skill(
273
+ cfg=cfg,
274
+ target=target,
275
+ skills_root=args.skills,
276
+ auth_mode=auth_mode,
277
+ max_turns=args.max_turns,
278
+ max_usd=args.max_usd,
279
+ feedback=args.feedback,
280
+ log=log,
281
+ )
282
+ )
283
+ skill = result.skill
284
+ files = sorted(p.relative_to(skill.directory).as_posix() for p in skill.directory.rglob("*") if p.is_file())
285
+ print(f"\nwrote {skill.directory}")
286
+ print(f" name: {skill.name}")
287
+ print(f" description: {skill.description}")
288
+ print(f" hash: {skill.hash}")
289
+ print(f" files: {', '.join(files)}")
290
+ print(f" cost: ${result.cost_usd:.2f} over {result.turns} turns")
291
+ _print_log_result(log)
292
+ print(f"\nnext: acumen bench --skill {skill.version}")
293
+ return 0
294
+
295
+
296
+ def _cmd_improve(args: argparse.Namespace) -> int:
297
+ cfg = load_config(args.config)
298
+ tasks = load_tasks(args.tasks)
299
+ if args.model:
300
+ cfg = replace(cfg, improve_model=args.model)
301
+
302
+ versions = available_versions(args.skills)
303
+ if not versions:
304
+ print(
305
+ f"no skill versions under {args.skills} — run `acumen draft` first, then bench it",
306
+ file=sys.stderr,
307
+ )
308
+ return 2
309
+ parent = args.from_version or latest_version(args.skills)
310
+ # Immutability guard: the improved version is always the next unused directory,
311
+ # so an existing version is never in the write path. Say the parent plainly up front.
312
+ skill = load_skill(args.skills, parent, expect_name=cfg.skill_name)
313
+ print(f"improving {skill.version} ({skill.name}, {skill.hash[:19]}…) with {cfg.improve_model}")
314
+
315
+ auth_mode = resolve_auth_mode(args.auth, allow_session=True)
316
+ _print_auth(auth_mode)
317
+ print(f"preparing target {cfg.repo}@{cfg.ref} ...", flush=True)
318
+ target = prepare_target(cfg, args.cache, refresh=args.refresh_target)
319
+ print(f"target ready: {target.fingerprint} @ {target.commit[:8]}", flush=True)
320
+
321
+ log = LiveLog.open(args.log_dir, "improve", stream=args.stream)
322
+ print(f"log → {log.jsonl_path}", flush=True)
323
+ with log:
324
+ result = asyncio.run(
325
+ improve_skill(
326
+ cfg=cfg,
327
+ target=target,
328
+ skills_root=args.skills,
329
+ runs_root=args.runs,
330
+ tasks=tasks,
331
+ auth_mode=auth_mode,
332
+ parent_version=parent,
333
+ max_turns=args.max_turns,
334
+ max_usd=args.max_usd,
335
+ feedback=args.feedback,
336
+ log=log,
337
+ )
338
+ )
339
+ new = result.skill
340
+ files = sorted(p.relative_to(new.directory).as_posix() for p in new.directory.rglob("*") if p.is_file())
341
+ print(f"\nwrote {new.directory} (parent {result.parent})")
342
+ print(f" name: {new.name}")
343
+ print(f" description: {new.description}")
344
+ print(f" hash: {new.hash}")
345
+ print(f" files: {', '.join(files)}")
346
+ print(f" evidence: {result.n_train_runs} train runs ({result.n_train_failures} failing)")
347
+ print(f" cost: ${result.cost_usd:.2f} over {result.turns} turns")
348
+ if new.hash == skill.hash:
349
+ print(
350
+ "warning: the new version is byte-identical to its parent — the improver changed nothing",
351
+ file=sys.stderr,
352
+ )
353
+ _print_log_result(log)
354
+ print(f"\nnext: acumen bench --skill {new.version} && acumen report")
355
+ return 0
356
+
357
+
358
+ def _cmd_tasks(args: argparse.Namespace) -> int:
359
+ cfg = load_config(args.config)
360
+ if args.model:
361
+ cfg = replace(cfg, tasks_model=args.model)
362
+
363
+ out = args.out
364
+ # Fail before the (costly) target prep and agent run if we'd have to clobber.
365
+ if out.exists() and not args.force:
366
+ print(
367
+ f"{out} already exists — pass --force to overwrite it (e.g. the placeholder from `acumen init`)",
368
+ file=sys.stderr,
369
+ )
370
+ return 2
371
+
372
+ auth_mode = resolve_auth_mode(args.auth, allow_session=True)
373
+ _print_auth(auth_mode)
374
+ print(f"preparing target {cfg.repo}@{cfg.ref} ...", flush=True)
375
+ target = prepare_target(cfg, args.cache, refresh=args.refresh_target)
376
+ print(f"target ready: {target.fingerprint} @ {target.commit[:8]}", flush=True)
377
+ print(
378
+ f"generating tasks with {cfg.tasks_model} (this reads the source and runs package code) ...",
379
+ flush=True,
380
+ )
381
+
382
+ log = LiveLog.open(args.log_dir, "tasks", stream=args.stream)
383
+ print(f"log → {log.jsonl_path}", flush=True)
384
+ with log:
385
+ result = asyncio.run(
386
+ generate_tasks(
387
+ cfg=cfg,
388
+ target=target,
389
+ out_path=out,
390
+ auth_mode=auth_mode,
391
+ max_turns=args.max_turns,
392
+ max_usd=args.max_usd,
393
+ force=args.force,
394
+ feedback=args.feedback,
395
+ log=log,
396
+ )
397
+ )
398
+ print(f"\nwrote {result.out_path.resolve()}")
399
+ print(f" tasks: {len(result.tasks)} ({', '.join(t.id for t in result.tasks)})")
400
+ print(f" cost: ${result.cost_usd:.2f} over {result.turns} turns")
401
+ _print_log_result(log)
402
+ print("\nnext: review the tasks, then `acumen draft` and `acumen bench`")
403
+ return 0
404
+
405
+
406
+ def _cmd_ship(args: argparse.Namespace) -> int:
407
+ cfg = load_config(args.config)
408
+ if args.model:
409
+ cfg = replace(cfg, ship_model=args.model)
410
+
411
+ # Validate the version exists before the (costly) target prep.
412
+ load_skill(args.skills, args.version, expect_name=cfg.skill_name)
413
+
414
+ where = (
415
+ "a local path — the change is written to the working tree"
416
+ if cfg.is_local
417
+ else ("a GitHub URL — the change is delivered as a pull request")
418
+ )
419
+ print(f"shipping {args.version} of {cfg.skill_name} into {cfg.repo} ({where})")
420
+ auth_mode = resolve_auth_mode(args.auth, allow_session=True)
421
+ _print_auth(auth_mode)
422
+ print(f"preparing target {cfg.repo}@{cfg.ref} ...", flush=True)
423
+ target = prepare_target(cfg, args.cache, refresh=args.refresh_target)
424
+ print(f"target ready: {target.fingerprint} @ {target.commit[:8]}", flush=True)
425
+ print(
426
+ f"running the ship agent with {cfg.ship_model} (real env: it builds, installs, and "
427
+ f"{'opens a PR' if not cfg.is_local else 'edits the working tree'}) ...",
428
+ flush=True,
429
+ )
430
+
431
+ log = LiveLog.open(args.log_dir, "ship", stream=args.stream)
432
+ print(f"log → {log.jsonl_path}", flush=True)
433
+ with log:
434
+ result = asyncio.run(
435
+ ship_skill(
436
+ cfg=cfg,
437
+ target=target,
438
+ skills_root=args.skills,
439
+ version=args.version,
440
+ auth_mode=auth_mode,
441
+ max_turns=args.max_turns,
442
+ max_usd=args.max_usd,
443
+ force=args.force,
444
+ log=log,
445
+ )
446
+ )
447
+ print(f"\nshipped {result.skill.version} of {result.skill.name}")
448
+ print(f" mode: {'pull request' if result.mode == 'github' else 'working tree (local)'}")
449
+ print(f" cost: ${result.cost_usd:.2f} over {result.turns} turns")
450
+ _print_log_result(log)
451
+ if result.summary:
452
+ print("\nagent summary:")
453
+ print(result.summary)
454
+ return 0
455
+
456
+
457
+ def _parse_palette(values: list[str] | None) -> dict[str, str]:
458
+ """Parse ``--palette MODEL=COLOUR`` arguments into a mapping.
459
+
460
+ The flag repeats, and one value may carry several comma-separated pairs — neither a
461
+ model id nor a colour spec contains a comma, so the split is unambiguous.
462
+ """
463
+ palette = {}
464
+ for value in values or []:
465
+ for pair in value.split(","):
466
+ if not pair.strip():
467
+ continue
468
+ model, sep, color = pair.partition("=")
469
+ if not sep or not model.strip() or not color.strip():
470
+ raise ReportError(f"--palette expects MODEL=COLOUR, got {pair.strip()!r}")
471
+ palette[model.strip()] = color.strip()
472
+ return palette
473
+
474
+
475
+ def _cmd_report(args: argparse.Namespace) -> int:
476
+ tasks = load_tasks(args.tasks) if args.tasks.exists() else None
477
+ if tasks is None:
478
+ print(f"note: {args.tasks} not found — per-task prompts will be omitted", file=sys.stderr)
479
+ skills_root = args.skills if args.skills.is_dir() else None
480
+ if skills_root is None:
481
+ print(f"note: {args.skills} not found — skill rationale/diff will be omitted", file=sys.stderr)
482
+ report = build_report(args.runs, args.out, tasks, skills_root=skills_root, palette=_parse_palette(args.palette))
483
+ df = report.results
484
+ arms = ", ".join(sorted(df["arm_label"].unique(), key=lambda a: (a != "noskill", a)))
485
+ print(f"aggregated {report.n_runs} runs across arms: {arms}")
486
+ for arm in sorted(df["arm_label"].unique(), key=lambda a: (a != "noskill", a)):
487
+ group = df[df["arm_label"] == arm]
488
+ print(f" {arm}: {int(group['success'].sum())}/{len(group)} passed")
489
+ print(f"wrote {args.out.resolve()}")
490
+ print(f"wrote {args.out.resolve().with_suffix('.csv')}")
491
+ return 0
492
+
493
+
494
+ def _cmd_init(args: argparse.Namespace) -> int:
495
+ written = scaffold(args.directory, force=args.force)
496
+ for path in written:
497
+ print(f"wrote {path}")
498
+ print("\nnext: edit config.yaml (repo) and tasks.yaml, then `acumen draft`")
499
+ return 0
500
+
501
+
502
+ def build_parser() -> argparse.ArgumentParser:
503
+ """Build the ``acumen`` argument parser."""
504
+ parser = argparse.ArgumentParser(
505
+ prog="acumen", description="Build, benchmark, and optimize Claude skills for Python packages."
506
+ )
507
+ sub = parser.add_subparsers(dest="command", required=True)
508
+ bench = sub.add_parser("bench", help="run a benchmark pass")
509
+ _add_bench_args(bench)
510
+ bench.set_defaults(func=_cmd_bench)
511
+
512
+ draft = sub.add_parser("draft", help="draft a skill from the target package's source")
513
+ draft.add_argument("--config", type=Path, default=Path("config.yaml"), help="path to config.yaml")
514
+ draft.add_argument("--skills", type=Path, default=Path("skills"), help="root of the skill tree")
515
+ draft.add_argument("--model", help="override config draft_model")
516
+ draft.add_argument("--max-turns", type=int, help="cap turns for the drafting agent (default: unbounded)")
517
+ draft.add_argument("--max-usd", type=float, help="cap spend for the drafting agent (default: unbounded)")
518
+ draft.add_argument("--cache", type=Path, default=DEFAULT_CACHE_ROOT, help="target cache root")
519
+ draft.add_argument("--refresh-target", action="store_true", help="rebuild the target checkout and venv")
520
+ draft.add_argument("--force", action="store_true", help="draft another version even if some already exist")
521
+ _add_auth_arg(draft)
522
+ _add_feedback_arg(draft, extra=" (e.g. package context, what the skill should emphasise)")
523
+ _add_log_args(draft)
524
+ draft.set_defaults(func=_cmd_draft)
525
+
526
+ improve = sub.add_parser("improve", help="improve the current skill into a new version from its train results")
527
+ improve.add_argument("--config", type=Path, default=Path("config.yaml"), help="path to config.yaml")
528
+ improve.add_argument("--tasks", type=Path, default=Path("tasks.yaml"), help="path to tasks.yaml")
529
+ improve.add_argument("--skills", type=Path, default=Path("skills"), help="root of the skill tree")
530
+ improve.add_argument("--runs", type=Path, default=Path("runs"), help="root of the run tree")
531
+ improve.add_argument("--from", dest="from_version", metavar="VERSION", help="version to improve (default: latest)")
532
+ improve.add_argument("--model", help="override config improve_model")
533
+ improve.add_argument("--max-turns", type=int, help="cap turns for the improving agent (default: unbounded)")
534
+ improve.add_argument("--max-usd", type=float, help="cap spend for the improving agent (default: unbounded)")
535
+ improve.add_argument("--cache", type=Path, default=DEFAULT_CACHE_ROOT, help="target cache root")
536
+ improve.add_argument("--refresh-target", action="store_true", help="rebuild the target checkout and venv")
537
+ _add_auth_arg(improve)
538
+ _add_feedback_arg(
539
+ improve,
540
+ extra=" (e.g. what to fix or emphasise; do NOT paste test-split answers — that defeats the held-out split)",
541
+ )
542
+ _add_log_args(improve)
543
+ improve.set_defaults(func=_cmd_improve)
544
+
545
+ tasks_cmd = sub.add_parser("tasks", help="autonomously generate a tasks.yaml from the target package")
546
+ tasks_cmd.add_argument("--config", type=Path, default=Path("config.yaml"), help="path to config.yaml")
547
+ tasks_cmd.add_argument("--out", type=Path, default=Path("tasks.yaml"), help="tasks.yaml to write")
548
+ tasks_cmd.add_argument("--model", help="override config tasks_model")
549
+ tasks_cmd.add_argument("--max-turns", type=int, help="cap turns for the generation agent (default: unbounded)")
550
+ tasks_cmd.add_argument("--max-usd", type=float, help="cap spend for the generation agent (default: unbounded)")
551
+ tasks_cmd.add_argument("--cache", type=Path, default=DEFAULT_CACHE_ROOT, help="target cache root")
552
+ tasks_cmd.add_argument("--refresh-target", action="store_true", help="rebuild the target checkout and venv")
553
+ tasks_cmd.add_argument("--force", action="store_true", help="overwrite an existing tasks file")
554
+ _add_auth_arg(tasks_cmd)
555
+ _add_feedback_arg(tasks_cmd, extra=" (e.g. which functionality to skip or focus on)")
556
+ _add_log_args(tasks_cmd)
557
+ tasks_cmd.set_defaults(func=_cmd_tasks)
558
+
559
+ ship = sub.add_parser("ship", help="make a benchmarked skill installable into the target package")
560
+ ship.add_argument(
561
+ "--skill", dest="version", metavar="VERSION", required=True, help="skill version to ship, e.g. v2"
562
+ )
563
+ ship.add_argument("--config", type=Path, default=Path("config.yaml"), help="path to config.yaml")
564
+ ship.add_argument("--skills", type=Path, default=Path("skills"), help="root of the skill tree")
565
+ ship.add_argument("--model", help="override config ship_model")
566
+ ship.add_argument("--max-turns", type=int, help="cap turns for the ship agent (default: unbounded)")
567
+ ship.add_argument("--max-usd", type=float, help="cap spend for the ship agent (default: unbounded)")
568
+ ship.add_argument("--cache", type=Path, default=DEFAULT_CACHE_ROOT, help="target cache root")
569
+ ship.add_argument("--refresh-target", action="store_true", help="rebuild the target checkout and venv")
570
+ ship.add_argument("--force", action="store_true", help="ship even if the package already has an installer")
571
+ _add_auth_arg(ship)
572
+ _add_log_args(ship)
573
+ ship.set_defaults(func=_cmd_ship)
574
+
575
+ report = sub.add_parser("report", help="aggregate the run tree into a self-contained report.html")
576
+ report.add_argument("--runs", type=Path, default=Path("runs"), help="root of the run tree")
577
+ report.add_argument("--tasks", type=Path, default=Path("tasks.yaml"), help="path to tasks.yaml (for task text)")
578
+ report.add_argument("--skills", type=Path, default=Path("skills"), help="root of the skill tree (rationale/diff)")
579
+ report.add_argument("--out", type=Path, default=Path("report.html"), help="output HTML path (overwritten)")
580
+ report.add_argument(
581
+ "--palette",
582
+ action="append",
583
+ metavar="MODEL=COLOUR",
584
+ help="recolour a model's bars, e.g. --palette claude-opus-5=#3b7ea1 (repeatable, or comma-separated)",
585
+ )
586
+ report.set_defaults(func=_cmd_report)
587
+
588
+ init = sub.add_parser("init", help="scaffold a starter config.yaml and tasks.yaml")
589
+ init.add_argument("--dir", type=Path, default=Path("."), dest="directory", help="directory to scaffold into")
590
+ init.add_argument("--force", action="store_true", help="overwrite existing config.yaml / tasks.yaml")
591
+ init.set_defaults(func=_cmd_init)
592
+ return parser
593
+
594
+
595
+ def main(argv: list[str] | None = None) -> int:
596
+ """Run the CLI.
597
+
598
+ Returns
599
+ -------
600
+ A process exit code.
601
+ """
602
+ parser = build_parser()
603
+ args = parser.parse_args(argv)
604
+ try:
605
+ return int(args.func(args))
606
+ except (
607
+ ConfigError,
608
+ TaskError,
609
+ EnvError,
610
+ SkillError,
611
+ DraftError,
612
+ ImproveError,
613
+ TaskGenError,
614
+ ShipError,
615
+ ReportError,
616
+ InitError,
617
+ ) as err:
618
+ print(f"error: {err}", file=sys.stderr)
619
+ return 2
620
+ except KeyboardInterrupt:
621
+ print("\ninterrupted — completed runs are preserved; rerun to resume", file=sys.stderr)
622
+ return 130
623
+
624
+
625
+ if __name__ == "__main__": # pragma: no cover
626
+ raise SystemExit(main())