rubyn-code 0.4.0 → 0.5.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +247 -9
  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 +87 -13
  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/megaplan.rb +50 -0
  14. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  15. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  16. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  17. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  18. data/lib/rubyn_code/cli/repl.rb +11 -1
  19. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  20. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  21. data/lib/rubyn_code/cli/setup.rb +13 -0
  22. data/lib/rubyn_code/config/defaults.rb +2 -0
  23. data/lib/rubyn_code/config/settings.rb +5 -2
  24. data/lib/rubyn_code/context/context_budget.rb +2 -1
  25. data/lib/rubyn_code/context/manager.rb +3 -3
  26. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  27. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  28. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  29. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  30. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  31. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
  32. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  33. data/lib/rubyn_code/ide/handlers.rb +17 -2
  34. data/lib/rubyn_code/ide/protocol.rb +17 -1
  35. data/lib/rubyn_code/ide/server.rb +39 -1
  36. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  37. data/lib/rubyn_code/learning/extractor.rb +4 -2
  38. data/lib/rubyn_code/llm/model_router.rb +2 -1
  39. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  40. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  41. data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
  42. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  43. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  44. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  45. data/lib/rubyn_code/self_test.rb +2 -1
  46. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  47. data/lib/rubyn_code/skills/catalog.rb +10 -0
  48. data/lib/rubyn_code/skills/document.rb +8 -2
  49. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  50. data/lib/rubyn_code/skills/loader.rb +1 -1
  51. data/lib/rubyn_code/skills/matcher.rb +89 -0
  52. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  53. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  54. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  55. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  56. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  57. data/lib/rubyn_code/tools/executor.rb +4 -2
  58. data/lib/rubyn_code/tools/grep.rb +2 -1
  59. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  60. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  61. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  62. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  63. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  64. data/lib/rubyn_code/tools/web_search.rb +2 -1
  65. data/lib/rubyn_code/version.rb +1 -1
  66. data/lib/rubyn_code.rb +20 -0
  67. data/skills/megaplan/megaplan.md +156 -0
  68. data/skills/rubyn_self_test.md +75 -0
  69. metadata +25 -4
@@ -95,6 +95,19 @@ module RubynCode
95
95
  #
96
96
  # To regenerate: rubyn-code --setup
97
97
  # To remove: rm #{path}
98
+ if [ ! -x "#{pinned_ruby}" ] || [ ! -f "#{gem_wrapper}" ]; then
99
+ echo "rubyn-code: launcher target missing" >&2
100
+ [ ! -x "#{pinned_ruby}" ] && echo " pinned Ruby: #{pinned_ruby}" >&2
101
+ [ ! -f "#{gem_wrapper}" ] && echo " gem wrapper: #{gem_wrapper}" >&2
102
+ echo >&2
103
+ echo "The Ruby that 'rubyn-code --setup' was pinned against (or the gem" >&2
104
+ echo "itself) was removed. To recover under your current Ruby:" >&2
105
+ echo >&2
106
+ echo " rm '$0'" >&2
107
+ echo " gem install rubyn-code" >&2
108
+ echo " rubyn-code --setup" >&2
109
+ exit 127
110
+ fi
98
111
  exec "#{pinned_ruby}" "#{gem_wrapper}" "$@"
99
112
  BASH
100
113
  end
