robot_lab-to 0.2.7 → 0.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -0
  3. data/Archspec.rb +34 -0
  4. data/CHANGELOG.md +4 -0
  5. data/CLAUDE.md +3 -2
  6. data/README.md +9 -7
  7. data/Rakefile +6 -108
  8. data/docs/concepts/stop-conditions.md +3 -3
  9. data/docs/configuration/cli.md +4 -5
  10. data/docs/configuration/index.md +2 -3
  11. data/docs/configuration/settings.md +7 -7
  12. data/docs/getting-started/installation.md +5 -5
  13. data/docs/index.md +3 -3
  14. data/docs/local-models/index.md +14 -17
  15. data/docs/local-models/lm-studio.md +92 -0
  16. data/docs/reference/architecture.md +4 -4
  17. data/examples/.envrc +8 -0
  18. data/examples/01_basic_usage/README.md +14 -15
  19. data/examples/01_basic_usage/basic_usage.rb +20 -57
  20. data/examples/02_advanced_usage/README.md +10 -7
  21. data/examples/02_advanced_usage/advanced_usage.rb +23 -31
  22. data/examples/03_scored/scored_run.rb +19 -51
  23. data/examples/04_prose/README.md +15 -14
  24. data/examples/04_prose/prose_run.rb +22 -38
  25. data/examples/common.rb +111 -0
  26. data/lib/robot_lab/to/cli.rb +61 -34
  27. data/lib/robot_lab/to/commit_manager.rb +4 -0
  28. data/lib/robot_lab/to/config.rb +12 -0
  29. data/lib/robot_lab/to/decision_manager.rb +12 -1
  30. data/lib/robot_lab/to/exit_summary.rb +17 -16
  31. data/lib/robot_lab/to/guards/checkpoint.rb +6 -3
  32. data/lib/robot_lab/to/guards/quality_monitor.rb +4 -2
  33. data/lib/robot_lab/to/guards/run_store.rb +4 -2
  34. data/lib/robot_lab/to/notes_manager.rb +3 -0
  35. data/lib/robot_lab/to/orchestrator.rb +85 -34
  36. data/lib/robot_lab/to/prompt_builder.rb +2 -0
  37. data/lib/robot_lab/to/run.rb +8 -8
  38. data/lib/robot_lab/to/stop_conditions.rb +10 -6
  39. data/lib/robot_lab/to/tools/bash.rb +7 -2
  40. data/lib/robot_lab/to/tools/edit.rb +9 -4
  41. data/lib/robot_lab/to/tools/read.rb +3 -3
  42. data/lib/robot_lab/to/tools/request_decision.rb +14 -10
  43. data/lib/robot_lab/to/tools/submit_result.rb +12 -11
  44. data/lib/robot_lab/to/tools/write.rb +2 -2
  45. data/lib/robot_lab/to/version.rb +1 -1
  46. data/lib/robot_lab/to.rb +22 -0
  47. data/mkdocs.yml +1 -1
  48. metadata +10 -7
  49. data/docs/local-models/ollama.md +0 -122
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # ===========================================================================
4
+ # common.rb — shared RubyLLM setup/teardown for the robot_lab-to example demos
5
+ # ===========================================================================
6
+ #
7
+ # Every example under examples/ requires this file (examples/.envrc supplies
8
+ # the RLTO_* / LMS_BASE_URL defaults) and calls `setup` before talking to
9
+ # RubyLLM -- once for a single-model example, or once per model role for an
10
+ # example with more than one (e.g. 04_prose's doer + judge).
11
+ #
12
+ # actual_provider = setup(provider: PROVIDER, model: MODEL)
13
+ # RobotLab::To.run(objective, provider: actual_provider, model: MODEL, ...)
14
+ #
15
+ # "lms" is the ruby_llm-providers-lms gem's native LM Studio provider.
16
+ # When `provider` is "lms", setup:
17
+ # 1. points the :lms provider at LMS_BASE_URL
18
+ # 2. starts the LM Studio server if it isn't already running
19
+ # 3. loads `model` into LM Studio if it isn't already loaded
20
+ # and returns :lms -- the RubyLLM-recognized provider to pass to RobotLab.
21
+ # Any other provider (a cloud one) passes straight through untouched; setup
22
+ # does nothing else for it (the example configures its own API key as before).
23
+ #
24
+ # teardown stops the LM Studio server, but ONLY if setup started it here -- a
25
+ # server you already had running (the LM Studio app, another example) is left
26
+ # alone. It runs automatically via an at_exit hook; examples never call it.
27
+ # ===========================================================================
28
+
29
+ require "json"
30
+ require "logger"
31
+ require "open3"
32
+ require "uri"
33
+
34
+ require "ruby_llm"
35
+ require "ruby_llm/providers/lms"
36
+
37
+ LMS_BASE_URL = ENV.fetch("LMS_BASE_URL", "http://localhost:1234/v1")
38
+
39
+ @lms_server_started_by_us = false
40
+
41
+ def setup(provider: ENV.fetch("RLTO_PROVIDER", "lms"), model: ENV.fetch("RLTO_MODEL", "qwen/qwen3.8-27b"))
42
+ provider = provider.to_sym
43
+ return provider unless provider == :lms
44
+
45
+ configure_lms!
46
+ ensure_lms_server_running!
47
+ ensure_lms_model_loaded!(model)
48
+ :lms
49
+ end
50
+
51
+ def teardown
52
+ return unless @lms_server_started_by_us
53
+
54
+ puts "Stopping the LM Studio server (started for this run)…"
55
+ Open3.capture2e("lms", "server", "stop")
56
+ end
57
+
58
+ at_exit { teardown }
59
+
60
+ # --- internals ---------------------------------------------------------------
61
+
62
+ # Point the :lms provider at the LM Studio endpoint. Local models are assumed
63
+ # to exist, so no registry refresh is needed.
64
+ def configure_lms!
65
+ RubyLLM.configure do |c|
66
+ c.lms_api_base = LMS_BASE_URL
67
+ c.request_timeout = 600
68
+ end
69
+ RubyLLM.logger.level = Logger::ERROR
70
+ end
71
+
72
+ def lms_server_running?
73
+ out, status = Open3.capture2("lms", "server", "status", "--json")
74
+ status.success? && JSON.parse(out)["running"] == true
75
+ rescue StandardError
76
+ false
77
+ end
78
+
79
+ def ensure_lms_server_running!
80
+ return if lms_server_running?
81
+
82
+ puts "Starting the LM Studio server…"
83
+ port = URI.parse(LMS_BASE_URL).port.to_s
84
+ out, status = Open3.capture2e("lms", "server", "start", "--port", port)
85
+ abort "Could not start the LM Studio server:\n#{out}" unless status.success? && lms_server_running?
86
+
87
+ @lms_server_started_by_us = true
88
+ rescue Errno::ENOENT
89
+ abort "The `lms` CLI was not found. Install LM Studio (https://lmstudio.ai) and " \
90
+ "run `lms bootstrap` to put it on your PATH."
91
+ end
92
+
93
+ def lms_model_loaded?(model)
94
+ out, status = Open3.capture2("lms", "ps", "--json")
95
+ return false unless status.success?
96
+
97
+ JSON.parse(out).any? { |m| m["modelKey"] == model || m["identifier"] == model }
98
+ rescue StandardError
99
+ false
100
+ end
101
+
102
+ def ensure_lms_model_loaded!(model)
103
+ return if lms_model_loaded?(model)
104
+
105
+ puts "Loading #{model} into LM Studio (first run only)…"
106
+ out, status = Open3.capture2e("lms", "load", model, "-y")
107
+ abort "Could not load #{model} in LM Studio:\n#{out}" unless status.success?
108
+ rescue Errno::ENOENT
109
+ abort "The `lms` CLI was not found. Install LM Studio (https://lmstudio.ai) and " \
110
+ "run `lms bootstrap` to put it on your PATH."
111
+ end
@@ -46,6 +46,17 @@ module RobotLab
46
46
  "Seconds between polls while waiting on a decision (default: 30)", :decision_wait_poll]
