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,302 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # ===========================================================================
5
+ # 04_prose — two-phase authoring: outline (graded), then sections (graded)
6
+ # ===========================================================================
7
+ #
8
+ # A single pairwise loop can't reliably build a structured document with a small
9
+ # local model. This demo splits the work into two graded phases, each driven by a
10
+ # CUSTOM eval that asks a *separate* judge model for an ABSOLUTE verdict:
11
+ #
12
+ # Phase 1 — OUTLINE: the doer writes outline.md; the judge grades it READY or
13
+ # NEEDS_WORK (with feedback). The run ends when the outline is READY.
14
+ # Phase 2 — SECTIONS: for the approved outline, the doer writes one section per
15
+ # iteration as its own file (sections/NN_*.md); the judge grades each
16
+ # section. The run ends when every outline section has a READY file.
17
+ # Finally the approved sections are assembled into guide.md.
18
+ #
19
+ # Two models, two roles:
20
+ # DOER (default qwen3.6:latest) — writes the outline and the sections
21
+ # VERIFIER (default gpt-oss:latest) — the judge; grades each artifact absolutely
22
+ #
23
+ # The judge writes its feedback to REVIEW.md (git-ignored in the sandbox); the
24
+ # doer is told to read REVIEW.md each iteration. write_guard is disabled for the
25
+ # doer so it can freely rewrite the file it is refining.
26
+ #
27
+ # ---------------------------------------------------------------------------
28
+ # Run it
29
+ # ---------------------------------------------------------------------------
30
+ # ollama pull qwen3.6:latest # doer
31
+ # ollama pull gpt-oss:latest # verifier / judge
32
+ # bundle exec ruby examples/04_prose/prose_run.rb
33
+ #
34
+ # Env: RLTO_MODEL (doer), RLTO_JUDGE_MODEL (verifier), RLTO_TOPIC, RLTO_LOCAL,
35
+ # RLTO_PROVIDER, OLLAMA_BASE.
36
+ # ===========================================================================
37
+
38
+ require "fileutils"
39
+ require "logger"
40
+ require "open3"
41
+ require "net/http"
42
+
43
+ [
44
+ File.expand_path("../../lib", __dir__),
45
+ File.expand_path("../../../robot_lab/lib", __dir__)
46
+ ].each { |p| $LOAD_PATH.unshift(p) if Dir.exist?(p) }
47
+
48
+ # Load robot_lab prompt templates (the judge's system prompt) from this demo's
49
+ # prompts_dir via robot_lab's OWN config env var — no reaching into PromptManager.
50
+ # Set before robot_lab loads its config so RobotLab.build(template:) resolves here.
51
+ PROMPTS_DIR = File.expand_path("prompts_dir", __dir__)
52
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] = PROMPTS_DIR
53
+
54
+ require "ruby_llm"
55
+ require "robot_lab"
56
+ require "robot_lab/to"
57
+ RobotLab.reload_config! if RobotLab.respond_to?(:reload_config!)
58
+
59
+ # --- configuration ---------------------------------------------------------
60
+
61
+ LOCAL = ENV.fetch("RLTO_LOCAL", "true") == "true"
62
+ PROVIDER = ENV.fetch("RLTO_PROVIDER", LOCAL ? "openai" : "anthropic").to_sym
63
+ DOER_MODEL = ENV.fetch("RLTO_MODEL", LOCAL ? "qwen3.6:latest" : "claude-sonnet-4-6")
64
+ JUDGE_MODEL = ENV.fetch("RLTO_JUDGE_MODEL", LOCAL ? "gpt-oss:latest" : "claude-sonnet-4-6")
65
+ OLLAMA = ENV.fetch("OLLAMA_BASE", "http://localhost:11434/v1")
66
+ TOPIC = ENV.fetch("RLTO_TOPIC", "writing good Git commit messages")
67
+
68
+ def configure_local!
69
+ RubyLLM.configure do |c|
70
+ c.openai_api_base = OLLAMA
71
+ c.openai_api_key = "ollama"
72
+ c.request_timeout = 600
73
+ end
74
+ RubyLLM.logger.level = Logger::ERROR
75
+ RubyLLM.models.refresh!
76
+ rescue StandardError => e
77
+ warn "warning: could not refresh Ollama models (#{e.class}: #{e.message})"
78
+ end
79
+
80
+ def preflight_local!
81
+ uri = URI.join(OLLAMA, "models")
82
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
83
+ rescue StandardError
84
+ abort "Cannot reach Ollama at #{OLLAMA}. Start it and pull #{DOER_MODEL} + #{JUDGE_MODEL}."
85
+ end
86
+
87
+ # --- the judge (absolute grader) -------------------------------------------
88
+
89
+ # Ask the judge model to grade `text` against `criteria`. The judge's system
90
+ # prompt is a robot_lab template (prompts_dir/judge.md).
91
+ # @return [Array(Boolean, String)] [ready?, feedback]
92
+ def grade(criteria, text)
93
+ judge = RobotLab.build(name: "judge", model: JUDGE_MODEL, provider: PROVIDER, template: :judge)
94
+ message = RobotLab.render_template(:grade_message, criteria: criteria, artifact: text)
95
+ reply = judge.run(message).last_text_content.to_s
96
+ ready = reply.match?(/\bREADY\b/i) && !reply.match?(/NEEDS_WORK/i)
97
+ feedback = reply[/NEEDS_WORK:?\s*(.+)/im, 1].to_s.strip.slice(0, 400)
98
+ [ready, feedback.empty? ? reply.strip.slice(0, 400) : feedback]
99
+ end
100
+
101
+ # The judge's feedback for the doer's next iteration. Written by the eval
102
+ # (orchestrator-side, so no WriteGuard); read by the doer. Git-ignored.
103
+ def write_review(dir, text)
104
+ File.write(File.join(dir, "REVIEW.md"), "# Reviewer feedback (read me before revising)\n\n#{text}\n")
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ def pending_score(detail)
110
+ RobotLab::To::Evals::Score.new(gate_ok: true, improved: false, met_target: false,
111
+ value: nil, detail: detail, output: nil)
112
+ end
113
+
114
+ # --- Phase 1 eval: grade the outline ---------------------------------------
115
+
116
+ class OutlineGrader < RobotLab::To::Evals::Base
117
+ def initialize(work_dir:)
118
+ super()
119
+ @work_dir = work_dir
120
+ end
121
+
122
+ def score(_context)
123
+ outline = File.read(File.join(@work_dir, "outline.md")) rescue ""
124
+ return pending_score("no outline yet") if outline.strip.empty?
125
+
126
+ ready, feedback = grade(RobotLab.render_template(:outline_criteria, topic: TOPIC), outline)
127
+ write_review(@work_dir, ready ? "The outline is approved. (Phase 2 will use it.)" : feedback)
128
+ RobotLab::To::Evals::Score.new(
129
+ gate_ok: true, improved: true, met_target: ready, value: nil,
130
+ detail: ready ? "outline READY" : "NEEDS_WORK: #{feedback}", output: feedback
131
+ )
132
+ end
133
+ end
134
+
135
+ # --- Phase 2 eval: grade each section, finish when all are written ----------
136
+
137
+ class SectionGrader < RobotLab::To::Evals::Base
138
+ def initialize(work_dir:, total:)
139
+ super()
140
+ @work_dir = work_dir
141
+ @total = total
142
+ end
143
+
144
+ def score(_context)
145
+ files = section_files
146
+ return pending_score("no sections yet") if files.empty?
147
+
148
+ latest = files.last
149
+ body = File.read(latest) rescue ""
150
+ ready, feedback = grade(RobotLab.render_template(:section_criteria, topic: TOPIC), body)
151
+ done = files.size >= @total && ready
152
+ write_review(@work_dir, section_note(files.size, ready, feedback))
153
+ RobotLab::To::Evals::Score.new(
154
+ gate_ok: true, improved: true, met_target: done, value: nil,
155
+ detail: "sections #{files.size}/#{@total} latest=#{ready ? 'READY' : 'NEEDS_WORK'}",
156
+ output: feedback
157
+ )
158
+ end
159
+
160
+ private
161
+
162
+ def section_files
163
+ Dir.glob(File.join(@work_dir, "sections", "*.md")).sort
164
+ end
165
+
166
+ def section_note(count, ready, feedback)
167
+ return "Section #{count}/#{@total} needs work: #{feedback}" unless ready
168
+ return "Section #{count} looks good. Write the NEXT section (#{count + 1}/#{@total})." if count < @total
169
+
170
+ "All #{@total} sections are written and approved."
171
+ end
172
+ end
173
+
174
+ # --- live feedback (robot_lab hook) ----------------------------------------
175
+
176
+ class FeedbackHook < RobotLab::Hook
177
+ class << self
178
+ def before_llm_generation(_ctx)
179
+ $stderr.puts " 🤔 thinking…"
180
+ rescue StandardError
181
+ nil
182
+ end
183
+
184
+ def before_tool_call(ctx)
185
+ args = ctx.tool_args || {}
186
+ target = tidy(args["path"] || args[:path] || args["command"] || args[:command])
187
+ $stderr.puts " 🔧 #{ctx.tool_name}: #{target}"
188
+ rescue StandardError
189
+ nil
190
+ end
191
+
192
+ def tidy(text)
193
+ text.to_s.lines.first.to_s.strip.gsub("#{Dir.pwd}/", "").slice(0, 90)
194
+ end
195
+ end
196
+ end
197
+
198
+ # --- sandbox ---------------------------------------------------------------
199
+
200
+ def sh(*args, chdir:)
201
+ out, err, status = Open3.capture3(*args, chdir: chdir)
202
+ raise "command failed: #{args.join(' ')}\n#{err}" unless status.success?
203
+
204
+ out
205
+ end
206
+
207
+ SANDBOX_DIR = File.expand_path("project", __dir__)
208
+ RUN_DIR = File.expand_path(".robot_lab_to", __dir__)
209
+
210
+ def clean_slate!
211
+ [SANDBOX_DIR, RUN_DIR].each do |path|
212
+ next unless File.exist?(path)
213
+
214
+ puts "Cleaning leftover: #{path}"
215
+ FileUtils.rm_rf(path)
216
+ end
217
+ end
218
+
219
+ def make_sandbox
220
+ FileUtils.mkdir_p(SANDBOX_DIR)
221
+ sh("git", "init", "-q", chdir: SANDBOX_DIR)
222
+ sh("git", "config", "user.email", "example@example.com", chdir: SANDBOX_DIR)
223
+ sh("git", "config", "user.name", "robot_lab-to example", chdir: SANDBOX_DIR)
224
+ File.write(File.join(SANDBOX_DIR, ".gitignore"), "REVIEW.md\n") # judge feedback, not part of the work
225
+ File.write(File.join(SANDBOX_DIR, "README.md"), "# Prose demo\n\nBuilt in two graded phases; see the guide.\n")
226
+ sh("git", "add", "-A", chdir: SANDBOX_DIR)
227
+ sh("git", "commit", "-qm", "initial: empty sandbox", chdir: SANDBOX_DIR)
228
+ SANDBOX_DIR
229
+ end
230
+
231
+ # Count the numbered items in the approved outline — the number of sections.
232
+ def outline_section_count(dir)
233
+ File.read(File.join(dir, "outline.md")).scan(/^\s*\d+\.\s+\S/).size
234
+ rescue StandardError
235
+ 0
236
+ end
237
+
238
+ def assemble_guide(dir)
239
+ sections = Dir.glob(File.join(dir, "sections", "*.md")).sort
240
+ return if sections.empty?
241
+
242
+ body = sections.map { |f| File.read(f).strip }.join("\n\n")
243
+ File.write(File.join(dir, "guide.md"), "# An Opinionated Guide to #{TOPIC.capitalize}\n\n#{body}\n")
244
+ sh("git", "add", "-A", chdir: dir)
245
+ sh("git", "commit", "-qm", "assemble: guide.md from approved sections", chdir: dir)
246
+ end
247
+
248
+ # --- objectives ------------------------------------------------------------
249
+
250
+ OUTLINE_OBJECTIVE = RobotLab.render_template(:outline_objective, topic: TOPIC).strip
251
+
252
+ def sections_objective(total)
253
+ RobotLab.render_template(:sections_objective, total: total).strip
254
+ end
255
+
256
+ # --- main ------------------------------------------------------------------
257
+
258
+ if LOCAL
259
+ preflight_local!
260
+ configure_local!
261
+ end
262
+
263
+ clean_slate!
264
+ sandbox = make_sandbox
265
+ puts "Project dir: #{sandbox}"
266
+ puts "Doer model: #{DOER_MODEL}"
267
+ puts "Verifier model: #{JUDGE_MODEL}"
268
+ puts "Topic: #{TOPIC}"
269
+ puts
270
+
271
+ RobotLab.on(FeedbackHook)
272
+
273
+ common = {
274
+ provider: PROVIDER, model: DOER_MODEL, local_guards: LOCAL, stream: !LOCAL,
275
+ run_dir: RUN_DIR, write_guard: false, require_improvement: false
276
+ }
277
+
278
+ Dir.chdir(sandbox) do
279
+ puts "=== Phase 1: outline (graded until READY) ==="
280
+ RobotLab::To.run(OUTLINE_OBJECTIVE, **common, max_iterations: 6,
281
+ eval: OutlineGrader.new(work_dir: sandbox))
282
+
283
+ total = outline_section_count(sandbox)
284
+ total = 5 if total.zero? # fall back if the outline wasn't a clean numbered list
285
+ puts "\n=== Phase 2: #{total} sections (each graded) ==="
286
+ RobotLab::To.run(sections_objective(total), **common, max_iterations: total * 3 + 2,
287
+ eval: SectionGrader.new(work_dir: sandbox, total: total))
288
+
289
+ assemble_guide(sandbox)
290
+ end
291
+
292
+ # --- report ----------------------------------------------------------------
293
+
294
+ puts "\n=== Result ==="
295
+ puts "Commits:"; puts sh("git", "log", "--oneline", chdir: sandbox)
296
+ guide = File.join(sandbox, "guide.md")
297
+ if File.exist?(guide)
298
+ puts "\nguide.md (#{File.read(guide).lines.size} lines):"
299
+ puts File.read(guide)
300
+ else
301
+ puts "\n(guide.md not assembled — check outline.md and sections/ in #{sandbox})"
302
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RobotLab
6
+ module To
7
+ # Crash-safe writes for run state (notes.md and friends).
8
+ #
9
+ # An overnight run rewrites its state every iteration; a crash or kill
10
+ # mid-write must not leave a truncated file (notes.md is re-injected into
11
+ # the next robot's prompt). The pattern: serialize writers with an flock,
12
+ # stage the full content in a sibling temp file, fsync it, atomically
13
+ # rename it over the target, then fsync the directory. A crash at any point
14
+ # leaves either the previous complete file or the new complete file --
15
+ # never a torn write.
16
+ module AtomicFile
17
+ class << self
18
+ # Atomically replace +path+ with +content+.
19
+ def write(path, content)
20
+ path = Pathname.new(path)
21
+ path.parent.mkpath
22
+ with_lock(path) { replace(path, content.to_s) }
23
+ end
24
+
25
+ # Atomically append +text+ to +path+ (read-modify-write under lock).
26
+ def append(path, text)
27
+ path = Pathname.new(path)
28
+ path.parent.mkpath
29
+ with_lock(path) do
30
+ existing = path.exist? ? path.read : +""
31
+ replace(path, existing + text.to_s)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def replace(path, content)
38
+ tmp = path.parent.join(".#{path.basename}.#{Process.pid}.tmp")
39
+ File.open(tmp, "w") do |f|
40
+ f.write(content)
41
+ f.flush
42
+ f.fsync
43
+ end
44
+ File.rename(tmp, path)
45
+ fsync_dir(path.parent)
46
+ ensure
47
+ # tmp is assigned on the first line, so it is always set here; on the
48
+ # success path the rename already moved it, so rm_f is a no-op.
49
+ FileUtils.rm_f(tmp)
50
+ end
51
+
52
+ def with_lock(path)
53
+ lock = path.parent.join(".#{path.basename}.lock")
54
+ File.open(lock, File::RDWR | File::CREAT, 0o600) do |f|
55
+ f.flock(File::LOCK_EX)
56
+ yield
57
+ end
58
+ end
59
+
60
+ def fsync_dir(dir)
61
+ File.open(dir, &:fsync)
62
+ rescue SystemCallError
63
+ # Some filesystems/platforms reject directory fsync; best-effort.
64
+ nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ # Exponential backoff with interruptible sleep.
6
+ #
7
+ # Formula: 60 * 2^(consecutive_errors - 1) seconds
8
+ # 1 error → 60s
9
+ # 2 errors → 120s
10
+ # 3 errors → 240s
11
+ class Backoff
12
+ BASE_SECONDS = 60
13
+
14
+ def initialize
15
+ @interrupted = false
16
+ end
17
+
18
+ def sleep_for(consecutive_errors)
19
+ @interrupted = false
20
+ return if consecutive_errors <= 0
21
+
22
+ duration = (BASE_SECONDS * (2**(consecutive_errors - 1))).to_i
23
+ duration.times do
24
+ return if @interrupted
25
+
26
+ sleep(1)
27
+ end
28
+ end
29
+
30
+ # Sleep a fixed number of seconds, interruptibly (1s ticks). Used for
31
+ # polling a blocking decision file. Does NOT reset @interrupted: once a
32
+ # signal fires, subsequent waits return immediately so shutdown is prompt.
33
+ def sleep_seconds(seconds)
34
+ seconds.to_i.times do
35
+ return if @interrupted
36
+
37
+ sleep(1)
38
+ end
39
+ end
40
+
41
+ def interrupt!
42
+ @interrupted = true
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RobotLab
6
+ module To
7
+ # OptionParser-based CLI for robot-to.
8
+ class CLI
9
+ # Value options: [flag, type-or-choices, description, opts-key]. Each sets
10
+ # opts[key] = parsed value. Kept as data so build_parser stays simple.
11
+ VALUE_OPTIONS = [
12
+ ["--provider NAME", String, "LLM provider to use (default: openai)", :provider],
13
+ ["--model MODEL", String, "LLM model to use (default: gpt-5.5)", :model],
14
+ ["--max-iterations N", Integer, "Stop after N iterations (default: unlimited)", :max_iterations],
15
+ ["--max-tokens N", Integer, "Stop after N total tokens (default: unlimited)", :max_tokens],
16
+ ["--stop-when CONDITION", String,
17
+ "Natural-language condition; robot sets should_fully_stop when met", :stop_when],
18
+ ["--max-consecutive-failures N", Integer,
19
+ "Abort after N consecutive failures (default: 3)", :max_consecutive_failures],
20
+ ["--max-verify-repairs N", Integer,
21
+ "Repair-in-place attempts when --verify-command fails (default: 2)", :max_verify_repairs],
22
+ ["--verify-command CMD", String,
23
+ "Command that must pass before a successful iteration is committed", :verify_command],
24
+ ["--verify-timeout SECONDS", Integer, "Timeout for --verify-command (default: 600)", :verify_timeout],
25
+ ["--eval NAME", String, "Eval strategy: code | null (default: code when --verify-command set)", :eval],
26
+ ["--measure CMD", String,
27
+ "Command printing a numeric score; higher is better (Evals::Code)", :eval_measure],
28
+ ["--target FLOAT", Float, "Stop once the measured score reaches TARGET", :eval_target],
29
+ ["--spec PATH", String, "Spec/outline artifact the eval measures against (Evals::Prose)", :eval_spec],
30
+ ["--floor CMD", String,
31
+ "Mechanizable floor-check command for prose (links, coverage, AI-tells)", :eval_floor],
32
+ ["--judge-model MODEL", String,
33
+ "Model for the pairwise prose judge (default: --model)", :eval_judge_model],
34
+ ["--stop-on-plateau N", Integer,
35
+ "Stop after N iterations with no committed improvement", :stop_on_plateau],
36
+ ["--run-dir PATH", String, "Directory for run state (default: .robot_lab_to)", :run_dir],
37
+ ["--commit-format FORMAT", %w[default conventional],
38
+ "Commit message format: default or conventional", :commit_format],
39
+ ["--resume RUN_ID", String,
40
+ "Resume a paused run (loads objective + state from its run.json)", :resume],
41
+ ["--decision-mode MODE", %w[wait exit],
42
+ "On a blocking decision: wait (poll) or exit (stop for --resume). Default: wait", :decision_mode],
43
+ ["--decision-timeout SECONDS", Integer,
44
+ "Max seconds to wait on a blocking decision (default: no limit)", :decision_timeout],
45
+ ["--decision-poll SECONDS", Integer,
46
+ "Seconds between polls while waiting on a decision (default: 30)", :decision_wait_poll]
47
+ ].freeze
48
+
49
+ def self.run(argv = ARGV)
50
+ new.run(argv)
51
+ end
52
+
53
+ def run(argv)
54
+ return run_decisions(argv[1..] || []) if argv.first == "decisions"
55
+
56
+ opts = {}
57
+ parser = build_parser(opts)
58
+ args = parser.parse!(argv.dup)
59
+
60
+ if opts[:version]
61
+ puts "robot-to #{VERSION}"
62
+ return
63
+ end
64
+
65
+ if opts[:resume]
66
+ return RobotLab::To.resume(opts[:resume], **opts.except(:version, :resume))
67
+ end
68
+
69
+ objective = args.first || read_stdin_objective
70
+ if objective.nil? || objective.strip.empty?
71
+ # $stderr.puts, not warn: warn is silenced when $VERBOSE is nil.
72
+ $stderr.puts "Error: objective required (pass as argument or via stdin)"
73
+ $stderr.puts parser
74
+ exit 1
75
+ end
76
+
77
+ RobotLab::To.run(objective.strip, **opts.except(:version, :resume))
78
+ end
79
+
80
+ private
81
+
82
+ # `robot-to decisions [run_id]` — list pending decisions and their file
83
+ # paths so a human knows what needs resolving before resuming.
84
+ def run_decisions(args)
85
+ run_dir = Config.new.run_dir
86
+ run_id = args.first || latest_run_id(run_dir)
87
+ if run_id.nil?
88
+ $stderr.puts "No runs found under #{run_dir}/runs"
89
+ exit 1
90
+ end
91
+
92
+ manager = DecisionManager.new(Pathname.new(run_dir).join("runs", run_id, "decisions"))
93
+ print_decisions(run_id, manager)
94
+ end
95
+
96
+ def print_decisions(run_id, manager)
97
+ pending = manager.pending
98
+ resolved = manager.resolved_open
99
+ puts "Run #{run_id}"
100
+ puts ""
101
+ if pending.empty? && resolved.empty?
102
+ puts "No open decisions."
103
+ return
104
+ end
105
+ list_group("Pending (awaiting your answer)", pending)
106
+ list_group("Resolved (not yet consumed)", resolved)
107
+ puts ""
108
+ puts "Resolve a pending decision by editing its file: set `status: resolved`"
109
+ puts "and fill `resolution:`, then run `robot-to --resume #{run_id}`."
110
+ end
111
+
112
+ def list_group(title, decisions)
113
+ return if decisions.empty?
114
+
115
+ puts "#{title}:"
116
+ decisions.each do |d|
117
+ flag = d.blocking? ? " [BLOCKING]" : ""
118
+ puts " - #{d.question}#{flag}"
119
+ puts " #{d.path}"
120
+ end
121
+ puts ""
122
+ end
123
+
124
+ def latest_run_id(run_dir)
125
+ Dir.glob(Pathname.new(run_dir).join("runs", "*")).select { |p| File.directory?(p) }
126
+ .sort.map { |p| File.basename(p) }.last
127
+ end
128
+
129
+ def build_parser(opts)
130
+ OptionParser.new do |p|
131
+ p.banner = "Usage: robot-to [objective] [options]\n " \
132
+ "robot-to --resume RUN_ID [options]\n " \
133
+ "robot-to decisions [RUN_ID]"
134
+ p.separator ""
135
+ p.separator "Runs a robot_lab Robot in an autonomous loop toward the given objective."
136
+ p.separator ""
137
+ p.separator "Options:"
138
+
139
+ VALUE_OPTIONS.each do |flag, type, desc, key|
140
+ p.on(flag, type, desc) { |v| opts[key] = v }
141
+ end
142
+
143
+ add_flag_options(p, opts)
144
+ end
145
+ end
146
+
147
+ # Boolean and terminal flags (each has bespoke behavior).
148
+ def add_flag_options(parser, opts)
149
+ parser.on("--[no-]require-improvement",
150
+ "Roll back gate-passing iterations that don't improve (default: on)") do |v|
151
+ opts[:require_improvement] = v
152
+ end
153
+ parser.on("--protect-path GLOB", "Lock a grader file from robot edits (repeatable)") do |v|
154
+ (opts[:protect_paths] ||= []) << v
155
+ end
156
+ parser.on("--no-decisions", "Disable the request_decision tool for this run") { opts[:decisions_enabled] = false }
157
+ parser.on("--local-guards", "Add built-in file tools + small-model guardrails (for local models)") do
158
+ opts[:local_guards] = true
159
+ end
160
+ parser.on("--no-stream", "Disable response streaming (required for local Ollama tool calls)") { opts[:stream] = false }
161
+ parser.on("--debug", "Enable verbose JSONL logging to stderr") { opts[:debug] = true }
162
+ parser.on("--version", "Print version and exit") { opts[:version] = true }
163
+ parser.on("-h", "--help", "Show this help") do
164
+ puts parser
165
+ exit
166
+ end
167
+ end
168
+
169
+ def read_stdin_objective
170
+ return nil if $stdin.tty?
171
+
172
+ $stdin.read.strip.then { |s| s.empty? ? nil : s }
173
+ end
174
+ end
175
+ end
176
+ end