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,254 @@
1
+ # Evals: Scoring Iterations
2
+
3
+ A robot reporting `success: true` is a claim; a `--verify-command` that must exit
4
+ `0` answers "did it break?". Neither answers the harder question: **is this
5
+ iteration actually better than the last one?** An agent will happily report
6
+ success — and pass a verify command — on a change that made no real progress.
7
+
8
+ An **eval** replaces the robot's self-report as the deciding authority. It is an
9
+ orchestrator-owned, pluggable scorer that judges the *uncommitted working tree*
10
+ after every iteration and returns a `Score`. The eval decides commit vs. rollback
11
+ (via `gate_ok` / `improved`) and, for measurable objectives, decides when the run
12
+ is *done* (`met_target`) — instead of the robot's self-reported
13
+ `should_fully_stop`.
14
+
15
+ This generalizes the [Verification Gate](verification.md): `--verify-command`
16
+ still exists and still works exactly as before, but it is now one input to the
17
+ default eval (`Evals::Code`) rather than the whole story.
18
+
19
+ ## The `Score`
20
+
21
+ Every eval returns a `RobotLab::To::Evals::Score` — a `Data.define` with six
22
+ fields:
23
+
24
+ | Field | Meaning |
25
+ |-------|---------|
26
+ | `gate_ok` | The floor holds (correctness). Must be `true` to commit at all. |
27
+ | `improved` | Better than the parent commit? The descent signal. |
28
+ | `met_target` | The objective is measurably reached — an orchestrator-owned stop. |
29
+ | `value` | The score itself (higher = better); `nil` for pure-pairwise evals. |
30
+ | `detail` | One-line human summary, written to `notes.md` and the log. |
31
+ | `output` | Diagnostic text (gate output, judge rationale) fed to repair prompts; `nil` when there is none. |
32
+
33
+ `gate_ok?`, `improved?`, and `met_target?` predicate readers are also defined.
34
+
35
+ ## How it drives the loop
36
+
37
+ ```
38
+ robot says success ──► Eval#score ──► gate_ok? ──► improved (or --no-require-improvement)? ──► COMMIT
39
+ │ │
40
+ │ no │ no
41
+ ▼ ▼
42
+ repair_until_gate (R2) ROLLBACK ([NO IMPROVEMENT])
43
+ then ROLLBACK if still failing
44
+ ```
45
+
46
+ - **Gate fails** (`gate_ok: false`) — the failure output is handed back to the
47
+ *same* robot up to `--max-verify-repairs` times, re-scoring after each attempt
48
+ (this is the same R2 repair loop the verify gate always had — see
49
+ [Verification Gate](verification.md)). If the budget runs out, the tree is
50
+ rolled back and the notes get a `[VERIFY FAILED]` entry.
51
+ - **Gate passes but didn't improve** (`improved: false`) — rolled back with a
52
+ `[NO IMPROVEMENT]` entry in `notes.md`, *unless* `--no-require-improvement` is
53
+ passed (see [`require_improvement`](#require_improvement-strict-descent)
54
+ below). This is **not** counted as a failure — it does not trip
55
+ `--max-consecutive-failures` — but it does count toward
56
+ [`--stop-on-plateau`](stop-conditions.md#plateau-no-improvement).
57
+ - **Gate passes and improved** — committed. `@run.last_score_value` is updated,
58
+ the plateau counter resets, and `met_target?` is checked: if true, the run
59
+ aborts with `target met: <detail>` — this **supersedes** the robot's
60
+ self-reported `should_fully_stop`; `--stop-when` remains as a fallback.
61
+
62
+ Each iteration's verdict is logged as an `eval` event in `run.log` (`gate`,
63
+ `improved`, `value`, `met_target`, `detail`) — see
64
+ [Run State & Event Log](run-state.md).
65
+
66
+ ## `Evals::Null` — the default, unscored
67
+
68
+ When nothing is configured, `Evals::Null` reports `gate_ok: true, improved: true,
69
+ met_target: false` on every call — every reported success commits, and the run
70
+ stops only via `--stop-when` or the other stop conditions. This is exactly
71
+ `robot_lab-to`'s original behavior; adding an eval is entirely opt-in.
72
+
73
+ ## `Evals::Code` — measured descent
74
+
75
+ For software, "better" is measurable. `Evals::Code` composes two independent
76
+ commands:
77
+
78
+ | Option | Role |
79
+ |--------|------|
80
+ | `--verify-command CMD` | **Floor** — must exit `0` (correctness). Same gate as before. |
81
+ | `--measure CMD` | **Descent signal** — a command that prints a number to stdout; higher is better. |
82
+ | `--target FLOAT` | Stop once the measured value reaches this. |
83
+
84
+ ```bash
85
+ robot-to "Raise parser coverage to 90%" \
86
+ --verify "bundle exec rake test" \ # floor: must stay green
87
+ --measure "bundle exec rake coverage" \ # descent signal
88
+ --target 90 # stop once the score reaches this
89
+ ```
90
+
91
+ `Evals::Code#score`:
92
+
93
+ - `gate_ok` — `true` when `--verify-command` is unset, or the command exits `0`.
94
+ - `value` — the **first number** matched in the `--measure` command's stdout
95
+ (`/-?\d+(?:\.\d+)?/`), or `nil` if `--measure` is unset.
96
+ - `improved` — with no `value`, equals `gate_ok` (any gate-passing change is
97
+ progress, matching legacy behavior). With a `value`, `true` only if it strictly
98
+ beats `previous_value` (or there is no prior committed value yet).
99
+ - `met_target` — `true` once `value >= --target`.
100
+
101
+ **Backward compatible by construction:** with no `--measure`, `Evals::Code`
102
+ behaves exactly like the original verify-only gate — `improved == gate_ok`,
103
+ `met_target` never fires, and the run stops via `--stop-when` / the other stop
104
+ conditions as before.
105
+
106
+ The `--measure` command just has to print a number: `rake coverage`, a benchmark
107
+ harness, `ruby -e 'puts passing_count'`, `grep -c` of a lint report — anything.
108
+
109
+ ## `Evals::Prose` — pairwise judgement
110
+
111
+ For a document, an opinion piece, or a book there is no `rake coverage`. Absolute
112
+ scores from an LLM are too noisy to descend against — but "is B better than A?"
113
+ is a reliable judgement. `Evals::Prose` asks a **judge model** to compare the
114
+ working tree to the last *committed* version:
115
+
116
+ ```bash
117
+ robot-to "Write an opinionated guide to the Viable Systems Model" \
118
+ --eval prose \
119
+ --spec outline.md \ # the spec the judge measures against
120
+ --floor "rake docs:lint" \ # optional mechanizable checks (links, TODOs)
121
+ --judge-model claude-opus-4 \ # defaults to the doer's --model
122
+ --stop-on-plateau 3 # stop after 3 drafts with no improvement
123
+ ```
124
+
125
+ `Evals::Prose#score`:
126
+
127
+ - `gate_ok` — `true` when `--floor` is unset, or the floor command exits `0`. If
128
+ the floor fails, the judge is **not** called (saves a request) and the verdict
129
+ is forced to `:worse`.
130
+ - The judge is shown `VERSION A` (the parent commit) and `VERSION B` (the working
131
+ tree) for every file that changed, plus the objective and — if `--spec` is set —
132
+ its contents, and replies exactly one word: `better`, `worse`, or `same`.
133
+ Anything ambiguous is treated as `:same` (does not count as improvement).
134
+ - **Brand-new document shortcut:** if the parent version is empty and the draft
135
+ is not, the judge is skipped and the verdict is `:better` automatically — an
136
+ "empty vs. content" pairwise comparison is degenerate and models frequently
137
+ (and wrongly) call it `:same`.
138
+ - `improved` — `true` only when the verdict is `:better`.
139
+ - `met_target` — **always `false`.** There is no absolute milestone for prose;
140
+ runs end via `--stop-on-plateau`, `--max-iterations`, or a human stopping it.
141
+ - `value` — always `nil` (pairwise verdicts have no scalar).
142
+
143
+ By default the judge **reuses the doer's `--model`** — no separate judge model is
144
+ required to get pairwise scoring. Pass `--judge-model` to use a different (often
145
+ cheaper, or deliberately more skeptical) model for the judge role. See
146
+ `examples/04_prose` in the gem for a worked two-model (doer + judge) setup.
147
+
148
+ ## `require_improvement` — strict descent
149
+
150
+ - **Default: `true`.** A gate-passing iteration that does not beat the parent is
151
+ rolled back (`[NO IMPROVEMENT]`), so the branch descends monotonically.
152
+ - `--no-require-improvement` restores "commit any gate-passing success," useful
153
+ when you want every non-regressing attempt kept rather than only strict
154
+ improvements.
155
+ - This is a hardcoded Ruby/CLI-only default (`Config#require_improvement?`), not
156
+ in `defaults.yml` — set it per-run via `--no-require-improvement` or
157
+ `RobotLab::To.run(require_improvement: false)`.
158
+
159
+ ## Plateau: the primary stop for judged evals
160
+
161
+ `Evals::Prose` never sets `met_target`, so its natural terminator is
162
+ `--stop-on-plateau N` — stop after `N` iterations with no committed improvement.
163
+ Plateau is tracked independently of `--max-consecutive-failures` (which is for
164
+ the robot being genuinely broken — errors, gate failures, no submit); a
165
+ gate-passing "same" verdict is normal, healthy exploration for prose, not a
166
+ failure. See [Stop Conditions](stop-conditions.md#plateau-no-improvement).
167
+
168
+ ## Score feedback in the prompt
169
+
170
+ When a run is scored (`--measure`, `--target`, `--eval prose`, or `--spec` is
171
+ set), `PromptBuilder` adds a **Score Feedback** section to every iteration's
172
+ system prompt:
173
+
174
+ - The best committed score so far and the target, if any: *"Best score committed
175
+ so far: 84.0 (target: 90.0)."*
176
+ - A plateau warning after a run of non-improving iterations: *"The last 3
177
+ iteration(s) did not improve and were rolled back... try a DIFFERENT approach
178
+ now."*
179
+ - Otherwise, positive reinforcement: *"Your last change improved the result and
180
+ was committed. Build on it."*
181
+
182
+ This turns each attempt into a testable hypothesis against the target rather
183
+ than a blind guess. Unscored runs (the `Evals::Null` default) get no such
184
+ section — behavior is unchanged.
185
+
186
+ ## Guarding the grader (`GraderLock`)
187
+
188
+ Whatever the eval, the robot must not be able to "win" by editing the criteria
189
+ it is scored against. `Guards::GraderLock` — a `RobotLab::Hook` — refuses any
190
+ `write`/`edit` targeting a locked path, and refuses any `bash` command whose
191
+ string references one (matched conservatively, by absolute path or basename).
192
+
193
+ Locked paths come from two sources, merged:
194
+
195
+ - `Evals::Base#protected_paths` — `Evals::Prose#protected_paths` returns
196
+ `[spec]` (a floor *command* isn't reliably a single file, so lock it
197
+ separately with `--protect-path` if it's a script); `Evals::Code` locks
198
+ nothing by default — **the test suite is deliberately editable**, since
199
+ adding tests is how coverage legitimately rises.
200
+ - `--protect-path GLOB` (repeatable) — user-specified grader files for any eval,
201
+ code or custom (e.g. a floor script, a scoring rubric).
202
+
203
+ `GraderLock` is installed in `Orchestrator#build_robot` whenever any protected
204
+ paths resolve, **independent of `--local-guards`** — unlike the small-model
205
+ guardrails (see [Guardrails](../local-models/guardrails.md)), a frontier model
206
+ can reward-hack too, so this one is always on.
207
+
208
+ ```bash
209
+ robot-to "..." --eval code --measure "ruby score.rb" --target 100 \
210
+ --protect-path score.rb --protect-path test/scoring_test.rb
211
+ ```
212
+
213
+ ## Custom evals
214
+
215
+ Three escape hatches, all via the Ruby API (`RobotLab::To.run` / `Config.new`):
216
+
217
+ ```ruby
218
+ # (a) subclass RobotLab::To::Evals::Base
219
+ class BenchmarkEval < RobotLab::To::Evals::Base
220
+ def score(ctx) = RobotLab::To::Evals::Score.new(gate_ok: true, improved: ..., met_target: ..., value: ..., detail: ..., output: nil)
221
+ end
222
+ RobotLab::To.run(objective, eval: BenchmarkEval.new)
223
+
224
+ # (b) a bare proc — wrapped in Evals::ProcEval automatically
225
+ RobotLab::To.run(objective, eval: ->(ctx) { RobotLab::To::Evals::Score.new(...) })
226
+
227
+ # (c) a registered symbol
228
+ RobotLab::To.register_eval(:benchmark) { |config| BenchmarkEval.new(target: config.eval_target) }
229
+ RobotLab::To.run(objective, eval: :benchmark)
230
+ ```
231
+
232
+ !!! warning "`--eval` on the CLI only resolves `code` / `null` / `prose`"
233
+ The CLI's `--eval NAME` flag always produces a **String**. The factory's
234
+ registry lookup only matches a **Symbol** (`Config.new(eval: :benchmark)`),
235
+ so a registered custom eval is reachable from the Ruby API but not yet from
236
+ `--eval benchmark` on the command line — passing an unrecognized string
237
+ raises `unknown eval strategy`. Likewise, pointing `--eval` at a `.rb` file
238
+ path is not implemented; use the Ruby API's instance/proc/registered-symbol
239
+ forms instead.
240
+
241
+ ## Backward compatibility
242
+
243
+ | Invocation | Eval selected | Behavior |
244
+ |---|---|---|
245
+ | No `--verify-command`, no `--eval`, no `--measure`/`--target` | `Evals::Null` | Commit any reported success; stop via `--stop-when`. **Identical to pre-Evals behavior.** |
246
+ | `--verify-command "rake test"` only | `Evals::Code` (gate-only) | Commit on pass; stop via `--stop-when`. **Identical.** |
247
+ | Old `run.json` (`--resume`) | — | Loads; `last_score_value` / `iterations_since_improvement` default to `nil` / `0`. |
248
+
249
+ New behavior only activates when `--measure`, `--target`, `--eval prose`, or
250
+ `--stop-on-plateau` is supplied.
251
+
252
+ ---
253
+
254
+ Next: [Run State & Event Log](run-state.md).
@@ -0,0 +1,128 @@
1
+ # The Iteration Loop
2
+
3
+ The orchestrator is a loop. Each pass is an independent attempt at **one**
4
+ improvement toward the objective. This page describes the full lifecycle and the
5
+ decisions made at each step.
6
+
7
+ ![The robot-to iteration loop](../assets/iteration-loop.svg)
8
+
9
+ ## Setup (once per run)
10
+
11
+ Before the loop begins, `setup_run`:
12
+
13
+ - Confirms `HEAD` exists — aborts with guidance if the repo has no commits.
14
+ - Creates the run branch `robot-to/<slug>-<timestamp>` and records `base_commit`.
15
+ - Creates the run directory and seeds `notes.md` with the objective header.
16
+ - Adds the run directory to `.git/info/exclude`.
17
+ - Builds the per-run collaborators: `PromptBuilder`, `Backoff`, `StopConditions`,
18
+ and the **eval** (`Evals.build(config, git:)` — `Evals::Code` when
19
+ `--verify-command`/`--measure`/`--target` is set, `Evals::Prose` for
20
+ `--eval prose`, otherwise the unscored `Evals::Null`; see [Evals](evals.md)).
21
+
22
+ ## One iteration
23
+
24
+ ### 1. Build a fresh robot
25
+
26
+ A **new** `RobotLab::Robot` is created every iteration — there is no shared chat
27
+ history between iterations. Continuity comes entirely from `notes.md`, which is
28
+ injected into the system prompt. The prompt (built by `PromptBuilder`) contains:
29
+
30
+ - the objective and current iteration number,
31
+ - the accumulated notes ("prior work"),
32
+ - the task instructions (make one focused change; run tests/build; a no-op is not
33
+ success),
34
+ - the requirement to call `submit_iteration_result`,
35
+ - optional repair and stop-when sections.
36
+
37
+ The robot is given the `submit_iteration_result` tool. With `--local-guards`, it
38
+ also receives built-in file tools (`read`, `write`, `edit`, `bash`) and the
39
+ guardrail hooks. See [Built-in Tools](../local-models/tools.md). If the
40
+ configured eval (or `--protect-path`) has protected paths, `Guards::GraderLock`
41
+ is attached too — independent of `--local-guards` — refusing edits to the
42
+ grader's own criteria. See [Evals](evals.md#guarding-the-grader-graderlock).
43
+
44
+ ### 2. The robot works
45
+
46
+ `robot.run` executes. The robot makes changes using whatever tools it has, then
47
+ calls `submit_iteration_result` with:
48
+
49
+ | Field | Meaning |
50
+ |-------|---------|
51
+ | `success` | Did this iteration make meaningful progress? |
52
+ | `summary` | One sentence describing what was done or why it stopped. |
53
+ | `key_changes` | Files or changes made this iteration. |
54
+ | `key_learnings` | Insights to carry forward in the notes. |
55
+ | `should_fully_stop` | Set when a `--stop-when` condition is judged met. |
56
+
57
+ ### 3. Handle "thinks but doesn't act"
58
+
59
+ If the robot ends its turn **without** calling `submit_iteration_result`, the
60
+ orchestrator re-prompts the *same* robot (preserving its chat) up to
61
+ `--max-submit-nudges` times, asking only for the result. This recovers the common
62
+ degenerate case where a model does work but forgets the final report. If it still
63
+ doesn't submit, the iteration is treated as a failure.
64
+
65
+ ### 4. Decide: commit or roll back
66
+
67
+ ```
68
+ result.success? ──► Eval#score ──► gate_ok? ──► improved (or --no-require-improvement)? ──► COMMIT
69
+ │ │ │
70
+ │ no │ no │ no
71
+ ▼ ▼ (after R2 repair budget) ▼
72
+ ROLLBACK ROLLBACK ROLLBACK
73
+ ```
74
+
75
+ - **Robot reported failure (or no submit)** → `git reset --hard`;
76
+ consecutive-failure counter increments. The eval is never called.
77
+ - **Robot reported success** → the configured **eval** scores the working tree
78
+ (`Evals::Null` by default — see [Evals](evals.md)):
79
+ - **Gate fails** (`gate_ok: false`) — handed back to the *same* robot for up
80
+ to `--max-verify-repairs` repair attempts, re-scoring each time; if the
81
+ gate is still failing when the budget runs out, `git reset --hard` and
82
+ recorded as `[VERIFY FAILED]`.
83
+ - **Gate passes, didn't improve** (`improved: false`, and
84
+ `require_improvement?` is true — the default) → `git reset --hard`;
85
+ recorded as `[NO IMPROVEMENT]`. Not counted as a failure.
86
+ - **Gate passes and improved** → `git add -A` and commit (if anything is
87
+ staged). The consecutive-failure/-error counters reset, and the eval's
88
+ `met_target?` is checked — if true, the run stops immediately.
89
+
90
+ See [Evals](evals.md) for the full scoring model (this generalizes what used to
91
+ be a verify-command-only gate — see [Verification Gate](verification.md)).
92
+
93
+ ### 5. Update the notes
94
+
95
+ Either way, the orchestrator appends an entry to `notes.md` — success, failure,
96
+ no-improvement, verify/gate-failure, or error — so the next iteration's robot
97
+ sees what happened. See [Cross-Iteration Memory](notes.md).
98
+
99
+ ### 6. Check stop conditions
100
+
101
+ Stop conditions are evaluated **before** and **after** each iteration. The loop
102
+ ends if iterations, tokens, consecutive failures, or the eval's plateau counter
103
+ hit their limits; if the eval reports `met_target`; or if the robot set
104
+ `should_fully_stop` for a `--stop-when` condition. See
105
+ [Stop Conditions](stop-conditions.md) and [Evals](evals.md).
106
+
107
+ ## Errors, retries, and backoff
108
+
109
+ Exceptions during an iteration (e.g. transient API errors) are caught:
110
+
111
+ - **Authentication errors** are permanent — the run aborts immediately.
112
+ - Other errors roll back the tree, record the error in notes, and **retry** after
113
+ an interruptible exponential backoff (`60 × 2ⁿ` seconds), up to `--max-retries`
114
+ consecutive errors.
115
+
116
+ A `SIGINT`/`SIGTERM` (Ctrl-C) requests a graceful stop: the in-flight robot is
117
+ interrupted, the loop exits cleanly, and the exit summary still prints.
118
+
119
+ ## Commit-failure repair
120
+
121
+ If a commit itself fails (e.g. a pre-commit hook rejects the change), the failure
122
+ is queued. The next iteration's prompt includes a **repair section** asking the
123
+ robot to fix the uncommitted changes first. The working tree is *not* reset while
124
+ a commit failure is pending, so the robot can address it.
125
+
126
+ ---
127
+
128
+ Next: [Cross-Iteration Memory](notes.md).
@@ -0,0 +1,91 @@
1
+ # Cross-Iteration Memory (`notes.md`)
2
+
3
+ Each iteration runs a **fresh** robot with no memory of previous iterations. The
4
+ only thing that carries forward is `notes.md` — a human-readable log the
5
+ orchestrator maintains and injects into every iteration's system prompt.
6
+
7
+ This is what lets the loop make progress instead of repeating the same attempt.
8
+
9
+ ## Who writes it, who reads it
10
+
11
+ - **The orchestrator writes it.** After every iteration it appends an entry
12
+ (success, failure, verify-failure, or error).
13
+ - **The robot reads it.** The `PromptBuilder` embeds the current notes into the
14
+ system prompt under a "Prior Work" section.
15
+ - **The robot must not edit it.** The prompt explicitly tells the robot that
16
+ `notes.md` is maintained automatically. Writes are atomic (`AtomicFile`).
17
+
18
+ ## Location
19
+
20
+ ```
21
+ .robot_lab_to/runs/<run_id>/notes.md
22
+ ```
23
+
24
+ The path is also passed to the robot in the prompt so it can read the file
25
+ directly if it has file tools.
26
+
27
+ ## Structure
28
+
29
+ The file starts with a header and grows one section per iteration:
30
+
31
+ ```markdown
32
+ # robot-to run: 20260623-144554-3e5c27
33
+
34
+ Objective: Migrate the codebase from Minitest to RSpec
35
+
36
+ ## Iteration Log
37
+
38
+ ### Iteration 1
39
+
40
+ **Summary:** Converted spec_helper and the parser tests to RSpec
41
+
42
+ **Changes:**
43
+ - spec/spec_helper.rb
44
+ - spec/parser_spec.rb
45
+
46
+ **Learnings:**
47
+ - The project uses `assert_equal`; RSpec equivalent is `expect(x).to eq(y)`
48
+
49
+ ### Iteration 2 [FAIL]
50
+
51
+ **Summary:** Attempted to convert the integration tests but broke the suite
52
+
53
+ **Learnings:**
54
+ - The integration tests depend on a Rack test helper not yet ported
55
+ ```
56
+
57
+ ## Entry types
58
+
59
+ | Marker | When | Includes |
60
+ |--------|------|----------|
61
+ | *(none)* | Successful, committed iteration | summary, changes, learnings |
62
+ | `[FAIL]` | Robot reported failure or didn't submit | summary, learnings |
63
+ | `[VERIFY FAILED]` | Robot claimed success but the eval's gate failed (`--verify-command` / `--floor`) | summary, gate output |
64
+ | `[NO IMPROVEMENT]` | Gate passed but the eval didn't rule it better than the parent commit (rolled back unless `--no-require-improvement`) | summary, score detail |
65
+ | `[ERROR]` | An exception was raised during the iteration | error class and message |
66
+
67
+ `[VERIFY FAILED]` and `[NO IMPROVEMENT]` come from the [Evals](evals.md) scoring
68
+ gate, not the robot — see there for how `gate_ok` and `improved` are decided.
69
+
70
+ Because failures and their learnings are recorded too, the next robot can avoid
71
+ repeating a dead end — the failure log is as valuable as the success log.
72
+
73
+ ## Designing objectives around the notes
74
+
75
+ The notes are most effective when:
76
+
77
+ - The objective is a single, clear goal the robot can chip away at.
78
+ - Each iteration is genuinely incremental — "make ONE focused change" is baked
79
+ into the prompt.
80
+ - `key_learnings` capture *transferable* facts ("the build uses esbuild, not
81
+ webpack"), not restatements of the diff.
82
+
83
+ You can read the notes live during a run to watch the robot's reasoning evolve:
84
+
85
+ ```bash
86
+ tail -f .robot_lab_to/runs/*/notes.md
87
+ ```
88
+
89
+ ---
90
+
91
+ Next: [Stop Conditions](stop-conditions.md).
@@ -0,0 +1,93 @@
1
+ # Run State & Event Log
2
+
3
+ Everything a run produces — besides the git commits — lives under a single run
4
+ directory. This page documents that layout and the JSONL event log.
5
+
6
+ ## Run directory layout
7
+
8
+ ```
9
+ .robot_lab_to/
10
+ └── runs/
11
+ └── <run_id>/ # e.g. 20260623-144554-3e5c27
12
+ ├── notes.md # human-readable cross-iteration memory
13
+ ├── run.log # JSONL event log (one event per line)
14
+ └── checkpoints/ # pre-edit file snapshots (local_guards only)
15
+ ```
16
+
17
+ - `<run_id>` is `YYYYMMDD-HHMMSS-<random hex>`.
18
+ - The whole `.robot_lab_to/` directory is added to `.git/info/exclude` at startup,
19
+ so it never appears in `git status` or commits.
20
+ - Change `--run-dir` to relocate it (default `.robot_lab_to`).
21
+
22
+ ## The JSONL event log (`run.log`)
23
+
24
+ `run.log` contains one JSON object per line — a structured, append-only trace of
25
+ the run. Every line has an `event` name and a UTC `ts` timestamp; other keys
26
+ depend on the event. The logger buffers up to 100 events before the file is open,
27
+ so the very first setup events are never lost.
28
+
29
+ A typical successful iteration:
30
+
31
+ ```json
32
+ {"event":"orchestrator:start","ts":"2026-06-23T14:45:54.001-05:00","run_id":"20260623-144554-3e5c27","objective":"...","branch":"robot-to/...","model":"claude-sonnet-4-6"}
33
+ {"event":"iteration:start","ts":"...","iteration":1}
34
+ {"event":"agent:run:start","ts":"...","iteration":1}
35
+ {"event":"agent:run:end","ts":"...","iteration":1}
36
+ {"event":"eval","ts":"...","iteration":1,"gate":true,"improved":true,"value":null,"met_target":false,"detail":"verify=true"}
37
+ {"event":"commit:success","ts":"...","iteration":1,"message":"robot-to 1: ..."}
38
+ ```
39
+
40
+ ### Event reference
41
+
42
+ | Event | Meaning |
43
+ |-------|---------|
44
+ | `orchestrator:start` | Run began. Carries `run_id`, `objective`, `branch`, `model`. |
45
+ | `orchestrator:resume` | A `--resume` re-entered the loop (`run_id`, `iteration`, `branch`). |
46
+ | `iteration:start` | A new iteration began. |
47
+ | `agent:run:start` / `agent:run:end` | The robot's `run` call started / finished. |
48
+ | `agent:nudge` | The robot didn't submit; re-asking (`attempt` N). |
49
+ | `agent:run:error` | An exception occurred during the iteration (`error`, `message`). |
50
+ | `backoff:start` / `backoff:end` | Retry backoff sleep around a transient error. |
51
+ | `eval` | The [eval's](evals.md) verdict for this iteration: `gate`, `improved`, `value`, `met_target`, `detail`. |
52
+ | `eval:repair` | R2 repair attempt after a gate failure (`attempt` N of `--max-verify-repairs`). |
53
+ | `eval:repair_error` | The repair attempt itself raised (`message`); repair loop stops. |
54
+ | `commit:success` | Iteration committed (`message`). |
55
+ | `commit:failed` | `git commit` failed; queued for repair (`output`). |
56
+ | `iteration:failure` | Robot reported failure or never submitted (`summary`). |
57
+ | `iteration:gate_failure` | Robot claimed success but the eval's gate failed (recorded in notes as `[VERIFY FAILED]`). |
58
+ | `iteration:no_improvement` | Gate passed but the eval didn't rule it an improvement (`detail`); rolled back. |
59
+ | `orchestrator:abort` | Run stopped by a stop condition, `met_target`, or permanent error (`reason`). |
60
+ | `orchestrator:fatal` | Unexpected fatal error (`error`, `message`). |
61
+ | `orchestrator:end` | Final totals: `iterations`, `commits`, `input_tokens`, `output_tokens`, `abort_reason`, `elapsed`. |
62
+
63
+ Decision-file events (`decision:blocking`, `decision:wait:start`,
64
+ `decision:wait:resolved`, `decision:raised`) are also written when decisions are
65
+ enabled — see the README's Human-in-the-Loop section.
66
+
67
+ ### Analyzing a run
68
+
69
+ Because every line is JSON, the log is easy to query with
70
+ [`jq`](https://stedolan.github.io/jq/):
71
+
72
+ ```bash
73
+ # Watch events live, formatted
74
+ tail -f .robot_lab_to/runs/*/run.log | jq -r '"\(.ts) \(.event)"'
75
+
76
+ # How many iterations committed vs failed?
77
+ jq -r 'select(.event=="commit:success")' run.log | wc -l
78
+ jq -r 'select(.event=="iteration:failure") | .summary' run.log
79
+
80
+ # Final token usage
81
+ jq 'select(.event=="orchestrator:end") | {iterations,commits,input_tokens,output_tokens}' run.log
82
+ ```
83
+
84
+ ## Debug logging
85
+
86
+ By default, RubyLLM/RobotLab logging is suppressed so the console stays clean.
87
+ Pass `--debug` to keep verbose provider logging enabled (useful when diagnosing
88
+ provider or tool-call issues). The JSONL event log is always written regardless
89
+ of `--debug`.
90
+
91
+ ---
92
+
93
+ Next: [Configuration](../configuration/index.md).
@@ -0,0 +1,111 @@
1
+ # Stop Conditions
2
+
3
+ Because `robot-to` is meant to run unattended, it must reliably stop on its own.
4
+ Five independent conditions can end a run; whichever trips first wins.
5
+
6
+ ## The five conditions
7
+
8
+ | Condition | Flag | Checked | Effect |
9
+ |-----------|------|---------|--------|
10
+ | Max iterations | `--max-iterations N` | before & after each iteration | Stop after `N` iterations. |
11
+ | Max tokens | `--max-tokens N` | before & after each iteration (and mid-stream when streaming) | Stop once cumulative tokens reach `N`. |
12
+ | Consecutive failures | `--max-consecutive-failures N` | after each iteration | Abort after `N` failures in a row (default **3**). |
13
+ | Plateau | `--stop-on-plateau N` | after each iteration | Abort after `N` iterations with no committed improvement (eval-gated runs only). |
14
+ | Stop-when | `--stop-when "<text>"` | after each *successful* iteration | The robot judges the condition; sets `should_fully_stop`. |
15
+
16
+ There is a sixth, orchestrator-owned stop that isn't a `StopConditions` check at
17
+ all: an [eval's](evals.md) `met_target` — reaching a measured `--target` aborts
18
+ the run immediately on that iteration's commit, taking priority over
19
+ `should_fully_stop`. See [Evals](evals.md).
20
+
21
+ If you set none of the bounded limits, the loop relies on
22
+ `--max-consecutive-failures` (default 3) and `--stop-when` to terminate. For a
23
+ genuinely unattended run, **always set at least `--max-iterations` or
24
+ `--max-tokens`.**
25
+
26
+ ## How each works
27
+
28
+ ### Max iterations
29
+
30
+ A simple counter. The run aborts with `max iterations reached (N)` once
31
+ `run.iteration >= N`.
32
+
33
+ ### Max tokens
34
+
35
+ Cumulative input + output tokens across all iterations are compared against the
36
+ limit.
37
+
38
+ - **Streaming runs** (the default) account tokens per chunk and can interrupt an
39
+ in-flight iteration the moment the budget is exhausted.
40
+ - **Non-streaming runs** (`--no-stream`, used for local models) account tokens
41
+ from each iteration's result and stop at the next iteration boundary. See
42
+ [Local Models](../local-models/ollama.md#streaming-and-tool-calls).
43
+
44
+ ### Consecutive failures
45
+
46
+ Resets to zero on every successful, committed iteration; increments on every
47
+ failure, verify-failure, or commit-failure. This is the safety net that stops a
48
+ run that is going nowhere. The default is **3**.
49
+
50
+ ### Plateau (no improvement)
51
+
52
+ `--stop-on-plateau N` aborts once `N` consecutive iterations pass the eval's gate
53
+ but fail to beat the parent commit's score (`iterations_since_improvement >= N`).
54
+ It resets to zero on every committed improvement.
55
+
56
+ This is the **primary terminator for `Evals::Prose`**, which never sets a
57
+ measurable `met_target` — a pairwise judge has no absolute bar to reach, only
58
+ "better than last time." It's distinct from `--max-consecutive-failures`:
59
+ plateau counts *valid, gate-passing work that just wasn't better* (including
60
+ "same" verdicts, which are common and normal for prose), while consecutive
61
+ failures counts the robot being genuinely broken (errors, gate failures, no
62
+ submit). Conflating the two would throttle legitimate exploration.
63
+
64
+ ```bash
65
+ robot-to "Write an opinionated guide to VSM" \
66
+ --eval prose --spec outline.md \
67
+ --stop-on-plateau 3 # stop after 3 drafts that didn't improve
68
+ ```
69
+
70
+ See [Evals](evals.md) for the full scoring model.
71
+
72
+ ### Stop-when (natural language)
73
+
74
+ `--stop-when` is a goal the robot evaluates. After a successful iteration, the
75
+ robot decides whether the condition is met and, if so, returns
76
+ `should_fully_stop: true` in its result. The orchestrator then aborts with
77
+ `stop condition met: <text>`.
78
+
79
+ ```bash
80
+ robot-to "Refactor the auth module for clarity" \
81
+ --max-iterations 30 \
82
+ --stop-when "the auth module has no method longer than 15 lines and tests pass"
83
+ ```
84
+
85
+ `--stop-when` only ends the loop on a **successful** iteration — a failing
86
+ iteration never triggers it, even if the robot thinks the goal is met.
87
+
88
+ ## Graceful interruption
89
+
90
+ Pressing **Ctrl-C** (`SIGINT`) or sending `SIGTERM` requests a graceful stop: the
91
+ in-flight robot is interrupted, any pending backoff sleep is cancelled, the loop
92
+ exits, and the exit summary still prints. The current iteration's incomplete work
93
+ is rolled back.
94
+
95
+ ## What you'll see
96
+
97
+ The exit summary header reports the reason:
98
+
99
+ ```
100
+ robot-to stopped — claude-sonnet-4-6 — 41m 08s
101
+ Reason: max iterations reached (30)
102
+ ```
103
+
104
+ Possible reasons include `max iterations reached (N)`, `max tokens reached (N)`,
105
+ `N consecutive failures`, `plateau: N iterations without improvement`,
106
+ `target met: <detail>`, `stop condition met: <text>`, a permanent error, or a
107
+ fatal error.
108
+
109
+ ---
110
+
111
+ Next: [Verification Gate](verification.md).