47
47
  ].freeze
48
48
 
49
+ # Boolean flags with a fixed value when passed (no argument). Flags
50
+ # needing bespoke behavior (protect-path, require-improvement, help)
51
+ # stay in #add_flag_options.
52
+ BOOLEAN_FLAGS = [
53
+ ["--no-decisions", "Disable the request_decision tool for this run", :decisions_enabled, false],
54
+ ["--local-guards", "Add built-in file tools + small-model guardrails (for local models)", :local_guards, true],
55
+ ["--no-stream", "Disable response streaming (required for local Ollama tool calls)", :stream, false],
56
+ ["--debug", "Enable verbose JSONL logging to stderr", :debug, true],
57
+ ["--version", "Print version and exit", :version, true]
58
+ ].freeze
59
+
49
60
  def self.run(argv = ARGV)
50
61
  new.run(argv)
51
62
  end
@@ -53,32 +64,33 @@ module RobotLab
53
64
  def run(argv)
54
65
  return run_decisions(argv[1..] || []) if argv.first == "decisions"
55
66
 
56
- opts = {}
57
- parser = build_parser(opts)
58
- args = parser.parse!(argv.dup)
67
+ opts, parser, args = parse_options(argv)
59
68
 
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
69
+ return puts("robot-to #{VERSION}") if opts[:version]
70
+ return RobotLab::To.resume(opts[:resume], **opts.except(:version, :resume)) if opts[:resume]
68
71
 
