rubyn-code 0.5.1 → 0.8.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -60,6 +60,63 @@ module RubynCode
60
60
  })
61
61
  end
62
62
 
63
+ # Returns the resources advertised by the server (empty unless the
64
+ # server declared the `resources` capability). Each entry is a Hash with
65
+ # "uri", "name", and optionally "description"/"mimeType".
66
+ #
67
+ # @return [Array<Hash>]
68
+ def resources
69
+ return [] unless supports_resources?
70
+
71
+ @resources ||= @transport.send_request('resources/list')&.fetch('resources', []) || []
72
+ rescue StandardError => e
73
+ RubynCode::Debug.warn("MCP '#{@name}' resources/list failed: #{e.message}")
74
+ []
75
+ end
76
+
77
+ # Reads a single resource by URI.
78
+ #
79
+ # @param uri [String]
80
+ # @return [Hash] result with a "contents" array
81
+ def read_resource(uri)
82
+ ensure_connected!
83
+ @transport.send_request('resources/read', { uri: uri })
84
+ end
85
+
86
+ # Returns the prompts advertised by the server (empty unless the server
87
+ # declared the `prompts` capability). Each entry has "name" and
88
+ # optionally "description"/"arguments".
89
+ #
90
+ # @return [Array<Hash>]
91
+ def prompts
92
+ return [] unless supports_prompts?
93
+
94
+ @prompts ||= @transport.send_request('prompts/list')&.fetch('prompts', []) || []
95
+ rescue StandardError => e
96
+ RubynCode::Debug.warn("MCP '#{@name}' prompts/list failed: #{e.message}")
97
+ []
98
+ end
99
+
100
+ # Fetches a prompt, expanding its template with the given arguments.
101
+ #
102
+ # @param name [String]
103
+ # @param arguments [Hash]
104
+ # @return [Hash] result with "messages" (and optionally "description")
105
+ def get_prompt(name, arguments = {})
106
+ ensure_connected!
107
+ @transport.send_request('prompts/get', { name: name, arguments: arguments })
108
+ end
109
+
110
+ # @return [Boolean] whether the server advertised the resources capability
111
+ def supports_resources?
112
+ @server_capabilities.is_a?(Hash) && @server_capabilities.key?('resources')
113
+ end
114
+
115
+ # @return [Boolean] whether the server advertised the prompts capability
116
+ def supports_prompts?
117
+ @server_capabilities.is_a?(Hash) && @server_capabilities.key?('prompts')
118
+ end
119
+
63
120
  # Gracefully disconnects from the MCP server.
64
121
  #
65
122
  # @return [void]
@@ -67,6 +124,8 @@ module RubynCode
67
124
  @transport.stop!
68
125
  @initialized = false
69
126
  @tools = nil
127
+ @resources = nil
128
+ @prompts = nil
70
129
  end
71
130
 
72
131
  # Returns whether the client is connected and the transport is alive.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rubyn_code/mcp/config'
