rubyn-code 0.4.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -2
  3. data/lib/rubyn_code/agent/conversation.rb +2 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +4 -2
  6. data/lib/rubyn_code/agent/loop.rb +7 -3
  7. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +4 -2
  10. data/lib/rubyn_code/cli/app.rb +85 -11
  11. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  12. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  13. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  14. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  15. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  16. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  17. data/lib/rubyn_code/cli/repl.rb +11 -1
  18. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  19. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  20. data/lib/rubyn_code/config/defaults.rb +2 -0
  21. data/lib/rubyn_code/config/settings.rb +5 -2
  22. data/lib/rubyn_code/context/context_budget.rb +2 -1
  23. data/lib/rubyn_code/context/manager.rb +3 -3
  24. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  25. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  26. data/lib/rubyn_code/ide/protocol.rb +2 -1
  27. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  28. data/lib/rubyn_code/learning/extractor.rb +4 -2
  29. data/lib/rubyn_code/llm/model_router.rb +2 -1
  30. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  31. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  32. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  33. data/lib/rubyn_code/self_test.rb +2 -1
  34. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  35. data/lib/rubyn_code/skills/catalog.rb +10 -0
  36. data/lib/rubyn_code/skills/document.rb +8 -2
  37. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  38. data/lib/rubyn_code/skills/loader.rb +1 -1
  39. data/lib/rubyn_code/skills/matcher.rb +89 -0
  40. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  41. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  42. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  43. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  44. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  45. data/lib/rubyn_code/tools/executor.rb +4 -2
  46. data/lib/rubyn_code/tools/grep.rb +2 -1
  47. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  48. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  49. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  50. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  51. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  52. data/lib/rubyn_code/tools/web_search.rb +2 -1
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +12 -0
  55. data/skills/rubyn_self_test.md +75 -0
  56. metadata +13 -1
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class ListSkills < Base
7
+ def self.command_name = '/skills'
8
+ def self.description = 'List installed skills or browse the registry'
9
+
10
+ def execute(args, ctx)
11
+ if args.include?('--available')
12
+ list_available(ctx)
13
+ else
14
+ list_installed(ctx)
15
+ end
16
+ rescue Skills::RegistryError => e
17
+ ctx.renderer.error("Registry error: #{e.message}")
18
+ rescue StandardError => e
19
+ ctx.renderer.error("Skills error: #{e.message}")
20
+ end
21
+
22
+ private
23
+
24
+ def list_installed(ctx)
25
+ catalog = ctx.skill_loader.catalog
26
+ all_skills = catalog.available
27
+ buckets = partition_skills(all_skills, ctx)
28
+
29
+ ctx.renderer.info("Loaded skills (#{all_skills.size} total)")
30
+ puts
31
+
32
+ render_builtin(buckets[:builtin], catalog)
33
+ render_packs(buckets[:packs])
34
+ render_community(buckets[:community], buckets[:packs].any?)
35
+ end
36
+
37
+ def partition_skills(all_skills, ctx)
38
+ community_dir = File.join(ctx.project_root, '.rubyn-code', 'skills')
39
+ global_dir = File.join(Config::Defaults::HOME_DIR, 'skills')
40
+ pack_dir = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
41
+
42
+ buckets = { builtin: [], community: [], packs: [] }
43
+ all_skills.each do |skill|
44
+ buckets[bucket_for(skill[:path].to_s, pack_dir, community_dir, global_dir)] << skill
45
+ end
46
+ buckets
47
+ end
48
+
49
+ def bucket_for(path, pack_dir, community_dir, global_dir)
50
+ return :packs if path.start_with?(pack_dir)
51
+ return :community if path.start_with?(community_dir) || path.start_with?(global_dir)
52
+
53
+ :builtin
54
+ end
55
+
56
+ def render_builtin(builtin, catalog)
57
+ puts " Built-in (#{builtin.size})"
58
+ builtin.group_by { |s| category_from_path(s[:path], catalog.skills_dirs) }
59
+ .sort_by { |cat, _| cat }
60
+ .each do |cat, skills|
61
+ label = cat.empty? ? 'general' : cat
62
+ puts " #{label}: #{skills.map { |s| s[:name] }.join(', ')}"
63
+ end
64
+ end
65
+
66
+ def render_packs(packs)
67
+ return if packs.empty?
68
+
69
+ puts
70
+ summary = packs.group_by { |s| pack_from_path(s[:path]) }
71
+ .sort
72
+ .map { |pack, skills| "#{pack} (#{skills.size})" }
73
+ .join(', ')
74
+ puts " Skill packs: #{summary}"
75
+ end
76
+
77
+ def render_community(community, packs_present)
78
+ if community.any?
79
+ puts
80
+ summary = community.group_by { |s| pack_from_path(s[:path]) }
81
+ .map { |pack, skills| "#{pack} (#{skills.size})" }
82
+ .join(', ')
83
+ puts " Community: #{summary}"
84
+ elsif !packs_present
85
+ puts
86
+ puts ' Skill packs: none installed'
87
+ puts ' Run /skills --available to browse, or /install-skills <name> to install.'
88
+ end
89
+ end
90
+
91
+ def list_available(ctx) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- readable catalog display
92
+ client = Skills::RegistryClient.new
93
+ catalog_data = client.fetch_catalog
94
+ packs = catalog_data['packs'] || []
95
+
96
+ if packs.empty?
97
+ ctx.renderer.info('No packs available in the registry.')
98
+ return
99
+ end
100
+
101
+ installer = Skills::PackInstaller.new(
102
+ registry_client: client,
103
+ project_root: ctx.project_root
104
+ )
105
+
106
+ ctx.renderer.info("Available skill packs (#{packs.size})")
107
+ puts
108
+
109
+ # Group by category
110
+ by_category = packs.group_by { |p| p['category'] || 'other' }
111
+ by_category.sort_by { |cat, _| cat }.each do |category, cat_packs|
112
+ puts " #{category.capitalize}"
113
+ cat_packs.each do |pack|
114
+ installed = installer.installed?(pack['name'])
115
+ marker = installed ? ' ✓' : ''
116
+ desc = pack['description']&.slice(0, 50) || ''
117
+ puts " #{pack['name'].ljust(20)} #{desc} (#{pack['skillCount']} skills)#{marker}"
118
+ end
119
+ puts
120
+ end
121
+
122
+ puts ' Install with: /install-skills <name>'
123
+ end
124
+
125
+ def category_from_path(path, skills_dirs)
126
+ skills_dirs.each do |dir|
127
+ expanded = File.expand_path(dir)
128
+ next unless path.to_s.start_with?(expanded)
129
+
130
+ relative = path.delete_prefix("#{expanded}/")
131
+ parts = relative.split('/')
132
+ return parts.size > 1 ? parts.first : ''
133
+ end
134
+ ''
135
+ end
136
+
137
+ def pack_from_path(path)
138
+ # Pack-style layouts: .rubyn-code/skills/<pack>/<file>.md
139
+ # or .rubyn-code/skill-packs/<pack>/<file>.md
140
+ parts = path.to_s.split('/')
141
+ idx = parts.rindex('skill-packs') || parts.rindex('skills')
142
+ return 'unknown' unless idx
143
+
144
+ parts[idx + 1] || 'unknown'
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -55,7 +55,8 @@ module RubynCode
55
55
  Auth::TokenStore.save_provider_key(name, opts[:key]) if opts[:key]