69
72
  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
73
+ abort_missing_objective!(parser) if objective.nil? || objective.strip.empty?
76
74
 
77
75
  RobotLab::To.run(objective.strip, **opts.except(:version, :resume))
78
76
  end
79
77
 
80
78
  private
81
79
 
80
+ def parse_options(argv)
81
+ opts = {}
82
+ parser = build_parser(opts)
83
+ args = parser.parse!(argv.dup)
84
+ [opts, parser, args]
85
+ end
86
+
87
+ def abort_missing_objective!(parser)
88
+ # $stderr.puts, not warn: warn is silenced when $VERBOSE is nil.
89
+ $stderr.puts "Error: objective required (pass as argument or via stdin)"
90
+ $stderr.puts parser
91
+ exit 1
92
+ end
93
+
82
94
  # `robot-to decisions [run_id]` — list pending decisions and their file
83
95
  # paths so a human knows what needs resolving before resuming.
84
96
  def run_decisions(args)
@@ -96,17 +108,19 @@ module RobotLab
96
108
  def print_decisions(run_id, manager)
97
109
  pending = manager.pending
98
110
  resolved = manager.resolved_open
99
- puts "Run #{run_id}"
100
- puts ""
111
+
101
112
  if pending.empty? && resolved.empty?
102
- puts "No open decisions."
113
+ puts "Run #{run_id}\n\nNo open decisions."
103
114
  return
104
115
  end
116
+
117
+ puts "Run #{run_id}\n\n"
105
118
  list_group("Pending (awaiting your answer)", pending)
106
119
  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}`."
120
+ puts <<~MSG
121
+ Resolve a pending decision by editing its file: set `status: resolved`
122
+ and fill `resolution:`, then run `robot-to --resume #{run_id}`.
123
+ MSG
110
124
  end
111
125
 
112
126
  def list_group(title, decisions)
@@ -126,6 +140,7 @@ module RobotLab
126
140
  .sort.map { |p| File.basename(p) }.last
127
141
  end
128
142
 
143
+ # :reek:FeatureEnvy -- configuring the OptionParser instance being built.
129
144
  def build_parser(opts)
130
145
  OptionParser.new do |p|
131
146
  p.banner = "Usage: robot-to [objective] [options]\n " \
@@ -136,30 +151,42 @@ module RobotLab
136
151
  p.separator ""
137
152
  p.separator "Options:"
138
153
 
139
- VALUE_OPTIONS.each do |flag, type, desc, key|
140
- p.on(flag, type, desc) { |v| opts[key] = v }
141
- end
142
-
154
+ add_value_options(p, opts)
143
155
  add_flag_options(p, opts)
144
156
  end
145
157
  end
146
158
 
159
+ # :reek:NestedIterators -- one .on registration per option, each with its
160
+ # own value-assignment callback; that's the OptionParser API shape.
161
+ def add_value_options(parser, opts)
162
+ VALUE_OPTIONS.each do |flag, type, desc, key|
163
+ parser.on(flag, type, desc) { |v| opts[key] = v }
164
+ end
165
+ end
166
+
167
+ # :reek:FeatureEnvy -- registering flags on the OptionParser being built.
147
168
  # Boolean and terminal flags (each has bespoke behavior).
148
169
  def add_flag_options(parser, opts)
170
+ add_require_improvement_option(parser, opts)
171
+ add_protect_path_option(parser, opts)
172
+ BOOLEAN_FLAGS.each { |flag, desc, key, value| parser.on(flag, desc) { opts[key] = value } }
173
+ add_help_option(parser)
174
+ end
175
+
176
+ def add_require_improvement_option(parser, opts)
149
177
  parser.on("--[no-]require-improvement",
150
178
  "Roll back gate-passing iterations that don't improve (default: on)") do |v|
151
179
  opts[:require_improvement] = v
152
180
  end
181
+ end
182
+
183
+ def add_protect_path_option(parser, opts)
153
184
  parser.on("--protect-path GLOB", "Lock a grader file from robot edits (repeatable)") do |v|
154
185
  (opts[:protect_paths] ||= []) << v
155
186
  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 }
187
+ end
188
+
189
+ def add_help_option(parser)
163
190
  parser.on("-h", "--help", "Show this help") do
164
191
  puts parser
165
192
  exit
@@ -8,6 +8,9 @@ module RobotLab
8
8
  #
9
9
  # All subprocess calls use explicit argv arrays (no shell interpolation).
10
10
  # GIT_TERMINAL_PROMPT=0 prevents credential prompts from hanging the loop.
11
+ # :reek:RepeatedConditional -- each `status.success?` belongs to a distinct
12
+ # Open3.capture3 call with its own failure handling; there is no shared
13
+ # condition to extract.
11
14
  class CommitManager
12
15
  GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
13
16
 
@@ -72,6 +75,7 @@ module RobotLab
72
75
  parse_diff_stat(out)
73
76
  end
74
77
 
78
+ # :reek:FeatureEnvy -- inherent to building/reading/appending a Pathname.
75
79
  def add_to_local_exclude(entry)
76
80
  exclude = Pathname.new(@work_dir).join(".git", "info", "exclude")
77
81
  exclude.parent.mkpath
@@ -11,6 +11,15 @@ module RobotLab
11
11
  # 2. User config file (~/.config/robot_lab/to.yml)
12
12
  # 3. Environment variables (ROBOT_LAB_TO_*)
13
13
  # 4. Constructor keyword arguments (CLI overrides)
14
+ # :reek:TooManyInstanceVariables -- a flat CLI-override bag for ~24
15
+ # independent settings; each ivar is genuinely one distinct setting.
16
+ # :reek:InstanceVariableAssumption -- the defaults.yml-backed ivars
17
+ # (provider, model, max_consecutive_failures, max_submit_nudges,
18
+ # max_verify_repairs, verify_timeout, run_dir, commit_format, local_guards,
19
+ # stream, debug, decisions_enabled, decision_mode, decision_wait_poll,
20
+ # decision_timeout) ARE assigned -- by `super()` (MywayConfig::Base /
21
+ # Anyway::Config), before any of this class's own code runs. Do NOT
22
+ # pre-nil them here: that overwrites the real loaded values with nil.
14
23
  class Config < MywayConfig::Base
15
24
  config_name :robot_lab_to
16
25
  env_prefix :robot_lab_to
@@ -34,6 +43,9 @@ module RobotLab
34
43
 
35
44
  def initialize(**overrides)
36
45
  super()
46
+ # CLI-only, no YAML default (nil means "no limit / not set") -- unlike
47
+ # the defaults.yml-backed ivars above, super() never touches these.
48
+ @max_iterations = @max_tokens = @stop_when = @verify_command = nil
37
49
  @eval = @eval_measure = @eval_target = nil
38
50
  @require_improvement = @stop_on_plateau = nil
39
51
  @eval_judge_model = @eval_spec = @eval_floor = nil
@@ -40,6 +40,11 @@ module RobotLab
40
40
  end
41
41
 
42
42
  # Persist a decision the robot raised. Returns the parsed Decision.
43
+ # :reek:BooleanParameter -- blocking is stored data (front matter), not a
44
+ # behavior switch.
45
+ # :reek:ControlParameter -- the ternary only coerces blocking to true/false.
46
+ # :reek:LongParameterList { max_params: 6 } -- one field per front-matter
47
+ # attribute; a hash would just move the same six names elsewhere.
43
48
  def record(question:, situation: "", options: [], recommendation: "", blocking: false, iteration: 0)
