skillroll 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (71) hide show
  1. {skillroll-0.2.0 → skillroll-0.3.0}/.gitignore +1 -0
  2. {skillroll-0.2.0 → skillroll-0.3.0}/PKG-INFO +43 -11
  3. {skillroll-0.2.0 → skillroll-0.3.0}/README.md +42 -10
  4. {skillroll-0.2.0 → skillroll-0.3.0}/examples/README.md +11 -6
  5. skillroll-0.3.0/examples/release-gate-comparison/README.md +82 -0
  6. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/_version.py +1 -1
  7. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/artifacts/records.py +29 -11
  8. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/artifacts/store.py +133 -2
  9. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/cli.py +69 -2
  10. skillroll-0.3.0/src/skillroll/commands/compare.py +1672 -0
  11. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/evaluate.py +476 -35
  12. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/config.py +11 -0
  13. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/inference/profile.py +1 -1
  14. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/inference/transport.py +7 -7
  15. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/initialization/templates.py +6 -2
  16. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/models.py +1 -0
  17. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/runtime/attempt.py +42 -8
  18. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/verdicts.py +5 -1
  19. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/world/session.py +88 -45
  20. {skillroll-0.2.0 → skillroll-0.3.0}/LICENSE +0 -0
  21. {skillroll-0.2.0 → skillroll-0.3.0}/docs/demo/README.md +0 -0
  22. {skillroll-0.2.0 → skillroll-0.3.0}/examples/release-action-boundary/README.md +0 -0
  23. {skillroll-0.2.0 → skillroll-0.3.0}/plugins/harness-prompts/skills/executor-prompt/references/omission.md +0 -0
  24. {skillroll-0.2.0 → skillroll-0.3.0}/plugins/harness-prompts/skills/executor-prompt/references/system.md +0 -0
  25. {skillroll-0.2.0 → skillroll-0.3.0}/plugins/harness-prompts/skills/semantic-judge-prompt/references/system.md +0 -0
  26. {skillroll-0.2.0 → skillroll-0.3.0}/plugins/harness-prompts/skills/world-simulator-prompt/references/system.md +0 -0
  27. {skillroll-0.2.0 → skillroll-0.3.0}/pyproject.toml +0 -0
  28. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/__init__.py +0 -0
  29. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/__main__.py +0 -0
  30. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/_assets/setup-mascot.ansi +0 -0
  31. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/artifacts/__init__.py +0 -0
  32. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/artifacts/hashes.py +0 -0
  33. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/assertions.py +0 -0
  34. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/checks.py +0 -0
  35. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/__init__.py +0 -0
  36. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/create_case.py +0 -0
  37. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/doctor.py +0 -0
  38. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/initialize.py +0 -0
  39. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/commands/validate.py +0 -0
  40. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/diagnostics.py +0 -0
  41. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/discovery.py +0 -0
  42. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/evals.py +0 -0
  43. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/github.py +0 -0
  44. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/github_action.py +0 -0
  45. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/github_workflow.py +0 -0
  46. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/guards.py +0 -0
  47. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/impact.py +0 -0
  48. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/inference/__init__.py +0 -0
  49. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/inference/openrouter.py +0 -0
  50. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/inference/preflight.py +0 -0
  51. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/initialization/__init__.py +0 -0
  52. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/initialization/discovery.py +0 -0
  53. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/initialization/transaction.py +0 -0
  54. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/judge.py +0 -0
  55. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/markdown.py +0 -0
  56. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/marketplace.py +0 -0
  57. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/outcomes.py +0 -0
  58. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/paths.py +0 -0
  59. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/prompt_resources.py +0 -0
  60. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/py.typed +0 -0
  61. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/release.py +0 -0
  62. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/repository_io.py +0 -0
  63. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/runtime/__init__.py +0 -0
  64. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/runtime/agents_sdk.py +0 -0
  65. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/runtime/execution.py +0 -0
  66. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/safe_yaml.py +0 -0
  67. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/validation.py +0 -0
  68. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/world/__init__.py +0 -0
  69. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/world/bundle.py +0 -0
  70. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/world/model.py +0 -0
  71. {skillroll-0.2.0 → skillroll-0.3.0}/src/skillroll/world/rules.py +0 -0
@@ -13,5 +13,6 @@ __pycache__/
13
13
  *.egg-info/
14
14
  .skillroll/runs/
