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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Autonomous
5
+ # Polls for new work when an agent is idle. Checks the mailbox
6
+ # (messages always take priority) and the task board on a regular
7
+ # interval. Blocks the calling thread until work is found, the
8
+ # idle timeout expires, or the poller is interrupted.
9
+ class IdlePoller
10
+ # @param mailbox [#pending_for] message mailbox
11
+ # @param task_manager [#db] task persistence layer
12
+ # @param agent_name [String] the polling agent's identifier
13
+ # @param poll_interval [Numeric] seconds between polls (default 5)
14
+ # @param idle_timeout [Numeric] max seconds to wait before shutdown (default 60)
15
+ def initialize(mailbox:, task_manager:, agent_name:, poll_interval: 5, idle_timeout: 60)
16
+ @mailbox = mailbox
17
+ @task_manager = task_manager
18
+ @agent_name = agent_name
19
+ @poll_interval = poll_interval
20
+ @idle_timeout = idle_timeout
21
+ @interrupted = false
22
+ end
23
+
24
+ # Blocks the caller, polling for new work at the configured interval.
25
+ #
26
+ # @return [:resume, :shutdown, :interrupted]
27
+ # - :resume - found work (message or task)
28
+ # - :shutdown - idle timeout elapsed with no work
29
+ # - :interrupted - #interrupt! was called externally
30
+ def poll!
31
+ deadline = monotonic_now + @idle_timeout
32
+
33
+ loop do
34
+ return :interrupted if @interrupted
35
+ return :shutdown if monotonic_now >= deadline
36
+
37
+ # Messages always take priority over tasks.
38
+ if has_pending_messages?
39
+ return :resume
40
+ end
41
+
42
+ if has_claimable_task?
43
+ return :resume
44
+ end
45
+
46
+ remaining = deadline - monotonic_now
47
+ return :shutdown if remaining <= 0
48
+
49
+ sleep [remaining, @poll_interval].min
50
+ end
51
+ end
52
+
53
+ # Signals the poller to stop at the next iteration.
54
+ #
55
+ # @return [void]
56
+ def interrupt!
57
+ @interrupted = true
58
+ end
59
+
60
+ # Re-injects the agent's identity message when the conversation
61
+ # context has been compressed (i.e. the messages array is very short).
62
+ # This ensures the agent still knows who it is after compaction.
63
+ #
64
+ # @param messages [Array<Hash>] the current conversation messages
65
+ # @param identity [String] the identity/system prompt to re-inject
66
+ # @param threshold [Integer] message count below which re-injection triggers (default 3)
67
+ # @return [void]
68
+ def self.reinject_identity(messages, identity:, threshold: 3)
69
+ return if messages.length >= threshold
70
+ return if identity.nil? || identity.empty?
71
+
72
+ # Only re-inject if the identity is not already present as the
73
+ # first user message.
74
+ first_user = messages.find { |m| m[:role] == "user" }
75
+ return if first_user && first_user[:content].to_s.include?(identity[0, 100])
76
+
77
+ messages.unshift({ role: "user", content: identity })
78
+ end
79
+
80
+ private
81
+
82
+ # @return [Boolean]
83
+ def has_pending_messages?
84
+ messages = @mailbox.pending_for(@agent_name)
85
+ messages.is_a?(Array) ? !messages.empty? : false
86
+ rescue StandardError
87
+ false
88
+ end
89
+
90
+ # @return [Boolean]
91
+ def has_claimable_task?
92
+ rows = @task_manager.db.query(<<~SQL).to_a
93
+ SELECT 1 FROM tasks
94
+ WHERE status = 'pending'
95
+ AND (owner IS NULL OR owner = '')
96
+ LIMIT 1
97
+ SQL
98
+ !rows.empty?
99
+ rescue StandardError
100
+ false
101
+ end
102
+
103
+ # Monotonic clock to avoid issues with wall-clock adjustments.
104
+ #
105
+ # @return [Float] seconds
106
+ def monotonic_now
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Autonomous
5
+ # Claims and prepares unclaimed tasks for agent execution.
6
+ # Uses optimistic locking to handle race conditions when multiple
7
+ # agents attempt to claim the same task concurrently.
8
+ module TaskClaimer
9
+ # Finds the first ready (pending, unowned) task, claims it for the
10
+ # given agent, and returns the updated Task. Returns nil if no work
11
+ # is available.
12
+ #
13
+ # @param task_manager [#db, #update_task, #list_tasks] task persistence layer
14
+ # @param agent_name [String] unique identifier of the claiming agent
15
+ # @return [Tasks::Task, nil] the claimed task, or nil if none available
16
+ def self.call(task_manager:, agent_name:)
17
+ db = task_manager.db
18
+
19
+ # Atomically claim the first eligible task. The WHERE conditions
20
+ # ensure that only pending tasks with no current owner are touched,
21
+ # avoiding race conditions with other agents.
22
+ db.execute(<<~SQL, [agent_name])
23
+ UPDATE tasks
24
+ SET owner = ?,
25
+ status = 'in_progress',
26
+ updated_at = datetime('now')
27
+ WHERE id = (
28
+ SELECT id FROM tasks
29
+ WHERE status = 'pending'
30
+ AND (owner IS NULL OR owner = '')
31
+ ORDER BY priority DESC, created_at ASC
32
+ LIMIT 1
33
+ )
34
+ AND status = 'pending'
35
+ AND (owner IS NULL OR owner = '')
36
+ SQL
37
+
38
+ # Fetch the task we just claimed. Using owner + status filters
39
+ # ensures we only retrieve a task that *this* agent successfully
40
+ # claimed (another agent cannot have flipped it in between).
41
+ rows = db.query(<<~SQL, [agent_name]).to_a
42
+ SELECT id, session_id, title, description, status,
43
+ priority, owner, result, metadata, created_at, updated_at
44
+ FROM tasks
45
+ WHERE owner = ?
46
+ AND status = 'in_progress'
47
+ ORDER BY updated_at DESC
48
+ LIMIT 1
49
+ SQL
50
+
51
+ return nil if rows.empty?
52
+
53
+ row = rows.first
54
+ build_task(row)
55
+ rescue StandardError => e
56
+ # If anything goes wrong (e.g. task was already claimed between
57
+ # our SELECT and UPDATE, or a constraint violation) we treat it
58
+ # as "no work available" rather than crashing the daemon.
59
+ RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
60
+ nil
61
+ end
62
+
63
+ class << self
64
+ private
65
+
66
+ # @param row [Hash] a database row hash
67
+ # @return [Tasks::Task]
68
+ def build_task(row)
69
+ metadata = parse_json(row["metadata"])
70
+
71
+ Tasks::Task.new(
72
+ id: row["id"],
73
+ session_id: row["session_id"],
74
+ title: row["title"],
75
+ description: row["description"],
76
+ status: row["status"],
77
+ priority: row["priority"].to_i,
78
+ owner: row["owner"],
79
+ result: row["result"],
80
+ metadata: metadata,
81
+ created_at: row["created_at"],
82
+ updated_at: row["updated_at"]
83
+ )
84
+ end
85
+
86
+ # @param raw [String, Hash, nil]
87
+ # @return [Hash]
88
+ def parse_json(raw)
89
+ case raw
90
+ when Hash then raw
91
+ when String then JSON.parse(raw, symbolize_names: true)
92
+ else {}
93
+ end
94
+ rescue JSON::ParserError
95
+ {}
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Background
5
+ Job = Data.define(:id, :command, :status, :result, :started_at, :completed_at) do
6
+ def running? = status == :running
7
+ def completed? = status == :completed
8
+ def error? = status == :error
9
+ def timeout? = status == :timeout
10
+
11
+ def duration
12
+ return nil unless started_at
13
+ return nil if running?
14
+
15
+ (completed_at || Time.now) - started_at
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module RubynCode
6
+ module Background
7
+ # Thread-safe notification queue for background job completions.
8
+ # Uses Ruby's stdlib Queue which is already thread-safe for push/pop,
9
+ # but we guard drain with a mutex to prevent interleaved partial drains.
10
+ class Notifier
11
+ def initialize
12
+ @queue = Queue.new
13
+ @drain_mutex = Mutex.new
14
+ end
15
+
16
+ # Enqueues a notification.
17
+ #
18
+ # @param notification [Hash, String, Object] arbitrary notification payload
19
+ # @return [void]
20
+ def push(notification)
21
+ @queue.push(notification)
22
+ end
23
+
24
+ # Drains all pending notifications in a single atomic operation.
25
+ # Returns an empty array if nothing is pending.
26
+ #
27
+ # @return [Array] all pending notifications
28
+ def drain
29
+ @drain_mutex.synchronize do
30
+ notifications = []
31
+ notifications << @queue.pop until @queue.empty?
32
+ notifications
33
+ end
34
+ end
35
+
36
+ # Returns true if there are notifications waiting.
37
+ #
38
+ # @return [Boolean]
39
+ def pending?
40
+ !@queue.empty?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "securerandom"
5
+ require "timeout"
6
+ require_relative "job"
7
+ require_relative "notifier"
8
+
9
+ module RubynCode
10
+ module Background
11
+ # Runs shell commands in background threads with configurable timeouts.
12
+ # Thread-safe job tracking with a hard cap on concurrency.
13
+ class Worker
14
+ MAX_CONCURRENT = 5
15
+
16
+ # @param project_root [String] working directory for spawned commands
17
+ # @param notifier [Notifier] notification queue for completed jobs
18
+ def initialize(project_root:, notifier: Notifier.new)
19
+ @project_root = File.expand_path(project_root)
20
+ @notifier = notifier
21
+ @jobs = {}
22
+ @threads = {}
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Spawns a background thread to run the given command.
27
+ #
28
+ # @param command [String] the shell command to execute
29
+ # @param timeout [Integer] timeout in seconds (default 120)
30
+ # @return [String] the job ID
31
+ # @raise [RuntimeError] if the concurrency cap is reached
32
+ def run(command, timeout: 120)
33
+ job_id = SecureRandom.uuid
34
+
35
+ @mutex.synchronize do
36
+ running = @jobs.count { |_, j| j.running? }
37
+ if running >= MAX_CONCURRENT
38
+ raise "Concurrency limit reached (#{MAX_CONCURRENT} jobs running). Wait for a job to finish."
39
+ end
40
+
41
+ job = Job.new(
42
+ id: job_id,
43
+ command: command,
44
+ status: :running,
45
+ result: nil,
46
+ started_at: Time.now,
47
+ completed_at: nil
48
+ )
49
+ @jobs[job_id] = job
50
+ end
51
+
52
+ thread = Thread.new { execute_job(job_id, command, timeout) }
53
+ thread.abort_on_exception = false
54
+
55
+ @mutex.synchronize { @threads[job_id] = thread }
56
+
57
+ job_id
58
+ end
59
+
60
+ # Returns the current state of a job.
61
+ #
62
+ # @param job_id [String]
63
+ # @return [Job, nil]
64
+ def status(job_id)
65
+ @mutex.synchronize { @jobs[job_id] }
66
+ end
67
+
68
+ # Delegates to the notifier to drain all pending notifications.
69
+ #
70
+ # @return [Array]
71
+ def drain_notifications
72
+ @notifier.drain
73
+ end
74
+
75
+ # Returns the number of currently running jobs.
76
+ #
77
+ # @return [Integer]
78
+ def active_count
79
+ @mutex.synchronize do
80
+ @jobs.count { |_, j| j.running? }
81
+ end
82
+ end
83
+
84
+ # Waits for all running threads to finish. Intended for graceful shutdown.
85
+ #
86
+ # @param timeout [Integer] maximum seconds to wait per thread (default 30)
87
+ # @return [void]
88
+ def shutdown!(timeout: 30)
89
+ threads = @mutex.synchronize { @threads.values.dup }
90
+ threads.each { |t| t.join(timeout) }
91
+ end
92
+
93
+ private
94
+
95
+ def execute_job(job_id, command, timeout_seconds)
96
+ stdout, stderr, process_status = nil
97
+ final_status = :completed
98
+
99
+ begin
100
+ Timeout.timeout(timeout_seconds) do
101
+ stdout, stderr, process_status = Open3.capture3(command, chdir: @project_root)
102
+ end
103
+
104
+ final_status = process_status.success? ? :completed : :error
105
+ rescue Timeout::Error
106
+ final_status = :timeout
107
+ stdout = nil
108
+ stderr = "Command timed out after #{timeout_seconds} seconds"
109
+ rescue StandardError => e
110
+ final_status = :error
111
+ stdout = nil
112
+ stderr = e.message
113
+ end
114
+
115
+ result = build_result(stdout, stderr)
116
+ completed_at = Time.now
117
+
118
+ completed_job = @mutex.synchronize do
119
+ @jobs[job_id] = Job.new(
120
+ id: job_id,
121
+ command: command,
122
+ status: final_status,
123
+ result: result,
124
+ started_at: @jobs[job_id].started_at,
125
+ completed_at: completed_at
126
+ )
127
+ end
128
+
129
+ @notifier.push({
130
+ type: :job_completed,
131
+ job_id: job_id,
132
+ status: final_status,
133
+ result: result,
134
+ duration: completed_job.duration
135
+ })
136
+ end
137
+
138
+ def build_result(stdout, stderr)
139
+ parts = []
140
+ parts << stdout if stdout && !stdout.empty?
141
+ parts << "STDERR: #{stderr}" if stderr && !stderr.empty?
142
+ parts.empty? ? "(no output)" : parts.join("\n")
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ class App
6
+ def self.start(argv)
7
+ new(argv).run
8
+ end
9
+
10
+ def initialize(argv)
11
+ @argv = argv
12
+ @options = parse_options(argv)
13
+ end
14
+
15
+ def run
16
+ case @options[:command]
17
+ when :version
18
+ puts "rubyn-code #{RubynCode::VERSION}"
19
+ when :auth
20
+ run_auth
21
+ when :help
22
+ display_help
23
+ when :run
24
+ run_single_prompt(@options[:prompt])
25
+ when :repl
26
+ run_repl
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_options(argv)
33
+ options = { command: :repl }
34
+
35
+ i = 0
36
+ while i < argv.length
37
+ case argv[i]
38
+ when "--version", "-v"
39
+ options[:command] = :version
40
+ when "--help", "-h"
41
+ options[:command] = :help
42
+ when "--auth"
43
+ options[:command] = :auth
44
+ when "--resume", "-r"
45
+ options[:session_id] = argv[i + 1]
46
+ i += 1
47
+ when "-p", "--prompt"
48
+ options[:command] = :run
49
+ options[:prompt] = argv[i + 1]
50
+ i += 1
51
+ when "--yolo"
52
+ options[:yolo] = true
53
+ end
54
+ i += 1
55
+ end
56
+
57
+ options
58
+ end
59
+
60
+ def run_auth
61
+ renderer = Renderer.new
62
+ renderer.info("Starting Claude OAuth authentication...")
63
+
64
+ begin
65
+ Auth::OAuth.new.authenticate!
66
+ renderer.success("Authentication successful! Token stored.")
67
+ rescue AuthenticationError => e
68
+ renderer.error("Authentication failed: #{e.message}")
69
+ exit(1)
70
+ end
71
+ end
72
+
73
+ def run_single_prompt(prompt)
74
+ return display_help unless prompt
75
+
76
+ repl = REPL.new(project_root: Dir.pwd)
77
+ # Non-interactive: send one message and exit
78
+ response = repl.instance_variable_get(:@agent_loop).send_message(prompt)
79
+ puts response
80
+ end
81
+
82
+ def run_repl
83
+ REPL.new(
84
+ session_id: @options[:session_id],
85
+ project_root: Dir.pwd,
86
+ yolo: @options[:yolo]
87
+ ).run
88
+ end
89
+
90
+ def display_help
91
+ puts <<~HELP
92
+ rubyn-code - Ruby & Rails Agentic Coding Assistant
93
+
94
+ Usage:
95
+ rubyn-code Start interactive REPL
96
+ rubyn-code -p "prompt" Run a single prompt and exit
97
+ rubyn-code --resume [ID] Resume a previous session
98
+ rubyn-code --auth Authenticate with Claude
99
+ rubyn-code --version Show version
100
+ rubyn-code --help Show this help
101
+
102
+ Interactive Commands:
103
+ /help Show available commands
104
+ /quit Exit
105
+ /compact Compress context
106
+ /cost Show usage costs
107
+ /tasks List tasks
108
+ /skill [name] Load or list skills
109
+
110
+ Environment:
111
+ Config: ~/.rubyn-code/config.yml
112
+ Data: ~/.rubyn-code/rubyn_code.db
113
+ Tokens: ~/.rubyn-code/tokens.yml
114
+ HELP
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ class InputHandler
6
+ SLASH_COMMANDS = {
7
+ "/quit" => :quit,
8
+ "/exit" => :quit,
9
+ "/q" => :quit,
10
+ "/compact" => :compact,
11
+ "/cost" => :cost,
12
+ "/clear" => :clear,
13
+ "/undo" => :undo,
14
+ "/help" => :help,
15
+ "/tasks" => :tasks,
16
+ "/budget" => :budget,
17
+ "/resume" => :resume,
18
+ "/skill" => :skill,
19
+ "/version" => :version,
20
+ "/review" => :review,
21
+ "/spawn" => :spawn_teammate
22
+ }.freeze
23
+
24
+ Command = Data.define(:action, :args)
25
+
26
+ def parse(input)
27
+ return Command.new(action: :quit, args: []) if input.nil?
28
+
29
+ stripped = input.strip
30
+ return Command.new(action: :empty, args: []) if stripped.empty?
31
+
32
+ if stripped.start_with?("/")
33
+ parse_slash_command(stripped)
34
+ else
35
+ Command.new(action: :message, args: [process_file_references(stripped)])
36
+ end
37
+ end
38
+
39
+ def multiline?(line)
40
+ line&.end_with?("\\")
41
+ end
42
+
43
+ def strip_continuation(line)
44
+ line.chomp("\\")
45
+ end
46
+
47
+ private
48
+
49
+ def parse_slash_command(input)
50
+ return Command.new(action: :list_commands, args: []) if input.strip == "/"
51
+
52
+ parts = input.split(/\s+/, 2)
53
+ cmd = parts[0].downcase
54
+ args = parts[1]&.split(/\s+/) || []
55
+
56
+ action = SLASH_COMMANDS[cmd]
57
+
58
+ if action
59
+ Command.new(action: action, args: args)
60
+ else
61
+ Command.new(action: :unknown_command, args: [cmd])
62
+ end
63
+ end
64
+
65
+ def process_file_references(input)
66
+ input.gsub(/@(\S+)/) do |match|
67
+ path = Regexp.last_match(1)
68
+ if File.exist?(path)
69
+ content = File.read(path, encoding: "utf-8")
70
+ truncated = content.length > 50_000 ? "#{content[0...50_000]}\n[truncated]" : content
71
+ "\n<file path=\"#{path}\">\n#{truncated}\n</file>\n"
72
+ else
73
+ match
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end