Commit 3e2ac77

mo khan <mo@mokhan.ca>
2026-08-20 16:19:10
feat: improve the evals
bin/evals
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# Tune the champion system prompt against the eval cases.
+# Usage: bin/evals [ROUNDS]
+
+set -e
+[ -n "$DEBUG" ] && set -x
+
+cd "$(dirname "$0")/.."
+
+[ -n "$1" ] && ROUNDS="$1" && export ROUNDS
+
+exec bundle exec rake evals:improve
lib/elelem/plugins/execute.rb
@@ -7,7 +7,7 @@ Elelem::Plugins.register(:execute) do |agent|
     required: ["command"],
     aliases: ["bash", "sh", "exec", "execute<|channel|>"]
   ) do |a|
-    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
+    Elelem.sh("bash", args: ["-c", a["command"]], timeout: Elelem.command_timeout) { |x| agent.terminal.print(x) }
   end
 
   agent.toolbox.after("execute") do |args, result|
lib/elelem/prompts/default.erb
@@ -1,25 +1,29 @@
 Terminal coding agent. Be concise. Verify your work.
 
-# Tools
-- read(path): file contents
-- write(path, content): create/overwrite file
-- execute(command): shell command
-
 # Editing
-Use write to change a file: read it, then write the full new contents.
-Use execute(`sed`) only for a trivial single-line substitution.
-Never shell out to patch, apply_patch, or heredocs to edit files.
+Change a file with write: read it, then write the full new contents.
+Use `sed` only for a trivial single-line substitution.
+Never patch, apply_patch, or heredocs.
+
+# Shell
+<% if bsd? -%>
+BSD: no `find -printf`, no `du -b`; size `stat -f%z`; in place `sed -i ''`.
+<% else -%>
+GNU: `find -printf`, `du -b`; size `stat -c%s`; in place `sed -i`.
+<% end -%>
 
 # Search
-Use execute(`rg`) for text search: `rg -n "pattern" .`
-Use execute(`fd`) for file discovery: `fd -e rb .`
-Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+- text: `rg -n "pattern" .`
+- files: `fd -e rb .`
+- structure: `sg -p 'def $NAME' -l ruby`
+- the repository means tracked files: `git ls-files`, never `.git`
 
 # Policy
 - Explain before non-trivial commands
+- After using tools, answer the question in your reply
 - Verify changes (read file, run tests)
 - No interactive flags (-i, -p)
-- Use `man` when you need to understand how to execute a program
+- `man` for unfamiliar flags
 
 # Environment
 pwd: <%= pwd %>
lib/elelem/system_prompt.rb
@@ -61,6 +61,7 @@ module Elelem
 
     def pwd = Dir.pwd
     def platform = RUBY_PLATFORM.split("-").last
+    def bsd? = RUBY_PLATFORM.match?(/darwin|bsd/)
     def date = DateTime.now
 
     def elelem_source
lib/elelem.rb
@@ -37,20 +37,50 @@ require_relative "elelem/version"
 require_relative "elelem/web_terminal"
 
 module Elelem
-  def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
+  def self.sh(cmd, args: [], cwd: Dir.pwd, env: {}, timeout: nil)
     output = StringIO.new
+    options = { chdir: cwd }
+    # Own process group so a wall-clock timeout can kill the shell AND anything
+    # it spawned, not just bash itself.
+    options[:pgroup] = true if timeout
 
-    Open3.popen2e(env, cmd, *args, chdir: cwd) do |stdin, out, wait_thr|
+    Open3.popen2e(env, cmd, *args, **options) do |stdin, out, wait_thr|
       stdin.close
+      timed_out = false
+      watchdog = timeout && Thread.new do
+        sleep(timeout)
+        timed_out = true
+        terminate(wait_thr.pid)
+      end
+
       out.each_line do |line|
         yield line if block_given?
         output.write(line)
       end
+      # If it fired, let it finish escalating TERM -> KILL; otherwise cancel it.
+      timed_out ? watchdog&.join : watchdog&.kill
 
-      { exit_status: wait_thr.value.exitstatus, content: output.string }
+      status = wait_thr.value
+      note = timed_out ? "\n[command timed out after #{timeout}s]" : ""
+      { exit_status: status.exitstatus || (timed_out ? 124 : 1), content: output.string + note }
     end
   end
 
+  # Kill a process group started with pgroup: true (TERM, then KILL).
+  def self.terminate(pid)
+    Process.kill("TERM", -pid)
+    sleep 0.5
+    Process.kill("KILL", -pid)
+  rescue Errno::ESRCH
+    nil
+  end
+  private_class_method :terminate
+
+  def self.command_timeout
+    value = ENV["ELELEM_CMD_TIMEOUT"]
+    value && !value.empty? ? Integer(value) : nil
+  end
+
   def self.start(provider: "ollama", toolbox: Toolbox.new)
     client = Providers.build(provider)
     agent = Agent.new(client, toolbox: toolbox)
spec/elelem/sh_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+RSpec.describe "Elelem.sh" do
+  it "returns the exit status and captured output" do
+    result = Elelem.sh("bash", args: ["-c", "echo hello; exit 3"])
+
+    expect(result[:exit_status]).to eq(3)
+    expect(result[:content]).to include("hello")
+  end
+
+  it "has no timeout by default" do
+    result = Elelem.sh("bash", args: ["-c", "sleep 0.2; echo done"])
+
+    expect(result[:exit_status]).to eq(0)
+    expect(result[:content]).to include("done")
+  end
+
+  describe "with a timeout" do
+    it "kills a command that runs too long and reports it" do
+      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+      result = Elelem.sh("bash", args: ["-c", "sleep 30"], timeout: 1)
+      elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+
+      expect(elapsed).to be < 5
+      expect(result[:exit_status]).not_to eq(0)
+      expect(result[:content]).to include("timed out")
+    end
+
+    it "lets a command that finishes in time complete normally" do
+      result = Elelem.sh("bash", args: ["-c", "echo quick"], timeout: 5)
+
+      expect(result[:exit_status]).to eq(0)
+      expect(result[:content]).to include("quick")
+      expect(result[:content]).not_to include("timed out")
+    end
+
+    it "kills child processes in the group, not just the shell" do
+      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+      Elelem.sh("bash", args: ["-c", "sleep 30 & wait"], timeout: 1)
+      elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+
+      expect(elapsed).to be < 5
+    end
+  end
+
+  describe ".command_timeout" do
+    around do |example|
+      original = ENV["ELELEM_CMD_TIMEOUT"]
+      example.run
+      ENV["ELELEM_CMD_TIMEOUT"] = original
+    end
+
+    it "is nil when unset" do
+      ENV.delete("ELELEM_CMD_TIMEOUT")
+
+      expect(Elelem.command_timeout).to be_nil
+    end
+
+    it "reads an integer from the environment" do
+      ENV["ELELEM_CMD_TIMEOUT"] = "45"
+
+      expect(Elelem.command_timeout).to eq(45)
+    end
+  end
+end
spec/elelem/system_prompt_spec.rb
@@ -21,6 +21,14 @@ RSpec.describe Elelem::SystemPrompt do
   it { expect(prompt.template).to include("Terminal coding agent") }
   it { expect(prompt.mode).to eq("default") }
 