15
15
  .skillroll/experiments/
16
+ .skillroll/comparisons/
16
17
  *.DS_Store
17
18
  /internal/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: skillroll
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A simple, drop-in eval harness for Agent Skills
5
5
  Author: SkillRoll contributors
6
6
  License-Expression: MIT
@@ -152,6 +152,13 @@ skillroll requires Python 3.12 or later and
152
152
  uv tool install skillroll
153
153
  ```
154
154
 
155
+ The published `v0.2.0` CLI does not yet include the `executor_model` setting or
156
+ the `compare` command described below; these features are in the upcoming
157
+ release. To try them from a SkillRoll source checkout containing this change,
158
+ run `uv sync --locked` in that checkout and invoke SkillRoll with `uv run`
159
+ (include `--repo PATH` when evaluating another repository). The rest of this
160
+ Quickstart uses the released CLI.
161
+
155
162
  Set up skillroll interactively and create `skillroll.toml` at the repository
156
163
  root. It automatically identifies the skills folder and helps configure the
157
164
  inference endpoint and API-key environment variable.
@@ -207,8 +214,8 @@ The command prints its verdict and saves the report under the repository's
207
214
  ## Inference
208
215
 
209
216
  Running an eval requires an API key for a compatible inference endpoint.
210
- skillroll works with OpenAI-compatible Chat Completions endpoints that support
211
- tool calling and strict JSON Schema structured outputs.
217
+ skillroll supports OpenAI-compatible Chat Completions and Responses endpoints
218
+ with tool calling and strict JSON Schema structured outputs.
212
219
 
213
220
  If you entered model settings during `init`, they are already in
214
221
  `skillroll.toml`. Otherwise, add them now:
@@ -223,15 +230,14 @@ api_key_env = "SKILLROLL_API_KEY"
223
230
  ### OpenRouter
224
231
 
225
232
  If you do not yet have an inference provider,
226
- [OpenRouter](https://openrouter.ai/) is a great place to start. It gives you
227
- control over API keys and spending, access to almost any model, and free
228
- inference on inexpensive models for sanity tests.
229
-
230
- Our go-to model is
231
- [`openai/gpt-5.6-luna-pro`](https://openrouter.ai/openai/gpt-5.6-luna-pro),
232
- which is cost-effective and performant enough for even complex evals.
233
+ [OpenRouter](https://openrouter.ai/) offers multiple models through one
234
+ endpoint, with account spending controls. Model availability and free-route
235
+ capacity vary.
233
236
 
234
- To use Luna Pro:
237
+ The repository keeps
238
+ [`openai/gpt-5.6-luna-pro`](https://openrouter.ai/openai/gpt-5.6-luna-pro)
239
+ as its existing evaluator baseline. This is a reference configuration, not a
240
+ claim that it is the cheapest or best model for every case:
235
241
 
236
242
  ```toml
237
243
  [inference]
@@ -240,6 +246,10 @@ model = "openai/gpt-5.6-luna-pro"
240
246
  api_key_env = "SKILLROLL_API_KEY"
241
247
  ```
242
248
 
249
+ For the upcoming executor-comparison workflow, see the
250
+ [tested GPT-6 Luna candidate setup](docs/comparing-models.md#a-current-openrouter-candidate).
251
+ It keeps the evaluator fixed while you assess a current lower-cost skill model.
252
+
243
253
  Or the free tier:
244
254
 
245
255
  ```toml
@@ -290,6 +300,27 @@ history.
290
300
  | `INCOMPLETE` | A required repository check did not run. |
291
301
  | `ERROR` | skillroll could not produce a trustworthy verdict. |
292
302
 
303
+ ## Compare models that run the skill
304
+
305
+ After you have a regression case, compare candidate models on the same case
306
+ while keeping the configured model fixed for the Dungeon Master and judge.
307
+ From a SkillRoll source checkout containing this change, run:
308
+
309
+ ```shell
310
+ uv run skillroll compare \
311
+ --repo /path/to/your-repository \
312
+ --executor-model provider/baseline \
313
+ --executor-model provider/candidate \
314
+ --samples 5 \
315
+ --case path/to/eval.md
316
+ ```
317
+
318
+ The comparison makes real model calls and may incur provider charges. Its
319
+ report summarizes observed outcomes; cost estimates require reported token
320
+ usage and configured rates. Results are evidence for these cases, not proof
321
+ that a lower-cost candidate matches the baseline. See the
322
+ [comparison guide](docs/comparing-models.md) for details.
323
+
293
324
  ## Add GitHub Actions
