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,553 @@
1
+ # Object-oriented design
2
+
3
+ Idiomatic Ruby OO for Ruby 3.2–3.4 / Rails 7.1–8.x. Ruby is dynamically typed with
4
+ duck typing, open classes, and mixins — so the "OO design" advice from statically
5
+ typed languages mostly applies, but the *mechanics* differ. Lean on objects that
6
+ respond to messages, not on type hierarchies.
7
+
8
+ Cross-references (don't re-explain these here):
9
+ - Metaprogramming mechanics (define_method, method_missing, eigenclass) → `references/metaprogramming.md`
10
+ - Exception hierarchy & rescue rules → `references/errors-and-types.md`
11
+ - Rails layering (concerns, app/services, callbacks) → `references/rails.md`
12
+
13
+ ## SOLID, the Ruby way
14
+
15
+ SOLID is a set of pressures, not laws. Apply with duck typing in mind.
16
+
17
+ **S — Single Responsibility.** A class should have one reason to change. The Ruby
18
+ smell test: can you describe the class in one sentence without "and"? If `User`
19
+ both persists data *and* renders emails *and* charges cards, split it.
20
+
21
+ **O — Open/Closed.** Extend behavior without editing existing code. In Ruby this is
22
+ usually composition + injection, not abstract base classes. New behavior = a new
23
+ object passed in, not a new `elsif`.
24
+
25
+ ```ruby
26
+ # WRONG — every new format edits this method (closed to extension)
27
+ def export(data, format)
28
+ case format
29
+ when :csv then CSVWriter.new.write(data)
30
+ when :json then JSONWriter.new.write(data)
31
+ end
32
+ end
33
+
34
+ # RIGHT — inject a writer that responds to #write; add formats without editing
35
+ def export(data, writer:)
36
+ writer.write(data)
37
+ end
38
+ ```
39
+
40
+ **L — Liskov Substitution.** Any object passed where another is expected must honor
41
+ the same *implicit contract* (same messages, same return shape, no surprise
42
+ exceptions). In Ruby the "type" is the set of messages it answers — the role, not
43
+ the class. A `NullLogger` must accept `#info`/`#error` just like `Logger`.
44
+
45
+ **I — Interface Segregation.** Don't force collaborators to depend on messages they
46
+ don't use. Keep modules/roles small; a giant `include Everything` couples callers
47
+ to methods they never call.
48
+
49
+ **D — Dependency Inversion.** Depend on roles (duck types), not concrete classes.
50
+ Pass collaborators in; don't `SomeClass.new` deep inside a method.
51
+
52
+ ```ruby
53
+ # WRONG — hard-wired dependency; untestable without hitting Stripe
54
+ class Checkout
55
+ def call(order)
56
+ Stripe::Charge.create(amount: order.total)
57
+ end
58
+ end
59
+
60
+ # RIGHT — depend on a "gateway" role; default to the real one
61
+ class Checkout
62
+ def initialize(gateway: StripeGateway.new)
63
+ @gateway = gateway
64
+ end
65
+
66
+ def call(order)
67
+ @gateway.charge(order.total)
68
+ end
69
+ end
70
+ ```
71
+
72
+ ## Sandi Metz's rules — heuristics, not dogma
73
+
74
+ Treat these as tripwires that prompt a second look, not hard failures:
75
+
76
+ - Classes ≤ **100 lines**.
77
+ - Methods ≤ **5 lines** (a body of 5 lines, excluding `def`/`end`).
78
+ - Method signatures ≤ **4 parameters** (kwargs count individually).
79
+ - Controllers/views: **one instance variable** per view, send one message to it.
80
+
81
+ You may break a rule only if you can convince a teammate it's justified. A 6-line
82
+ method that's clearer than two 3-line methods is fine. The point is the *friction*:
83
+ when you blow past 100 lines, ask whether a second object is hiding inside.
84
+
85
+ Also useful — the "squint test" and: extract a class the moment a method needs
86
+ data from another object more than from `self` (feature envy).
87
+
88
+ ## Composition over inheritance
89
+
90
+ Prefer **has-a** over **is-a**. Inheritance couples subclass to superclass internals
91
+ and forces a single rigid axis of variation. Composition lets you swap parts.
92
+
93
+ ```ruby
94
+ # WRONG — inheritance for code reuse; deep, brittle hierarchy
95
+ class Report; def header; ...; end; end
96
+ class PdfReport < Report; end # now coupled to Report's privates
97
+ class CsvReport < Report; end
98
+
99
+ # RIGHT — compose a formatter (a role), inject it
100
+ class Report
101
+ def initialize(formatter:)
102
+ @formatter = formatter
103
+ end
104
+
105
+ def render(rows) = @formatter.format(rows)
106
+ end
107
+
108
+ Report.new(formatter: PdfFormatter.new)
109
+ Report.new(formatter: CsvFormatter.new)
110
+ ```
111
+
112
+ Use inheritance only for genuine *is-a* specialization where the subclass is
113
+ substitutable and the hierarchy is shallow (1–2 levels). Template Method (abstract
114
+ superclass calling subclass hooks) is the one inheritance pattern that stays clean.
115
+
116
+ ## Mixins / modules — the Ruby way to share behavior, and the traps
117
+
118
+ Modules let you share behavior across unrelated classes. Three insertion points:
119
+
120
+ ```ruby
121
+ module Greet
122
+ def hello = "hi from #{name}"
123
+ end
124
+
125
+ class A; include Greet; end # instance methods
126
+ class B; extend Greet; end # class/singleton methods
127
+ class C; prepend Greet; end # inserted BEFORE C in ancestors (wraps methods)
128
+ ```
129
+
130
+ Use a module when it represents a **role** several classes can play (`Comparable`,
131
+ `Enumerable`, `Trackable`). Define the small "core" method in the class and let the
132
+ module build on it — exactly how `Comparable` needs only `<=>`:
133
+
134
+ ```ruby
135
+ class Version
136
+ include Comparable
137
+ attr_reader :n
138
+ def initialize(n) = @n = n
139
+ def <=>(other) = n <=> other.n # Comparable gives ==, <, >, between?, clamp
140
+ end
141
+ ```
142
+
143
+ **Pitfalls:**
144
+ - **Hidden coupling on host state.** A module method calling `name`/`@total`
145
+ silently requires every host to provide it. Document the required interface.
146
+ - **Namespace/method collisions.** `include`d methods land directly in the
147
+ ancestor chain; two modules defining `process` clash silently (last wins).
148
+ - **"Concern soup."** Don't use modules as a junk drawer to shrink a class — that
149
+ hides the size, it doesn't fix the design. If a module only makes sense with one
150
+ host and shares its ivars, it's not a role; it's that class wanting to be split
151
+ into a *collaborator* (see composition). Rails-specific concern guidance lives in
152
+ `references/rails.md`.
153
+ - Modules can't be instantiated and carry no per-instance state of their own.
154
+
155
+ Rule of thumb: **composition for state + behavior, mixins for stateless behavior /
156
+ shared roles.**
157
+
158
+ ## Duck typing, "tell don't ask", Law of Demeter
159
+
160
+ **Duck typing:** depend on what an object *does*, not its class.
161
+
162
+ ```ruby
163
+ # WRONG — type-checking defeats polymorphism
164
+ def total(items)
165
+ items.sum { |i| i.is_a?(Discounted) ? i.discount_price : i.price }
166
+ end
167
+
168
+ # RIGHT — every item answers #price; let it decide
169
+ def total(items) = items.sum(&:price)
170
+ ```
171
+
172
+ Avoid `is_a?`/`kind_of?`/`respond_to?` branching for control flow. If you must check
173
+ capability, `respond_to?` is the least-bad option, but a polymorphic method or a
174
+ Null Object is usually better.
175
+
176
+ **Tell, don't ask:** send a command instead of pulling state out to decide.
177
+
178
+ ```ruby
179
+ # WRONG — ask then act (logic leaks to the caller)
180
+ if account.balance >= amount
181
+ account.balance -= amount
182
+ end
183
+
184
+ # RIGHT — tell the object; it enforces its own invariants
185
+ account.withdraw(amount)
186
+ ```
187
+
188
+ **Law of Demeter** ("only talk to your immediate neighbors"): avoid chains that
189
+ reach through objects. `a.b.c.d` couples you to the whole graph.
190
+
191
+ ```ruby
192
+ # WRONG — train wreck; knows about company AND address internals
193
+ user.company.address.zip_code
194
+
195
+ # RIGHT — delegate, exposing intent not structure
196
+ class User
197
+ def company_zip = company.zip_code
198
+ end
199
+ # or in Rails:
200
+ delegate :zip_code, to: :company, prefix: true # user.company_zip_code
201
+ ```
202
+
203
+ Chains on a *collection pipeline* (`items.select{}.map{}.sum`) are fine — that's one
204
+ object (Enumerable), not a Demeter violation.
205
+
206
+ ## Value objects (Data.define)
207
+
208
+ Immutable, compared by value, no identity. Use `Data.define` (Ruby 3.2+) for these —
209
+ it gives `==`, `hash`, `eql?`, keyword + positional init, `with`, and `deconstruct`.
210
+
211
+ ```ruby
212
+ Money = Data.define(:cents, :currency) do
213
+ def +(other)
214
+ raise ArgumentError, "currency mismatch" unless currency == other.currency
215
+ with(cents: cents + other.cents) # returns a NEW Money
216
+ end
217
+
218
+ def to_s = format("%.2f %s", cents / 100.0, currency)
219
+ end
220
+
221
+ a = Money.new(cents: 500, currency: "USD")
222
+ b = Money[300, "USD"] # positional via []
223
+ a + b # => #<data Money cents=800 ...>
224
+ a == Money.new(cents: 500, currency: "USD") # => true (value equality)
225
+ ```
226
+
227
+ `Data` instances are frozen and have **no setters** — that's the point. Use `Struct`
228
+ only when you genuinely need mutability or array-style access; otherwise prefer
229
+ `Data`. (Struct vs Data detail → `references/language-idioms.md`.)
230
+
231
+ ## Service objects — one public `#call`
232
+
233
+ A service object models a *verb* / use case. Convention: one public method, named
234
+ `#call`, collaborators injected in `#initialize`, returns a Result (below).
235
+
236
+ ```ruby
237
+ class RegisterUser
238
+ def self.call(...) = new(...).call # convenience entry point
239
+
240
+ def initialize(repo: UserRepository.new, mailer: WelcomeMailer)
241
+ @repo = repo
242
+ @mailer = mailer
243
+ end
244
+
245
+ def call(email:, password:)
246
+ return Result.failure(:invalid_email) unless email.include?("@")
247
+
248
+ user = @repo.create(email:, password:)
249
+ @mailer.deliver(user)
250
+ Result.success(user)
251
+ end
252
+
253
+ private attr_reader :repo, :mailer # private + everything else below
254
+ end
255
+ ```
256
+
257
+ Do: keep `#call` thin and orchestrative; push real logic into domain objects.
258
+ Don't: stuff seven public methods in and call it a "service" — that's just a class
259
+ with a vague name. (Placement under `app/services` → `references/rails.md`.)
260
+
261
+ ## Form / query / policy objects, decorators/presenters
262
+
263
+ **Form object** — coordinates validation/persistence across multiple models or
264
+ non-AR input. Wraps params, exposes `valid?` + `save`, keeps controllers thin.
265
+
266
+ ```ruby
267
+ class SignupForm
268
+ include ActiveModel::Model # gives validations + #valid?
269
+ attr_accessor :email, :company_name
270
+ validates :email, presence: true
271
+
272
+ def save
273
+ return false unless valid?
274
+ ActiveRecord::Base.transaction { create_company! && create_user! }
275
+ end
276
+ end
277
+ ```
278
+
279
+ **Query object** — encapsulates a non-trivial DB query (reuse, testability) instead
280
+ of fat scopes or controller-built relations.
281
+
282
+ ```ruby
283
+ class ActivePremiumUsers
284
+ def initialize(relation = User.all) = @relation = relation
285
+ def call = @relation.where(active: true).where(plan: :premium).order(:created_at)
286
+ end
287
+ ```
288
+
289
+ **Policy object** — answers a yes/no authorization/business question.
290
+
291
+ ```ruby
292
+ class PublishPolicy
293
+ def initialize(user, post) = (@user, @post = user, post)
294
+ def allowed? = @user.editor? && @post.draft?
295
+ end
296
+ ```
297
+
298
+ **Decorator / presenter** — adds view/display behavior to an object *without*
299
+ touching the model. A decorator wraps and forwards; a presenter is the same idea
300
+ focused on view formatting. Use plain Ruby + `SimpleDelegator`:
301
+
302
+ ```ruby
303
+ class UserPresenter < SimpleDelegator
304
+ def display_name = full_name.presence || email.split("@").first
305
+ def joined = created_at.strftime("%b %Y")
306
+ end
307
+
308
+ UserPresenter.new(user).display_name # forwards full_name/email to the wrapped user
309
+ ```
310
+
311
+ Don't put `created_at.strftime(...)` logic in the model — display concerns belong in
312
+ the presenter/decorator layer.
313
+
314
+ ## Result / Either objects vs exceptions for control flow
315
+
316
+ Use exceptions for *exceptional, unexpected* conditions. Use a **Result** object for
317
+ *expected* success/failure branches (validation fails, payment declined). Exceptions
318
+ as flow control are slow and hide the happy path.
319
+
320
+ ```ruby
321
+ # WRONG — exceptions to model an expected outcome
322
+ def charge(order)
323
+ raise PaymentDeclined unless gateway.ok?(order)
324
+ ...
325
+ end
326
+ begin; charge(order); rescue PaymentDeclined; show_error; end
327
+
328
+ # RIGHT — explicit Result; both branches are visible at the call site
329
+ Result = Data.define(:success, :value, :error) do
330
+ def self.success(value) = new(success: true, value:, error: nil)
331
+ def self.failure(error) = new(success: false, value: nil, error:)
332
+ def success? = success
333
+ def on_success = (yield value if success?; self)
334
+ def on_failure = (yield error unless success?; self)
335
+ end
336
+
337
+ result = Charge.new.call(order)
338
+ result.on_success { |receipt| notify(receipt) }
339
+ .on_failure { |err| log(err) }
340
+ ```
341
+
342
+ Pattern matching pairs beautifully with results (deconstruct → see
343
+ `references/language-idioms.md`):
344
+
345
+ ```ruby
346
+ case Charge.new.call(order)
347
+ in { success: true, value: } then redirect_to(value)
348
+ in { success: false, error: } then flash[:error] = error
349
+ end
350
+ ```
351
+
352
+ For larger flows, the `dry-monads` gem (`Success`/`Failure`, `Do` notation) is the
353
+ idiomatic library choice — but a 10-line `Result` like above is often enough.
354
+
355
+ ## Dependency injection in plain Ruby
356
+
357
+ No DI framework needed. Pass collaborators as keyword args with sensible defaults.
358
+ This keeps production wiring zero-config while making tests trivial.
359
+
360
+ ```ruby
361
+ class ReportMailer
362
+ def initialize(clock: Time, transport: SMTP.new) # defaults = real objects
363
+ @clock = clock
364
+ @transport = transport
365
+ end
366
+
367
+ def send_daily
368
+ @transport.deliver(at: @clock.now)
369
+ end
370
+ end
371
+
372
+ # Test: inject fakes, no stubbing of globals
373
+ ReportMailer.new(clock: FrozenClock.new, transport: FakeTransport.new).send_daily
374
+ ```
375
+
376
+ Do: default to the production object so callers write `ReportMailer.new`.
377
+ Don't: instantiate hard dependencies inside business methods, or reach for a global
378
+ `Container[:thing]` when a constructor arg does the job.
379
+
380
+ ## Null Object pattern
381
+
382
+ Replace `nil`-checks scattered across the codebase with an object that answers the
383
+ same messages with do-nothing/neutral behavior. Honors LSP; removes `if x` noise.
384
+
385
+ ```ruby
386
+ # WRONG — every caller must nil-check
387
+ user.account&.notify or default_notify
388
+
389
+ # RIGHT
390
+ class GuestUser
391
+ def name = "Guest"
392
+ def admin? = false
393
+ def notify(*) = nil # quietly does nothing
394
+ end
395
+
396
+ def current_user = session_user || GuestUser.new
397
+
398
+ current_user.name # always safe, no &. needed
399
+ ```
400
+
401
+ Caveat: a Null Object that *silently* swallows everything can mask bugs. Make it
402
+ explicit and narrow; don't `method_missing`-everything-to-nil.
403
+
404
+ ## GoF patterns that are idiomatic in Ruby (and ones to skip)
405
+
406
+ Ruby's blocks/procs and open classes collapse several patterns to almost nothing.
407
+
408
+ **Strategy → a block or proc.** Don't build a class hierarchy for a one-method
409
+ strategy; pass a callable.
410
+
411
+ ```ruby
412
+ # WRONG — Strategy-as-classes ceremony
413
+ class SumStrategy; def apply(a) = a.sum; end
414
+ calc.strategy = SumStrategy.new
415
+
416
+ # RIGHT — the strategy IS a block
417
+ def calculate(items, &strategy) = strategy.call(items)
418
+ calculate(items) { |xs| xs.sum }
419
+ # or store a proc:
420
+ PRICERS = { flat: ->(o) { o.qty * 10 }, tiered: ->(o) { ... } }
421
+ PRICERS.fetch(plan).call(order)
422
+ ```
423
+
424
+ **Observer → `Observable` / plain callbacks.** Use the stdlib `observer` mixin, or
425
+ just hold an array of subscribers (procs) and `each(&:call)`.
426
+
427
+ ```ruby
428
+ class Publisher
429
+ def initialize = @subs = []
430
+ def subscribe(&blk) = @subs << blk
431
+ def publish(event) = @subs.each { |s| s.call(event) }
432
+ end
433
+ ```
434
+
435
+ **Adapter → a thin wrapper exposing the role you need.** Idiomatic and common
436
+ (e.g. wrapping a third-party client to present your app's interface).
437
+
438
+ ```ruby
439
+ class SlackNotifier # adapts Slack::Client to a #notify role
440
+ def initialize(client) = @client = client
441
+ def notify(msg) = @client.chat_postMessage(channel: "#ops", text: msg)
442
+ end
443
+ ```
444
+
445
+ **Decorator → `SimpleDelegator`** (shown above). **Iterator → `Enumerable` + `each`**
446
+ (don't hand-roll). **Template Method → small inheritance with hook methods** (fine).
447
+
448
+ **Skip / avoid in Ruby:** Singleton-as-global-state (use a plain object passed in;
449
+ the `Singleton` mixin is rarely worth it). Abstract Factory / heavy Factory classes
450
+ (a method returning the right object, or a hash lookup, is enough). Visitor (usually
451
+ pattern matching is cleaner). Any pattern whose only job is to fake first-class
452
+ functions or interfaces — Ruby already has those.
453
+
454
+ ## Cohesion, coupling, naming
455
+
456
+ - **High cohesion:** a class's methods and ivars all relate to one job. Methods that
457
+ ignore the ivars (only touch their args) probably belong elsewhere or want to be a
458
+ module function.
459
+ - **Low coupling:** depend on roles (duck types) and few of them. Count the
460
+ collaborators a class names; more than ~3–4 concrete ones is a smell.
461
+ - **Connascence (the deep version of coupling):** prefer connascence of *name*
462
+ (rename safely) over of *position* (use kwargs!) over of *meaning/algorithm*
463
+ (magic numbers — extract a constant). Keep strong connascence *inside* one class.
464
+
465
+ ```ruby
466
+ # WRONG — connascence of position; caller must remember the order
467
+ def schedule(user, time, retries, urgent); end
468
+ schedule(u, t, 3, true)
469
+
470
+ # RIGHT — connascence of name; order-independent, self-documenting
471
+ def schedule(user:, at:, retries: 0, urgent: false); end
472
+ schedule(user: u, at: t, urgent: true)
473
+ ```
474
+
475
+ **Naming:** classes are nouns (`Invoice`, `PaymentGateway`); service objects can be
476
+ verb-phrases (`RegisterUser`, `ChargeOrder`). Methods asking a question end in `?`
477
+ and return boolean; mutating/bang methods end in `!`. Booleans read as predicates
478
+ (`active?` not `is_active`). Reveal *intent*, not implementation
479
+ (`overdue?` not `days_since_due > 30`). Avoid `Manager`/`Helper`/`Util`/`Data` class
480
+ names — they signal a missing abstraction.
481
+
482
+ ## Refactoring before/after (full example)
483
+
484
+ ```ruby
485
+ # BEFORE — fat method: validation, branching on type, persistence, email, all here
486
+ class OrdersController
487
+ def create
488
+ if params[:email] =~ /@/
489
+ order = Order.new(params)
490
+ if params[:kind] == "gift"
491
+ order.total = order.items.sum(&:price) * 0.9
492
+ else
493
+ order.total = order.items.sum(&:price)
494
+ end
495
+ order.save
496
+ Mailer.confirm(order.id, params[:email]).deliver_now
497
+ redirect_to order
498
+ else
499
+ render :new
500
+ end
501
+ end
502
+ end
503
+ ```
504
+
505
+ ```ruby
506
+ # AFTER — controller stays skinny; logic lives in well-named collaborators
507
+ class OrdersController
508
+ def create
509
+ PlaceOrder.call(params: order_params)
510
+ .on_success { |order| redirect_to order }
511
+ .on_failure { render :new }
512
+ end
513
+ end
514
+
515
+ class PlaceOrder
516
+ def self.call(...) = new(...).call
517
+ def initialize(params:, pricer: Pricer.for(params[:kind]), mailer: Mailer)
518
+ @params, @pricer, @mailer = params, pricer, mailer
519
+ end
520
+
521
+ def call
522
+ return Result.failure(:bad_email) unless @params[:email].to_s.include?("@")
523
+ order = Order.create!(@params.merge(total: @pricer.call(@params)))
524
+ @mailer.confirm(order).deliver_later
525
+ Result.success(order)
526
+ end
527
+ end
528
+
529
+ # Strategy as injected callable — no type branching in the use case
530
+ Pricer = Module.new
531
+ def Pricer.for(kind) = kind == "gift" ? ->(p){ subtotal(p) * 0.9 } : ->(p){ subtotal(p) }
532
+ ```
533
+
534
+ What changed: SRP (controller orchestrates, service decides, pricer prices), DI
535
+ (mailer/pricer injected → testable), no `is_a?` branching (strategy proc), explicit
536
+ Result instead of nested `if`, Demeter respected.
537
+
538
+ ## Quick checklist
539
+
540
+ - One reason to change per class; describe it without "and".
541
+ - Inject collaborators (kwargs, real-object defaults); don't `new` deep dependencies.
542
+ - Compose by default; inherit only for true, shallow *is-a* + substitutability.
543
+ - Modules = stateless roles; document the host interface they require; beware collisions.
544
+ - Duck-type on messages; avoid `is_a?`/`respond_to?` branching for flow.
545
+ - Tell, don't ask; obey Law of Demeter (delegate; chains OK only on Enumerable).
546
+ - Value objects → `Data.define` (immutable, value-equal). Mutable? then `Struct`.
547
+ - Service object: one public `#call`, returns a Result; logic lives in domain objects.
548
+ - Result/Either for *expected* failure; exceptions only for *exceptional* (`errors-and-types.md`).
549
+ - Null Object instead of scattered nil-checks (but keep it explicit, not magic).
550
+ - Strategy → block/proc; Observer → callbacks; Adapter → wrapper; skip Singleton/Visitor/heavy Factory.
551
+ - Prefer kwargs (connascence of name) over positional args; extract magic numbers.
552
+ - Names reveal intent; `?`/`!` conventions; avoid `Manager`/`Helper`/`Util`.
553
+ - Sandi's rules (100 lines / 5 lines / 4 params / 1 ivar-per-view) are tripwires — justify breaking them.