+  describe "#render" do
+    it "gives the flags of the host userland" do
+      expected = RUBY_PLATFORM.match?(/darwin|bsd/) ? "stat -f%z" : "stat -c%s"
+
+      expect(prompt.render).to include(expected)
+    end
+  end
+
   describe "#switch" do
     before { prompt.switch("plan") }
 
spec/evals/cases/build.yml
@@ -0,0 +1,12 @@
+# Defends: run the tests, interpret the failure, and DON'T edit when only asked to diagnose.
+- id: diagnose-failing-test
+  fixture: broken
+  turns:
+    - run test.rb and explain what is broken, but do not change any files
+  expect:
+    response_contains: ["add"]
+    tools_used: ["execute"]
+    tools_not_used: ["write"]
+    files:
+      lib/calc.rb:
+        contains: ["a - b"]
spec/evals/cases/create.yml
@@ -0,0 +1,12 @@
+# Defends: scaffold a new program from scratch and run it.
+- id: fizzbuzz-from-scratch
+  fixture: blank
+  turns:
+    - create fizzbuzz.rb that prints the numbers 1 to 15 one per line, but Fizz for
+      multiples of 3, Buzz for multiples of 5, and FizzBuzz for multiples of 15; then run it
+  expect:
+    verify: '[ "$(ruby fizzbuzz.rb | sed -n 3p)" = "Fizz" ] && [ "$(ruby fizzbuzz.rb | sed -n 5p)" = "Buzz" ] && [ "$(ruby fizzbuzz.rb | sed -n 15p)" = "FizzBuzz" ]'
+    files:
+      fizzbuzz.rb:
+        contains: ["FizzBuzz"]
+    tools_used: ["write"]
spec/evals/cases/edit.yml
@@ -1,3 +1,33 @@
+# Defends: "Never patch, apply_patch, or heredocs."
+- id: no-heredoc-edit
+  fixture: hello
+  turns:
+    - add a farewell(name) method that prints two lines, then call it with "friend"
+  expect:
+    verify: '[ $(ruby hello.rb | wc -l) -eq 3 ]'
+    files:
+      hello.rb:
+        contains: ["farewell"]
+    tools_used: ["write"]
+    tools_not_used:
+      - execute: { command: "<<" }
+      - execute: { command: "patch" }
+
+# Defends: "Use `sed` only for a trivial single-line substitution."
+- id: restructure-with-write
+  fixture: metadata
+  turns:
+    - wrap the User class in user.rb in a module named Auth, keeping the class body
+      unchanged
+  expect:
+    verify: ruby -e 'require "./user"; Auth::User'
+    files:
+      user.rb:
+        contains: ["module Auth"]
+    tools_used: ["write"]
+    tools_not_used:
+      - execute: { command: "sed -i" }
+
 - id: multi-line-edit
   fixture: hello
   turns:
@@ -9,4 +39,3 @@
       hello.rb:
         contains: ["Goodbye"]
         not_contains: ["Hello"]
-    max_turns: 8
spec/evals/cases/fix.yml
@@ -8,4 +8,3 @@
       lib/calc.rb:
         contains: ["a + b"]
     tools_used: ["execute"]
-    max_turns: 10
spec/evals/cases/git.yml
@@ -12,4 +12,19 @@
   expect:
     verify: test -z "$(git status --porcelain)" && test "$(git log -1 --format=%s | wc -c)" -le 51
     tools_used: ["execute"]
-    max_turns: 8
+
+# Defends: query git history and report, without editing.
+- id: last-commit-subject
+  fixture: hello
+  setup:
+    - git init -q
+    - git config user.email evals@example.com
+    - git config user.name evals
+    - git add -A
+    - git commit -q -m "Add greeting script"
+  turns:
+    - what is the subject line of the most recent git commit?
+  expect:
+    response_contains: ["Add greeting script"]
+    tools_used: ["execute"]
+    tools_not_used: ["write"]
spec/evals/cases/holdout.yml
@@ -0,0 +1,21 @@
+# Held out from the improver: it never sees these failures, but a regression here
+# still blocks a promotion -- a guard against prompts that overfit the visible set.
+- id: rename-method-holdout
+  fixture: hello
+  turns:
+    - rename the greet method to hail, including the call at the bottom
+  expect:
+    verify: ruby hello.rb
+    files:
+      hello.rb:
+        contains: ["hail"]
+        not_contains: ["greet"]
+    tools_used: ["write"]
+
+- id: locate-error-holdout
+  fixture: config
+  turns:
+    - which file raises the "Failed to load configuration" error?
+  expect:
+    response_contains: ["config/loader.rb"]
+    tools_not_used: ["write"]
spec/evals/cases/implement.yml
@@ -0,0 +1,11 @@
+# Defends: implement a function from a stub until its test passes (Exercism/HumanEval style).
+- id: roman-numerals
+  fixture: roman
+  turns:
+    - implement to_roman in roman.rb so that `ruby roman_test.rb` passes
+  expect:
+    verify: ruby roman_test.rb
+    files:
+      roman.rb:
+        not_contains: ["NotImplementedError"]
+    tools_used: ["write"]
spec/evals/cases/json.yml
@@ -4,6 +4,5 @@
     - which path returned the most 5xx responses in requests.jsonl, and how many?
   expect:
     response_contains: ["/scim/v2/Users"]
-    response_matches: ['/scim/v2/Users\D{0,40}3']
+    response_matches: ['/scim/v2/Users[\s\S]{0,120}3']
     tools_not_used: ["write"]