294
325
 
295
326
  After `skillroll.toml` exists, generate the advisory workflow:
@@ -357,6 +388,7 @@ today; expect the interface to change between minor releases.
357
388
  - [Author and audit good skills and evals](docs/authoring.md)
358
389
  - [Write an eval](docs/writing-evals.md)
359
390
  - [Configure models and cost](docs/configuration.md)
391
+ - [Compare skill executor models](docs/comparing-models.md)
360
392
  - [Understand results and fix problems](docs/results.md)
361
393
  - [Add advisory GitHub checks](docs/github-actions.md)
362
394
  - [Review the security model](docs/security.md)
@@ -136,6 +136,13 @@ skillroll requires Python 3.12 or later and
136
136
  uv tool install skillroll
137
137
  ```
138
138
 
139
+ The published `v0.2.0` CLI does not yet include the `executor_model` setting or
140
+ the `compare` command described below; these features are in the upcoming
141
+ release. To try them from a SkillRoll source checkout containing this change,
142
+ run `uv sync --locked` in that checkout and invoke SkillRoll with `uv run`
143
+ (include `--repo PATH` when evaluating another repository). The rest of this
144
+ Quickstart uses the released CLI.
145
+
139
146
  Set up skillroll interactively and create `skillroll.toml` at the repository
140
147
  root. It automatically identifies the skills folder and helps configure the
141
148
  inference endpoint and API-key environment variable.
@@ -191,8 +198,8 @@ The command prints its verdict and saves the report under the repository's
191
198
  ## Inference
192
199
 
193
200
  Running an eval requires an API key for a compatible inference endpoint.
194
- skillroll works with OpenAI-compatible Chat Completions endpoints that support
195
- tool calling and strict JSON Schema structured outputs.
201
+ skillroll supports OpenAI-compatible Chat Completions and Responses endpoints
202
+ with tool calling and strict JSON Schema structured outputs.
196
203
 
197
204
  If you entered model settings during `init`, they are already in
198
205
  `skillroll.toml`. Otherwise, add them now:
@@ -207,15 +214,14 @@ api_key_env = "SKILLROLL_API_KEY"
207
214
  ### OpenRouter
208
215
 
209
216
  If you do not yet have an inference provider,
210
- [OpenRouter](https://openrouter.ai/) is a great place to start. It gives you
211
- control over API keys and spending, access to almost any model, and free
212
- inference on inexpensive models for sanity tests.
213
-
214
- Our go-to model is
215
- [`openai/gpt-5.6-luna-pro`](https://openrouter.ai/openai/gpt-5.6-luna-pro),
216
- which is cost-effective and performant enough for even complex evals.
217
+ [OpenRouter](https://openrouter.ai/) offers multiple models through one
218
+ endpoint, with account spending controls. Model availability and free-route
219
+ capacity vary.
217
220
 
218
- To use Luna Pro:
221
+ The repository keeps
222
+ [`openai/gpt-5.6-luna-pro`](https://openrouter.ai/openai/gpt-5.6-luna-pro)
223
+ as its existing evaluator baseline. This is a reference configuration, not a
224
+ claim that it is the cheapest or best model for every case:
219
225
 
220
226
  ```toml
221
227
  [inference]
@@ -224,6 +230,10 @@ model = "openai/gpt-5.6-luna-pro"
224
230
  api_key_env = "SKILLROLL_API_KEY"
225
231
  ```
226
232
 
233
+ For the upcoming executor-comparison workflow, see the
234
+ [tested GPT-6 Luna candidate setup](docs/comparing-models.md#a-current-openrouter-candidate).
235
+ It keeps the evaluator fixed while you assess a current lower-cost skill model.
236
+
227
237
  Or the free tier:
228
238
 
229
239
  ```toml
@@ -274,6 +284,27 @@ history.
274
284
  | `INCOMPLETE` | A required repository check did not run. |
275
285
  | `ERROR` | skillroll could not produce a trustworthy verdict. |
276
286
 
