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/runner.py ADDED
@@ -0,0 +1,343 @@
1
+ """SDK invocation for a single benchmark run.
2
+
3
+ Runs one agent in one sandbox, captures its transcript, grades what it wrote, and emits
4
+ ``result.json`` — the unit of record. Writing ``result.json`` last is deliberate: its
5
+ presence is what marks a run complete, which is what makes ``--resume`` safe.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import TextIO
17
+
18
+ from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
19
+
20
+ from acumen.env import AuthMode, Target, sdk_version
21
+ from acumen.grade import Grade, Reason, grade_run
22
+ from acumen.paths import (
23
+ ANSWER_FILE,
24
+ RESULT_FILE,
25
+ SCRIPT_FILE,
26
+ TRANSCRIPT_HTML,
27
+ TRANSCRIPT_JSONL,
28
+ RunKey,
29
+ )
30
+ from acumen.prompts import benchmark_prompt
31
+ from acumen.sandbox import Sandbox, sandbox
32
+ from acumen.skills import Skill
33
+ from acumen.tasks import Task
34
+ from acumen.transcript import locate_transcript, render_transcript
35
+
36
+ #: Result subtypes the CLI reports on a cap breach, mapped to our reason taxonomy.
37
+ _SUBTYPE_REASONS: dict[str, Reason] = {
38
+ "error_max_budget_usd": "budget",
39
+ "error_max_turns": "max_turns",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RunOutcome:
45
+ """What a run produced, as recorded in ``result.json``."""
46
+
47
+ key: RunKey
48
+ success: bool
49
+ reason: Reason
50
+ payload: dict
51
+
52
+
53
+ def _terminal_reason(message: ResultMessage) -> Reason | None:
54
+ """Map a cap breach or hard error onto a reason, or ``None`` if the agent finished.
55
+
56
+ ``subtype`` is a free-form string from the CLI, so match defensively rather than
57
+ against an exhaustive list we cannot see from here.
58
+ """
59
+ subtype = (message.subtype or "").lower()
60
+ if subtype in _SUBTYPE_REASONS:
61
+ return _SUBTYPE_REASONS[subtype]
62
+ if "budget" in subtype:
63
+ return "budget"
64
+ if "max_turns" in subtype:
65
+ return "max_turns"
66
+ if message.is_error or subtype.startswith("error"):
67
+ return "error"
68
+ return None
69
+
70
+
71
+ def _find_transcript(box: Sandbox, session_id: str) -> Path | None:
72
+ return locate_transcript(box.config_dir, box.root, session_id)
73
+
74
+
75
+ def _skill_fired(jsonl: Path, name: str) -> bool | None:
76
+ """Return whether the agent loaded the skill called ``name``, read from the transcript.
77
+
78
+ This is the evidence the skill arm actually used the skill — the skill arm must show it
79
+ loading and the baseline must not — so it is recorded per run rather than left to
80
+ hand-inspection. ``None`` means the transcript was unavailable, which is not the same
81
+ as the skill not loading.
82
+
83
+ The match is on the skill *identity*, not on the tool: a ``Skill`` call carries the
84
+ requested skill in ``input.skill``, and the CLI ships bundled skills of its own that the
85
+ agent can reach in either arm. Loading one of those says nothing about the skill under
86
+ test, so only ``input.skill == name`` counts.
87
+ """
88
+ try:
89
+ lines = jsonl.read_text().splitlines()
90
+ except OSError:
91
+ return None
92
+ for line in lines:
93
+ try:
94
+ record = json.loads(line)
95
+ except json.JSONDecodeError:
96
+ continue
97
+ if record.get("type") != "assistant":
98
+ continue
99
+ content = record.get("message", {}).get("content")
100
+ if not isinstance(content, list):
101
+ continue
102
+ for block in content:
103
+ if not isinstance(block, dict) or block.get("type") != "tool_use" or block.get("name") != "Skill":
104
+ continue
105
+ payload = block.get("input")
106
+ if isinstance(payload, dict) and payload.get("skill") == name:
107
+ return True
108
+ return False
109
+
110
+
111
+ def _collect_artifacts(box: Sandbox, run_dir: Path) -> None:
112
+ for name in (ANSWER_FILE, SCRIPT_FILE):
113
+ src = box.root / name
114
+ if src.is_file():
115
+ shutil.copyfile(src, run_dir / name)
116
+
117
+
118
+ def _usage_tokens(usage: dict | None) -> tuple[int, int]:
119
+ if not usage:
120
+ return 0, 0
121
+ inp = int(usage.get("input_tokens", 0) or 0)
122
+ inp += int(usage.get("cache_read_input_tokens", 0) or 0)
123
+ inp += int(usage.get("cache_creation_input_tokens", 0) or 0)
124
+ return inp, int(usage.get("output_tokens", 0) or 0)
125
+
126
+
127
+ class StderrFilter:
128
+ """Dedupe the CLI subprocess stderr the SDK forwards, keeping the first of each line.
129
+
130
+ A benchmark pass spawns one CLI subprocess per run, and each reprints the same
131
+ startup diagnostics — most visibly the ``claude.ai connectors are disabled`` notice,
132
+ which fires on every spawn because an auth source is present in the agents' env. Left
133
+ alone that is one identical warning per run.
134
+
135
+ Share a single instance across a whole pass and hand it to :func:`run_matrix` as its
136
+ ``stderr`` callback. The SDK pipes subprocess stderr only when a callback is set and
137
+ then invokes it one line at a time on the event loop, so this becomes the sole sink:
138
+ the first time a given line is seen it is forwarded to our own stderr, and every later
139
+ repeat is dropped. Distinct lines still all pass through; only exact repeats are cut.
140
+
141
+ Called on a single-threaded event loop, so the seen-set needs no locking.
142
+ """
143
+
144
+ def __init__(self, sink: TextIO | None = None) -> None:
145
+ self._seen: set[str] = set()
146
+ self._sink = sink if sink is not None else sys.stderr
147
+
148
+ def __call__(self, line: str) -> None:
149
+ """Forward ``line`` the first time it is seen; drop it on every later repeat."""
150
+ if line in self._seen:
151
+ return
152
+ self._seen.add(line)
153
+ print(line, file=self._sink, flush=True)
154
+
155
+
156
+ def _build_options(
157
+ *,
158
+ box: Sandbox,
159
+ model: str,
160
+ max_turns: int,
161
+ max_usd: float,
162
+ stderr: Callable[[str], None] | None = None,
163
+ ) -> ClaudeAgentOptions:
164
+ """Assemble the SDK options for one run.
165
+
166
+ These options are **identical for every arm** — baseline parity means the
167
+ noskill and skill arms differ only in whether a skill directory exists inside the
168
+ sandbox, never in how the agent is configured.
169
+
170
+ ``setting_sources`` is always set explicitly, never left to default: the default of
171
+ ``None`` loads user settings *and* memories, and leaving it unset while
172
+ passing ``skills`` silently re-enables the ``"user"`` source.
173
+ ``["project"]`` scopes discovery to ``<sandbox>/.claude/``, which is exactly where
174
+ the skill is installed — empirically verified, along with the fact that
175
+ ``[]`` disables skill discovery entirely.
176
+
177
+ ``skills`` is deliberately left ``None``: with ``setting_sources=["project"]`` the
178
+ skill is discovered from the sandbox anyway (verified), whereas naming it here would
179
+ append ``Skill(<name>)`` to ``allowed_tools`` in the skill arm only — an options-level
180
+ difference between arms, which is the one thing parity forbids.
181
+ """
182
+ return ClaudeAgentOptions(
183
+ cwd=str(box.root),
184
+ env=box.env,
185
+ model=model,
186
+ max_turns=max_turns,
187
+ max_budget_usd=max_usd,
188
+ setting_sources=["project"],
189
+ permission_mode="bypassPermissions",
190
+ system_prompt={"type": "preset", "preset": "claude_code"},
191
+ stderr=stderr,
192
+ )
193
+
194
+
195
+ async def run_once(
196
+ *,
197
+ key: RunKey,
198
+ task: Task,
199
+ target: Target,
200
+ run_dir: Path,
201
+ model: str,
202
+ max_turns: int,
203
+ max_usd: float,
204
+ auth_mode: AuthMode = "api",
205
+ skill: Skill | None = None,
206
+ skill_name: str | None = None,
207
+ sandbox_base: Path | None = None,
208
+ keep_sandbox: bool = False,
209
+ stderr: Callable[[str], None] | None = None,
210
+ env_passthrough: Sequence[str] | None = None,
211
+ ) -> RunOutcome:
212
+ """Execute one benchmark run end to end and write its ``result.json``.
213
+
214
+ Parameters
215
+ ----------
216
+ key
217
+ Identifies the run; also determines ``run_dir`` upstream.
218
+ task
219
+ The task being run; ``key.split`` selects which half.
220
+ target
221
+ The prepared target, supplying the interpreter and fingerprint.
222
+ run_dir
223
+ Where the five run files are written.
224
+ model, max_turns, max_usd
225
+ Already resolved against per-task overrides by the caller.
226
+ auth_mode
227
+ Which credential benchmark runs authenticate with; always ``"api"`` so the recorded
228
+ ``cost_usd`` reflects real metered spend.
229
+ skill
230
+ The skill to install, or ``None`` for the baseline arm. Must agree with
231
+ ``key.arm``, else the run would be filed under an arm it doesn't belong to.
232
+ skill_name
233
+ Name of the skill under test, used to recognise it in the transcript. Defaults to
234
+ the installed skill's name; pass it in the baseline arm too, where nothing is
235
+ installed but a stray load of that same skill is exactly what must be caught.
236
+ Without either, ``skill_loaded`` is left ``None`` — unattributable, not absent.
237
+ sandbox_base
238
+ Parent directory for the throwaway sandbox.
239
+ keep_sandbox
240
+ Leave the sandbox on disk — useful when hand-checking a failure.
241
+ stderr
242
+ Callback for the CLI subprocess's stderr, one line at a time. Pass a shared
243
+ :class:`StderrFilter` across a pass to collapse the per-spawn startup warnings;
244
+ ``None`` (the default) lets the subprocess stderr inherit the terminal unfiltered.
245
+ env_passthrough
246
+ Extra environment variable names to carry into the sandbox on top of the built-in
247
+ allowlist (the operator's ``config.env_passthrough``).
248
+
249
+ Returns
250
+ -------
251
+ The outcome, matching what was written to disk.
252
+ """
253
+ if (key.skill is None) != (skill is None):
254
+ # Filing a skill run under noskill/ (or vice versa) would silently corrupt the
255
+ # comparison the whole benchmark rests on, so refuse rather than record it.
256
+ raise ValueError(
257
+ f"arm {key.arm!r} expects skill {key.skill!r} but was given {skill.version if skill else None!r}"
258
+ )
259
+ run_dir.mkdir(parents=True, exist_ok=True)
260
+ split = task.split(key.split)
261
+
262
+ result: ResultMessage | None = None
263
+ error: str | None = None
264
+
265
+ with sandbox(
266
+ target,
267
+ auth_mode=auth_mode,
268
+ base=sandbox_base,
269
+ keep=keep_sandbox,
270
+ skill=skill,
271
+ env_passthrough=env_passthrough,
272
+ ) as box:
273
+ prompt = benchmark_prompt(
274
+ split.prompt,
275
+ sandbox=box.root,
276
+ python=target.python,
277
+ package=target.pkg_name,
278
+ )
279
+ options = _build_options(box=box, model=model, max_turns=max_turns, max_usd=max_usd, stderr=stderr)
280
+ try:
281
+ async for message in query(prompt=prompt, options=options):
282
+ if isinstance(message, ResultMessage):
283
+ result = message
284
+ except Exception as err: # noqa: BLE001 - a crashed agent is a recorded failure, not a crashed pass
285
+ error = f"{type(err).__name__}: {err}"
286
+
287
+ _collect_artifacts(box, run_dir)
288
+ if result is not None:
289
+ jsonl = _find_transcript(box, result.session_id)
290
+ if jsonl is not None:
291
+ shutil.copyfile(jsonl, run_dir / TRANSCRIPT_JSONL)
292
+
293
+ rendered = False
294
+ skill_loaded: bool | None = None
295
+ expected_skill = skill.name if skill is not None else skill_name
296
+ if (run_dir / TRANSCRIPT_JSONL).is_file():
297
+ rendered = render_transcript(run_dir / TRANSCRIPT_JSONL, run_dir / TRANSCRIPT_HTML)
298
+ if expected_skill is not None:
299
+ skill_loaded = _skill_fired(run_dir / TRANSCRIPT_JSONL, expected_skill)
300
+
301
+ grade: Grade = grade_run(run_dir, split.answer)
302
+ if error is not None or result is None:
303
+ success, reason = False, "error"
304
+ else:
305
+ terminal = _terminal_reason(result)
306
+ if terminal is not None:
307
+ # A cap breach is a failure regardless of what landed in answer.md.
308
+ success, reason = False, terminal
309
+ else:
310
+ success, reason = grade.success, grade.reason
311
+
312
+ inp, out = _usage_tokens(result.usage if result else None)
313
+ payload = {
314
+ "task_id": key.task_id,
315
+ "split": key.split,
316
+ "skill": key.skill,
317
+ "arm": key.arm,
318
+ "model": model,
319
+ "rep": key.rep,
320
+ "success": success,
321
+ "reason": reason,
322
+ "turns": result.num_turns if result else 0,
323
+ "cost_usd": (result.total_cost_usd if result else None) or 0.0,
324
+ "input_tokens": inp,
325
+ "output_tokens": out,
326
+ "duration_s": round((result.duration_ms if result else 0) / 1000, 2),
327
+ "answer": grade.answer,
328
+ "expected": split.answer.strip(),
329
+ "pkg_version": target.fingerprint,
330
+ "commit": target.commit,
331
+ "skill_hash": skill.hash if skill else None,
332
+ "skill_name": skill.name if skill else None,
333
+ # Evidence, not configuration: did the agent actually invoke the Skill tool?
334
+ "skill_loaded": skill_loaded,
335
+ "sdk_version": sdk_version(),
336
+ "session_id": result.session_id if result else None,
337
+ "stop_reason": result.stop_reason if result else None,
338
+ "subtype": result.subtype if result else None,
339
+ "transcript_html": rendered,
340
+ "error": error or (result.errors if result else None),
341
+ }
342
+ (run_dir / RESULT_FILE).write_text(json.dumps(payload, indent=2) + "\n")
343
+ return RunOutcome(key=key, success=success, reason=reason, payload=payload)
acumen/sandbox.py ADDED
@@ -0,0 +1,153 @@
1
+ """Per-run sandbox: a fresh empty directory with the target venv on PATH.
2
+
3
+ The agent sees an installed package and nothing else — no repo source, no user
4
+ settings, no memories. Each run gets its own sandbox, throwaway ``HOME`` and throwaway
5
+ ``CLAUDE_CONFIG_DIR``, so runs cannot see each other either.
6
+
7
+ A skill arm differs from the baseline in exactly one way: ``skills/v{n}/`` is
8
+ copied into ``<sandbox>/.claude/skills/<name>/``, where the agent's own project settings
9
+ discovery finds it. Same prompt, same tools, same caps, same env otherwise.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shutil
15
+ import tempfile
16
+ from collections.abc import Iterator, Sequence
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from acumen.env import AuthMode, Target, build_agent_env
22
+ from acumen.procs import label_env, reap
23
+ from acumen.skills import Skill, content_files
24
+
25
+ #: Where the ``claude`` CLI looks for project skills, relative to the agent's cwd.
26
+ SKILLS_SUBDIR = Path(".claude") / "skills"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Sandbox:
31
+ """A prepared, isolated working directory for exactly one agent run."""
32
+
33
+ root: Path
34
+ home: Path
35
+ config_dir: Path
36
+ env: dict[str, str]
37
+ authenticated: bool
38
+ skill: Skill | None = None
39
+
40
+ @property
41
+ def transcript_root(self) -> Path:
42
+ """Where the ``claude`` CLI writes transcripts for this run."""
43
+ return self.config_dir / "projects"
44
+
45
+ @property
46
+ def skills_dir(self) -> Path:
47
+ """The project skills directory the agent discovers, ``<root>/.claude/skills``."""
48
+ return self.root / SKILLS_SUBDIR
49
+
50
+ @property
51
+ def skill_hash(self) -> str | None:
52
+ """The installed skill's content hash, or ``None`` for the baseline arm."""
53
+ return self.skill.hash if self.skill else None
54
+
55
+
56
+ def install_skill(root: Path, skill: Skill) -> Path:
57
+ """Copy a skill's content into a sandbox as a discoverable project skill.
58
+
59
+ ``meta.json`` is deliberately left behind: it is acumen's provenance record, and the
60
+ agent consuming the skill has no business reading the rationale for its own version.
61
+
62
+ Parameters
63
+ ----------
64
+ root
65
+ The sandbox root — the agent's ``cwd``.
66
+ skill
67
+ The loaded skill to install.
68
+
69
+ Returns
70
+ -------
71
+ The installed skill directory, ``<root>/.claude/skills/<name>``.
72
+ """
73
+ dest = root / SKILLS_SUBDIR / skill.name
74
+ dest.mkdir(parents=True, exist_ok=True)
75
+ for src in content_files(skill.directory):
76
+ target = dest / src.relative_to(skill.directory)
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ shutil.copyfile(src, target)
79
+ return dest
80
+
81
+
82
+ @contextmanager
83
+ def sandbox(
84
+ target: Target,
85
+ *,
86
+ auth_mode: AuthMode,
87
+ base: Path | None = None,
88
+ keep: bool = False,
89
+ skill: Skill | None = None,
90
+ env_passthrough: Sequence[str] | None = None,
91
+ ) -> Iterator[Sandbox]:
92
+ """Create a fresh sandbox for one run and clean it up afterwards.
93
+
94
+ Parameters
95
+ ----------
96
+ target
97
+ The prepared target; its venv ``bin`` goes on the sandbox PATH.
98
+ auth_mode
99
+ Which credential the run authenticates with (see :func:`acumen.env.build_agent_env`).
100
+ Benchmark runs always pass ``"api"`` so the recorded ``cost_usd`` is real.
101
+ base
102
+ Parent directory for the sandbox. Defaults to the system temp dir.
103
+ keep
104
+ Leave the sandbox on disk after the run — useful when debugging a failure.
105
+ skill
106
+ Skill to install for this run, or ``None`` for the baseline arm.
107
+ env_passthrough
108
+ Extra environment variable names to carry into the sandbox on top of the built-in
109
+ allowlist (the operator's ``config.env_passthrough``).
110
+
111
+ Yields
112
+ ------
113
+ The sandbox, whose ``root`` is the agent's ``cwd``.
114
+ """
115
+ if base is not None:
116
+ base.mkdir(parents=True, exist_ok=True)
117
+ holder = Path(tempfile.mkdtemp(prefix="acumen-run-", dir=base))
118
+ try:
119
+ root = holder / "work"
120
+ home = holder / "home"
121
+ config_dir = home / ".claude"
122
+ for path in (root, home, config_dir, home / "tmp"):
123
+ path.mkdir(parents=True, exist_ok=True)
124
+ if skill is not None:
125
+ install_skill(root, skill)
126
+ # The marker is what lets the teardown below find whatever this agent leaves running.
127
+ env = label_env(
128
+ build_agent_env(
129
+ config_dir=config_dir,
130
+ home=home,
131
+ extra_path=[target.bin_dir],
132
+ auth_mode=auth_mode,
133
+ extra_allow=env_passthrough,
134
+ ),
135
+ holder,
136
+ )
137
+ yield Sandbox(
138
+ root=root,
139
+ home=home,
140
+ config_dir=config_dir,
141
+ env=env,
142
+ # Only session mode seeds the OAuth login into the throwaway config dir.
143
+ authenticated=auth_mode == "session",
144
+ skill=skill,
145
+ )
146
+ finally:
147
+ # The agent's own descendants outlive it — a backgrounded job, or anything still
148
+ # running when the CLI was terminated on a cap breach or a Ctrl-C. Kill them before
149
+ # the directory goes, so nothing is left writing into a path that no longer exists.
150
+ # ``keep`` preserves the files for inspection, never the processes.
151
+ reap(holder)
152
+ if not keep:
153
+ shutil.rmtree(holder, ignore_errors=True)
acumen/scaffold.py ADDED
@@ -0,0 +1,118 @@
1
+ """Scaffold starter ``config.yaml`` and ``tasks.yaml`` files for ``acumen init``.
2
+
3
+ The templates are package-agnostic placeholders, kept heavily commented so a new user can
4
+ read what each field does and fill in the blanks rather than starting from an empty file.
5
+ The placeholders (``OWNER/REPO``, the ``REPLACE_ME`` answers) are meant to be edited before
6
+ the first run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ CONFIG_FILENAME = "config.yaml"
14
+ TASKS_FILENAME = "tasks.yaml"
15
+
16
+ #: An annotated config with placeholder target/skill fields for the user to fill in.
17
+ CONFIG_TEMPLATE = """\
18
+ # acumen config — the target package and how to benchmark against it.
19
+ # Every field below except `repo` has a default; delete a line to take it.
20
+
21
+ repo: https://github.com/OWNER/REPO # GitHub URL, or a local path to an installable package
22
+ ref: main # branch, tag, or commit (ignored for local paths)
23
+ extras: [] # optional pip extras, e.g. [dev, test]
24
+ python: "3.12" # interpreter for the target's throwaway venv
25
+ env_passthrough: [] # extra env vars agents may keep (e.g. [OMP_NUM_THREADS]); the agent env is otherwise a clean allowlist
26
+
27
+ models: [claude-opus-5, claude-sonnet-5, claude-haiku-4-5-20251001] # benchmark models; a pass is models x tasks x reps x splits
28
+ n_replicates: 3 # runs per (model, task, split) cell
29
+ max_concurrency: 4 # simultaneous agents
30
+
31
+ max_turns: 40 # turn cap for benchmark agents; draft/improve/ship/tasks are unbounded
32
+ max_usd: 3.0 # budget cap (USD) for benchmark agents; draft/improve/ship/tasks are unbounded
33
+
34
+ draft_model: claude-opus-5 # model that writes the first skill (reads the source)
35
+ improve_model: claude-opus-5 # model that improves a skill from its train results
36
+ tasks_model: claude-opus-5 # model for `acumen tasks` (generates this file); runs code
37
+ ship_model: claude-opus-5 # model for `acumen ship` (wires a multi-framework install-skills script)
38
+
39
+ # skill_name: your_skill # name for the built skill; defaults to the repo's name
40
+ """
41
+
42
+ #: A tasks file with one placeholder task, showing the required shape. Each task needs a
43
+ #: stable `id` and a train/test pair; the answer is graded by exact string match after
44
+ #: `strip()`.
45
+ TASKS_TEMPLATE = """\
46
+ # acumen tasks — what the agent is asked to do, and the answer it is graded against.
47
+ #
48
+ # Each task has a stable `id` (used in run paths — renaming it orphans old runs) and a
49
+ # train/test pair. The improver only ever sees train results; test is the held-out measure
50
+ # of whether a skill actually helps. Answers are compared by EXACT string match after
51
+ # strip(), so keep them to a single unambiguous token.
52
+ #
53
+ # Do NOT name the target package in a prompt. The agent is already told, before every task,
54
+ # that your package (from `config.yaml`) is installed and is the one to use — so "using
55
+ # <package>, ..." is redundant. Describe only the analysis: the goal, the input, and exactly
56
+ # what to report. (`acumen tasks` follows the same rule when it generates this file.)
57
+ #
58
+ # A good task is one your target package solves and the baseline gets wrong — that is where
59
+ # a skill has room to help. Replace the placeholders below with your own, or run
60
+ # `acumen tasks` to generate this file automatically from the target package.
61
+ #
62
+ # Optional per-task overrides: `max_turns`, `max_usd`, `model`.
63
+
64
+ tasks:
65
+ - id: example_task
66
+ train:
67
+ prompt: >-
68
+ Describe, concretely, the analysis the agent should carry out on the training input
69
+ — the data, the goal, and exactly what to report. Do not name the package (it is
70
+ provided). End by asking for only the final answer, so it grades as a single token.
71
+ answer: REPLACE_ME_TRAIN
72
+ test:
73
+ prompt: >-
74
+ The same kind of analysis on a different, held-out input. This split is what measures
75
+ whether the skill generalizes; the improver never sees its results.
76
+ answer: REPLACE_ME_TEST
77
+ """
78
+
79
+
80
+ class InitError(ValueError):
81
+ """Raised when scaffolding cannot proceed — e.g. files exist and ``force`` is unset."""
82
+
83
+
84
+ def scaffold(directory: Path, *, force: bool = False) -> list[Path]:
85
+ """Write starter ``config.yaml`` and ``tasks.yaml`` into ``directory``.
86
+
87
+ Parameters
88
+ ----------
89
+ directory
90
+ Where to scaffold. Created if it does not exist.
91
+ force
92
+ Overwrite existing files. Without it, an existing ``config.yaml`` or ``tasks.yaml``
93
+ is left untouched and an error is raised, so a real project is never clobbered.
94
+
95
+ Returns
96
+ -------
97
+ The paths written, in the order they were written.
98
+
99
+ Raises
100
+ ------
101
+ InitError
102
+ If a target file exists and ``force`` is not set.
103
+ """
104
+ directory.mkdir(parents=True, exist_ok=True)
105
+ targets = {directory / CONFIG_FILENAME: CONFIG_TEMPLATE, directory / TASKS_FILENAME: TASKS_TEMPLATE}
106
+
107
+ if not force:
108
+ existing = [p for p in targets if p.exists()]
109
+ if existing:
110
+ names = ", ".join(p.name for p in existing)
111
+ verb = "exists" if len(existing) == 1 else "exist"
112
+ raise InitError(f"{names} already {verb} in {directory} — pass --force to overwrite")
113
+
114
+ written = []
115
+ for path, template in targets.items():
116
+ path.write_text(template)
117
+ written.append(path)
118
+ return written