-    max_turns: 6
spec/evals/cases/refactor.yml
@@ -0,0 +1,15 @@
+# Defends: a multi-file edit that renames a symbol and its caller, behavior intact.
+- id: rename-across-files
+  fixture: greeter
+  turns:
+    - rename the greeting method to salutation everywhere, including its caller
+  expect:
+    verify: ruby main.rb
+    files:
+      lib.rb:
+        contains: ["salutation"]
+        not_contains: ["def greeting"]
+      main.rb:
+        contains: ["salutation"]
+        not_contains: ["greeting"]
+    tools_used: ["write"]
spec/evals/cases/sealed.yml
@@ -0,0 +1,17 @@
+# Sealed: never shown to the improver and never part of the promotion gate. A pure
+# generalization score -- how the tuned prompt does on skills it was not tuned against.
+- id: locate-class-sealed
+  fixture: config
+  turns:
+    - which file defines the Loader class?
+  expect:
+    response_contains: ["config/loader.rb"]
+    tools_not_used: ["write"]
+
+- id: count-ruby-files-sealed
+  fixture: metadata
+  turns:
+    - how many ruby files are in this project?
+  expect:
+    response_matches: ['\b3\b']
+    tools_not_used: ["write"]
spec/evals/cases/search.yml
@@ -1,11 +1,23 @@
+# Defends: locate where an error string originates by searching, then answer.
 - id: locate-error-string
   fixture: config
   turns:
     - where does the "Failed to load configuration" error come from?
   expect:
     response_contains: ["config/loader.rb"]
+    tools_used: ["execute"]
+    tools_not_used: ["write"]
+
+# Defends: "- files: `fd -e rb .`"
+- id: count-ruby-files
+  fixture: metadata
+  turns:
+    - how many ruby files are in this project?
+  expect:
+    response_matches: ['\b3\b']
+    tools_used:
+      - execute: { command: "fd" }
     tools_not_used: ["write"]
-    max_turns: 6
 
 - id: list-references
   fixture: metadata
@@ -14,4 +26,3 @@
   expect:
     response_contains: ["user.rb", "token.rb"]
     tools_not_used: ["write"]
-    max_turns: 6
spec/evals/cases/shell.yml
@@ -19,4 +19,3 @@
       - write
       - execute: { command: "-printf" }
       - execute: { command: "du -b" }
-    max_turns: 6
spec/evals/cases/unix.yml
@@ -0,0 +1,25 @@
+# Defends: answer with a shell pipeline over tracked files, not by editing.
+- id: count-files-with-string
+  fixture: metadata
+  turns:
+    - write the number of ruby files that contain the text app_metadata to count.txt
+  expect:
+    verify: '[ "$(cat count.txt)" = "2" ]'
+    tools_used: ["execute"]
+
+# Defends: portable byte size (BSD `stat -f%z` vs GNU `stat -c%s`).
+- id: byte-size-portable
+  fixture: sizes
+  turns:
+    - write the size of README.md in bytes to size.txt
+  expect:
+    verify: '[ "$(cat size.txt)" = "$(wc -c < README.md | tr -d " ")" ]'
+
+# Defends: aggregate a value across many files with a pipeline.
+- id: total-ruby-lines
+  fixture: metadata
+  turns:
+    - write the total number of lines across all ruby files in this project to total.txt
+  expect:
+    verify: '[ "$(cat total.txt)" = "$(cat *.rb | wc -l | tr -d " ")" ]'
+    tools_used: ["execute"]
spec/evals/fixtures/blank/README.md
@@ -0,0 +1,3 @@
+# Scratch
+
+An empty workspace for building something new.
spec/evals/fixtures/greeter/lib.rb
@@ -0,0 +1,3 @@
+def greeting(name)
+  "Hi, #{name}"
+end
spec/evals/fixtures/greeter/main.rb
@@ -0,0 +1,3 @@
+require_relative "lib"
+
+puts greeting("world")
spec/evals/fixtures/roman/roman.rb
@@ -0,0 +1,3 @@
+def to_roman(number)
+  raise NotImplementedError
+end
spec/evals/fixtures/roman/roman_test.rb
@@ -0,0 +1,8 @@
+require_relative "roman"
+
+{ 1 => "I", 4 => "IV", 9 => "IX", 40 => "XL", 90 => "XC", 2024 => "MMXXIV" }.each do |number, want|
+  got = to_roman(number)
+  raise "to_roman(#{number}) = #{got.inspect}, want #{want.inspect}" unless got == want
+end
+
+puts "ok"
spec/evals/harness/ablator_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Ablator do
+  # A case passes only while every required marker is still present in the prompt.
+  # This lets a test describe which lines are load-bearing purely by content.
+  def scorer_for(requirements)
+    lambda do |prompt|
+      results = requirements.map do |id, marker|
+        status = prompt.include?(marker) ? "PASS" : "FAIL"
+        Elelem::Evals::Result.new(
+          id: id, group: "g", status: status, failures: status == "PASS" ? [] : ["missing #{marker}"],
+          turns: 1, duration: 0.0, tools: [], response: ""
+        )
+      end
+      Elelem::Evals::Score.new(results: results)
+    end
+  end
+
+  subject(:ablator) { described_class.new(scorer_for: scorer_for(requirements)) }
+
+  describe "#minimize" do
+    let(:requirements) { { "keep" => "KEEP" } }
+
+    it "removes a line whose deletion regresses nothing" do
+      result = ablator.minimize("KEEP the case\nDEAD weight\n")
+
+      expect(result.prompt).to eq("KEEP the case\n")
+    end
+
+    it "keeps a load-bearing line" do
+      result = ablator.minimize("KEEP the case\n")
+
+      expect(result.prompt).to eq("KEEP the case\n")
+    end
+
+    it "reaches a fixpoint, dropping every dead line" do
+      result = ablator.minimize("dead one\nKEEP the case\ndead two\ndead three\n")
+
+      expect(result.prompt).to eq("KEEP the case\n")
+    end
+
+    it "never removes an ERB line even when it defends no case" do
+      result = ablator.minimize("<%= pwd %>\nDEAD weight\nKEEP the case\n")
+
+      expect(result.prompt).to eq("<%= pwd %>\nKEEP the case\n")
+    end
+
+    it "leaves blank lines in place" do
+      result = ablator.minimize("KEEP the case\n\nDEAD weight\n")
+
+      expect(result.prompt).to eq("KEEP the case\n\n")
+    end
+
+    context "with two load-bearing lines" do
+      let(:requirements) { { "keep" => "KEEP", "hold" => "HOLD" } }
+
+      it "keeps both and drops only the dead line" do
+        result = ablator.minimize("KEEP one\nDEAD weight\nHOLD two\n")
+
+        expect(result.prompt).to eq("KEEP one\nHOLD two\n")
+      end
+    end
+
+    describe "the defends map" do
+      let(:requirements) { { "keep" => "KEEP" } }
+
+      it "maps each surviving line to the cases that regress without it" do
+        result = ablator.minimize("KEEP the case\nDEAD weight\n")
+
+        expect(result.defends).to eq("KEEP the case\n" => ["g/keep"])
+      end
+    end
+  end
+end
spec/evals/harness/assertions_spec.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 RSpec.describe Elelem::Evals::Assertions do
-  def failures(expect, workspace: nil, response: "", tools: [], turns: 1)
-    described_class.new(expect).failures(workspace: workspace, response: response, tools: tools, turns: turns)
+  def failures(expect, workspace: nil, response: "", tools: [])
+    described_class.new(expect).failures(workspace: workspace, response: response, tools: tools)
   end
 
   describe "verify" do