@@ -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.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles "plan/interview/answer" — feeds the user's answer into the
7
+ # active InterviewSession, emits the next question OR the final plan
8
+ # via notifications, and returns an empty ack to the extension.
9
+ class PlanInterviewAnswerHandler
10
+ SESSION_NOT_FOUND_CODE = -32_011
11
+ INVALID_INTERVIEW_CODE = -32_010
12
+
13
+ def initialize(server)
14
+ @server = server
15
+ end
16
+
17
+ def call(params)
18
+ session_id = params['sessionId'].to_s
19
+ question_id = params['questionId'].to_s
20
+ answer = params['answer'].to_s
21
+
22
+ session = @server.lookup_interview_session(session_id)
23
+ unless session
24
+ raise Protocol::JsonRpcError.new(SESSION_NOT_FOUND_CODE,
25
+ "Unknown interview session: #{session_id}")
26
+ end
27
+
28
+ outcome = session.answer(question_id, answer)
29
+ emit_outcome(session, outcome)
30
+ {}
31
+ rescue Megaplan::InterviewSession::InvalidAnswerError => e
32
+ raise Protocol::JsonRpcError.new(Protocol::INVALID_PARAMS, e.message)
33
+ rescue Megaplan::InterviewSession::MalformedResponseError,
34
+ Megaplan::PlanProposer::InvalidProposalError => e
35
+ warn "[PlanInterviewAnswerHandler] interview failed: #{e.message}"
36
+ @server.notify('plan/interview/error', {
37
+ 'sessionId' => params['sessionId'],
38
+ 'message' => e.message
39
+ })
40
+ @server.drop_interview_session(params['sessionId'].to_s)
41
+ raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
42
+ end
43
+
44
+ private
45
+
46
+ def emit_outcome(session, outcome)
47
+ if outcome.is_a?(Megaplan::InterviewSession::Question)
48
+ @server.notify('plan/interview/question', {
49
+ 'sessionId' => session.session_id,
50
+ 'questionId' => outcome.id,
51
+ 'text' => outcome.text,
52
+ 'options' => outcome.options
53
+ })
54
+ else
55
+ @server.notify('plan/interview/done', {
56
+ 'sessionId' => session.session_id,
57
+ 'plan' => outcome
58
+ })
59
+ @server.drop_interview_session(session.session_id)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles "plan/interview/cancel" — drops the named InterviewSession.
7
+ # Notification handler (no id), so it never sends a response. Unknown
8
+ # sessionIds are no-ops; the extension treats cancel as best-effort.
9
+ class PlanInterviewCancelHandler
10
+ def initialize(server)
11
+ @server = server
12
+ end
13
+
14
+ def call(params)
15
+ session_id = params['sessionId'].to_s
16
+ @server.drop_interview_session(session_id)
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles "plan/interview/start" — kicks off a megaplan interview
7
+ # session. Creates the InterviewSession, fires the first LLM turn,
8
+ # and emits either a plan/interview/question or plan/interview/done
9
+ # notification before returning the new sessionId to the extension.
10
+ class PlanInterviewStartHandler
11
+ INVALID_INTERVIEW_CODE = -32_010
12
+
13
+ def initialize(server, factory: nil)
14
+ @server = server
15
+ @factory = factory || ->(workspace_path:) {
16
+ Megaplan::InterviewSession.new(workspace_path: workspace_path)
17
+ }
18
+ end
19
+
20
+ def call(_params)
21
+ session = @factory.call(workspace_path: @server.workspace_path || Dir.pwd)
22
+ @server.register_interview_session(session)
23
+ outcome = session.start
24
+ emit_outcome(session, outcome)
25
+ { 'sessionId' => session.session_id }
26
+ rescue Megaplan::InterviewSession::MalformedResponseError,
27
+ Megaplan::PlanProposer::InvalidProposalError => e
28
+ warn "[PlanInterviewStartHandler] interview failed: #{e.message}"
29
+ raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
30
+ end
31
+
32
+ private
33
+
34
+ def emit_outcome(session, outcome)
35
+ if outcome.is_a?(Megaplan::InterviewSession::Question)
36
+ @server.notify('plan/interview/question', {
37
+ 'sessionId' => session.session_id,
38
+ 'questionId' => outcome.id,
39
+ 'text' => outcome.text,
40
+ 'options' => outcome.options
41
+ })
42
+ else
43
+ @server.notify('plan/interview/done', {
44
+ 'sessionId' => session.session_id,
45
+ 'plan' => outcome
46
+ })
47
+ @server.drop_interview_session(session.session_id)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles the "plan/propose" JSON-RPC request.
9
+ #
10
+ # Synchronous: blocks until the LLM returns a structured plan or
11
+ # raises. The extension shows a progress spinner during the call;
12
+ # plan generation typically takes 5-30s.
13
+ #
14
+ # Response shape mirrors what the extension's PlanManager.toPlan
15
+ # expects: { slug, feature, phases: [{ number, name, slug, summary,
16
+ # requirements_md, design_md, tasks_md }] }.
17
+ class PlanProposeHandler
18
+ # JSON-RPC error code: a clear signal to the extension that the
19
+ # LLM produced an unparseable / malformed plan_proposal. The
20
+ # extension surfaces this with the actual error message.
21
+ INVALID_PROPOSAL_CODE = -32_001
22
+
23
+ def initialize(server, proposer: nil)
24
+ @server = server
25
+ @proposer = proposer
26
+ end
27
+
28
+ def call(params)
29
+ feature = params['feature'].to_s.strip
30
+ raise Protocol::JsonRpcError.new(Protocol::INVALID_PARAMS, 'feature is required') if feature.empty?
31
+
32
+ proposer = @proposer || Megaplan::PlanProposer.new
33
+ proposer.propose(feature)
34
+ rescue Megaplan::PlanProposer::InvalidProposalError => e
35
+ warn "[PlanProposeHandler] invalid proposal: #{e.message}"
36
+ raise Protocol::JsonRpcError.new(INVALID_PROPOSAL_CODE, e.message)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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']
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Handlers
8
+ # Handles the "recover_ci" JSON-RPC request.
9
+ #
10
+ # Takes the recovery context the extension packaged (failing log,
11
+ # phase docs, branch, attempt counts) and runs the agent against
12
+ # it. Returns the recovery_outcome { kind, commit_sha?, summary? }.
13
+ #
14
+ # The handler spawns the actual agent work in a background thread
15
+ # so the JSON-RPC reply can include a sessionId immediately. The
16
+ # extension watches the session via the existing agent/status
17
+ # notifications + a terminal recovery/outcome notification carrying
18
+ # the structured result.
19
+ class RecoverCiHandler
20
+ def initialize(server, recovery: nil)
21
+ @server = server
22
+ @recovery = recovery
23
+ end
24
+
25
+ def call(params)
26
+ context = normalize_params(params)
27
+ return error_response('context required') if context.nil?
28
+
29
+ session_id = params['sessionId'] || SecureRandom.uuid
30
+ Thread.new do
31
+ run_recovery(session_id, context)
32
+ end
33
+ { 'accepted' => true, 'sessionId' => session_id }
34
+ end
35
+
36
+ private
37
+
38
+ def run_recovery(session_id, context)
39
+ @server.notify('agent/status', {
40
+ 'sessionId' => session_id,
41
+ 'status' => 'recovering',
42
+ 'phaseNumber' => context['phase_number'],
43
+ 'attemptNumber' => context['attempt_number']
44
+ })
45
+
46
+ recovery = @recovery || Megaplan::CiRecovery.new(
47
+ agent_invoker: build_invoker(session_id)
48
+ )
49
+ outcome = recovery.recover(context)
50
+
51
+ @server.notify('recovery/outcome', {
52
+ 'sessionId' => session_id,
53
+ 'planId' => context['plan_id'],
54
+ 'phaseNumber' => context['phase_number'],
55
+ 'kind' => outcome['kind'],
56
+ 'commitSha' => outcome['commit_sha'],
57
+ 'summary' => outcome['summary']
58
+ })
59
+
60
+ @server.notify('agent/status', {
61
+ 'sessionId' => session_id,
62
+ 'status' => 'done',
63
+ 'summary' => outcome['summary']
64
+ })
65
+ rescue StandardError => e
66
+ warn "[RecoverCiHandler] error: #{e.message}"
67
+ @server.notify('recovery/outcome', {
68
+ 'sessionId' => session_id,
69
+ 'planId' => context['plan_id'],
70
+ 'phaseNumber' => context['phase_number'],
71
+ 'kind' => 'errored',
72
+ 'summary' => e.message
73
+ })
74
+ @server.notify('agent/status', {
75
+ 'sessionId' => session_id,
76
+ 'status' => 'error',
77
+ 'error' => e.message
78
+ })
79
+ end
80
+
81
+ # Build an agent invoker that routes the recovery prompt through
82
+ # the same Agent::Loop the existing PromptHandler uses, but with
83
+ # the streaming text wired back as agent/status notifications
84
+ # tagged with the recovery session id.
85
+ def build_invoker(session_id)
86
+ lambda do |prompt, _context|
87
+ llm_client = LLM::Client.new
88
+ response = llm_client.chat(
89
+ messages: [{ role: 'user', content: prompt }],
90
+ on_text: ->(text) {
91
+ @server.notify('stream/text', {
92
+ 'sessionId' => session_id,
93
+ 'text' => text,
94
+ 'final' => false
95
+ })
96
+ }
97
+ )
98
+ response.is_a?(Hash) ? response : { 'text' => response.to_s }
99
+ end
100
+ end
101
+
102
+ def normalize_params(params)
103
+ return nil unless params.is_a?(Hash)
104
+
105
+ # Accept both snake_case and camelCase keys — the extension
106
+ # produces camelCase, but the LLM-side service uses snake_case
107
+ # internally. Normalize once at the boundary.
108
+ mapping = {
109
+ 'planId' => 'plan_id',
110
+ 'phaseNumber' => 'phase_number',
111
+ 'prNumber' => 'pr_number',
112
+ 'failingCheckName' => 'failing_check_name',
113
+ 'fullLog' => 'full_log',
114
+ 'trimmedLog' => 'trimmed_log',
115
+ 'commitSha' => 'commit_sha',
116
+ 'attemptNumber' => 'attempt_number',
117
+ 'maxAttempts' => 'max_attempts'
118
+ }
119
+ out = params.dup
120
+ mapping.each do |camel, snake|
121
+ out[snake] = out.delete(camel) if out.key?(camel) && !out.key?(snake)
122
+ end
123
+ out
124
+ end
125
+
126
+ def error_response(message, code: -32_602)
127
+ { 'error' => { 'code' => code, 'message' => message } }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -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
 
