rubyn-code 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 342d51b0944ce35908b8fd56f52b0321cf698ea01ec3cc8fa572d92b9c332b22
4
- data.tar.gz: '033868c98fedc4814cb9e8c94eb8f013b15641d5a3e7cecf7e819d3d3b57f771'
3
+ metadata.gz: b43dc2a8fab138bcfe303180881aa9fa23878faaa532367f07a0fee7605feb34
4
+ data.tar.gz: 12dffaa487a75dfe276be9d20ff8152814bd60c6b107efd9ed10d2993afd57a5
5
5
  SHA512:
6
- metadata.gz: 283cff44827418497c9ed9d27e4331d8fdf24c04825ee3f28e46a33da28bf61f65d0a2b85173187947393856053fad5466dce5782fcef3d0e1dcce68d37e00eb
7
- data.tar.gz: 6c96e7beba37f74c0fe947470a1a37d50b521087b573407d6ba7d0d0649d71385b263b70b126fe9c2b7da3c910a233862fdbfb8c1d7c534db6f6cc1d813ec305
6
+ metadata.gz: fcbe574a607028345e38587388a6c0bd9cdb8ab134e690d45583a1a27b4c698383bd43088eb8fa537cddba8690e77d15ab155be92fb997117c8fe62a978df211
7
+ data.tar.gz: 43e72475ec24edf03f21e4aafa4402de31351728a38de42e55b69db104aea1828b930a1cafb79d3065d07e16f84c8a5a5023311da5d1e40f7a667daa90ddee3c
data/README.md CHANGED
@@ -35,9 +35,11 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
35
35
  - [MCP — External Tool Servers](#mcp--external-tool-servers)
36
36
  - [Codebase Indexing](#codebase-indexing)
37
37
  - [112 Best Practice Skills](#112-best-practice-skills)
38
+ - [Skill Packs — Registry-Backed Extensions](#skill-packs--registry-backed-extensions)
38
39
  - [Context Architecture](#context-architecture)
39
40
  - [RUBYN.md — Project Instructions](#rubynmd--project-instructions)
40
41
  - [PR Review](#pr-review)
42
+ - [Megaplan — Phased Planning](#megaplan--phased-planning)
41
43
  - [Sub-Agents & Teams](#sub-agents--teams)
42
44
  - [GOLEM — Autonomous Daemon](#golem--autonomous-daemon)
43
45
  - [Continuous Learning](#continuous-learning)
@@ -61,14 +63,15 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
61
63
 
62
64
  - **Rails-native** — understands service object extraction, RSpec conventions, ActiveRecord patterns, and Hotwire
63
65
  - **Context-aware** — automatically incorporates schema, routes, specs, factories, and models
64
- - **Best practices built in** — ships with 112 curated Ruby and Rails guidelines that load on demand
66
+ - **Best practices built in** — ships with 112 curated Ruby and Rails guidelines that load on demand, plus registry-backed [skill packs](#skill-packs--registry-backed-extensions) that autoload as you need them
67
+ - **Plans big work in phases** — [`/megaplan`](#megaplan--phased-planning) runs a read-only interview, then breaks rewrites and migrations into vertical-slice phases that ship one at a time
65
68
  - **Agentic** — doesn't just answer questions. Reads files, writes code, runs specs, commits, reviews PRs, spawns sub-agents, and remembers what it learns
66
69
  - **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
67
70
  - **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
68
71
 
69
72
  ## Install
70
73
 
71
- Requires **Ruby 4.0+**. Install with your latest Ruby, then pin it so it works in every project:
74
+ Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
72
75
 
73
76
  ```bash
74
77
  # Install the gem
@@ -83,11 +86,11 @@ That's it. `rubyn-code` now works in any project regardless of `.ruby-version`.
83
86
  <details>
84
87
  <summary>Using rbenv?</summary>
85
88
 
86
- If you manage multiple Rubies with rbenv, install on your latest:
89
+ If you manage multiple Rubies with rbenv, install on your latest (run `rbenv versions` to list what you have):
87
90
 
88
91
  ```bash
89
- RBENV_VERSION=4.0.2 gem install rubyn-code
90
- RBENV_VERSION=4.0.2 rubyn-code --setup
92
+ RBENV_VERSION=<your-ruby-version> gem install rubyn-code
93
+ RBENV_VERSION=<your-ruby-version> rubyn-code --setup
91
94
  ```
92
95
 
93
96
  The `--setup` command creates a launcher in `~/.local/bin` that calls the gem wrapper directly, skipping rbenv's shim. As long as `~/.local/bin` is in your PATH before `~/.rbenv/shims`, you're good.
@@ -98,7 +101,7 @@ The `--setup` command creates a launcher in `~/.local/bin` that calls the gem wr
98
101
  <summary>Using rvm?</summary>
99
102
 
100
103
  ```bash
101
- rvm use 4.0.2
104
+ rvm use <your-ruby-version>
102
105
  gem install rubyn-code
103
106
  rubyn-code --setup
104
107
  ```
@@ -203,7 +206,7 @@ Rubyn Code includes a VS Code extension that provides a full IDE experience with
203
206
  | `default` | Per-tool approval required |
204
207
  | `bypass` | YOLO — skip all approval prompts |
205
208
 
206
- The extension communicates over 14 RPC methods: `initialize`, `prompt`, `cancel`, `review`, `approveToolUse`, `acceptEdit`, `session/*`, `config/*`, `models/list`, and `shutdown`.
209
+ The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`, `review`, `approveToolUse`, `acceptEdit`, `session/*`, `config/*`, `models/list`, `plan/propose`, `plan/interview/*` (chat-resident [megaplan](#megaplan--phased-planning)), `recover_ci`, and `shutdown`.
207
210
 
208
211
  ## 29 Built-in Tools
209
212
 
@@ -316,6 +319,29 @@ mkdir -p ~/.rubyn-code/skills
316
319
  echo "# Use double quotes for strings" > ~/.rubyn-code/skills/my_style.md
317
320
  ```
318
321
 
322
+ ## Skill Packs — Registry-Backed Extensions
323
+
324
+ Beyond the 112 built-in skills, Rubyn can pull additional skill packs from the [rubyn.ai](https://rubyn.ai) registry. Packs are bundles of related skills published by the community or by Rubyn itself.
325
+
326
+ ```
327
+ rubyn > /skills # list installed packs and browse the registry
328
+ rubyn > /install-skills sidekiq # install a pack by name
329
+ rubyn > /install-skills graphql viewcomponent # install multiple at once
330
+ rubyn > /remove-skills sidekiq # uninstall
331
+ ```
332
+
333
+ Installed packs live at `~/.rubyn-code/skill-packs/<pack-name>/` and load alongside the built-in catalog.
334
+
335
+ ### Auto-suggest from your Gemfile
336
+
337
+ On session start, Rubyn parses your `Gemfile` and quietly suggests matching packs the first time it sees a gem (e.g. detects `sidekiq` → suggests the sidekiq pack). Suggestions are recorded in `.rubyn-code/suggested.json` so you only see each one once.
338
+
339
+ ### Trigger-based autoload
340
+
341
+ If your message mentions a topic that matches an uninstalled pack's name or tags, Rubyn fetches the pack from the registry on the fly, installs it, and feeds the relevant skills into the same turn. Registry failures are silent — the conversation continues as if the autoload weren't there.
342
+
343
+ Point at a custom registry with `RUBYN_REGISTRY_URL=https://your-registry.example.com`.
344
+
319
345
  ## Context Architecture
320
346
 
321
347
  Rubyn automatically loads relevant context based on what you're working on:
@@ -364,6 +390,34 @@ Focus areas: `all`, `security`, `performance`, `style`, `testing`
364
390
 
365
391
  Severity ratings: **[critical]** **[warning]** **[suggestion]** **[nitpick]**
366
392
 
393
+ ## Megaplan — Phased Planning
394
+
395
+ For work too big for a single PR — rewrites, migrations, multi-feature initiatives — Rubyn ships a planning workflow that breaks the feature into vertical-slice phases before any code gets written.
396
+
397
+ ```
398
+ rubyn > /megaplan extract billing into its own service
399
+ Megaplan mode — interviewer with read-only tools
400
+
401
+ Decisions so far: (none yet)
402
+
403
+ Q1. What triggers the extraction now — a scaling issue, a team boundary,
404
+ or a compliance constraint?
405
+ 1. Scaling (recommended — billing is the hottest table)
406
+ 2. Team boundary
407
+ 3. Compliance
408
+ ```
409
+
410
+ What happens when you run `/megaplan`:
411
+
412
+ - Loads the **megaplan** skill into context.
413
+ - Flips the agent into **plan mode** — only read-only tools (file reads, search, git status) are available. No edits, no shell mutations.
414
+ - Conducts a one-question-at-a-time interview to lock down scope, constraints, and risk before proposing phases.
415
+ - Outputs a numbered phase breakdown, each phase shippable on its own with the trunk staying green.
416
+
417
+ Trigger phrases like "megaplan", "mega plan", "plan phases", or "phase this out" in normal conversation will surface the skill via [trigger-based autoload](#skill-packs--registry-backed-extensions) too.
418
+
419
+ In the VS Code extension the same workflow runs as a chat-resident interview with structured question cards instead of free-text Q&A. Same skill driving both surfaces.
420
+
367
421
  ## Sub-Agents & Teams
368
422
 
369
423
  ### Sub-Agents (disposable)
@@ -707,7 +761,7 @@ Checks Ruby version, bundler, database state, authentication, skills, project ty
707
761
 
708
762
  ## Development
709
763
 
710
- Requires Ruby 4.0+.
764
+ Requires Ruby 4.0.2+.
711
765
 
712
766
  ```bash
713
767
  git clone https://github.com/MatthewSuttles/rubyn-code.git
@@ -64,7 +64,7 @@ module RubynCode
64
64
  '--auth' => :auth, '--setup' => :setup
65
65
  }.freeze
66
66
  BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
67
- VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
67
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode, '--dir' => :workspace_dir }.freeze
68
68
  DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
69
69
  '--poll-interval' => :poll_interval }.freeze
70
70
  DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
@@ -209,7 +209,7 @@ module RubynCode
209
209
 
210
210
  def run_ide
211
211
  mode = resolve_permission_mode
212
- IDE::Server.new(permission_mode: mode).run
212
+ IDE::Server.new(permission_mode: mode, workspace_path: @options[:workspace_dir]).run
213
213
  end
214
214
 
215
215
  def run_daemon
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/megaplan` REPL command — mirrors the VS Code chat's chat-resident
7
+ # megaplan entry. Loads the megaplan skill (from the shared
8
+ # skills catalog) into the conversation, flips plan mode ON so the
9
+ # agent is restricted to read-only tools, and kicks off a
10
+ # conversational interview.
11
+ #
12
+ # The interview itself is chat-style here (multi-turn natural-
13
+ # language Q&A), not the structured question-card UI the VS Code
14
+ # extension uses. Same skill content driving both surfaces.
15
+ class Megaplan < Base
16
+ def self.command_name = '/megaplan'
17
+ def self.description = 'Plan a feature in phases (interview, then numbered phase docs)'
18
+ def self.aliases = ['/mega-plan'].freeze
19
+
20
+ def execute(args, ctx)
21
+ content = ctx.skill_loader.load('megaplan')
22
+ ctx.conversation.add_user_message("<skill>#{content}</skill>")
23
+ ctx.renderer.info('Megaplan mode — interviewer with read-only tools 🧠')
24
+
25
+ ctx.send_message(build_prompt(args.join(' ').strip))
26
+
27
+ { action: :set_plan_mode, enabled: true }
28
+ rescue StandardError => e
29
+ ctx.renderer.error("Megaplan error: #{e.message}")
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def build_prompt(feature)
36
+ base = <<~PROMPT.strip
37
+ Conduct a megaplan interview, following the megaplan skill loaded above.
38
+ Stay strictly read-only: you may inspect files, search code, and check
39
+ git state, but do NOT edit, write, run shell mutations, or call any
40
+ destructive tool. Ask ONE question at a time. When you have enough,
41
+ output the final phase breakdown as a numbered outline.
42
+ PROMPT
43
+ return base if feature.empty?
44
+
45
+ "#{base}\n\nThe feature to plan: #{feature}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -23,7 +23,8 @@ module RubynCode
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
24
  Commands::Model, Commands::NewSession, Commands::Mcp,
25
25
  Commands::Provider, Commands::InstallSkills,
26
- Commands::RemoveSkills, Commands::Skills
26
+ Commands::RemoveSkills, Commands::Skills,
27
+ Commands::Megaplan
27
28
  ].each { |cmd| @command_registry.register(cmd) }
28
29
  end
29
30
 
@@ -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
@@ -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
@@ -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
@@ -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.