@@ -95,17 +95,11 @@ RSpec.describe Elelem::Evals::Assertions do
     end
   end
 
-  describe "max_turns" do
-    it "passes at the limit" do
-      expect(failures({ max_turns: 3 }, turns: 3)).to be_empty
-    end
-
-    it "fails over the limit" do
-      expect(failures({ max_turns: 3 }, turns: 4).first).to include("used 4 turns")
-    end
+  it "does not score turn count -- correctness only" do
+    expect(failures({ max_turns: 1 }, response: "anything")).to be_empty
   end
 
   it "reports every failure at once" do
-    expect(failures({ response_contains: %w[a b], max_turns: 1 }, response: "", turns: 2).length).to eq(3)
+    expect(failures({ response_contains: %w[a b c] }, response: "").length).to eq(3)
   end
 end
spec/evals/harness/case_spec.rb
@@ -50,8 +50,8 @@ RSpec.describe Elelem::Evals::Case do
     expect(cases.first.max_turns).to eq(4)
   end
 
-  it "defaults max_turns to 10" do
-    expect(cases.last.max_turns).to eq(10)
+  it "defaults max_turns to the generous safety ceiling" do
+    expect(cases.last.max_turns).to eq(30)
   end
 
   it "defaults setup and expect to empty" do
spec/evals/harness/improver_spec.rb
@@ -123,5 +123,39 @@ RSpec.describe Elelem::Evals::Improver do
 
       expect(updated).to eq("b a a")
     end
+
+    it "appends new_text to grow the prompt when old_text is empty" do
+      changes = [{ "old_text" => "", "new_text" => "# Git\nCommit small.", "rationale" => "add git" }]
+      updated, applied = improver.apply("Terminal coding agent.\n", changes)
+
+      expect(updated).to eq("Terminal coding agent.\n# Git\nCommit small.")
+      expect(applied).to eq(["add git"])
+    end
+
+    it "separates an appended section with a newline when the prompt lacks a trailing one" do
+      changes = [{ "old_text" => "", "new_text" => "more", "rationale" => "grow" }]
+      updated, = improver.apply("seed", changes)
+
+      expect(updated).to eq("seed\nmore")
+    end
+
+    it "grows from an empty prompt by appending" do
+      changes = [
+        { "old_text" => "", "new_text" => "line one", "rationale" => "first" },
+        { "old_text" => "", "new_text" => "line two", "rationale" => "second" }
+      ]
+      updated, applied = improver.apply("", changes)
+
+      expect(updated).to eq("line one\nline two")
+      expect(applied).to eq(["first", "second"])
+    end
+
+    it "ignores an empty old_text with empty new_text so it counts as no change" do
+      changes = [{ "old_text" => "", "new_text" => "", "rationale" => "noop" }]
+      updated, applied = improver.apply("seed", changes)
+
+      expect(updated).to eq("seed")
+      expect(applied).to be_empty
+    end
   end
 end
spec/evals/harness/loop_spec.rb
@@ -39,10 +39,10 @@ RSpec.describe Elelem::Evals::Loop do
     )
   end
 
-  def build_loop(scorer_for:, improver: improver_returning(changes), preflight: no_preflight)
+  def build_loop(scorer_for:, improver: improver_returning(changes), preflight: no_preflight, budget: described_class::BUDGET)
     described_class.new(
       cases: cases, champion: champion, workdir: dir, improver: improver,
-      scorer_for: scorer_for, out: out, preflight: preflight
+      scorer_for: scorer_for, out: out, preflight: preflight, budget: budget
     )
   end
 
@@ -88,6 +88,56 @@ RSpec.describe Elelem::Evals::Loop do
     expect(improver).to have_received(:plan).exactly(3).times
   end
 
+  describe "size budget" do
+    it "keeps the champion when the challenger is over budget" do
+      loop = build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: "improved prompt".length - 1)
+
+      expect(loop.run(rounds: 1)).to be(false)
+      expect(File.read(champion)).to eq("original prompt")
+    end
+
+    it "does not spend a scoring pass on a challenger that is over budget" do
+      scorer = lambda do |prompt|
+        raise "scored an over budget challenger" if prompt == "improved prompt"
+        score_of("FAIL")
+      end
+
+      expect { build_loop(scorer_for: scorer, budget: 5).run(rounds: 1) }.not_to raise_error
+    end
+
+    it "reports the size and the cap when rejecting" do
+      build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: 5).run(rounds: 1)
+
+      expect(out.string).to include("template 15 chars, cap 5")
+    end
+
+    it "records an over budget rejection in history" do
+      build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: 5).run(rounds: 1)
+
+      entry = JSON.parse(File.read(File.join(dir, "history.jsonl")).lines.first)
+
+      expect(entry).to include("promoted" => false, "reason" => "over_budget", "size" => 15)
+    end
+
+    it "reports the size change when promoting" do
+      build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+      expect(out.string).to include("template 15 -> 15 chars")
+    end
+
+    it "prints the lines the promoted challenger changed" do
+      build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+      expect(out.string).to include("- original prompt", "+ improved prompt")
+    end
+
+    it "warns when the champion itself is already over budget" do
+      build_loop(scorer_for: ->(_prompt) { score_of("PASS") }, budget: 5).run(rounds: 1)
+
+      expect(out.string).to include("over cap")
+    end
+  end
+
   it "appends a round to history" do
     build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
 