@@ -14,6 +14,11 @@ require_relative 'handlers/session_reset_handler'
14
14
  require_relative 'handlers/session_list_handler'
15
15
  require_relative 'handlers/session_resume_handler'
16
16
  require_relative 'handlers/session_fork_handler'
17
+ require_relative 'handlers/plan_propose_handler'
18
+ require_relative 'handlers/plan_interview_start_handler'
19
+ require_relative 'handlers/plan_interview_answer_handler'
20
+ require_relative 'handlers/plan_interview_cancel_handler'
21
+ require_relative 'handlers/recover_ci_handler'
17
22
 
18
23
  module RubynCode
19
24
  module IDE
@@ -33,7 +38,12 @@ module RubynCode
33
38
  'session/reset' => SessionResetHandler,
34
39
  'session/list' => SessionListHandler,
35
40
  'session/resume' => SessionResumeHandler,
36
- 'session/fork' => SessionForkHandler
41
+ 'session/fork' => SessionForkHandler,
42
+ 'plan/propose' => PlanProposeHandler,
43
+ 'plan/interview/start' => PlanInterviewStartHandler,
44
+ 'plan/interview/answer' => PlanInterviewAnswerHandler,
45
+ 'plan/interview/cancel' => PlanInterviewCancelHandler,
46
+ 'recover_ci' => RecoverCiHandler
37
47
  }.freeze