287
+ ## Compare models that run the skill
288
+
289
+ After you have a regression case, compare candidate models on the same case
290
+ while keeping the configured model fixed for the Dungeon Master and judge.
291
+ From a SkillRoll source checkout containing this change, run:
292
+
293
+ ```shell
294
+ uv run skillroll compare \
295
+ --repo /path/to/your-repository \
296
+ --executor-model provider/baseline \
297
+ --executor-model provider/candidate \
298
+ --samples 5 \
299
+ --case path/to/eval.md
300
+ ```
301
+
302
+ The comparison makes real model calls and may incur provider charges. Its
303
+ report summarizes observed outcomes; cost estimates require reported token
304
+ usage and configured rates. Results are evidence for these cases, not proof
305
+ that a lower-cost candidate matches the baseline. See the
306
+ [comparison guide](docs/comparing-models.md) for details.
307
+
277
308
  ## Add GitHub Actions
278
309
 
279
310
  After `skillroll.toml` exists, generate the advisory workflow:
@@ -341,6 +372,7 @@ today; expect the interface to change between minor releases.
341
372
  - [Author and audit good skills and evals](docs/authoring.md)
342
373
  - [Write an eval](docs/writing-evals.md)
343
374
  - [Configure models and cost](docs/configuration.md)
375
+ - [Compare skill executor models](docs/comparing-models.md)
344
376
  - [Understand results and fix problems](docs/results.md)
345
377
  - [Add advisory GitHub checks](docs/github-actions.md)
346
378
  - [Review the security model](docs/security.md)
@@ -1,8 +1,14 @@
1
1
  # SkillRoll examples
2
2
 
3
- These three small repositories are checked-in examples of different Agent
3
+ These small repositories are checked-in examples of different Agent
4
4
  Skill boundaries. They use the current `skillroll.toml` and Markdown eval
5
- format and contain no inference endpoint or API key.
5
+ format. Their active configuration requires no inference endpoint or API key
6
+ for validation.
7
+
8
+ The [release-gate comparison](release-gate-comparison/) follows a queued-CI
9
+ failure through a prompt repair and compares two models running the revised
10
+ skill. It includes recorded live action traces and a provider configuration
11
+ for reproducing the runs.
6
12
 
7
13
  Start with the [release-action-boundary](release-action-boundary/) example. It
8
14
  checks required CI before merging and keeps an independent approval requirement
@@ -49,7 +55,6 @@ api_key_env = "SKILLROLL_API_KEY"
49
55
 
50
56
  Then set the named environment variable in the current shell, run
51
57
  `skillroll doctor`, and only after it passes run `skillroll eval --all`.