56
56
  end
57
57
 
58
- def confirm_added(name, base_url, opts, ctx) # rubocop:disable Metrics/AbcSize -- sequential output lines
58
+ # -- sequential output lines
59
+ def confirm_added(name, base_url, opts, ctx)
59
60
  ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
60
61
  ctx.renderer.info(" base_url: #{base_url}")
61
62
  ctx.renderer.info(' api_key: stored') if opts[:key]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class RemoveSkills < Base
7
+ def self.command_name = '/remove-skills'
8
+ def self.description = 'Remove installed skill packs'
9
+
10
+ def execute(args, ctx)
11
+ if args.empty?
12
+ ctx.renderer.warning('Usage: /remove-skills <pack-name> [pack-name ...]')
13
+ return
14
+ end
15
+
16
+ pack_manager = RubynCode::Skills::PackManager.new
17
+
18
+ args.each { |name| remove_pack(name, pack_manager, ctx) }
19
+ end
20
+
21
+ private
22
+
23
+ def remove_pack(name, pack_manager, ctx)
24
+ unless pack_manager.installed?(name)
25
+ ctx.renderer.warning("Pack '#{name}' is not installed.")
26
+ return
27
+ end
28
+
29
+ pack_manager.remove(name)
30
+ ctx.renderer.info("Removed skill pack '#{name}'.")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -33,7 +33,8 @@ module RubynCode
33
33
  skills.each { |skill| puts " /#{skill[:name]}: #{skill[:description]}" }