5
+
6
+ module RubynCode
7
+ module MCP
8
+ # Discovers MCP server definitions from a project's `.mcp.json` file
9
+ # (the Claude Code–style layout at the project root) and merges them
10
+ # with the user-level `.rubyn-code/mcp.json` definitions parsed by
11
+ # `MCP::Config`.
12
+ #
13
+ # Project-level entries are tagged `source: :project` so /mcp can
14
+ # distinguish them from user-level entries.
15
+ module Discovery
16
+ PROJECT_CONFIG_FILENAME = '.mcp.json'
17
+
18
+ Entry = Data.define(:name, :command, :args, :env, :url, :source)
19
+
20
+ module_function
21
+
22
+ # @param project_root [String, nil] project root (nil => nothing discovered)
23
+ # @return [Array<Entry>]
24
+ def discover(project_root)
25
+ user = MCP::Config.load(project_root.to_s).map { |cfg| to_entry(cfg, :user) }
26
+ proj = load_project(project_root)
27
+ user + proj
28
+ end
29
+
30
+ # @param project_root [String, nil]
31
+ # @return [Array<Entry>]
32
+ def load_project(project_root)
33
+ return [] if project_root.to_s.empty?
34
+
35
+ path = File.join(project_root, PROJECT_CONFIG_FILENAME)
36
+ return [] unless File.exist?(path)
37
+
38
+ data = JSON.parse(File.read(path))
39
+ Array(data['mcpServers']).filter_map do |name, server_def|
40
+ build_entry(name, server_def, :project)
41
+ end
42
+ rescue JSON::ParserError => e
43
+ RubynCode::Debug.warn("[MCP::Discovery] Failed to parse #{path}: #{e.message}")
44
+ []
45
+ rescue SystemCallError => e
46
+ RubynCode::Debug.warn("[MCP::Discovery] Could not read #{path}: #{e.message}")
47
+ []
48
+ end
49
+
50
+ # @return [Array<Entry>] entries that the local runner can start right now
51
+ def stdio_servers(entries)
52
+ entries.reject { |e| e.command.to_s.empty? }
53
+ end
54
+
55
+ # @return [Array<Entry>] entries that need a network protocol (deferred)
56
+ def remote_servers(entries)
57
+ entries.select { |e| e.command.to_s.empty? && !e.url.to_s.empty? }
58
+ end
59
+
60
+ def to_entry(cfg, source)
61
+ Entry.new(
62
+ name: cfg[:name],
63
+ command: cfg[:command],
64
+ args: cfg[:args],
65
+ env: cfg[:env],
66
+ url: cfg[:url],
67
+ source: source
68
+ )
69
+ end
70
+
71
+ def build_entry(name, server_def, source)
72
+ return nil unless server_def.is_a?(Hash)
73
+
74
+ command = server_def['command'].to_s
75
+ url = server_def['url'].to_s
76
+
77
+ if command.empty? && url.empty?
78
+ RubynCode::Debug.warn("[MCP::Discovery] Skipping #{name}: no command or url")
79
+ return nil
80
+ end
81
+
82
+ Entry.new(
83
+ name: name,
84
+ command: command,
85
+ args: Array(server_def['args']),
86
+ env: server_def['env'].is_a?(Hash) ? server_def['env'].transform_keys(&:to_s) : {},
87
+ url: url,
88
+ source: source
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module MCP
5
+ # Bridges an MCP server's *resources* and *prompts* into native RubynCode
6
+ # tools — the non-"tools" half of the MCP surface. Split out of ToolBridge
7
+ # so tool bridging and resource/prompt bridging each stay one focused job.
8
+ #
9
+ # Each capability registers a single tool whose description lists what's
10
+ # available (resource URIs / prompt names) and reads or fetches any of them.
11
+ module ServerExtrasBridge
12
+ class << self
13
+ # If the server exposes resources, register one tool that lists the
14
+ # available URIs (in its description) and reads any of them.
15
+ #
16
+ # @return [Array<Class>] the [read_resource] tool class, or [] if none
17
+ def bridge_resources(mcp_client)
18
+ resources = safe_list(mcp_client, :resources)
19
+ return [] if resources.empty?
20
+
21
+ klass = create_resource_tool(mcp_client, resources)
22
+ Tools::Registry.register(klass)
23
+ [klass]
24
+ end
25
+
26
+ # If the server exposes prompts, register one tool that lists the
27
+ # available prompt names and fetches any of them (with arguments).
28
+ #
29
+ # @return [Array<Class>] the [get_prompt] tool class, or [] if none
30
+ def bridge_prompts(mcp_client)
31
+ prompts = safe_list(mcp_client, :prompts)
32
+ return [] if prompts.empty?
33
+
34
+ klass = create_prompt_tool(mcp_client, prompts)
35
+ Tools::Registry.register(klass)
36
+ [klass]
37
+ end
38
+
39
+ private
40
+
41
+ def safe_list(mcp_client, method)
42
+ Array(mcp_client.public_send(method))
43
+ rescue StandardError
44
+ []
45
+ end
46
+
47
+ def create_resource_tool(mcp_client, resources)
48
+ listing = resources.map { |r| "- #{r['uri']}#{r['name'] && " (#{r['name']})"}" }.join("\n")
49
+ description = "Read a resource from MCP server '#{mcp_client.name}'. Available resources:\n#{listing}"
50
+ params = { uri: { type: :string, description: 'The resource URI to read', required: true } }
51
+
52
+ tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_read_resource"
53
+ build_dynamic_tool(tool_name, description, params) do |args|
54
+ format_resource_contents(mcp_client.read_resource(args[:uri] || args['uri']))
55
+ end
56
+ end
57
+
58
+ def create_prompt_tool(mcp_client, prompts)
59
+ listing = prompts.map { |p| "- #{p['name']}#{p['description'] && ": #{p['description']}"}" }.join("\n")
60
+ description = "Fetch a prompt from MCP server '#{mcp_client.name}'. Available prompts:\n#{listing}"
61
+ params = {
62
+ name: { type: :string, description: 'The prompt name', required: true },
63
+ arguments: { type: :object, description: 'Prompt arguments (optional)', required: false }
64
+ }
65
+
66
+ tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_get_prompt"
67
+ build_dynamic_tool(tool_name, description, params) do |args|
68
+ result = mcp_client.get_prompt(args[:name] || args['name'], args[:arguments] || args['arguments'] || {})
69
+ format_prompt_messages(result)
70
+ end
71
+ end
72
+
73
+ # Build a Tools::Base subclass whose #execute delegates to the block.
74
+ def build_dynamic_tool(tool_name, description, parameters, &handler)
75
+ Class.new(Tools::Base) do
76
+ const_set(:TOOL_NAME, tool_name)
77
+ const_set(:DESCRIPTION, description)
78
+ const_set(:PARAMETERS, parameters)
79
+ const_set(:RISK_LEVEL, :external)
80
+ const_set(:REQUIRES_CONFIRMATION, true)
81
+
82
+ define_method(:execute) { |**params| handler.call(params) }
83
+ end
84
+ end
85
+
86
+ def format_resource_contents(result)
87
+ contents = result.is_a?(Hash) ? Array(result['contents']) : []
88
+ return result.to_s if contents.empty?
89
+
90
+ contents.map { |c| c['text'] || "[binary resource: #{c['uri']} #{c['mimeType']}]" }.join("\n")
91
+ end
92
+
93
+ def format_prompt_messages(result)
94
+ messages = result.is_a?(Hash) ? Array(result['messages']) : []
95
+ return result.to_s if messages.empty?
96
+
97
+ messages.map { |m| "#{m['role']}: #{prompt_message_text(m['content'])}" }.join("\n\n")
98
+ end
99
+
100
+ def prompt_message_text(content)
101
+ case content
102
+ when Hash then content['text'] || content.to_s
103
+ when Array then content.map { |b| b.is_a?(Hash) ? b['text'] : b }.compact.join("\n")
104
+ else content.to_s
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require 'uri'
6
5
 
@@ -111,6 +110,7 @@ module RubynCode
111
110
  end
112
111
 
113
112
  def connection
113
+ require 'faraday'
114
114
  @connection ||= Faraday.new(url: base_url) do |f|
115
115
  f.options.timeout = @timeout
116
116
  f.options.open_timeout = @timeout
@@ -165,6 +165,7 @@ module RubynCode
165
165
  end
166
166
 
167
167
  def build_sse_connection
168
+ require 'faraday'
168
169
  Faraday.new(url: base_url) do |f|
169
170
  f.options.timeout = nil
170
171
  f.options.open_timeout = @timeout
@@ -10,6 +10,9 @@ module RubynCode
10
10
  # - Has RISK_LEVEL = :external
11
11
  # - Delegates #execute to the MCP client's #call_tool
12
12
  # - Registers itself with Tools::Registry
13
+ #
14
+ # A server's resources and prompts are bridged separately by
15
+ # {ServerExtrasBridge}, which #bridge folds into the returned classes.
13
16
  module ToolBridge
14
17
  JSON_TYPE_MAP = {
15
18
  'string' => :string, 'integer' => :integer, 'number' => :number,
@@ -18,17 +21,24 @@ module RubynCode
18
21
 
19
22
  class << self
20
23
  # Discovers tools from an MCP client and creates corresponding
21
- # RubynCode tool classes.
24
+ # RubynCode tool classes (plus any resource/prompt bridge tools).
22
25
  #
23
26
  # @param mcp_client [MCP::Client] a connected MCP client
24
27
  # @return [Array<Class>] the dynamically created tool classes
25
28
  def bridge(mcp_client)
26
- tools = mcp_client.tools
27
- return [] if tools.nil? || tools.empty?
29
+ classes = Array(mcp_client.tools).map { |tool_def| build_tool_class(mcp_client, tool_def) }
30
+ classes.concat(ServerExtrasBridge.bridge_resources(mcp_client))
31
+ classes.concat(ServerExtrasBridge.bridge_prompts(mcp_client))
32
+ classes
33
+ end
28
34
 
29
- tools.map do |tool_def|
30
- build_tool_class(mcp_client, tool_def)
31
- end
35
+ # Sanitizes a tool name into a Ruby-friendly identifier. Public so
36
+ # {ServerExtrasBridge} names its resource/prompt tools the same way.
37
+ #
38
+ # @param name [String] the original tool name
39
+ # @return [String] sanitized name
40
+ def sanitize_name(name)
41
+ name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
32
42
  end
33
43
 
34
44
  private
@@ -119,14 +129,6 @@ module RubynCode
119
129
  def map_json_type(json_type)
120
130
  JSON_TYPE_MAP.fetch(json_type, :string)
121
131
  end
122
-
123
- # Sanitizes a tool name for use as a Ruby-friendly identifier.
124
- #
125
- # @param name [String] the original tool name
126
- # @return [String] sanitized name
127
- def sanitize_name(name)
128
- name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
129
- end
130
132
  end
131
133
  end
132
134
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module Megaplan
@@ -17,7 +17,7 @@ module RubynCode
17
17
  class CiRecovery
18
18
  class RecoveryError < RubynCode::Error; end
19
19
 
20
- SYSTEM_PROMPT = <<~PROMPT.freeze
20
+ SYSTEM_PROMPT = <<~PROMPT
21
21
  You are Rubyn doing CI auto-recovery on a megaplan PR.
22
22
 
23
23
  Read the failing job log, identify the root cause, push a fix
@@ -93,7 +93,7 @@ module RubynCode
93
93
  end
94
94
  end
95
95
 
96
- def default_agent_invoker(prompt, _context)
96
+ def default_agent_invoker(_prompt, _context)
97
97
  # Stub for now — real wiring happens in RecoverCiHandler which has
98
98
  # an Agent::Loop on hand. The handler injects its own invoker via
99
99
  # the constructor.
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
@@ -65,7 +65,7 @@ module RubynCode
65
65
  # The skill teaches *what* a megaplan is and *how* to interview; this
66
66
  # contract teaches the LLM the wire format the gem expects on every
67
67
  # turn AND that its tool palette is read-only.
68
- JSON_OUTPUT_CONTRACT = <<~CONTRACT.freeze
68
+ JSON_OUTPUT_CONTRACT = <<~CONTRACT
69
69
  # Output contract (overrides any other formatting instinct)
70
70
 
71
71
  You are an interviewer, not a coding agent. You have a READ-ONLY
@@ -192,13 +192,18 @@ module RubynCode
192
192
  result = if INTERVIEW_TOOLS.include?(call.name.to_s)
193
193
  @executor.execute(call.name, stringify_keys(call.input))
194
194
  else
195
- "Tool '#{call.name}' is not available in interview mode (read-only palette: #{INTERVIEW_TOOLS.join(', ')})."
195
+ unavailable_tool_message(call.name)
196
196
  end
197
197
  { type: 'tool_result', tool_use_id: call.id, content: result.to_s }
198
198
  end
199
199
  { role: 'user', content: content }
200
200
  end
201
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
+
202
207
  def stringify_keys(input)
203
208
  return input unless input.is_a?(Hash)
204
209
 
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
@@ -18,7 +18,7 @@ module RubynCode
18
18
  class InvalidProposalError < RubynCode::Error; end
19
19
 
20
20
  MAX_PHASES = 12
21
- DEFAULT_SYSTEM_PROMPT = <<~PROMPT.freeze
21
+ DEFAULT_SYSTEM_PROMPT = <<~PROMPT
22
22
  You are a senior Ruby/Rails architect breaking a feature request into a megaplan.
23
23
 
24
24
  A megaplan is a multi-phase development plan where each phase is a
@@ -127,7 +127,7 @@ module RubynCode
127
127
  slug = slugify(feature) if slug.empty?
128
128
  phases = payload['phases'].each_with_index.map do |phase, idx|
129
129
  {
130
- 'number' => phase['number'] || idx + 1,
130
+ 'number' => phase['number'] || (idx + 1),
131
131
  'slug' => (phase['slug'].to_s.strip.empty? ? slugify(phase['name']) : phase['slug']),
132
132
  'name' => phase['name'],
133
133
  'summary' => phase['summary'],
@@ -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