rubino-agent 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,424 @@
1
+ # Ruby on Rails — the Rails Way
2
+
3
+ Pragmatic, current Rails for **Ruby 3.2–3.4** and **Rails 7.1–8.x**. Optimize for convention, clarity, and safe production changes.
4
+
5
+ > **Precedence rule:** existing project conventions ALWAYS win. If the app already uses interactors, dry-rb, Trailblazer, or a custom layout, match it. The patterns below are defaults for greenfield or under-specified code.
6
+
7
+ ## Convention over configuration & the app/ layout
8
+
9
+ Rails autoloads via **Zeitwerk**: file path ⇒ constant name. `app/services/billing/charge_card.rb` ⇒ `Billing::ChargeCard`. Don't `require` app code; let Zeitwerk resolve it. Don't fight the naming (`app/models/user.rb` ⇒ `User`).
10
+
11
+ ```
12
+ app/
13
+ models/ # Active Record + POROs that own domain data
14
+ user.rb
15
+ user/ # model-scoped concerns: User::Searchable -> app/models/user/searchable.rb
16
+ controllers/ # skinny; HTTP <-> domain glue only
17
+ services/ # service objects: one public #call
18
+ jobs/ # ActiveJob subclasses
19
+ mailers/
20
+ views/
21
+ components/ # ViewComponent, if used
22
+ models/concerns/ # cross-model concerns (use sparingly)
23
+ controllers/concerns/
24
+ ```
25
+
26
+ Add your own top-level dirs (`app/queries`, `app/policies`, `app/forms`) freely — anything under `app/` is autoloaded.
27
+
28
+ ## Active Record
29
+
30
+ ### Associations
31
+
32
+ ```ruby
33
+ class Post < ApplicationRecord
34
+ belongs_to :author, class_name: "User" # required by default (Rails 5+)
35
+ has_many :comments, dependent: :destroy
36
+ has_many :commenters, through: :comments, source: :user
37
+ has_one :feature_flag
38
+ has_many :tags, dependent: :delete_all # skips callbacks; faster, use when no callbacks needed
39
+ end
40
+ ```
41
+
42
+ - `dependent: :destroy` runs callbacks per row (N deletes). `:delete_all` is one SQL DELETE but skips callbacks. `:nullify` to orphan.
43
+ - `belongs_to` is `optional: false` by default — add `optional: true` for nullable FKs, don't just remove the validation.
44
+ - Use `inverse_of` when Rails can't infer it (custom `class_name`/`foreign_key`) to avoid loading the parent twice.
45
+ - Always back associations with a DB **foreign key** (`add_foreign_key`) — `dependent:` is app-level only.
46
+
47
+ ### Validations
48
+
49
+ ```ruby
50
+ validates :email, presence: true, uniqueness: { case_sensitive: false }
51
+ validates :state, inclusion: { in: %w[draft published] }
52
+ validate :publish_date_in_future, if: :published?
53
+ ```
54
+
55
+ **Uniqueness validation has a race** — two requests can pass simultaneously. Always pair it with a **DB unique index**; rescue `ActiveRecord::RecordNotUnique` for the true guarantee.
56
+
57
+ ### Scopes
58
+
59
+ ```ruby
60
+ scope :published, -> { where(state: "published") }
61
+ scope :recent, ->(n = 10) { order(created_at: :desc).limit(n) }
62
+
63
+ # Class method is equivalent and better when logic is non-trivial:
64
+ def self.for_account(account) = where(account: account)
65
+ ```
66
+
67
+ A scope MUST return a relation (chainable). Guard conditional scopes: `scope :search, ->(q) { where("name ILIKE ?", "%#{q}%") if q.present? }` — returning `nil`/`all` keeps it chainable. Prefer `where.not`, `merge`, and named scopes over raw SQL fragments.
68
+
69
+ ### Callbacks — minimize them
70
+
71
+ Callbacks create hidden control flow that fires on every save, breaks in bulk operations, and makes tests slow. **Default to NOT using them for business logic.**
72
+
73
+ ```ruby
74
+ # AVOID: side effects buried in a callback
75
+ class Order < ApplicationRecord
76
+ after_create :charge_customer, :send_receipt # fires in tests, seeds, imports...
77
+ end
78
+
79
+ # PREFER: explicit orchestration in a service object
80
+ class PlaceOrder
81
+ def call(order)
82
+ order.save!
83
+ ChargeCustomer.new.call(order)
84
+ OrderMailer.receipt(order).deliver_later
85
+ end
86
+ end
87
+ ```
88
+
89
+ Acceptable callback uses: normalizing/deriving the record's **own** data (`before_validation` to downcase email), maintaining counters, setting defaults. Avoid callbacks that touch other records, send mail, enqueue jobs, or call external services. Never put `after_commit` chains across models — they become untraceable.
90
+
91
+ ### Query interface
92
+
93
+ ```ruby
94
+ User.where(active: true).where.not(role: "admin").order(:name)
95
+ User.where(id: ids) # IN (...)
96
+ User.where("age >= ?", 18) # parameterized — never interpolate user input
97
+ Post.where(author: { admin: true }) # hash conditions across joins (Rails 7+)
98
+ User.where.missing(:posts) # LEFT JOIN ... WHERE posts.id IS NULL
99
+ Order.where(created_at: 1.day.ago..) # endless range
100
+ ```
101
+
102
+ Never string-interpolate user input into `where` — see `references/security.md` for SQL injection.
103
+
104
+ ### Avoiding N+1: includes / preload / eager_load
105
+
106
+ ```ruby
107
+ # N+1: one query per post for comments
108
+ Post.all.each { |p| p.comments.size }
109
+
110
+ # includes: Rails picks preload (2 queries) or eager_load (JOIN) automatically
111
+ Post.includes(:comments).each { |p| p.comments.size }
112
+
113
+ # Force the strategy when you need to:
114
+ Post.preload(:comments) # always separate queries; can't filter on comments
115
+ Post.eager_load(:comments) # always LEFT JOIN; needed to WHERE on the association
116
+ Post.includes(:comments).where(comments: { spam: false }).references(:comments)
117
+ ```
118
+
119
+ Rule of thumb: `preload` (2 queries) is cheaper unless you must filter/order by the associated table, then use `eager_load`/`references`. Detect N+1 with the **bullet** gem or `prosopite`. Nested: `includes(comments: :author)`.
120
+
121
+ ### select / pluck
122
+
123
+ ```ruby
124
+ User.pluck(:email) # ["a@x.com", ...] — no model instantiation, fast
125
+ User.pluck(:id, :email) # [[1, "a@x.com"], ...]
126
+ User.where(active: true).pick(:id) # first value only
127
+ User.select(:id, :email) # ActiveRecord objects, only those columns loaded
128
+ User.sum(:balance) # aggregate in SQL, not Ruby
129
+ ```
130
+
131
+ Use `pluck` for "just give me values"; `select` when you still need model behavior. Don't `User.all.map(&:email)` when `pluck(:email)` does it in one query.
132
+
133
+ ### find_each / in_batches
134
+
135
+ ```ruby
136
+ # Load 100k rows without blowing memory — batches of 1000 by default:
137
+ User.where(active: true).find_each { |u| u.recompute! }
138
+
139
+ User.in_batches(of: 500) do |relation|
140
+ relation.update_all(synced_at: Time.current) # one UPDATE per batch
141
+ end
142
+ ```
143
+
144
+ `find_each` ignores `order` (it orders by primary key for cursoring). For bulk column updates use `update_all`/`in_batches` (no callbacks/validations) — see migration note below.
145
+
146
+ ### Transactions & locking
147
+
148
+ ```ruby
149
+ ApplicationRecord.transaction do
150
+ account.withdraw!(amount)
151
+ recipient.deposit!(amount)
152
+ raise ActiveRecord::Rollback if fraud? # rolls back without raising out
153
+ end
154
+ ```
155
+
156
+ Gotchas: a transaction commits at the **outermost** block end; nested transactions don't roll back independently unless `requires_new: true`. **Never enqueue a job or call an external API inside a transaction** — use `after_commit`/`enqueue after commit` so you don't act on uncommitted (or rolled-back) data.
157
+
158
+ ```ruby
159
+ # Optimistic locking: add a `lock_version` integer column; Rails raises on stale write
160
+ # StaleObjectError => reload & retry
161
+
162
+ # Pessimistic locking: SELECT ... FOR UPDATE, blocks other writers
163
+ Account.transaction do
164
+ account = Account.lock.find(id) # or .lock("FOR UPDATE NOWAIT")
165
+ account.update!(balance: account.balance - amount)
166
+ end
167
+
168
+ product.with_lock { product.decrement!(:stock) } # transaction + row lock
169
+ ```
170
+
171
+ Use **optimistic** for low-contention web edits, **pessimistic** for money/inventory where you must serialize.
172
+
173
+ ### Safe migrations (strong_migrations mindset)
174
+
175
+ A migration that locks a large table takes the app down. Use the **strong_migrations** gem and follow these:
176
+
177
+ ```ruby
178
+ # DON'T: add NOT NULL column with default on a big table -> full table rewrite / lock (old PG)
179
+ add_column :users, :status, :string, null: false, default: "active"
180
+
181
+ # DO: nullable add, backfill in batches, then enforce
182
+ class AddStatus < ActiveRecord::Migration[7.2]
183
+ disable_ddl_transaction! # required for CONCURRENTLY
184
+ def change
185
+ add_column :users, :status, :string # nullable, no default
186
+ end
187
+ end
188
+ # separate migration / rake task: backfill
189
+ User.in_batches(of: 5_000) { |b| b.update_all(status: "active") }
190
+ # then: change_column_null + add default in a later deploy
191
+ ```
192
+
193
+ ```ruby
194
+ # Indexes: build without locking writes
195
+ add_index :users, :email, algorithm: :concurrently, unique: true
196
+ ```
197
+
198
+ Rules: **add columns nullable**, **backfill in batches** (never `update_all` a whole giant table in one statement under load), **add indexes `algorithm: :concurrently`** (with `disable_ddl_transaction!`), add NOT NULL/FK as `validate: false` then `validate_foreign_key`/`validate_check_constraint` separately, drop columns via `ignored_columns` first. Make migrations reversible (`change` or explicit `up`/`down`).
199
+
200
+ ## Skinny controllers / rich models
201
+
202
+ ```ruby
203
+ # AVOID: business logic in the controller
204
+ def create
205
+ @order = Order.new(order_params)
206
+ @order.total = @order.line_items.sum(&:price) * 1.08
207
+ if @order.save
208
+ Stripe::Charge.create(...)
209
+ OrderMailer.receipt(@order).deliver_later
210
+ redirect_to @order
211
+ else
212
+ render :new, status: :unprocessable_entity
213
+ end
214
+ end
215
+
216
+ # PREFER: controller delegates to a service / model method
217
+ def create
218
+ result = PlaceOrder.new.call(order_params)
219
+ if result.success?
220
+ redirect_to result.order, notice: "Order placed"
221
+ else
222
+ @order = result.order
223
+ render :new, status: :unprocessable_entity
224
+ end
225
+ end
226
+ ```
227
+
228
+ Controllers should: parse params, invoke one domain call, set status/flash, render/redirect. Push everything else down. See `references/oo-design.md` for service/Result object shapes.
229
+
230
+ ## RESTful routing & resources
231
+
232
+ ```ruby
233
+ resources :posts do
234
+ resources :comments, only: %i[create destroy], shallow: true
235
+ member { post :publish } # POST /posts/:id/publish
236
+ collection { get :search } # GET /posts/search
237
+ end
238
+ resource :session, only: %i[new create destroy] # singular: no :id
239
+ namespace :admin { resources :users }
240
+ ```
241
+
242
+ Prefer the 7 standard actions; when you reach for many custom member routes, that's a sign a **new resource** is hiding (`posts/:id/publish` ⇒ consider `resources :publications`). Use `only:`/`except:` to keep the route table tight.
243
+
244
+ ## Strong parameters
245
+
246
+ ```ruby
247
+ def post_params
248
+ params.require(:post).permit(:title, :body, tag_ids: [], meta: {})
249
+ end
250
+ ```
251
+
252
+ `permit(:a, :b)` allowlists scalars; `tag_ids: []` permits an array; `meta: {}` permits an arbitrary hash (use cautiously). Never `permit!` user input. Rails 8 adds `params.expect(post: [:title, :body])` which raises a 400 on malformed structure — prefer it on Rails 8. See `references/security.md` for mass assignment.
253
+
254
+ ## Concerns — done right and abused
255
+
256
+ `ActiveSupport::Concern` handles module dependencies and the `included do ... end` block.
257
+
258
+ ```ruby
259
+ # app/models/post/publishable.rb -> Post::Publishable (model-scoped concern)
260
+ module Post::Publishable
261
+ extend ActiveSupport::Concern
262
+
263
+ included do
264
+ scope :published, -> { where.not(published_at: nil) }
265
+ validates :published_at, presence: true, if: :published?
266
+ end
267
+
268
+ def publish!(now = Time.current) = update!(published_at: now)
269
+
270
+ class_methods do
271
+ def latest_published = published.order(published_at: :desc)
272
+ end
273
+ end
274
+
275
+ class Post < ApplicationRecord
276
+ include Publishable # resolves to Post::Publishable
277
+ end
278
+ ```
279
+
280
+ **Good concern:** cohesive, named after a capability (`Publishable`, `Archivable`), ideally model-scoped under `app/models/<model>/`, shared by ≥2 models or extracted to shrink a fat model meaningfully.
281
+
282
+ **Concern abuse:** a "concern" that's just a junk drawer; a concern only one model uses and that references private internals of the host (that's not reuse, it's hiding code); deep `included do` blocks that mutate the host in surprising ways. If a concern needs the host's guts and isn't reused, it's a candidate for a **service or value object** instead (see `references/oo-design.md`). Concerns share behavior; they don't reduce coupling.
283
+
284
+ ## Service objects (app/services)
285
+
286
+ One public method, usually `#call`. Verb-named class. Returns a Result, not a boolean grab-bag.
287
+
288
+ ```ruby
289
+ # app/services/orders/place_order.rb -> Orders::PlaceOrder
290
+ module Orders
291
+ class PlaceOrder
292
+ Result = Data.define(:order, :error) do
293
+ def success? = error.nil?
294
+ end
295
+
296
+ def initialize(payments: Payments::Gateway.new) = @payments = payments # inject collaborators
297
+
298
+ def call(params)
299
+ order = Order.new(params)
300
+ Order.transaction do
301
+ order.save!
302
+ @payments.charge!(order)
303
+ end
304
+ Result.new(order:, error: nil)
305
+ rescue ActiveRecord::RecordInvalid, Payments::Error => e
306
+ Result.new(order:, error: e.message)
307
+ end
308
+ end
309
+ end
310
+ ```
311
+
312
+ Inject dependencies via the constructor with sensible defaults (testable, no global mocks). Don't make services stateful across calls. See `references/oo-design.md` for Result/Either and `references/errors-and-types.md` for rescue discipline.
313
+
314
+ ## ActiveJob & background jobs
315
+
316
+ ```ruby
317
+ class SyncContactJob < ApplicationJob
318
+ queue_as :default
319
+ retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
320
+ discard_on ActiveJob::DeserializationError # record was deleted; don't retry forever
321
+
322
+ def perform(contact_id)
323
+ contact = Contact.find_by(id: contact_id)
324
+ return unless contact # idempotent: tolerate missing record
325
+ CRM.upsert(contact) # this op must itself be idempotent
326
+ end
327
+ end
328
+
329
+ SyncContactJob.perform_later(contact.id)
330
+ ```
331
+
332
+ - **Pass IDs, not objects** — args are serialized; passing a record serializes a stale snapshot (`GlobalID` reloads it but adds a query; IDs are explicit and let you handle deletion).
333
+ - **Idempotency is mandatory** — jobs run at-least-once; a retry must not double-charge or double-send. Use unique keys / upserts / "already processed?" guards.
334
+ - **Retries:** `retry_on` for transient errors with backoff (`wait: :polynomially_longer`), `discard_on` for permanent ones. Cap attempts.
335
+ - **Queue choice:** separate latency-sensitive (`:mailers`, `:default`) from slow/bulk (`:low`, `:imports`) so a backlog of imports doesn't delay password-reset emails.
336
+ - **Backend:** **Solid Queue** (DB-backed, the Rails 8 default, no Redis) or **Sidekiq** (Redis, high throughput). Solid Queue ships in the default stack; pick Sidekiq when you need its throughput/ecosystem. Enqueue jobs **after commit**, not inside the transaction.
337
+
338
+ ## Current attributes & request context
339
+
340
+ ```ruby
341
+ # app/models/current.rb
342
+ class Current < ActiveSupport::CurrentAttributes
343
+ attribute :user, :account, :request_id
344
+ end
345
+
346
+ # in ApplicationController
347
+ before_action { Current.user = authenticated_user }
348
+ ```
349
+
350
+ `CurrentAttributes` is request/thread-local and **auto-reset after each request/job** — safe from leaking between requests. Use it for ambient context (current user, tenant, request id) to avoid threading them through every method. **Don't overuse it** as a global variable bus; it's still hidden global state and makes code harder to test. Never store it in jobs unless you re-set it from job args.
351
+
352
+ ## Hotwire essentials (high level)
353
+
354
+ - **Turbo Drive:** intercepts links/forms, swaps `<body>` via fetch — SPA-like nav with zero JS. Forms that fail validation must render with `status: :unprocessable_entity` (422) or Turbo won't show the errors.
355
+ - **Turbo Frames:** `<turbo-frame id="x">` scopes navigation/updates to a region; a link inside replaces only that frame. Lazy-load with `src:`.
356
+ - **Turbo Streams:** server sends `append`/`prepend`/`replace`/`remove` actions over HTTP response or WebSocket (`turbo_stream_from`) to update specific DOM ids — used with `broadcasts_to` on models for live updates.
357
+ - **Stimulus:** small JS controllers (`data-controller`, `data-action`, targets) for sprinkles of behavior. Keep logic server-side; Stimulus glues DOM events to it.
358
+
359
+ Reach for Hotwire before a heavy SPA. See library docs for specifics; this file stays high-level.
360
+
361
+ ## ActionMailer
362
+
363
+ ```ruby
364
+ class OrderMailer < ApplicationMailer
365
+ def receipt(order)
366
+ @order = order
367
+ mail(to: order.email, subject: "Your receipt")
368
+ end
369
+ end
370
+
371
+ OrderMailer.receipt(order).deliver_later # enqueue via ActiveJob; NOT deliver_now in requests
372
+ ```
373
+
374
+ Use `deliver_later` so SMTP latency/failure doesn't block the request. Mailer previews under `test/mailers/previews`. Keep view logic in the mailer template; mailers are a thin adapter.
375
+
376
+ ## Caching
377
+
378
+ ```ruby
379
+ # Fragment + Russian-doll: nested fragments, inner key change busts only that fragment
380
+ <% cache @product do %> # key includes updated_at -> auto-busts on change
381
+ <% cache @product.vendor do %> ... <% end %>
382
+ <% end %>
383
+
384
+ # Low-level cache: expensive computation keyed yourself
385
+ Rails.cache.fetch("stats/#{account.id}", expires_in: 1.hour) do
386
+ account.compute_expensive_stats
387
+ end
388
+ ```
389
+
390
+ - Use `touch: true` on `belongs_to` so a child update bumps the parent's `updated_at` (drives Russian-doll invalidation).
391
+ - Cache keys should encode everything that affects output (model + `cache_version`/`updated_at`); never hand-roll keys that can go stale.
392
+ - **Solid Cache** is the Rails 8 default store (DB-backed). For per-request memoization use a method-level `||=` or `Current`, not the cache store.
393
+
394
+ ## Secure defaults
395
+
396
+ - **CSRF:** `protect_from_forgery` is on by default for non-GET HTML; keep it. API-only controllers use token auth instead.
397
+ - **Strong params:** never `permit!`; allowlist explicitly (above).
398
+ - **Params filtering / logging:** `config.filter_parameters += [:password, :token, :ssn]` so secrets don't hit logs. Rails seeds common ones.
399
+ - **Encrypted credentials:** `bin/rails credentials:edit` ⇒ `config/credentials.yml.enc` + `master.key` (gitignored). Read with `Rails.application.credentials.dig(:stripe, :secret_key)`. Don't put secrets in `config/*.yml` or commit `master.key`.
400
+ - **Active Record Encryption** for column-level encryption of PII (`encrypts :ssn`).
401
+ - **Force SSL:** `config.force_ssl = true` in production.
402
+
403
+ Deeper vuln coverage (SQLi, XSS, mass assignment, SSRF, Brakeman) lives in `references/security.md`.
404
+
405
+ ## Quick checklist
406
+
407
+ - Existing project conventions override every default here.
408
+ - Let Zeitwerk autoload; name files to match constants; don't `require` app code.
409
+ - Back every association/uniqueness rule with a real DB constraint (FK, unique index).
410
+ - Minimize callbacks; move side effects (mail, jobs, external calls) into service objects.
411
+ - Kill N+1 with `includes`/`preload`; use `eager_load`+`references` only to filter on the join.
412
+ - Use `pluck`/`select`/aggregates instead of loading-then-mapping in Ruby.
413
+ - `find_each`/`in_batches` for large sets; `update_all` for bulk column writes (no callbacks).
414
+ - Wrap multi-row writes in transactions; enqueue jobs and call APIs **after commit**, never inside.
415
+ - Pick optimistic (low contention) vs pessimistic (`lock`/`with_lock`, money/inventory) locking deliberately.
416
+ - Safe migrations: nullable add → batch backfill → enforce; indexes `algorithm: :concurrently` + `disable_ddl_transaction!`.
417
+ - Skinny controllers: parse params, one domain call, render/redirect; 422 on failed Turbo forms.
418
+ - Strong params allowlist only; `params.expect` on Rails 8; never `permit!`.
419
+ - Concerns must be cohesive and reused; otherwise prefer a service/value object.
420
+ - Service objects: verb name, one `#call`, inject collaborators, return a Result.
421
+ - Jobs: pass IDs, be idempotent, `retry_on`/`discard_on` with capped backoff, separate queues; Solid Queue or Sidekiq.
422
+ - `CurrentAttributes` for request context — but it's still global state; use sparingly.
423
+ - `deliver_later` for mail; Russian-doll caching with `touch: true`; `Rails.cache.fetch` for low-level.
424
+ - Keep credentials encrypted, filter params from logs, keep CSRF on; security depth in `references/security.md`.