rubyn-code 0.5.0 → 0.7.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -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 || lambda { |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
@@ -57,7 +57,15 @@ module RubynCode
57
57
  return unless thread&.alive?
58
58
 
59
59
  thread.raise(Interrupt)
60
- thread.join(2) # give it a moment to clean up
60
+ begin
61
+ thread.join(2) # give it a moment to clean up
62
+ rescue Interrupt
63
+ # We asked the thread to stop, so its Interrupt is expected. If it
64
+ # lands before the agent installs its own rescue, Thread#join would
65
+ # otherwise re-raise it into the caller (the cancel path) and, in
66
+ # tests, escape the example and abort the whole run.
67
+ nil
68
+ end
61
69
  end
62
70
 
63
71
  private
@@ -0,0 +1,143 @@
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
+ notify_recovering(session_id, context)
40
+
41
+ recovery = @recovery || Megaplan::CiRecovery.new(
42
+ agent_invoker: build_invoker(session_id)
43
+ )
44
+ outcome = recovery.recover(context)
45
+
46
+ notify_outcome(session_id, context, outcome)
47
+ @server.notify('agent/status', {
48
+ 'sessionId' => session_id,
49
+ 'status' => 'done',
50
+ 'summary' => outcome['summary']
51
+ })
52
+ rescue StandardError => e
53
+ warn "[RecoverCiHandler] error: #{e.message}"
54
+ notify_error(session_id, context, e)
55
+ end
56
+
57
+ def notify_recovering(session_id, context)
58
+ @server.notify('agent/status', {
59
+ 'sessionId' => session_id,
60
+ 'status' => 'recovering',
61
+ 'phaseNumber' => context['phase_number'],
62
+ 'attemptNumber' => context['attempt_number']
63
+ })
64
+ end
65
+
66
+ def notify_outcome(session_id, context, outcome)
67
+ @server.notify('recovery/outcome', {
68
+ 'sessionId' => session_id,
69
+ 'planId' => context['plan_id'],
70
+ 'phaseNumber' => context['phase_number'],
71
+ 'kind' => outcome['kind'],
72
+ 'commitSha' => outcome['commit_sha'],
73
+ 'summary' => outcome['summary']
74
+ })
75
+ end
76
+
77
+ def notify_error(session_id, context, error)
78
+ @server.notify('recovery/outcome', {
79
+ 'sessionId' => session_id,
80
+ 'planId' => context['plan_id'],
81
+ 'phaseNumber' => context['phase_number'],
82
+ 'kind' => 'errored',
83
+ 'summary' => error.message
84
+ })
85
+ @server.notify('agent/status', {
86
+ 'sessionId' => session_id,
87
+ 'status' => 'error',
88
+ 'error' => error.message
89
+ })
90
+ end
91
+
92
+ # Build an agent invoker that routes the recovery prompt through
93
+ # the same Agent::Loop the existing PromptHandler uses, but with
94
+ # the streaming text wired back as agent/status notifications
95
+ # tagged with the recovery session id.
96
+ def build_invoker(session_id)
97
+ lambda do |prompt, _context|
98
+ llm_client = LLM::Client.new
99
+ response = llm_client.chat(
100
+ messages: [{ role: 'user', content: prompt }],
101
+ on_text: lambda { |text|
102
+ @server.notify('stream/text', {
103
+ 'sessionId' => session_id,
104
+ 'text' => text,
105
+ 'final' => false
106
+ })
107
+ }
108
+ )
109
+ response.is_a?(Hash) ? response : { 'text' => response.to_s }
110
+ end
111
+ end
112
+
113
+ def normalize_params(params)
114
+ return nil unless params.is_a?(Hash)
115
+
116
+ # Accept both snake_case and camelCase keys — the extension
117
+ # produces camelCase, but the LLM-side service uses snake_case
118
+ # internally. Normalize once at the boundary.
119
+ mapping = {
120
+ 'planId' => 'plan_id',
121
+ 'phaseNumber' => 'phase_number',
122
+ 'prNumber' => 'pr_number',
123
+ 'failingCheckName' => 'failing_check_name',
124
+ 'fullLog' => 'full_log',
125
+ 'trimmedLog' => 'trimmed_log',
126
+ 'commitSha' => 'commit_sha',
127
+ 'attemptNumber' => 'attempt_number',
128
+ 'maxAttempts' => 'max_attempts'
129
+ }
130
+ out = params.dup
131
+ mapping.each do |camel, snake|
132
+ out[snake] = out.delete(camel) if out.key?(camel) && !out.key?(snake)
133
+ end
134
+ out
135
+ end
136
+
137
+ def error_response(message, code: -32_602)
138
+ { 'error' => { 'code' => code, 'message' => message } }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -30,7 +30,7 @@ module RubynCode
30
30
  prompt = @server.handler_instance(:prompt)
31
31
  if prompt
32
32
  conversation = Agent::Conversation.new
33
- messages.each { |msg| conversation.messages << msg }
33
+ conversation.replace!(messages.dup)
34
34
  prompt.inject_conversation(session_id, conversation)
35
35
  end
36
36
 
@@ -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,6 +21,21 @@ 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.
@@ -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")
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'open3'
5
6
 
6
7
  module RubynCode
7
8
  module Index
@@ -42,7 +43,9 @@ module RubynCode
42
43
 
43
44
  data = JSON.parse(File.read(@index_path))
44
45
  @nodes = data['nodes'] || []
45
- @edges = data['edges'] || []
46
+ # uniq drops duplicate edges accumulated by older versions, which
47
+ # appended tests edges on every update! without dedup.
48
+ @edges = (data['edges'] || []).uniq
46
49
  @file_mtimes = data['file_mtimes'] || {}
47
50
  self
48
51
  rescue StandardError
@@ -69,6 +72,19 @@ module RubynCode
69
72
  self
70
73
  end
71
74
 
75
+ # Incremental update for a single known-changed file (e.g. after a
76
+ # write_file/edit_file tool call). Avoids the full-tree scan in update!.
77
+ def update_file!(path)
78
+ absolute = File.expand_path(path, @project_root)
79
+ return self unless absolute.start_with?("#{@project_root}/")
80
+
81
+ remove_nodes_for(absolute)
82
+ index_file(absolute) if File.exist?(absolute)
83
+ extract_rails_edges
84
+ save!
85
+ self
86
+ end
87
+
72
88
  # Query the index for symbols matching a search term.
73
89
  def query(term)
74
90
  pattern = term.to_s.downcase
@@ -192,6 +208,26 @@ module RubynCode
192
208
  end
193
209
 
194
210
  def ruby_files
211
+ git_ruby_files || glob_ruby_files
212
+ end
213
+
214
+ # Prefer git's file list when available: it skips ignored dirs
215
+ # (tmp/, log/, coverage/, db/) the glob would index. --others picks up
216
+ # untracked-but-not-ignored files so freshly created ones still appear.
217
+ def git_ruby_files
218
+ return nil unless File.exist?(File.join(@project_root, '.git'))
219
+
220
+ stdout, status = Open3.capture2(
221
+ 'git', '-C', @project_root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.rb'
222
+ )
223
+ return nil unless status.success?
224
+
225
+ stdout.split("\0").map { |f| File.join(@project_root, f) }.select { |f| File.file?(f) }
226
+ rescue StandardError
227
+ nil
228
+ end
229
+
230
+ def glob_ruby_files
195
231
  Dir.glob(File.join(@project_root, '**', '*.rb'))
196
232
  .reject { |f| f.include?('/vendor/') || f.include?('/node_modules/') }
197
233
  end
@@ -253,6 +289,8 @@ module RubynCode
253
289
  end
254
290
 
255
291
  def extract_rails_edges
292
+ # Rebuild tests edges from scratch so repeated updates stay idempotent.
293
+ @edges.reject! { |e| e['relationship'] == 'tests' }
256
294
  spec_files = @file_mtimes.keys.select { |f| f.include?('spec/') || f.include?('test/') }
257
295
  spec_files.each do |spec_file|
258
296
  source = spec_file.sub(%r{spec/}, 'app/').sub(/_spec\.rb$/, '.rb')