38
48
 
39
49
  # Short name => method name mapping (for handler_instance lookups).
@@ -51,7 +61,12 @@ module RubynCode
51
61
  session_reset: 'session/reset',
52
62
  session_list: 'session/list',
53
63
  session_resume: 'session/resume',
54
- session_fork: 'session/fork'
64
+ session_fork: 'session/fork',
65
+ plan_propose: 'plan/propose',
66
+ plan_interview_start: 'plan/interview/start',
67
+ plan_interview_answer: 'plan/interview/answer',
68
+ plan_interview_cancel: 'plan/interview/cancel',
69
+ recover_ci: 'recover_ci'
55
70
  }.freeze
56
71
 
57
72
  # Register all handlers on the given server instance.
@@ -21,11 +21,27 @@ module RubynCode
21
21
  SESSION_NOT_FOUND = -2
22
22
  BUDGET_EXCEEDED = -3
23
23
 
24
+ # Raise from a handler to emit a JSON-RPC error response with a
25
+ # specific code. The dispatcher catches this and converts it to a
26
+ # proper `error` envelope — handlers don't have to know how to write
27
+ # to the wire. Use this instead of returning `{ 'error' => ... }` as
28
+ # the handler result, which the dispatcher would otherwise serialize
29
+ # as a successful `result` payload (the very bug this class fixes).
30
+ class JsonRpcError < StandardError
31
+ attr_reader :code
32
+
33
+ def initialize(code, message)
34
+ super(message)
35
+ @code = code
36
+ end
37
+ end
38
+
24
39
  module_function
