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/__init__.py +160 -0
- acumen/_skills/__init__.py +0 -0
- acumen/_skills/data/SKILL.md +109 -0
- acumen/_skills/data/references/authoring.md +91 -0
- acumen/_skills/data/references/benchmark.md +100 -0
- acumen/_skills/data/references/python-api.md +106 -0
- acumen/_skills/data/references/setup.md +94 -0
- acumen/_skills/data/references/ship.md +53 -0
- acumen/_skills/install.py +185 -0
- acumen/assets/banner.svg +501 -0
- acumen/assets/logo.svg +47 -0
- acumen/bench.py +202 -0
- acumen/cli.py +626 -0
- acumen/config.py +204 -0
- acumen/draft.py +202 -0
- acumen/env.py +602 -0
- acumen/grade.py +104 -0
- acumen/improve.py +596 -0
- acumen/logs.py +255 -0
- acumen/paths.py +164 -0
- acumen/procs.py +195 -0
- acumen/prompts.py +942 -0
- acumen/report.py +1858 -0
- acumen/runner.py +343 -0
- acumen/sandbox.py +153 -0
- acumen/scaffold.py +118 -0
- acumen/ship.py +298 -0
- acumen/skills.py +298 -0
- acumen/taskgen.py +424 -0
- acumen/tasks.py +144 -0
- acumen/transcript.py +77 -0
- acumen-0.0.1.dev0.dist-info/METADATA +168 -0
- acumen-0.0.1.dev0.dist-info/RECORD +36 -0
- acumen-0.0.1.dev0.dist-info/WHEEL +4 -0
- acumen-0.0.1.dev0.dist-info/entry_points.txt +3 -0
- acumen-0.0.1.dev0.dist-info/licenses/LICENSE +21 -0
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())
|