34
34
  end
35
35
 
36
- def search_skills(term, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
36
+ # -- readable sequential display logic
37
+ def search_skills(term, ctx)
37
38
  if term.nil? || term.strip.empty?
38
39
  ctx.renderer.warning('Usage: /skill search <term>')
39
40
  return
@@ -49,7 +50,8 @@ module RubynCode
49
50
  display_entries(results)
50
51
  end
51
52
 
52
- def list_by_category(category, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
53
+ # -- readable sequential display logic
54
+ def list_by_category(category, ctx)
53
55
  catalog = ctx.skill_loader.catalog
54
56
  return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
55
57
 
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Skills < Base
7
+ def self.command_name = '/skills'
8
+ def self.description = 'List installed skill packs or browse the registry'
9
+
10
+ def execute(args, ctx)
11
+ case args.first
12
+ when 'search' then search_registry(args[1..].join(' '), ctx)
13
+ when 'available' then list_available(ctx)
14
+ when nil, 'list' then list_installed(ctx)
15
+ else
16
+ ctx.renderer.warning(
17
+ "Unknown subcommand '#{args.first}'. Try: /skills, /skills available, /skills search <term>"
18
+ )
19
+ end
20
+ rescue RubynCode::Skills::RegistryError => e
21
+ ctx.renderer.error(e.message)
22
+ end
23
+
24
+ private
25
+
26
+ def list_installed(ctx)
27
+ packs = RubynCode::Skills::PackManager.new.installed
28
+
29
+ if packs.empty?
30
+ ctx.renderer.info(
31
+ 'No skill packs installed. Use /skills available to browse or /install-skills <name> to install.'
32
+ )
33
+ return
34
+ end
35
+
36
+ ctx.renderer.info("Installed skill packs (#{packs.size}):")
37
+ packs.each { |pack| puts " #{format_installed_pack(pack)}" }
38
+ end
39
+
40
+ def list_available(ctx)
41
+ ctx.renderer.info('Fetching available packs from registry...')
42
+ result = RubynCode::Skills::RegistryClient.new.fetch_catalog
43
+ packs = result[:data]
44
+ return ctx.renderer.info('No packs found in the registry.') unless valid_results?(packs)
45
+
46
+ pack_manager = RubynCode::Skills::PackManager.new
47
+ ctx.renderer.info("Available skill packs (#{packs.size}):")
48
+ packs.each { |pack| puts " #{format_available_pack(pack, pack_manager)}" }
49
+ end
50
+
51
+ def search_registry(term, ctx)
52
+ if term.nil? || term.strip.empty?
53
+ ctx.renderer.warning('Usage: /skills search <term>')
54
+ return
55
+ end
56
+
57
+ query = term.strip
58
+ ctx.renderer.info("Searching registry for '#{query}'...")
59
+ result = RubynCode::Skills::RegistryClient.new.search_packs(query)
60
+ packs = result[:data]
61
+
62
+ unless valid_results?(packs)
63
+ ctx.renderer.info("No packs found matching '#{query}'.")
64
+ return
65
+ end
66
+
67
+ ctx.renderer.info("Packs matching '#{query}' (#{packs.size}):")
68
+ packs.each { |pack| puts " #{format_pack_line(pack)}" }
69
+ end
70
+
71
+ def valid_results?(results)
72
+ results.is_a?(Array) && !results.empty?
73
+ end
74
+
75
+ def format_installed_pack(pack)
76
+ version = pack[:version] ? " v#{pack[:version]}" : ''
77
+ desc = pack[:description].to_s.empty? ? '' : " — #{pack[:description]}"
78
+ "#{pack[:name]}#{version}#{desc}"
79
+ end
80
+
81
+ def format_available_pack(pack, pack_manager)
82
+ name = pack_name(pack)
83
+ installed = pack_manager.installed?(name) ? ' [installed]' : ''
84
+ "#{format_pack_line(pack)}#{installed}"
85
+ end
86
+
87
+ def format_pack_line(pack)
88
+ name = pack_name(pack)
89
+ desc = pack_description(pack)
90
+ label = desc.empty? ? '' : " — #{desc}"
91
+ "#{name}#{label}"
92
+ end
93
+
94
+ def pack_name(pack)
95
+ pack[:name] || pack['name']
96
+ end
97
+
98
+ def pack_description(pack)
99
+ (pack[:description] || pack['description']).to_s
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -34,6 +34,7 @@ module RubynCode
34
34
 
35
35
  @renderer.welcome
36
36
  @version_check.notify
37
+ check_skill_suggestions!
37
38
 
38
39
  at_exit { shutdown! }
39
40
 
@@ -44,6 +45,14 @@ module RubynCode
44
45
 
45
46
  private
46
47
 
48
+ def check_skill_suggestions!
49
+ suggest = Skills::AutoSuggest.new(project_root: @project_root)
50
+ message = suggest.check
51
+ @renderer.info(message) if message
52
+ rescue StandardError
53
+ # Never block session start on suggestion failure
54
+ end
55
+
47
56
  def run_input_loop
48
57
  while @running
49
58
  begin
@@ -98,7 +107,8 @@ module RubynCode
98
107
  @stream_formatter&.feed(text)
99
108
  end
100
109
 
101
- def handle_message(input) # rubocop:disable Metrics/AbcSize -- sequential steps with interrupt rescue
110
+ # -- sequential steps with interrupt rescue
111
+ def handle_message(input)
102
112
  @spinner.start
103
113
  @streaming_first_chunk = true
104
114
 
@@ -22,7 +22,8 @@ module RubynCode
22
22
  Commands::Spawn, Commands::Doctor, Commands::Tokens,
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
24
  Commands::Model, Commands::NewSession, Commands::Mcp,
25
- Commands::Provider
25
+ Commands::Provider, Commands::InstallSkills,
26
+ Commands::RemoveSkills, Commands::Skills
26
27
  ].each { |cmd| @command_registry.register(cmd) }
27
28
  end
28
29
 
@@ -42,9 +42,41 @@ module RubynCode
42
42
  @budget_enforcer = Observability::BudgetEnforcer.new(@db, session_id: current_session_id)
43
43
  @background_worker = Background::Worker.new(project_root: @project_root)
44
44
  @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
45
+ @skill_matcher = build_skill_matcher
46
+ @web_skill_autoload = build_web_skill_autoload
45
47
  @session_persistence = Memory::SessionPersistence.new(@db)
46
48
  end
47
49
 
50
+ def build_skill_matcher
51
+ return nil unless skills_autoload_enabled?
52
+
53
+ Skills::Matcher.new(catalog: @skill_loader.catalog, project_root: @project_root)
54
+ end
55
+
56
+ def build_web_skill_autoload
57
+ return nil unless @skill_matcher && skills_autoload_enabled?
58
+
59
+ Skills::RegistryAutoload.new(
60
+ loader: @skill_loader,
61
+ matcher: @skill_matcher,
62
+ on_fetching: on_pack_fetching_callback
63
+ )
64
+ end
65
+
66
+ def skills_autoload_enabled?
67
+ Config::Settings.new.skills_autoload
68
+ rescue Config::Settings::LoadError
69
+ true
70
+ end
71
+
72
+ def on_skills_autoloaded_callback
73
+ ->(names) { @renderer.system_message("📚 Loaded: #{names.join(' · ')}") }
74
+ end
75
+
76
+ def on_pack_fetching_callback
77
+ ->(name) { @renderer.system_message("📥 Fetching skill pack '#{name}' from registry…") }
78
+ end
79
+
48
80
  def setup_executor_callbacks!
49
81
  @tool_executor.llm_client = @llm_client
50
82
  @tool_executor.background_worker = @background_worker
@@ -107,7 +139,10 @@ module RubynCode
107
139
  on_tool_call: ->(name, params) { handle_on_tool_call(name, params) },
108
140
  on_tool_result: ->(name, result, _is_error = false) { handle_on_tool_result(name, result) },
109
141
  on_text: ->(text) { handle_on_text(text) },
110
- skill_loader: @skill_loader, project_root: @project_root
142
+ on_skills_autoloaded: on_skills_autoloaded_callback,
143
+ skill_loader: @skill_loader, skill_matcher: @skill_matcher,
144
+ web_skill_autoload: @web_skill_autoload,
145
+ project_root: @project_root
111
146
  )