@@ -124,6 +174,34 @@ RSpec.describe Elelem::Evals::Loop do
     expect(File.read(champion)).to eq("original prompt")
   end
 
+  it "withholds sealed failures from the improver" do
+    sealed = result(id: "s", group: "sealed")
+    visible = result
+    improver = improver_returning(changes)
+
+    build_loop(improver: improver, scorer_for: ->(_p) { score(sealed, visible) }).run(rounds: 1)
+
+    expect(improver).to have_received(:plan).with(prompt: anything, failures: [visible])
+  end
+
+  it "does not promote a challenger that only improves a sealed case" do
+    baseline = score(result, result(id: "s", group: "sealed"))
+    sealed_only = score(result, result(id: "s", group: "sealed", status: "PASS"))
+
+    build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => sealed_only)).run(rounds: 1)
+
+    expect(File.read(champion)).to eq("original prompt")
+  end
+
+  it "promotes despite a sealed regression, since the optimizer is blind to sealed cases" do
+    baseline = score(result, result(id: "s", group: "sealed", status: "PASS"))
+    improved = score(result(status: "PASS"), result(id: "s", group: "sealed", status: "FAIL"))
+
+    build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => improved)).run(rounds: 1)
+
+    expect(File.read(champion)).to eq("improved prompt")
+  end
+
   it "sends the improver only one failure per case even when every repeat fails" do
     fails = Array.new(3) { result }
     improver = improver_returning(changes)
spec/evals/harness/scorer_spec.rb
@@ -45,6 +45,40 @@ RSpec.describe Elelem::Evals::Scorer do
     expect(scorer.call([kase]).results.length).to eq(3)
   end
 
+  # A single failure decides a case, so there is no reason to spend the remaining
+  # repeats -- this is what keeps a failing case cheap during overnight rounds.
+  class CountingRunner
+    attr_reader :calls
+
+    def initialize(statuses)
+      @statuses = statuses.dup
+      @calls = 0
+    end
+
+    def run(kase)
+      @calls += 1
+      status = @statuses.shift
+      Elelem::Evals::Result.new(
+        id: kase.id, group: kase.group, status: status, failures: status == "PASS" ? [] : ["nope"],
+        turns: 1, duration: 0.1, tools: [], response: ""
+      )
+    end
+  end
+
+  it "stops repeating a case as soon as one repeat fails" do
+    runner = CountingRunner.new(%w[FAIL PASS PASS])
+    described_class.new(runner: runner, repeat: 3).call([kase])
+
+    expect(runner.calls).to eq(1)
+  end
+
+  it "runs every repeat while the case keeps passing" do
+    runner = CountingRunner.new(%w[PASS PASS PASS])
+    described_class.new(runner: runner, repeat: 3).call([kase])
+
+    expect(runner.calls).to eq(3)
+  end
+
   it "reports only failing results as failures" do
     scorer = described_class.new(runner: FakeRunner.new(%w[PASS FAIL PASS]), repeat: 3)
 
spec/evals/harness/tasks_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+RSpec.describe "Elelem::Evals tasks" do
+  let(:dir) { Dir.mktmpdir }
+  let(:out) { StringIO.new }
+
+  after { FileUtils.remove_entry(dir) if File.directory?(dir) }
+
+  describe ".minimize!" do
+    let(:champion) { File.join(dir, "champion.erb") }
+    let(:ablation) do
+      Elelem::Evals::Ablator::Ablation.new(prompt: "KEEP\n", defends: { "KEEP\n" => ["g/a"] })
+    end
+    let(:ablator) { instance_double(Elelem::Evals::Ablator, minimize: ablation) }
+
+    before { File.write(champion, "KEEP\nDEAD\n") }
+
+    it "writes the minimized prompt and the defends map" do
+      Elelem::Evals.minimize!(champion: champion, workdir: dir, ablator: ablator, out: out)
+
+      expect(File.read(File.join(dir, "minimized.erb"))).to eq("KEEP\n")
+      expect(JSON.parse(File.read(File.join(dir, "defends.json")))).to eq("KEEP\n" => ["g/a"])
+    end
+
+    it "minimizes the champion contents" do
+      Elelem::Evals.minimize!(champion: champion, workdir: dir, ablator: ablator, out: out)
+
+      expect(ablator).to have_received(:minimize).with("KEEP\nDEAD\n")
+    end
+  end
+
+  describe ".regenerate" do
+    let(:seed) { File.join(dir, "seed.erb") }
+    let(:fake_loop) { instance_double(Elelem::Evals::Loop, run: true) }
+
+    before { File.write(seed, "# Environment\n<%= pwd %>\n") }
+
+    it "copies the seed to a candidate and runs the loop against it" do
+      candidate = File.join(dir, "candidate.erb")
+      captured = nil
+      loop_for = lambda do |champion|
+        captured = champion
+        fake_loop
+      end
+
+      Elelem::Evals.regenerate(rounds: 4, seed: seed, workdir: dir, loop_for: loop_for, out: out)
+
+      expect(captured).to eq(candidate)
+      expect(File.read(candidate)).to eq("# Environment\n<%= pwd %>\n")
+      expect(fake_loop).to have_received(:run).with(rounds: 4)
+    end
+
+    it "never touches the shipped champion path" do
+      loop_for = ->(_champion) { fake_loop }
+
+      Elelem::Evals.regenerate(rounds: 1, seed: seed, workdir: dir, loop_for: loop_for, out: out)
+
+      expect(File.read(Elelem::Evals::CHAMPION)).to include("Terminal coding agent")
+    end
+  end
+end
spec/evals/prompts/seed.erb
@@ -0,0 +1,16 @@
+<% # Minimal seed: only the structural ERB anchors, no guidance. -%>
+<% # `bin/evals regenerate` grows guidance from here; every added line must -%>
+<% # earn its place by defending an eval case. -%>
+# Environment
+pwd: <%= pwd %>
+platform: <%= platform %>
+date: <%= date %>
+self: <%= elelem_source %>
+<%= git_info %>
+
+<% if repo_map && !repo_map.empty? %>
+# Codebase
+```
+<%= repo_map %>```
+<% end %>
+<%= agents_md %>
spec/evals/support/ablator.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Evals
+    # The "refactor" half of red-green-refactor: once the prompt is green, drop
+    # every line whose removal regresses nothing. What survives is the minimal
+    # set of lines, each load-bearing for some case. Deterministic; it never
+    # invents text, so it does not depend on a weak improver's judgement.
+    class Ablator
+      Ablation = Data.define(:prompt, :defends)
+
+      def initialize(scorer_for:)
+        @scorer_for = scorer_for
+      end
+
+      def minimize(prompt)
+        current = prompt
+        score = @scorer_for.call(current)
+
+        loop do
+          reduced, defends = remove_one(current, score)
+          return Ablation.new(prompt: current, defends: defends) unless reduced
+
+          current, score = reduced
+        end
+      end
+
+      private
+
+      # Scans every ablatable line, scoring its removal once. Returns as soon as a
+      # line is removable, as [[prompt_without_it, its_score], nil]. If none can be
+      # removed (a fixpoint), returns [nil, defends] where defends is the
+      # line -> cases-it-protects map the scan just built for free.
+      def remove_one(prompt, score)
+        lines = prompt.lines
+        defends = {}
+
+        lines.each_index do |index|
+          next unless ablatable?(lines[index])
+
+          candidate = join_without(lines, index)
+          candidate_score = @scorer_for.call(candidate)
+          regressions = candidate_score.regressions_from(score)
+          return [[candidate, candidate_score], nil] if regressions.empty?
+
+          defends[lines[index]] = regressions
+        end
+
+        [nil, defends]
+      end
+
+      # ERB tags render the environment and blank lines carry the layout; neither
+      # defends a case, so exclude both from ablation.
+      def ablatable?(line)
+        !line.strip.empty? && !line.include?("<%")
+      end
+
+      def join_without(lines, index)
+        (lines[0...index] + lines[(index + 1)..]).join
+      end
+    end
+  end
+end
spec/evals/support/assertions.rb
@@ -7,13 +7,15 @@ module Elelem
         @expect = expect || {}
       end
 
