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
|
@@ -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")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Megaplan
|
|
5
|
+
# Drives one CI recovery attempt on a Rubyn-opened PR.
|
|
6
|
+
#
|
|
7
|
+
# Receives the failure context the extension's CiWatcher packaged
|
|
8
|
+
# (trimmed log + phase docs + branch + attempt counts) and asks the
|
|
9
|
+
# agent to push a fix commit. Returns a `recovery_outcome` shape:
|
|
10
|
+
#
|
|
11
|
+
# { kind: 'fixed' | 'no_fix' | 'errored', commit_sha?, summary? }
|
|
12
|
+
#
|
|
13
|
+
# 'fixed' means the agent identified and committed a fix on the
|
|
14
|
+
# branch. 'no_fix' means the agent looked but couldn't see an
|
|
15
|
+
# obvious correctness fix in the log (escalate to the human).
|
|
16
|
+
# 'errored' means the agent loop itself crashed.
|
|
17
|
+
class CiRecovery
|
|
18
|
+
class RecoveryError < RubynCode::Error; end
|
|
19
|
+
|
|
20
|
+
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
21
|
+
You are Rubyn doing CI auto-recovery on a megaplan PR.
|
|
22
|
+
|
|
23
|
+
Read the failing job log, identify the root cause, push a fix
|
|
24
|
+
commit to the existing branch. Keep the diff minimal and focused
|
|
25
|
+
— this is not a refactoring opportunity.
|
|
26
|
+
|
|
27
|
+
If you can't identify a concrete fix from the log, output exactly:
|
|
28
|
+
|
|
29
|
+
NO_FIX_IDENTIFIED: <one-sentence reason>
|
|
30
|
+
|
|
31
|
+
Do not invent fixes. Do not "try something" just to try. A clean
|
|
32
|
+
escalation to the human beats a wrong commit any day.
|
|
33
|
+
PROMPT
|
|
34
|
+
|
|
35
|
+
def initialize(agent_invoker: nil)
|
|
36
|
+
@agent_invoker = agent_invoker || method(:default_agent_invoker)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param context [Hash] the recovery_ci payload from the extension
|
|
40
|
+
# @return [Hash] recovery_outcome { kind, commit_sha?, summary? }
|
|
41
|
+
def recover(context)
|
|
42
|
+
validate!(context)
|
|
43
|
+
prompt = build_prompt(context)
|
|
44
|
+
result = @agent_invoker.call(prompt, context)
|
|
45
|
+
interpret(result, context)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
{ 'kind' => 'errored', 'summary' => e.message }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate!(context)
|
|
53
|
+
raise ArgumentError, 'context required' unless context.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
%w[plan_id phase_number branch pr_number trimmed_log attempt_number max_attempts].each do |key|
|
|
56
|
+
raise ArgumentError, "missing #{key}" if context[key].nil?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_prompt(context)
|
|
61
|
+
phase = context['phase'] || {}
|
|
62
|
+
<<~PROMPT
|
|
63
|
+
Auto-recovery attempt #{context['attempt_number']} of #{context['max_attempts']}.
|
|
64
|
+
|
|
65
|
+
**PR:** ##{context['pr_number']}
|
|
66
|
+
**Branch:** `#{context['branch']}`
|
|
67
|
+
**Failing check:** #{context['failing_check_name'] || 'unknown'}
|
|
68
|
+
**Commit SHA:** #{context['commit_sha']}
|
|
69
|
+
|
|
70
|
+
**Phase #{context['phase_number']} — #{phase['name']}**
|
|
71
|
+
#{phase['summary']}
|
|
72
|
+
|
|
73
|
+
**Trimmed log:**
|
|
74
|
+
```
|
|
75
|
+
#{context['trimmed_log']}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Fix the failure on the branch above. If you can't identify a fix,
|
|
79
|
+
respond with `NO_FIX_IDENTIFIED: <reason>` instead.
|
|
80
|
+
PROMPT
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def interpret(result, context)
|
|
84
|
+
text = result.is_a?(Hash) ? (result[:text] || result['text'] || '') : result.to_s
|
|
85
|
+
if text =~ /\bNO_FIX_IDENTIFIED:\s*(.+)$/
|
|
86
|
+
{ 'kind' => 'no_fix', 'summary' => Regexp.last_match(1).strip }
|
|
87
|
+
else
|
|
88
|
+
{
|
|
89
|
+
'kind' => 'fixed',
|
|
90
|
+
'summary' => 'Agent recovery attempt completed.',
|
|
91
|
+
'commit_sha' => context['commit_sha']
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def default_agent_invoker(prompt, _context)
|
|
97
|
+
# Stub for now — real wiring happens in RecoverCiHandler which has
|
|
98
|
+
# an Agent::Loop on hand. The handler injects its own invoker via
|
|
99
|
+
# the constructor.
|
|
100
|
+
raise RecoveryError, 'No agent invoker configured.'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
# Eager-load so LLM::TextBlock / LLM::ToolUseBlock constants resolve at
|
|
7
|
+
# class definition time — we reference them directly in `assistant_turn_blocks`
|
|
8
|
+
# before any MessageBuilder call has had a chance to trigger autoload.
|
|
9
|
+
require_relative '../llm/message_builder'
|
|
10
|
+
|
|
11
|
+
module RubynCode
|
|
12
|
+
module Megaplan
|
|
13
|
+
# Drives a multi-turn LLM conversation that gathers enough context to
|
|
14
|
+
# produce a megaplan. The model has a small whitelist of READ-ONLY
|
|
15
|
+
# tools (read_file, grep, glob, git_status, git_diff, git_log) so it
|
|
16
|
+
# can inspect the codebase before asking sharper questions — but it
|
|
17
|
+
# cannot edit, run shell mutations, or call any side-effecting tool.
|
|
18
|
+
#
|
|
19
|
+
# Each interview turn ends in one of two JSON shapes:
|
|
20
|
+
#
|
|
21
|
+
# { "question": { "text": "...", "options": ["a", "b"] | null } }
|
|
22
|
+
# { "plan": { "slug": ..., "feature": ..., "phases": [...] } }
|
|
23
|
+
#
|
|
24
|
+
# Validation of the plan payload is delegated to PlanProposer's
|
|
25
|
+
# existing rules so both /megaplan paths stay consistent.
|
|
26
|
+
class InterviewSession
|
|
27
|
+
class InvalidAnswerError < RubynCode::Error; end
|
|
28
|
+
class MalformedResponseError < RubynCode::Error; end
|
|
29
|
+
|
|
30
|
+
Question = Data.define(:id, :text, :options) do
|
|
31
|
+
def open? = options.nil? || options.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The megaplan skill lives in the gem's shared skill catalog
|
|
35
|
+
# (skills/megaplan/megaplan.md) so it's also reachable as
|
|
36
|
+
# `/skill megaplan` from the REPL and the chat. We load the file
|
|
37
|
+
# body directly (skipping the YAML frontmatter) for the system
|
|
38
|
+
# prompt.
|
|
39
|
+
SKILL_PATH = File.expand_path('../../../skills/megaplan/megaplan.md', __dir__)
|
|
40
|
+
|
|
41
|
+
def self.load_skill_body
|
|
42
|
+
raw = File.read(SKILL_PATH)
|
|
43
|
+
raw.sub(/\A---\s*\n.+?\n---\s*\n/m, '')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whitelist of read-only tools the interviewer may call. Picked from
|
|
47
|
+
# the existing Tools::Registry by name. Anything that writes, runs
|
|
48
|
+
# shell mutations, or spawns sub-agents is intentionally excluded.
|
|
49
|
+
INTERVIEW_TOOLS = %w[
|
|
50
|
+
read_file
|
|
51
|
+
glob
|
|
52
|
+
grep
|
|
53
|
+
git_status
|
|
54
|
+
git_diff
|
|
55
|
+
git_log
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Safety cap on the interview's per-turn tool loop. A well-behaved
|
|
59
|
+
# interviewer should read at most a handful of files before asking
|
|
60
|
+
# its next question; this stops a runaway model from stalling the
|
|
61
|
+
# session indefinitely. Per-turn, not per-session.
|
|
62
|
+
MAX_TOOL_TURNS = 10
|
|
63
|
+
|
|
64
|
+
# Strict output contract bolted on top of the megaplan skill body.
|
|
65
|
+
# The skill teaches *what* a megaplan is and *how* to interview; this
|
|
66
|
+
# contract teaches the LLM the wire format the gem expects on every
|
|
67
|
+
# turn AND that its tool palette is read-only.
|
|
68
|
+
JSON_OUTPUT_CONTRACT = <<~CONTRACT.freeze
|
|
69
|
+
# Output contract (overrides any other formatting instinct)
|
|
70
|
+
|
|
71
|
+
You are an interviewer, not a coding agent. You have a READ-ONLY
|
|
72
|
+
tool palette: `read_file`, `glob`, `grep`, `git_status`, `git_diff`,
|
|
73
|
+
`git_log`. Use them sparingly — only when looking at the code would
|
|
74
|
+
let you ask a SHARPER question (e.g. confirming a column already
|
|
75
|
+
exists before asking about it). You must NOT edit, write, run
|
|
76
|
+
shell mutations, or call any other tool. There are no other tools
|
|
77
|
+
available.
|
|
78
|
+
|
|
79
|
+
After any tool use, your next message must be a single JSON object
|
|
80
|
+
— no markdown fences, no prose before or after — in one of these
|
|
81
|
+
two shapes:
|
|
82
|
+
|
|
83
|
+
{ "question": { "text": "<one focused question>", "options": ["a", "b", "c"] | null } }
|
|
84
|
+
|
|
85
|
+
{ "plan": { "slug": "<kebab-case>", "feature": "<short description>",
|
|
86
|
+
"phases": [{ "number": 1, "slug": "<kebab>", "name": "<name>",
|
|
87
|
+
"summary": "<one sentence>",
|
|
88
|
+
"requirements_md": "<markdown>",
|
|
89
|
+
"design_md": "<markdown>",
|
|
90
|
+
"tasks_md": "<markdown>" }, ...] } }
|
|
91
|
+
|
|
92
|
+
Interview rules:
|
|
93
|
+
- Ask one question at a time. Never bundle multiple.
|
|
94
|
+
- Prefer numbered options (3-5 choices) when there's an obvious option set.
|
|
95
|
+
- Use null `options` only for genuinely open questions (end-state, constraints prose).
|
|
96
|
+
- Walk the megaplan-skill agenda (goal → constraints → assets → ordering
|
|
97
|
+
→ external deps → destructive ops → tests → done-per-phase). Skip
|
|
98
|
+
topics already obvious from context — including anything you've
|
|
99
|
+
confirmed via a read-only tool.
|
|
100
|
+
- Stop interviewing when you're 95% sure of the shape; emit the plan.
|
|
101
|
+
|
|
102
|
+
Plan rules:
|
|
103
|
+
- 1 to 12 phases. Each phase is a vertical slice that ships independently.
|
|
104
|
+
- Trunk works at every phase boundary.
|
|
105
|
+
- tasks_md uses `[ ]` checkboxes; requirements_md uses EARS-style SHALL
|
|
106
|
+
statements when phrasing acceptance criteria.
|
|
107
|
+
|
|
108
|
+
When you emit your final answer for a turn (a question or a plan),
|
|
109
|
+
produce ONLY the JSON object. No prefatory text. No trailing
|
|
110
|
+
commentary. Never produce free-form coding-agent output.
|
|
111
|
+
CONTRACT
|
|
112
|
+
|
|
113
|
+
DEFAULT_INTERVIEW_PROMPT = "#{load_skill_body}\n\n#{JSON_OUTPUT_CONTRACT}".freeze
|
|
114
|
+
|
|
115
|
+
attr_reader :session_id
|
|
116
|
+
|
|
117
|
+
def initialize(llm_client: nil, system_prompt: nil, workspace_path: nil, executor: nil)
|
|
118
|
+
@llm_client = llm_client || LLM::Client.new
|
|
119
|
+
@system_prompt = system_prompt || DEFAULT_INTERVIEW_PROMPT
|
|
120
|
+
@session_id = SecureRandom.uuid
|
|
121
|
+
@history = []
|
|
122
|
+
@last_question = nil
|
|
123
|
+
@workspace_path = workspace_path || Dir.pwd
|
|
124
|
+
@executor = executor || Tools::Executor.new(project_root: @workspace_path)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns a Question to ask the user, or a Hash (validated plan payload)
|
|
128
|
+
# if the LLM jumped straight to the plan.
|
|
129
|
+
def start
|
|
130
|
+
ask_llm('Begin the interview. Ask your first question.')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param question_id [String] echoes back the question's id (anti-race)
|
|
134
|
+
# @param answer_text [String] the user's answer
|
|
135
|
+
# @return [Question, Hash] the next question OR the final plan payload
|
|
136
|
+
def answer(question_id, answer_text)
|
|
137
|
+
raise InvalidAnswerError, 'no question awaiting answer' unless @last_question
|
|
138
|
+
raise InvalidAnswerError, 'wrong question id' unless @last_question.id == question_id
|
|
139
|
+
|
|
140
|
+
@history << { role: 'user', content: answer_text.to_s }
|
|
141
|
+
ask_llm(answer_text.to_s)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def ask_llm(prompt)
|
|
147
|
+
@history << { role: 'user', content: prompt } if @history.empty? || @history.last[:content] != prompt
|
|
148
|
+
|
|
149
|
+
MAX_TOOL_TURNS.times do
|
|
150
|
+
response = @llm_client.chat(
|
|
151
|
+
messages: @history,
|
|
152
|
+
system: @system_prompt,
|
|
153
|
+
tools: interview_tool_definitions
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
tool_calls = response.respond_to?(:tool_calls) ? response.tool_calls : []
|
|
157
|
+
if tool_calls.any?
|
|
158
|
+
@history << assistant_turn_blocks(response)
|
|
159
|
+
@history << tool_results_turn(tool_calls)
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
text = extract_text(response)
|
|
164
|
+
@history << { role: 'assistant', content: text }
|
|
165
|
+
return parse_outcome(text)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
raise MalformedResponseError,
|
|
169
|
+
"Interview tool loop exceeded #{MAX_TOOL_TURNS} turns without producing a question or plan"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def interview_tool_definitions
|
|
173
|
+
@executor.tool_definitions.select do |defn|
|
|
174
|
+
INTERVIEW_TOOLS.include?(defn[:name].to_s)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def assistant_turn_blocks(response)
|
|
179
|
+
blocks = response.content.filter_map do |block|
|
|
180
|
+
case block
|
|
181
|
+
when LLM::TextBlock
|
|
182
|
+
{ type: 'text', text: block.text }
|
|
183
|
+
when LLM::ToolUseBlock
|
|
184
|
+
{ type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
{ role: 'assistant', content: blocks }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def tool_results_turn(tool_calls)
|
|
191
|
+
content = tool_calls.map do |call|
|
|
192
|
+
result = if INTERVIEW_TOOLS.include?(call.name.to_s)
|
|
193
|
+
@executor.execute(call.name, stringify_keys(call.input))
|
|
194
|
+
else
|
|
195
|
+
"Tool '#{call.name}' is not available in interview mode (read-only palette: #{INTERVIEW_TOOLS.join(', ')})."
|
|
196
|
+
end
|
|
197
|
+
{ type: 'tool_result', tool_use_id: call.id, content: result.to_s }
|
|
198
|
+
end
|
|
199
|
+
{ role: 'user', content: content }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def stringify_keys(input)
|
|
203
|
+
return input unless input.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
input.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Mirrors PlanProposer#extract_text so both /megaplan paths handle
|
|
209
|
+
# LLM::Response Data objects, Hash legacy shapes, and raw Strings.
|
|
210
|
+
def extract_text(response)
|
|
211
|
+
return response.text if response.respond_to?(:text) && !response.is_a?(String)
|
|
212
|
+
return response[:text] || response['text'] if response.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
response.to_s
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def parse_outcome(text)
|
|
218
|
+
cleaned = text.to_s.strip
|
|
219
|
+
.sub(/\A```(?:json)?\s*\n?/, '')
|
|
220
|
+
.sub(/\n?```\s*\z/, '')
|
|
221
|
+
payload = JSON.parse(cleaned)
|
|
222
|
+
if payload.is_a?(Hash) && payload['question']
|
|
223
|
+
q = build_question(payload['question'])
|
|
224
|
+
@last_question = q
|
|
225
|
+
q
|
|
226
|
+
elsif payload.is_a?(Hash) && payload['plan']
|
|
227
|
+
plan = payload['plan']
|
|
228
|
+
PlanProposer.new.validate!(plan)
|
|
229
|
+
@last_question = nil
|
|
230
|
+
plan
|
|
231
|
+
else
|
|
232
|
+
raise MalformedResponseError, 'LLM response is neither a question nor a plan'
|
|
233
|
+
end
|
|
234
|
+
rescue JSON::ParserError => e
|
|
235
|
+
raise MalformedResponseError, "LLM response is not valid JSON: #{e.message}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_question(payload)
|
|
239
|
+
options = payload['options']
|
|
240
|
+
options = nil if options.is_a?(Array) && options.empty?
|
|
241
|
+
Question.new(id: SecureRandom.uuid, text: payload['text'].to_s, options: options)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Megaplan
|
|
8
|
+
# Proposes a multi-phase megaplan for a feature description.
|
|
9
|
+
#
|
|
10
|
+
# Asks the LLM to produce a JSON payload that matches the extension's
|
|
11
|
+
# `plan_proposal` shape — one folder per phase, three documents per
|
|
12
|
+
# phase, vertical-slice ordering. The handler validates the response
|
|
13
|
+
# and returns it to the IDE.
|
|
14
|
+
#
|
|
15
|
+
# The LLM call is the slow part (~5-30s); callers should run this
|
|
16
|
+
# off the main JSON-RPC thread.
|
|
17
|
+
class PlanProposer
|
|
18
|
+
class InvalidProposalError < RubynCode::Error; end
|
|
19
|
+
|
|
20
|
+
MAX_PHASES = 12
|
|
21
|
+
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
22
|
+
You are a senior Ruby/Rails architect breaking a feature request into a megaplan.
|
|
23
|
+
|
|
24
|
+
A megaplan is a multi-phase development plan where each phase is a
|
|
25
|
+
VERTICAL SLICE that can ship independently. Trunk works at every phase
|
|
26
|
+
boundary. No "scaffolding first, behavior later" — every phase delivers
|
|
27
|
+
a thin, end-to-end working increment.
|
|
28
|
+
|
|
29
|
+
Output a single JSON object with this exact shape:
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
"slug": "kebab-case-feature-slug",
|
|
33
|
+
"feature": "Short feature description",
|
|
34
|
+
"phases": [
|
|
35
|
+
{
|
|
36
|
+
"number": 1,
|
|
37
|
+
"slug": "kebab-case-phase-slug",
|
|
38
|
+
"name": "Human-readable phase name",
|
|
39
|
+
"summary": "One-sentence summary of what this phase ships",
|
|
40
|
+
"requirements_md": "# Phase 1 — <name>: Requirements\\n\\n...",
|
|
41
|
+
"design_md": "# Phase 1 — <name>: Design\\n\\n...",
|
|
42
|
+
"tasks_md": "# Phase 1 — <name>: Tasks\\n\\n## [ ] 1. ...\\n\\n- [ ] 1.1 ..."
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Constraints:
|
|
48
|
+
- 1 to 12 phases. Smaller, sharper phases beat fewer mega-phases.
|
|
49
|
+
- Each phase must be a vertical slice.
|
|
50
|
+
- tasks_md is a checklist with `[ ]` boxes (megaplan convention).
|
|
51
|
+
- Every phase needs requirements_md, design_md, tasks_md.
|
|
52
|
+
- Return ONLY the JSON. No markdown fences. No commentary.
|
|
53
|
+
PROMPT
|
|
54
|
+
|
|
55
|
+
def initialize(llm_client: nil, system_prompt: nil, max_phases: MAX_PHASES)
|
|
56
|
+
@llm_client = llm_client || LLM::Client.new
|
|
57
|
+
@system_prompt = system_prompt || DEFAULT_SYSTEM_PROMPT
|
|
58
|
+
@max_phases = max_phases
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param feature [String] the user's feature description
|
|
62
|
+
# @return [Hash] payload with `slug`, `feature`, `phases`
|
|
63
|
+
# @raise [InvalidProposalError] if the LLM response can't be parsed
|
|
64
|
+
def propose(feature)
|
|
65
|
+
raise ArgumentError, 'feature is required' if feature.nil? || feature.strip.empty?
|
|
66
|
+
|
|
67
|
+
response = @llm_client.chat(
|
|
68
|
+
messages: [{ role: 'user', content: feature_prompt(feature) }],
|
|
69
|
+
system: @system_prompt
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
text = extract_text(response)
|
|
73
|
+
payload = parse_payload(text)
|
|
74
|
+
validate!(payload, feature)
|
|
75
|
+
normalize(payload, feature)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validate a parsed plan_proposal Hash. Public so the interview path
|
|
79
|
+
# (which produces the same shape via a different LLM workflow) can
|
|
80
|
+
# reuse the rule set without reaching into a private method.
|
|
81
|
+
def validate!(payload, _feature = nil)
|
|
82
|
+
raise InvalidProposalError, 'payload is not an object' unless payload.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
phases = payload['phases']
|
|
85
|
+
raise InvalidProposalError, 'phases must be an array' unless phases.is_a?(Array)
|
|
86
|
+
raise InvalidProposalError, 'phases is empty' if phases.empty?
|
|
87
|
+
raise InvalidProposalError, "too many phases (max #{@max_phases})" if phases.size > @max_phases
|
|
88
|
+
|
|
89
|
+
phases.each_with_index do |phase, idx|
|
|
90
|
+
%w[name summary requirements_md design_md tasks_md].each do |key|
|
|
91
|
+
next unless phase[key].nil? || phase[key].to_s.strip.empty?
|
|
92
|
+
|
|
93
|
+
raise InvalidProposalError, "phase #{idx + 1} missing #{key}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# LLM::Client#chat returns a `LLM::Response` Data object whose `.text`
|
|
101
|
+
# joins all text blocks. Tests and older callers may pass a String or
|
|
102
|
+
# a Hash — handle all three so the proposer doesn't crash with
|
|
103
|
+
# `#<data ...>` ending up as parser input.
|
|
104
|
+
def extract_text(response)
|
|
105
|
+
return response.text if response.respond_to?(:text) && !response.is_a?(String)
|
|
106
|
+
return response[:text] || response['text'] if response.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
response.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def feature_prompt(feature)
|
|
112
|
+
"Plan this feature as a megaplan:\n\n#{feature.strip}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_payload(text)
|
|
116
|
+
# The LLM can leak fences despite the prompt — strip a leading/trailing
|
|
117
|
+
# ``` block if present.
|
|
118
|
+
cleaned = text.to_s.strip
|
|
119
|
+
cleaned = cleaned.sub(/\A```(?:json)?\s*\n?/, '').sub(/\n?```\s*\z/, '')
|
|
120
|
+
JSON.parse(cleaned)
|
|
121
|
+
rescue JSON::ParserError => e
|
|
122
|
+
raise InvalidProposalError, "LLM response is not valid JSON: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize(payload, feature)
|
|
126
|
+
slug = payload['slug'].to_s.strip
|
|
127
|
+
slug = slugify(feature) if slug.empty?
|
|
128
|
+
phases = payload['phases'].each_with_index.map do |phase, idx|
|
|
129
|
+
{
|
|
130
|
+
'number' => phase['number'] || idx + 1,
|
|
131
|
+
'slug' => (phase['slug'].to_s.strip.empty? ? slugify(phase['name']) : phase['slug']),
|
|
132
|
+
'name' => phase['name'],
|
|
133
|
+
'summary' => phase['summary'],
|
|
134
|
+
'requirements_md' => phase['requirements_md'],
|
|
135
|
+
'design_md' => phase['design_md'],
|
|
136
|
+
'tasks_md' => phase['tasks_md']
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
{
|
|
140
|
+
'slug' => slug,
|
|
141
|
+
'feature' => payload['feature'] || feature,
|
|
142
|
+
'phases' => phases
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def slugify(text)
|
|
147
|
+
cleaned = text.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-+|-+$/, '')
|
|
148
|
+
cleaned = cleaned[0, 80]
|
|
149
|
+
cleaned.empty? ? 'feature' : cleaned
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/rubyn_code/version.rb
CHANGED
data/lib/rubyn_code.rb
CHANGED
|
@@ -162,6 +162,13 @@ module RubynCode
|
|
|
162
162
|
autoload :Models, 'rubyn_code/tasks/models'
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
+
# Layer 7b: Megaplan
|
|
166
|
+
module Megaplan
|
|
167
|
+
autoload :PlanProposer, 'rubyn_code/megaplan/plan_proposer'
|
|
168
|
+
autoload :InterviewSession, 'rubyn_code/megaplan/interview_session'
|
|
169
|
+
autoload :CiRecovery, 'rubyn_code/megaplan/ci_recovery'
|
|
170
|
+
end
|
|
171
|
+
|
|
165
172
|
# Layer 8: Background
|
|
166
173
|
module Background
|
|
167
174
|
autoload :Worker, 'rubyn_code/background/worker'
|
|
@@ -296,6 +303,7 @@ module RubynCode
|
|
|
296
303
|
autoload :InstallSkills, 'rubyn_code/cli/commands/install_skills'
|
|
297
304
|
autoload :RemoveSkills, 'rubyn_code/cli/commands/remove_skills'
|
|
298
305
|
autoload :Skills, 'rubyn_code/cli/commands/skills'
|
|
306
|
+
autoload :Megaplan, 'rubyn_code/cli/commands/megaplan'
|
|
299
307
|
end
|
|
300
308
|
end
|
|
301
309
|
|