robot_lab 0.2.1 → 0.2.7

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # RobotLab
2
2
 
3
- > [!INFO]
4
- > See the [CHANGELOG](CHANGELOG.md) for the latest changes. The [examples directory has a good cross section of demo apps](examples/README.md) that show-off the various capabilities of the RobotLab library.
3
+ > [!NOTE]
4
+ > See the [CHANGELOG](CHANGELOG.md) for the latest changes. The [examples directory has a good cross section of demo apps](examples/README.md) that show-off the various capabilities of the RobotLab library.
5
5
 
6
6
  <br>
7
7
  <table>
@@ -21,14 +21,16 @@
21
21
  - <strong>Human-in-the-Loop</strong> - AskUser tool for interactive prompting<br>
22
22
  - <strong>Content Streaming</strong> - Stored callbacks, per-call blocks, or both<br>
23
23
  - <strong>MCP Integration</strong> - Connect to external tool servers with timeouts and retry<br>
24
- - <strong>Local LLM Providers</strong> - Ollama, GPUStack, LM Studio via provider passthrough<br>
24
+ - <strong>Local LLM Providers</strong> - Ollama and GPUStack via provider passthrough<br>
25
25
  - <strong>Shared Memory</strong> - Reactive key-value store with subscriptions<br>
26
26
  - <strong>Message Bus</strong> - Bidirectional robot communication via TypedBus<br>
27
27
  - <strong>Dynamic Spawning</strong> - Robots create new robots at runtime<br>
28
+ - <strong>Hook System</strong> - Lifecycle hooks across every execution boundary for instrumentation, caching, and extensions<br>
28
29
  - <strong>Layered Configuration</strong> - Cascading YAML, env vars, and RunConfig<br>
29
30
  - <strong>Rails Integration</strong> - Generators, background jobs, Turbo Stream broadcasting (via <a href="https://github.com/MadBomber/robot_lab-rails">robot_lab-rails</a>)<br>
30
31
  - <strong>Token &amp; Cost Tracking</strong> - Per-run and cumulative token counts on every robot<br>
31
32
  - <strong>Tool Loop Circuit Breaker</strong> - <code>max_tool_rounds:</code> guards against runaway tool call loops<br>
33
+ - <strong>Doom Loop Detection</strong> - always-on detection of repeated or cyclic tool call patterns, tunable via <code>doom_loop_threshold:</code><br>
32
34
  - <strong>Learning Accumulation</strong> - <code>robot.learn()</code> builds up cross-run observations with deduplication<br>
33
35
  - <strong>Context Window Compression</strong> - <code>robot.compress_history()</code> prunes irrelevant old turns via TF cosine scoring<br>
34
36
  - <strong>Convergence Detection</strong> - <code>RobotLab::Convergence</code> detects when independent agents agree, enabling reconciler fast-path<br>
@@ -37,7 +39,7 @@
37
39
  </tr>
38
40
  </table>
39
41
 
