llm-experiment 0.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +190 -0
  6. data/exe/llmx +6 -0
  7. data/lib/llm-experiment.rb +3 -0
  8. data/lib/llm_experiment/auth.rb +143 -0
  9. data/lib/llm_experiment/cleaner.rb +183 -0
  10. data/lib/llm_experiment/cli/build_command.rb +37 -0
  11. data/lib/llm_experiment/cli/clean_command.rb +42 -0
  12. data/lib/llm_experiment/cli/doctor_command.rb +30 -0
  13. data/lib/llm_experiment/cli/login_command.rb +25 -0
  14. data/lib/llm_experiment/cli/metrics_command.rb +32 -0
  15. data/lib/llm_experiment/cli/new_command.rb +21 -0
  16. data/lib/llm_experiment/cli/parse_command.rb +77 -0
  17. data/lib/llm_experiment/cli/run_command.rb +74 -0
  18. data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
  19. data/lib/llm_experiment/cli/shell_command.rb +35 -0
  20. data/lib/llm_experiment/cli/status_command.rb +69 -0
  21. data/lib/llm_experiment/cli/version_command.rb +19 -0
  22. data/lib/llm_experiment/cli.rb +120 -0
  23. data/lib/llm_experiment/container.rb +178 -0
  24. data/lib/llm_experiment/doctor.rb +126 -0
  25. data/lib/llm_experiment/experiment.rb +163 -0
  26. data/lib/llm_experiment/grid.rb +117 -0
  27. data/lib/llm_experiment/image_builder/app.rb +267 -0
  28. data/lib/llm_experiment/image_builder/base.rb +64 -0
  29. data/lib/llm_experiment/metrics_report.rb +138 -0
  30. data/lib/llm_experiment/pins.rb +22 -0
  31. data/lib/llm_experiment/sanitizer.rb +144 -0
  32. data/lib/llm_experiment/scaffold.rb +43 -0
  33. data/lib/llm_experiment/shell.rb +60 -0
  34. data/lib/llm_experiment/stats.rb +69 -0
  35. data/lib/llm_experiment/transcript/claude.rb +104 -0
  36. data/lib/llm_experiment/transcript/codex.rb +96 -0
  37. data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
  38. data/lib/llm_experiment/transcript/parser.rb +105 -0
  39. data/lib/llm_experiment/transcript.rb +27 -0
  40. data/lib/llm_experiment/trial.rb +204 -0
  41. data/lib/llm_experiment/version.rb +5 -0
  42. data/lib/llm_experiment.rb +70 -0
  43. data/templates/README.md.erb +26 -0
  44. data/templates/base.Containerfile +120 -0
  45. data/templates/experiment.yml.erb +30 -0
  46. data/templates/gitignore +3 -0
  47. data/templates/runner.rb +305 -0
  48. metadata +92 -0
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "shellwords"
6
+
7
+ module LLMExperiment
8
+ # One trial: one task, one agent, one condition, in a fresh container.
9
+ #
10
+ # On caps: the trial runs to completion under a wall-clock timeout rather
11
+ # than being killed at the first edit. Stopping at the first edit would
12
+ # measure the cost of locating the defect but throw away whether the fix was
13
+ # any good, and an analysis cap can always be applied afterwards from the
14
+ # event log, whereas a killed trial cannot be un-killed. The transcript
15
+ # records the index of the first defect-file read and of the first edit, so a
16
+ # "cost to locate" figure survives without destroying anything.
17
+ #
18
+ # Results land in results-raw/, which is gitignored. Nothing reaches
19
+ # results/ until the sanitize gate has looked at it.
20
+ class Trial
21
+ # Both agents read the mounted credential store, so this asks a dead login
22
+ # the same question a trial would.
23
+ LOGIN_CHECKS = {
24
+ "claude" => ["claude auth status 2>&1", '"loggedIn": false'],
25
+ "codex" => ["codex login status 2>&1", "Not logged in"]
26
+ }.freeze
27
+
28
+ attr_reader :experiment, :task_id, :agent, :condition
29
+
30
+ def initialize(experiment:, task_id:, agent:, condition:, container: nil,
31
+ timeout: nil, shell: Shell)
32
+ @experiment = experiment
33
+ @task_id = task_id.to_s
34
+ @agent = agent.to_s
35
+ @condition = condition.to_s
36
+ @container = container
37
+ @timeout = timeout
38
+ @shell = shell
39
+ end
40
+
41
+ def task = @task ||= experiment.task(task_id)
42
+ def app = @app ||= experiment.app(task.app)
43
+ def timeout_seconds = (@timeout || experiment.timeout_seconds).to_i
44
+ def prompt_path = experiment.prompt_path(task, condition)
45
+
46
+ # Pure: reads configuration and the prompt file, touches nothing else. The
47
+ # container never sees anything that is not in here.
48
+ def env
49
+ validate!
50
+ values = {
51
+ "LLMX_TASK_ID" => task.id,
52
+ "LLMX_APP" => app.key,
53
+ "LLMX_AGENT" => agent,
54
+ "LLMX_CONDITION" => condition,
55
+ "LLMX_BRANCH" => task.branch,
56
+ "LLMX_TEST_FILE" => task.test_file,
57
+ "LLMX_IMPL_FILES" => task.impl_files.join(","),
58
+ "LLMX_DB_PREPARE" => app.db_prepare,
59
+ "LLMX_DATABASE" => app.database,
60
+ "LLMX_TEST_COMMAND" => app.test_command,
61
+ "LLMX_SUITE_RAN_PATTERN" => app.suite_ran_pattern,
62
+ "LLMX_TIMEOUT_SECONDS" => timeout_seconds.to_s,
63
+ # Base64 keeps the prompt out of shell quoting entirely: newlines,
64
+ # backticks and quotes in a prompt must not become shell syntax.
65
+ # pack("m0") is strict_encode64 without needing the base64 gem, which
66
+ # Ruby 3.4 unbundled -- and the runner decodes it with unpack1("m0").
67
+ "LLMX_PROMPT_B64" => [prompt].pack("m0")
68
+ }
69
+ model = experiment.agents.dig(agent, "model")
70
+ values["LLMX_MODEL"] = model if model
71
+ values
72
+ end
73
+
74
+ def run(dry_run: false)
75
+ environment = env
76
+ return dry_run_report(environment) if dry_run
77
+
78
+ preflight!
79
+ out_dir = prepare_results_dir
80
+ argv = run_argv(out_dir, environment)
81
+
82
+ puts "trial #{task_id} / #{agent} / #{condition} -> #{out_dir}"
83
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ ok = @shell.sh(*argv, allow_failure: true,
85
+ log_as: run_argv(out_dir, redacted(environment)).shelljoin)
86
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1)
87
+
88
+ report(record_meta(out_dir, elapsed, ok), elapsed)
89
+ out_dir
90
+ end
91
+
92
+ private
93
+
94
+ def container = @container ||= Container.new
95
+
96
+ # Read once. The copy saved next to the results has to be the bytes the
97
+ # agent actually got, not whatever the file says a second later.
98
+ def prompt
99
+ @prompt ||= begin
100
+ path = prompt_path
101
+ raise Error, "missing prompt file #{path}; render the prompts first" unless File.exist?(path)
102
+
103
+ File.read(path)
104
+ end
105
+ end
106
+
107
+ def validate!
108
+ unless experiment.agents.key?(agent)
109
+ raise Error, "unknown agent #{agent.inspect}; experiment.yml declares #{experiment.agents.keys.join(", ")}"
110
+ end
111
+ return if experiment.conditions.include?(condition)
112
+
113
+ raise Error, "unknown condition #{condition.inspect}; experiment.yml declares #{experiment.conditions.join(", ")}"
114
+ end
115
+
116
+ # The prompt is the one environment value worth hiding from a log line:
117
+ # it is the whole task, base64-encoded, and it is already saved verbatim
118
+ # as prompt.txt beside the results.
119
+ def redacted(environment)
120
+ environment.merge("LLMX_PROMPT_B64" => "<#{environment["LLMX_PROMPT_B64"].bytesize} bytes>")
121
+ end
122
+
123
+ def run_argv(out_dir, environment)
124
+ container.run_argv(app.image, "exec ruby /results/runner.rb",
125
+ memory: experiment.memory, cpus: experiment.cpus,
126
+ volumes: ["#{out_dir}:/results"],
127
+ env: environment, workdir: "/workspace/app")
128
+ end
129
+
130
+ def preflight!
131
+ container.ensure_system!
132
+ raise Error, "image #{app.image} not found; run `llmx build app #{app.key}`" unless container.image?(app.image)
133
+
134
+ check_login!
135
+ end
136
+
137
+ # Checked once up front rather than discovered halfway through a grid: a
138
+ # dead login turns every remaining trial into a wasted container.
139
+ def check_login!
140
+ command, dead = LOGIN_CHECKS[agent]
141
+ return unless command
142
+
143
+ status, = @shell.try(*container.run_argv(LLMExperiment.base_image, command,
144
+ memory: LLMExperiment.default_memory,
145
+ cpus: LLMExperiment.default_cpus))
146
+ return unless status.include?(dead)
147
+
148
+ raise Error, <<~MSG
149
+ #{agent} is not logged in inside the container, so this trial would fail.
150
+
151
+ Run this once and complete the browser flow:
152
+ llmx login --agent #{agent}
153
+
154
+ Credentials persist in #{LLMExperiment.auth_dir} and are mounted into every trial.
155
+ MSG
156
+ end
157
+
158
+ def prepare_results_dir
159
+ stamp = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
160
+ dir = experiment.trial_dir(task_id, agent, condition, stamp)
161
+ FileUtils.mkdir_p(dir)
162
+ # The runner is copied in rather than baked into the image, so fixing the
163
+ # harness does not mean rebuilding every app image.
164
+ FileUtils.cp(LLMExperiment.template_path("runner.rb"), File.join(dir, "runner.rb"))
165
+ File.write(File.join(dir, "prompt.txt"), prompt)
166
+ dir
167
+ end
168
+
169
+ def record_meta(out_dir, elapsed, ok)
170
+ path = File.join(out_dir, "meta.json")
171
+ unless File.exist?(path)
172
+ raise Error, "trial produced no meta.json after #{elapsed}s (container exit #{ok.inspect}). " \
173
+ "Inspect #{out_dir}, or open a shell with `llmx shell #{app.key}`"
174
+ end
175
+
176
+ meta = JSON.parse(File.read(path))
177
+ meta["host_wall_seconds"] = elapsed
178
+ meta["container_ok"] = ok
179
+ # No result should ever be unattributable to the harness that produced it.
180
+ meta["llm_experiment_version"] = LLMExperiment::VERSION
181
+ File.write(path, JSON.pretty_generate(meta))
182
+ meta
183
+ end
184
+
185
+ def report(meta, elapsed)
186
+ puts format("\n reproduced before : %s", meta["task_reproduces"])
187
+ puts format(" agent exit : %s (timed out: %s)", meta["agent_exit"], meta["timed_out"])
188
+ puts format(" changed files : %s", meta["changed_files"].inspect)
189
+ puts format(" touched impl file : %s", meta["touched_impl_file"])
190
+ puts format(" fix verified : %s", meta["fix_verified"])
191
+ puts format(" wall seconds : %s (container), %s (host)", meta["wall_seconds"], elapsed)
192
+ end
193
+
194
+ # A dry run starts no container and writes no results directory: it is for
195
+ # reading the prompt and the command back, not for rehearsing them.
196
+ def dry_run_report(environment)
197
+ out_dir = experiment.trial_dir(task_id, agent, condition, "<stamp>")
198
+ puts "would run:"
199
+ puts " #{run_argv(out_dir, redacted(environment)).join(" ")}"
200
+ puts "\nprompt (#{condition}):\n\n#{prompt}"
201
+ out_dir
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_experiment/version"
4
+
5
+ module LLMExperiment
6
+ class Error < StandardError; end
7
+ class ConfigError < Error; end
8
+ class ContainerError < Error; end
9
+
10
+ class << self
11
+ attr_accessor :base_image, :auth_dir, :agent_user,
12
+ :default_memory, :default_cpus,
13
+ :builder_cpus, :builder_memory, :min_free_gb,
14
+ :container_data_dir, :verbose
15
+ end
16
+
17
+ # Every default can be overridden by an LLMX_* variable, matching the source
18
+ # harness, so existing setups keep working.
19
+ self.base_image = ENV.fetch("LLMX_BASE_IMAGE", "llmx-base:latest")
20
+ self.auth_dir = File.expand_path(ENV.fetch("LLMX_AUTH_DIR", "~/.llmx/auth"))
21
+ self.agent_user = "agent"
22
+ self.default_memory = ENV.fetch("LLMX_MEMORY", "6g")
23
+ self.default_cpus = ENV.fetch("LLMX_CPUS", "4")
24
+ self.builder_cpus = ENV.fetch("LLMX_BUILDER_CPUS", "6")
25
+ self.builder_memory = ENV.fetch("LLMX_BUILDER_MEMORY", "8G")
26
+ # Measured, not guessed: a campfire-sized Rails app image drove free space
27
+ # from 34 GB to 1.8 GB. About 24 GB, and roughly 17 GB of that was the
28
+ # BuildKit builder VM rather than the image itself. The previous 12 GB floor
29
+ # green-lit exactly that build.
30
+ self.min_free_gb = ENV.fetch("LLMX_MIN_FREE_GB", "25").to_i
31
+ # Where the CLI unpacks images. What an image costs is what lives here, not
32
+ # what its manifest sums to, so `llmx clean` plans against `du` on this path.
33
+ self.container_data_dir = File.expand_path(
34
+ ENV.fetch("LLMX_CONTAINER_DATA_DIR", "~/Library/Application Support/com.apple.container")
35
+ )
36
+
37
+ # `--verbose` echoes every container command. Off by default: a grid runs
38
+ # hundreds of them and the interesting output belongs to the agent.
39
+ self.verbose = ENV.key?("LLMX_VERBOSE")
40
+
41
+ def self.template_path(name)
42
+ File.expand_path(File.join(__dir__, "..", "templates", name))
43
+ end
44
+ end
45
+
46
+ # Below the module body on purpose. These files reference Error, ConfigError
47
+ # and the accessors above, and a file that touches one of them at load time
48
+ # rather than at call time would fail with an inscrutable NameError if it were
49
+ # required first.
50
+ require_relative "llm_experiment/shell"
51
+ require_relative "llm_experiment/pins"
52
+ require_relative "llm_experiment/container"
53
+ require_relative "llm_experiment/auth"
54
+ require_relative "llm_experiment/experiment"
55
+ require_relative "llm_experiment/scaffold"
56
+ require_relative "llm_experiment/doctor"
57
+ require_relative "llm_experiment/image_builder/base"
58
+ require_relative "llm_experiment/image_builder/app"
59
+ require_relative "llm_experiment/trial"
60
+ require_relative "llm_experiment/grid"
61
+ require_relative "llm_experiment/transcript"
62
+ require_relative "llm_experiment/transcript/claude"
63
+ require_relative "llm_experiment/transcript/codex"
64
+ require_relative "llm_experiment/transcript/hermeticity"
65
+ require_relative "llm_experiment/transcript/parser"
66
+ require_relative "llm_experiment/stats"
67
+ require_relative "llm_experiment/metrics_report"
68
+ require_relative "llm_experiment/sanitizer"
69
+ require_relative "llm_experiment/cleaner"
70
+ require_relative "llm_experiment/cli"
@@ -0,0 +1,26 @@
1
+ # <%= name %>
2
+
3
+ **Status: designed, not yet run.**
4
+
5
+ ## The question
6
+
7
+ ## The design
8
+
9
+ ## What gets measured
10
+
11
+ ## Running it
12
+
13
+ ```sh
14
+ export LLMX_APP_SAMPLE=/path/to/your/checkout # never committed
15
+ llmx doctor
16
+ llmx build base # once per machine
17
+ llmx login # once, interactive
18
+ llmx build app sample
19
+ llmx run --all --dry-run
20
+ llmx run --all
21
+ llmx parse --all
22
+ llmx metrics
23
+ llmx sanitize
24
+ ```
25
+
26
+ ## Caveats
@@ -0,0 +1,120 @@
1
+ # Base image for LLM agent experiments.
2
+ #
3
+ # Gives every trial a freshly installed agent CLI with no host state: no user
4
+ # CLAUDE.md, no MCP servers, no login. Credentials arrive at run time from a
5
+ # mounted auth volume, never from this image.
6
+ #
7
+ # Built by `llmx build base`, which owns the version pins.
8
+
9
+ FROM ubuntu:24.04
10
+
11
+ ARG RUBY_VERSIONS="3.4.5 4.0.1"
12
+ ARG NODE_VERSION="22"
13
+ ARG CLAUDE_CODE_VERSION
14
+ ARG CODEX_VERSION
15
+ ARG OPENCODE_VERSION
16
+ ARG AGENT_USER=agent
17
+ ARG AGENT_UID=1001
18
+
19
+ ENV DEBIAN_FRONTEND=noninteractive \
20
+ LANG=C.UTF-8 \
21
+ LC_ALL=C.UTF-8 \
22
+ TZ=UTC
23
+
24
+ # System packages: Ruby build dependencies, native media libraries, SQLite,
25
+ # PostgreSQL, and git for the fixture clone.
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ ca-certificates curl wget git xz-utils unzip \
28
+ build-essential autoconf bison patch pkg-config \
29
+ libssl-dev libyaml-dev libreadline-dev zlib1g-dev libncurses-dev \
30
+ libffi-dev libgdbm-dev libdb-dev uuid-dev libgmp-dev \
31
+ libsqlite3-dev sqlite3 \
32
+ libpq-dev postgresql postgresql-contrib \
33
+ libvips42 libvips-tools ffmpeg \
34
+ libjemalloc2 tzdata procps less ripgrep jq \
35
+ && rm -rf /var/lib/apt/lists/*
36
+
37
+ # mise manages the rubies and node. Installed into /opt so every user sees the
38
+ # same toolchain and the shims work in non-login shells.
39
+ ENV MISE_DATA_DIR=/opt/mise \
40
+ MISE_CONFIG_DIR=/opt/mise/config \
41
+ MISE_CACHE_DIR=/opt/mise/cache \
42
+ MISE_STATE_DIR=/opt/mise/state \
43
+ MISE_INSTALL_PATH=/usr/local/bin/mise \
44
+ MISE_YES=1
45
+ ENV PATH=/opt/mise/shims:$PATH
46
+
47
+ RUN curl -fsSL https://mise.run | sh && mise --version
48
+
49
+ # Rubies are compiled here once so trials never pay for it.
50
+ RUN set -eux; \
51
+ for v in ${RUBY_VERSIONS}; do mise install "ruby@${v}"; done; \
52
+ mise install "node@${NODE_VERSION}"; \
53
+ first_ruby="$(echo ${RUBY_VERSIONS} | awk '{print $1}')"; \
54
+ mise use -g "ruby@${first_ruby}" "node@${NODE_VERSION}"; \
55
+ mise reshim
56
+
57
+ # Agent CLIs. Pinned by build_base.rb so a rebuild is reproducible.
58
+ #
59
+ # opencode needs its platform binary named explicitly. opencode-ai resolves its
60
+ # real executable through optional per-platform packages, and npm picks the musl
61
+ # build for this glibc image, then fails in the postinstall with "Unsupported
62
+ # platform ... wanted libc musl, current glibc". Naming the package settles the
63
+ # choice instead of leaving it to detection, and the architecture is read at
64
+ # build time so this works on Apple silicon and on x86 alike.
65
+ RUN set -eux; \
66
+ case "$(uname -m)" in \
67
+ aarch64|arm64) opencode_platform="opencode-linux-arm64" ;; \
68
+ x86_64|amd64) opencode_platform="opencode-linux-x64" ;; \
69
+ *) echo "no opencode build for $(uname -m)" >&2; exit 1 ;; \
70
+ esac; \
71
+ npm install -g \
72
+ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
73
+ "@openai/codex@${CODEX_VERSION}" \
74
+ "opencode-ai@${OPENCODE_VERSION}" \
75
+ "${opencode_platform}@${OPENCODE_VERSION}"; \
76
+ mise reshim
77
+
78
+ # Never let a trial silently run a different CLI build than the one recorded.
79
+ ENV DISABLE_AUTOUPDATER=1 \
80
+ DISABLE_TELEMETRY=1 \
81
+ DISABLE_ERROR_REPORTING=1 \
82
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
83
+ npm_config_update_notifier=false
84
+
85
+ # The toolchain is owned by the agent user, not root. `gem install` triggers a
86
+ # mise reshim, and a root-owned shims directory makes that fail noisily on every
87
+ # app image build.
88
+ RUN useradd --create-home --uid ${AGENT_UID} --shell /bin/bash ${AGENT_USER} \
89
+ && chown -R ${AGENT_USER}:${AGENT_USER} /opt/mise \
90
+ && mkdir -p /workspace /results \
91
+ && chown ${AGENT_USER}:${AGENT_USER} /workspace /results
92
+
93
+ # Gems live outside the checkout so `git status` in a trial stays clean and the
94
+ # agent's edits are the only diff.
95
+ ENV BUNDLE_PATH=/home/${AGENT_USER}/bundle \
96
+ BUNDLE_JOBS=4 \
97
+ BUNDLE_RETRY=3 \
98
+ GEM_HOME=/home/${AGENT_USER}/gems \
99
+ PATH=/home/${AGENT_USER}/gems/bin:/opt/mise/shims:$PATH
100
+
101
+ # Record exactly what got installed. Trials copy this into their metadata so a
102
+ # result can always be traced back to a toolchain.
103
+ RUN set -eux; \
104
+ printf '{\n' > /etc/llmx-versions.json; \
105
+ printf ' "image_built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /etc/llmx-versions.json; \
106
+ printf ' "ubuntu": "%s",\n' "$(. /etc/os-release; echo $VERSION_ID)" >> /etc/llmx-versions.json; \
107
+ printf ' "arch": "%s",\n' "$(uname -m)" >> /etc/llmx-versions.json; \
108
+ printf ' "mise": "%s",\n' "$(mise --version | head -1)" >> /etc/llmx-versions.json; \
109
+ printf ' "rubies": "%s",\n' "${RUBY_VERSIONS}" >> /etc/llmx-versions.json; \
110
+ printf ' "node": "%s",\n' "$(node --version)" >> /etc/llmx-versions.json; \
111
+ printf ' "claude_code": "%s",\n' "$(claude --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
112
+ printf ' "codex": "%s",\n' "$(codex --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
113
+ printf ' "opencode": "%s",\n' "$(opencode --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
114
+ printf ' "postgres": "%s"\n' "$(/usr/lib/postgresql/*/bin/postgres --version | head -1)" >> /etc/llmx-versions.json; \
115
+ printf '}\n' >> /etc/llmx-versions.json; \
116
+ cat /etc/llmx-versions.json
117
+
118
+ USER ${AGENT_USER}
119
+ WORKDIR /workspace
120
+ CMD ["bash"]
@@ -0,0 +1,30 @@
1
+ name: <%= name %>
2
+ question: "State the question this experiment answers."
3
+
4
+ agents:
5
+ claude:
6
+ model: claude-opus-5
7
+ codex:
8
+ model: gpt-5.6-sol
9
+
10
+ conditions: [control, treatment]
11
+
12
+ trial:
13
+ timeout_seconds: 900
14
+
15
+ apps:
16
+ sample:
17
+ ruby: "3.4.5"
18
+ bundler: default
19
+ database: sqlite3
20
+ test_command: bin/rails test
21
+ db_prepare: bin/rails db:test:prepare
22
+ publish_transcripts: false
23
+ branch_prefix: exp/<%= name %>
24
+ neutralize: [CLAUDE.md, AGENTS.md, .mcp.json, .claude, .codex]
25
+
26
+ tasks:
27
+ - id: sample-01
28
+ app: sample
29
+ test_file: test/models/replace_me_test.rb
30
+ impl_files: [app/models/replace_me.rb]
@@ -0,0 +1,3 @@
1
+ results-raw/
2
+ .apps.local
3
+ .DS_Store