52
- `doctor` and `eval` are networked and credentialed; their outcomes depend on
53
- the provider, model, case revision, and authorized run. Nothing in this
54
- example bundle claims that a live run has occurred or that a real release,
55
- refund, or customer action was performed.
58
+ Both commands contact the configured provider. Model behavior depends on the
59
+ provider, model, and case revision. Each example uses a simulated environment
60
+ for releases, refunds, and other external actions.
@@ -0,0 +1,82 @@
1
+ # Repair a release skill and compare its executors
2
+
3
+ A release skill sees a queued CI check for the current revision. The original
4
+ prompt requires successful CI and approval for that revision. The revised
5
+ prompt turns those requirements into an ordered checklist and explicitly
6
+ says that queued CI is not a successful check.
7
+
8
+ The example uses synthetic responses from a simulated release service. The
9
+ action trace shows whether the skill tried to deploy and which evidence it
10
+ used. The case also asks the judge to check the skill's explanation.
11
+
12
+ ## Run the example
13
+
14
+ Use SkillRoll 0.3.0 or later. To use a local release candidate, install the
15
+ wheel with `uv tool install /path/to/skillroll-0.3.0-py3-none-any.whl`.
16
+ From a checkout of this repository, copy the example into a temporary
17
+ directory:
18
+
19
+ ```shell
20
+ demo_repo="$(mktemp -d)"
21
+ cp -R examples/release-gate-comparison/. "$demo_repo/"
22
+ cd "$demo_repo"
23
+ skillroll validate --all
24
+ ```
25
+
26
+ Validation checks the case files without contacting a model. For the live
27
+ steps, set `SKILLROLL_API_KEY` in your shell and copy the recorded OpenRouter
28
+ configuration into place:
29
+
30
+ ```shell
31
+ cp recorded-run.toml skillroll.toml
32
+ ```
33
+
34
+ The configuration uses GPT-5.6 Luna Pro for the World and judge. The four
35
+ cases specify exact tool replies, so the simulator can handle the expected
36
+ actions without model calls. The evaluator can still judge the resulting
37
+ evidence differently on repeated runs. The live commands below make billable
38
+ provider calls.
39
+
40
+ ## Reproduce the failure and repair
41
+
42
+ Run the original prompt twice against the queued-CI case:
43
+
44
+ ```shell
45
+ cp weak/SKILL.md skills/release-gate/SKILL.md
46
+ skillroll eval --case release-gate/evals/test-ci-queued.eval.md \
47
+ --executor-model openai/gpt-4.1-nano --samples 2
48
+ ```
49
+
50
+ Restore the revised prompt and repeat the same case with the same executor:
51
+
52
+ ```shell
53
+ cp repaired/SKILL.md skills/release-gate/SKILL.md
54
+ skillroll eval --case release-gate/evals/test-ci-queued.eval.md \
55
+ --executor-model openai/gpt-4.1-nano --samples 2
56
+ ```
57
+
58
+ Inspect `report.md` and `transcript.jsonl` under the printed run directories.
59
+ The prompt is the only input file that changes between these commands. In
60
+ particular, the queued-CI case and the provider configuration stay byte-identical.
61
+
62
+ ## Compare the revised skill on two models
63
+
64
+ ```shell
65
+ skillroll compare --all --samples 2 --seed 20261109 \
66
+ --executor-model openai/gpt-5.6-luna-pro \
67
+ --executor-model openai/gpt-6-luna
68
+ ```
69
+
70
+ This runs 16 attempts. The suite covers queued CI, stale CI for another
71
+ revision, a ready release, and an instruction embedded in a failed-CI tool
72
+ response. The executor changes and the evaluator stays fixed. The seed
73
+ controls the schedule order. Each attempt has its own model responses.
74
+
75
+ Read the comparison report under `.skillroll/comparisons/`. The recorded
76
+ configuration uses a November 8, 2026 price snapshot. Replace those rates
77
+ with your provider's rates for fresh estimates. The per-role token costs
78
+ are estimates and the executor timing measures work inside this harness.
79
+ Inspect the observed actions separately from the judge verdict when
80
+ reviewing a deployment decision.
81
+
82
+ [Recorded results and action traces](EVIDENCE.md)
@@ -1,3 +1,3 @@
1
1
  """The one canonical public SkillRoll version."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -72,6 +72,7 @@ class RunFacts:
72
72
  profile_name: str | None = None
73
73
  profile_purpose: str | None = None
74
74
  skill_available: bool = True
75
+ executor_model: str | None = None
75
76
 
76
77
 
77
78
  def run_bytes(facts: RunFacts) -> bytes:
@@ -91,6 +92,11 @@ def run_bytes(facts: RunFacts) -> bytes:
91
92
  "name": facts.profile_name,
92
93
  "purpose": facts.profile_purpose,
93
94
  },
95
+ "models": {
96
+ "executor": facts.executor_model or facts.model,
97
+ "world": facts.model,
98
+ "judge": facts.model,
99
+ },
94
100
  "configured_limits": dict(facts.configured_limits),
95
101
  "effective_limits": dict(facts.effective_limits),
96
102
  "input_manifest": {
@@ -119,7 +125,8 @@ def report_bytes(facts: RunFacts) -> bytes:
119
125
  "",
120
126
  f"- Skill: `{facts.skill}`",
121
127
  f"- Eval case: `{facts.case}`",
122
- f"- Model: `{facts.model}`",
128
+ f"- Executor model: `{facts.executor_model or facts.model}`",
129
+ f"- World and judge model: `{facts.model}`",
123
130
  "- Skill instructions: "
124
131
  + ("available" if facts.skill_available else "intentionally omitted"),
125
132
  "- Effective limits: "
@@ -202,6 +209,7 @@ def final_report_bytes(
202
209
  model_profile: str | None = None,
203
210
  model_profile_purpose: str | None = None,
204
211
  skill_available: bool = True,
212
+ executor_model: str | None = None,
205
213
  ) -> bytes:
206
214
  """Render a final report in terms a new maintainer can act on."""
207
215
  if finished is None:
@@ -223,7 +231,8 @@ def final_report_bytes(
223
231
  "- Skill finished: " + ("yes" if finished else "no"),
224
232
  ]
225
233
  if model is not None:
226
- lines.insert(4, f"- Model: `{model}`")
234
+ lines.insert(4, f"- World and judge model: `{model}`")
235
+ lines.insert(5, f"- Executor model: `{executor_model or model}`")
227
236
  if model_profile is not None:
228
237
  lines.insert(5, f"- Model profile: `{model_profile}`")
229
238
  if model_profile_purpose is not None:
@@ -351,17 +360,23 @@ def execution_bytes(
351
360
  usage: object,
352
361
  *,
353
362
  turns_source: str = "unavailable",
363
+ executor_model: str | None = None,
364
+ world_judge_model: str | None = None,
354
365
  ) -> bytes:
355
366
  """Render completed execution facts before a repository check can begin."""
356
- return canonical_json(
357
- {
358
- "format_version": ARTIFACT_FORMAT_VERSION,
359
- "final_output": final_output,
360
- "turns_used": turns,
361
- "turns_source": turns_source,
362
- "usage": usage,
367
+ value: dict[str, object] = {
368
+ "format_version": ARTIFACT_FORMAT_VERSION,
369
+ "final_output": final_output,
370
+ "turns_used": turns,
371
+ "turns_source": turns_source,
372
+ "usage": usage,
373
+ }
374
+ if executor_model is not None or world_judge_model is not None:
375
+ value["models"] = {
376
+ "executor": executor_model,
377
+ "world_and_judge": world_judge_model,
363
378
  }
364
- )
379
+ return canonical_json(value)
365
380
 
366
381
 
367
382
  def judge_bytes(judge: object, exact_fact_checks: object) -> bytes:
@@ -412,11 +427,14 @@ def experiment_report_bytes(summary: Mapping[str, object]) -> bytes:
412
427
  interpretation = summary.get("interpretation")
413
428
  if not isinstance(interpretation, Mapping):
414
429
  interpretation = {}
430
+ executor_model = summary.get("executor_model", summary.get("model", "unknown"))
431
+ evaluator_model = summary.get("model", "unknown")
415
432
  lines = [
416
433
  "# SkillRoll comparison report",
417
434
  "",
418
435
  f"- Eval case: `{summary.get('case', 'selected cases')}`",
419
- f"- Model: `{summary.get('model', 'unknown')}`",
436
+ f"- Executor model: `{executor_model}`",
437
+ f"- World and judge model: `{evaluator_model}`",
420
438
  f"- Samples: {summary.get('samples_requested', 'unknown')}",
421
439
  f"- Interpretation: **{interpretation.get('status', 'unknown')}**",
422
440
  "",
@@ -216,6 +216,7 @@ class ArtifactStore:
216
216
  value: bytes,
217
217
  *,
218
218
  trusted_directory: bool = False,
219
+ directory_check: Callable[[], Path] | None = None,
219
220
  ) -> None:
220
221
  """Write one file on platforms without descriptor-relative operations.
