rubyn-code 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -0,0 +1,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
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,250 @@
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
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
+ unavailable_tool_message(call.name)
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 unavailable_tool_message(name)
203
+ "Tool '#{name}' is not available in interview mode " \
204
+ "(read-only palette: #{INTERVIEW_TOOLS.join(', ')})."
205
+ end
206
+
207
+ def stringify_keys(input)
208
+ return input unless input.is_a?(Hash)
209
+
210
+ input.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
211
+ end
212
+
213
+ # Mirrors PlanProposer#extract_text so both /megaplan paths handle
214
+ # LLM::Response Data objects, Hash legacy shapes, and raw Strings.
215
+ def extract_text(response)
216
+ return response.text if response.respond_to?(:text) && !response.is_a?(String)
217
+ return response[:text] || response['text'] if response.is_a?(Hash)
218
+
219
+ response.to_s
220
+ end
221
+
222
+ def parse_outcome(text)
223
+ cleaned = text.to_s.strip
224
+ .sub(/\A```(?:json)?\s*\n?/, '')
225
+ .sub(/\n?```\s*\z/, '')
226
+ payload = JSON.parse(cleaned)
227
+ if payload.is_a?(Hash) && payload['question']
228
+ q = build_question(payload['question'])
229
+ @last_question = q
230
+ q
231
+ elsif payload.is_a?(Hash) && payload['plan']
232
+ plan = payload['plan']
233
+ PlanProposer.new.validate!(plan)
234
+ @last_question = nil
235
+ plan
236
+ else
237
+ raise MalformedResponseError, 'LLM response is neither a question nor a plan'
238
+ end
239
+ rescue JSON::ParserError => e
240
+ raise MalformedResponseError, "LLM response is not valid JSON: #{e.message}"
241
+ end
242
+
243
+ def build_question(payload)
244
+ options = payload['options']
245
+ options = nil if options.is_a?(Array) && options.empty?
246
+ Question.new(id: SecureRandom.uuid, text: payload['text'].to_s, options: options)
247
+ end
248
+ end
249
+ end
250
+ 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
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
@@ -6,9 +6,10 @@ require_relative 'models'
6
6
  module RubynCode
7
7
  module Memory
8
8
  # Searches memories using SQLite FTS5 full-text search and standard
9
- # queries. Every search method automatically increments access_count
10
- # and updates last_accessed_at on returned records, reinforcing
11
- # frequently-accessed memories against decay.
9
+ # queries. Search methods automatically increment access_count and
10
+ # update last_accessed_at on returned records, reinforcing
11
+ # frequently-accessed memories against decay; #recent accepts
12
+ # touch: false to opt out for passive reads.
12
13
  class Search
13
14
  # @param db [DB::Connection] database connection
14
15
  # @param project_path [String] scoping path for searches
@@ -60,8 +61,11 @@ module RubynCode
60
61
  # Returns the most recently created memories.
61
62
  #
62
63
  # @param limit [Integer] maximum results (default 10)
64
+ # @param touch [Boolean] whether to record an access on returned
65
+ # records; pass false for passive reads (e.g. prompt assembly)
66
+ # that shouldn't reinforce memories or issue a write
63
67
  # @return [Array<MemoryRecord>]
64
- def recent(limit: 10)
68
+ def recent(limit: 10, touch: true)
65
69
  rows = @db.query(<<~SQL, [@project_path, limit]).to_a
66
70
  SELECT id, project_path, tier, category, content,
67
71
  relevance_score, access_count, last_accessed_at,
@@ -73,7 +77,7 @@ module RubynCode
73
77
  SQL
74
78
 
75
79
  records = rows.map { |row| build_record(row) }
76
- touch_accessed(records)
80
+ touch_accessed(records) if touch
77
81
  records
78
82
  end
79
83