rubyn-code 0.1.0 → 0.2.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require_relative "models"
5
- require_relative "dag"
3
+ require 'securerandom'
4
+ require_relative 'models'
5
+ require_relative 'dag'
6
6
 
7
7
  module RubynCode
8
8
  module Tasks
@@ -27,8 +27,8 @@ module RubynCode
27
27
  # @return [Task]
28
28
  def create(title:, description: nil, session_id: nil, blocked_by: [], priority: 0)
29
29
  id = SecureRandom.uuid
30
- now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
31
- status = blocked_by.empty? ? "pending" : "blocked"
30
+ now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
31
+ status = blocked_by.empty? ? 'pending' : 'blocked'
32
32
 
33
33
  @db.transaction do
34
34
  @db.execute(<<~SQL, [id, session_id, title, description, status, priority, now, now])
@@ -51,7 +51,7 @@ module RubynCode
51
51
  # @return [Task]
52
52
  def update(id, **attrs)
53
53
  allowed = %i[status priority owner result description title metadata]
54
- filtered = attrs.select { |k, _| allowed.include?(k) }
54
+ filtered = attrs.slice(*allowed)
55
55
  return get(id) if filtered.empty?
56
56
 
57
57
  sets = filtered.map { |k, _| "#{k} = ?" }
@@ -77,7 +77,7 @@ module RubynCode
77
77
  values = []
78
78
 
79
79
  if result
80
- sets << "result = ?"
80
+ sets << 'result = ?'
81
81
  values << result
82
82
  end
83
83
 
@@ -112,7 +112,7 @@ module RubynCode
112
112
  # @param id [String]
113
113
  # @return [Task, nil]
114
114
  def get(id)
115
- rows = @db.query("SELECT * FROM tasks WHERE id = ?", [id]).to_a
115
+ rows = @db.query('SELECT * FROM tasks WHERE id = ?', [id]).to_a
116
116
  row_to_task(rows.first)
117
117
  end
118
118
 
@@ -126,18 +126,18 @@ module RubynCode
126
126
  params = []
127
127
 
128
128
  if status
129
- conditions << "status = ?"
129
+ conditions << 'status = ?'
130
130
  params << status
131
131
  end
132
132
 
133
133
  if session_id
134
- conditions << "session_id = ?"
134
+ conditions << 'session_id = ?'
135
135
  params << session_id
136
136
  end
137
137
 
138
- sql = "SELECT * FROM tasks"
138
+ sql = 'SELECT * FROM tasks'
139
139
  sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
140
- sql += " ORDER BY priority DESC, created_at ASC"
140
+ sql += ' ORDER BY priority DESC, created_at ASC'
141
141
 
142
142
  @db.query(sql, params).to_a.filter_map { |row| row_to_task(row) }
143
143
  end
@@ -159,7 +159,7 @@ module RubynCode
159
159
  # @param id [String]
160
160
  # @return [void]
161
161
  def delete(id)
162
- @db.execute("DELETE FROM tasks WHERE id = ?", [id])
162
+ @db.execute('DELETE FROM tasks WHERE id = ?', [id])
163
163
  end
164
164
 
165
165
  private
@@ -194,17 +194,17 @@ module RubynCode
194
194
  return nil if row.nil?
195
195
 
196
196
  Task.new(
197
- id: row["id"],
198
- session_id: row["session_id"],
199
- title: row["title"],
200
- description: row["description"],
201
- status: row["status"],
202
- priority: row["priority"],
203
- owner: row["owner"],
204
- result: row["result"],
205
- metadata: row["metadata"],
206
- created_at: row["created_at"],
207
- updated_at: row["updated_at"]
197
+ id: row['id'],
198
+ session_id: row['session_id'],
199
+ title: row['title'],
200
+ description: row['description'],
201
+ status: row['status'],
202
+ priority: row['priority'],
203
+ owner: row['owner'],
204
+ result: row['result'],
205
+ metadata: row['metadata'],
206
+ created_at: row['created_at'],
207
+ updated_at: row['updated_at']
208
208
  )