221
222
 
@@ -226,7 +227,14 @@ class ArtifactStore:
226
227
  temporary: Path | None = None
227
228
  try:
228
229
  for _ in range(3):
229
- candidate = directory / f".{name}.{uuid.uuid4().hex}.tmp"
230
+ checked = (
231
+ directory_check()
232
+ if directory_check is not None
233
+ else directory
234
+ if trusted_directory
235
+ else self._checked_fallback_directory(directory)
236
+ )
237
+ candidate = checked / f".{name}.{uuid.uuid4().hex}.tmp"
230
238
  try:
231
239
  candidate.lstat()
232
240
  except FileNotFoundError:
@@ -253,7 +261,9 @@ class ArtifactStore:
253
261
  finally:
254
262
  os.close(file_fd)
255
263
  checked = (
256
- directory
264
+ directory_check()
265
+ if directory_check is not None
266
+ else directory
257
267
  if trusted_directory
258
268
  else self._checked_fallback_directory(directory)
259
269
  )
@@ -483,3 +493,124 @@ class ArtifactStore:
483
493
  f"SkillRoll could not atomically write {name} in the "
484
494
  "experiment-evidence folder."
485
495
  ) from error
496
+
497
+ def create_comparison(self) -> tuple[str, Path]:
498
+ """Reserve a private comparison folder below ``.skillroll``."""
499
+ if not self._root.is_dir() or self._root.is_symlink():
500
+ raise ArtifactError(
501
+ "SkillRoll cannot save comparison evidence because the selected "
502
+ "repository folder is unsafe."
503
+ )
504
+ base = self._root / ".skillroll"
505
+ comparisons = base / "comparisons"
506
+ try:
507
+ if base.is_symlink() or (base.exists() and not base.is_dir()):
508
+ raise ArtifactError(
509
+ "SkillRoll cannot save comparison evidence because .skillroll "
510
+ "is not an ordinary folder."
511
+ )
512
+ base.mkdir(exist_ok=True)
513
+ if comparisons.is_symlink() or (
514
+ comparisons.exists() and not comparisons.is_dir()
515
+ ):
516
+ raise ArtifactError(
517
+ "SkillRoll cannot save comparison evidence because "
518
+ ".skillroll/comparisons is not an ordinary folder."
519
+ )
520
+ comparisons.mkdir(exist_ok=True)
521
+ for _ in range(3):
522
+ comparison_id = f"comparison-{self._id_factory()}"
523
+ destination = comparisons / comparison_id
524
+ try:
525
+ destination.mkdir(mode=0o700)
526
+ except FileExistsError:
527
+ continue
528
+ return comparison_id, destination
529
+ except OSError as error:
530
+ raise ArtifactError(
531
+ "SkillRoll could not create its private comparison-evidence folder."
532
+ ) from error
533
+ raise ArtifactError(
534
+ "SkillRoll could not reserve a unique comparison-evidence directory."
535
+ )
536
+
537
+ def _expected_comparison_directory(self, directory: Path) -> Path:
538
+ expected = self._root / ".skillroll" / "comparisons" / directory.name
539
+ if directory != expected or not directory.name.startswith("comparison-"):
540
+ raise ArtifactError(
541
+ "SkillRoll refused to write comparison evidence outside its "
542
+ "private comparisons folder."
543
+ )
544
+ return expected
545
+
546
+ def _open_comparison_directory(self, directory: Path) -> int:
547
+ """Open a reserved comparison directory through no-follow POSIX handles."""
548
+ self._expected_comparison_directory(directory)
549
+ flags = (
550
+ os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
551
+ )
552
+ handles: list[int] = []
553
+ try:
554
+ handles.append(os.open(self._root, flags))
555
+ handles.append(os.open(".skillroll", flags, dir_fd=handles[-1]))
556
+ handles.append(os.open("comparisons", flags, dir_fd=handles[-1]))
557
+ handles.append(os.open(directory.name, flags, dir_fd=handles[-1]))
558
+ except OSError as error:
559
+ for handle in reversed(handles):
560
+ os.close(handle)
561
+ raise ArtifactError(
562
+ "SkillRoll could not safely reopen this comparison-evidence folder. "
563
+ "It may have changed since the comparison started."
564
+ ) from error
565
+ for handle in handles[:-1]:
566
+ os.close(handle)
567
+ return handles[-1]
568
+
569
+ def _checked_comparison_directory(self, directory: Path) -> Path:
570
+ expected = self._expected_comparison_directory(directory)
571
+ for component in (
572
+ self._root,
573
+ self._root / ".skillroll",
574
+ self._root / ".skillroll" / "comparisons",
575
+ expected,
576
+ ):
577
+ try:
578
+ mode = component.lstat().st_mode
579
+ except OSError as error:
580
+ raise ArtifactError(
581
+ "SkillRoll could not safely reopen this comparison-evidence "
582
+ "folder. It may have changed since the comparison started."
583
+ ) from error
584
+ if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
585
+ raise ArtifactError(
586
+ "SkillRoll refused this comparison-evidence folder because one "
587
+ "of its folders changed or is a link."
588
+ )
589
+ return expected
590
+
591
+ def write_comparison(
592
+ self, directory: Path, values: tuple[tuple[str, bytes], ...]
593
+ ) -> None:
594
+ """Safely write the fixed comparison manifest, checkpoint, and report."""
595
+ allowed = {"manifest.json", "comparison.json", "report.md"}
596
+ if any(name not in allowed for name, _ in values):
597
+ raise ArtifactError("SkillRoll refused an unsafe comparison file name.")
598
+ safe_values = tuple((name, self._safe(value)) for name, value in values)
599
+ if _descriptor_safety_available():
600
+ directory_fd = self._open_comparison_directory(directory)
601
+ try:
602
+ for name, value in safe_values:
603
+ self._write(directory_fd, name, value)
604
+ finally:
605
+ os.close(directory_fd)
606
+ else:
607
+ self._checked_comparison_directory(directory)
608
+ for name, value in safe_values:
609
+ self._write_fallback(
610
+ directory,
611
+ name,
612
+ value,
613
+ directory_check=lambda: self._checked_comparison_directory(
614
+ directory
615
+ ),
616
+ )
@@ -217,7 +217,12 @@ def _parser() -> SkillRollParser:
217
217
  evaluate_parser.add_argument(
218
218
  "--model-profile",
219
219
  metavar="NAME",
220
- help="Model profile to use.",
220
+ help="Evaluator model profile to use for the World and judge.",
221
+ )
222
+ evaluate_parser.add_argument(
223
+ "--executor-model",
224
+ metavar="MODEL",
225
+ help="Run the skill on MODEL while keeping the configured evaluator fixed.",
221
226
  )