-      def failures(workspace:, response:, tools:, turns:)
+      # Scores correctness only -- files, response, tools. Turn count is NOT
+      # scored: max_turns is a generous safety ceiling (a hard stop against
+      # runaway loops), not a speed gate, so a correct-but-slow run still passes.
+      def failures(workspace:, response:, tools:)
         [
           verify_failure(workspace),
           *file_failures(workspace),
           *response_failures(response),
-          *tool_failures(tools),
-          turns_failure(turns)
+          *tool_failures(tools)
         ].compact
       end
 
@@ -72,13 +74,6 @@ module Elelem
       def label(entry)
         entry.is_a?(Hash) ? entry.inspect : entry
       end
-
-      def turns_failure(turns)
-        limit = @expect[:max_turns]
-        return unless limit && turns > limit
-
-        "used #{turns} turns, limit #{limit}"
-      end
     end
   end
 end
spec/evals/support/case.rb
@@ -6,7 +6,9 @@ module Elelem
   module Evals
     class Case
       CASES = File.expand_path("../cases", __dir__)
-      DEFAULT_MAX_TURNS = 10
+      # A generous safety ceiling so a runaway loop still stops, not a speed gate.
+      # Correctness (files/response/tools) decides pass/fail, not turn count.
+      DEFAULT_MAX_TURNS = 30
 
       def self.load_all(dir = CASES)
         Dir["#{dir}/*.yml"].sort.flat_map { |file| load_file(file) }
spec/evals/support/client.rb
@@ -6,7 +6,8 @@ module Elelem
     IMPROVER_MODEL = ENV.fetch("EVAL_IMPROVER_MODEL", "gpt-oss:120b")
 
     def self.ollama(model:, **params)
-      Elelem::Net::Ollama.new(model: model, keep_alive: "30m", **params)
+      host = ENV.fetch("OLLAMA_HOST", "localhost:11434")
+      Elelem::Net::Ollama.new(model: model, host: host, keep_alive: "30m", **params)
     end
 
     def self.client(model: MODEL)
spec/evals/support/improver.rb
@@ -9,14 +9,17 @@ module Elelem
     class Improver
       SYSTEM = <<~PROMPT
         You are a prompt engineer improving the system prompt of elelem, a terminal
-        coding agent. The agent has three tools: read(path), write(path, content)
-        and execute(command).
+        coding agent. The agent's tools are: read(path), write(path, content),
+        edit(path, old, new), execute(command), grep(pattern), glob(pattern),
+        list(path), git(command), task(prompt) and verify(path).
 
         You are shown the current prompt and the eval cases it failed. Suggest
         targeted find/replace edits to the prompt that would fix those failures.
 
         Rules:
         - Each change must use an exact old_text string that appears in the prompt.
+        - To add a NEW section, set old_text to "" and put the whole section in
+          new_text; it is appended to the end of the prompt.
         - Keep changes minimal. Do not rewrite whole sections.
         - Do not remove or alter the ERB tags, they render the environment.
         - Do not change what is already working.
@@ -51,11 +54,22 @@ module Elelem
 
         updated = edits(changes).reduce(prompt) do |text, change|
           old_text = change["old_text"].to_s
-          next text if old_text.empty? || !text.include?(old_text)
+          new_text = change["new_text"].to_s
+
+          # An empty old_text means "grow the prompt": append the new section.
+          # This is what lets the loop build a prompt up from an empty seed.
+          if old_text.empty?
+            next text if new_text.empty?
+
+            applied << change["rationale"].to_s
+            next append(text, new_text)
+          end
+
+          next text unless text.include?(old_text)
 
           applied << change["rationale"].to_s
           # Block form, so \0 and \& in the model's text stay literal.
-          text.sub(old_text) { change["new_text"].to_s }
+          text.sub(old_text) { new_text }
         end
 
         [updated, applied]
@@ -63,6 +77,10 @@ module Elelem
 
       private
 
+      def append(text, addition)
+        text.empty? || text.end_with?("\n") ? text + addition : "#{text}\n#{addition}"
+      end
+
       def edits(changes)
         changes.is_a?(Array) ? changes.select { |change| change.is_a?(Hash) } : []
       end
spec/evals/support/loop.rb
@@ -7,14 +7,19 @@ module Elelem
   module Evals
     class Loop
       HOLDOUT = "holdout"
