ruby-skill-bench 1.1.0 → 1.2.0

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +166 -35
  3. data/docs/architecture.md +3 -1
  4. data/docs/first-eval-guide.md +7 -7
  5. data/docs/testing-guide.md +1 -1
  6. data/lib/skill_bench/agent/react_agent/loop_runner.rb +44 -9
  7. data/lib/skill_bench/agent/react_agent/step.rb +7 -1
  8. data/lib/skill_bench/cli/batch_result_printer.rb +45 -0
  9. data/lib/skill_bench/cli/eval/eval_options.rb +4 -0
  10. data/lib/skill_bench/cli/help_printer.rb +10 -2
  11. data/lib/skill_bench/cli/init_command.rb +2 -1
  12. data/lib/skill_bench/cli/result_printer.rb +1 -1
  13. data/lib/skill_bench/cli/run_command.rb +47 -9
  14. data/lib/skill_bench/cli/validate_command.rb +242 -0
  15. data/lib/skill_bench/cli.rb +3 -0
  16. data/lib/skill_bench/client.rb +43 -1
  17. data/lib/skill_bench/clients/all.rb +2 -0
  18. data/lib/skill_bench/clients/base_client.rb +12 -1
  19. data/lib/skill_bench/clients/base_url_validator.rb +105 -0
  20. data/lib/skill_bench/clients/provider_config.rb +34 -1
  21. data/lib/skill_bench/clients/provider_schemas.rb +4 -0
  22. data/lib/skill_bench/clients/providers/mistral.rb +47 -0
  23. data/lib/skill_bench/commands/init.rb +5 -0
  24. data/lib/skill_bench/commands/skill_new.rb +3 -1
  25. data/lib/skill_bench/config/applier.rb +2 -0
  26. data/lib/skill_bench/config/defaults.rb +2 -0
  27. data/lib/skill_bench/config/facade_readers.rb +7 -0
  28. data/lib/skill_bench/config/facade_writers.rb +17 -0
  29. data/lib/skill_bench/config/json_loader.rb +1 -1
  30. data/lib/skill_bench/config/store.rb +29 -0
  31. data/lib/skill_bench/config.rb +18 -0
  32. data/lib/skill_bench/evaluation/runner.rb +20 -3
  33. data/lib/skill_bench/execution/context_hydrator.rb +52 -11
  34. data/lib/skill_bench/execution/sandbox.rb +58 -11
  35. data/lib/skill_bench/judge/judge.rb +4 -0
  36. data/lib/skill_bench/judge/prompt.rb +42 -6
  37. data/lib/skill_bench/models/config.rb +32 -0
  38. data/lib/skill_bench/output_formatter.rb +60 -1
  39. data/lib/skill_bench/package_verifier.rb +1 -1
  40. data/lib/skill_bench/rails/skill_templates.rb +19 -5
  41. data/lib/skill_bench/services/agent_spawner_service.rb +7 -3
  42. data/lib/skill_bench/services/batch_runner_service.rb +111 -0
  43. data/lib/skill_bench/services/compare_option_parser.rb +1 -0
  44. data/lib/skill_bench/services/cost_calculator.rb +91 -0
  45. data/lib/skill_bench/services/html_formatter.rb +289 -0
  46. data/lib/skill_bench/services/json_formatter.rb +19 -1
  47. data/lib/skill_bench/services/junit_formatter.rb +74 -24
  48. data/lib/skill_bench/services/provider_resolver.rb +5 -2
  49. data/lib/skill_bench/services/response_cache.rb +130 -0
  50. data/lib/skill_bench/services/runner_service.rb +88 -4
  51. data/lib/skill_bench/services/summary_formatter.rb +90 -0
  52. data/lib/skill_bench/services/template_registry.rb +43 -9
  53. data/lib/skill_bench/services/trend_recorder_service.rb +29 -2
  54. data/lib/skill_bench/tools/registry.rb +29 -3
  55. data/lib/skill_bench/tools/run_command.rb +171 -19
  56. data/lib/skill_bench/trend_tracker/persistence.rb +27 -10
  57. data/lib/skill_bench/trend_tracker.rb +5 -5
  58. data/lib/skill_bench/version.rb +1 -1
  59. data/lib/skill_bench.rb +2 -3
  60. metadata +17 -36
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2ad524e13bc006a56f0197d07b3ba7b0ce2f99f60b61f0739c3d5bc0d75a687
4
- data.tar.gz: a920c473148b52584653acbb1e91cb3973791c09de6c4df994a77c097eabc476
3
+ metadata.gz: f47976b55f6f8c147adb4ed784ce04ba52ff71f805f8e35d797ba776021641c4
4
+ data.tar.gz: c2febaadbdeb7e149041661258ce84e41499121445cf726cefece642e60174a4
5
5
  SHA512:
6
- metadata.gz: c1f131af9bcde90e7fc3a7e6bef7f3770edfa4e2826ee19c3aabf5c210d6d3b6e5bdd460778a87f6fdc77b5b99bc17b2225e1b79de31674ad4acfe1bbc89f862
7
- data.tar.gz: d8e3791c91242b25779afa3a21c57daeb06995bc4c65b01ca6f378a69491aeec95c5844ea541e5dcaf18b46d8e7f153ffb38ff2bb060ab5dacd653c8c1026bcd
6
+ metadata.gz: 5ab3082fa715a0776455a88b28e2d990d3bb7e52fbc5f0cc47176e1d44e76cdc430531bc0401abab350cb4c2321d0b625ce78d381094fe404cdedd7e61b27227
7
+ data.tar.gz: 3d5f67b876457691e003e62a8ba57fc04ac21e002bb5c6a84d1ae7954cf7dd7136f05a658d7ee4d416ab2db19cb0ad90e51dd0d920df9ffa998cb8391db65df5
data/README.md CHANGED
@@ -30,7 +30,7 @@ See the [Ecosystem Overview](https://github.com/igmarin/agent-mcp-runtime/blob/m
30
30
  - **Isolated Git Sandboxes**: Every run operates in a temporary repo. Clean diffs, zero side-effects, 100% reproducibility.
31
31
  - **Blind Judging with Dimensions**: LLM judge scores baseline and context independently across 5 canonical dimensions (Correctness, Skill Adherence, Code Quality, Test Coverage, Documentation). Eval authors configure weights and thresholds via `criteria.json`.
32
32
  - **Sophisticated ReAct Loop**: Employs a robust `Thought → Tool → Observation` loop to handle complex, multi-step engineering tasks.
33
- - **Multi-Provider Ecosystem**: Native support for **OpenAI**, **Anthropic**, **Google Gemini**, **Azure OpenAI**, **Ollama**, **Groq**, **DeepSeek**, and **OpenCode**.
33
+ - **Multi-Provider Ecosystem**: Native support for **OpenAI**, **Anthropic**, **Google Gemini**, **Azure OpenAI**, **Ollama**, **Groq**, **DeepSeek**, **Mistral**, and **OpenCode**.
34
34
  - **Standardized Intelligence**: Consistent reporting format regardless of the underlying LLM provider.
35
35
 
36
36
  ---
@@ -64,11 +64,14 @@ CLI / API → RunnerService → Sandbox + ReAct Agent → LLM Client Layer → P
64
64
  | **Ollama** | — | `:ollama` |
65
65
  | **Groq** | `SKILL_BENCH_GROQ_API_KEY` | `:groq` |
66
66
  | **DeepSeek** | `SKILL_BENCH_DEEPSEEK_API_KEY` | `:deepseek` |
67
+ | **Mistral** | `SKILL_BENCH_MISTRAL_API_KEY` | `:mistral` |
67
68
  | **OpenCode** | `SKILL_BENCH_OPENCODE_API_KEY`, `SKILL_BENCH_OPENCODE_BASE_URL` | `:opencode` |
68
69
 
69
70
  > **Note:** Environment variables are loaded automatically. You can also configure provider settings in `skill-bench.json` (created by `skill-bench init`).
70
71
  >
71
72
  > **OpenCode requires a custom `base_url`:** OpenCode does not host a public LLM API. You must provide your own OpenAI-compatible endpoint (e.g. a LiteLLM proxy, self-hosted vLLM, or company gateway) via the `base_url` config key. Without it, the provider will fail with "Base URL not set for Opencode".
73
+ >
74
+ > **Mistral** uses Mistral's OpenAI-compatible chat completions API (default model `mistral-large-latest`). Set `SKILL_BENCH_MISTRAL_API_KEY` and scaffold it with `skill-bench init --mistral`.
72
75
 
73
76
  ### Command Allowlist
74
77
 
@@ -79,6 +82,7 @@ By default, no shell commands are permitted. You must configure `allowed_command
79
82
  "provider": "openai",
80
83
  "max_execution_time": 30,
81
84
  "allowed_commands": ["rspec", "bundle", "ruby", "git"],
85
+ "allow_host_execution": false,
82
86
  "config": {
83
87
  "api_key": null,
84
88
  "model": "gpt-4o"
@@ -87,6 +91,8 @@ By default, no shell commands are permitted. You must configure `allowed_command
87
91
  ```
88
92
 
89
93
  > **Security:** The agent can only execute commands on this list. Dangerous commands (bash, curl, sudo, etc.) are always blocked regardless of configuration.
94
+ >
95
+ > **Where commands run:** Allowed commands run inside a temporary git **sandbox directory** on the host — a copy of your eval files, not your project. True container isolation (Docker) is **not yet shipped**, so the sandbox directory is the only boundary. Because of this, host execution **fails closed**: it is disabled by default and must be explicitly enabled with `"allow_host_execution": true`. With it disabled (the default), `run_command` refuses to execute and returns an error instead of running un-isolated. Enable it only when you accept that allowed commands run directly on your machine.
90
96
 
91
97
  ### Configuration Hierarchy
92
98
 
@@ -137,7 +143,9 @@ skill-bench init --openai
137
143
  }
138
144
  ```
139
145
 
140
- **Available providers:** `--openai`, `--anthropic`, `--gemini`, `--ollama`, `--azure`, `--groq`, `--deepseek`, `--opencode`
146
+ **Available providers:** `--openai`, `--anthropic`, `--gemini`, `--ollama`, `--azure`, `--groq`, `--deepseek`, `--mistral`, `--opencode`
147
+
148
+ **Zero-config offline path:** `skill-bench init --mock` scaffolds a minimal offline config that needs no API key and no network — `{"provider":"mock","max_execution_time":30}`. Use it to try the full flow (and run the bundled examples) before wiring up a real provider.
141
149
 
142
150
  Use `--force` to overwrite an existing config.
143
151
 
@@ -338,7 +346,7 @@ skill-bench run my-first-eval --skill=my-service
338
346
  3. **Context run** — Agent receives `task.md` + `SKILL.md` as prompt → produces output B
339
347
  4. **Blind judging** — LLM judge scores output A and output B independently across the dimensions defined in `criteria.json`
340
348
  5. **Delta computation** — Compare scores, compute deltas, apply pass/fail logic
341
- 6. **History recording** — Store result in `.skill-bench-history.json` for trend tracking
349
+ 6. **History recording** — Store result in `.skill-bench-trends.json` for trend tracking
342
350
 
343
351
  Provider is read from `skill-bench.json` — no `--provider` flag needed.
344
352
 
@@ -350,11 +358,54 @@ skill-bench run my-first-eval --skill=skill-a --skill=skill-b
350
358
 
351
359
  Both skill contexts are concatenated and sent to the agent. The judge evaluates whether the combined context improves results.
352
360
 
353
- **Output Formats:**
361
+ **Output Formats:** `--format human` (default), `json`, `junit`, or `html`.
362
+
363
+ - Human-readable (default) — full delta table, iteration timeline, and a `Tokens: N | Est. Cost: $X.XXXX` line.
364
+ - JSON: `--format json` — machine-readable, including top-level `tokens` and `cost` fields.
365
+ - JUnit XML: `--format junit` — for CI test reporting.
366
+ - HTML: `--format html` — a self-contained, shareable report (styles inlined, no external assets) with the delta table and iteration timeline. Redirect it to a file:
367
+
368
+ ```bash
369
+ skill-bench run my-first-eval --skill=my-service --format html > report.html
370
+ ```
371
+
372
+ ---
373
+
374
+ ## Pre-flight Checks: `validate` / `doctor`
375
+
376
+ Before spending tokens on a run, sanity-check your setup. `skill-bench validate` (aliased as `doctor`) runs read-only pre-flight checks — it never runs an eval and never makes a network call:
377
+
378
+ ```bash
379
+ skill-bench validate
380
+ # or, identically:
381
+ skill-bench doctor
382
+ ```
383
+
384
+ It runs three checks and prints a `PASS` / `FAIL` / `SKIP` line for each:
385
+
386
+ 1. **criteria** — validates the criteria JSON (default `criteria.json`, override with `--criteria PATH`). Skipped if the default file is absent.
387
+ 2. **config** — schema-checks `skill-bench.json` (default, override with `--config PATH`): `provider` is required and must be a known provider, `max_execution_time` must be a positive integer, and `config` (when present) must be an object.
388
+ 3. **provider key** — reports whether the configured provider's API key is present (the `mock` provider needs none).
354
389
 
355
- - Human-readable (default)
356
- - JSON: `--format json`
357
- - JUnit XML: `--format junit`
390
+ A passing report exits `0`:
391
+
392
+ ```text
393
+ skill-bench validate
394
+
395
+ [PASS] criteria criteria.json is valid
396
+ [PASS] config skill-bench.json matches the expected shape
397
+ [PASS] provider key openai credentials present
398
+
399
+ All checks passed.
400
+ ```
401
+
402
+ A failure exits non-zero and names what is wrong:
403
+
404
+ ```text
405
+ [FAIL] provider key openai is missing: api_key
406
+
407
+ 1 check(s) failed.
408
+ ```
358
409
 
359
410
  ---
360
411
 
@@ -427,6 +478,22 @@ The `--variant` spec supports two forms:
427
478
  - `pack:<name>` — resolve via registry manifest
428
479
  - `/absolute/path` or `relative/path` — use a direct path
429
480
 
481
+ ### Response Caching (opt-in, `--cache`)
482
+
483
+ LLM responses can be cached so identical requests reuse a previous result instead of calling the provider again. Caching is **off by default**. Enable it per run with `--cache`, or set the `SKILL_BENCH_CACHE` environment variable to a truthy value (`1`, `true`, `yes`, or `on`):
484
+
485
+ ```bash
486
+ # Per-run flag
487
+ skill-bench run my-first-eval --skill=my-service --cache
488
+
489
+ # Or via the environment
490
+ SKILL_BENCH_CACHE=1 skill-bench run my-first-eval --skill=my-service
491
+ ```
492
+
493
+ The cache is in-memory (process-lifetime) and content-addressed: the key is a SHA-256 digest of the provider, model, system prompt, messages, tools, and temperature, so only truly identical requests share an entry. The `mock` and null providers are never cached.
494
+
495
+ This pays off most with `compare`, which runs the skill-less baseline twice with identical inputs — with caching enabled, the repeated baseline reuses the cached response instead of making a second call.
496
+
430
497
  ---
431
498
 
432
499
  ## File Reference: What Lives on Disk
@@ -446,6 +513,7 @@ SkillBench creates and manages three files in your project. Understanding them h
446
513
  "provider": "openai",
447
514
  "max_execution_time": 300,
448
515
  "allowed_commands": ["rspec", "bundle", "ruby", "git"],
516
+ "allow_host_execution": false,
449
517
  "config": {
450
518
  "api_key": "sk-...",
451
519
  "model": "gpt-4o",
@@ -458,10 +526,11 @@ SkillBench creates and manages three files in your project. Understanding them h
458
526
  - Configuration is loaded in this order: **code defaults** → `~/.skill-bench.json` (user-wide) → `./skill-bench.json` (local) → **environment variables**. Later sources override earlier ones.
459
527
  - If `api_key` is `null`, SkillBench looks for the matching environment variable (e.g. `SKILL_BENCH_OPENAI_API_KEY`).
460
528
  - `allowed_commands` is a **safeguard**, not a convenience. By default the agent cannot run *any* shell command. Add only what your evals need.
529
+ - `allow_host_execution` (default `false`) gates whether `run_command` may run on the host when no container isolation is active. Since container isolation is not yet shipped, leaving it `false` means `run_command` **fails closed** (refuses to execute). Set it to `true` only if you accept that allowed commands run directly on your machine inside the temporary sandbox directory.
461
530
 
462
531
  ---
463
532
 
464
- ### `.skill-bench-history.json` — Evaluation History (Auto-Generated)
533
+ ### `.skill-bench-trends.json` — Evaluation History (Auto-Generated)
465
534
 
466
535
  **What it is:** A JSON array that records every successful eval run. SkillBench appends to it automatically. It stores the timestamp, eval name, skill names, scores, and deltas so you can track improvement over time.
467
536
 
@@ -497,13 +566,13 @@ TREND: baseline ↑ (+2), context ↑ (+7)
497
566
 
498
567
  The trend compares the current run against the *previous run of the same eval + skill*. This tells you at a glance whether your latest skill edit made things better or worse.
499
568
 
500
- **Pro tip:** Commit `.skill-bench-history.json` to git if you want to share trend data with your team. Add it to `.gitignore` if you prefer to keep scores private.
569
+ **Pro tip:** `.skill-bench-trends.json` is git-ignored by default (via the `.skill-bench-trends.json*` line in `.gitignore`). If you want to share trend data with your team, remove that line so the file can be committed.
501
570
 
502
571
  ---
503
572
 
504
- ### `.skill-bench-history.json.bak` — Backup (Auto-Generated)
573
+ ### `.skill-bench-trends.json.bak` — Backup (Auto-Generated)
505
574
 
506
- **What it is:** A copy of `.skill-bench-history.json` created every time SkillBench writes a new entry. If the main file gets corrupted (e.g. you kill the process mid-write), SkillBench automatically falls back to the `.bak` file.
575
+ **What it is:** A snapshot of the *previous* good version of `.skill-bench-trends.json`, copied just before each new write. (The first run has no prior version yet, so it creates no `.bak`.) If the main file gets corrupted (e.g. you kill the process mid-write), SkillBench automatically falls back to the `.bak` file.
507
576
 
508
577
  **Who edits it:** Nobody. It is a safety net.
509
578
 
@@ -541,7 +610,7 @@ Read the output carefully. Look at **two things:**
541
610
  ### Step 3: Inspect the History
542
611
 
543
612
  ```bash
544
- cat .skill-bench-history.json | jq '.[-1]'
613
+ cat .skill-bench-trends.json | jq '.[-1]'
545
614
  ```
546
615
 
547
616
  This shows the latest entry. Focus on the dimension with the smallest delta — that is where your skill is weakest.
@@ -729,6 +798,7 @@ These 5 dimensions are **mandatory** in every `criteria.json`. You can add custo
729
798
  Eval: my-first-eval
730
799
  Skill: my-service
731
800
  Provider: openai
801
+ Tokens: 18432 | Est. Cost: $0.0934
732
802
  ═══════════════════════════════════════════════════════
733
803
 
734
804
  === BASELINE ITERATIONS ===
@@ -774,8 +844,9 @@ These 5 dimensions are **mandatory** in every `criteria.json`. You can add custo
774
844
  - **CONTEXT:** The agent's score *with* the skill. This is the "aided" performance.
775
845
  - **DELTA:** `CONTEXT - BASELINE`. How much the skill helped.
776
846
  - **TOTAL:** Sum of all dimension scores. Max possible is 100.
777
- - **TREND:** Comparison against the previous run of the same eval + skill (from `.skill-bench-history.json`). Shows whether scores are improving over time.
847
+ - **TREND:** Comparison against the previous run of the same eval + skill (from `.skill-bench-trends.json`). Shows whether scores are improving over time.
778
848
  - **VERDICT:** `PASS` only if `CONTEXT >= pass_threshold` AND `DELTA >= minimum_delta`.
849
+ - **Tokens / Est. Cost:** The header shows total tokens used across the run and an estimated USD cost as `Tokens: N | Est. Cost: $X.XXXX`. The cost is approximate — it comes from a built-in per-model price table (`Services::CostCalculator`) and shows `—` when the model isn't in that table. JSON output (`--format json`) exposes the same data as top-level `tokens` and `cost` fields.
779
850
 
780
851
  **Iteration timeline:**
781
852
 
@@ -827,7 +898,7 @@ Your eval result depends on **both** conditions. Here is every scenario:
827
898
 
828
899
  ## Reliability & Security
829
900
 
830
- - **Safe-by-Design**: No code execution occurs on the host system; everything happens in the sandbox.
901
+ - **Allowlist-Gated Execution**: The agent can only run commands you add to `allowed_commands`; with an empty allowlist it can run nothing. Commands run inside a temporary git sandbox **directory** (a copy of the eval files) on the host container isolation is not yet shipped, so host execution is **disabled by default** and must be explicitly opted into with `allow_host_execution: true`.
831
902
  - **Command Blocklist**: Dangerous commands (`bash`, `sh`, `python`, `curl`, etc.) are always blocked, even if listed in `allowed_commands`.
832
903
  - **Path Validation**: Eval paths are validated to prevent directory traversal attacks.
833
904
  - **Atomic History Writes**: Benchmark history uses file locking to prevent corruption from concurrent writes.
@@ -836,7 +907,7 @@ Your eval result depends on **both** conditions. Here is every scenario:
836
907
  - **Traceability**: Every thought and tool call is logged with full backtrace for post-mortem analysis.
837
908
  - **Robust Error Recovery**: Handles provider outages and rate limits gracefully with standardized error logging.
838
909
  - **XML-Safe Output**: JUnit XML output is properly escaped to prevent injection attacks.
839
- - **Test Coverage**: 373+ tests covering core engine, CLI commands, and all provider clients.
910
+ - **Test Coverage**: 700+ tests covering core engine, CLI commands, and all provider clients. Run `bundle exec rake test` to see the current count.
840
911
 
841
912
  ## Testing
842
913
 
@@ -855,9 +926,18 @@ bundle exec ruby -Itest test/integration_test.rb
855
926
 
856
927
  **Test Structure:**
857
928
 
858
- - `test/evaluator/` — Core evaluation engine tests
859
- - `test/agent_eval/` — CLI, models, and service tests
929
+ - `test/agent/` — Agent runtime tests
930
+ - `test/agent_eval/` — Agent evaluation tests
931
+ - `test/cli/` — CLI command tests
860
932
  - `test/clients/` — Provider client tests
933
+ - `test/evaluator/` — Core evaluation engine tests
934
+ - `test/history_recorder/` — Benchmark history persistence tests
935
+ - `test/models/` — Domain model tests
936
+ - `test/registry/` — Skill/eval registry tests
937
+ - `test/services/` — Service layer tests
938
+ - `test/skills/` — Skill loading tests
939
+ - `test/tools/` — Agent tool tests
940
+ - Plus several top-level `test/*_test.rb` files (e.g. `integration_test.rb`, `evaluation_runner_test.rb`, `trend_tracker_test.rb`).
861
941
 
862
942
  ---
863
943
 
@@ -886,11 +966,17 @@ Ruby Skill Bench is designed with security as a primary concern. The system exec
886
966
  - **Command Allowlist:** Only explicitly allowed commands can be executed
887
967
  - **Dangerous Commands Blocklist:** Dangerous commands (bash, curl, sudo, etc.) are always blocked
888
968
  - **Shell Tokenization:** Commands are tokenized before execution to prevent shell injection
889
- - **Docker Isolation:** Commands can be executed in isolated Docker containers with hardened security settings
969
+ - **Fail-Closed Host Execution:** Container isolation is not yet active, so commands run on the host inside a temporary sandbox directory. To match this reality, `run_command` refuses to execute unless `allow_host_execution: true` is set; it is **disabled by default**.
970
+
971
+ > **The allowlist is the only real authorization control — and it only checks the base command.** `run_command` authorizes by the first token of the command (`rake`, `find`, `git`, …); it does **not** inspect arguments. Shell tokenization stops metacharacter injection, but it does **not** sandbox what an allowlisted binary can do. Because many common tools are general-purpose execution wrappers, **allowlisting any one of them is equivalent to granting arbitrary host code execution** — for example `rake -e '...'`, `rspec -e`, `make` (arbitrary recipes), `find . -exec ...`, or `git` (hooks, `-c core.fsmonitor=...`, `! ...` aliases). Combined with the fail-closed model above (`run_command` refuses to run on the host unless `allow_host_execution` is explicitly enabled — see `HOST_EXECUTION_REFUSED` in `run_command.rb`), the practical guidance is: **keep `allowed_commands` as minimal as possible — empty for untrusted skills** — and treat every entry as if you were handing the skill a shell.
972
+ >
973
+ > An **optional, default-off** `command_argument_constraints` setting can refuse commands whose arguments contain configured substrings/flags (for example blocking `-e` or `-exec`). It is a defense-in-depth speed bump, **not** a sandbox, and is unset by default; the allowlist remains the control that matters.
974
+
975
+ #### Docker Security Hardening (Planned — Not Yet Active)
890
976
 
891
- #### Docker Security Hardening
977
+ > **Status:** The container isolation model described below is **planned, not shipped**. No Docker build context is packaged, so containers are never launched today — `run_command` runs on the host gated by the allowlist and `allow_host_execution`. The settings below document the intended hardened model for when container isolation lands.
892
978
 
893
- When Docker is available, containers are launched with hardened security settings:
979
+ When container isolation is enabled in a future release, containers are intended to launch with hardened security settings:
894
980
 
895
981
  - **Non-root User:** Containers run as a non-root user
896
982
  - **Privilege Prevention:** `--security-opt no-new-privileges` prevents privilege escalation
@@ -922,12 +1008,10 @@ When Docker is available, containers are launched with hardened security setting
922
1008
 
923
1009
  ### Reporting Security Issues
924
1010
 
925
- If you discover a security vulnerability:
926
-
927
- 1. **Do Not Open a Public Issue:** Send a private email to the maintainers
928
- 2. **Provide Details:** Include steps to reproduce and potential impact
929
- 3. **Allow Time for Fix:** Give maintainers time to address the issue before disclosure
930
- 4. **Follow Responsible Disclosure:** Follow responsible disclosure practices
1011
+ To report a security vulnerability, please follow the process in
1012
+ [SECURITY.md](SECURITY.md). **Do not open a public issue** — use GitHub's
1013
+ private vulnerability reporting (Security tab) or email the maintainer at
1014
+ [ismael.marin@gmail.com](mailto:ismael.marin@gmail.com).
931
1015
 
932
1016
  ---
933
1017
 
@@ -955,9 +1039,9 @@ If you discover a security vulnerability:
955
1039
  - **Solution:** Increase `max_execution_time` in your `skill-bench.json` or simplify the task
956
1040
  - **Check:** Verify the command isn't hanging or waiting for input
957
1041
 
958
- **Problem:** "Docker container failed to start"
959
- - **Solution:** Ensure Docker is running and you have permissions to run Docker commands
960
- - **Check:** Run `docker info` to verify Docker daemon is accessible
1042
+ **Problem:** "Command execution refused: no sandbox isolation is active and 'allow_host_execution' is not enabled"
1043
+ - **Cause:** Container isolation is not yet shipped, so commands would run on the host. SkillBench fails closed by default rather than run un-isolated.
1044
+ - **Solution:** Set `"allow_host_execution": true` in `skill-bench.json` to permit allowed commands to run directly on the host (inside the temporary sandbox directory). Enable it only when you accept that trade-off.
961
1045
 
962
1046
  **Problem:** "Context hydration failed"
963
1047
  - **Solution:** Verify the source path exists and is a directory
@@ -1006,15 +1090,62 @@ If you encounter issues not covered here:
1006
1090
 
1007
1091
  ## CI/CD Integration
1008
1092
 
1009
- GitHub Actions workflow included (`.github/workflows/ci.yml`):
1093
+ ### Batch Runs
1094
+
1095
+ Run every eval at once instead of one at a time:
1096
+
1097
+ ```bash
1098
+ # Every eval under the default evals/ directory
1099
+ skill-bench run --all --skill=my-service
1100
+
1101
+ # Or point at a specific directory
1102
+ skill-bench run --evals-dir path/to/evals --skill=my-service
1103
+ ```
1104
+
1105
+ A batch run exits `0` only when **every** eval passes and non-zero if any fail, so the process exit code is itself a CI gate. Two formats are built for batch consumption:
1106
+
1107
+ - `--summary` emits an aggregate JSON gate — `passed` / `failed` / `total` counts, summed `tokens` and `cost`, and the `worst_delta` eval (the smallest context-minus-baseline delta in the batch). Archive it as a single machine-readable artifact:
1108
+
1109
+ ```bash
1110
+ skill-bench run --all --skill=my-service --summary
1111
+ ```
1112
+
1113
+ - `--format junit` aggregates the batch into one JUnit document with **one `<testcase>` per eval** (a `<failure>` child for each failing eval), so test reporters show per-eval results:
1114
+
1115
+ ```bash
1116
+ skill-bench run --all --skill=my-service --format junit > junit.xml
1117
+ ```
1118
+
1119
+ ### GitHub Action
1120
+
1121
+ Downstream repos can gate a skill change on every push or PR with the bundled composite action. Add a step that references `igmarin/ruby-skill-bench@v1`:
1122
+
1123
+ ```yaml
1124
+ # .github/workflows/skill-bench.yml
1125
+ name: skill-bench
1126
+ on: [pull_request]
1127
+
1128
+ jobs:
1129
+ skill-bench:
1130
+ runs-on: ubuntu-latest
1131
+ steps:
1132
+ - uses: actions/checkout@v4
1133
+ - uses: igmarin/ruby-skill-bench@v1
1134
+ with:
1135
+ evals-dir: evals # directory scanned for evals (default: evals)
1136
+ skill: skills/my-service # skill applied to every eval (default: "")
1137
+ format: junit # human | json | junit | html (default: junit)
1138
+ ruby-version: "3.3" # Ruby for ruby/setup-ruby (default: 3.3)
1139
+ args: --summary # extra flags appended verbatim (e.g. --summary, --pack NAME)
1140
+ ```
1141
+
1142
+ The action installs the gem and runs `skill-bench run --all --evals-dir <evals-dir> --format <format>` (adding `--skill` when set and appending `args` verbatim). The run step's exit code is the gate. For a full copy-paste workflow template, see [`examples/ci/`](examples/ci/).
1143
+
1144
+ > The gem's own repository CI (`.github/workflows/ci.yml`) runs the test suite — rubocop, reek, and minitest against Ruby 3.3 and 3.4, on push and pull requests — and is separate from the reusable action above.
1010
1145
 
1011
- - Runs on push and pull requests
1012
- - Tests against Ruby 3.3 and 3.4
1013
- - Executes rubocop, reek, and minitest
1014
- - Outputs JUnit XML for test reporting
1146
+ To preview the machine-readable output locally:
1015
1147
 
1016
1148
  ```bash
1017
- # Run locally with CI output
1018
1149
  skill-bench run my-eval --skill=my-skill --format json
1019
1150
  ```
1020
1151
 
data/docs/architecture.md CHANGED
@@ -172,9 +172,11 @@ project-root/
172
172
  │ └── my-first-eval/
173
173
  │ ├── task.md # Agent prompt
174
174
  │ └── criteria.json # Scoring rules
175
- └── .skill-bench-history.json # Benchmark history (auto-generated)
175
+ └── .skill-bench-trends.json # Benchmark history (auto-generated)
176
176
  ```
177
177
 
178
+ A `.skill-bench-trends.json.bak` file is created automatically as a backup of the trend file.
179
+
178
180
  ### Skill Discovery
179
181
 
180
182
  Skills are discovered recursively. These are all valid:
@@ -268,7 +268,7 @@ Provider is read from `skill-bench.json` — no `--provider` flag needed.
268
268
  2. Agent runs **with** skill context → produces context output
269
269
  3. Judge scores both independently → per-dimension scores
270
270
  4. Engine computes deltas → applies pass/fail logic
271
- 5. Result is recorded in `.skill-bench-history.json` for trend tracking
271
+ 5. Result is recorded in `.skill-bench-trends.json` for trend tracking
272
272
 
273
273
  **Run with multiple skills:**
274
274
 
@@ -346,7 +346,7 @@ Both skill contexts are concatenated. The judge evaluates whether the combined c
346
346
  | **BASELINE** | Score without skill (unaided performance). Think: "How well does the AI do on its own?" |
347
347
  | **CONTEXT** | Score with skill (aided performance). Think: "How well does the AI do when it reads my skill?" |
348
348
  | **DELTA** | Improvement = CONTEXT - BASELINE. Think: "How many points did my skill add?" |
349
- | **TREND** | Change since the *previous* run of this exact eval + skill. Stored in `.skill-bench-history.json`. |
349
+ | **TREND** | Change since the *previous* run of this exact eval + skill. Stored in `.skill-bench-trends.json`. |
350
350
  | **VERDICT** | PASS only if CONTEXT >= threshold AND DELTA >= minimum_delta. Both must be true. |
351
351
  | **Iterations** | ReAct loop steps for each run: thought → tools → observation. Helps you understand *how* the agent worked. |
352
352
  | **What went well** | Dimensions scoring ≥ 80% of max, with judge reasoning. Strengths of your skill. |
@@ -417,10 +417,10 @@ Your first run probably will not pass. That is normal. Here is how to improve.
417
417
 
418
418
  ### Use the History File
419
419
 
420
- After each run, SkillBench appends to `.skill-bench-history.json`. You can read it to track progress:
420
+ After each run, SkillBench appends to `.skill-bench-trends.json`. You can read it to track progress:
421
421
 
422
422
  ```bash
423
- cat .skill-bench-history.json | jq '.[-1]'
423
+ cat .skill-bench-trends.json | jq '.[-1]'
424
424
  ```
425
425
 
426
426
  Look at the dimension with the **smallest delta**. That is where your skill is weakest. Open `SKILL.md` and add a concrete rule targeting that dimension.
@@ -467,7 +467,7 @@ Created by `skill-bench init`. Stores provider, API key, model, timeout, and all
467
467
  }
468
468
  ```
469
469
 
470
- ### `.skill-bench-history.json` — Evaluation History (Auto-Generated)
470
+ ### `.skill-bench-trends.json` — Evaluation History (Auto-Generated)
471
471
 
472
472
  A JSON array recording every successful eval run. SkillBench writes it automatically. It stores timestamps, eval names, skill names, scores, and deltas. This powers the **TREND** line in your output.
473
473
 
@@ -487,9 +487,9 @@ A JSON array recording every successful eval run. SkillBench writes it automatic
487
487
 
488
488
  **Tip:** Commit this file to git if you want to share trend data with your team.
489
489
 
490
- ### `.skill-bench-history.json.bak` — Backup (Auto-Generated)
490
+ ### `.skill-bench-trends.json.bak` — Backup (Auto-Generated)
491
491
 
492
- A safety copy of the history file. If the main file gets corrupted, SkillBench recovers from this backup automatically. You never need to touch it.
492
+ A snapshot of the previous good version of the history file, copied just before each new write. If the main file gets corrupted, SkillBench recovers from this backup automatically. You never need to touch it.
493
493
 
494
494
  ---
495
495
 
@@ -273,7 +273,7 @@ Both must be true. This prevents two failure modes:
273
273
  TREND: baseline ↑ (+2), context ↑ (+7)
274
274
  ```
275
275
 
276
- This compares the current run against the **previous run of the same eval + skill** (stored in `.skill-bench-history.json`).
276
+ This compares the current run against the **previous run of the same eval + skill** (stored in `.skill-bench-trends.json`).
277
277
 
278
278
  - `↑` = improved since last run
279
279
  - `↓` = regressed since last run
@@ -16,6 +16,7 @@ module SkillBench
16
16
  def self.call(initial_prompt, max_iterations, config)
17
17
  messages = [{ role: 'user', content: initial_prompt }]
18
18
  iterations_log = []
19
+ total_usage = empty_usage
19
20
  step_count = 0
20
21
 
21
22
  while step_count < max_iterations
@@ -24,24 +25,27 @@ module SkillBench
24
25
  step_result = Step.call(messages, config)
25
26
  iteration = step_result[:iteration]
26
27
  iterations_log << attach_step_number(iteration, step_count) if iteration
28
+ total_usage = add_usage(total_usage, step_result[:usage])
27
29
 
28
30
  unless step_result[:continue]
29
31
  final_result = step_result[:result] || { success: false, response: { error: { message: 'Step returned no result' } } }
30
- return merge_iterations(final_result, iterations_log)
32
+ return finalize(final_result, iterations_log, total_usage)
31
33
  end
32
34
 
33
35
  messages = step_result[:messages]
34
36
  end
35
37
 
36
- merge_iterations(
38
+ finalize(
37
39
  { success: false, response: { error: { message: Agent::ReactAgent::MAX_ITERATIONS_REACHED } } },
38
- iterations_log
40
+ iterations_log,
41
+ total_usage
39
42
  )
40
43
  rescue StandardError => e
41
44
  SkillBench::ErrorLogger.log_error(e, 'ReactAgent Error')
42
- merge_iterations(
45
+ finalize(
43
46
  { success: false, response: { error: { message: e.message } } },
44
- iterations_log
47
+ iterations_log,
48
+ total_usage
45
49
  )
46
50
  end
47
51
 
@@ -54,14 +58,45 @@ module SkillBench
54
58
  iteration.merge(step_number: step_count)
55
59
  end
56
60
 
57
- # Merges the collected iterations into the result response.
61
+ # Merges the collected iterations and accumulated usage into the response.
58
62
  #
59
63
  # @param result [Hash] The final result hash from the loop.
60
64
  # @param iterations_log [Array<Hash>] Collected iteration metadata.
61
- # @return [Hash] The result with :iterations injected into :response.
62
- def self.merge_iterations(result, iterations_log)
65
+ # @param total_usage [Hash] Summed token usage across all iterations.
66
+ # @return [Hash] The result with :iterations and :usage injected into :response.
67
+ def self.finalize(result, iterations_log, total_usage)
63
68
  response = result[:response] || {}
64
- result.merge(response: response.merge(iterations: iterations_log))
69
+ result.merge(response: response.merge(iterations: iterations_log, usage: total_usage))
70
+ end
71
+
72
+ # A zeroed token-usage accumulator.
73
+ #
74
+ # @return [Hash] Usage hash with prompt/completion/total token counts set to zero.
75
+ def self.empty_usage
76
+ { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
77
+ end
78
+
79
+ # Adds a single step's usage onto a running total.
80
+ #
81
+ # @param total [Hash] The running usage total.
82
+ # @param usage [Hash, nil] A step's usage hash (may be nil or empty).
83
+ # @return [Hash] A new summed usage hash.
84
+ def self.add_usage(total, usage)
85
+ usage ||= {}
86
+ {
87
+ prompt_tokens: total[:prompt_tokens] + token_count(usage, :prompt_tokens),
88
+ completion_tokens: total[:completion_tokens] + token_count(usage, :completion_tokens),
89
+ total_tokens: total[:total_tokens] + token_count(usage, :total_tokens)
90
+ }
91
+ end
92
+
93
+ # Reads a token count from a usage hash, tolerating string keys.
94
+ #
95
+ # @param usage [Hash] The usage hash.
96
+ # @param key [Symbol] The usage key (e.g. :prompt_tokens).
97
+ # @return [Integer] The token count, or zero when absent.
98
+ def self.token_count(usage, key)
99
+ (usage[key] || usage[key.to_s] || 0).to_i
65
100
  end
66
101
  end
67
102
  end
@@ -12,7 +12,8 @@ module SkillBench
12
12
  #
13
13
  # @param messages [Array<Hash>] The conversation history.
14
14
  # @param config [Hash] Configuration for this step (client params, system prompt, working dir).
15
- # @return [Hash] Step outcome containing :continue (boolean), :result (hash, if finished), and :messages.
15
+ # @return [Hash] Step outcome containing :continue (boolean), :result (hash, if finished),
16
+ # :usage (token usage for this step), and :messages.
16
17
  def self.call(messages, config)
17
18
  messages = messages.dup
18
19
  client_result = Client.call(
@@ -21,12 +22,14 @@ module SkillBench
21
22
  tools: Tools.definitions,
22
23
  **config[:client_params]
23
24
  )
25
+ usage = client_result[:usage] || {}
24
26
 
25
27
  unless client_result[:success]
26
28
  error_msg = client_result.dig(:response, :error, :message) || 'Unknown error'
27
29
  return {
28
30
  continue: false,
29
31
  result: client_result,
32
+ usage: usage,
30
33
  iteration: build_iteration(thought: '', tools_used: [], observation_summary: error_msg)
31
34
  }
32
35
  end
@@ -36,6 +39,7 @@ module SkillBench
36
39
  return {
37
40
  continue: false,
38
41
  result: { success: false, response: { error: { message: 'Empty response from LLM' } } },
42
+ usage: usage,
39
43
  iteration: build_iteration(thought: '', tools_used: [], observation_summary: 'Empty response from LLM')
40
44
  }
41
45
  end
@@ -51,6 +55,7 @@ module SkillBench
51
55
  return {
52
56
  continue: false,
53
57
  result: { success: true, response: { content: content } },
58
+ usage: usage,
54
59
  iteration: build_iteration(thought: thought, tools_used: [], observation_summary: '')
55
60
  }
56
61
  end
@@ -69,6 +74,7 @@ module SkillBench
69
74
  {
70
75
  continue: true,
71
76
  messages: messages,
77
+ usage: usage,
72
78
  iteration: build_iteration(thought: thought, tools_used: tools_used, observation_summary: observation_summary)
73
79
  }
74
80
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output_formatter'
4
+ require_relative '../services/summary_formatter'
5
+
6
+ module SkillBench
7
+ module Cli
8
+ # Prints the aggregate result of a batch `skill-bench run --all` command.
9
+ #
10
+ # Defaults to the human-readable batch summary, but can instead emit a
11
+ # JUnit document (`format: :junit`) or a JSON gate (`summary: true`). The
12
+ # returned exit code is always {OutputFormatter.batch_exit_code}, so CI
13
+ # gating works identically across every output mode.
14
+ class BatchResultPrinter
15
+ # Prints the aggregate summary and returns the appropriate exit code.
16
+ #
17
+ # @param aggregate [Hash] Aggregate envelope from BatchRunnerService.
18
+ # @param format [Symbol, nil] Output format (:junit for JUnit XML, else human).
19
+ # @param summary [Boolean] When true, print the JSON summary gate instead.
20
+ # @return [Integer] Exit code (0 when all pass, 1 when any fails).
21
+ def self.call(aggregate, format: nil, summary: false)
22
+ puts batch_output(aggregate, format: format, summary: summary)
23
+ OutputFormatter.batch_exit_code(aggregate)
24
+ end
25
+
26
+ # Selects the rendered batch output for the requested mode.
27
+ #
28
+ # `:junit` and `:json` produce machine-readable batch output; `:json` maps
29
+ # to the same JSON gate as `summary: true`. `:html` (and any other format)
30
+ # falls back to the human batch summary, since there is no batch HTML report.
31
+ #
32
+ # @param aggregate [Hash] Aggregate envelope from BatchRunnerService.
33
+ # @param format [Symbol, nil] Output format (:junit, :json, else human).
34
+ # @param summary [Boolean] When true, render the JSON summary gate.
35
+ # @return [String] The formatted batch output.
36
+ def self.batch_output(aggregate, format:, summary:)
37
+ return Services::SummaryFormatter.format(aggregate) if summary || format == :json
38
+ return Services::JUnitFormatter.format_batch(aggregate) if format == :junit
39
+
40
+ OutputFormatter.format_batch(aggregate)
41
+ end
42
+ private_class_method :batch_output
43
+ end
44
+ end
45
+ end