222
227
  evaluate_parser.add_argument(
223
228
  "--concurrency",
@@ -238,6 +243,53 @@ def _parser() -> SkillRollParser:
238
243
  action="store_true",
239
244
  help="Also run without the skill to check whether the eval depends on it.",
240
245
  )
246
+ compare_parser = commands.add_parser(
247
+ "compare", help="Compare executor models with a fixed evaluator."
248
+ )
249
+ compare_parser.add_argument(
250
+ "--repo", metavar="PATH", help="Repository directory override."
251
+ )
252
+ compare_selection = compare_parser.add_mutually_exclusive_group()
253
+ compare_selection.add_argument(
254
+ "--skill", metavar="PATH", help="Skill path relative to skills_path."
255
+ )
256
+ compare_selection.add_argument(
257
+ "--case", metavar="PATH", help="Eval-case path relative to skills_path."
258
+ )
259
+ compare_selection.add_argument(
260
+ "--all", action="store_true", help="Compare all cases in the repository."
261
+ )
262
+ compare_parser.add_argument(
263
+ "--executor-model",
264
+ action="append",
265
+ dest="executor_models",
266
+ metavar="MODEL",
267
+ help="Candidate model; repeat for 2-8 distinct models, baseline first.",
268
+ )
269
+ compare_parser.add_argument(
270
+ "--model-profile", metavar="NAME", help="Fixed evaluator model profile."
271
+ )
272
+ compare_parser.add_argument(
273
+ "--samples",
274
+ type=int,
275
+ default=5,
276
+ metavar="N",
277
+ help="Attempts per case/model (1-10; default: 5).",
278
+ )
279
+ compare_parser.add_argument(
280
+ "--seed",
281
+ type=int,
282
+ default=0,
283
+ metavar="N",
284
+ help=(
285
+ "Run-order seed (0-4294967295; default: 0); does not seed model responses."
286
+ ),
287
+ )
288
+ compare_parser.add_argument(
289
+ "--run-commands",
290
+ action="store_true",
291
+ help="Run trusted repository checks declared by cases; comparison is serial.",
292
+ )
241
293
  return parser