112
147
  end
113
148
 
@@ -139,6 +174,8 @@ module RubynCode
139
174
  dirs << project_skills if Dir.exist?(project_skills)
140
175
  user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
141
176
  dirs << user_skills if Dir.exist?(user_skills)
177
+ skill_packs = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
178
+ dirs << skill_packs if Dir.exist?(skill_packs)
142
179
  dirs
143
180
  end
144
181
 
@@ -31,6 +31,8 @@ module RubynCode
31
31
  POLL_INTERVAL = 5
32
32
  IDLE_TIMEOUT = 60
33
33
 
34
+ SKILLS_AUTOLOAD = true
35
+
34
36
  SESSION_BUDGET_USD = 5.00
35
37
  DAILY_BUDGET_USD = 10.00
36
38
 
@@ -16,6 +16,7 @@ module RubynCode
16
16
  session_budget_usd daily_budget_usd
17
17
  oauth_client_id oauth_redirect_uri oauth_authorize_url
18
18
  oauth_token_url oauth_scopes
19
+ skills_autoload
19
20
  ].freeze
20
21
 
21
22
  DEFAULT_MAP = {
@@ -35,7 +36,8 @@ module RubynCode
35
36
  oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
36
37
  oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
37
38
  oauth_token_url: Defaults::OAUTH_TOKEN_URL,
38
- oauth_scopes: Defaults::OAUTH_SCOPES
39
+ oauth_scopes: Defaults::OAUTH_SCOPES,
40
+ skills_autoload: Defaults::SKILLS_AUTOLOAD
39
41
  }.freeze
