agent-skill-description-optimizer 0.2.0__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.
skill_optimizer/cli.py ADDED
@@ -0,0 +1,1450 @@
1
+ """Command-line entry point and run orchestration."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import webbrowser
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from skill_optimizer._process import (
16
+ claude_available,
17
+ claude_bin,
18
+ )
19
+ from skill_optimizer.evaluation import evaluate, subset_result
20
+ from skill_optimizer.improver import (
21
+ ImproverFatalProcessError,
22
+ ImproverRetryableError,
23
+ _LaunchBudget, # pyright: ignore[reportPrivateUsage]
24
+ build_improver_prompt,
25
+ call_improver,
26
+ )
27
+ from skill_optimizer.models import (
28
+ MODEL_ALIASES,
29
+ EvalConfig,
30
+ EvalQuery,
31
+ EvalResult,
32
+ ImproverAttempt,
33
+ PerQuery,
34
+ )
35
+ from skill_optimizer.report import generate_html
36
+ from skill_optimizer.selection import (
37
+ is_better_candidate,
38
+ resolve_models,
39
+ stratified_split,
40
+ summarize,
41
+ summarize_verbose,
42
+ )
43
+ from skill_optimizer.skill_md import parse_skill_md, safe_name_token, write_description
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Upper bound on improve->re-eval rounds. Bounds autonomous cost: composed with the
48
+ # per-slot retry and the internal shortening call, this caps improver child launches
49
+ # (see ``_LaunchBudget``). ``--iterations`` is validated to the inclusive [0, 50] range.
50
+ MAX_ITERATIONS = 50
51
+
52
+
53
+ def _validate_iterations(iterations: int) -> int:
54
+ """Return an iteration count in ``[0, 50]``, or raise the exact ``ValueError``.
55
+
56
+ Args:
57
+ iterations: The requested iteration count.
58
+
59
+ Returns:
60
+ ``iterations`` unchanged when it lies in the inclusive ``[0, 50]`` range.
61
+
62
+ Raises:
63
+ ValueError: If ``iterations`` is outside ``[0, 50]``.
64
+ """
65
+ if not 0 <= iterations <= MAX_ITERATIONS:
66
+ raise ValueError(f"--iterations must be between 0 and {MAX_ITERATIONS}")
67
+ return iterations
68
+
69
+
70
+ def build_parser() -> argparse.ArgumentParser:
71
+ """Build the command-line argument parser.
72
+
73
+ Returns:
74
+ The configured :class:`argparse.ArgumentParser`.
75
+ """
76
+ from skill_optimizer import __version__
77
+
78
+ parser = argparse.ArgumentParser(
79
+ description="No-API-key skill description optimizer (uses `claude -p`)."
80
+ )
81
+ parser.add_argument(
82
+ "--version", action="version", version=f"%(prog)s {__version__}"
83
+ )
84
+ parser.add_argument(
85
+ "--skill-path", required=True, help="Path to the skill dir (contains SKILL.md)"
86
+ )
87
+ parser.add_argument(
88
+ "--eval-set", required=True, help="JSON: [{query, should_trigger}, ...]"
89
+ )
90
+ parser.add_argument(
91
+ "--out", default=None, help="Output dir for run artifacts (default: a temp dir)"
92
+ )
93
+ parser.add_argument(
94
+ "--models",
95
+ default=None,
96
+ help="Comma list of eval models (aliases haiku/sonnet/opus or full ids). "
97
+ "Default: sonnet (or --model)",
98
+ )
99
+ parser.add_argument(
100
+ "--improver-model",
101
+ default=None,
102
+ help="Model for the improver (alias or id). Default: opus (or --model)",
103
+ )
104
+ parser.add_argument(
105
+ "--model",
106
+ default=None,
107
+ help="Single model id for BOTH eval and improver (skill-creator "
108
+ "compatibility); --models/--improver-model take precedence",
109
+ )
110
+ parser.add_argument(
111
+ "--description",
112
+ default=None,
113
+ help="Override the starting description instead of reading SKILL.md's",
114
+ )
115
+ parser.add_argument(
116
+ "--improver-effort",
117
+ default="high",
118
+ help="Effort for improver (high/medium/low/none-> omit)",
119
+ )
120
+ parser.add_argument(
121
+ "--repeats",
122
+ "--runs-per-query",
123
+ dest="repeats",
124
+ type=int,
125
+ default=3,
126
+ help="Runs per (query, model)",
127
+ )
128
+ parser.add_argument(
129
+ "--iterations", "--max-iterations", dest="iterations", type=int, default=5
130
+ )
131
+ parser.add_argument("--timeout", type=int, default=90)
132
+ parser.add_argument(
133
+ "--workers", "--num-workers", dest="workers", type=int, default=10
134
+ )
135
+ parser.add_argument(
136
+ "--threshold",
137
+ "--trigger-threshold",
138
+ dest="threshold",
139
+ type=float,
140
+ default=0.5,
141
+ help="Trigger-rate pass threshold",
142
+ )
143
+ parser.add_argument(
144
+ "--test-frac",
145
+ "--holdout",
146
+ dest="test_frac",
147
+ type=float,
148
+ default=0.4,
149
+ help="Held-out fraction (stratified by class), in [0, 1). 0 disables the "
150
+ "holdout and selects on train; a positive fraction needs >=2 queries per "
151
+ "class and must leave a train and a test member in each.",
152
+ )
153
+ parser.add_argument(
154
+ "--seed",
155
+ type=int,
156
+ default=42,
157
+ help="RNG seed for the stratified train/test split. Fixed by default so a run "
158
+ "is reproducible; echoed in the output JSON so a run reproduces from its own "
159
+ "record. Vary it to check split robustness.",
160
+ )
161
+ parser.add_argument(
162
+ "--select-epsilon",
163
+ type=float,
164
+ default=0.05,
165
+ help="Held-out mean differences within this band count as ties and are "
166
+ "broken by the weakest-model (min) accuracy. 0 = strict mean-only selection.",
167
+ )
168
+ parser.add_argument(
169
+ "--max-desc-chars",
170
+ type=int,
171
+ default=1024,
172
+ help="Hard character budget for the description (default 1024). Over-budget "
173
+ "candidates can never be selected, and --write refuses an over-budget winner.",
174
+ )
175
+ parser.add_argument(
176
+ "--disable-plugin",
177
+ action="append",
178
+ default=[],
179
+ help="Disable an installed plugin during eval so it can't out-compete the "
180
+ "injected candidate, e.g. --disable-plugin astral@astral-sh (repeatable)",
181
+ )
182
+ parser.add_argument(
183
+ "--report",
184
+ default="auto",
185
+ help="HTML report: 'auto' (temp file, opened in a browser), 'none' to disable, "
186
+ "or an explicit output path.",
187
+ )
188
+ parser.add_argument(
189
+ "--dry-run",
190
+ action="store_true",
191
+ help="Validate inputs (eval set, skill, holdout split, claude availability) and "
192
+ "print the run plan as JSON to stdout (with an estimated claude -p call count), "
193
+ "then exit without spending any tokens or writing artifacts.",
194
+ )
195
+ parser.add_argument(
196
+ "--results-dir",
197
+ default=None,
198
+ help="Save results.json, report.html, and logs/ under a timestamped "
199
+ "subdirectory here. Mutually exclusive with --out.",
200
+ )
201
+ parser.add_argument(
202
+ "--write",
203
+ action="store_true",
204
+ help="Write the best description back into SKILL.md (backs up to SKILL.md.bak)",
205
+ )
206
+ parser.add_argument(
207
+ "--verbose",
208
+ action="store_true",
209
+ help="Enable the detailed per-model, confusion-matrix, and per-query summaries "
210
+ "(the compact one-line summaries are used otherwise)",
211
+ )
212
+ return parser
213
+
214
+
215
+ def _unwrap_eval_root(data: Any) -> list[Any]:
216
+ """Return the query list from a bare list or a single-key wrapper object.
217
+
218
+ A wrapper may carry unrelated metadata, which is ignored, but it must contain
219
+ exactly one of the recognized ``queries`` / ``evals`` keys with a list value.
220
+
221
+ Args:
222
+ data: The decoded JSON root.
223
+
224
+ Returns:
225
+ The (still unvalidated) list of query items.
226
+
227
+ Raises:
228
+ ValueError: With an ``Invalid eval set: ...`` message if the root is neither a
229
+ list nor an object, has neither or both recognized wrapper keys, or the
230
+ recognized wrapper value is not a list.
231
+ """
232
+ if isinstance(data, list):
233
+ return cast("list[Any]", data)
234
+ if isinstance(data, dict):
235
+ mapping = cast("dict[str, Any]", data)
236
+ present = [key for key in ("queries", "evals") if key in mapping]
237
+ if len(present) != 1:
238
+ raise ValueError(
239
+ "Invalid eval set: wrapper must contain exactly one of "
240
+ "'queries' or 'evals'"
241
+ )
242
+ key = present[0]
243
+ value = mapping[key]
244
+ if not isinstance(value, list):
245
+ raise ValueError(f"Invalid eval set: '{key}' must be a list")
246
+ return cast("list[Any]", value)
247
+ raise ValueError("Invalid eval set: root must be a list or wrapper object")
248
+
249
+
250
+ def _validate_eval_item(index: int, item: Any) -> None:
251
+ """Validate one eval item's shape and field runtime types.
252
+
253
+ Requires ``query`` to be a ``str`` and ``should_trigger`` to be exactly a ``bool``
254
+ (integers and truthy values are not coerced). Extra keys are permitted.
255
+
256
+ Args:
257
+ index: Zero-based position of the item, used in the error message.
258
+ item: The decoded item to validate.
259
+
260
+ Raises:
261
+ ValueError: With an ``Invalid eval set: item <index> ...`` message if the item
262
+ is not an object or a required field is missing or of the wrong type.
263
+ """
264
+ if not isinstance(item, dict):
265
+ raise ValueError(f"Invalid eval set: item {index} must be an object")
266
+ mapping = cast("dict[str, Any]", item)
267
+ if not isinstance(mapping.get("query"), str):
268
+ raise ValueError(
269
+ f"Invalid eval set: item {index} field 'query' must be a string"
270
+ )
271
+ if not isinstance(mapping.get("should_trigger"), bool):
272
+ raise ValueError(
273
+ f"Invalid eval set: item {index} field 'should_trigger' must be a boolean"
274
+ )
275
+
276
+
277
+ def _load_eval_set(path: Path) -> list[EvalQuery]:
278
+ """Load and validate the eval set, tolerating recognized wrappers and metadata.
279
+
280
+ Accepts a bare JSON list, or an object with exactly one of ``queries`` / ``evals``
281
+ (a list value; unrelated metadata is ignored). Every item must be an object with a
282
+ string ``query`` and a boolean ``should_trigger``; item order, duplicate queries,
283
+ and extra item keys are preserved. A read error (a missing or unreadable file) and
284
+ invalid JSON are both mapped to a friendly ``Invalid eval set: ...`` message so a
285
+ stdout-parsing caller fails legibly instead of on a raw traceback.
286
+
287
+ Args:
288
+ path: Path to the eval-set JSON file.
289
+
290
+ Returns:
291
+ The validated list of evaluation queries.
292
+
293
+ Raises:
294
+ ValueError: With an ``Invalid eval set: ...`` message when the file cannot be
295
+ read, on invalid JSON, a bad root/wrapper shape, an empty list, or any item
296
+ that violates the contract.
297
+ """
298
+ try:
299
+ text = path.read_text()
300
+ except OSError as exc:
301
+ reason = exc.strerror or exc.__class__.__name__
302
+ raise ValueError(f"Invalid eval set: cannot read {path}: {reason}") from exc
303
+ try:
304
+ data: Any = json.loads(text)
305
+ except json.JSONDecodeError as exc:
306
+ raise ValueError("Invalid eval set: invalid JSON") from exc
307
+ except RecursionError as exc:
308
+ # A pathologically nested eval file overflows json's C-stack recursion guard,
309
+ # surfacing as RecursionError (a RuntimeError, not JSONDecodeError). Map it to
310
+ # the same friendly message so the precondition contract holds (fail legibly,
311
+ # never a mid-run traceback). A separate clause -- not
312
+ # ``except (json.JSONDecodeError, RecursionError)`` -- because ruff-format
313
+ # rewrites that tuple into the invalid Py2 ``except A, B:`` syntax.
314
+ raise ValueError("Invalid eval set: invalid JSON") from exc
315
+ items = _unwrap_eval_root(data)
316
+ if not items:
317
+ raise ValueError("Invalid eval set: must contain at least one query")
318
+ for index, item in enumerate(items):
319
+ _validate_eval_item(index, item)
320
+ return cast("list[EvalQuery]", items)
321
+
322
+
323
+ @dataclass(frozen=True, slots=True)
324
+ class _LoopInputs:
325
+ """Static inputs to the improvement loop.
326
+
327
+ Attributes:
328
+ name: Skill name.
329
+ body: Skill body, passed to the improver for context.
330
+ config: Shared evaluation settings.
331
+ improver_model: Resolved improver model id.
332
+ effort: Improver reasoning effort, or ``None``.
333
+ iterations: Maximum improve->re-eval rounds.
334
+ timeout: Per-run eval timeout, in seconds.
335
+ select_epsilon: Held-out tie-break band.
336
+ out: Directory for per-iteration artifacts.
337
+ verbose: Whether to emit the confusion/per-model verbose summaries.
338
+ max_desc_chars: Hard character budget for the description.
339
+ """
340
+
341
+ name: str
342
+ body: str
343
+ config: EvalConfig
344
+ improver_model: str
345
+ effort: str | None
346
+ iterations: int
347
+ timeout: int
348
+ select_epsilon: float
349
+ out: Path
350
+ verbose: bool = False
351
+ max_desc_chars: int = 1024
352
+
353
+
354
+ @dataclass(frozen=True, slots=True)
355
+ class _LoopResult:
356
+ """Outcome of the improvement loop.
357
+
358
+ Attributes:
359
+ best_desc: The selected best description.
360
+ best_test: Its held-out mean accuracy, or ``None`` when the holdout is disabled.
361
+ best_test_min: Its held-out weakest-model accuracy, or ``None`` with no holdout.
362
+ history: Per-stage records (baseline plus each iteration), each with ``is_best``.
363
+ best_train: Training-set view of the selected best description.
364
+ best_test_eval: Held-out view of the selected best description (an empty,
365
+ iterable-compatible result when the holdout is disabled).
366
+ exit_reason: Why the loop stopped (``all_passed``/``max_iterations`` with the
367
+ iteration number).
368
+ iterations_run: Number of proposal slots entered (past the early-exit check),
369
+ whether they produced a candidate or exhausted their retries.
370
+ final_description: The last candidate evaluated (baseline if none).
371
+ improver_failed_iterations: Bounded ledger of slots whose two outer improver
372
+ attempts both failed retryably; empty when none did.
373
+ """
374
+
375
+ best_desc: str
376
+ best_test: float | None
377
+ best_test_min: float | None
378
+ history: list[dict[str, Any]]
379
+ best_train: EvalResult
380
+ best_test_eval: EvalResult
381
+ exit_reason: str
382
+ iterations_run: int
383
+ final_description: str
384
+ improver_failed_iterations: list[dict[str, Any]]
385
+
386
+
387
+ @dataclass(frozen=True, slots=True)
388
+ class _RetryExhausted(Exception):
389
+ """Both outer improver attempts for one slot failed retryably.
390
+
391
+ An internal signal (never surfaced publicly): it carries the exact two typed
392
+ retryable errors, distinguishing expected exhaustion from a missing or fatal
393
+ outcome so exhaustion never masquerades as terminal.
394
+
395
+ Attributes:
396
+ errors: The two retryable errors, in attempt order.
397
+ """
398
+
399
+ errors: tuple[ImproverRetryableError, ImproverRetryableError]
400
+
401
+
402
+ def _improver_failure_record(
403
+ iteration: int, exhausted: _RetryExhausted
404
+ ) -> dict[str, Any]:
405
+ """Build one public ``improver_failed_iterations`` record from an exhaustion.
406
+
407
+ Every field is derived from validated retryable errors (allowlisted kinds and
408
+ messages), so no raw diagnostic can reach this public record.
409
+
410
+ Args:
411
+ iteration: 1-based slot number that exhausted its retries.
412
+ exhausted: The exhaustion signal carrying the two typed errors.
413
+
414
+ Returns:
415
+ A record with ``iteration``, ``attempt_count`` (always 2), and an ``errors``
416
+ list of ``{attempt, kind, message}`` in ascending attempt order.
417
+ """
418
+ return {
419
+ "iteration": iteration,
420
+ "attempt_count": 2,
421
+ "errors": [
422
+ {
423
+ "attempt": 1,
424
+ "kind": exhausted.errors[0].kind,
425
+ "message": exhausted.errors[0].message,
426
+ },
427
+ {
428
+ "attempt": 2,
429
+ "kind": exhausted.errors[1].kind,
430
+ "message": exhausted.errors[1].message,
431
+ },
432
+ ],
433
+ }
434
+
435
+
436
+ def _split_counts(per_query: list[PerQuery]) -> tuple[int, int, int, int]:
437
+ """Count passed / failed / judged-total / unjudged queries (tri-state aware).
438
+
439
+ Args:
440
+ per_query: The per-query roll-ups to count.
441
+
442
+ Returns:
443
+ ``(passed, failed, judged_total, unjudged)`` where ``judged_total`` excludes
444
+ unjudged (``all_pass is None``) queries.
445
+ """
446
+ passed = sum(pq["all_pass"] is True for pq in per_query)
447
+ failed = sum(pq["all_pass"] is False for pq in per_query)
448
+ unjudged = sum(pq["all_pass"] is None for pq in per_query)
449
+ return passed, failed, passed + failed, unjudged
450
+
451
+
452
+ def _score_str(per_query: list[PerQuery]) -> str:
453
+ """Render a ``"k/N"`` query-pass score with a judged-query denominator.
454
+
455
+ Args:
456
+ per_query: The per-query roll-ups to score.
457
+
458
+ Returns:
459
+ ``"k/N"`` (judged-and-passed over judged), with ``" (+u unjudged)"`` appended
460
+ when any query was unjudged, or ``"n/a"`` when no query was judged.
461
+ """
462
+ passed, _, total, unjudged = _split_counts(per_query)
463
+ if total == 0:
464
+ return "n/a"
465
+ suffix = f" (+{unjudged} unjudged)" if unjudged else ""
466
+ return f"{passed}/{total}{suffix}"
467
+
468
+
469
+ def _result_entries(ev: EvalResult) -> list[dict[str, Any]]:
470
+ """Flatten an eval result's per-query rows into report/history result entries.
471
+
472
+ Each entry is keyed on the original positional ``index`` (dedup-safe) and sums
473
+ ``triggers``/``runs``/``errors`` across models while keeping a per-model breakdown.
474
+
475
+ Args:
476
+ ev: The evaluation result to flatten.
477
+
478
+ Returns:
479
+ One entry per query: ``{index, query, should_trigger, triggers, runs, errors,
480
+ pass, models}``.
481
+ """
482
+ entries: list[dict[str, Any]] = []
483
+ for pq in ev["per_query"]:
484
+ models = pq["models"]
485
+ entries.append(
486
+ {
487
+ "index": pq["index"],
488
+ "query": pq["query"],
489
+ "should_trigger": pq["should_trigger"],
490
+ "triggers": sum(models[m]["triggers"] for m in models),
491
+ "runs": sum(models[m]["runs"] for m in models),
492
+ "errors": sum(models[m]["errors"] for m in models),
493
+ "pass": pq["all_pass"],
494
+ "models": {
495
+ m: {"triggers": models[m]["triggers"], "runs": models[m]["runs"]}
496
+ for m in models
497
+ },
498
+ }
499
+ )
500
+ return entries
501
+
502
+
503
+ def _history_entry(
504
+ iteration: int,
505
+ description: str,
506
+ rationale: str,
507
+ full_mean: float,
508
+ train_ev: EvalResult,
509
+ test_ev: EvalResult,
510
+ has_holdout: bool,
511
+ ) -> dict[str, Any]:
512
+ """Build one history record (baseline or an iteration) for the envelope + report.
513
+
514
+ With no holdout, every held-out *measurement* field is ``None`` and ``test_results``
515
+ is an empty list (the structural collection is retained so report/consumers stay
516
+ iterable); ``test_ev`` is ignored in that case.
517
+
518
+ Args:
519
+ iteration: 0 for the baseline, 1..N for improve iterations.
520
+ description: The description this record scored.
521
+ rationale: The improver rationale (empty for the baseline).
522
+ full_mean: Full-set mean accuracy for this description.
523
+ train_ev: Training-set view of this description.
524
+ test_ev: Held-out view of this description (used only when ``has_holdout``).
525
+ has_holdout: Whether a held-out set exists; ``False`` nulls the test metrics.
526
+
527
+ Returns:
528
+ The history entry, with ``is_best`` defaulted to ``False`` (set post-loop).
529
+ """
530
+ tp, tf, tt, tu = _split_counts(train_ev["per_query"])
531
+ if has_holdout:
532
+ ep, ef, et, eu = _split_counts(test_ev["per_query"])
533
+ test_mean: float | None = test_ev["mean_accuracy"]
534
+ test_min: float | None = test_ev["min_accuracy"]
535
+ test_passed: int | None = ep
536
+ test_failed: int | None = ef
537
+ test_total: int | None = et
538
+ test_unjudged: int | None = eu
539
+ test_score: str | None = _score_str(test_ev["per_query"])
540
+ test_results = _result_entries(test_ev)
541
+ else:
542
+ test_mean = test_min = None
543
+ test_passed = test_failed = test_total = test_unjudged = None
544
+ test_score = None
545
+ test_results = []
546
+ return {
547
+ "stage": "baseline" if iteration == 0 else f"iter{iteration}",
548
+ "iteration": iteration,
549
+ "description": description,
550
+ "rationale": rationale,
551
+ "chars": len(description),
552
+ "full_mean": full_mean,
553
+ "test_mean": test_mean,
554
+ "test_min": test_min,
555
+ "train_passed": tp,
556
+ "train_failed": tf,
557
+ "train_total": tt,
558
+ "train_unjudged": tu,
559
+ "test_passed": test_passed,
560
+ "test_failed": test_failed,
561
+ "test_total": test_total,
562
+ "test_unjudged": test_unjudged,
563
+ "train_score": _score_str(train_ev["per_query"]),
564
+ "test_score": test_score,
565
+ "train_results": _result_entries(train_ev),
566
+ "test_results": test_results,
567
+ "is_best": False,
568
+ }
569
+
570
+
571
+ def _call_improver_with_retry(
572
+ inputs: _LoopInputs, prompt: str, it: int, budget: _LaunchBudget
573
+ ) -> dict[str, Any]:
574
+ """Call the improver for one slot, retrying once on a typed retryable failure.
575
+
576
+ Makes at most two outer attempts, each writing a distinct transcript. A fatal or
577
+ unclassified exception from :func:`call_improver` propagates immediately.
578
+
579
+ Args:
580
+ inputs: Static loop inputs.
581
+ prompt: The improver prompt.
582
+ it: 1-based slot number, used for the transcript filenames.
583
+ budget: Shared launch budget threaded to every child spawn.
584
+
585
+ Returns:
586
+ The accepted proposal object.
587
+
588
+ Raises:
589
+ _RetryExhausted: When both outer attempts fail with typed retryable errors.
590
+ """
591
+ log_names = (f"iter{it}_improve.json", f"iter{it}_improve_retry.json")
592
+ errors: list[ImproverRetryableError] = []
593
+ for attempt in (1, 2):
594
+ try:
595
+ return call_improver(
596
+ prompt,
597
+ inputs.improver_model,
598
+ inputs.effort,
599
+ max(inputs.timeout * 4, 300),
600
+ max_chars=inputs.max_desc_chars,
601
+ log_path=inputs.out / log_names[attempt - 1],
602
+ budget=budget,
603
+ )
604
+ except ImproverRetryableError as exc:
605
+ errors.append(exc)
606
+ if attempt == 1:
607
+ logger.warning("Improver retryable attempt 1 failed (%s).", exc.kind)
608
+ raise _RetryExhausted((errors[0], errors[1]))
609
+
610
+
611
+ def _propose_and_score(
612
+ inputs: _LoopInputs,
613
+ best_desc: str,
614
+ train_eval: EvalResult,
615
+ prior_attempts: Sequence[ImproverAttempt],
616
+ queries: list[EvalQuery],
617
+ train_idx: list[int],
618
+ test_idx: list[int],
619
+ it: int,
620
+ budget: _LaunchBudget,
621
+ ) -> tuple[str, dict[str, Any], EvalResult, EvalResult, EvalResult]:
622
+ """Propose a new description and score it, evaluating the full set exactly once.
623
+
624
+ The candidate is evaluated once over the full eval set; the train and held-out
625
+ views are then sliced from that single result with :func:`subset_result`, halving
626
+ the ``claude -p`` call count versus separate train/test/full evaluations.
627
+
628
+ Args:
629
+ inputs: Static loop inputs.
630
+ best_desc: The current best description to improve from.
631
+ train_eval: Training-set view driving the improver.
632
+ prior_attempts: Descriptions already attempted, with train-only results, so the
633
+ improver avoids repeating them (held-out results are never included).
634
+ queries: The full eval set.
635
+ train_idx: Positional indices of the training queries.
636
+ test_idx: Positional indices of the held-out queries.
637
+ it: 1-based iteration number, used for artifact filenames.
638
+ budget: Shared launch budget threaded to every improver child spawn.
639
+
640
+ Returns:
641
+ ``(candidate, proposal, full_eval, train_eval, test_eval)``.
642
+
643
+ Raises:
644
+ _RetryExhausted: When both outer improver attempts fail retryably.
645
+ """
646
+ prompt = build_improver_prompt(
647
+ inputs.name, best_desc, inputs.body, train_eval, prior_attempts
648
+ )
649
+ (inputs.out / f"iter{it}_prompt.txt").write_text(prompt)
650
+ proposal = _call_improver_with_retry(inputs, prompt, it, budget)
651
+ cand = str(proposal["description"]).strip()
652
+ (inputs.out / f"iter{it}_proposal.json").write_text(json.dumps(proposal, indent=2))
653
+ logger.info(" rationale: %s", proposal.get("rationale", ""))
654
+ cand_full = evaluate(queries, inputs.name, cand, inputs.config, verbose=False)
655
+ cand_train = subset_result(cand_full, queries, train_idx, inputs.config.models)
656
+ cand_test = subset_result(cand_full, queries, test_idx, inputs.config.models)
657
+ (inputs.out / f"iter{it}_eval.json").write_text(json.dumps(cand_full, indent=2))
658
+ label = f"ITER {it} (full)"
659
+ if inputs.verbose:
660
+ logger.info("%s", summarize_verbose(label, cand_full))
661
+ else:
662
+ logger.info("%s", summarize(label, cand_full))
663
+ return cand, proposal, cand_full, cand_train, cand_test
664
+
665
+
666
+ def _candidate_wins(
667
+ cand: str,
668
+ cand_selection: EvalResult,
669
+ best_desc: str,
670
+ best_selection: EvalResult,
671
+ inputs: _LoopInputs,
672
+ score_label: str,
673
+ ) -> tuple[bool, str]:
674
+ """Decide whether a scored candidate should replace the incumbent.
675
+
676
+ Applies the selection pre-condition (both the candidate's and the incumbent's
677
+ selection views must be usable) before the mean/min/char-budget comparison. The
678
+ selection view is the held-out result with a holdout, or the training result when
679
+ the holdout is disabled; ``score_label`` names it in the reason strings.
680
+
681
+ Args:
682
+ cand: The candidate description.
683
+ cand_selection: The candidate's selection-view evaluation.
684
+ best_desc: The incumbent best description.
685
+ best_selection: The incumbent's selection-view evaluation.
686
+ inputs: Static loop inputs (epsilon, char budget).
687
+ score_label: ``"held-out"`` with a holdout, ``"train"`` when it is disabled.
688
+
689
+ Returns:
690
+ ``(win, reason)`` from :func:`is_better_candidate`, or ``(False, ...)`` when the
691
+ selection view is unusable for selection.
692
+ """
693
+ if not cand_selection["score_valid"] or not best_selection["score_valid"]:
694
+ return False, f"{score_label} set unusable for selection"
695
+ return is_better_candidate(
696
+ cand_selection["mean_accuracy"],
697
+ cand_selection["min_accuracy"],
698
+ best_selection["mean_accuracy"],
699
+ best_selection["min_accuracy"],
700
+ inputs.select_epsilon,
701
+ cand_chars=len(cand),
702
+ best_chars=len(best_desc),
703
+ max_chars=inputs.max_desc_chars,
704
+ score_label=score_label,
705
+ )
706
+
707
+
708
+ @dataclass(slots=True)
709
+ class _BestState:
710
+ """Mutable running-best across the optimization loop.
711
+
712
+ Attributes:
713
+ desc: The current best description.
714
+ full: Its full-set evaluation (sliced each iteration to drive the improver).
715
+ train: Its training-set view.
716
+ test_eval: Its held-out view (an empty result when the holdout is disabled).
717
+ selection: Its selection view (held-out with a holdout, else train).
718
+ history_idx: Index of its history entry (the row flagged ``is_best``).
719
+ """
720
+
721
+ desc: str
722
+ full: EvalResult
723
+ train: EvalResult
724
+ test_eval: EvalResult
725
+ selection: EvalResult
726
+ history_idx: int
727
+
728
+
729
+ @dataclass(frozen=True, slots=True)
730
+ class _LoopContext:
731
+ """Immutable per-run context shared across the improve loop.
732
+
733
+ Attributes:
734
+ inputs: Static loop inputs (models, improver, iteration budget, ...).
735
+ queries: The full eval set.
736
+ train_idx: Positional indices of the training queries.
737
+ test_idx: Positional indices of the held-out queries.
738
+ has_holdout: Whether a held-out set exists.
739
+ score_label: ``"held-out"`` with a holdout, ``"train"`` otherwise.
740
+ budget: Shared improver launch budget.
741
+ emit: Optional live-report callback, or ``None``.
742
+ """
743
+
744
+ inputs: _LoopInputs
745
+ queries: list[EvalQuery]
746
+ train_idx: list[int]
747
+ test_idx: list[int]
748
+ has_holdout: bool
749
+ score_label: str
750
+ budget: _LaunchBudget
751
+ emit: Callable[[list[dict[str, Any]], int, int, list[dict[str, Any]]], None] | None
752
+
753
+
754
+ def _log_selection_mean(
755
+ has_holdout: bool, cand_selection: EvalResult, best_selection: EvalResult
756
+ ) -> None:
757
+ """Log the candidate's selection mean against the incumbent, per selection view.
758
+
759
+ Chooses the label so the optional held-out float is never formatted for a
760
+ no-holdout run.
761
+
762
+ Args:
763
+ has_holdout: Whether a held-out set exists.
764
+ cand_selection: The candidate's selection-view evaluation.
765
+ best_selection: The incumbent's selection-view evaluation.
766
+ """
767
+ label = "held-out test mean" if has_holdout else "train mean"
768
+ logger.info(
769
+ " %s: %.3f (best so far %.3f)",
770
+ label,
771
+ cand_selection["mean_accuracy"],
772
+ best_selection["mean_accuracy"],
773
+ )
774
+
775
+
776
+ def _consider_candidate(
777
+ scored: tuple[str, dict[str, Any], EvalResult, EvalResult, EvalResult],
778
+ it: int,
779
+ ctx: _LoopContext,
780
+ best: _BestState,
781
+ history: list[dict[str, Any]],
782
+ attempts: list[ImproverAttempt],
783
+ ) -> str:
784
+ """Record a scored candidate in history and apply the selection decision.
785
+
786
+ Appends the candidate's train-only attempt record and its history entry, then
787
+ updates ``best`` in place when the candidate wins.
788
+
789
+ Args:
790
+ scored: ``(candidate, proposal, full_eval, train_eval, test_eval)``.
791
+ it: 1-based iteration number.
792
+ ctx: The shared per-run loop context.
793
+ best: The running-best state, mutated in place on a win.
794
+ history: The history list, appended to.
795
+ attempts: The prior-attempt list, appended to.
796
+
797
+ Returns:
798
+ The candidate description (the run's new ``final_description``).
799
+ """
800
+ cand, proposal, cand_full, cand_train, cand_test = scored
801
+ attempts.append({"description": cand, "train_results": cand_train["per_query"]})
802
+ cand_selection = cand_test if ctx.has_holdout else cand_train
803
+ _log_selection_mean(ctx.has_holdout, cand_selection, best.selection)
804
+ history.append(
805
+ _history_entry(
806
+ it,
807
+ cand,
808
+ proposal.get("rationale", ""),
809
+ cand_full["mean_accuracy"],
810
+ cand_train,
811
+ cand_test,
812
+ ctx.has_holdout,
813
+ )
814
+ )
815
+ win, reason = _candidate_wins(
816
+ cand, cand_selection, best.desc, best.selection, ctx.inputs, ctx.score_label
817
+ )
818
+ if win:
819
+ best.desc = cand
820
+ best.full = cand_full
821
+ best.train = cand_train
822
+ best.test_eval = cand_test
823
+ best.selection = cand_selection
824
+ best.history_idx = len(history) - 1
825
+ logger.info(" -> new best (%s)", reason)
826
+ else:
827
+ logger.info(" -> rejected (%s)", reason)
828
+ return cand
829
+
830
+
831
+ def _run_improve_loop(
832
+ ctx: _LoopContext,
833
+ best: _BestState,
834
+ history: list[dict[str, Any]],
835
+ attempts: list[ImproverAttempt],
836
+ improver_failed: list[dict[str, Any]],
837
+ ) -> tuple[str, int, str]:
838
+ """Run the improve iterations, mutating ``best``/``history``/``attempts``/ledger.
839
+
840
+ Each slot gets at most two outer improver attempts; a slot whose retries both fail
841
+ retryably records one bounded entry and the loop continues, while a fatal improver
842
+ error propagates.
843
+
844
+ Args:
845
+ ctx: The shared per-run loop context.
846
+ best: The running-best state, mutated in place.
847
+ history: The history list, appended to.
848
+ attempts: The prior-attempt list, appended to.
849
+ improver_failed: The bounded failure ledger, appended to on exhaustion.
850
+
851
+ Returns:
852
+ ``(exit_reason, iterations_run, final_description)`` where ``iterations_run`` is
853
+ the number of entered slots and ``final_description`` is the last candidate that
854
+ was successfully proposed and scored (the baseline if none was).
855
+ """
856
+ models = ctx.inputs.config.models
857
+ exit_reason = f"max_iterations ({ctx.inputs.iterations})"
858
+ iterations_run = 0
859
+ final_description = best.desc
860
+ for it in range(1, ctx.inputs.iterations + 1):
861
+ logger.info(
862
+ "\n=== Iteration %d: improver=%s effort=%s ===",
863
+ it,
864
+ ctx.inputs.improver_model,
865
+ ctx.inputs.effort,
866
+ )
867
+ train_eval = subset_result(best.full, ctx.queries, ctx.train_idx, models)
868
+ if train_eval["per_query"] and all(
869
+ pq["all_pass"] is True for pq in train_eval["per_query"]
870
+ ):
871
+ exit_reason = f"all_passed (iteration {it})"
872
+ logger.info(
873
+ " all %d train queries pass; stopping early.",
874
+ len(train_eval["per_query"]),
875
+ )
876
+ break
877
+ # This slot is entered: count it before the first outer attempt, so the live
878
+ # count and the failure ledger stay consistent even when a slot is skipped.
879
+ iterations_run = it
880
+ try:
881
+ scored = _propose_and_score(
882
+ ctx.inputs,
883
+ best.desc,
884
+ train_eval,
885
+ attempts,
886
+ ctx.queries,
887
+ ctx.train_idx,
888
+ ctx.test_idx,
889
+ it,
890
+ ctx.budget,
891
+ )
892
+ except _RetryExhausted as exhausted:
893
+ # Preserve the last verified candidate and all prior state; record one
894
+ # bounded public entry, refresh live state, and continue later slots.
895
+ improver_failed.append(_improver_failure_record(it, exhausted))
896
+ logger.warning(
897
+ "Improver retry attempts exhausted for iteration %d; continuing.", it
898
+ )
899
+ if ctx.emit is not None:
900
+ ctx.emit(history, best.history_idx, iterations_run, improver_failed)
901
+ continue
902
+ final_description = _consider_candidate(
903
+ scored, it, ctx, best, history, attempts
904
+ )
905
+ if ctx.emit is not None:
906
+ ctx.emit(history, best.history_idx, iterations_run, improver_failed)
907
+ return exit_reason, iterations_run, final_description
908
+
909
+
910
+ def _optimize(
911
+ inputs: _LoopInputs,
912
+ train_idx: list[int],
913
+ test_idx: list[int],
914
+ queries: list[EvalQuery],
915
+ base_desc: str,
916
+ base_full: EvalResult,
917
+ emit: Callable[[list[dict[str, Any]], int, int, list[dict[str, Any]]], None]
918
+ | None = None,
919
+ ) -> _LoopResult:
920
+ """Run the improve->re-eval loop, selecting the best description by held-out score.
921
+
922
+ Each description is evaluated once over the full set; the train view (used to drive
923
+ the improver) and the held-out view (used for selection) are sliced from the
924
+ current best's full result, so no set is re-evaluated redundantly. The iteration
925
+ budget is validated first, and a shared 200-launch budget bounds all improver
926
+ children before any slot runs.
927
+
928
+ Args:
929
+ inputs: Static loop inputs (models, improver, iteration budget, ...).
930
+ train_idx: Positional indices of the training queries.
931
+ test_idx: Positional indices of the held-out queries.
932
+ queries: The full eval set.
933
+ base_desc: The starting description.
934
+ base_full: Full-set evaluation of ``base_desc``.
935
+ emit: Optional callback ``(history, best_history_idx, iterations_run,
936
+ improver_failed_iterations)`` invoked after every scored candidate and every
937
+ exhausted slot to refresh a live report.
938
+
939
+ Returns:
940
+ The best description found and its held-out scores, plus per-stage history and
941
+ the bounded improver-failure ledger.
942
+ """
943
+ _validate_iterations(inputs.iterations)
944
+ models = inputs.config.models
945
+ has_holdout = bool(test_idx)
946
+ base_test = subset_result(base_full, queries, test_idx, models)
947
+ base_train = subset_result(base_full, queries, train_idx, models)
948
+ # Selection view: the held-out result with a holdout, else the training result.
949
+ best = _BestState(
950
+ desc=base_desc,
951
+ full=base_full,
952
+ train=base_train,
953
+ test_eval=base_test,
954
+ selection=base_test if has_holdout else base_train,
955
+ history_idx=0,
956
+ )
957
+ history: list[dict[str, Any]] = [
958
+ _history_entry(
959
+ 0,
960
+ base_desc,
961
+ "",
962
+ base_full["mean_accuracy"],
963
+ base_train,
964
+ base_test,
965
+ has_holdout,
966
+ )
967
+ ]
968
+ # Train-only attempt records (blinding: held-out results never reach the improver).
969
+ attempts: list[ImproverAttempt] = [
970
+ {"description": base_desc, "train_results": base_train["per_query"]}
971
+ ]
972
+ improver_failed: list[dict[str, Any]] = []
973
+ ctx = _LoopContext(
974
+ inputs=inputs,
975
+ queries=queries,
976
+ train_idx=train_idx,
977
+ test_idx=test_idx,
978
+ has_holdout=has_holdout,
979
+ score_label="held-out" if has_holdout else "train",
980
+ # 50 slots * 2 outer attempts * 2 children (initial + shortening) = 200. Shared
981
+ # across the whole run so no 201st improver child can start.
982
+ budget=_LaunchBudget(MAX_ITERATIONS * 2 * 2),
983
+ emit=emit,
984
+ )
985
+ exit_reason, iterations_run, final_description = _run_improve_loop(
986
+ ctx, best, history, attempts, improver_failed
987
+ )
988
+
989
+ for i, entry in enumerate(history):
990
+ entry["is_best"] = i == best.history_idx
991
+
992
+ best_test = best.test_eval["mean_accuracy"] if has_holdout else None
993
+ best_test_min = best.test_eval["min_accuracy"] if has_holdout else None
994
+ return _LoopResult(
995
+ best.desc,
996
+ best_test,
997
+ best_test_min,
998
+ history,
999
+ best.train,
1000
+ best.test_eval,
1001
+ exit_reason,
1002
+ iterations_run,
1003
+ final_description,
1004
+ improver_failed,
1005
+ )
1006
+
1007
+
1008
+ def _resolve_config(args: argparse.Namespace) -> tuple[str, EvalConfig]:
1009
+ """Resolve the improver model and the shared :class:`EvalConfig` from CLI args.
1010
+
1011
+ Args:
1012
+ args: Parsed CLI arguments.
1013
+
1014
+ Returns:
1015
+ ``(improver_model, config)``.
1016
+ """
1017
+ eval_spec: str = args.models or args.model or "sonnet"
1018
+ improver_spec: str = args.improver_model or args.model or "opus"
1019
+ improver_model = MODEL_ALIASES.get(improver_spec, improver_spec)
1020
+ settings_json = (
1021
+ json.dumps({"enabledPlugins": {p: False for p in args.disable_plugin}})
1022
+ if args.disable_plugin
1023
+ else None
1024
+ )
1025
+ config = EvalConfig(
1026
+ models=tuple(resolve_models(eval_spec)),
1027
+ repeats=args.repeats,
1028
+ timeout=args.timeout,
1029
+ workers=args.workers,
1030
+ threshold=args.threshold,
1031
+ settings_json=settings_json,
1032
+ )
1033
+ return improver_model, config
1034
+
1035
+
1036
+ def _build_plan(
1037
+ name: str,
1038
+ queries: list[EvalQuery],
1039
+ train_idx: list[int],
1040
+ test_idx: list[int],
1041
+ config: EvalConfig,
1042
+ iterations: int,
1043
+ improver_model: str,
1044
+ effort: str | None,
1045
+ holdout: float,
1046
+ seed: int,
1047
+ ) -> dict[str, Any]:
1048
+ """Build the machine-readable run plan (also the ``--dry-run`` payload).
1049
+
1050
+ ``estimated_eval_calls`` is an upper bound: it assumes every improve iteration runs
1051
+ (the loop exits early once all train queries pass) and counts the baseline plus one
1052
+ full-set evaluation per iteration. ``estimated_improver_calls`` is the typical one
1053
+ call per iteration (a call may retry, bounded by the launch budget). A consumer can
1054
+ read ``estimated_claude_calls`` to budget a run before spending any tokens.
1055
+
1056
+ Args:
1057
+ name: Skill name.
1058
+ queries: The full eval set.
1059
+ train_idx: Positional indices of the training queries.
1060
+ test_idx: Positional indices of the held-out queries.
1061
+ config: Resolved evaluation settings (models, repeats, threshold, ...).
1062
+ iterations: Maximum improve->re-eval rounds.
1063
+ improver_model: Resolved improver model id.
1064
+ effort: Improver reasoning effort, or ``None``.
1065
+ holdout: The held-out fraction (``--test-frac``).
1066
+ seed: Split RNG seed.
1067
+
1068
+ Returns:
1069
+ The plan dict: a resolved-config echo plus ``estimated_eval_calls`` /
1070
+ ``estimated_improver_calls`` / ``estimated_claude_calls``.
1071
+ """
1072
+ n = len(queries)
1073
+ eval_calls = n * len(config.models) * config.repeats * (iterations + 1)
1074
+ return {
1075
+ "skill": name,
1076
+ "queries": n,
1077
+ "train_size": len(train_idx),
1078
+ "test_size": len(test_idx),
1079
+ "holdout": holdout,
1080
+ "seed": seed,
1081
+ "models": list(config.models),
1082
+ "improver_model": improver_model,
1083
+ "improver_effort": effort,
1084
+ "repeats": config.repeats,
1085
+ "iterations": iterations,
1086
+ "threshold": config.threshold,
1087
+ "estimated_eval_calls": eval_calls,
1088
+ "estimated_improver_calls": iterations,
1089
+ "estimated_claude_calls": eval_calls + iterations,
1090
+ }
1091
+
1092
+
1093
+ def _plan_summary(plan: dict[str, Any]) -> str:
1094
+ """Render a one-line stderr summary of a run plan.
1095
+
1096
+ Args:
1097
+ plan: A plan dict from :func:`_build_plan`.
1098
+
1099
+ Returns:
1100
+ A compact ``Plan: ...`` line naming the estimate, split sizes, and seed.
1101
+ """
1102
+ return (
1103
+ f"Plan: {plan['queries']} queries x {len(plan['models'])} model(s) x "
1104
+ f"{plan['repeats']} repeats over <={plan['iterations'] + 1} evals "
1105
+ f"~= {plan['estimated_claude_calls']} claude calls "
1106
+ f"(train={plan['train_size']} test={plan['test_size']} seed={plan['seed']})"
1107
+ )
1108
+
1109
+
1110
+ _PLACEHOLDER_HTML = (
1111
+ "<html><body><h1>Starting optimization loop…</h1>"
1112
+ "<meta http-equiv='refresh' content='5'></body></html>"
1113
+ )
1114
+
1115
+
1116
+ def _report_paths(
1117
+ args: argparse.Namespace, skill_name: str, timestamp: str
1118
+ ) -> tuple[Path, Path | None, Path | None]:
1119
+ """Resolve the artifact dir, results dir, and live-report path from the flags.
1120
+
1121
+ Args:
1122
+ args: Parsed CLI arguments.
1123
+ skill_name: Skill name, used in the auto report filename.
1124
+ timestamp: Run timestamp for the results-dir / auto-report name.
1125
+
1126
+ Returns:
1127
+ ``(out, results_dir, live_report_path)`` where ``out`` is the per-iteration
1128
+ artifact dir (created), ``results_dir`` is the timestamped results dir or
1129
+ ``None``, and ``live_report_path`` is where the HTML report is written or
1130
+ ``None`` (``--report none``). The mutually-exclusive ``--out`` / ``--results-dir``
1131
+ combination is rejected earlier, in :func:`run`'s preflight.
1132
+ """
1133
+ results_dir: Path | None = None
1134
+ if args.results_dir:
1135
+ results_dir = Path(args.results_dir) / timestamp
1136
+ out = results_dir / "logs"
1137
+ elif args.out:
1138
+ out = Path(args.out)
1139
+ else:
1140
+ out = Path(tempfile.mkdtemp(prefix="skilldesc-"))
1141
+ out.mkdir(parents=True, exist_ok=True)
1142
+
1143
+ if args.report == "none":
1144
+ live: Path | None = None
1145
+ elif args.report == "auto":
1146
+ live = (
1147
+ results_dir / "report.html"
1148
+ if results_dir is not None
1149
+ # ``skill_name`` is attacker-controlled SKILL.md frontmatter; sanitize it
1150
+ # before it becomes a filename so a hostile name cannot traverse out of the
1151
+ # temp dir when writing the auto report.
1152
+ else Path(tempfile.gettempdir())
1153
+ / f"skill_description_report_{safe_name_token(skill_name)}_{timestamp}.html"
1154
+ )
1155
+ else:
1156
+ live = Path(args.report)
1157
+ return out, results_dir, live
1158
+
1159
+
1160
+ def _write_placeholder_and_open(live_report_path: Path) -> None:
1161
+ """Write the initial placeholder report and open it in a browser (best-effort).
1162
+
1163
+ The browser open is wrapped so a headless/CI environment (no browser) never aborts
1164
+ the run.
1165
+
1166
+ Args:
1167
+ live_report_path: Where to write the placeholder HTML.
1168
+ """
1169
+ try:
1170
+ live_report_path.parent.mkdir(parents=True, exist_ok=True)
1171
+ live_report_path.write_text(_PLACEHOLDER_HTML)
1172
+ except OSError:
1173
+ logger.debug("could not write placeholder report", exc_info=True)
1174
+ return
1175
+ try:
1176
+ webbrowser.open(str(live_report_path))
1177
+ except Exception: # noqa: BLE001 - no browser in headless/CI; never fatal
1178
+ logger.debug("could not open browser for report", exc_info=True)
1179
+
1180
+
1181
+ def _write_html(path: Path, report: dict[str, Any], name: str, refresh: bool) -> None:
1182
+ """Render and write an HTML report, swallowing write errors (headless/CI safe).
1183
+
1184
+ Args:
1185
+ path: Destination HTML path.
1186
+ report: The report dict passed to :func:`generate_html`.
1187
+ name: Skill name for the report title.
1188
+ refresh: Whether to embed the auto-refresh meta tag.
1189
+ """
1190
+ try:
1191
+ path.parent.mkdir(parents=True, exist_ok=True)
1192
+ path.write_text(generate_html(report, auto_refresh=refresh, skill_name=name))
1193
+ except OSError:
1194
+ logger.debug("could not write HTML report to %s", path, exc_info=True)
1195
+
1196
+
1197
+ def run(args: argparse.Namespace) -> None:
1198
+ """Run the optimization loop described by parsed CLI arguments.
1199
+
1200
+ With ``--dry-run`` the run stops after preflight: it prints the ``{"dry_run": true,
1201
+ ...}`` plan JSON (including ``estimated_claude_calls``) to stdout and returns without
1202
+ evaluating, spending tokens, or writing artifacts.
1203
+
1204
+ Args:
1205
+ args: Parsed arguments from :func:`build_parser`.
1206
+
1207
+ Raises:
1208
+ SystemExit: If no ``SKILL.md`` exists at ``--skill-path``; if both ``--out`` and
1209
+ ``--results-dir`` are supplied; if ``--iterations``, the eval set, or the
1210
+ holdout split is invalid; or if the ``claude`` CLI is not found or not
1211
+ executable.
1212
+ """
1213
+ skill_md = Path(args.skill_path) / "SKILL.md"
1214
+ if not skill_md.exists():
1215
+ raise SystemExit(f"No SKILL.md at {skill_md}")
1216
+ name, base_desc, body = parse_skill_md(skill_md)
1217
+ name = name or Path(args.skill_path).name
1218
+ if args.description:
1219
+ base_desc = args.description
1220
+
1221
+ # Complete input preflight before any config/path/report/browser/evaluator side
1222
+ # effect, so invalid user input is cheap and artifact-free (findings 3, 4, 8).
1223
+ try:
1224
+ _validate_iterations(args.iterations)
1225
+ except ValueError as exc:
1226
+ raise SystemExit(str(exc)) from exc
1227
+ try:
1228
+ queries = _load_eval_set(Path(args.eval_set))
1229
+ except ValueError as exc:
1230
+ raise SystemExit(str(exc)) from exc
1231
+ if args.out and args.results_dir:
1232
+ raise SystemExit("--out and --results-dir are mutually exclusive")
1233
+ try:
1234
+ train_idx, test_idx = stratified_split(queries, args.test_frac, seed=args.seed)
1235
+ except ValueError as exc:
1236
+ raise SystemExit(f"Invalid holdout split: {exc}") from exc
1237
+ # Both loop halves shell out to ``claude -p``; verify the CLI is invocable up front
1238
+ # so a missing/non-executable binary fails as one legible setup error here (like the
1239
+ # SKILL.md and eval-set checks) rather than as a mid-run FileNotFoundError traceback
1240
+ # or a silent all-unjudged "success" when --iterations is 0.
1241
+ if not claude_available():
1242
+ raise SystemExit(
1243
+ f"claude CLI not found or not executable: {claude_bin()!r}. Install it and "
1244
+ "log in, or set SKILL_OPTIMIZER_CLAUDE_BIN to the CLI path."
1245
+ )
1246
+
1247
+ improver_model, config = _resolve_config(args)
1248
+ effort = None if args.improver_effort.lower() == "none" else args.improver_effort
1249
+
1250
+ # Machine-readable run plan: the --dry-run payload and, on a real run, a startup
1251
+ # estimate a consumer can budget against. Built only after preflight, so it always
1252
+ # describes a runnable config.
1253
+ plan = _build_plan(
1254
+ name,
1255
+ queries,
1256
+ train_idx,
1257
+ test_idx,
1258
+ config,
1259
+ args.iterations,
1260
+ improver_model,
1261
+ effort,
1262
+ args.test_frac,
1263
+ args.seed,
1264
+ )
1265
+ if args.dry_run:
1266
+ logger.info("Dry run (no tokens spent, no artifacts). %s", _plan_summary(plan))
1267
+ print(json.dumps({"dry_run": True, **plan}, indent=2))
1268
+ return
1269
+ logger.info("%s", _plan_summary(plan))
1270
+
1271
+ timestamp = time.strftime("%Y-%m-%d_%H%M%S")
1272
+ out, results_dir, live_report_path = _report_paths(args, name, timestamp)
1273
+ if not args.out and not args.results_dir:
1274
+ logger.info("No --out/--results-dir given; writing run artifacts to %s", out)
1275
+ if live_report_path is not None:
1276
+ _write_placeholder_and_open(live_report_path)
1277
+
1278
+ logger.info(
1279
+ "Skill '%s': %d queries (train=%d, test=%d), models=%s, repeats=%d",
1280
+ name,
1281
+ len(queries),
1282
+ len(train_idx),
1283
+ len(test_idx),
1284
+ list(config.models),
1285
+ config.repeats,
1286
+ )
1287
+
1288
+ base_full = evaluate(queries, name, base_desc, config)
1289
+ (out / "baseline.json").write_text(json.dumps(base_full, indent=2))
1290
+ if args.verbose:
1291
+ logger.info("%s", summarize_verbose("BASELINE (full)", base_full))
1292
+ else:
1293
+ logger.info("%s", summarize("BASELINE (full)", base_full))
1294
+
1295
+ inputs = _LoopInputs(
1296
+ name=name,
1297
+ body=body,
1298
+ config=config,
1299
+ improver_model=improver_model,
1300
+ effort=effort,
1301
+ iterations=args.iterations,
1302
+ timeout=args.timeout,
1303
+ select_epsilon=args.select_epsilon,
1304
+ out=out,
1305
+ verbose=args.verbose,
1306
+ max_desc_chars=args.max_desc_chars,
1307
+ )
1308
+
1309
+ def _emit_live(
1310
+ history: list[dict[str, Any]],
1311
+ best_idx: int,
1312
+ iterations_run: int,
1313
+ improver_failed: list[dict[str, Any]],
1314
+ ) -> None:
1315
+ # Defensive/type-narrowing guard: ``_emit_live`` is only wired into the loop when
1316
+ # ``live_report_path`` is not None (see the ``emit=`` argument below), so this
1317
+ # early return is unreachable at runtime -- it is kept solely so the report
1318
+ # writes further down narrow ``Path | None`` to ``Path`` for the type checker.
1319
+ if live_report_path is None: # pragma: no cover - unreachable; narrows the type
1320
+ return
1321
+ marked = [{**h, "is_best": i == best_idx} for i, h in enumerate(history)]
1322
+ best = marked[best_idx]
1323
+ live = {
1324
+ "original_description": base_desc,
1325
+ "best_description": best["description"],
1326
+ "best_score": best["test_score"] if test_idx else best["train_score"],
1327
+ "best_train_score": best["train_score"],
1328
+ "best_test_score": best["test_score"] if test_idx else None,
1329
+ # Explicit entered-slot count: a skipped (exhausted) slot has no history row,
1330
+ # so this must not be derived from len(history) - 1.
1331
+ "iterations_run": iterations_run,
1332
+ "holdout": args.test_frac,
1333
+ "train_size": len(train_idx),
1334
+ "test_size": len(test_idx),
1335
+ "history": marked,
1336
+ # Copy so a later append cannot mutate this emitted snapshot.
1337
+ "improver_failed_iterations": list(improver_failed),
1338
+ }
1339
+ _write_html(live_report_path, live, name, refresh=True)
1340
+
1341
+ result = _optimize(
1342
+ inputs,
1343
+ train_idx,
1344
+ test_idx,
1345
+ queries,
1346
+ base_desc,
1347
+ base_full,
1348
+ emit=_emit_live if live_report_path is not None else None,
1349
+ )
1350
+
1351
+ best_train_score = _score_str(result.best_train["per_query"])
1352
+ best_test_score = (
1353
+ _score_str(result.best_test_eval["per_query"]) if test_idx else None
1354
+ )
1355
+ report: dict[str, Any] = {
1356
+ "skill": name,
1357
+ "skill_path": str(args.skill_path),
1358
+ "models": list(config.models),
1359
+ "improver": {"model": improver_model, "effort": effort},
1360
+ "baseline_description": base_desc,
1361
+ "best_description": result.best_desc,
1362
+ "best_test_mean": result.best_test,
1363
+ "best_test_min": result.best_test_min,
1364
+ "select_epsilon": args.select_epsilon,
1365
+ "history": result.history,
1366
+ # Additive skill-creator envelope (nothing above is removed).
1367
+ "original_description": base_desc,
1368
+ "final_description": result.final_description,
1369
+ "exit_reason": result.exit_reason,
1370
+ "iterations_run": result.iterations_run,
1371
+ "improver_failed_iterations": result.improver_failed_iterations,
1372
+ "holdout": args.test_frac,
1373
+ "seed": args.seed,
1374
+ "estimated_claude_calls": plan["estimated_claude_calls"],
1375
+ "train_size": len(train_idx),
1376
+ "test_size": len(test_idx),
1377
+ "baseline_chars": len(base_desc),
1378
+ "best_chars": len(result.best_desc),
1379
+ "best_train_score": best_train_score,
1380
+ "best_test_score": best_test_score,
1381
+ "best_score": best_test_score if test_idx else best_train_score,
1382
+ }
1383
+ (out / "report.json").write_text(json.dumps(report, indent=2))
1384
+ if results_dir is not None:
1385
+ (results_dir / "results.json").write_text(json.dumps(report, indent=2))
1386
+ logger.info("\n=== DONE ===")
1387
+ if test_idx:
1388
+ logger.info(
1389
+ "Best held-out mean: %.3f (%s)", result.best_test, result.exit_reason
1390
+ )
1391
+ else:
1392
+ logger.info(
1393
+ "Best train mean: %.3f (%s)",
1394
+ result.best_train["mean_accuracy"],
1395
+ result.exit_reason,
1396
+ )
1397
+ logger.info("\nBEST DESCRIPTION:\n%s\n", result.best_desc)
1398
+ logger.info("Report: %s", out / "report.json")
1399
+ # Machine-readable result on stdout (stderr carries progress) — consumers read
1400
+ # `best_description` from this, matching skill-creator's run_loop contract.
1401
+ print(json.dumps(report, indent=2))
1402
+
1403
+ if live_report_path is not None:
1404
+ _write_html(live_report_path, report, name, refresh=False)
1405
+ if results_dir is not None and args.report != "none":
1406
+ report_html = results_dir / "report.html"
1407
+ if live_report_path != report_html:
1408
+ _write_html(report_html, report, name, refresh=False)
1409
+
1410
+ over_budget = len(result.best_desc) > args.max_desc_chars
1411
+ if args.write and over_budget:
1412
+ logger.warning(
1413
+ "Refusing --write: best description is %d chars, over the %d-char budget; "
1414
+ "%s left unchanged.",
1415
+ len(result.best_desc),
1416
+ args.max_desc_chars,
1417
+ skill_md,
1418
+ )
1419
+ elif args.write and result.best_desc.strip() != base_desc.strip():
1420
+ write_description(skill_md, result.best_desc)
1421
+ logger.info(
1422
+ "Wrote best description into %s (backup at %s.bak)", skill_md, skill_md
1423
+ )
1424
+ elif args.write:
1425
+ logger.info("No change to write (best == baseline).")
1426
+
1427
+
1428
+ def main(argv: list[str] | None = None) -> int:
1429
+ """CLI entry point: parse arguments, configure logging, and run.
1430
+
1431
+ Catch :class:`ImproverFatalProcessError` (a fatal improver failure — a completed
1432
+ nonzero child exit or launch-budget exhaustion, which :func:`run` raises by design)
1433
+ and present it as one ``stderr`` line with exit code 1, no traceback, and no stdout,
1434
+ so a stdout-parsing caller fails legibly. The raw child returncode/stderr remain in
1435
+ the per-iteration improver transcript.
1436
+
1437
+ Args:
1438
+ argv: Argument vector (defaults to ``sys.argv[1:]``).
1439
+
1440
+ Returns:
1441
+ Process exit code (``0`` on success, ``1`` on a fatal improver failure).
1442
+ """
1443
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(message)s")
1444
+ args = build_parser().parse_args(argv)
1445
+ try:
1446
+ run(args)
1447
+ except ImproverFatalProcessError as exc:
1448
+ logger.error("Improver failed fatally: %s (no result written).", exc)
1449
+ return 1
1450
+ return 0