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 +4 -4
- data/README.md +62 -8
- data/lib/rubyn_code/cli/app.rb +2 -2
- data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +15 -0
- data/lib/rubyn_code/ide/server.rb +39 -1
- data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +8 -0
- data/skills/megaplan/megaplan.md +156 -0
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b43dc2a8fab138bcfe303180881aa9fa23878faaa532367f07a0fee7605feb34
|
|
4
|
+
data.tar.gz: 12dffaa487a75dfe276be9d20ff8152814bd60c6b107efd9ed10d2993afd57a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
90
|
-
RBENV_VERSION
|
|
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
|
|
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
|
|
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
|
data/lib/rubyn_code/cli/app.rb
CHANGED
|
@@ -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
|
|
data/lib/rubyn_code/cli/setup.rb
CHANGED
|
@@ -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.
|