40
42
 
41
43
  attr_reader :config_path, :data
@@ -160,7 +162,8 @@ module RubynCode
160
162
 
161
163
  # Backfills missing 'models' keys into existing provider configs.
162
164
  # Never overwrites user-set values — only adds what's missing.
163
- def backfill_provider_models! # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- iterates providers with guard clauses
165
+ # -- iterates providers with guard clauses
166
+ def backfill_provider_models!
164
167
  providers = @data['providers']
165
168
  return unless providers.is_a?(Hash)
166
169
 
@@ -130,7 +130,8 @@ module RubynCode
130
130
  end
131
131
  end
132
132
 
133
- def process_signature_line(line, signatures, indent_stack) # rubocop:disable Metrics/AbcSize -- signature extraction dispatch
133
+ # -- signature extraction dispatch
134
+ def process_signature_line(line, signatures, indent_stack)
134
135
  stripped = line.strip
135
136
  if signature_line?(stripped)
136
137
  signatures << line
@@ -74,11 +74,9 @@ module RubynCode
74
74
  MICRO_COMPACT_RATIO_UNCACHED = 0.5
75
75
 
76
76
  def check_compaction!(conversation)
77
- # Guard: skip if compaction already ran this turn
77
+ # Guard: skip if compaction already succeeded this turn
78
78
  return if @last_compaction_turn == @current_turn
