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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +21 -0
- data/.quality/reek_baseline.txt +17 -0
- data/.rubocop.yml +7 -0
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +74 -0
- data/COMMITS.md +27 -0
- data/README.md +352 -0
- data/Rakefile +132 -0
- data/bin/robot-to +14 -0
- data/docs/about/changelog.md +82 -0
- data/docs/assets/architecture.svg +110 -0
- data/docs/assets/images/robot_lab-to.jpg +0 -0
- data/docs/assets/iteration-loop.svg +97 -0
- data/docs/concepts/evals.md +254 -0
- data/docs/concepts/iteration-loop.md +128 -0
- data/docs/concepts/notes.md +91 -0
- data/docs/concepts/run-state.md +93 -0
- data/docs/concepts/stop-conditions.md +111 -0
- data/docs/concepts/verification.md +75 -0
- data/docs/configuration/cli.md +111 -0
- data/docs/configuration/index.md +93 -0
- data/docs/configuration/settings.md +270 -0
- data/docs/getting-started/anatomy-of-a-run.md +111 -0
- data/docs/getting-started/installation.md +78 -0
- data/docs/getting-started/quick-start.md +105 -0
- data/docs/index.md +96 -0
- data/docs/local-models/guardrails.md +117 -0
- data/docs/local-models/index.md +88 -0
- data/docs/local-models/ollama.md +122 -0
- data/docs/local-models/tools.md +92 -0
- data/docs/reference/architecture.md +139 -0
- data/examples/01_basic_usage/.gitignore +9 -0
- data/examples/01_basic_usage/README.md +173 -0
- data/examples/01_basic_usage/basic_usage.rb +386 -0
- data/examples/02_advanced_usage/.gitignore +8 -0
- data/examples/02_advanced_usage/README.md +117 -0
- data/examples/02_advanced_usage/advanced_usage.rb +308 -0
- data/examples/02_advanced_usage/quality_gate.rb +99 -0
- data/examples/03_scored/.gitignore +2 -0
- data/examples/03_scored/README.md +117 -0
- data/examples/03_scored/scored_run.rb +325 -0
- data/examples/04_prose/.gitignore +2 -0
- data/examples/04_prose/README.md +43 -0
- data/examples/04_prose/prompts_dir/grade_message.md +14 -0
- data/examples/04_prose/prompts_dir/judge.md +11 -0
- data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
- data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
- data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
- data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
- data/examples/04_prose/prose_run.rb +302 -0
- data/lib/robot_lab/to/atomic_file.rb +69 -0
- data/lib/robot_lab/to/backoff.rb +46 -0
- data/lib/robot_lab/to/cli.rb +176 -0
- data/lib/robot_lab/to/commit_manager.rb +135 -0
- data/lib/robot_lab/to/config/defaults.yml +27 -0
- data/lib/robot_lab/to/config.rb +86 -0
- data/lib/robot_lab/to/decision.rb +40 -0
- data/lib/robot_lab/to/decision_manager.rb +193 -0
- data/lib/robot_lab/to/errors.rb +30 -0
- data/lib/robot_lab/to/evals/base.rb +23 -0
- data/lib/robot_lab/to/evals/code.rb +77 -0
- data/lib/robot_lab/to/evals/context.rb +17 -0
- data/lib/robot_lab/to/evals/factory.rb +84 -0
- data/lib/robot_lab/to/evals/null.rb +17 -0
- data/lib/robot_lab/to/evals/prose.rb +110 -0
- data/lib/robot_lab/to/evals/score.rb +22 -0
- data/lib/robot_lab/to/exit_summary.rb +75 -0
- data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
- data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
- data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
- data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
- data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
- data/lib/robot_lab/to/guards/run_store.rb +31 -0
- data/lib/robot_lab/to/guards/write_guard.rb +71 -0
- data/lib/robot_lab/to/guards.rb +54 -0
- data/lib/robot_lab/to/iteration_result.rb +29 -0
- data/lib/robot_lab/to/jsonl_logger.rb +59 -0
- data/lib/robot_lab/to/notes_manager.rb +121 -0
- data/lib/robot_lab/to/orchestrator.rb +642 -0
- data/lib/robot_lab/to/prompt_builder.rb +208 -0
- data/lib/robot_lab/to/run.rb +109 -0
- data/lib/robot_lab/to/stop_conditions.rb +71 -0
- data/lib/robot_lab/to/tools/bash.rb +75 -0
- data/lib/robot_lab/to/tools/edit.rb +52 -0
- data/lib/robot_lab/to/tools/file_tool.rb +32 -0
- data/lib/robot_lab/to/tools/read.rb +57 -0
- data/lib/robot_lab/to/tools/request_decision.rb +66 -0
- data/lib/robot_lab/to/tools/submit_result.rb +50 -0
- data/lib/robot_lab/to/tools/write.rb +32 -0
- data/lib/robot_lab/to/verifier.rb +63 -0
- data/lib/robot_lab/to/version.rb +7 -0
- data/lib/robot_lab/to.rb +81 -0
- data/mkdocs.yml +145 -0
- 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,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.
|