209
209
  end
210
210
  end
@@ -6,10 +6,10 @@ module RubynCode
6
6
  :id, :session_id, :title, :description, :status,
7
7
  :priority, :owner, :result, :metadata, :created_at, :updated_at
8
8
  ) do
9
- def pending? = status == "pending"
10
- def in_progress? = status == "in_progress"
11
- def completed? = status == "completed"
12
- def blocked? = status == "blocked"
9
+ def pending? = status == 'pending'
10
+ def in_progress? = status == 'in_progress'
11
+ def completed? = status == 'completed'
12
+ def blocked? = status == 'blocked'
13
13
 
14
14
  def to_h
15
15
  {
@@ -0,0 +1,14 @@
1
+ # Layer 9: Teams
2
+
3
+ Persistent named teammate agents with asynchronous mailbox messaging.
4
+
5
+ ## Classes
6
+
7
+ - **`Manager`** — Spawns and manages persistent teammate agents. Each teammate has a name,
8
+ role, and its own conversation context. Persisted in the `teams` SQLite table.
9
+
10
+ - **`Teammate`** — Represents a single teammate: name, role, conversation state, status.
11
+ Processes messages from its mailbox and can send messages back.
12
+
13
+ - **`Mailbox`** — Asynchronous message queue between agents. `send_message` enqueues,
14
+ `read_inbox` dequeues. Messages are typed (`:message`, `:task`, `:result`).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "securerandom"
3
+ require 'json'
4
+ require 'securerandom'
5
5
 
6
6
  module RubynCode
7
7
  module Teams
@@ -23,18 +23,18 @@ module RubynCode
23
23
  # @param content [String] message body
24
24
  # @param message_type [String] type of message (default: "message")
25
25
  # @return [String] the message id
26
- def send(from:, to:, content:, message_type: "message")
26
+ def send(from:, to:, content:, message_type: 'message')
27
27
  id = SecureRandom.uuid
28
28
  now = Time.now.utc.iso8601
29
29
 
30
30
  payload = JSON.generate({
31
- id: id,
32
- from: from,
33
- to: to,
34
- content: content,
35
- message_type: message_type,
36
- timestamp: now
37
- })
31
+ id: id,
32
+ from: from,
33
+ to: to,
34
+ content: content,
35
+ message_type: message_type,
36
+ timestamp: now
37
+ })
38
38
 