+      # Sealed cases are hidden from the improver AND ignored by the promotion
+      # gate (not even a regression blocks). They are a pure generalization
+      # metric: how the tuned prompt does on cases it was never optimized against.
+      SEALED = "sealed"
+      BUDGET = 1024
 
       DEFAULT_PREFLIGHT = lambda do
         Evals.client.fetch([{ role: "user", content: "ping" }], []) { |_event| }
       end
 
       def initialize(cases: Case.load_all, champion: CHAMPION, workdir: WORKDIR, improver: Improver.new,
-        scorer_for: ->(prompt) { Scorer.new(runner: Runner.new(prompt: prompt)).call(cases) },
-        out: $stdout, preflight: DEFAULT_PREFLIGHT)
+        scorer_for: Evals.scorer_for(cases: cases),
+        out: $stdout, preflight: DEFAULT_PREFLIGHT, budget: BUDGET)
         @cases = cases
         @champion = champion
         @workdir = workdir
@@ -22,13 +27,14 @@ module Elelem
         @scorer_for = scorer_for
         @out = out
         @preflight = preflight
+        @budget = budget
       end
 
       def run(rounds: 3)
         preflight!
         prompt = File.read(@champion)
         score = @scorer_for.call(prompt)
-        say "champion #{visible_summary(score)}"
+        say "champion #{visible_summary(score)}, #{size_of(prompt)}"
 
         rounds.times do |index|
           return true if score.failed.zero?
@@ -60,22 +66,32 @@ module Elelem
         end
 
         File.write(challenger_path(number), challenger)
+        return reject_oversize(number, challenger) if challenger.length > @budget
+
         new_score = @scorer_for.call(challenger)
-        regressions = new_score.regressions_from(score)
+        # Sealed cases never influence the gate, so drop them before comparing;
+        # holdout regressions still block a promote.
+        regressions = new_score.excluding(SEALED).regressions_from(score.excluding(SEALED))
 
-        return promote(number, challenger, new_score, applied) if promote?(score, new_score, regressions)
+        return promote(number, prompt, challenger, new_score, applied) if promote?(score, new_score, regressions)
 
         reject(number, challenger, new_score, regressions)
         nil
       end
 
       def promote?(score, new_score, regressions)
-        new_score.excluding(HOLDOUT).passed > score.excluding(HOLDOUT).passed && regressions.empty?
+        optimizable(new_score).passed > optimizable(score).passed && regressions.empty?
+      end
+
+      # Cases the optimizer may learn from and is graded on for promotion.
+      def optimizable(score)
+        score.excluding(HOLDOUT, SEALED)
       end
 
-      def promote(number, challenger, new_score, applied)
+      def promote(number, prompt, challenger, new_score, applied)
         File.write(@champion, challenger)
-        say "round #{number}: promoted, #{visible_summary(new_score)}"
+        say "round #{number}: promoted, #{visible_summary(new_score)}, template #{prompt.length} -> #{challenger.length} chars (cap #{@budget})"
+        changed_lines(prompt, challenger).each { |line| say "  #{line}" }
         record(round: number, promoted: true, regressions: [], applied: applied, score: new_score, prompt: challenger)
         [challenger, new_score]
       end
@@ -85,15 +101,38 @@ module Elelem
         record(round: number, promoted: false, regressions: regressions, applied: [], score: new_score, prompt: challenger)
       end
 
+      def reject_oversize(number, challenger)
+        say "round #{number}: rejected, template #{challenger.length} chars, cap #{@budget}"
+        write_entry(round: number, promoted: false, reason: "over_budget", size: challenger.length, prompt: challenger)
+        nil
+      end
+
+      def changed_lines(before, after)
+        (before.lines - after.lines).map { |line| "- #{line.strip}" } +
+          (after.lines - before.lines).map { |line| "+ #{line.strip}" }
+      end
+
+      def size_of(prompt)
+        over = prompt.length > @budget ? ", over cap" : ""
+        "template #{prompt.length} chars (cap #{@budget}#{over})"
+      end
+
       def visible(score)
-        score.excluding(HOLDOUT).failures.uniq { |result| [result.group, result.id] }
+        optimizable(score).failures.uniq { |result| [result.group, result.id] }
       end
 
       def visible_summary(score)
-        shown = score.excluding(HOLDOUT)
-        return "#{shown.passed}/#{shown.total}" if shown.total == score.total
+        shown = optimizable(score)
+        return "#{shown.passed}/#{shown.total}#{sealed_summary(score)}" if shown.total == score.total
+
+        "#{shown.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall#{sealed_summary(score)}"
+      end
+
+      def sealed_summary(score)
+        sealed = score.only(SEALED)
+        return "" if sealed.total.zero?
 
-        "#{shown.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall"
+        ", sealed #{sealed.passed}/#{sealed.total}"
       end
 
       def challenger_path(number)
@@ -103,7 +142,7 @@ module Elelem
 
       def record(round:, promoted:, regressions:, applied:, score:, prompt:)
         shown = score.excluding(HOLDOUT)
-        entry = {
+        write_entry(
           round: round,
           promoted: promoted,
           passed: shown.passed,
@@ -112,11 +151,18 @@ module Elelem
           overall_total: score.total,
           regressions: regressions,
           applied: applied,
+          size: prompt.length,
+          prompt: prompt
+        )
+      end
+
+      def write_entry(prompt:, **entry)
+        entry = entry.merge(
           timestamp: Time.now.utc.iso8601,
           model: MODEL,
           improver_model: IMPROVER_MODEL,
           prompt: Digest::SHA256.hexdigest(prompt)[0, 12]
-        }
+        )
 
         FileUtils.mkdir_p(@workdir)
         File.open(File.join(@workdir, "history.jsonl"), "a") do |file|
spec/evals/support/runner.rb
@@ -14,6 +14,7 @@ module Elelem
     # can drift from it. Challengers and history stay out of lib/.
     CHAMPION = File.expand_path("../../../lib/elelem/prompts/default.erb", __dir__)
     WORKDIR = File.expand_path("../prompts", __dir__)
+    SEED = File.join(WORKDIR, "seed.erb")
 
     class Runner
       def initialize(prompt:, client: -> { Evals.client })
