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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- 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
|