robot_lab-to 0.2.6

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.loki +21 -0
  5. data/.quality/reek_baseline.txt +17 -0
  6. data/.rubocop.yml +7 -0
  7. data/CHANGELOG.md +67 -0
  8. data/CLAUDE.md +74 -0
  9. data/COMMITS.md +27 -0
  10. data/README.md +352 -0
  11. data/Rakefile +132 -0
  12. data/bin/robot-to +14 -0
  13. data/docs/about/changelog.md +82 -0
  14. data/docs/assets/architecture.svg +110 -0
  15. data/docs/assets/images/robot_lab-to.jpg +0 -0
  16. data/docs/assets/iteration-loop.svg +97 -0
  17. data/docs/concepts/evals.md +254 -0
  18. data/docs/concepts/iteration-loop.md +128 -0
  19. data/docs/concepts/notes.md +91 -0
  20. data/docs/concepts/run-state.md +93 -0
  21. data/docs/concepts/stop-conditions.md +111 -0
  22. data/docs/concepts/verification.md +75 -0
  23. data/docs/configuration/cli.md +111 -0
  24. data/docs/configuration/index.md +93 -0
  25. data/docs/configuration/settings.md +270 -0
  26. data/docs/getting-started/anatomy-of-a-run.md +111 -0
  27. data/docs/getting-started/installation.md +78 -0
  28. data/docs/getting-started/quick-start.md +105 -0
  29. data/docs/index.md +96 -0
  30. data/docs/local-models/guardrails.md +117 -0
  31. data/docs/local-models/index.md +88 -0
  32. data/docs/local-models/ollama.md +122 -0
  33. data/docs/local-models/tools.md +92 -0
  34. data/docs/reference/architecture.md +139 -0
  35. data/examples/01_basic_usage/.gitignore +9 -0
  36. data/examples/01_basic_usage/README.md +173 -0
  37. data/examples/01_basic_usage/basic_usage.rb +386 -0
  38. data/examples/02_advanced_usage/.gitignore +8 -0
  39. data/examples/02_advanced_usage/README.md +117 -0
  40. data/examples/02_advanced_usage/advanced_usage.rb +308 -0
  41. data/examples/02_advanced_usage/quality_gate.rb +99 -0
  42. data/examples/03_scored/.gitignore +2 -0
  43. data/examples/03_scored/README.md +117 -0
  44. data/examples/03_scored/scored_run.rb +325 -0
  45. data/examples/04_prose/.gitignore +2 -0
  46. data/examples/04_prose/README.md +43 -0
  47. data/examples/04_prose/prompts_dir/grade_message.md +14 -0
  48. data/examples/04_prose/prompts_dir/judge.md +11 -0
  49. data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
  50. data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
  51. data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
  52. data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
  53. data/examples/04_prose/prose_run.rb +302 -0
  54. data/lib/robot_lab/to/atomic_file.rb +69 -0
  55. data/lib/robot_lab/to/backoff.rb +46 -0
  56. data/lib/robot_lab/to/cli.rb +176 -0
  57. data/lib/robot_lab/to/commit_manager.rb +135 -0
  58. data/lib/robot_lab/to/config/defaults.yml +27 -0
  59. data/lib/robot_lab/to/config.rb +86 -0
  60. data/lib/robot_lab/to/decision.rb +40 -0
  61. data/lib/robot_lab/to/decision_manager.rb +193 -0
  62. data/lib/robot_lab/to/errors.rb +30 -0
  63. data/lib/robot_lab/to/evals/base.rb +23 -0
  64. data/lib/robot_lab/to/evals/code.rb +77 -0
  65. data/lib/robot_lab/to/evals/context.rb +17 -0
  66. data/lib/robot_lab/to/evals/factory.rb +84 -0
  67. data/lib/robot_lab/to/evals/null.rb +17 -0
  68. data/lib/robot_lab/to/evals/prose.rb +110 -0
  69. data/lib/robot_lab/to/evals/score.rb +22 -0
  70. data/lib/robot_lab/to/exit_summary.rb +75 -0
  71. data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
  72. data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
  73. data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
  74. data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
  75. data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
  76. data/lib/robot_lab/to/guards/run_store.rb +31 -0
  77. data/lib/robot_lab/to/guards/write_guard.rb +71 -0
  78. data/lib/robot_lab/to/guards.rb +54 -0
  79. data/lib/robot_lab/to/iteration_result.rb +29 -0
  80. data/lib/robot_lab/to/jsonl_logger.rb +59 -0
  81. data/lib/robot_lab/to/notes_manager.rb +121 -0
  82. data/lib/robot_lab/to/orchestrator.rb +642 -0
  83. data/lib/robot_lab/to/prompt_builder.rb +208 -0
  84. data/lib/robot_lab/to/run.rb +109 -0
  85. data/lib/robot_lab/to/stop_conditions.rb +71 -0
  86. data/lib/robot_lab/to/tools/bash.rb +75 -0
  87. data/lib/robot_lab/to/tools/edit.rb +52 -0
  88. data/lib/robot_lab/to/tools/file_tool.rb +32 -0
  89. data/lib/robot_lab/to/tools/read.rb +57 -0
  90. data/lib/robot_lab/to/tools/request_decision.rb +66 -0
  91. data/lib/robot_lab/to/tools/submit_result.rb +50 -0
  92. data/lib/robot_lab/to/tools/write.rb +32 -0
  93. data/lib/robot_lab/to/verifier.rb +63 -0
  94. data/lib/robot_lab/to/version.rb +7 -0
  95. data/lib/robot_lab/to.rb +81 -0
  96. data/mkdocs.yml +145 -0
  97. metadata +175 -0
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ===========================================================================
5
+ # 02_advanced_usage — a multi-phase workflow: a network of robots collaborates
6
+ # with the user to ideate and plan, then robot_lab-to autonomously implements
7
+ # the plan behind a real quality gate.
8
+ # ===========================================================================
9
+ #
10
+ # This composes the two robot_lab subsystems:
11
+ #
12
+ # Phase 1 IDEATE (robot_lab Network task, gpt-5.5, interactive)
13
+ # An "Ideator" robot interviews YOU via the AskUser tool, turning a rough
14
+ # idea into a concrete requirements brief.
15
+ #
16
+ # Phase 2 PLAN (robot_lab Network task, gpt-5.5)
17
+ # A "Planner" robot reads the brief and writes a Minitest *acceptance
18
+ # suite* into the project (test/). It does not implement anything. Its
19
+ # reply is a one-paragraph implementation objective.
20
+ #
21
+ # Phase 3 IMPLEMENT (robot_lab-to, local Ollama qwen3.6:latest)
22
+ # robot_lab-to runs an autonomous loop that writes lib/ code until the
23
+ # Planner's acceptance suite passes AND a quality gate is clean. The
24
+ # verify command (quality_gate.rb) runs tests + rubocop + flog + flay, so
25
+ # the robot must earn each commit on correctness AND quality.
26
+ #
27
+ # Models (per your request): reasoning on OpenAI gpt-5.5, building on local
28
+ # Ollama qwen3.6. Because both use RubyLLM's :openai provider but different
29
+ # endpoints (api.openai.com vs Ollama's /v1), and openai_api_base is global, we
30
+ # toggle it between the (sequential) phases.
31
+ #
32
+ # Everything stays under examples/02_advanced_usage/ (project/, .robot_lab_to/),
33
+ # both git-ignored and recreated on each run.
34
+ #
35
+ # Run it (from the gem root, with OPENAI_API_KEY set and Ollama serving qwen3.6):
36
+ # bundle exec ruby examples/02_advanced_usage/advanced_usage.rb
37
+ # ===========================================================================
38
+
39
+ require "fileutils"
40
+ require "logger"
41
+ require "open3"
42
+ require "net/http"
43
+
44
+ # Make the example runnable straight from the repo, with or without bundler.
45
+ [
46
+ File.expand_path("../../lib", __dir__), # robot_lab-to/lib
47
+ File.expand_path("../../../robot_lab/lib", __dir__) # sibling robot_lab/lib
48
+ ].each { |p| $LOAD_PATH.unshift(p) if Dir.exist?(p) }
49
+
50
+ require "ruby_llm"
51
+ require "robot_lab"
52
+ require "robot_lab/to"
53
+
54
+ # --- configuration ---------------------------------------------------------
55
+
56
+ REASON_PROVIDER = ENV.fetch("RLTO_REASON_PROVIDER", "openai").to_sym
57
+ REASON_MODEL = ENV.fetch("RLTO_REASON_MODEL", "gpt-5.5") # real OpenAI
58
+ BUILD_PROVIDER = ENV.fetch("RLTO_BUILD_PROVIDER", "openai").to_sym
59
+ BUILD_MODEL = ENV.fetch("RLTO_BUILD_MODEL", "qwen3.6:latest") # local Ollama
60
+ OLLAMA_BASE = ENV.fetch("OLLAMA_BASE", "http://localhost:11434/v1")
61
+
62
+ SANDBOX_DIR = File.expand_path("project", __dir__)
63
+ RUN_DIR = File.expand_path(".robot_lab_to", __dir__)
64
+ ARTIFACTS = [SANDBOX_DIR, RUN_DIR].freeze
65
+
66
+ RUBOCOP_CONFIG = <<~YAML
67
+ AllCops:
68
+ NewCops: enable
69
+ SuggestExtensions: false
70
+ TargetRubyVersion: "3.2"
71
+ Style/Documentation: { Enabled: false }
72
+ Style/FrozenStringLiteralComment: { Enabled: false }
73
+ Metrics: { Enabled: false }
74
+ Naming/MethodParameterName: { MinNameLength: 1 }
75
+ YAML
76
+
77
+ # --- live feedback -----------------------------------------------------------
78
+ #
79
+ # Live narration of every robot's actions across all three phases now comes from
80
+ # robot_lab core (RobotLab::Narrator) — we just enable it in main, below. No
81
+ # hand-written hook needed: it registers globally (covering the network robots
82
+ # AND the implementation loop) and writes to $stderr. For richer per-tool output
83
+ # (icons, shell exit codes) you can subclass RobotLab::Narrator and override
84
+ # before_tool_call / after_tool_call.
85
+
86
+ # --- provider configuration (toggled per phase) ----------------------------
87
+
88
+ # Reasoning phases talk to the real OpenAI API.
89
+ def use_real_openai!
90
+ RubyLLM.configure do |c|
91
+ c.openai_api_base = nil # default → api.openai.com
92
+ c.openai_api_key = ENV.fetch("OPENAI_API_KEY")
93
+ c.request_timeout = 600
94
+ end
95
+ RubyLLM.logger.level = Logger::ERROR # keep raw API traffic out of the feed
96
+ end
97
+
98
+ # Implementation phase routes the :openai provider at the local Ollama endpoint.
99
+ def use_ollama!
100
+ RubyLLM.configure do |c|
101
+ c.openai_api_base = OLLAMA_BASE
102
+ c.openai_api_key = "ollama" # ignored by Ollama
103
+ c.request_timeout = 600
104
+ end
105
+ RubyLLM.logger.level = Logger::ERROR
106
+ RubyLLM.models.refresh! # register local models so tool attachment works
107
+ rescue StandardError => e
108
+ warn "warning: could not refresh Ollama models (#{e.class}: #{e.message})"
109
+ end
110
+
111
+ # --- preflight -------------------------------------------------------------
112
+
113
+ def preflight!
114
+ abort "Set OPENAI_API_KEY (the ideation/planning phases use #{REASON_MODEL})." unless ENV["OPENAI_API_KEY"]
115
+
116
+ uri = URI.join(OLLAMA_BASE, "models")
117
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
118
+ rescue StandardError
119
+ abort "Cannot reach Ollama at #{OLLAMA_BASE}. Start it and `ollama pull #{BUILD_MODEL}`."
120
+ end
121
+
122
+ # --- sandbox ---------------------------------------------------------------
123
+
124
+ def sh(*args, chdir:)
125
+ out, err, status = Open3.capture3(*args, chdir: chdir)
126
+ raise "command failed: #{args.join(' ')}\n#{err}" unless status.success?
127
+
128
+ out
129
+ end
130
+
131
+ def clean_slate!
132
+ ARTIFACTS.each do |path|
133
+ next unless File.exist?(path)
134
+
135
+ puts "Cleaning leftover: #{path}"
136
+ FileUtils.rm_rf(path)
137
+ end
138
+ end
139
+
140
+ # Fresh git repo seeded with the quality gate + rubocop config (the implementer
141
+ # inherits these). The acceptance tests are added later, by the Planner.
142
+ def make_sandbox
143
+ FileUtils.mkdir_p(SANDBOX_DIR)
144
+ sh("git", "init", "-q", chdir: SANDBOX_DIR)
145
+ sh("git", "config", "user.email", "example@example.com", chdir: SANDBOX_DIR)
146
+ sh("git", "config", "user.name", "robot_lab-to example", chdir: SANDBOX_DIR)
147
+
148
+ FileUtils.cp(File.join(__dir__, "quality_gate.rb"), File.join(SANDBOX_DIR, "quality_gate.rb"))
149
+ File.write(File.join(SANDBOX_DIR, ".rubocop.yml"), RUBOCOP_CONFIG)
150
+ File.write(File.join(SANDBOX_DIR, "README.md"),
151
+ "# Demo project\n\nlib/ implementation must satisfy test/ and pass `ruby quality_gate.rb`.\n")
152
+ FileUtils.mkdir_p(File.join(SANDBOX_DIR, "lib"))
153
+ FileUtils.mkdir_p(File.join(SANDBOX_DIR, "test"))
154
+
155
+ sh("git", "add", "-A", chdir: SANDBOX_DIR)
156
+ sh("git", "commit", "-qm", "chore: quality gate + project skeleton", chdir: SANDBOX_DIR)
157
+ SANDBOX_DIR
158
+ end
159
+
160
+ # --- the reasoning robots (Phase 1 + 2) ------------------------------------
161
+
162
+ IDEATOR_PROMPT = <<~PROMPT
163
+ You are a product ideation partner. Through a short interview, turn the user's
164
+ rough idea into a crisp requirements brief for a TINY single-file Ruby library
165
+ (implementable in ~100 lines, no gems, no I/O, no network).
166
+
167
+ Use the ask_user tool to:
168
+ 1. Ask what small Ruby library they would like to build.
169
+ 2. Ask 2-3 focused clarifying questions (core operations, key edge cases,
170
+ error handling, naming).
171
+ Keep scope deliberately small — steer the user toward something a junior dev
172
+ could build in an afternoon.
173
+
174
+ When done, reply with ONLY the requirements brief: the library name (snake_case),
175
+ the module/class name, and 5-8 bullet points describing required behavior and
176
+ edge cases. Do not write any code.
177
+ PROMPT
178
+
179
+ PLANNER_PROMPT = <<~PROMPT
180
+ You are a planning engineer. You are given a requirements brief for a tiny Ruby
181
+ library. Produce the SPECIFICATION as an executable test suite — do NOT
182
+ implement the library.
183
+
184
+ Using the write tool, create ONE file: test/<snake_name>_test.rb — a thorough
185
+ Minitest suite (require "minitest/autorun" and require "<snake_name>"). It must
186
+ pin down: the happy paths, the important edge cases, and the error behavior
187
+ named in the brief. Assume the implementation will live at lib/<snake_name>.rb
188
+ and define the module/class the tests reference. Write 6-12 focused test methods.
189
+ Do NOT create any lib/ file. Do NOT implement anything.
190
+
191
+ After writing the test file, reply with ONE paragraph: a precise implementation
192
+ objective naming lib/<snake_name>.rb, the module/class, and the public methods
193
+ to implement so that test/<snake_name>_test.rb passes.
194
+ PROMPT
195
+
196
+ def build_reasoning_robots
197
+ ideator = RobotLab.build(
198
+ name: "ideator", system_prompt: IDEATOR_PROMPT,
199
+ provider: REASON_PROVIDER, model: REASON_MODEL,
200
+ local_tools: [RobotLab::AskUser.new], max_tool_rounds: 20
201
+ )
202
+ planner = RobotLab.build(
203
+ name: "planner", system_prompt: PLANNER_PROMPT,
204
+ provider: REASON_PROVIDER, model: REASON_MODEL,
205
+ local_tools: [RobotLab::To::Tools::Write.new, RobotLab::To::Tools::Read.new],
206
+ max_tool_rounds: 20
207
+ )
208
+ [ideator, planner]
209
+ end
210
+
211
+ # --- report ----------------------------------------------------------------
212
+
213
+ def report(dir)
214
+ puts "\n=== Result ==="
215
+ puts "Branch: #{sh('git', 'branch', '--show-current', chdir: dir).strip}"
216
+ puts "\nCommits:"
217
+ puts sh("git", "log", "--oneline", chdir: dir)
218
+
219
+ impl = Dir[File.join(dir, "lib", "**", "*.rb")].first
220
+ if impl
221
+ puts "\n#{impl.sub("#{dir}/", '')}:"
222
+ puts File.read(impl)
223
+ else
224
+ puts "\n(no lib/ implementation was produced — check the notes for why)"
225
+ end
226
+
227
+ puts "\nFinal quality gate:"
228
+ out, = Open3.capture2e("ruby", "quality_gate.rb", chdir: dir)
229
+ puts out.strip
230
+
231
+ notes = Dir.glob(File.join(RUN_DIR, "runs", "*", "notes.md")).max
232
+ puts "\nNotes: #{notes}" if notes
233
+ puts "Project: #{dir}"
234
+ puts "Run logs: #{RUN_DIR}"
235
+ end
236
+
237
+ # --- main ------------------------------------------------------------------
238
+
239
+ preflight!
240
+ clean_slate!
241
+ sandbox = make_sandbox
242
+ RobotLab::Narrator.enable! # live narration for every robot, across all phases
243
+
244
+ puts "Project dir: #{sandbox}"
245
+ puts "Reasoning: #{REASON_PROVIDER}/#{REASON_MODEL} (cloud) → ideate + plan"
246
+ puts "Building: #{BUILD_PROVIDER}/#{BUILD_MODEL} (local Ollama) → implement"
247
+ puts
248
+
249
+ # -- Phases 1 & 2: ideate -> plan, as a robot_lab network --------------------
250
+ puts "── Phase 1+2: ideation & planning (a network of robots) ──"
251
+ use_real_openai!
252
+ ideator, planner = build_reasoning_robots
253
+
254
+ network = RobotLab.create_network(name: "ideate_and_plan") do
255
+ task :ideate, ideator, depends_on: :none
256
+ task :plan, planner, depends_on: [:ideate]
257
+ end
258
+
259
+ # Run inside the sandbox so the Planner's `write` lands in project/test/.
260
+ result = Dir.chdir(sandbox) do
261
+ network.run(message: "Begin the ideation interview with the user.")
262
+ end
263
+
264
+ # The Planner-authored tests ARE the spec, so we derive the implementation
265
+ # objective from them. The Planner's prose reply is appended as a hint —
266
+ # robot_lab core now backfills last_text_content from the chat when a turn ends
267
+ # on a tool call, so it's reliably populated rather than blank.
268
+ test_files = Dir[File.join(sandbox, "test", "**", "*_test.rb")].map { |f| f.sub("#{sandbox}/", "") }
269
+ if test_files.empty?
270
+ abort "Planning did not produce a test suite; aborting before implementation."
271
+ end
272
+
273
+ plan_notes = result.context[:plan]&.last_text_content.to_s.strip
274
+ objective = +<<~OBJ
275
+ Implement the Ruby library so the acceptance test suite passes AND the quality
276
+ gate is clean. The acceptance tests (do NOT edit them) are: #{test_files.join(', ')}.
277
+ Read those tests to learn the required module/class, methods, and edge cases,
278
+ create the matching lib/*.rb file(s) they require, and iterate until
279
+ `ruby quality_gate.rb` exits 0 — it runs the tests plus rubocop, flog (method
280
+ complexity), and flay (duplication) on lib/. Keep the code clean and simple.
281
+ OBJ
282
+ objective << "\nPlanner's notes: #{plan_notes}\n" unless plan_notes.empty?
283
+
284
+ # Commit the Planner-authored acceptance suite as the spec baseline.
285
+ sh("git", "add", "-A", chdir: sandbox)
286
+ sh("git", "commit", "-qm", "spec: acceptance tests from planning", chdir: sandbox)
287
+ puts "\nPlanning complete. Acceptance suite: #{test_files.join(', ')}"
288
+ puts "Objective derived from the spec.\n\n"
289
+
290
+ # -- Phase 3: autonomous implementation with robot_lab-to --------------------
291
+ puts "── Phase 3: implementation (robot_lab-to, quality-gated) ──"
292
+ use_ollama!
293
+
294
+ Dir.chdir(sandbox) do
295
+ RobotLab::To.run(
296
+ objective,
297
+ provider: BUILD_PROVIDER,
298
+ model: BUILD_MODEL,
299
+ local_guards: true, # built-in file tools + small-model guardrails
300
+ stream: false, # Ollama tool calls require non-streaming
301
+ max_iterations: 8,
302
+ run_dir: RUN_DIR,
303
+ verify_command: "ruby quality_gate.rb", # tests + rubocop + flog + flay
304
+ stop_when: "ruby quality_gate.rb passes with exit status 0"
305
+ )
306
+ end
307
+
308
+ report(sandbox)
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ===========================================================================
5
+ # quality_gate.rb — aggregated correctness + quality gate
6
+ # ===========================================================================
7
+ #
8
+ # Seeded into the demo project and used as robot_lab-to's --verify-command. An
9
+ # iteration is only committed when ALL of these pass:
10
+ #
11
+ # tests the Planner-authored Minitest acceptance suite (test/**/*_test.rb)
12
+ # rubocop no style offenses in lib/ and test/
13
+ # flog no method more complex than FLOG_MAX
14
+ # flay structural duplication mass below FLAY_MAX
15
+ #
16
+ # So the robot must earn its commit on correctness AND quality — it can't ship
17
+ # working-but-ugly code. Prints a readable report and exits non-zero on any
18
+ # failure. Run from the project root.
19
+ # ===========================================================================
20
+
21
+ require "open3"
22
+ require "flog"
23
+ require "flay"
24
+ require "stringio"
25
+
26
+ FLOG_MAX = Integer(ENV.fetch("FLOG_MAX", "25")) # per-method complexity ceiling
27
+ FLAY_MAX = Integer(ENV.fetch("FLAY_MAX", "40")) # structural-duplication mass ceiling
28
+ LIB_GLOB = "lib/**/*.rb"
29
+
30
+ # Each gate returns [ok?, one-line detail].
31
+
32
+ def gate_tests
33
+ files = Dir["test/**/*_test.rb"]
34
+ return [false, "no test/**/*_test.rb files found"] if files.empty?
35
+
36
+ requires = files.map { |f| "require './#{f}'" }.join("; ")
37
+ out, status = Open3.capture2e("ruby", "-Ilib", "-Itest", "-rminitest/autorun", "-e", requires)
38
+ summary = out[/\d+ runs?, .*?skips?/] || out.lines.last.to_s.strip
39
+ [status.success?, summary]
40
+ end
41
+
42
+ # Lint only lib/ — the implementer's code. The Planner-authored tests in test/
43
+ # are the spec (graded by passing), not the implementer's to restyle.
44
+ #
45
+ # Pass explicit FILE paths (not the "lib" directory) and pin to the project's own
46
+ # .rubocop.yml: this demo project lives inside the robot_lab-to repo, whose
47
+ # .rubocop.yml excludes examples/** — so `rubocop lib` would inspect 0 files and
48
+ # falsely pass. Explicit paths bypass that inherited exclusion.
49
+ def gate_rubocop
50
+ files = Dir[LIB_GLOB]
51
+ return [true, "no lib/ to lint"] if files.empty?
52
+
53
+ cmd = ["rubocop", "--format", "simple", "--no-color"]
54
+ cmd += ["--config", ".rubocop.yml"] if File.exist?(".rubocop.yml")
55
+ out, status = Open3.capture2e(*cmd, *files)
56
+ detail = out[/\d+ files? inspected.*/] || "see rubocop output"
57
+ [status.success?, detail]
58
+ end
59
+
60
+ def gate_flog
61
+ files = Dir[LIB_GLOB]
62
+ return [true, "no lib/ to analyze"] if files.empty?
63
+
64
+ flog = Flog.new(continue: true)
65
+ flog.flog(*files)
66
+ worst_name, worst = flog.totals.max_by { |_, score| score } || [nil, 0.0]
67
+ [worst <= FLOG_MAX, format("worst method %.1f (max %d)%s", worst, FLOG_MAX,
68
+ worst > FLOG_MAX ? " — #{worst_name}" : "")]
69
+ end
70
+
71
+ def gate_flay
72
+ files = Dir[LIB_GLOB]
73
+ return [true, "no lib/ to analyze"] if files.empty?
74
+
75
+ flay = Flay.new
76
+ flay.process(*files)
77
+ flay.analyze # populates masses; process() alone leaves them empty
78
+ mass = flay.masses.values.sum
79
+ [mass <= FLAY_MAX, "duplication mass #{mass} (max #{FLAY_MAX})"]
80
+ end
81
+
82
+ GATES = { tests: -> { gate_tests }, rubocop: -> { gate_rubocop },
83
+ flog: -> { gate_flog }, flay: -> { gate_flay } }.freeze
84
+
85
+ puts "quality gate:"
86
+ failures = []
87
+ GATES.each do |name, gate|
88
+ ok, detail = gate.call
89
+ failures << name unless ok
90
+ puts format(" %<mark>s %<name>-8s %<detail>s", mark: ok ? "✓" : "✗", name: name, detail: detail)
91
+ end
92
+
93
+ if failures.empty?
94
+ puts "→ PASS"
95
+ exit 0
96
+ else
97
+ puts "→ FAIL (#{failures.join(', ')})"
98
+ exit 1
99
+ end
@@ -0,0 +1,2 @@
1
+ /project/
2
+ /.robot_lab_to/
@@ -0,0 +1,117 @@
1
+ # 03 — Scored runs (evals)
2
+
3
+ The `01_basic_usage` and `02_advanced_usage` examples use a **verify command**: a
4
+ change is committed if the test suite exits 0. That answers *"did it break?"* but
5
+ not *"is it better?"* — and an agent will happily report success on a change that
6
+ made no real progress.
7
+
8
+ An **eval** replaces the robot's self-report with an orchestrator-owned,
9
+ measurable judgement, and makes the loop **descend toward a target**. This example
10
+ is a cookbook of the three ways to score a run; each recipe is a drop-in change to
11
+ the `RobotLab::To.run` call (or the `robot-to` CLI) in the earlier examples.
12
+
13
+ ---
14
+
15
+ ## 1. Code — measured descent
16
+
17
+ For software, "better" is a number. Add a **measure** command (prints a number,
18
+ higher = better) and a **target**:
19
+
20
+ ```bash
21
+ robot-to "Raise parser test coverage to 90%" \
22
+ --verify "bundle exec rake test" \ # floor: must stay green (correctness)
23
+ --measure "bundle exec rake coverage" \ # descent signal (higher = better)
24
+ --target 90 # stop once the score reaches this
25
+ ```
26
+
27
+ ```ruby
28
+ RobotLab::To.run(
29
+ "Raise parser test coverage to 90%",
30
+ verify_command: "bundle exec rake test",
31
+ eval_measure: "bundle exec rake coverage", # must print a number to stdout
32
+ eval_target: 90.0,
33
+ max_iterations: 20
34
+ )
35
+ ```
36
+
37
+ An iteration commits only if it **improves** the measured score *and* the verify
38
+ floor passes; a change that scores no better is rolled back, so the branch
39
+ descends monotonically and the run stops itself at the target. Each iteration's
40
+ prompt now includes a **Score Feedback** section — "Best score committed so far:
41
+ 84.0 (target: 90.0)" and a plateau warning — so the robot's next attempt is a
42
+ hypothesis against the target, not a blind guess.
43
+
44
+ > The `measure` command just has to print a number. `rake coverage`, a benchmark
45
+ > harness, `ruby -e 'puts passing_count'`, `grep -c` of a lint report — anything.
46
+
47
+ ---
48
+
49
+ ## 2. Prose — pairwise judgement
50
+
51
+ For a document or opinion piece there is no `rake coverage`. The **prose** eval
52
+ scores each draft with an LLM judge, comparing it *pairwise* against the last
53
+ committed version (absolute LLM scores drift; "is B better than A?" is reliable):
54
+
55
+ ```bash
56
+ robot-to "Write an opinionated guide to the Viable Systems Model" \
57
+ --eval prose \
58
+ --spec outline.md \ # the spec the judge measures against
59
+ --floor "rake docs:lint" \ # optional mechanizable checks (links, TODOs)
60
+ --stop-on-plateau 3 # stop after 3 drafts with no improvement
61
+ ```
62
+
63
+ A draft commits only when the judge rules it **better** than its parent. There's
64
+ no absolute target, so the run ends on `--stop-on-plateau` or when you stop it.
65
+ The judge reuses the doer's model unless you pass `--judge-model`.
66
+
67
+ The `outline.md` spec is **locked** for the whole run — the robot cannot edit the
68
+ criteria it is scored against. Add `--protect-path` for any other grader files.
69
+
70
+ ---
71
+
72
+ ## 3. Custom eval — your own scoring
73
+
74
+ Anything responding to `#score(context)` and returning an `Evals::Score` is an
75
+ eval. Pass an instance (or a proc) as `eval:`:
76
+
77
+ ```ruby
78
+ class FixmeEval < RobotLab::To::Evals::Base
79
+ # Fewer FIXMEs is better; done at zero.
80
+ def score(context)
81
+ remaining = Dir.glob(File.join(context.work_dir, "**/*.rb"))
82
+ .sum { |f| File.read(f).scan(/FIXME/).size }
83
+ RobotLab::To::Evals::Score.new(
84
+ gate_ok: true,
85
+ improved: context.previous_value.nil? || remaining < context.previous_value,
86
+ met_target: remaining.zero?,
87
+ value: -remaining, # higher = better, so negate
88
+ detail: "#{remaining} FIXMEs left",
89
+ output: nil
90
+ )
91
+ end
92
+ end
93
+
94
+ RobotLab::To.run("Burn down the FIXMEs in lib/", eval: FixmeEval.new, max_iterations: 15)
95
+ ```
96
+
97
+ The `context` gives you `work_dir`, `previous_ref` (the parent commit), and
98
+ `previous_value` (the last committed score) so you can compute `improved`
99
+ yourself. For CLI use, register it and pass `--eval fixme`:
100
+
101
+ ```ruby
102
+ RobotLab::To.register_eval(:fixme) { |_config| FixmeEval.new }
103
+ ```
104
+
105
+ ---
106
+
107
+ ## How the pieces fit
108
+
109
+ | Field on `Score` | Decides |
110
+ |------------------|---------|
111
+ | `gate_ok` | correctness floor — must hold to commit at all |
112
+ | `improved` | did it beat the parent? — the descent signal (commit vs. roll back) |
113
+ | `met_target` | is the objective reached? — ends the run (orchestrator-owned) |
114
+ | `value` | the score, for the trajectory / prompt feedback (nil for pairwise) |
115
+
116
+ The harness (loop, commit/rollback, stop conditions, grader lockdown, prompt
117
+ feedback) never changes per product — you only swap the eval.