rubyn-code 0.1.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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Protocols
5
+ # Handles SIGINT (Ctrl-C) gracefully with a two-stage interrupt protocol.
6
+ #
7
+ # First Ctrl-C sets the interrupted flag so the current LLM call can
8
+ # check and abort gracefully. A second Ctrl-C within 2 seconds forces
9
+ # an immediate exit.
10
+ module InterruptHandler
11
+ @interrupted = false
12
+ @last_interrupt_at = nil
13
+ @callbacks = []
14
+ @mutex = Mutex.new
15
+
16
+ class << self
17
+ # Installs the SIGINT trap with the two-stage interrupt protocol.
18
+ #
19
+ # @return [void]
20
+ def setup!
21
+ @mutex.synchronize do
22
+ @interrupted = false
23
+ @last_interrupt_at = nil
24
+ end
25
+
26
+ trap("INT") do
27
+ handle_interrupt
28
+ end
29
+ end
30
+
31
+ # Returns whether the interrupted flag is currently set.
32
+ #
33
+ # @return [Boolean]
34
+ def interrupted?
35
+ @mutex.synchronize { @interrupted }
36
+ end
37
+
38
+ # Clears the interrupted flag and resets the last interrupt timestamp.
39
+ #
40
+ # @return [void]
41
+ def reset!
42
+ @mutex.synchronize do
43
+ @interrupted = false
44
+ @last_interrupt_at = nil
45
+ end
46
+ end
47
+
48
+ # Registers a callback to be invoked on the first interrupt.
49
+ # Callbacks are executed in registration order.
50
+ #
51
+ # @yield the block to run on interrupt
52
+ # @return [void]
53
+ def on_interrupt(&block)
54
+ @mutex.synchronize do
55
+ @callbacks << block
56
+ end
57
+ end
58
+
59
+ # Clears all registered callbacks. Intended for test cleanup.
60
+ #
61
+ # @return [void]
62
+ def clear_callbacks!
63
+ @mutex.synchronize { @callbacks.clear }
64
+ end
65
+
66
+ private
67
+
68
+ # Core interrupt handler logic. Called from the SIGINT trap.
69
+ # Signal handlers must be reentrant-safe and avoid complex operations.
70
+ def handle_interrupt
71
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+
73
+ if @last_interrupt_at && (now - @last_interrupt_at) < 2.0
74
+ # Second Ctrl-C within 2 seconds: force exit
75
+ $stderr.write("\nForce exiting...\n")
76
+ exit!(1)
77
+ end
78
+
79
+ @last_interrupt_at = now
80
+ @interrupted = true
81
+
82
+ $stderr.write("\nInterrupted. Press Ctrl-C again within 2s to force exit.\n")
83
+
84
+ # Fire callbacks outside the mutex to avoid deadlock in signal context.
85
+ # We read the callbacks array directly since signal handlers should be fast.
86
+ @callbacks.each do |callback|
87
+ callback.call
88
+ rescue StandardError
89
+ # Swallow errors in signal handlers to avoid crashing
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "tty-reader"
5
+ require "pastel"
6
+
7
+ module RubynCode
8
+ module Protocols
9
+ # Presents a plan to the user for approval before executing significant changes.
10
+ #
11
+ # This protocol ensures that destructive or wide-reaching operations are
12
+ # reviewed by a human before proceeding.
13
+ module PlanApproval
14
+ APPROVED = :approved
15
+ REJECTED = :rejected
16
+
17
+ class << self
18
+ # Displays a plan and asks the user to approve or reject it.
19
+ #
20
+ # @param plan_text [String] the plan description to display
21
+ # @param prompt [String, nil] optional custom prompt message
22
+ # @return [Symbol] :approved or :rejected
23
+ def request(plan_text, prompt: nil)
24
+ pastel = Pastel.new
25
+ tty = build_prompt
26
+
27
+ $stdout.puts
28
+ $stdout.puts pastel.cyan.bold("Proposed Plan")
29
+ $stdout.puts pastel.cyan("=" * 60)
30
+ $stdout.puts plan_text
31
+ $stdout.puts pastel.cyan("=" * 60)
32
+ $stdout.puts
33
+
34
+ if prompt
35
+ $stdout.puts pastel.yellow(prompt)
36
+ $stdout.puts
37
+ end
38
+
39
+ approved = tty.yes?(
40
+ pastel.yellow.bold("Do you approve this plan?"),
41
+ default: false
42
+ )
43
+
44
+ if approved
45
+ $stdout.puts pastel.green("Plan approved.")
46
+ APPROVED
47
+ else
48
+ $stdout.puts pastel.red("Plan rejected.")
49
+ REJECTED
50
+ end
51
+ rescue TTY::Reader::InputInterrupt
52
+ $stdout.puts pastel.red("\nPlan rejected (interrupted).")
53
+ REJECTED
54
+ end
55
+
56
+ private
57
+
58
+ # Builds a TTY::Prompt instance configured for non-destructive interrupt handling.
59
+ #
60
+ # @return [TTY::Prompt]
61
+ def build_prompt
62
+ TTY::Prompt.new(interrupt: :noop)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Protocols
5
+ # Graceful shutdown protocol for agent teammates.
6
+ #
7
+ # Implements a cooperative handshake where a coordinator requests
8
+ # shutdown and the target agent acknowledges after saving state.
9
+ module ShutdownHandshake
10
+ # Default timeout in seconds when waiting for a shutdown response.
11
+ TIMEOUT = 10
12
+
13
+ class << self
14
+ # Initiates a shutdown request to a teammate and waits for acknowledgement.
15
+ #
16
+ # @param mailbox [Teams::Mailbox] the team mailbox
17
+ # @param from [String] the requesting agent name
18
+ # @param to [String] the target agent name to shut down
19
+ # @param timeout [Integer] seconds to wait for response (default: 10)
20
+ # @return [Symbol] :acknowledged or :timeout
21
+ def initiate(mailbox:, from:, to:, timeout: TIMEOUT)
22
+ mailbox.send(
23
+ from: from,
24
+ to: to,
25
+ content: "shutdown_request",
26
+ message_type: "shutdown_request"
27
+ )
28
+
29
+ deadline = Time.now + timeout
30
+
31
+ loop do
32
+ messages = mailbox.read_inbox(from)
33
+ response = messages.find do |msg|
34
+ msg[:from] == to && msg[:message_type] == "shutdown_response"
35
+ end
36
+
37
+ return :acknowledged if response
38
+
39
+ return :timeout if Time.now >= deadline
40
+
41
+ sleep(0.25)
42
+ end
43
+ end
44
+
45
+ # Sends a shutdown response (approval or denial) from the target agent.
46
+ #
47
+ # @param mailbox [Teams::Mailbox] the team mailbox
48
+ # @param from [String] the responding agent name
49
+ # @param to [String] the agent that requested the shutdown
50
+ # @param approve [Boolean] whether to approve the shutdown (default: true)
51
+ # @return [String] the message id
52
+ def respond(mailbox:, from:, to:, approve: true)
53
+ content = approve ? "shutdown_approved" : "shutdown_denied"
54
+
55
+ mailbox.send(
56
+ from: from,
57
+ to: to,
58
+ content: content,
59
+ message_type: "shutdown_response"
60
+ )
61
+ end
62
+
63
+ # Performs a full graceful shutdown for an agent: saves state,
64
+ # sends acknowledgement, and sets status to offline.
65
+ #
66
+ # @param agent_name [String] the agent being shut down
67
+ # @param mailbox [Teams::Mailbox] the team mailbox
68
+ # @param session_persistence [#save_session] persistence layer for saving session state
69
+ # @param conversation [Agent::Conversation] the agent's conversation to persist
70
+ # @param requester [String, nil] the agent that requested shutdown (for acknowledgement)
71
+ # @return [void]
72
+ def graceful_shutdown(agent_name, mailbox:, session_persistence:, conversation:, requester: nil)
73
+ # Step 1: Save current session state
74
+ save_state(agent_name, session_persistence, conversation)
75
+
76
+ # Step 2: Send shutdown acknowledgement if there is a requester
77
+ if requester
78
+ respond(mailbox: mailbox, from: agent_name, to: requester, approve: true)
79
+ end
80
+
81
+ # Step 3: Broadcast offline status to all listeners
82
+ mailbox.send(
83
+ from: agent_name,
84
+ to: "_system",
85
+ content: "#{agent_name} is now offline",
86
+ message_type: "status_change"
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ # Saves the agent's session state via the persistence layer.
93
+ #
94
+ # @param agent_name [String]
95
+ # @param session_persistence [#save_session]
96
+ # @param conversation [Agent::Conversation]
97
+ # @return [void]
98
+ def save_state(agent_name, session_persistence, conversation)
99
+ session_persistence.save_session(
100
+ agent_name: agent_name,
101
+ messages: conversation.messages
102
+ )
103
+ rescue StandardError => e
104
+ $stderr.puts "[ShutdownHandshake] Warning: failed to save state for '#{agent_name}': #{e.message}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Skills
5
+ class Catalog
6
+ SKILL_GLOB = "**/*.md"
7
+
8
+ attr_reader :skills_dirs
9
+
10
+ def initialize(skills_dirs)
11
+ @skills_dirs = Array(skills_dirs)
12
+ @index = nil
13
+ end
14
+
15
+ def descriptions
16
+ entries = available
17
+ return "" if entries.empty?
18
+
19
+ entries.map { |entry| "- /#{entry[:name]}: #{entry[:description]}" }.join("\n")
20
+ end
21
+
22
+ def available
23
+ build_index unless @index
24
+ @index
25
+ end
26
+
27
+ def find(name)
28
+ entry = available.find { |e| e[:name] == name.to_s }
29
+ entry&.fetch(:path)
30
+ end
31
+
32
+ private
33
+
34
+ def build_index
35
+ @index = []
36
+
37
+ skills_dirs.each do |dir|
38
+ next unless File.directory?(dir)
39
+
40
+ Dir.glob(File.join(dir, SKILL_GLOB)).sort.each do |path|
41
+ entry = extract_metadata(path)
42
+ @index << entry if entry
43
+ end
44
+ end
45
+
46
+ @index.uniq! { |e| e[:name] }
47
+ end
48
+
49
+ def extract_metadata(path)
50
+ header = File.read(path, 1024, encoding: "UTF-8")
51
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
52
+ doc = Document.parse(header, filename: path)
53
+
54
+ name = if doc.name.empty? || doc.name == "unknown"
55
+ File.basename(path, ".md")
56
+ else
57
+ doc.name
58
+ end
59
+
60
+ {
61
+ name: name,
62
+ description: doc.description,
63
+ path: File.expand_path(path)
64
+ }
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubynCode
6
+ module Skills
7
+ class Document
8
+ FRONTMATTER_PATTERN = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
9
+
10
+ attr_reader :name, :description, :tags, :body
11
+
12
+ def initialize(name:, description:, tags:, body:)
13
+ @name = name
14
+ @description = description
15
+ @tags = tags
16
+ @body = body
17
+ end
18
+
19
+ class << self
20
+ def parse(content, filename: nil)
21
+ match = FRONTMATTER_PATTERN.match(content)
22
+
23
+ if match
24
+ frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
25
+ body = match[2].to_s.strip
26
+
27
+ new(
28
+ name: frontmatter["name"].to_s,
29
+ description: frontmatter["description"].to_s,
30
+ tags: Array(frontmatter["tags"]),
31
+ body: body
32
+ )
33
+ else
34
+ body = content.to_s.strip
35
+ title = extract_title(body)
36
+ derived_name = filename ? File.basename(filename, ".*").tr("_", "-") : title_to_name(title)
37
+ tags = derive_tags(derived_name, body)
38
+
39
+ new(
40
+ name: derived_name,
41
+ description: title,
42
+ tags: tags,
43
+ body: body
44
+ )
45
+ end
46
+ end
47
+
48
+ def parse_file(path)
49
+ raise Error, "Skill file not found: #{path}" unless File.exist?(path)
50
+ raise Error, "Not a file: #{path}" unless File.file?(path)
51
+
52
+ content = File.read(path, encoding: "UTF-8")
53
+ parse(content, filename: path)
54
+ end
55
+
56
+ private
57
+
58
+ def extract_title(body)
59
+ first_line = body.lines.first&.strip || ""
60
+ first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : first_line[0..80]
61
+ end
62
+
63
+ def title_to_name(title)
64
+ title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")[0..40]
65
+ end
66
+
67
+ def derive_tags(name, body)
68
+ tags = []
69
+ tags << "ruby" if body.match?(/\bruby\b/i) || name.include?("ruby")
70
+ tags << "rails" if body.match?(/\brails\b/i) || name.include?("rails")
71
+ tags << "rspec" if body.match?(/\brspec\b/i) || name.include?("rspec")
72
+ tags << "testing" if body.match?(/\b(test|spec|minitest)\b/i)
73
+ tags << "patterns" if body.match?(/\b(pattern|design|solid)\b/i)
74
+ tags << "refactoring" if body.match?(/\brefactor/i)
75
+ tags.uniq
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Skills
5
+ class Loader
6
+ attr_reader :catalog
7
+
8
+ def initialize(catalog)
9
+ @catalog = catalog
10
+ @loaded = {}
11
+ end
12
+
13
+ def load(name)
14
+ name = name.to_s
15
+
16
+ if @loaded.key?(name)
17
+ return @loaded[name]
18
+ end
19
+
20
+ path = catalog.find(name)
21
+ raise Error, "Skill not found: #{name}" unless path
22
+
23
+ doc = Document.parse_file(path)
24
+ content = format_skill(doc)
25
+
26
+ @loaded[name] = content
27
+ content
28
+ end
29
+
30
+ def loaded
31
+ @loaded.keys
32
+ end
33
+
34
+ def descriptions_for_prompt
35
+ catalog.descriptions
36
+ end
37
+
38
+ private
39
+
40
+ def format_skill(doc)
41
+ parts = []
42
+ parts << "<skill name=\"#{escape_xml(doc.name)}\">"
43
+ parts << doc.body unless doc.body.empty?
44
+ parts << "</skill>"
45
+ parts.join("\n")
46
+ end
47
+
48
+ def escape_xml(text)
49
+ text.to_s
50
+ .gsub("&", "&amp;")
51
+ .gsub("<", "&lt;")
52
+ .gsub(">", "&gt;")
53
+ .gsub("\"", "&quot;")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module SubAgents
5
+ class Runner
6
+ AGENT_TOOL_SETS = {
7
+ explore: %w[read_file glob grep bash].freeze,
8
+ general: nil # resolved at runtime to exclude sub-agent tools
9
+ }.freeze
10
+
11
+ SUB_AGENT_TOOLS = %w[sub_agent spawn_agent].freeze
12
+ MAX_ITERATIONS_HARD_LIMIT = 50
13
+
14
+ class << self
15
+ def call(prompt:, llm_client:, project_root:, agent_type: :explore, max_iterations: 30)
16
+ new(
17
+ prompt: prompt,
18
+ llm_client: llm_client,
19
+ project_root: project_root,
20
+ agent_type: agent_type,
21
+ max_iterations: max_iterations
22
+ ).run
23
+ end
24
+ end
25
+
26
+ def initialize(prompt:, llm_client:, project_root:, agent_type:, max_iterations:)
27
+ @prompt = prompt
28
+ @llm_client = llm_client
29
+ @project_root = File.expand_path(project_root)
30
+ @agent_type = agent_type.to_sym
31
+ @max_iterations = [max_iterations.to_i, MAX_ITERATIONS_HARD_LIMIT].min
32
+ end
33
+
34
+ def run
35
+ conversation = build_conversation
36
+ executor = build_executor
37
+ tool_defs = build_tool_definitions
38
+
39
+ iteration = 0
40
+ final_text = ""
41
+
42
+ loop do
43
+ break if iteration >= @max_iterations
44
+
45
+ response = request_llm(conversation, tool_defs)
46
+ iteration += 1
47
+
48
+ text_content = extract_text(response)
49
+ tool_calls = extract_tool_calls(response)
50
+
51
+ if tool_calls.empty?
52
+ final_text = text_content
53
+ break
54
+ end
55
+
56
+ conversation << { role: "assistant", content: response }
57
+
58
+ tool_results = execute_tools(executor, tool_calls)
59
+ conversation << { role: "user", content: tool_results }
60
+
61
+ final_text = text_content unless text_content.empty?
62
+ end
63
+
64
+ Summarizer.call(final_text)
65
+ end
66
+
67
+ private
68
+
69
+ def build_conversation
70
+ [
71
+ { role: "user", content: @prompt }
72
+ ]
73
+ end
74
+
75
+ def build_executor
76
+ Tools::Executor.new(project_root: @project_root)
77
+ end
78
+
79
+ def build_tool_definitions
80
+ allowed = allowed_tool_names
81
+ Tools::Registry.all
82
+ .select { |t| allowed.include?(t.tool_name) }
83
+ .map(&:to_schema)
84
+ end
85
+
86
+ def allowed_tool_names
87
+ preset = AGENT_TOOL_SETS[@agent_type]
88
+
89
+ if preset
90
+ # Only include tools that are actually registered
91
+ registered = Tools::Registry.tool_names
92
+ preset & registered
93
+ else
94
+ # :general — all registered tools minus sub-agent spawning tools
95
+ Tools::Registry.tool_names - SUB_AGENT_TOOLS
96
+ end
97
+ end
98
+
99
+ def request_llm(conversation, tool_defs)
100
+ @llm_client.chat(
101
+ messages: conversation,
102
+ tools: tool_defs
103
+ )
104
+ end
105
+
106
+ def extract_text(response)
107
+ case response
108
+ when String
109
+ response
110
+ when Hash
111
+ content = response[:content] || response["content"]
112
+ extract_text_from_content(content)
113
+ when Array
114
+ extract_text_from_content(response)
115
+ else
116
+ response.to_s
117
+ end
118
+ end
119
+
120
+ def extract_text_from_content(content)
121
+ return content.to_s unless content.is_a?(Array)
122
+
123
+ content
124
+ .select { |block| block_type(block) == "text" }
125
+ .map { |block| block[:text] || block["text"] }
126
+ .compact
127
+ .join("\n")
128
+ end
129
+
130
+ def extract_tool_calls(response)
131
+ content = case response
132
+ when Hash then response[:content] || response["content"]
133
+ when Array then response
134
+ else return []
135
+ end
136
+
137
+ return [] unless content.is_a?(Array)
138
+
139
+ content.select { |block| block_type(block) == "tool_use" }
140
+ end
141
+
142
+ def block_type(block)
143
+ (block[:type] || block["type"]).to_s
144
+ end
145
+
146
+ def execute_tools(executor, tool_calls)
147
+ tool_calls.map do |call|
148
+ tool_name = call[:name] || call["name"]
149
+ tool_input = call[:input] || call["input"] || {}
150
+ tool_id = call[:id] || call["id"]
151
+
152
+ # Prevent recursive sub-agent spawning
153
+ result = if SUB_AGENT_TOOLS.include?(tool_name)
154
+ "Error: Sub-agents cannot spawn other sub-agents."
155
+ else
156
+ executor.execute(tool_name, tool_input)
157
+ end
158
+
159
+ {
160
+ type: "tool_result",
161
+ tool_use_id: tool_id,
162
+ content: result.to_s
163
+ }
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module SubAgents
5
+ module Summarizer
6
+ DEFAULT_MAX_LENGTH = 2000
7
+ TRUNCATION_SUFFIX = "\n\n[... output truncated ...]"
8
+
9
+ class << self
10
+ def call(text, max_length: DEFAULT_MAX_LENGTH)
11
+ return "" if text.nil? || text.empty?
12
+
13
+ text = text.to_s.strip
14
+
15
+ return text if text.length <= max_length
16
+
17
+ truncate_with_context(text, max_length)
18
+ end
19
+
20
+ private
21
+
22
+ def truncate_with_context(text, max_length)
23
+ usable = max_length - TRUNCATION_SUFFIX.length
24
+ return text[0, max_length] if usable <= 0
25
+
26
+ # Keep the beginning (context setup) and end (final result) of the output.
27
+ # The end usually contains the most relevant conclusion.
28
+ head_size = (usable * 0.4).to_i
29
+ tail_size = usable - head_size
30
+
31
+ head = text[0, head_size]
32
+ tail = text[-tail_size, tail_size]
33
+
34
+ # Trim to nearest newline boundaries when possible to avoid mid-line cuts.
35
+ head = trim_to_last_newline(head)
36
+ tail = trim_to_first_newline(tail)
37
+
38
+ "#{head}#{TRUNCATION_SUFFIX}\n\n#{tail}"
39
+ end
40
+
41
+ def trim_to_last_newline(text)
42
+ last_nl = text.rindex("\n")
43
+ return text unless last_nl && last_nl > (text.length * 0.5)
44
+
45
+ text[0..last_nl]
46
+ end
47
+
48
+ def trim_to_first_newline(text)
49
+ first_nl = text.index("\n")
50
+ return text unless first_nl && first_nl < (text.length * 0.3)
51
+
52
+ text[(first_nl + 1)..]
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end