rubyn-code 0.1.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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class ReadFile < Base
9
+ TOOL_NAME = "read_file"
10
+ DESCRIPTION = "Reads a file from the filesystem. Returns file content with line numbers prepended."
11
+ PARAMETERS = {
12
+ path: { type: :string, required: true, description: "Path to the file to read (relative to project root or absolute)" },
13
+ offset: { type: :integer, required: false, description: "Line number to start reading from (1-based)" },
14
+ limit: { type: :integer, required: false, description: "Number of lines to read" }
15
+ }.freeze
16
+ RISK_LEVEL = :read
17
+ REQUIRES_CONFIRMATION = false
18
+
19
+ def execute(path:, offset: nil, limit: nil)
20
+ resolved = read_file_safely(path)
21
+
22
+ lines = File.readlines(resolved)
23
+
24
+ start_line = offset ? [offset.to_i - 1, 0].max : 0
25
+ end_line = limit ? start_line + limit.to_i : lines.length
26
+
27
+ selected = lines[start_line...end_line] || []
28
+
29
+ selected.each_with_index.map do |line, idx|
30
+ line_num = start_line + idx + 1
31
+ "#{line_num.to_s.rjust(6)}\t#{line}"
32
+ end.join
33
+ end
34
+ end
35
+
36
+ Registry.register(ReadFile)
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ # Tool for reading unread messages from a teammate's inbox.
9
+ class ReadInbox < Base
10
+ TOOL_NAME = "read_inbox"
11
+ DESCRIPTION = "Reads all unread messages from the agent's inbox and marks them as read."
12
+ PARAMETERS = {
13
+ name: { type: :string, required: true, description: "The agent name whose inbox to read" }
14
+ }.freeze
15
+ RISK_LEVEL = :read
16
+ REQUIRES_CONFIRMATION = false
17
+
18
+ # @param project_root [String]
19
+ # @param mailbox [Teams::Mailbox] the team mailbox instance
20
+ def initialize(project_root:, mailbox:)
21
+ super(project_root: project_root)
22
+ @mailbox = mailbox
23
+ end
24
+
25
+ # Reads and returns all unread messages for the given agent.
26
+ #
27
+ # @param name [String] the reader's agent name
28
+ # @return [String] formatted messages or a notice if the inbox is empty
29
+ def execute(name:)
30
+ raise Error, "Agent name is required" if name.nil? || name.strip.empty?
31
+
32
+ messages = @mailbox.read_inbox(name)
33
+
34
+ return "No unread messages for '#{name}'." if messages.empty?
35
+
36
+ formatted = messages.map.with_index(1) do |msg, idx|
37
+ format_message(idx, msg)
38
+ end
39
+
40
+ header = "#{messages.size} message#{'s' if messages.size != 1} for '#{name}':\n"
41
+ header + formatted.join("\n")
42
+ end
43
+
44
+ private
45
+
46
+ # Formats a single message for display.
47
+ #
48
+ # @param index [Integer] message number
49
+ # @param msg [Hash] the parsed message hash
50
+ # @return [String]
51
+ def format_message(index, msg)
52
+ lines = []
53
+ lines << "--- Message #{index} ---"
54
+ lines << " From: #{msg[:from]}"
55
+ lines << " Type: #{msg[:message_type]}"
56
+ lines << " Time: #{msg[:timestamp]}"
57
+ lines << " Content: #{msg[:content]}"
58
+ lines.join("\n")
59
+ end
60
+ end
61
+
62
+ Registry.register(ReadInbox)
63
+ end
64
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ module Registry
6
+ @tools = {}
7
+
8
+ class << self
9
+ def register(tool_class)
10
+ name = tool_class.tool_name
11
+ @tools[name] = tool_class
12
+ end
13
+
14
+ def get(name)
15
+ @tools.fetch(name) do
16
+ raise ToolNotFoundError, "Unknown tool: #{name}. Available: #{tool_names.join(', ')}"
17
+ end
18
+ end
19
+
20
+ def all
21
+ @tools.values
22
+ end
23
+
24
+ def tool_definitions
25
+ @tools.values.map(&:to_schema)
26
+ end
27
+
28
+ def tool_names
29
+ @tools.keys.sort
30
+ end
31
+
32
+ def reset!
33
+ @tools = {}
34
+ end
35
+
36
+ def load_all!
37
+ tool_files = Dir[File.join(__dir__, "*.rb")]
38
+ tool_files.each do |file|
39
+ basename = File.basename(file, ".rb")
40
+ next if %w[base registry schema executor].include?(basename)
41
+
42
+ require_relative basename
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class ReviewPr < Base
9
+ TOOL_NAME = "review_pr"
10
+ DESCRIPTION = "Review current branch changes against Ruby/Rails best practices. " \
11
+ "Gets the diff of the current branch vs the base branch, analyzes each changed file, " \
12
+ "and provides actionable suggestions with explanations."
13
+ PARAMETERS = {
14
+ base_branch: {
15
+ type: :string,
16
+ description: "Base branch to diff against (default: main)",
17
+ required: false
18
+ },
19
+ focus: {
20
+ type: :string,
21
+ description: "Focus area: 'all', 'security', 'performance', 'style', 'testing' (default: all)",
22
+ required: false
23
+ }
24
+ }.freeze
25
+ RISK_LEVEL = :read
26
+
27
+ def execute(base_branch: "main", focus: "all")
28
+ # Check git is available
29
+ unless system("git rev-parse --is-inside-work-tree > /dev/null 2>&1", chdir: project_root)
30
+ return "Error: Not a git repository or git is not installed."
31
+ end
32
+
33
+ # Get current branch
34
+ current = run_git("rev-parse --abbrev-ref HEAD").strip
35
+ return "Error: Could not determine current branch." if current.empty?
36
+
37
+ if current == base_branch
38
+ return "You're on #{base_branch}. Switch to a feature branch first, or specify a different base: review_pr(base_branch: 'develop')"
39
+ end
40
+
41
+ # Check base branch exists
42
+ unless run_git("rev-parse --verify #{base_branch} 2>/dev/null").strip.length > 0
43
+ # Try origin/main
44
+ base_branch = "origin/#{base_branch}"
45
+ unless run_git("rev-parse --verify #{base_branch} 2>/dev/null").strip.length > 0
46
+ return "Error: Base branch '#{base_branch}' not found."
47
+ end
48
+ end
49
+
50
+ # Get the diff
51
+ diff = run_git("diff #{base_branch}...HEAD")
52
+ return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
53
+
54
+ # Get changed files with stats
55
+ stat = run_git("diff #{base_branch}...HEAD --stat")
56
+ files_changed = run_git("diff #{base_branch}...HEAD --name-only").strip.split("\n")
57
+ commit_log = run_git("log #{base_branch}..HEAD --oneline")
58
+
59
+ # Build the review context
60
+ ruby_files = files_changed.select { |f| f.match?(/\.(rb|rake|gemspec|ru)$/) }
61
+ erb_files = files_changed.select { |f| f.match?(/\.(erb|haml|slim)$/) }
62
+ spec_files = files_changed.select { |f| f.match?(/_spec\.rb$|_test\.rb$/) }
63
+ migration_files = files_changed.select { |f| f.include?("db/migrate") }
64
+ config_files = files_changed.select { |f| f.match?(/config\/|\.yml$|\.yaml$/) }
65
+
66
+ review = []
67
+ review << "# PR Review: #{current} → #{base_branch}"
68
+ review << ""
69
+ review << "## Summary"
70
+ review << stat
71
+ review << ""
72
+ review << "## Commits"
73
+ review << commit_log
74
+ review << ""
75
+ review << "## Files by Category"
76
+ review << "- Ruby: #{ruby_files.length} files" unless ruby_files.empty?
77
+ review << "- Templates: #{erb_files.length} files" unless erb_files.empty?
78
+ review << "- Specs: #{spec_files.length} files" unless spec_files.empty?
79
+ review << "- Migrations: #{migration_files.length} files" unless migration_files.empty?
80
+ review << "- Config: #{config_files.length} files" unless config_files.empty?
81
+ review << ""
82
+
83
+ # Add focus-specific review instructions
84
+ review << "## Review Focus: #{focus.upcase}"
85
+ review << review_instructions(focus)
86
+ review << ""
87
+
88
+ # Add the diff (truncated if too large)
89
+ if diff.length > 40_000
90
+ review << "## Diff (truncated — #{diff.length} chars total)"
91
+ review << diff[0...40_000]
92
+ review << "\n... [truncated #{diff.length - 40_000} chars]"
93
+ else
94
+ review << "## Full Diff"
95
+ review << diff
96
+ end
97
+
98
+ review << ""
99
+ review << "---"
100
+ review << "Review this diff against Ruby/Rails best practices. For each issue found:"
101
+ review << "1. Quote the specific code"
102
+ review << "2. Explain what's wrong and WHY it matters"
103
+ review << "3. Show the suggested fix"
104
+ review << "4. Rate severity: [critical] [warning] [suggestion] [nitpick]"
105
+ review << ""
106
+ review << "Also check for:"
107
+ review << "- Missing tests for new code"
108
+ review << "- N+1 queries in ActiveRecord changes"
109
+ review << "- Security issues (SQL injection, XSS, mass assignment)"
110
+ review << "- Missing database indexes for new associations"
111
+ review << "- Proper error handling"
112
+
113
+ truncate(review.join("\n"))
114
+ end
115
+
116
+ private
117
+
118
+ def run_git(command)
119
+ `cd #{project_root} && git #{command} 2>/dev/null`
120
+ end
121
+
122
+ def review_instructions(focus)
123
+ case focus.to_s.downcase
124
+ when "security"
125
+ "Focus on: SQL injection, XSS, CSRF, mass assignment, authentication/authorization gaps, " \
126
+ "sensitive data exposure, insecure dependencies, command injection, path traversal."
127
+ when "performance"
128
+ "Focus on: N+1 queries, missing indexes, eager loading, caching opportunities, " \
129
+ "unnecessary database calls, memory bloat, slow iterations, missing pagination."
130
+ when "style"
131
+ "Focus on: Ruby idioms, naming conventions, method length, class organization, " \
132
+ "frozen string literals, guard clauses, DRY violations, dead code."
133
+ when "testing"
134
+ "Focus on: Missing test coverage, test quality, factory usage, assertion quality, " \
135
+ "test isolation, flaky test risks, edge cases, integration vs unit test balance."
136
+ else
137
+ "Review all aspects: code quality, security, performance, testing, Rails conventions, " \
138
+ "Ruby idioms, and architectural patterns."
139
+ end
140
+ end
141
+ end
142
+
143
+ Registry.register(ReviewPr)
144
+ end
145
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base"
5
+ require_relative "registry"
6
+
7
+ module RubynCode
8
+ module Tools
9
+ class RunSpecs < Base
10
+ TOOL_NAME = "run_specs"
11
+ DESCRIPTION = "Runs RSpec or Minitest specs. Auto-detects which test framework is in use."
12
+ PARAMETERS = {
13
+ path: { type: :string, required: false, description: "Specific test file or directory to run" },
14
+ format: { type: :string, required: false, default: "documentation", description: "Output format (default: 'documentation')" },
15
+ fail_fast: { type: :boolean, required: false, description: "Stop on first failure" }
16
+ }.freeze
17
+ RISK_LEVEL = :execute
18
+ REQUIRES_CONFIRMATION = false
19
+
20
+ def execute(path: nil, format: "documentation", fail_fast: false)
21
+ framework = detect_framework
22
+
23
+ command = build_command(framework, path, format, fail_fast)
24
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
25
+
26
+ build_output(stdout, stderr, status)
27
+ end
28
+
29
+ private
30
+
31
+ def detect_framework
32
+ gemfile_path = File.join(project_root, "Gemfile")
33
+
34
+ if File.exist?(gemfile_path)
35
+ content = File.read(gemfile_path)
36
+ return :rspec if content.match?(/['"]rspec['"]/) || content.match?(/['"]rspec-rails['"]/)
37
+ return :minitest if content.match?(/['"]minitest['"]/)
38
+ end
39
+
40
+ return :rspec if File.exist?(File.join(project_root, ".rspec"))
41
+ return :rspec if File.directory?(File.join(project_root, "spec"))
42
+ return :minitest if File.directory?(File.join(project_root, "test"))
43
+
44
+ raise Error, "Could not detect test framework. Ensure RSpec or Minitest is configured."
45
+ end
46
+
47
+ def build_command(framework, path, format, fail_fast)
48
+ case framework
49
+ when :rspec
50
+ cmd = "bundle exec rspec"
51
+ cmd += " --format #{format}" if format
52
+ cmd += " --fail-fast" if fail_fast
53
+ cmd += " #{path}" if path
54
+ cmd
55
+ when :minitest
56
+ if path
57
+ "bundle exec ruby -Itest #{path}"
58
+ else
59
+ "bundle exec rails test"
60
+ end
61
+ end
62
+ end
63
+
64
+ def build_output(stdout, stderr, status)
65
+ parts = []
66
+ parts << stdout unless stdout.empty?
67
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
68
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
69
+ parts.empty? ? "(no output)" : parts.join("\n")
70
+ end
71
+ end
72
+
73
+ Registry.register(RunSpecs)
74
+ end
75
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ module Schema
6
+ TYPE_MAP = {
7
+ string: "string",
8
+ integer: "integer",
9
+ number: "number",
10
+ boolean: "boolean",
11
+ array: "array",
12
+ object: "object"
13
+ }.freeze
14
+
15
+ class << self
16
+ def build(params_hash)
17
+ return { type: "object", properties: {}, required: [] } if params_hash.empty?
18
+
19
+ properties = {}
20
+ required = []
21
+
22
+ params_hash.each do |name, spec|
23
+ name_str = name.to_s
24
+ prop = build_property(spec)
25
+ properties[name_str] = prop
26
+
27
+ required << name_str if spec[:required]
28
+ end
29
+
30
+ schema = {
31
+ type: "object",
32
+ properties: properties
33
+ }
34
+ schema[:required] = required unless required.empty?
35
+ schema
36
+ end
37
+
38
+ private
39
+
40
+ def build_property(spec)
41
+ prop = {}
42
+
43
+ type = spec[:type] || :string
44
+ prop[:type] = TYPE_MAP.fetch(type, type.to_s)
45
+
46
+ prop[:description] = spec[:description] if spec[:description]
47
+ prop[:default] = spec[:default] if spec.key?(:default)
48
+ prop[:enum] = spec[:enum] if spec[:enum]
49
+
50
+ if spec[:items]
51
+ prop[:items] = build_property(spec[:items])
52
+ end
53
+
54
+ prop
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ # Tool for sending messages to teammates via the team mailbox.
9
+ class SendMessage < Base
10
+ TOOL_NAME = "send_message"
11
+ DESCRIPTION = "Sends a message to a teammate. Used for inter-agent communication within a team."
12
+ PARAMETERS = {
13
+ to: { type: :string, required: true, description: "Name of the recipient teammate" },
14
+ content: { type: :string, required: true, description: "Message content to send" },
15
+ message_type: { type: :string, required: false, default: "message",
16
+ description: 'Type of message (default: "message")' }
17
+ }.freeze
18
+ RISK_LEVEL = :write
19
+ REQUIRES_CONFIRMATION = false
20
+
21
+ # @param project_root [String]
22
+ # @param mailbox [Teams::Mailbox] the team mailbox instance
23
+ # @param sender_name [String] the name of the sending agent
24
+ def initialize(project_root:, mailbox:, sender_name:)
25
+ super(project_root: project_root)
26
+ @mailbox = mailbox
27
+ @sender_name = sender_name
28
+ end
29
+
30
+ # Sends a message to the specified teammate.
31
+ #
32
+ # @param to [String] recipient name
33
+ # @param content [String] message body
34
+ # @param message_type [String] type of message
35
+ # @return [String] confirmation with the message id
36
+ def execute(to:, content:, message_type: "message")
37
+ raise Error, "Recipient name is required" if to.nil? || to.strip.empty?
38
+ raise Error, "Message content is required" if content.nil? || content.strip.empty?
39
+
40
+ message_id = @mailbox.send(
41
+ from: @sender_name,
42
+ to: to,
43
+ content: content,
44
+ message_type: message_type
45
+ )
46
+
47
+ "Message sent to '#{to}' (id: #{message_id}, type: #{message_type})"
48
+ end
49
+ end
50
+
51
+ Registry.register(SendMessage)
52
+ end
53
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class SpawnAgent < Base
9
+ TOOL_NAME = "spawn_agent"
10
+ DESCRIPTION = "Spawn an isolated sub-agent to handle a task. The sub-agent gets its own fresh context, " \
11
+ "works independently, and returns only a summary. Use 'explore' type for research/reading, " \
12
+ "'worker' type for writing code/files. The sub-agent shares the filesystem but not your conversation."
13
+ PARAMETERS = {
14
+ prompt: {
15
+ type: :string,
16
+ description: "The task for the sub-agent to perform",
17
+ required: true
18
+ },
19
+ agent_type: {
20
+ type: :string,
21
+ description: "Type of agent: 'explore' (read-only tools) or 'worker' (full write access). Default: explore",
22
+ required: false,
23
+ enum: %w[explore worker]
24
+ }
25
+ }.freeze
26
+ RISK_LEVEL = :execute
27
+
28
+ # These get injected by the executor or the REPL
29
+ attr_writer :llm_client, :on_status
30
+
31
+ def execute(prompt:, agent_type: "explore")
32
+ type = agent_type.to_sym
33
+ callback = @on_status || method(:default_status)
34
+ @tool_count = 0
35
+
36
+ callback.call(:started, "Spawning #{type} agent...")
37
+
38
+ tools = tools_for_type(type)
39
+
40
+ result = run_sub_agent(prompt: prompt, tools: tools, type: type, callback: callback)
41
+
42
+ callback.call(:done, "Agent finished (#{@tool_count} tool calls).")
43
+
44
+ summary = RubynCode::SubAgents::Summarizer.call(result, max_length: 3000)
45
+ "## Sub-Agent Result (#{type})\n\n#{summary}"
46
+ end
47
+
48
+ private
49
+
50
+ def run_sub_agent(prompt:, tools:, type:, callback:)
51
+ conversation = RubynCode::Agent::Conversation.new
52
+ conversation.add_user_message(prompt)
53
+
54
+ max_iterations = type == :explore ? 20 : 30
55
+ iteration = 0
56
+
57
+ loop do
58
+ break if iteration >= max_iterations
59
+
60
+ response = @llm_client.chat(
61
+ messages: conversation.to_api_format,
62
+ tools: tools,
63
+ system: sub_agent_system_prompt(type)
64
+ )
65
+
66
+ content = response.respond_to?(:content) ? Array(response.content) : []
67
+ tool_calls = content.select { |b| b.respond_to?(:type) && b.type == "tool_use" }
68
+
69
+ if tool_calls.empty?
70
+ # Final text response
71
+ text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
72
+ .map(&:text).join("\n")
73
+ conversation.add_assistant_message(content)
74
+ return text
75
+ end
76
+
77
+ # Add assistant message with tool calls
78
+ conversation.add_assistant_message(content)
79
+
80
+ # Execute each tool call
81
+ tool_calls.each do |tc|
82
+ name = tc.respond_to?(:name) ? tc.name : tc[:name]
83
+ input = tc.respond_to?(:input) ? tc.input : tc[:input]
84
+ id = tc.respond_to?(:id) ? tc.id : tc[:id]
85
+
86
+ @tool_count += 1
87
+ callback.call(:tool, "#{name}")
88
+
89
+ begin
90
+ tool_class = RubynCode::Tools::Registry.get(name)
91
+
92
+ # Block recursive spawning
93
+ if %w[spawn_agent].include?(name)
94
+ conversation.add_tool_result(id, name, "Error: Sub-agents cannot spawn other agents.", is_error: true)
95
+ next
96
+ end
97
+
98
+ # Block write tools for explore agents
99
+ if type == :explore && tool_class.risk_level != :read
100
+ conversation.add_tool_result(id, name, "Error: Explore agents can only use read-only tools.", is_error: true)
101
+ next
102
+ end
103
+
104
+ tool = tool_class.new(project_root: project_root)
105
+ result = tool.execute(**input.transform_keys(&:to_sym))
106
+ truncated = tool.truncate(result.to_s)
107
+
108
+ conversation.add_tool_result(id, name, truncated)
109
+ rescue StandardError => e
110
+ conversation.add_tool_result(id, name, "Error: #{e.message}", is_error: true)
111
+ end
112
+ end
113
+
114
+ iteration += 1
115
+ end
116
+
117
+ "Sub-agent reached iteration limit (#{max_iterations})."
118
+ end
119
+
120
+ def tools_for_type(type)
121
+ all_tools = RubynCode::Tools::Registry.tool_definitions
122
+ blocked = %w[spawn_agent send_message read_inbox compact memory_write]
123
+
124
+ if type == :explore
125
+ # Read-only tools
126
+ read_tools = %w[read_file glob grep bash load_skill memory_search]
127
+ all_tools.select { |t| read_tools.include?(t[:name]) }
128
+ else
129
+ # Worker gets everything except agent-spawning and team tools
130
+ all_tools.reject { |t| blocked.include?(t[:name]) }
131
+ end
132
+ end
133
+
134
+ def sub_agent_system_prompt(type)
135
+ base = "You are a Rubyn sub-agent. Complete your task efficiently and return a clear summary of what you found or did."
136
+
137
+ case type
138
+ when :explore
139
+ "#{base}\nYou have read-only access. Search, read files, and analyze. Do NOT attempt to write or modify anything."
140
+ when :worker
141
+ "#{base}\nYou have full read/write access. Make the changes needed, run tests if appropriate, and report what you did."
142
+ else
143
+ base
144
+ end
145
+ end
146
+
147
+ def default_status(type, message)
148
+ $stderr.puts "[sub-agent] #{message}" if ENV["RUBYN_DEBUG"]
149
+ end
150
+ end
151
+
152
+ Registry.register(SpawnAgent)
153
+ end
154
+ end