242
294
 
243
295
 
@@ -277,7 +329,7 @@ def _missing_command() -> CommandResult:
277
329
  "Choose one SkillRoll command to continue.",
278
330
  next_action=(
279
331
  "Run 'skillroll --help' and choose init, new, doctor, validate, "
280
- "or eval."
332
+ "eval, or compare."
281
333
  ),
282
334
  ),
283
335
  ),
@@ -380,10 +432,25 @@ def main(
380
432
  all_cases=namespace.all,
381
433
  run_commands=namespace.run_commands,
382
434
  model_profile=namespace.model_profile,
435
+ executor_model=namespace.executor_model,
383
436
  samples=namespace.samples,
384
437
  concurrency=namespace.concurrency,
385
438
  with_skill_control=namespace.with_skill_control,
386
439
  )
440
+ elif namespace.command == "compare" and commands is None:
441
+ from skillroll.commands import compare
442
+
443
+ result = compare.run(
444
+ repo=namespace.repo,
445
+ skill=namespace.skill,
446
+ case=namespace.case,
447
+ all_cases=namespace.all,
448
+ executor_models=tuple(namespace.executor_models or ()),
449
+ model_profile=namespace.model_profile,
450
+ samples=namespace.samples,
451
+ seed=namespace.seed,
452
+ run_commands=namespace.run_commands,
453
+ )
387
454
  else:
388
455
  result = (COMMANDS if commands is None else commands)[
389
456
  namespace.command