44
49
  id = generate_id
45
50
  path = @dir.join("#{id}.md")
@@ -57,6 +62,7 @@ module RobotLab
57
62
  def pending = all.select(&:pending?)
58
63
  # resolved but not yet closed
59
64
  def resolved_open = all.select(&:resolved?)
65
+ # :reek:FeatureEnvy -- filtering by a Decision's own predicates.
60
66
  def blocking_pending = all.select { |d| d.pending? && d.blocking? }
61
67
  def blocking_pending? = blocking_pending.any?
62
68
 
@@ -66,6 +72,7 @@ module RobotLab
66
72
  # Mark a resolved decision closed: its resolution has been delivered to a
67
73
  # robot and committed, so it should no longer be re-injected. Flips only
68
74
  # the status line, preserving whatever the human wrote in the body.
75
+ # :reek:FeatureEnvy -- reading/rewriting the decision's own file.
69
76
  def close(decision)
70
77
  raw = File.read(decision.path)
71
78
  AtomicFile.write(decision.path, flip_status(raw, "closed"))
@@ -82,8 +89,10 @@ module RobotLab
82
89
  raw.sub(/^status:.*$/, "status: #{status}")
83
90
  end
84
91
 
92
+ # :reek:LongParameterList { max_params: 7 } -- one field per front-matter
93
+ # attribute, mirroring #record.
85
94
  def render_new(id:, question:, situation:, options:, recommendation:, blocking:, iteration:)
86
- options_md = options.empty? ? "(none provided)\n" : options.each_with_index.map { |o, i| "#{i + 1}. #{o}" }.join("\n") + "\n"
95
+ options_md = options.empty? ? "(none provided)\n" : options.each_with_index.map { |opt, i| "#{i + 1}. #{opt}" }.join("\n") + "\n"
87
96
  # Build the front matter with YAML.dump so question/recommendation are
88
97
  # escaped correctly (they can contain colons, quotes, etc.).