25
40
 
26
41
  # Parse a JSON string into a request hash.
27
42
  # 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
43
+ # -- JSON-RPC validation checks
44
+ def parse(line)
29
45
  begin
30
46
  data = JSON.parse(line)
31
47
  rescue JSON::ParserError
@@ -21,7 +21,7 @@ module RubynCode
21
21
  :permission_mode
22
22
  attr_reader :ide_client
23
23
 
24
- def initialize(permission_mode: :default, yolo: false)
24
+ def initialize(permission_mode: :default, yolo: false, workspace_path: nil)
25
25
  @permission_mode = yolo ? :bypass : permission_mode.to_sym
26
26
  @running = false
27
27
  @write_mutex = Mutex.new
@@ -33,10 +33,45 @@ module RubynCode
33
33
  @session_persistence = nil
34
34
  @tool_output_adapter = nil
35
35
  @ide_client = Client.new(self)
36
+ @interview_sessions = {}
37
+
38
+ apply_initial_workspace(workspace_path)
36
39
 
37
40
  Handlers.register_all(self)
38
41
  end
39
42
 
43
+ # ── Interview session registry ──────────────────────────────────
44
+ # Owned by the IDE Server so handlers can look up the same session
45
+ # across start / answer / cancel JSON-RPC calls.
46
+
47
+ def register_interview_session(session)
48
+ @interview_sessions[session.session_id] = session
49
+ end
50
+
51
+ def lookup_interview_session(session_id)
52
+ @interview_sessions[session_id]
53
+ end
54
+
55
+ def drop_interview_session(session_id)
56
+ @interview_sessions.delete(session_id)
57
+ end
58
+
59
+ # Adopt a workspace path supplied on the command line (--dir). Done
60
+ # before the `initialize` JSON-RPC handshake so tools that resolve
61
+ # their project_root at construction time don't fall back to Dir.pwd
62
+ # — which in some launch contexts (Docker, double-clicked VS Code on
63
+ # macOS) is something useless like `/app` or `/`.
64
+ def apply_initial_workspace(path)
65
+ return unless path && !path.empty?
66
+
67
+ if Dir.exist?(path)
68
+ Dir.chdir(path)
69
+ @workspace_path = path
70
+ else
71
+ warn "[IDE::Server] --dir path does not exist, ignoring: #{path}"
72
+ end
73
+ end
74
+
40
75
  # Backward-compatible reader: true when permission_mode is :bypass.
41
76
  def yolo
42
77
  @permission_mode == :bypass
@@ -107,6 +142,9 @@ module RubynCode
107
142
  end
108
143
 
109
144
  dispatch(msg)
145
+ rescue Protocol::JsonRpcError => e
146
+ id = msg.is_a?(Hash) ? msg['id'] : nil
147
+ write(Protocol.error(id, e.code, e.message)) if id
110
148
  rescue StandardError => e
111
149
  warn "[IDE::Server] error handling message: #{e.message}"
112
150
  warn e.backtrace&.first(5)&.join("\n")
@@ -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