79
79
 
80
- @last_compaction_turn = @current_turn
81
-
82
80
  messages = conversation.messages
83
81
 
84
82
  # Step 1: Zero-cost micro-compact — but only when we're approaching
@@ -93,6 +91,7 @@ module RubynCode
93
91
  collapsed = ContextCollapse.call(messages, threshold: @threshold)
94
92
  if collapsed
95
93
  apply_compacted_messages(conversation, collapsed)
94
+ @last_compaction_turn = @current_turn
96
95
  return
97
96
  end
98
97
 
@@ -102,6 +101,7 @@ module RubynCode
102
101
  compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
103
102
  new_messages = compactor.auto_compact!(messages)
104
103
  apply_compacted_messages(conversation, new_messages)
104
+ @last_compaction_turn = @current_turn
105
105
  end
106
106
 
107
107
  # Resets cumulative token counters to zero.
@@ -62,7 +62,8 @@ module RubynCode
62
62
 
63
63
  private
64
64
 
65
- def run_agent(session_id, text, context) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- orchestrates agent lifecycle with notifications
65
+ # -- orchestrates agent lifecycle with notifications
66
+ def run_agent(session_id, text, context)
66
67
  @server.notify('agent/status', {
67
68
  'sessionId' => session_id,
68
69
  'status' => 'thinking'
@@ -165,7 +166,8 @@ module RubynCode
165
166
  # Install a ToolOutput adapter on the server so AcceptEdit /
166
167
  # ApproveToolUse handlers can route responses back to this session.
167
168
  def build_tool_output_adapter
168
- adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode, hook_runner: build_ide_hook_runner)
169
+ adapter = IDE::Adapters::ToolOutput.new(@server, permission_mode: @server.permission_mode,
170
+ hook_runner: build_ide_hook_runner)
169
171
  @server.tool_output_adapter = adapter
170
172
  adapter
171
173
  end
@@ -190,7 +192,8 @@ module RubynCode
190
192
  Hooks::Runner.new(registry: registry)
191
193
  end
192
194
 
193
- def build_enriched_input(text, context) # rubocop:disable Metrics/AbcSize -- assembles context parts from multiple optional fields
195
+ # -- assembles context parts from multiple optional fields
196
+ def build_enriched_input(text, context)
194
197
  parts = []
195
198
 
196
199
  parts << "[Active file: #{context['activeFile']}]" if context['activeFile']
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../skills/pack_context'
4
+
3
5
  module RubynCode
4
6
  module IDE
5
7
  module Handlers
@@ -31,15 +33,16 @@ module RubynCode
31
33
 
32
34
  private
33
35
 
34
- def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- review lifecycle with finding notifications
36
+ def run_review(session_id, base_branch, focus) # rubocop:disable Metrics/MethodLength -- review lifecycle with finding notifications
35
37
  @server.notify('agent/status', {
36
38
  'sessionId' => session_id,
37
39
  'status' => 'reviewing'
38
40
  })
39
41
 
40
42
  workspace = @server.workspace_path || Dir.pwd
43
+ pack_context = build_pack_context(workspace)
41
44
  review_tool = Tools::ReviewPr.new(project_root: workspace)
42
- result = review_tool.execute(base_branch: base_branch, focus: focus)
45
+ result = review_tool.execute(base_branch: base_branch, focus: focus, pack_context: pack_context)
43
46
 
44
47
  # Parse the review output into individual findings and emit them
45
48
  findings = extract_findings(result)
@@ -68,6 +71,20 @@ module RubynCode
68
71
  })
69
72
  end
70
73
 
