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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RobotLab
6
+ module To
7
+ # All git operations needed by the orchestrator.
8
+ #
9
+ # All subprocess calls use explicit argv arrays (no shell interpolation).
10
+ # GIT_TERMINAL_PROMPT=0 prevents credential prompts from hanging the loop.
11
+ class CommitManager
12
+ GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
13
+
14
+ def initialize(work_dir: Dir.pwd)
15
+ @work_dir = work_dir
16
+ end
17
+
18
+ def current_branch
19
+ git("rev-parse", "--abbrev-ref", "HEAD").chomp
20
+ end
21
+
22
+ def head_sha
23
+ git("rev-parse", "HEAD").chomp
24
+ end
25
+
26
+ def create_branch(name)
27
+ git("checkout", "-b", name)
28
+ end
29
+
30
+ # Switch to an existing branch (used when resuming a prior run).
31
+ def checkout_branch(name)
32
+ git("checkout", name)
33
+ end
34
+
35
+ def staged?
36
+ _out, _err, status = Open3.capture3(GIT_ENV, "git", "diff", "--cached", "--quiet",
37
+ chdir: @work_dir)
38
+ !status.success? # exit 1 = changes are staged
39
+ end
40
+
41
+ def add_all
42
+ git("add", "-A")
43
+ end
44
+
45
+ def commit(message)
46
+ out, err, status = Open3.capture3(
47
+ GIT_ENV, "git", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
48
+ "commit", "-m", message,
49
+ chdir: @work_dir
50
+ )
51
+ return if status.success?
52
+
53
+ raise CommitFailedError.new("git commit failed", output: (out + err).strip)
54
+ end
55
+
56
+ def head_exists?
57
+ _out, _err, status = Open3.capture3(GIT_ENV, "git", "rev-parse", "--verify", "HEAD",
58
+ chdir: @work_dir)
59
+ status.success?
60
+ end
61
+
62
+ def reset_hard
63
+ return unless head_exists?
64
+
65
+ git("reset", "--hard", "HEAD")
66
+ git("clean", "-fd")
67
+ end
68
+
69
+ def diff_stat(base_sha)
70
+ out, _err, _status = Open3.capture3(GIT_ENV, "git", "diff", "--stat",
71
+ "#{base_sha}..HEAD", chdir: @work_dir)
72
+ parse_diff_stat(out)
73
+ end
74
+
75
+ def add_to_local_exclude(entry)
76
+ exclude = Pathname.new(@work_dir).join(".git", "info", "exclude")
77
+ exclude.parent.mkpath
78
+ content = exclude.exist? ? exclude.read : ""
79
+ return if content.include?(entry)
80
+
81
+ File.open(exclude, "a") { |f| f.puts(entry) }
82
+ end
83
+
84
+ def changed_files_since(base_sha)
85
+ out, _err, _status = Open3.capture3(GIT_ENV, "git", "diff", "--name-only",
86
+ "#{base_sha}..HEAD", chdir: @work_dir)
87
+ out.lines.map(&:chomp).reject(&:empty?)
88
+ end
89
+
90
+ # Files differing between a ref and the current working tree (uncommitted
91
+ # changes), INCLUDING new untracked files. Used by pairwise evals to compare
92
+ # a draft against its parent -- and a prose doer typically CREATES the
93
+ # document, so the untracked case is the common one (`git diff` alone misses
94
+ # new files).
95
+ def changed_vs_worktree(ref)
96
+ tracked, = Open3.capture3(GIT_ENV, "git", "diff", "--name-only", ref, chdir: @work_dir)
97
+ untracked, = Open3.capture3(GIT_ENV, "git", "ls-files", "--others", "--exclude-standard",
98
+ chdir: @work_dir)
99
+ (tracked.lines + untracked.lines).map(&:chomp).reject(&:empty?).uniq
100
+ end
101
+
102
+ # Contents of a path at a given ref, or "" when it did not exist there.
103
+ def show(ref, path)
104
+ out, _err, status = Open3.capture3(GIT_ENV, "git", "show", "#{ref}:#{path}",
105
+ chdir: @work_dir)
106
+ status.success? ? out : ""
107
+ end
108
+
109
+ # All tracked files — the project's committed layout. Excludes the run dir
110
+ # (it's in .git/info/exclude), so it's a clean workspace digest.
111
+ def tracked_files
112
+ out, _err, status = Open3.capture3(GIT_ENV, "git", "ls-files", chdir: @work_dir)
113
+ return [] unless status.success?
114
+
115
+ out.lines.map(&:chomp).reject(&:empty?)
116
+ end
117
+
118
+ private
119
+
120
+ def git(*args)
121
+ out, err, status = Open3.capture3(GIT_ENV, "git", *args, chdir: @work_dir)
122
+ raise PermanentError, "git #{args.first} failed: #{err.strip}" unless status.success?
123
+
124
+ out
125
+ end
126
+
127
+ def parse_diff_stat(text)
128
+ insertions = text.scan(/(\d+) insertion/).flatten.map(&:to_i).sum
129
+ deletions = text.scan(/(\d+) deletion/).flatten.map(&:to_i).sum
130
+ files = text.scan(/(\d+) file/).flatten.map(&:to_i).first || 0
131
+ { insertions: insertions, deletions: deletions, files: files }
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,27 @@
1
+ defaults:
2
+ provider: openai
3
+ model: gpt-5.5
4
+ max_tool_rounds: 100
5
+ max_consecutive_failures: 3
6
+ max_retries: 2
7
+ max_submit_nudges: 1
8
+ max_verify_repairs: 2
9
+ verify_timeout: 600
10
+ commit_format: default
11
+ run_dir: .robot_lab_to
12
+ local_guards: false
13
+ stream: true
14
+ debug: false
15
+ decisions_enabled: true
16
+ decision_mode: wait # wait = poll until resolved; exit = stop and require --resume
17
+ decision_wait_poll: 30 # seconds between polls while waiting on a blocking decision
18
+ decision_timeout: null # max seconds to wait (null = wait indefinitely)
19
+
20
+ development:
21
+ debug: false
22
+
23
+ test:
24
+ debug: false
25
+
26
+ production:
27
+ debug: false
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "myway_config"
4
+
5
+ module RobotLab
6
+ module To
7
+ # Configuration for a robot-to run.
8
+ #
9
+ # Sources (lowest to highest precedence):
10
+ # 1. Bundled defaults (config/defaults.yml)
11
+ # 2. User config file (~/.config/robot_lab/to.yml)
12
+ # 3. Environment variables (ROBOT_LAB_TO_*)
13
+ # 4. Constructor keyword arguments (CLI overrides)
14
+ class Config < MywayConfig::Base
15
+ config_name :robot_lab_to
16
+ env_prefix :robot_lab_to
17
+ defaults_path File.expand_path("config/defaults.yml", __dir__)
18
+ auto_configure!
19
+
20
+ # Runtime CLI overrides — applied after load.
21
+ attr_writer :provider, :model, :max_iterations, :max_tokens, :stop_when,
22
+ :max_consecutive_failures, :max_submit_nudges, :max_verify_repairs,
23
+ :verify_command, :verify_timeout, :run_dir, :commit_format,
24
+ :local_guards, :stream, :debug,
25
+ :decisions_enabled, :decision_mode, :decision_wait_poll, :decision_timeout,
26
+ :eval, :eval_measure, :eval_target, :require_improvement, :stop_on_plateau
27
+
28
+ # Prose/grader settings (Phase 3). attr_accessor keeps these off the reek
29
+ # method-count budget (Config sits at its TooManyMethods limit); they are
30
+ # assigned in initialize to avoid InstanceVariableAssumption. write_guard
31
+ # (default true) lets local_guards runs opt out of WriteGuard so a robot can
32
+ # freely overwrite files it is iteratively refining (e.g. prose drafts).
33
+ attr_accessor :eval_judge_model, :eval_spec, :eval_floor, :protect_paths, :write_guard
34
+
35
+ def initialize(**overrides)
36
+ super()
37
+ @eval = @eval_measure = @eval_target = nil
38
+ @require_improvement = @stop_on_plateau = nil
39
+ @eval_judge_model = @eval_spec = @eval_floor = nil
40
+ @protect_paths = []
41
+ @write_guard = true
42
+ overrides.each { |k, v| public_send(:"#{k}=", v) if respond_to?(:"#{k}=") }
43
+ end
44
+
45
+ # Keys defined in defaults.yml — super is safe
46
+ def provider = @provider || super
47
+ def model = @model || super
48
+ def max_consecutive_failures = @max_consecutive_failures || super
49
+ def max_submit_nudges = @max_submit_nudges || super
50
+ def max_verify_repairs = @max_verify_repairs || super
51
+ def verify_timeout = @verify_timeout || super
52
+
53
+ def run_dir = @run_dir || super
54
+ def commit_format = @commit_format || super
55
+ def local_guards? = @local_guards.nil? ? super : @local_guards
56
+ def stream? = @stream.nil? ? super : @stream
57
+ def debug? = @debug.nil? ? super : @debug
58
+
59
+ def decisions_enabled? = @decisions_enabled.nil? ? super : @decisions_enabled
60
+ def decision_mode = @decision_mode || super
61
+ def decision_wait_poll = @decision_wait_poll || super
62
+ # nil = wait indefinitely
63
+ def decision_timeout = @decision_timeout || super
64
+
65
+ # CLI-only options with no YAML default (nil means "no limit / not set")
66
+ def max_iterations = @max_iterations
67
+ def max_tokens = @max_tokens
68
+ def stop_when = @stop_when
69
+ def verify_command = @verify_command
70
+
71
+ # Eval strategy selection + tuning (see RobotLab::To::Evals.build). All
72
+ # CLI-only / nil-default. #eval may hold a strategy name ("code"/"null"), a
73
+ # registered symbol, an instance responding to #score, or a proc. The
74
+ # judge/spec/floor readers Prose needs arrive with Phase 3.
75
+ def eval = @eval
76
+ def eval_measure = @eval_measure
77
+ def eval_target = @eval_target
78
+ def stop_on_plateau = @stop_on_plateau
79
+
80
+ # CLI/Ruby-only with a hardcoded default (MywayConfig does not generate a
81
+ # reliable `require_improvement?` predicate). Default true per design: the
82
+ # loop is strictly monotone unless --no-require-improvement is passed.
83
+ def require_improvement? = @require_improvement.nil? || @require_improvement
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ # An immutable snapshot of one decision file on disk.
6
+ #
7
+ # A decision is the artifact the robot writes when it hits a choice it
8
+ # should not make autonomously: the situation, the options, and its own
9
+ # recommended "lean". A human resolves it out-of-band by editing the file
10
+ # (set `status: resolved`, fill `resolution:`); a later iteration reads the
11
+ # resolution back in.
12
+ #
13
+ # Status lifecycle: pending -> resolved (by human) -> closed (resolution
14
+ # delivered to a robot and committed). `dismissed` is a terminal human veto.
15
+ Decision = Data.define(
16
+ :id, # String — d-YYYYMMDD-HHMMSS-<hex>
17
+ :status, # String — pending | resolved | closed | dismissed
18
+ :blocking, # Boolean — does the loop pause until this is resolved?
19
+ :created_at, # String — ISO8601
20
+ :created_iteration, # Integer
21
+ :resolved_at, # String or nil — ISO8601
22
+ :resolution, # String or nil — the human's answer (best available)
23
+ :question, # String
24
+ :situation, # String
25
+ :options, # Array<String>
26
+ :recommendation, # String
27
+ :body, # String — full markdown body (below front matter)
28
+ :path # String — absolute path to the file
29
+ ) do
30
+ def pending? = status.to_s == "pending"
31
+ def resolved? = status.to_s == "resolved"
32
+ def closed? = status.to_s == "closed"
33
+ def dismissed? = status.to_s == "dismissed"
34
+ def blocking? = !!blocking
35
+
36
+ # True when a human has answered (resolved with a non-empty resolution).
37
+ def answered? = resolved? && !resolution.to_s.strip.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "yaml"
5
+
6
+ module RobotLab
7
+ module To
8
+ # Reads and writes decision files under <run_dir>/decisions/.
9
+ #
10
+ # Parallels NotesManager: the orchestrator records decisions the robot
11
+ # raises and queries their status each iteration; a human resolves them by
12
+ # editing the file between iterations (or between cron ticks).
13
+ #
14
+ # Each decision is a markdown file with a YAML front matter block:
15
+ #
16
+ # ---
17
+ # id: d-20260701-143022-a1b2c3
18
+ # status: pending
19
+ # blocking: true
20
+ # created_at: 2026-07-01T14:30:22Z
21
+ # created_iteration: 7
22
+ # resolved_at:
23
+ # resolution:
24
+ # ---
25
+ # # Decision: <question>
26
+ # ...
27
+ #
28
+ # All writes go through AtomicFile so a crash never leaves a torn file.
29
+ class DecisionManager
30
+ GLOB = "d-*.md"
31
+
32
+ attr_reader :dir
33
+
34
+ def initialize(dir)
35
+ @dir = Pathname.new(dir)
36
+ end
37
+
38
+ def setup
39
+ @dir.mkpath
40
+ end
41
+
42
+ # Persist a decision the robot raised. Returns the parsed Decision.
43
+ def record(question:, situation: "", options: [], recommendation: "", blocking: false, iteration: 0)
44
+ id = generate_id
45
+ path = @dir.join("#{id}.md")
46
+ AtomicFile.write(path, render_new(id: id, question: question, situation: situation,
47
+ options: Array(options), recommendation: recommendation.to_s,
48
+ blocking: blocking ? true : false, iteration: iteration.to_i))
49
+ parse(path)
50
+ end
51
+
52
+ # All decisions on disk, oldest first (ids sort chronologically).
53
+ def all
54
+ Dir.glob(@dir.join(GLOB)).map { |p| parse(p) }.compact
55
+ end
56
+
57
+ def pending = all.select(&:pending?)
58
+ # resolved but not yet closed
59
+ def resolved_open = all.select(&:resolved?)
60
+ def blocking_pending = all.select { |d| d.pending? && d.blocking? }
61
+ def blocking_pending? = blocking_pending.any?
62
+
63
+ # Re-read a single decision from disk (a human may have edited it).
64
+ def reload(decision) = parse(decision.path)
65
+
66
+ # Mark a resolved decision closed: its resolution has been delivered to a
67
+ # robot and committed, so it should no longer be re-injected. Flips only
68
+ # the status line, preserving whatever the human wrote in the body.
69
+ def close(decision)
70
+ raw = File.read(decision.path)
71
+ AtomicFile.write(decision.path, flip_status(raw, "closed"))
72
+ end
73
+
74
+ private
75
+
76
+ def generate_id
77
+ "d-#{Time.now.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.hex(3)}"
78
+ end
79
+
80
+ # Replace the status: line inside the leading front matter block only.
81
+ def flip_status(raw, status)
82
+ raw.sub(/^status:.*$/, "status: #{status}")
83
+ end
84
+
85
+ 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"
87
+ # Build the front matter with YAML.dump so question/recommendation are
88
+ # escaped correctly (they can contain colons, quotes, etc.).
89
+ front = {
90
+ "id" => id, "status" => "pending", "blocking" => blocking,
91
+ "created_at" => Time.now.utc.iso8601, "created_iteration" => iteration,
92
+ "resolved_at" => nil, "resolution" => nil,
93
+ "question" => question, "recommendation" => recommendation
94
+ }
95
+ fm = YAML.dump(front).sub(/\A---\n/, "").chomp
96
+ <<~MD
97
+ ---
98
+ #{fm}
99
+ ---
100
+ # Decision: #{question}
101
+
102
+ ## Situation
103
+ #{situation.to_s.strip.empty? ? "(none provided)" : situation.strip}
104
+
105
+ ## Options
106
+ #{options_md}
107
+ ## Recommendation (robot's lean)
108
+ #{recommendation.strip.empty? ? "(none provided)" : recommendation.strip}
109
+
110
+ ## Your Decision
111
+ <!-- Human: set `status: resolved` and fill `resolution:` in the front
112
+ matter above (or write your answer below this line), then re-run
113
+ robot-to (or `robot-to --resume <run_id>`). -->
114
+ MD
115
+ end
116
+
117
+ # Split a file into (front_matter_hash, body). Returns a Decision or nil
118
+ # when the file is unreadable.
119
+ def parse(path)
120
+ text = File.read(path)
121
+ fm, body = split_front_matter(text)
122
+ Decision.new(
123
+ id: fm["id"] || File.basename(path, ".md"),
124
+ status: (fm["status"] || "pending").to_s,
125
+ blocking: truthy?(fm["blocking"]),
126
+ created_at: fm["created_at"].to_s,
127
+ created_iteration: fm["created_iteration"].to_i,
128
+ resolved_at: presence(fm["resolved_at"]),
129
+ resolution: resolution_for(fm, body),
130
+ question: fm["question"].to_s,
131
+ situation: fm["situation"].to_s,
132
+ options: Array(fm["options"]),
133
+ recommendation: fm["recommendation"].to_s,
134
+ body: body,
135
+ path: path.to_s
136
+ )
137
+ rescue SystemCallError
138
+ nil
139
+ end
140
+
141
+ def split_front_matter(text)
142
+ if text.start_with?("---\n") && (close_idx = text.index("\n---", 4))
143
+ raw_fm = text[4...close_idx]
144
+ body = text[(close_idx + 4)..].to_s.sub(/\A\n/, "")
145
+ [safe_yaml(raw_fm), body]
146
+ else
147
+ [{}, text]
148
+ end
149
+ end
150
+
151
+ # YAML.safe_load, falling back to a lenient line scanner when a human's
152
+ # hand-edited front matter is not valid YAML.
153
+ def safe_yaml(raw)
154
+ parsed = YAML.safe_load(raw, permitted_classes: [Date, Time])
155
+ parsed.is_a?(Hash) ? stringify(parsed) : lenient_parse(raw)
156
+ rescue Psych::Exception
157
+ lenient_parse(raw)
158
+ end
159
+
160
+ def lenient_parse(raw)
161
+ raw.lines.each_with_object({}) do |line, h|
162
+ next unless (m = line.match(/^([a-z_]+):\s*(.*)$/))
163
+
164
+ h[m[1]] = m[2].strip
165
+ end
166
+ end
167
+
168
+ def stringify(hash)
169
+ hash.transform_keys(&:to_s).transform_values { |v| v.is_a?(Time) ? v.utc.iso8601 : v }
170
+ end
171
+
172
+ # Prefer the front matter resolution; fall back to prose under
173
+ # "## Your Decision" (stripping the HTML instruction comment).
174
+ def resolution_for(fm, body)
175
+ fm_res = presence(fm["resolution"])
176
+ return fm_res if fm_res
177
+
178
+ section = body[/^##\s+Your Decision\s*\n(.*)\z/m, 1].to_s
179
+ section = section.gsub(/<!--.*?-->/m, "").strip
180
+ presence(section)
181
+ end
182
+
183
+ def presence(value)
184
+ s = value.to_s.strip
185
+ s.empty? ? nil : s
186
+ end
187
+
188
+ def truthy?(value)
189
+ %w[true yes 1].include?(value.to_s.strip.downcase)
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ class Error < StandardError; end
6
+
7
+ # Unrecoverable condition (e.g. credit exhaustion). Aborts the run immediately.
8
+ class PermanentError < Error; end
9
+
10
+ # git commit failed. Preserves uncommitted changes so the next iteration can repair them.
11
+ class CommitFailedError < Error
12
+ attr_reader :output
13
+
14
+ def initialize(msg, output: nil)
15
+ super(msg)
16
+ @output = output
17
+ end
18
+ end
19
+
20
+ # A stop condition was triggered. Results in a clean exit (not an error).
21
+ class AbortError < Error
22
+ attr_reader :reason
23
+
24
+ def initialize(reason)
25
+ super
26
+ @reason = reason
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Evals
6
+ # Base class for an Eval strategy. An Eval scores the uncommitted working
7
+ # tree against a spec and returns a Score. It is the deciding authority for
8
+ # commit/rollback (via gate_ok/improved) and stop (via met_target) -- not
9
+ # the robot's self-report.
10
+ #
11
+ # Subclasses override #score. #protected_paths lists grader artifacts the
12
+ # robot must not edit (wired into GraderLock in a later phase); the default
13
+ # is none.
14
+ class Base
15
+ def score(context)
16
+ raise NotImplementedError, "#{self.class}#score must return an Evals::Score"
17
+ end
18
+
19
+ def protected_paths = []
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Evals
6
+ # Measured / deterministic eval for software products.
7
+ #
8
+ # verify - floor gate; a command that must exit 0 (correctness)
9
+ # measure - optional command printing a number to stdout; higher = better
10
+ # target - optional; met_target fires when measured value >= target
11
+ #
12
+ # Composes Verifier for command execution. With no `measure`, it behaves
13
+ # exactly like the original verify-command gate: improved == gate, and
14
+ # met_target never fires (stop falls back to --stop-when).
15
+ class Code < Base
16
+ def initialize(verify: nil, measure: nil, target: nil, timeout: 600, work_dir: Dir.pwd)
17
+ super()
18
+ @verify = verify
19
+ @measure = measure
20
+ @target = target
21
+ @timeout = timeout
22
+ @work_dir = work_dir
23
+ end
24
+
25
+ def score(context)
26
+ gate_ok, output = run_gate
27
+ value = measure_value
28
+ Score.new(
29
+ gate_ok: gate_ok,
30
+ improved: improved?(gate_ok, value, context.previous_value),
31
+ met_target: target_met?(value),
32
+ value: value,
33
+ detail: detail_for(gate_ok, value),
34
+ output: output
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ # Returns [passed?, output]. No verify command means the floor is open.
41
+ def run_gate
42
+ return [true, nil] unless @verify
43
+
44
+ result = Verifier.new(@verify, work_dir: @work_dir, timeout: @timeout).run
45
+ [result.passed?, result.output]
46
+ end
47
+
48
+ # First number the measure command prints, or nil when not measuring.
49
+ def measure_value
50
+ return nil unless @measure
51
+
52
+ output = Verifier.new(@measure, work_dir: @work_dir, timeout: @timeout).run.output
53
+ match = output[/-?\d+(?:\.\d+)?/]
54
+ match&.to_f
55
+ end
56
+
57
+ # Without a measured value, any gate-passing change is progress. With one,
58
+ # progress means the value strictly beat the parent (or there is no parent).
59
+ def improved?(gate_ok, value, previous_value)
60
+ return gate_ok if value.nil?
61
+
62
+ previous_value.nil? || value > previous_value
63
+ end
64
+
65
+ def target_met?(value)
66
+ !@target.nil? && !value.nil? && value >= @target
67
+ end
68
+
69
+ def detail_for(gate_ok, value)
70
+ parts = ["verify=#{gate_ok}"]
71
+ parts << "measure=#{value}" unless value.nil?
72
+ parts.join(" ")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Evals
6
+ # What the orchestrator hands an Eval each iteration. One value object so
7
+ # new fields can be added without breaking custom evals.
8
+ #
9
+ # work_dir - the working tree holding the robot's uncommitted changes
10
+ # previous_ref - git ref of the last committed state (HEAD before this iter)
11
+ # previous_value - last committed score value (nil until tracked; Phase 2)
12
+ # objective - the run objective
13
+ # iteration - 1-based iteration number
14
+ Context = Data.define(:work_dir, :previous_ref, :previous_value, :objective, :iteration)
15
+ end
16
+ end
17
+ end