rubyn-code 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +247 -9
  3. data/lib/rubyn_code/agent/conversation.rb +2 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +4 -2
  6. data/lib/rubyn_code/agent/loop.rb +7 -3
  7. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +4 -2
  10. data/lib/rubyn_code/cli/app.rb +87 -13
  11. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  12. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  13. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  14. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  15. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  16. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  17. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  18. data/lib/rubyn_code/cli/repl.rb +11 -1
  19. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  20. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  21. data/lib/rubyn_code/cli/setup.rb +13 -0
  22. data/lib/rubyn_code/config/defaults.rb +2 -0
  23. data/lib/rubyn_code/config/settings.rb +5 -2
  24. data/lib/rubyn_code/context/context_budget.rb +2 -1
  25. data/lib/rubyn_code/context/manager.rb +3 -3
  26. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  27. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  28. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  29. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  30. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  31. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
  32. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  33. data/lib/rubyn_code/ide/handlers.rb +17 -2
  34. data/lib/rubyn_code/ide/protocol.rb +17 -1
  35. data/lib/rubyn_code/ide/server.rb +39 -1
  36. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  37. data/lib/rubyn_code/learning/extractor.rb +4 -2
  38. data/lib/rubyn_code/llm/model_router.rb +2 -1
  39. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  40. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  41. data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
  42. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  43. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  44. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  45. data/lib/rubyn_code/self_test.rb +2 -1
  46. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  47. data/lib/rubyn_code/skills/catalog.rb +10 -0
  48. data/lib/rubyn_code/skills/document.rb +8 -2
  49. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  50. data/lib/rubyn_code/skills/loader.rb +1 -1
  51. data/lib/rubyn_code/skills/matcher.rb +89 -0
  52. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  53. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  54. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  55. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  56. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  57. data/lib/rubyn_code/tools/executor.rb +4 -2
  58. data/lib/rubyn_code/tools/grep.rb +2 -1
  59. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  60. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  61. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  62. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  63. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  64. data/lib/rubyn_code/tools/web_search.rb +2 -1
  65. data/lib/rubyn_code/version.rb +1 -1
  66. data/lib/rubyn_code.rb +20 -0
  67. data/skills/megaplan/megaplan.md +156 -0
  68. data/skills/rubyn_self_test.md +75 -0
  69. metadata +25 -4
@@ -51,7 +51,7 @@ module RubynCode
51
51
  klass
52
52
  end
53
53
 
54
- def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- dynamic class creation requires setting many constants
54
+ def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/MethodLength -- dynamic class creation requires setting many constants
55
55
  bridge = self
56
56
 
57
57
  Class.new(Tools::Base) do
@@ -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
@@ -80,7 +80,8 @@ module RubynCode
80
80
  }
81
81
  end
82
82
 
83
- def build_session_summary_lines(session_id, turns, totals) # rubocop:disable Metrics/AbcSize -- assembles multi-field summary
83
+ # -- assembles multi-field summary
84
+ def build_session_summary_lines(session_id, turns, totals)
84
85
  avg_cost = turns.positive? ? totals[:cost] / turns : 0.0
85
86
  [
86
87
  header('Session Summary'),
@@ -104,7 +105,8 @@ module RubynCode
104
105
  ).to_a
105
106
  end
106
107
 
107
- def build_daily_summary_lines(today, rows) # rubocop:disable Metrics/AbcSize -- assembles multi-field daily summary
108
+ # -- assembles multi-field daily summary
109
+ def build_daily_summary_lines(today, rows)
108
110
  total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
109
111
  total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
110
112
  total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
@@ -97,7 +97,8 @@ module RubynCode
97
97
  table
98
98
  end
99
99
 
100
- def fill_lcs_row(table, row, old_lines, new_lines, col_count) # rubocop:disable Metrics/AbcSize -- LCS algorithm step
100
+ # -- LCS algorithm step
101
+ def fill_lcs_row(table, row, old_lines, new_lines, col_count)
101
102
  (1..col_count).each do |col|
102
103
  table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
103
104
  table[row - 1][col - 1] + 1
@@ -121,7 +122,7 @@ module RubynCode
121
122
  result
122
123
  end
123
124
 
124
- def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists -- LCS backtrack step requires all state
125
+ def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
125
126
  if lines_match?(old_lines, new_lines, old_idx, new_idx)
126
127
  result.unshift([:equal, old_idx - 1, new_idx - 1])
127
128
  [old_idx - 1, new_idx - 1]
@@ -53,7 +53,8 @@ module RubynCode
53
53
  record('File read (version.rb)', content.include?('VERSION ='))
54
54
  end
55
55
 
56
- def check_file_write_edit_cleanup # rubocop:disable Metrics/AbcSize -- sequential file ops
56
+ # -- sequential file ops
57
+ def check_file_write_edit_cleanup
57
58
  tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
58
59
  FileUtils.mkdir_p(File.dirname(tmp))
59
60