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.
- checksums.yaml +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- metadata +94 -21
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
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(
|
|
31
|
-
status = blocked_by.empty? ?
|
|
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.
|
|
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 <<
|
|
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(
|
|
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 <<
|
|
129
|
+
conditions << 'status = ?'
|
|
130
130
|
params << status
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
if session_id
|
|
134
|
-
conditions <<
|
|
134
|
+
conditions << 'session_id = ?'
|
|
135
135
|
params << session_id
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
sql =
|
|
138
|
+
sql = 'SELECT * FROM tasks'
|
|
139
139
|
sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
|
|
140
|
-
sql +=
|
|
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(
|
|
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:
|
|
198
|
-
session_id:
|
|
199
|
-
title:
|
|
200
|
-
description: row[
|
|
201
|
-
status:
|
|
202
|
-
priority:
|
|
203
|
-
owner:
|
|
204
|
-
result:
|
|
205
|
-
metadata:
|
|
206
|
-
created_at:
|
|
207
|
-
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 ==
|
|
10
|
-
def in_progress? = status ==
|
|
11
|
-
def completed? = status ==
|
|
12
|
-
def blocked? = status ==
|
|
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
|
|
4
|
-
require
|
|
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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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[
|
|
67
|
-
messages = rows.map { |r| JSON.parse(r[
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
117
|
+
'SELECT COUNT(*) AS cnt FROM mailbox_messages WHERE recipient = ? AND read = 0',
|
|
100
118
|
[name]
|
|
101
119
|
).to_a
|
|
102
|
-
rows.first&.fetch(
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require_relative
|
|
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,
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
116
|
-
[
|
|
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[
|
|
128
|
+
metadata = parse_metadata(row['metadata'])
|
|
129
129
|
|
|
130
130
|
Teammate.new(
|
|
131
|
-
id: row[
|
|
132
|
-
name: row[
|
|
133
|
-
role: row[
|
|
134
|
-
persona: row[
|
|
135
|
-
model: row[
|
|
136
|
-
status: row[
|
|
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[
|
|
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 ==
|
|
14
|
+
def idle? = status == 'idle'
|
|
16
15
|
|
|
17
16
|
# @return [Boolean]
|
|
18
|
-
def active? = status ==
|
|
17
|
+
def active? = status == 'active'
|
|
19
18
|
|
|
20
19
|
# @return [Boolean]
|
|
21
|
-
def offline? = status ==
|
|
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
|
|
4
|
-
require_relative
|
|
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 =
|
|
10
|
-
DESCRIPTION =
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
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 =
|
|
12
|
-
DESCRIPTION =
|
|
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:
|
|
15
|
-
timeout: { type: :integer, required: false, default: 120, description:
|
|
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
|
-
|
|
23
|
+
stdout, stderr, status = safe_capture3(scrubbed_env, command, chdir: project_root, timeout: timeout)
|
|
24
24
|
|
|
25
|
-
stdout, stderr, status
|
|
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] =
|
|
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? ?
|
|
57
|
+
parts.empty? ? '(no output)' : parts.join("\n")
|
|
76
58
|
end
|
|
77
59
|
end
|
|
78
60
|
|