40
- <p>RobotLab enables sophisticated AI applications using multiple specialized robots (LLM agents) that work together to accomplish complex tasks. Each robot has its own instructions, skills, tools, and capabilities. Review the [full documentation website](https://madbomber.github.io/robot_lab) snd explore the [many examples](examples/README.md) available as working demo applications.</p>
42
+ <p>RobotLab enables sophisticated AI applications using multiple specialized robots (LLM agents) that work together to accomplish complex tasks. Each robot has its own instructions, skills, tools, and capabilities. Review the [full documentation website](https://madbomber.github.io/robot_lab) and explore the [many examples](examples/README.md) available as working demo applications.</p>
41
43
 
42
44
  ## Installation
43
45
 
@@ -80,7 +82,11 @@ puts result.last_text_content
80
82
 
81
83
  ### Local LLM Providers
82
84
 
83
- For local LLM providers (Ollama, GPUStack, LM Studio, etc.), use the `provider:` parameter:
85
+ For local LLM providers, use the `provider:` parameter. RubyLLM's provider list is the authority on valid values — `:ollama` and `:gpustack` are the local ones (alongside `:anthropic`, `:azure`, `:bedrock`, `:deepseek`, `:gemini`, `:mistral`, `:openai`, `:openrouter`, `:perplexity`, `:vertexai`, and `:xai`). The provider's API base must be configured first, or building the robot raises `RubyLLM::ConfigurationError`:
86
+
87
+ ```bash
88
+ export ROBOT_LAB_RUBY_LLM__OLLAMA_API_BASE=http://localhost:11434/v1
89
+ ```
84
90
 
85
91
  ```ruby
86
92
  robot = RobotLab.build(
@@ -91,13 +97,15 @@ robot = RobotLab.build(
91
97
  )
92
98
  ```
93
99
 
100
+ Specifying `provider:` also sets `assume_model_exists`, so the model name is not validated against a known-model list.
101
+
94
102
  ### Configuration
95
103
 
96
104
  RobotLab uses [MywayConfig](https://github.com/MadBomber/myway_config) for layered configuration. Configuration is loaded automatically from multiple sources in priority order:
97
105
 
98
106
  1. Bundled defaults (`lib/robot_lab/config/defaults.yml`)
99
107
  2. Environment-specific overrides (development, test, production)
100
- 3. XDG user config (`~/.config/robot_lab/config.yml`)
108
+ 3. XDG user config (`~/.config/robot_lab/robot_lab.yml` — note the filename repeats the app name)
101
109
  4. Project config (`./config/robot_lab.yml`)
102
110
  5. Environment variables (`ROBOT_LAB_*` prefix)
103
111
 
@@ -112,7 +120,6 @@ export ROBOT_LAB_RUBY_LLM__MODEL=claude-sonnet-4
112
120
  # Access configuration values
113
121
  RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
114
122
  RobotLab.config.ruby_llm.request_timeout #=> 120
115
- RobotLab.config.streaming_enabled #=> true
116
123
  ```
117
124
 
118
125
  Or create a project config file at `./config/robot_lab.yml`:
@@ -191,9 +198,10 @@ tools:
191
198
  - CodeSearchTool
192
199
  mcp:
193
200
  - name: github
194
- transport: stdio
195
- command: npx
196
- args: ["-y", "@modelcontextprotocol/server-github"]
201
+ transport:
202
+ type: stdio
203
+ command: npx
204
+ args: ["-y", "@modelcontextprotocol/server-github"]
197
205
  model: claude-sonnet-4
198
206
  ---
199
207
  You are a GitHub assistant. Use available tools to help with repository tasks.
@@ -204,7 +212,10 @@ You are a GitHub assistant. Use available tools to help with repository tasks.
204
212
  robot = RobotLab.build(template: :github_assistant)
205
213
  ```
206
214
 
207
- Front matter supports: `description`, `robot_name`, `tools`, `mcp`, `skills`, `parameters`, and LLM config keys (`model`, `temperature`, `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `stop`). Constructor-provided values always take precedence over front matter.
215
+ Front matter supports: `description`, `robot_name`, `tools`, `mcp`, `skills`, `parameters`, and LLM config keys. Constructor-provided values always take precedence over front matter.
216
+
217
+ > [!IMPORTANT]
218
+ > Of the LLM config keys, only **`model`** and **`temperature`** currently take effect from front matter. `top_p`, `top_k`, `max_tokens`, `presence_penalty`, `frequency_penalty`, and `stop` are parsed but not applied — set those as constructor kwargs (or via `config:`), where they do work.
208
219
 
209
220
  ### Composable Skills
210
221
 
@@ -236,7 +247,7 @@ Skills are expanded depth-first and can reference other skills (with automatic c
236
247
 
237
248
  ### Combining Templates with System Prompts
238
249
 
239
- The `system_prompt` parameter can also be used alongside a template. When both are provided, the template renders first and the `system_prompt` is appended. This is particularly useful during development and testing when you want to add temporary instructions or context to an existing template:
250
+ The `system_prompt` parameter can also be used alongside a template. When both are provided, the template renders first and the `system_prompt` is appended, producing a single combined system message. The appended text also survives template re-rendering, so it persists across `run()` calls that supply runtime context. This is particularly useful during development and testing when you want to add temporary instructions or context to an existing template:
240
251
 
241
252
  ```ruby
242
253
  robot = RobotLab.build(
@@ -249,7 +260,7 @@ robot = RobotLab.build(
249
260
 
250
261
  ### Shared Configuration with RunConfig
251
262
 
252
- `RunConfig` lets you define operational defaults that flow through the hierarchy: Network -> Robot -> Template -> Task -> Runtime. Use it to share LLM settings across multiple robots or an entire network.
263
+ `RunConfig` lets you define operational defaults. For a single robot they cascade from least- to most-specific: template front matter -> `config:` -> constructor kwargs. Front matter is the *base*, not an override constructor kwargs always win.
253
264
 
254
265
  ```ruby
255
266
  # Create a shared config
@@ -266,7 +277,7 @@ robot = RobotLab.build(
266
277
  config: shared
267
278
  )
268
279
 
269
- # Apply to an entire network (all robots inherit these defaults)
280
+ # Apply at the network level (see the note below on what actually propagates)
270
281
  network = RobotLab.create_network(name: "pipeline", config: shared) do
271
282
  task :analyzer, analyzer_robot, depends_on: :none
272
283
  task :writer, writer_robot, depends_on: [:analyzer]
@@ -281,6 +292,11 @@ robot = RobotLab.build(
281
292
  )
282
293
  ```
283
294
 
295
+ > [!IMPORTANT]
296
+ > A network-level `config:` propagates only `mcp` and `tools` down to its robots, and only when the robot opts in with `mcp: :inherit` / `tools: :inherit` — `:none` at any level resolves to an empty list. LLM fields (`model`, `temperature`, `max_tokens`, …) and callbacks (`on_content`) are read from each robot's own config at construction time, so they are **not** inherited from the network. To share LLM settings, pass the same `RunConfig` to each robot via `config:`.
297
+ >
298
+ > `max_concurrent_robots` is the one field the network itself consumes.
299
+
284
300
  RunConfig supports keyword construction, block DSL, and merge semantics:
285
301
 
286
302
  ```ruby
@@ -300,7 +316,7 @@ effective.model #=> "claude-sonnet-4"
300
316
 
301
317
  ### Chaining Configuration
302
318
 
303
- Robots support method chaining to adjust configuration after creation:
319
+ Robots support method chaining to adjust configuration after creation. The `with_*` methods are delegated dynamically from the underlying `RubyLLM::Chat`, so the available set is whatever that class defines — currently `with_context`, `with_headers`, `with_instructions`, `with_model`, `with_params`, `with_schema`, `with_temperature`, `with_thinking`, `with_tool`, and `with_tools`. Each returns the robot, so calls chain.
304
320
 
305
321
  ```ruby
306
322
  robot = RobotLab.build(name: "writer", system_prompt: "You are a creative writer.")
@@ -308,10 +324,18 @@ robot = RobotLab.build(name: "writer", system_prompt: "You are a creative writer
308
324
  result = robot
309
325
  .with_temperature(0.9)
310
326
  .with_model("claude-sonnet-4")
311
- .with_max_tokens(2000)
312
327
  .run("Write a haiku about Ruby programming")
313
328
  ```
314
329
 
330
+ There is no `with_max_tokens`. Set token limits in the constructor, or pass them through `with_params`:
331
+
332
+ ```ruby
333
+ robot = RobotLab.build(name: "writer", system_prompt: "...", max_tokens: 2000)
334
+
335
+ # or, after creation
336
+ robot.with_params(max_tokens: 2000)
337
+ ```
338
+
315
339
  ## Graceful Tool Error Handling
316
340
 
317
341
  `RobotLab::Tool` automatically catches exceptions in `execute` and returns a plain-text error to the LLM instead of crashing the run. The LLM can then reason about the error and try an alternative approach.
@@ -367,9 +391,22 @@ robot = RobotLab.build(
367
391
  local_tools: [Magic8Ball]
368
392
  )
369
393
 
370
- result = robot.run("Should I start learning Rust?")
394
+ # NOTE the `tools: :inherit` see the warning below
395
+ result = robot.run("Should I start learning Rust?", tools: :inherit)
371
396
  ```
372
397
 
398
+ > [!WARNING]
399
+ > `run()` defaults to `tools: :none` and `mcp: :none`, and `:none` means **send zero tools this turn**. Attaching tools with `local_tools:` (or servers with `mcp:`) is therefore not enough on its own — a plain `robot.run("...")` sends the LLM no tools at all and connects no MCP servers.
400
+ >
401
+ > Pass `tools: :inherit` / `mcp: :inherit` on each run to use what the robot has attached:
402
+ >
403
+ > ```ruby
404
+ > robot.run("...", tools: :inherit) # send attached tools
405
+ > robot.run("...", mcp: :inherit, tools: :inherit) # connect MCP + send its tools
406
+ > ```
407
+ >
408
+ > `:inherit` means "no filter, use everything attached"; an explicit array acts as a name allowlist. Note that `tools: :inherit` at *build* time behaves differently — it resolves against the parent level, so keep `:inherit` on the `run()` call.
409
+
373
410
  ## Orchestrating Multiple Robots
374
411
 
375
412
  Networks use [SimpleFlow](https://github.com/MadBomber/simple_flow) pipelines with optional task activation for intelligent routing:
@@ -422,24 +459,29 @@ puts result.value.last_text_content
422
459
 
423
460
  ## Memory
424
461
 
425
- Both robots and networks have inherent memory that persists across runs:
462
+ Two independent mechanisms carry state across runs, and it is worth keeping them straight:
463
+
464
+ - **Chat history** — each robot owns a persistent `RubyLLM::Chat`, so conversational context carries forward automatically with no work on your part.
465
+ - **`RobotLab::Memory`** — an explicit key-value store you read and write yourself, shared across robots when they run inside a network.
426
466
 
427
467
  ```ruby
428
- # Standalone robot with inherent memory
429
468
  robot = RobotLab.build(name: "assistant", system_prompt: "You are helpful.")
430
469
 
431
470
  robot.run("My name is Alice")
432
- robot.run("What's my name?") # Memory persists automatically
471
+ robot.run("What's my name?") # answered from the persistent chat history
433
472
 
434
- # Access robot's memory
473
+ # Access robot's memory (the explicit key-value store)
435
474
  robot.memory[:user_id] = 123
436
475
  robot.memory.data[:category] = "billing"
437
476
 
438
477
  # Runtime memory injection
439
478
  robot.run("Help me", memory: { session_id: "abc123", tier: "premium" })
440
479
 
441
- # Reset memory when needed
480
+ # Reset the key-value store (does NOT clear chat history)
442
481
  robot.reset_memory
482
+
483
+ # Clear chat history instead, keeping the system prompt
484
+ robot.clear_messages
443
485
  ```
444
486
 
445
487
  Networks pass context through SimpleFlow::Result:
@@ -466,6 +508,8 @@ billing_result = result.context[:billing]
466
508
  puts result.value.last_text_content
467
509
  ```
468
510
 
511
+ Results are keyed in `result.context` by the **robot's** `name`, not by the task name given to `task`. Keep the two identical unless you have a reason not to.
512
+
469
513
  ## MCP Integration
470
514
 
471
515
  Connect to external tool servers via Model Context Protocol:
@@ -489,17 +533,18 @@ robot = RobotLab.build(
489
533
  mcp: [filesystem_server]
490
534
  )
491
535
 
492
- # Optionally connect eagerly (default is lazy on first run)
536
+ # Connect. Do this explicitly — run() defaults to mcp: :none, so a plain
537
+ # run() never connects the servers configured above.
493
538
  robot.connect_mcp!
494
539
 
495
540
  # Check connection status
496
541
  puts "Failed: #{robot.failed_mcp_server_names}" if robot.failed_mcp_server_names.any?
497
542
 
498
- # Robot can now use filesystem tools
499
- result = robot.run("List the files in the current directory")
543
+ # Robot can now use filesystem tools — :inherit is required on the run
544
+ result = robot.run("List the files in the current directory", mcp: :inherit, tools: :inherit)
500
545
  ```
501
546
 
502
- MCP connections are resilient: failed servers are automatically retried on subsequent `run()` calls, and one failing server does not prevent others from connecting.
547
+ MCP connections are resilient: failed servers are automatically retried on subsequent `run(mcp: :inherit)` calls, and one failing server does not prevent others from connecting. Connection failures are logged and recorded in `failed_mcp_server_names` rather than raised, so a robot whose servers all failed still builds and runs — just without those tools.
503
548
 
504
549
  ## Message Bus
505
550
 
@@ -633,7 +678,7 @@ robot = RobotLab.build(
633
678
  robot.run("Tell me a story") { |chunk| stream_to_client(chunk.content) }
634
679
  ```
635
680
 
636
- The `on_content:` callback participates in the RunConfig cascade, so it can be set at the network or config level and inherited by robots.
681
+ The `on_content:` callback is a `RunConfig` field, so it can also be supplied through a robot's `config:` instead of as a constructor kwarg. It is read from the robot's own config at construction time — a network-level `config:` does not supply it.
637
682
 
638
683
  ## Token & Cost Tracking
639
684
 
@@ -674,7 +719,8 @@ robot = RobotLab.build(
674
719
  begin
675
720
  robot.run("Run all steps.")
676
721
  rescue RobotLab::ToolLoopError => e
677
- puts e.message # "Tool call limit of 10 exceeded"
722
+ puts e.message
723
+ # "Circuit breaker triggered: 11 tool calls exceeded max_tool_rounds (10)"
678
724
  end
679
725
  ```
680
726
 
@@ -687,14 +733,16 @@ result = robot.run("Something new.") # robot is healthy again
687
733
 
688
734
  ## Doom Loop Detection
689
735
 
690
- Doom loop detection catches the subtler failure mode where a robot repeats the same tool call pattern indefinitely — not hitting `max_tool_rounds`, but cycling through the same sequence over and over. Set `doom_loop_threshold:` to enable it:
736
+ Doom loop detection catches the subtler failure mode where a robot repeats the same tool call pattern indefinitely — not hitting `max_tool_rounds`, but cycling through the same sequence over and over.
737
+
738
+ Unlike the circuit breaker, doom loop detection is **always active** — it is installed on every run. `doom_loop_threshold:` tunes the sensitivity; it does not switch the feature on. The default threshold is `3`.
691
739
 
692
740
  ```ruby
693
741
  robot = RobotLab.build(
694
742
  name: "runner",
695
743
  system_prompt: "Execute steps.",
696
744
  local_tools: [StepTool],
697
- doom_loop_threshold: 3 # alert after 3 identical consecutive or cyclic sequences
745
+ doom_loop_threshold: 5 # tolerate more repetition before warning (default: 3)
698
746
  )
699
747
  ```
700
748
 
@@ -709,27 +757,33 @@ robot = RobotLab.build(name: "runner", system_prompt: "...", config: config)
709
757
 
710
758
  `auto_compact` triggers context window compression automatically before each `run()`, preventing context overflow without manual intervention.
711
759
 
760
+ `auto_compact` and `compact_threshold` are `RunConfig` fields, not constructor keywords — pass them via `config:`:
761
+
712
762
  ```ruby
713
763
  # Built-in trigger: compact when estimated token usage exceeds 80% of context window
714
764
  robot = RobotLab.build(
715
765
  name: "analyst",
716
766
  system_prompt: "You are a research analyst.",
717
- auto_compact: :context_window
767
+ config: RobotLab::RunConfig.new(auto_compact: :context_window)
718
768
  )
719
769
 
720
770
  # Tune the threshold (here: compact at 70%)
721
771
  robot = RobotLab.build(
722
772
  name: "analyst",
723
773
  system_prompt: "You are a research analyst.",
724
- auto_compact: :context_window,
725
- compact_threshold: 0.70
774
+ config: RobotLab::RunConfig.new(
775
+ auto_compact: :context_window,
776
+ compact_threshold: 0.70
777
+ )
726
778
  )
727
779
 
728
780
  # Application-owned compaction: full control over when and how
729
781
  robot = RobotLab.build(
730
782
  name: "analyst",
731
783
  system_prompt: "You are a research analyst.",
732
- auto_compact: ->(r) { r.compress_history(recent_turns: 5) if r.chat.messages.size > 40 }
784
+ config: RobotLab::RunConfig.new(
785
+ auto_compact: ->(r) { r.compress_history(recent_turns: 5) if r.chat.messages.size > 40 }
786
+ )
733
787
  )
734
788
  ```
735
789
 
@@ -739,12 +793,7 @@ robot = RobotLab.build(
739
793
  | `:context_window` | Compact when estimated token usage exceeds `compact_threshold` fraction of model's context window |
740
794
  | `Proc` | Called with the robot before each `run()`; application decides when and how to compact |
741
795
 
742
- `compact_threshold` defaults to `0.80` (80%). Requires the `classifier` gem when using the built-in `:context_window` strategy. Via `RunConfig`:
743
-
744
- ```ruby
745
- config = RobotLab::RunConfig.new(auto_compact: :context_window, compact_threshold: 0.75)
746
- robot = RobotLab.build(name: "analyst", system_prompt: "...", config: config)
747
- ```
796
+ `compact_threshold` defaults to `0.80` (80%). The built-in `:context_window` strategy uses the optional `classifier` gem; if it is missing, compaction is skipped with a logged warning rather than raising.
748
797
 
749
798
  ## Learning Accumulation
750
799
 
@@ -762,18 +811,9 @@ reviewer.learn("This codebase prefers map/collect over manual array accumulation
762
811
  reviewer.run("Review snippet B") # learning is injected automatically
763
812
  ```
764
813
 
765
- Pass `learn: true` in the constructor to enable automatic end-of-session learning promotion via the `robot_lab-durable` gem:
766
-
767
- ```ruby
768
- reviewer = RobotLab.build(
769
- name: "reviewer",
770
- system_prompt: "You are a Ruby code reviewer.",
771
- learn: true,
772
- learn_domain: "ruby_review"
773
- )
774
- ```
814
+ Cross-session learning promotion is provided by the [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) gem, which hooks the `:learn` lifecycle family. See that gem's documentation for setup.
775
815
 
776
- Learnings deduplicate bidirectionally: if a broader learning is added that contains an existing narrower one, the narrower one is dropped. Learnings are persisted to the robot's `Memory` and survive a robot rebuild when the same `Memory` object is reused.
816
+ Learnings deduplicate bidirectionally: if a broader learning is added that contains an existing narrower one, the narrower one is dropped. Learnings are persisted to the robot's `Memory` for the lifetime of that robot.
777
817
 
778
818
  ```ruby
779
819
  reviewer.learnings # => ["This codebase prefers map/collect..."]
@@ -782,7 +822,9 @@ reviewer.learn("new fact") # deduplicates before storing
782
822
 
783
823
  ## Context Window Compression
784
824
 
785
- `robot.compress_history` prunes old conversation turns using TF-IDF cosine similarity, keeping only turns that are relevant to the most recent context. System messages and tool call/result pairs are always preserved.
825
+ `robot.compress_history` prunes old conversation turns using term-frequency cosine similarity, keeping only turns that are relevant to the most recent context. System messages and tool call/result pairs are always preserved.
826
+
827
+ Scoring uses raw term frequencies without IDF weighting — on a topic-focused conversation, IDF would suppress exactly the recurring terms that signal relevance.
786
828
 
787
829
  ```ruby
788
830
  # Basic compression: protect the 3 most recent turns, drop unrelated old turns
@@ -810,7 +852,7 @@ gem "classifier", "~> 2.3"
810
852
 
811
853
  ## Convergence Detection
812
854
 
813
- `RobotLab::Convergence` detects when two independent agents have reached the same conclusion using TF-IDF cosine similarity. Use it as a router fast-path to skip an expensive reconciler LLM call when verifiers already agree.
855
+ `RobotLab::Convergence` detects when two independent agents have reached the same conclusion using term-frequency cosine similarity (no IDF on a two-document comparison, IDF collapses the shared terms to zero). Use it to skip an expensive reconciler LLM call when verifiers already agree.
814
856
 
815
857
  ```ruby
816
858
  # Check similarity directly
@@ -825,18 +867,30 @@ RobotLab::Convergence.detected?(result_a.reply, result_b.reply)
825
867
  RobotLab::Convergence.detected?(text_a, text_b, threshold: 0.75)
826
868
  ```
827
869
 
828
- A common pattern is wiring convergence into a network router to skip reconciliation:
870
+ `similarity` returns `0.0` if either text is blank or shorter than 30 characters (`Convergence::MIN_TEXT_LENGTH`), since term-frequency scoring is not meaningful on very short strings. Short replies like `"yes"` will therefore never register as converged.
871
+
872
+ A common pattern is a gate robot that decides whether reconciliation is needed. Routing is done by overriding `call` and activating an `depends_on: :optional` task — networks have no separate router parameter:
829
873
 
830
874
  ```ruby
831
- router = ->(args) do
832
- a = args.context[:verifier_a]&.reply.to_s
833
- b = args.context[:verifier_b]&.reply.to_s
834
- RobotLab::Convergence.detected?(a, b) ? nil : ["reconciler"]
875
+ class ConvergenceGate < RobotLab::Robot
876
+ def call(result)
877
+ a = result.context[:verifier_a]&.reply.to_s
878
+ b = result.context[:verifier_b]&.reply.to_s
879
+
880
+ # Converged? Pass the result straight through and leave :reconciler dormant.
881
+ RobotLab::Convergence.detected?(a, b) ? result : result.activate(:reconciler)
882
+ end
835
883
  end
836
884
 
837
- network = RobotLab.create_network(name: "verify", router: router) do
838
- # ...
885
+ network = RobotLab.create_network(name: "verify") do
886
+ task :verifier_a, verifier_a, depends_on: :none
887
+ task :verifier_b, verifier_b, depends_on: :none
888
+ task :gate, ConvergenceGate.new(name: "gate"), depends_on: [:verifier_a, :verifier_b]
889
+ task :reconciler, reconciler, depends_on: :optional
839
890
  end
891
+
892
+ result = network.run(message: "Audit this change")
893
+ result.context.key?(:reconciler) # => false when the verifiers agreed
840
894
  ```
841
895
 
842
896
  Requires the `classifier` gem (`~> 2.3`).
@@ -876,17 +930,84 @@ future.robot_name # => "analyst"
876
930
  future.delegated_by # => "manager"
877
931
  ```
878
932
 
933
+ ## Hook System
934
+
935
+ RobotLab's hook system lets you intercept any point in a robot's execution pipeline without modifying framework code. Hooks use handler classes — subclasses of `RobotLab::Hook` that define lifecycle callbacks as class methods.
936
+
937
+ ```ruby
938
+ class TimerHook < RobotLab::Hook
939
+ # namespace is auto-derived: TimerHook → :timer_hook
940
+ # override with: self.namespace = :timer
941
+
942
+ def self.before_run(ctx)
943
+ ctx.local.start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
944
+ end
945
+
946
+ def self.after_run(ctx)
947
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.start) * 1000).round(1)
948
+ puts "[timer] #{ctx.robot.name} #{elapsed}ms"
949
+ end
950
+
951
+ def self.on_error(ctx)
952
+ puts "[timer] #{ctx.robot.name} failed: #{ctx.error.message}"
953
+ end
954
+ end
955
+ ```
956
+
957
+ Register at the global, network, or robot level — or for a single call:
958
+
959
+ ```ruby
960
+ RobotLab.on(TimerHook) # every robot in this process
961
+ network.on(TimerHook) # robots inside this network only
962
+ robot.on(TimerHook) # this robot only
963
+ robot.run("msg", hooks: TimerHook) # this call only
964
+ robot.run("msg", hooks: [TimerHook, OtherHook])
965
+ ```
966
+
967
+ Pass `context:` to set default `DotState` values for the handler's namespace before each callback fires:
968
+
969
+ ```ruby
970
+ RobotLab.on(TimerHook, context: { threshold_ms: 500 })
971
+ ```
972
+
973
+ Around hooks receive a block and must call it — and return its value — so the actual work executes:
974
+
975
+ ```ruby
976
+ class PerfHook < RobotLab::Hook
977
+ self.namespace = :perf
978
+
979
+ def self.around_run(ctx, &block)
980
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
981
+ result = block.call # executes the run
982
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
983
+ $stderr.puts "[perf] #{ctx.robot.name} #{ms}ms"
984
+ result # must return the result
985
+ end
986
+ end
987
+ ```
988
+
989
+ Because handler classes are Ruby constants (not Procs), all hook registrations are natively Ractor-serializable.
990
+
991
+ See the [full Hook System guide](docs/guides/hooks.md) for all hook families, context objects, and extension patterns.
992
+
879
993
  ## Extension Gems
880
994
 
881
995
  RobotLab's optional capabilities are packaged as separate gems:
882
996
 
883
997
  | Gem | Description |
884
998
  |-----|-------------|
885
- | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | CPU parallelism via Ruby Ractors `ractor_safe` tools and DAG-scheduled parallel networks |
999
+ | [robot_lab-a2a](https://github.com/MadBomber/robot_lab-a2a) | Expose robots and networks as Agent2Agent (A2A) protocol services over HTTP+SSE |
1000
+ | [robot_lab-audit](https://github.com/MadBomber/robot_lab-audit) | SQLite-backed execution audit log wired through the Hook system |
1001
+ | [robot_lab-discovery](https://github.com/MadBomber/robot_lab-discovery) | Zero-configuration mDNS/DNS-SD robot discovery on local networks |
1002
+ | [robot_lab-document_store](https://github.com/MadBomber/robot_lab-document_store) | Embedding-based semantic document store for search / RAG |
1003
+ | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | HTM-backed long-term memory and cross-session knowledge persistence |
1004
+ | [robot_lab-ractor](https://github.com/MadBomber/robot_lab-ractor) | Ractor-based parallel tool execution and DAG-scheduled parallel networks |
886
1005
  | [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) | Rails Engine, generators, `RobotLab::Job` ActiveJob base with Turbo Stream broadcasting |
887
- | [robot_lab-durable](https://github.com/MadBomber/robot_lab-durable) | Cross-session knowledge persistence via YAML-backed durable store |
888
- | [robot_lab-document_store](https://github.com/MadBomber/robot_lab-document_store) | In-memory vector store with fastembed embeddings for semantic search / RAG |
889
- | [robot_lab-acp](https://github.com/MadBomber/robot_lab-acp) | Expose robots and networks as ACP (Agent Communication Protocol) HTTP+SSE services |
1006
+ | [robot_lab-to](https://github.com/MadBomber/robot_lab-to) | Autonomous overnight agent loop iterate a robot toward an objective |
1007
+ | [robot_lab-web](https://github.com/MadBomber/robot_lab-web) | Browser console stream a robot's run over Server-Sent Events |
1008
+
1009
+ > [!NOTE]
1010
+ > `robot_lab-acp` is retired; its functionality is superseded by `robot_lab-a2a`.
890
1011
 
891
1012
  ## Documentation
892
1013
 
data/Rakefile CHANGED
@@ -3,6 +3,8 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
+ RUBOCOP_ENV = { "RUBOCOP_CACHE_ROOT" => "tmp/rubocop_cache" }.freeze
7
+
6
8
  Rake::TestTask.new(:test) do |t|
7
9
  t.libs << "test"
8
10
  t.libs << "lib"
@@ -34,16 +36,6 @@ Rake::TestTask.new(:integration) do |t|
34
36
  t.ruby_opts << "-rtest_helper"
35
37
  end
36
38
 
37
- desc "Check code style with RuboCop"
38
- task :rubocop do
39
- sh "bundle exec rubocop"
40
- end
41
-
42
- desc "Auto-correct RuboCop offenses"
43
- task :rubocop_fix do
44
- sh "bundle exec rubocop -a"
45
- end
46
-
47
39
  desc "Check code complexity with Flog (warn ≥20, fail ≥50)"
48
40
  task :flog_check do
49
41
  require 'flog'
@@ -82,7 +74,26 @@ task :flog_check do
82
74
  end
83
75
  end
84
76
 
85
- desc "Run all quality checks: tests (with coverage), RuboCop, and Flog"
77
+ desc "Check for structural code duplication with Flay (mass >= 50)"
78
+ task :flay_check do
79
+ require 'flay'
80
+
81
+ mass_threshold = 50
82
+
83
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
84
+ flay.process(*Dir.glob('lib/**/*.rb'))
85
+ flay.analyze
86
+
87
+ if flay.hashes.empty?
88
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
89
+ else
90
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
91
+ flay.report
92
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
93
+ end
94
+ end
95
+
96
+ desc "Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay"
86
97
  task :quality do
87
98
  results = {}
88
99
 
@@ -94,13 +105,18 @@ task :quality do
94
105
  puts "\n#{'=' * 60}"
95
106
  puts "Quality Gate: RuboCop"
96
107
  puts '=' * 60
97
- results[:rubocop] = system("bundle exec rubocop") ? :pass : :fail
108
+ results[:rubocop] = system(RUBOCOP_ENV, "bundle exec rubocop") ? :pass : :fail
98
109
 
99
110
  puts "\n#{'=' * 60}"
100
111
  puts "Quality Gate: Flog Complexity"
101
112
  puts '=' * 60
102
113
  results[:flog] = system("bundle exec rake flog_check") ? :pass : :fail
103
114
 
115
+ puts "\n#{'=' * 60}"
116
+ puts "Quality Gate: Flay Duplication"
117
+ puts '=' * 60
118
+ results[:flay] = system("bundle exec rake flay_check") ? :pass : :fail
119
+
104
120
  puts "\n#{'=' * 60}"
105
121
  puts "Quality Summary"
106
122
  puts '=' * 60