89
98
  front = {
@@ -116,6 +125,7 @@ module RobotLab
116
125
 
117
126
  # Split a file into (front_matter_hash, body). Returns a Decision or nil
118
127
  # when the file is unreadable.
128
+ # :reek:FeatureEnvy -- building a Decision from its own front matter hash.
119
129
  def parse(path)
120
130
  text = File.read(path)
121
131
  fm, body = split_front_matter(text)
@@ -138,6 +148,7 @@ module RobotLab
138
148
  nil
139
149
  end
140
150
 
151
+ # :reek:FeatureEnvy -- slicing the raw file text into front matter/body.
141
152
  def split_front_matter(text)
142
153
  if text.start_with?("---\n") && (close_idx = text.index("\n---", 4))
143
154
  raw_fm = text[4...close_idx]
@@ -11,14 +11,9 @@ module RobotLab
11
11
  end
12
12
 
13
13
  def print
14
- stat = @run.base_commit ? diff_stat : { insertions: 0, deletions: 0, files: 0 }
15
- aborted = !@abort_reason.nil?
16
- good_iters = @run.commits
17
- fail_iters = @run.iteration - good_iters
18
-
19
14
  puts ""
20
- print_header(aborted)
21
- print_counters(good_iters, fail_iters, stat)
15
+ @abort_reason ? print_aborted_header : print_completed_header
16
+ print_counters
22
17
  print_paths
23
18
  print_next_steps
24
19
  puts ""
@@ -26,17 +21,21 @@ module RobotLab
26
21
 
27
22
  private
28
23
 
29
- def print_header(aborted)
30
- if aborted
31
- puts "robot-to stopped — #{@config.model} — #{@run.elapsed_human}"
32
- puts "Reason: #{@abort_reason}"
33
- else
34
- puts "robot-to complete — #{@config.model} — #{@run.elapsed_human}"
35
- puts "Branch: #{@run.branch}"
36
- end
24
+ def print_aborted_header
25
+ puts "robot-to stopped — #{@config.model} — #{@run.elapsed_human}"
26
+ puts "Reason: #{@abort_reason}"
27
+ end
28
+
29
+ def print_completed_header
30
+ puts "robot-to complete — #{@config.model} — #{@run.elapsed_human}"
31
+ puts "Branch: #{@run.branch}"
37
32
  end
38
33
 
39
- def print_counters(good_iters, fail_iters, stat)
34
+ def print_counters
35
+ good_iters = @run.commits
36
+ fail_iters = @run.iteration - good_iters
37
+ stat = diff_stat
38
+
40
39
  puts ""
41
40
  puts "Iterations: #{@run.iteration} total (#{good_iters} good / #{fail_iters} failed)"
42
41
  puts "Tokens: #{comma(@run.total_tokens)} (#{comma(@run.input_tokens)} in / #{comma(@run.output_tokens)} out)"
@@ -61,6 +60,8 @@ module RobotLab
61
60
  end
62
61
 
63
62
  def diff_stat
63
+ return { insertions: 0, deletions: 0, files: 0 } unless @run.base_commit
64
+
64
65
  git = CommitManager.new
65
66
  git.diff_stat(@run.base_commit)
66
67
  rescue StandardError
@@ -48,7 +48,12 @@ module RobotLab
48
48
 
49
49
  tracked << path
50
50
  FileUtils.mkdir_p(dir)
51
- dest = File.join(dir, safe_name(path))
51
+ write_backup(path, File.join(dir, safe_name(path)))
52
+ rescue SystemCallError, IOError
53
+ nil
54
+ end
55
+
56
+ def write_backup(path, dest)
52
57
  if File.exist?(path)
53
58
  FileUtils.cp(path, dest)
54
59
  dest
@@ -56,8 +61,6 @@ module RobotLab
56
61
  File.write("#{dest}.absent", "")
57
62
  "#{dest}.absent"
58
63
  end
59
- rescue SystemCallError, IOError
60
- nil
61
64
  end
62
65
 
63
66
  # @return [String] directory for this run's checkpoints (created)
@@ -73,8 +73,9 @@ module RobotLab
73
73
  return :empty_response if text.to_s.strip.empty? && calls.empty?
74
74
 
75
75
  calls.each do |c|
76
- return :empty_tool_name if c[:name].to_s.empty?
77
- return "unknown_tool:#{c[:name]}" if known.any? && !known.include?(c[:name])
76
+ name = c[:name]
77
+ return :empty_tool_name if name.to_s.empty?
78
+ return "unknown_tool:#{name}" if known.any? && !known.include?(name)
78
79
  end
79
80
 
80
81
  return :repeated_tool_call if repeated?(calls, previous)
@@ -83,6 +84,7 @@ module RobotLab
83
84
  end
84
85
 
85
86
  # True when any current call exactly matches any previous call.
87
+ # :reek:NestedIterators -- an all-pairs comparison needs both loops.
86
88
  def repeated?(calls, previous)
87
89
  return false if calls.empty? || previous.empty?
88
90
 
@@ -22,8 +22,10 @@ module RobotLab
22
22
 
23
23
  # Fetch the value for `key`, initializing to `default` when unset.
24
24
  def fetch(key, default = nil)
25
- Thread.current[key] = default if Thread.current[key].nil? && !default.nil?
26
- Thread.current[key]
25
+ value = Thread.current[key]
26
+ return value unless value.nil?
27
+
28
+ Thread.current[key] = default
27
29
  end
28
30
  end
29
31
  end
@@ -13,6 +13,7 @@ module RobotLab
13
13
  @path = Pathname.new(path)
14
14
  end
15
15
 
16
+ # :reek:FeatureEnvy -- interpolating the run's own fields into the header.
16
17
  def setup(run)
17
18
  AtomicFile.write(@path, <<~HEADER)
18
19
  # robot-to run: #{run.run_id}
@@ -84,6 +85,7 @@ module RobotLab
84
85
  MD
85
86
  end
86
87
 
88
+ # :reek:FeatureEnvy -- interpolating the error's own class/message.
87
89
  def append_error(error, iteration)
88
90
  append(<<~MD)
89
91
 
@@ -93,6 +95,7 @@ module RobotLab
93
95
  MD
94
96
  end
95
97
 
98
+ # :reek:FeatureEnvy -- interpolating the decision's own fields.
96
99
  def append_decision(decision, iteration)
97
100
  append(<<~MD)
98
101