39
39
  @db.execute(
40
40
  <<~SQL,
@@ -63,11 +63,11 @@ module RubynCode
63
63
 
64
64
  return [] if rows.empty?
65
65
 
66
- ids = rows.map { |r| r["id"] }
67
- messages = rows.map { |r| JSON.parse(r["payload"], symbolize_names: true) }
66
+ ids = rows.map { |r| r['id'] }
67
+ messages = rows.map { |r| JSON.parse(r['payload'], symbolize_names: true) }
68
68
 
69
69
  # Mark all fetched messages as read in a single statement
70
- placeholders = ids.map { "?" }.join(", ")
70
+ placeholders = ids.map { '?' }.join(', ')
71
71
  @db.execute(
72
72
  "UPDATE mailbox_messages SET read = 1 WHERE id IN (#{placeholders})",
73
73
  ids
@@ -86,35 +86,55 @@ module RubynCode
86
86
  recipients = all_names.reject { |n| n == from }
87
87
 
88
88
  recipients.map do |recipient|
89
- send(from: from, to: recipient, content: content, message_type: "broadcast")
89
+ send(from: from, to: recipient, content: content, message_type: 'broadcast')
90
90
  end
91
91
  end
92
92
 
93
+ # Returns unread messages for the given agent WITHOUT marking them as read.
94
+ # Used by IdlePoller to check for pending work without consuming messages.
95
+ #
96
+ # @param name [String] the recipient agent name
97
+ # @return [Array<Hash>] parsed message hashes
98
+ def pending_for(name)
99
+ rows = @db.query(
100
+ <<~SQL,
101
+ SELECT payload FROM mailbox_messages
102
+ WHERE recipient = ? AND read = 0
103
+ ORDER BY created_at ASC
104
+ SQL
105
+ [name]
106
+ ).to_a
107
+
108
+ rows.map { |r| JSON.parse(r['payload'], symbolize_names: true) }
109
+ end
110
+
93
111
  # Returns the count of unread messages for the given agent.
94
112
  #
95
113
  # @param name [String] the recipient agent name
96
114
  # @return [Integer]
97
115
  def unread_count(name)
98
116
  rows = @db.query(
99
- "SELECT COUNT(*) AS cnt FROM mailbox_messages WHERE recipient = ? AND read = 0",
117
+ 'SELECT COUNT(*) AS cnt FROM mailbox_messages WHERE recipient = ? AND read = 0',
100
118
  [name]
101
119
  ).to_a
102
- rows.first&.fetch("cnt", 0) || 0
120
+ rows.first&.fetch('cnt', 0) || 0
103
121
  end
104
122
 
105
123
  private
106
124
 
107
125
  # Creates the mailbox_messages table if it does not already exist.
126
+ # Schema must stay in sync with db/migrations/009_create_teams.sql.
108
127
  def ensure_table!
109
128
  @db.execute(<<~SQL)
110
129
  CREATE TABLE IF NOT EXISTS mailbox_messages (
111
130
  id TEXT PRIMARY KEY,
112
131
  sender TEXT NOT NULL,
113
132
  recipient TEXT NOT NULL,
114
- message_type TEXT NOT NULL DEFAULT 'message',
133
+ message_type TEXT NOT NULL DEFAULT 'message'
134
+ CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
115
135
  payload TEXT NOT NULL,
116
136
  read INTEGER NOT NULL DEFAULT 0,
117
- created_at TEXT NOT NULL
137
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
118
138
  )
119
139
  SQL
120
140
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "securerandom"
5
- require_relative "teammate"
3
+ require 'json'
4
+ require 'securerandom'
5
+ require_relative 'teammate'
6
6
 
7
7
  module RubynCode
8
8
  module Teams
@@ -40,7 +40,7 @@ module RubynCode
40
40
  INSERT INTO teammates (id, name, role, persona, model, status, metadata, created_at)
41
41
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
42
42
  SQL
43
- [id, name, role, persona, model, "idle", metadata_json, now]
43
+ [id, name, role, persona, model, 'idle', metadata_json, now]
44
44
  )
45
45
 
46
46
  Teammate.new(
@@ -49,7 +49,7 @@ module RubynCode
49
49
  role: role,
50
50
  persona: persona,
51
51
  model: model,
52
- status: "idle",
52
+ status: 'idle',
53
53
  metadata: {},
54
54
  created_at: now
55
55
  )
@@ -59,7 +59,7 @@ module RubynCode
59
59
  #
60
60
  # @return [Array<Teammate>]
61
61
  def list
62
- rows = @db.query("SELECT * FROM teammates ORDER BY created_at ASC").to_a
62
+ rows = @db.query('SELECT * FROM teammates ORDER BY created_at ASC').to_a
63
63
  rows.map { |row| row_to_teammate(row) }
64
64
  end
65
65
 
@@ -68,7 +68,7 @@ module RubynCode
68
68
  # @param name [String]
69
69
  # @return [Teammate, nil]
70
70
  def get(name)
71
- rows = @db.query("SELECT * FROM teammates WHERE name = ? LIMIT 1", [name]).to_a
71
+ rows = @db.query('SELECT * FROM teammates WHERE name = ? LIMIT 1', [name]).to_a
72
72
  return nil if rows.empty?
73
73
 
74
74
  row_to_teammate(rows.first)
@@ -90,7 +90,7 @@ module RubynCode
90
90
  raise Error, "Teammate '#{name}' not found" unless teammate
91
91
 
92
92
  @db.execute(
93
- "UPDATE teammates SET status = ? WHERE name = ?",
93
+ 'UPDATE teammates SET status = ? WHERE name = ?',
94
94
  [status, name]
95
95
  )
96
96
  end
@@ -104,7 +104,7 @@ module RubynCode
104
104
  teammate = get(name)
105
105
  raise Error, "Teammate '#{name}' not found" unless teammate
106
106
 
107
- @db.execute("DELETE FROM teammates WHERE name = ?", [name])
107
+ @db.execute('DELETE FROM teammates WHERE name = ?', [name])
108
108
  end
109
109
 
110
110
  # Returns all teammates with status "active".
@@ -112,8 +112,8 @@ module RubynCode
112
112
  # @return [Array<Teammate>]
113
113
  def active_teammates
114
114
  rows = @db.query(
115
- "SELECT * FROM teammates WHERE status = ? ORDER BY created_at ASC",
116
- ["active"]
115
+ 'SELECT * FROM teammates WHERE status = ? ORDER BY created_at ASC',
116
+ ['active']
117
117
  ).to_a
118
118
  rows.map { |row| row_to_teammate(row) }
119
119
  end
@@ -125,17 +125,17 @@ module RubynCode
125
125
  # @param row [Hash]
126
126
  # @return [Teammate]
127
127
  def row_to_teammate(row)
128
- metadata = parse_metadata(row["metadata"])
128
+ metadata = parse_metadata(row['metadata'])
129
129
 
130
130
  Teammate.new(
131
- id: row["id"],
132
- name: row["name"],
133
- role: row["role"],
134
- persona: row["persona"],
135
- model: row["model"],
136
- status: row["status"],
131
+ id: row['id'],
132
+ name: row['name'],
133
+ role: row['role'],
134
+ persona: row['persona'],
135
+ model: row['model'],
136
+ status: row['status'],
137
137
  metadata: metadata,
138
- created_at: row["created_at"]
138
+ created_at: row['created_at']
139
139
  )
140
140
  end
141
141
 
@@ -10,15 +10,14 @@ module RubynCode
10
10
  Teammate = Data.define(
11
11
  :id, :name, :role, :persona, :model, :status, :metadata, :created_at
12
12
  ) do
13
-
14
13
  # @return [Boolean]
15
- def idle? = status == "idle"
14
+ def idle? = status == 'idle'
16
15
 
17
16
  # @return [Boolean]
18
- def active? = status == "active"
17
+ def active? = status == 'active'
19
18
 
20
19
  # @return [Boolean]
21
- def offline? = status == "offline"
20
+ def offline? = status == 'offline'
22
21
 
23
22
  # @return [Hash]
24
23
  def to_h
@@ -0,0 +1,38 @@
1
+ # Layer 2: Tools
2
+
3
+ 32 built-in tools that Claude can invoke. The extensibility surface of the system.
4
+
5
+ ## Core Classes
6
+
7
+ - **`Base`** — Abstract base class. Subclasses define `self.tool_name`, `self.description`,
8
+ `self.schema` (JSON Schema), and `execute(params)`. Returns a string result.
9
+
10
+ - **`Registry`** — Maps tool names to classes. Tools self-register on load.
11
+ `Registry.find('read_file')` returns the tool class.
12
+
13
+ - **`Schema`** — Converts tool classes into Claude's expected tool definition format
14
+ (name, description, input_schema).
15
+
16
+ - **`Executor`** — Dispatches tool calls. Checks `Permissions::Policy` before execution,
17
+ wraps errors, and returns results. The bridge between Claude's tool_use blocks and Ruby.
18
+
19
+ ## Tool Categories
20
+
21
+ | Category | Tools |
22
+ |----------|-------|
23
+ | File I/O | `read_file`, `write_file`, `edit_file`, `glob`, `grep` |
24
+ | Shell | `bash`, `background_run` |
25
+ | Rails | `rails_generate`, `db_migrate`, `run_specs`, `bundle_install`, `bundle_add` |
26
+ | Git | `git_commit`, `git_diff`, `git_log`, `git_status` |
27
+ | Web | `web_search`, `web_fetch` |
28
+ | Memory | `memory_search`, `memory_write` |
29
+ | Agents | `spawn_agent`, `spawn_teammate`, `send_message`, `read_inbox` |
30
+ | Meta | `compact`, `load_skill`, `task`, `review_pr` |
31
+
32
+ ## Adding a Tool
33
+
34
+ 1. Create `my_tool.rb` in this directory, inherit `Tools::Base`
35
+ 2. Define `self.tool_name`, `self.description`, `self.schema`
36
+ 3. Implement `execute(params)` — return a string
37
+ 4. Add `autoload :MyTool` in `lib/rubyn_code.rb`
38
+ 5. Register in `Tools::Registry`
@@ -1,24 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "registry"
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
5
 
6
6
  module RubynCode
7
7
  module Tools
8
8
  class BackgroundRun < Base
9
- TOOL_NAME = "background_run"
10
- DESCRIPTION = "Run a command in the background (test suites, builds, deploys). " \
11
- "Returns immediately with a job ID. Results are delivered automatically " \
12
- "before your next LLM call."
9
+ TOOL_NAME = 'background_run'
10
+ DESCRIPTION = 'Run a command in the background (test suites, builds, deploys). ' \
11
+ 'Returns immediately with a job ID. Results are delivered automatically ' \
12
+ 'before your next LLM call.'
13
13
  PARAMETERS = {
14
14
  command: {
15
15
  type: :string,
16
- description: "The shell command to run in the background",
16
+ description: 'The shell command to run in the background',
17
17
  required: true
18
18
  },
19
19
  timeout: {
20
20
  type: :integer,
21
- description: "Timeout in seconds (default: 300)",
21
+ description: 'Timeout in seconds (default: 300)',
22
22
  required: false
23
23
  }
24
24
  }.freeze
@@ -27,9 +27,7 @@ module RubynCode
27
27
  attr_writer :background_worker
28
28
 
29
29
  def execute(command:, timeout: 300)
30
- unless @background_worker
31
- return "Error: Background worker not available. Use bash tool instead."
32
- end
30
+ return 'Error: Background worker not available. Use bash tool instead.' unless @background_worker
33
31
 
34
32
  job_id = @background_worker.run(command, timeout: timeout)
35
33
  "Background job started: #{job_id}\nCommand: #{command}\nTimeout: #{timeout}s\nResults will appear automatically when complete."
@@ -3,8 +3,8 @@
3
3
  module RubynCode
4
4
  module Tools
5
5
  class Base
6
- TOOL_NAME = ""
7
- DESCRIPTION = ""
6
+ TOOL_NAME = ''
7
+ DESCRIPTION = ''
8
8
  PARAMETERS = {}.freeze
9
9
  RISK_LEVEL = :read
10
10
  REQUIRES_CONFIRMATION = false
@@ -63,7 +63,7 @@ module RubynCode
63
63
  expanded
64
64
  end
65
65
 
66
- def truncate(output, max: 50_000)
66
+ def truncate(output, max: 10_000)
67
67
  return output if output.nil? || output.length <= max
68
68
 
69
69
  half = max / 2
@@ -72,6 +72,57 @@ module RubynCode
72
72
 
73
73
  private
74
74
 
75
+ # Safe replacement for Open3.capture3 that avoids Ruby 4.0's IOError
76
+ # when threads race on stream closure. All tools should use this instead
77
+ # of Open3.capture3 directly.
78
+ def safe_capture3(*cmd, chdir: project_root, timeout: 120, **)
79
+ stdin, stdout_io, stderr_io, wait_thr = Open3.popen3(*cmd, chdir: chdir, **)
80
+ stdin.close
81
+
82
+ stdout = +''
83
+ stderr = +''
84
+
85
+ out_reader = Thread.new do
86
+ stdout << stdout_io.read
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ err_reader = Thread.new do
91
+ stderr << stderr_io.read
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ timed_out = false
97
+ unless wait_thr.join(timeout)
98
+ timed_out = true
99
+ begin
100
+ Process.kill('TERM', wait_thr.pid)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ sleep 0.1
105
+ begin
106
+ Process.kill('KILL', wait_thr.pid)
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ wait_thr.join(5)
111
+ end
112
+
113
+ out_reader.join(5)
114
+ err_reader.join(5)
115
+ [stdout_io, stderr_io].each do |io|
116
+ io.close
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ raise Error, "Command timed out after #{timeout}s" if timed_out
122
+
123
+ [stdout, stderr, wait_thr.value]
124
+ end
125
+
75
126
  def read_file_safely(path)
76
127
  resolved = safe_path(path)
77
128
  raise Error, "File not found: #{path}" unless File.exist?(resolved)
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "timeout"
5
- require_relative "base"
6
- require_relative "registry"
3
+ require 'open3'
4
+ require 'timeout'
5
+ require_relative 'base'
6
+ require_relative 'registry'
7
7
 
8
8
  module RubynCode
9
9
  module Tools
10
10
  class Bash < Base
11
- TOOL_NAME = "bash"
12
- DESCRIPTION = "Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables."
11
+ TOOL_NAME = 'bash'
12
+ DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables.'
13
13
  PARAMETERS = {
14
- command: { type: :string, required: true, description: "The shell command to execute" },
15
- timeout: { type: :integer, required: false, default: 120, description: "Timeout in seconds (default: 120)" }
14
+ command: { type: :string, required: true, description: 'The shell command to execute' },
15
+ timeout: { type: :integer, required: false, default: 120, description: 'Timeout in seconds (default: 120)' }
16
16
  }.freeze
17
17
  RISK_LEVEL = :execute
18
18
  REQUIRES_CONFIRMATION = true
@@ -20,28 +20,16 @@ module RubynCode
20
20
  def execute(command:, timeout: 120)
21
21
  validate_command!(command)
22
22
 
23
- env = scrubbed_env
23
+ stdout, stderr, status = safe_capture3(scrubbed_env, command, chdir: project_root, timeout: timeout)
24
24
 
25
- stdout, stderr, status = nil
26
- begin
27
- Timeout.timeout(timeout) do
28
- stdout, stderr, status = Open3.capture3(env, command, chdir: project_root)
29
- end
30
- rescue Timeout::Error
31
- raise Error, "Command timed out after #{timeout} seconds: #{command}"
32
- end
33
-
34
- output = build_output(stdout, stderr, status)
35
- output
25
+ build_output(stdout, stderr, status)
36
26
  end
37
27
 
38
28
  private
39
29
 
40
30
  def validate_command!(command)
41
31
  Config::Defaults::DANGEROUS_PATTERNS.each do |pattern|
42
- if command.include?(pattern)
43
- raise PermissionDeniedError, "Blocked dangerous command pattern: '#{pattern}'"
44
- end
32
+ raise PermissionDeniedError, "Blocked dangerous command pattern: '#{pattern}'" if command.include?(pattern)
45
33
  end
46
34
  end
47
35
 
@@ -50,7 +38,7 @@ module RubynCode
50
38
 
51
39
  env.each_key do |key|
52
40
  if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase.include?(sensitive) }
53
- env[key] = "[SCRUBBED]"
41
+ env[key] = '[SCRUBBED]'
54
42
  end
55
43
  end
56
44
 
@@ -60,19 +48,13 @@ module RubynCode
60
48
  def build_output(stdout, stderr, status)
61
49
  parts = []
62
50
 
63
- unless stdout.empty?
64
- parts << stdout
65
- end
51
+ parts << stdout unless stdout.empty?
66
52
 
67
- unless stderr.empty?
68
- parts << "STDERR:\n#{stderr}"
69
- end
53
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
70
54
 
71
- unless status.success?
72
- parts << "Exit code: #{status.exitstatus}"
73
- end
55
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
74
56
 
75
- parts.empty? ? "(no output)" : parts.join("\n")
57
+ parts.empty? ? '(no output)' : parts.join("\n")
76
58
  end
77
59
  end
78
60