@@ -33,7 +34,7 @@ module Elelem
             kase.turns.each { |turn| response = agent.turn(turn) }
 
             failures = Assertions.new(kase.expect).failures(
-              workspace: workspace, response: response, tools: tools, turns: bounded.turns
+              workspace: workspace, response: response, tools: tools
             )
 
             Result.new(
spec/evals/support/scorer.rb
@@ -28,8 +28,12 @@ module Elelem
         previous.status.select { |id, ok| ok && !current.fetch(id, false) }.keys
       end
 
-      def excluding(group)
-        Score.new(results: results.reject { |result| result.group == group })
+      def excluding(*groups)
+        Score.new(results: results.reject { |result| groups.include?(result.group) })
+      end
+
+      def only(*groups)
+        Score.new(results: results.select { |result| groups.include?(result.group) })
       end
     end
 
@@ -40,7 +44,21 @@ module Elelem
       end
 
       def call(cases)
-        Score.new(results: cases.flat_map { |kase| Array.new(@repeat) { @runner.run(kase) } })
+        Score.new(results: cases.flat_map { |kase| runs_for(kase) })
+      end
+
+      private
+
+      # A case passes only if every repeat passes, so the first failure settles
+      # it -- stop early and spend no more runs on a case already known to fail.
+      def runs_for(kase)
+        results = []
+        @repeat.times do
+          result = @runner.run(kase)
+          results << result
+          break unless result.passed?
+        end
+        results
       end
     end
   end
spec/evals/support/tasks.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "json"
+require "fileutils"
+
+module Elelem
+  module Evals
+    # A scorer_for lambda over the full case set: prompt -> Score, running the
+    # real agent against the real model. Shared by the loop, minimize, regenerate.
+    def self.scorer_for(cases: Case.load_all)
+      ->(prompt) { Scorer.new(runner: Runner.new(prompt: prompt)).call(cases) }
+    end
+
+    # Refactor step: strip every champion line that defends no case, writing the
+    # result and the line -> case map to WORKDIR for a human to review and adopt.
+    def self.minimize!(champion: CHAMPION, workdir: WORKDIR, out: $stdout,
+      ablator: Ablator.new(scorer_for: scorer_for))
+      before = File.read(champion)
+      ablation = ablator.minimize(before)
+
+      FileUtils.mkdir_p(workdir)
+      File.write(File.join(workdir, "minimized.erb"), ablation.prompt)
+      File.write(File.join(workdir, "defends.json"), JSON.pretty_generate(ablation.defends))
+
+      out.puts "minimize: #{before.length} -> #{ablation.prompt.length} chars, " \
+        "wrote minimized.erb + defends.json to #{workdir}"
+      ablation
+    end
+
+    # Grow a fresh prompt from the minimal seed. Runs the loop against a candidate
+    # copy so the shipped champion is never overwritten; a human diffs and adopts.
+    def self.regenerate(rounds:, seed: SEED, workdir: WORKDIR, out: $stdout,
+      loop_for: ->(champion) { Loop.new(champion: champion, out: out) })
+      FileUtils.mkdir_p(workdir)
+      candidate = File.join(workdir, "candidate.erb")
+      FileUtils.cp(seed, candidate)
+
+      loop_for.call(candidate).run(rounds: rounds)
+
+      out.puts "regenerate: grew #{candidate} from seed (champion untouched)"
+      candidate
+    end
+  end
+end
.gitignore
@@ -17,3 +17,6 @@ target/
 .rspec_status
 
 /spec/evals/prompts/challenger-*.erb
+/spec/evals/prompts/history.jsonl
+/spec/evals/prompts/candidate.erb
+/spec/evals/prompts/minimized.erb
Rakefile
@@ -21,6 +21,20 @@ namespace :evals do
 
     Elelem::Evals::Loop.new.run(rounds: Integer(ENV.fetch("ROUNDS", "3")))
   end
+
+  desc "Drop every champion prompt line whose removal regresses no case"
+  task :minimize do
+    require_relative "spec/support/evals"
+
+    Elelem::Evals.minimize!
+  end
+
+  desc "Grow a fresh prompt from the minimal seed (leaves the champion untouched)"
+  task :regenerate do
+    require_relative "spec/support/evals"
+
+    Elelem::Evals.regenerate(rounds: Integer(ENV.fetch("ROUNDS", "8")))
+  end
 end
 
 task default: %i[spec]
README.md
@@ -91,8 +91,10 @@ elelem chat
 
 ### Options
 
-* `--provider` – LLM provider: `ollama`, `anthropic`, `openai`, or `vertex-ai` (default: `ollama`).
-* `--model` – Override the default model for the selected provider.
+* `--provider` – LLM provider: `ollama`, `anthropic`, `openai`, or `vertex` (default: `ollama`).
+
+The model is chosen per provider via environment variables (`OLLAMA_MODEL`,
+`ANTHROPIC_MODEL`, `OPENAI_MODEL`, `VERTEX_MODEL`), not a CLI flag.
 
 ### Examples
 
@@ -107,7 +109,7 @@ ANTHROPIC_API_KEY=sk-... elelem chat --provider anthropic
 OPENAI_API_KEY=sk-... elelem chat --provider openai
 
 # VertexAI (uses gcloud ADC)
-elelem chat --provider vertex-ai --model claude-sonnet-4@20250514
+VERTEX_MODEL=claude-sonnet-4@20250514 elelem chat --provider vertex
 ```
 
 ### Provider Configuration
@@ -119,7 +121,7 @@ Each provider reads its configuration from environment variables:
 | ollama      | `OLLAMA_HOST` (default: localhost:11434)          |
 | anthropic   | `ANTHROPIC_API_KEY`                               |
 | openai      | `OPENAI_API_KEY`, `OPENAI_BASE_URL`               |
-| vertex-ai   | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION`     |
+| vertex      | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION`     |
 
 ## Features
 
@@ -217,6 +219,27 @@ Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
 
 HTTP servers support OAuth authentication automatically.
 
+## Tuning the system prompt (evals)
+
+The system prompt is tuned empirically. `spec/evals/cases/*.yml` are eval cases
+(YAML: `fixture`, `turns`, `expect`) run against the real agent and model.
+
+```bash
+bin/evals        # tune the champion prompt against the eval cases
+bin/evals 5      # ...for 5 rounds (default 3)
+```
+
+Each round an LLM improver adds prompt lines to pass failing cases, and a
+challenger is promoted only if it passes strictly more cases with no regression,
+under a ~1KB budget. Cases in the `holdout` group are hidden from the improver
+but their regressions still block a promotion; `sealed` cases are hidden and
+never gate at all -- a pure generalization score.
+
+Point `EVAL_MODEL` at the strongest coding model your machine runs locally
+(default `gpt-oss:latest`). Less common operations are plain rake tasks:
+`rake evals` (score once), `rake evals:minimize` (drop lines that defend no
+case), `rake evals:regenerate` (grow a fresh prompt from the seed).
+
 ## Known Limitations
 
 * Assumes the current directory is a Git repository.