74
+ # Fetch skill pack context for gems detected in the repo's Gemfile.
75
+ # Returns nil on any failure — pack context is best-effort and must never
76
+ # block the review from running.
77
+ #
78
+ # @param workspace [String] absolute path to the repository
79
+ # @return [String, nil] formatted context block or nil
80
+ def build_pack_context(workspace)
81
+ context = Skills::PackContext.for_repo(project_root: workspace)
82
+ block = context.build_context_block
83
+ block.empty? ? nil : block
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
71
88
  def extract_findings(review_text)
72
89
  return [] unless review_text.is_a?(String)
73
90
 
@@ -25,7 +25,8 @@ module RubynCode
25
25
 
26
26
  # Parse a JSON string into a request hash.
27
27
  # Returns either a valid request hash or an error response hash.
28
- def parse(line) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- JSON-RPC validation checks
28
+ # -- JSON-RPC validation checks
29
+ def parse(line)
29
30
  begin
30
31
  data = JSON.parse(line)
31
32
  rescue JSON::ParserError
@@ -260,7 +260,8 @@ module RubynCode
260
260
  end
261
261
  end
262
262
 
263
- def classify_node(file, type) # rubocop:disable Metrics/CyclomaticComplexity -- Rails directory mapping
263
+ # -- Rails directory mapping
264
+ def classify_node(file, type)
264
265
  return 'model' if file.include?('app/models/')
265
266
  return 'controller' if file.include?('app/controllers/')
266
267
  return 'service' if file.include?('app/services/')
@@ -82,7 +82,8 @@ module RubynCode
82
82
  messages.map { |m| format_turn(m) }.join("\n\n")
83
83
  end
84
84
 
85
- def format_turn(msg) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- content polymorphism
85
+ # -- content polymorphism
86
+ def format_turn(msg)
86
87
  role = (msg[:role] || msg['role'] || 'unknown').capitalize
87
88
  content = msg[:content] || msg['content']
88
89
  text = if content.is_a?(Array)
@@ -121,7 +122,8 @@ module RubynCode
121
122
  )
122
123
  end
123
124
 
124
- def parse_response(response) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- response parsing with multiple fallbacks
125
+ # -- response parsing with multiple fallbacks
126
+ def parse_response(response)
125
127
  return [] if response.nil?
126
128
 
127
129
  text = if response.respond_to?(:content)
@@ -84,7 +84,8 @@ module RubynCode
84
84
  # @param task_type [Symbol]
85
85
  # @param client [LLM::Client, nil] active client (for provider checks)
86
86
  # @return [Hash] { provider:, model: }
87
- def resolve(task_type, client: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- multi-source fallback chain
87
+ # -- multi-source fallback chain
88
+ def resolve(task_type, client: nil)
88
89
  tier = tier_for(task_type)
89
90
  active = active_provider
90
91
 
@@ -51,7 +51,7 @@ module RubynCode
51
51
  klass
52
52
  end
53
53
 
54
- def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- dynamic class creation requires setting many constants
54
+ def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/MethodLength -- dynamic class creation requires setting many constants
55
55
  bridge = self
56
56
 
57
57
  Class.new(Tools::Base) do
@@ -80,7 +80,8 @@ module RubynCode
80
80
  }
81
81
  end
82
82
 
83
- def build_session_summary_lines(session_id, turns, totals) # rubocop:disable Metrics/AbcSize -- assembles multi-field summary
83
+ # -- assembles multi-field summary
84
+ def build_session_summary_lines(session_id, turns, totals)
84
85
  avg_cost = turns.positive? ? totals[:cost] / turns : 0.0
85
86
  [
86
87
  header('Session Summary'),
@@ -104,7 +105,8 @@ module RubynCode
104
105
  ).to_a
105
106
  end
106
107
 
107
- def build_daily_summary_lines(today, rows) # rubocop:disable Metrics/AbcSize -- assembles multi-field daily summary
108
+ # -- assembles multi-field daily summary
109
+ def build_daily_summary_lines(today, rows)
108
110
  total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
109
111
  total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
110
112
  total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }