rubyn-code 0.5.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Set or clear the reasoning effort level (output_config.effort).
|
|
7
|
+
#
|
|
8
|
+
# /effort # show current value
|
|
9
|
+
# /effort off # clear back to model default
|
|
10
|
+
# /effort <level> # low | medium | high | xhigh | max
|
|
11
|
+
class Effort < Base
|
|
12
|
+
LEVELS = %w[low medium high xhigh max].freeze
|
|
13
|
+
|
|
14
|
+
def self.command_name = '/effort'
|
|
15
|
+
def self.description = 'Set reasoning effort (low/medium/high/xhigh/max)'
|
|
16
|
+
|
|
17
|
+
def execute(args, ctx)
|
|
18
|
+
arg = args.first
|
|
19
|
+
|
|
20
|
+
current = ctx.llm_client.respond_to?(:effort) ? ctx.llm_client.effort : nil
|
|
21
|
+
|
|
22
|
+
case arg
|
|
23
|
+
when nil
|
|
24
|
+
show_status(current, ctx)
|
|
25
|
+
when 'off'
|
|
26
|
+
ctx.llm_client.effort = nil
|
|
27
|
+
ctx.renderer.info('Reasoning effort cleared — using model default 🎚️')
|
|
28
|
+
when *LEVELS
|
|
29
|
+
ctx.llm_client.effort = arg
|
|
30
|
+
ctx.renderer.info("Reasoning effort set to #{arg} 🎚️")
|
|
31
|
+
else
|
|
32
|
+
ctx.renderer.warning("Usage: /effort [off|#{LEVELS.join('|')}]. Got: #{arg}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def show_status(current, ctx)
|
|
39
|
+
state = current || 'not set (model default: high on 4.6+)'
|
|
40
|
+
ctx.renderer.info("Reasoning effort: #{state}")
|
|
41
|
+
ctx.renderer.info("Usage: /effort <level> levels: #{LEVELS.join(', ')} (or /effort off)")
|
|
42
|
+
ctx.renderer.info('Supported on Claude 4.6+ models; xhigh requires Opus 4.7+ / Sonnet 5 / Fable 5.')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Export the current conversation transcript to a file.
|
|
10
|
+
#
|
|
11
|
+
# /export path/to/transcript.md # markdown (default)
|
|
12
|
+
# /export --format jsonl path/to/transcript.jsonl
|
|
13
|
+
# /export path/to/transcript.md --force # overwrite existing
|
|
14
|
+
class Export < Base
|
|
15
|
+
def self.command_name = '/export'
|
|
16
|
+
def self.description = 'Export conversation transcript to markdown or JSONL'
|
|
17
|
+
|
|
18
|
+
MAX_LINE_LENGTH = 100
|
|
19
|
+
|
|
20
|
+
# -- CLI arg parsing is intrinsically procedural
|
|
21
|
+
def execute(args, ctx)
|
|
22
|
+
opts = parse_args(args)
|
|
23
|
+
return unless opts
|
|
24
|
+
|
|
25
|
+
messages = ctx.conversation.to_a
|
|
26
|
+
if messages.empty?
|
|
27
|
+
ctx.renderer.warning('No messages to export.')
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path = File.expand_path(opts[:path])
|
|
32
|
+
dir = File.dirname(path)
|
|
33
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
34
|
+
|
|
35
|
+
if File.exist?(path) && !opts[:force] && !tty_yes?(ctx, "File exists at #{path}. Overwrite? [y/N]")
|
|
36
|
+
ctx.renderer.info('Export cancelled.')
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
body = opts[:format] == 'jsonl' ? render_jsonl(messages) : render_markdown(messages)
|
|
41
|
+
File.write(path, body)
|
|
42
|
+
plural = messages.size == 1 ? '' : 's'
|
|
43
|
+
ctx.renderer.info("Exported #{messages.size} message#{plural} to #{path}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @return [Hash{Symbol=>Object}, nil] nil on bad input
|
|
49
|
+
# -- arg parsing is intrinsically branchy
|
|
50
|
+
def parse_args(args) # rubocop:disable Metrics/MethodLength -- arg parsing is intrinsically procedural
|
|
51
|
+
format = 'markdown'
|
|
52
|
+
force = false
|
|
53
|
+
path = nil
|
|
54
|
+
|
|
55
|
+
if (idx = args.index('--format') || args.index('-f'))
|
|
56
|
+
format = args[idx + 1].to_s
|
|
57
|
+
end
|
|
58
|
+
format = case format
|
|
59
|
+
when 'md' then 'markdown'
|
|
60
|
+
else format
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
args.each do |a|
|
|
64
|
+
case a
|
|
65
|
+
when '--jsonl'
|
|
66
|
+
format = 'jsonl'
|
|
67
|
+
when '--markdown', '--md'
|
|
68
|
+
format = 'markdown'
|
|
69
|
+
when '--format', '-f'
|
|
70
|
+
# already handled above
|
|
71
|
+
when '--force', '-y'
|
|
72
|
+
force = true
|
|
73
|
+
when /\A--.+/
|
|
74
|
+
next
|
|
75
|
+
else
|
|
76
|
+
path ||= a
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return nil if path.to_s.empty?
|
|
81
|
+
|
|
82
|
+
{ path: path, format: format, force: force }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_markdown(messages)
|
|
86
|
+
title = 'Rubyn transcript'
|
|
87
|
+
buffer = String.new
|
|
88
|
+
buffer << "# #{title}\n\n"
|
|
89
|
+
buffer << "_Exported at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n"
|
|
90
|
+
messages.each { |msg| render_markdown_message(buffer, msg) }
|
|
91
|
+
buffer
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_markdown_message(buffer, msg)
|
|
95
|
+
role = msg[:role] || msg['role']
|
|
96
|
+
case role
|
|
97
|
+
when 'user'
|
|
98
|
+
text = render_user_text(msg[:content] || msg['content'])
|
|
99
|
+
buffer << "## User\n\n#{text}\n\n"
|
|
100
|
+
when 'assistant'
|
|
101
|
+
render_assistant_blocks(buffer, msg[:content] || msg['content'])
|
|
102
|
+
when 'tool'
|
|
103
|
+
buffer << "### tool result\n\n```\n#{msg[:content] || msg['content']}\n```\n\n"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_user_text(content)
|
|
108
|
+
blocks = normalize_content(content)
|
|
109
|
+
if blocks.any? { |b| block_type(b) == 'image' }
|
|
110
|
+
blocks.map { |b| block_type(b) == 'image' ? '_(image attachment)_' : text_of(b) }.join("\n")
|
|
111
|
+
else
|
|
112
|
+
blocks.map { |b| text_of(b) }.join("\n")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_assistant_blocks(buffer, content)
|
|
117
|
+
blocks = normalize_content(content)
|
|
118
|
+
if blocks.empty?
|
|
119
|
+
buffer << "## Assistant\n\n_(empty response)_\n\n"
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
blocks.each do |b|
|
|
123
|
+
render_assistant_block(buffer, b)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render_assistant_block(buffer, b) # rubocop:disable Naming/MethodParameterName -- short names are clearer here than "block"
|
|
128
|
+
case block_type(b)
|
|
129
|
+
when 'text'
|
|
130
|
+
buffer << "## Assistant\n\n#{text_of(b)}\n\n"
|
|
131
|
+
when 'thinking'
|
|
132
|
+
buffer << "<details><summary>thinking</summary>\n\n#{text_of(b)}\n\n</details>\n\n"
|
|
133
|
+
when 'tool_use'
|
|
134
|
+
buffer << "[tool: #{b[:name] || b['name']}]\n\n```json\n#{JSON.pretty_generate(input_of(b))}\n```\n\n"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render_jsonl(messages)
|
|
139
|
+
lines = messages.map do |msg|
|
|
140
|
+
role = msg[:role] || msg['role']
|
|
141
|
+
raw = msg[:content] || msg['content']
|
|
142
|
+
content = raw.is_a?(String) ? raw : normalize_content(raw)
|
|
143
|
+
"#{JSON.generate(role: role, content: content, timestamp: Time.now.utc.iso8601)}\n"
|
|
144
|
+
end
|
|
145
|
+
lines.join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Wrap a String content into a single text block so we can render uniformly.
|
|
149
|
+
def normalize_content(content)
|
|
150
|
+
return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
|
|
151
|
+
return [{ type: 'text', text: '' }] if content.nil?
|
|
152
|
+
|
|
153
|
+
content
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def block_type(block)
|
|
157
|
+
block[:type] || block['type']
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def text_of(block)
|
|
161
|
+
(block[:text] || block['text']).to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def input_of(block)
|
|
165
|
+
(block[:input] || block['input']) || {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def tty_yes?(ctx, prompt)
|
|
169
|
+
ctx.renderer.respond_to?(:ask) ? ctx.renderer.ask(prompt, default: false) : true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/goal` — set a session goal that Rubyn keeps working toward until it
|
|
7
|
+
# is met. Installs a Stop hook (Hooks::GoalHook) that blocks the agent
|
|
8
|
+
# from finishing while the goal is unmet; the goal auto-clears once an
|
|
9
|
+
# evaluator judges it satisfied.
|
|
10
|
+
#
|
|
11
|
+
# /goal <condition> set a goal and start working toward it
|
|
12
|
+
# /goal show the current goal (if any)
|
|
13
|
+
# /goal clear cancel the active goal early
|
|
14
|
+
class Goal < Base
|
|
15
|
+
def self.command_name = '/goal'
|
|
16
|
+
def self.description = 'Set a goal Rubyn works toward until met (/goal clear to cancel)'
|
|
17
|
+
|
|
18
|
+
CLEAR_WORDS = %w[clear cancel off stop].freeze
|
|
19
|
+
|
|
20
|
+
def execute(args, ctx)
|
|
21
|
+
first = args.first&.strip&.downcase
|
|
22
|
+
return clear_goal(ctx) if CLEAR_WORDS.include?(first)
|
|
23
|
+
|
|
24
|
+
condition = args.join(' ').strip
|
|
25
|
+
return show_status(ctx) if condition.empty?
|
|
26
|
+
|
|
27
|
+
set_goal(ctx, condition)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def set_goal(ctx, condition)
|
|
33
|
+
deactivate_existing(ctx)
|
|
34
|
+
evaluator = RubynCode::Goal::Evaluator.new(llm_client: ctx.llm_client)
|
|
35
|
+
ctx.hook_registry.on(:stop, Hooks::GoalHook.new(condition: condition, evaluator: evaluator), priority: 10)
|
|
36
|
+
|
|
37
|
+
ctx.renderer.info("🎯 Goal set: #{condition}")
|
|
38
|
+
ctx.renderer.info("Rubyn will keep working until it's met. /goal clear to cancel.")
|
|
39
|
+
ctx.send_message(kickoff_prompt(condition))
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear_goal(ctx)
|
|
44
|
+
if deactivate_existing(ctx).positive?
|
|
45
|
+
ctx.renderer.info('Goal cleared. ✌️')
|
|
46
|
+
else
|
|
47
|
+
ctx.renderer.info('No active goal to clear.')
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def show_status(ctx)
|
|
53
|
+
active = active_goals(ctx)
|
|
54
|
+
if active.empty?
|
|
55
|
+
ctx.renderer.info('No active goal. Set one with: /goal <what you want done>')
|
|
56
|
+
else
|
|
57
|
+
ctx.renderer.info("🎯 Active goal: #{active.first.condition}")
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Deactivate any active GoalHook(s) on the registry.
|
|
63
|
+
# @return [Integer] number of goals deactivated
|
|
64
|
+
def deactivate_existing(ctx)
|
|
65
|
+
active_goals(ctx).each(&:clear!).size
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def active_goals(ctx)
|
|
69
|
+
return [] unless ctx.hook_registry
|
|
70
|
+
|
|
71
|
+
ctx.hook_registry.hooks_for(:stop).select do |hook|
|
|
72
|
+
hook.is_a?(Hooks::GoalHook) && hook.active?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def kickoff_prompt(condition)
|
|
77
|
+
<<~PROMPT.strip
|
|
78
|
+
New session goal: #{condition}
|
|
79
|
+
|
|
80
|
+
Start working toward this goal now. Keep going until it is genuinely
|
|
81
|
+
met — don't stop to ask what to do next.
|
|
82
|
+
PROMPT
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/learning` — inspect and move the instincts Rubyn learns across
|
|
7
|
+
# sessions. Continuous learning runs automatically at the end of each
|
|
8
|
+
# session; this command lets you carry those learnings to another machine.
|
|
9
|
+
#
|
|
10
|
+
# /learning show learning stats
|
|
11
|
+
# /learning export [path] export all instincts to a JSON file
|
|
12
|
+
# /learning import <path> [--here] import instincts (--here = remap to this project)
|
|
13
|
+
class Learning < Base
|
|
14
|
+
def self.command_name = '/learning'
|
|
15
|
+
def self.description = 'Show, export, or import learned instincts (/learning export|import)'
|
|
16
|
+
|
|
17
|
+
DEFAULT_FILE = 'rubyn-learnings.json'
|
|
18
|
+
|
|
19
|
+
def execute(args, ctx)
|
|
20
|
+
case args.first
|
|
21
|
+
when 'export' then export(ctx, args[1])
|
|
22
|
+
when 'import' then import(ctx, args[1..])
|
|
23
|
+
when nil then stats(ctx)
|
|
24
|
+
else
|
|
25
|
+
ctx.renderer.info('Usage: /learning [export [path] | import <path> [--here]]')
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
rescue RubynCode::Learning::Porter::Error => e
|
|
29
|
+
ctx.renderer.error("Learning: #{e.message}")
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def stats(ctx)
|
|
36
|
+
summary = RubynCode::Learning::Porter.stats(ctx.db)
|
|
37
|
+
ctx.renderer.info("🧠 #{summary[:count]} instinct(s) learned across #{summary[:projects]} project(s).")
|
|
38
|
+
ctx.renderer.info('Move them with: /learning export → /learning import <file> --here')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export(ctx, path)
|
|
42
|
+
dest = File.expand_path(path || DEFAULT_FILE, ctx.project_root)
|
|
43
|
+
count = RubynCode::Learning::Porter.export(db: ctx.db, path: dest)
|
|
44
|
+
ctx.renderer.info("📤 Exported #{count} instinct(s) to #{dest}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def import(ctx, rest)
|
|
48
|
+
args = Array(rest)
|
|
49
|
+
here = args.delete('--here')
|
|
50
|
+
path = args.first
|
|
51
|
+
return ctx.renderer.info('Usage: /learning import <path> [--here]') unless path
|
|
52
|
+
|
|
53
|
+
remap = here ? ctx.project_root : nil
|
|
54
|
+
result = RubynCode::Learning::Porter.import(
|
|
55
|
+
db: ctx.db, path: File.expand_path(path, ctx.project_root), remap_project: remap
|
|
56
|
+
)
|
|
57
|
+
ctx.renderer.info("📥 Imported #{result[:imported]} instinct(s) (#{result[:skipped]} skipped as duplicates).")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# `/loop` — run a prompt or slash command repeatedly, mirroring Claude
|
|
7
|
+
# Code's /loop. Either on a fixed interval, or self-paced (the agent
|
|
8
|
+
# decides when the recurring task is done).
|
|
9
|
+
#
|
|
10
|
+
# /loop 5m /review run /review every 5 minutes
|
|
11
|
+
# /loop 30s check the deploy send a prompt every 30 seconds
|
|
12
|
+
# /loop x10 5m /babysit-prs ...at most 10 times
|
|
13
|
+
# /loop keep triaging issues self-paced (until the agent emits LOOP_DONE)
|
|
14
|
+
#
|
|
15
|
+
# Returns a :run_loop action; the REPL owns the actual loop so Ctrl-C
|
|
16
|
+
# stops it cleanly and slash payloads can be re-dispatched.
|
|
17
|
+
class Loop < Base
|
|
18
|
+
def self.command_name = '/loop'
|
|
19
|
+
def self.description = 'Repeat a prompt or slash command on an interval (/loop 5m /review)'
|
|
20
|
+
|
|
21
|
+
MAX_TOKEN = /\Ax(\d+)\z/i
|
|
22
|
+
|
|
23
|
+
def execute(args, ctx)
|
|
24
|
+
return usage(ctx) if args.empty?
|
|
25
|
+
|
|
26
|
+
max, rest = extract_max(args)
|
|
27
|
+
interval = LoopRunner.parse_interval(rest.first)
|
|
28
|
+
payload = (interval ? rest[1..] : rest).join(' ').strip
|
|
29
|
+
return usage(ctx) if payload.empty?
|
|
30
|
+
|
|
31
|
+
{ action: :run_loop, interval: interval, max: max, payload: payload }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Pull an optional leading "xN" max-iterations token from anywhere in
|
|
37
|
+
# the first two positions (so both `/loop x5 5m ...` and `/loop 5m ...`
|
|
38
|
+
# read naturally).
|
|
39
|
+
def extract_max(args)
|
|
40
|
+
idx = args[0, 2].index { |a| a.match?(MAX_TOKEN) }
|
|
41
|
+
return [LoopRunner::DEFAULT_MAX_ITERATIONS, args] unless idx
|
|
42
|
+
|
|
43
|
+
max = args[idx][MAX_TOKEN, 1].to_i
|
|
44
|
+
[max.positive? ? max : LoopRunner::DEFAULT_MAX_ITERATIONS, args[0...idx] + args[(idx + 1)..]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def usage(ctx)
|
|
48
|
+
ctx.renderer.info('Usage: /loop [xN] [interval] <prompt-or-/command>')
|
|
49
|
+
ctx.renderer.info(' /loop 5m /review — run /review every 5 minutes')
|
|
50
|
+
ctx.renderer.info(' /loop 30s check status — send a prompt every 30s')
|
|
51
|
+
ctx.renderer.info(' /loop x3 1m /babysit — at most 3 times')
|
|
52
|
+
ctx.renderer.info(' /loop keep triaging — self-paced (Ctrl-C to stop)')
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -8,46 +8,83 @@ module RubynCode
|
|
|
8
8
|
def self.description = 'MCP server status'
|
|
9
9
|
|
|
10
10
|
def execute(_args, ctx)
|
|
11
|
-
|
|
11
|
+
entries = load_entries(ctx.project_root)
|
|
12
12
|
|
|
13
|
-
if
|
|
13
|
+
if entries.empty?
|
|
14
14
|
ctx.renderer.info('No MCP servers configured.')
|
|
15
15
|
puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
|
|
16
16
|
return
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
ctx.renderer.info("MCP servers (#{
|
|
19
|
+
ctx.renderer.info("MCP servers (#{entries.size}):")
|
|
20
20
|
puts
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
entries.each { |entry| render_server(entry) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# Load merged user + project entries via MCP::Discovery so the
|
|
28
|
+
# output can show which servers came from `~/.rubyn-code/mcp.json`
|
|
29
|
+
# vs the project-root `.mcp.json`.
|
|
30
|
+
def load_entries(project_root)
|
|
31
|
+
MCP::Discovery.discover(project_root)
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
def
|
|
34
|
+
def source_label(source)
|
|
35
|
+
case source
|
|
36
|
+
when :project then '[project]'
|
|
37
|
+
when :user then '[user]'
|
|
38
|
+
else '[-]'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_server(entry)
|
|
43
|
+
cfg = entry_to_config(entry)
|
|
32
44
|
client = build_client(cfg)
|
|
33
|
-
status,
|
|
45
|
+
status, counts = probe_server(client)
|
|
34
46
|
icon = status_icon(status)
|
|
35
|
-
|
|
47
|
+
label = source_label(entry.respond_to?(:source) ? entry.source : :user)
|
|
36
48
|
|
|
37
|
-
puts " #{icon} #{
|
|
49
|
+
puts " #{icon} #{entry.name} #{label} [#{status}]#{capability_label(counts)}"
|
|
38
50
|
render_transport_info(cfg)
|
|
39
51
|
ensure
|
|
40
52
|
client&.disconnect! if client&.connected?
|
|
41
53
|
end
|
|
42
54
|
|
|
55
|
+
# Discovery::Entry → Config hash shape so existing renderers work.
|
|
56
|
+
def entry_to_config(entry)
|
|
57
|
+
{
|
|
58
|
+
name: entry.name,
|
|
59
|
+
command: entry.command,
|
|
60
|
+
args: entry.args,
|
|
61
|
+
env: entry.env,
|
|
62
|
+
url: entry.url
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def capability_label(counts)
|
|
67
|
+
return '' unless counts
|
|
68
|
+
|
|
69
|
+
parts = []
|
|
70
|
+
parts << "#{counts[:tools]} tools"
|
|
71
|
+
parts << "#{counts[:resources]} resources" if counts[:resources].positive?
|
|
72
|
+
parts << "#{counts[:prompts]} prompts" if counts[:prompts].positive?
|
|
73
|
+
" (#{parts.join(', ')})"
|
|
74
|
+
end
|
|
75
|
+
|
|
43
76
|
def build_client(cfg)
|
|
44
77
|
MCP::Client.from_config(cfg)
|
|
45
78
|
end
|
|
46
79
|
|
|
47
80
|
def probe_server(client)
|
|
48
81
|
client.connect!
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
counts = {
|
|
83
|
+
tools: client.tools.size,
|
|
84
|
+
resources: client.resources.size,
|
|
85
|
+
prompts: client.prompts.size
|
|
86
|
+
}
|
|
87
|
+
[:connected, counts]
|
|
51
88
|
rescue StandardError
|
|
52
89
|
[:error, nil]
|
|
53
90
|
end
|
|
@@ -13,14 +13,16 @@ module RubynCode
|
|
|
13
13
|
@classes = []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Register a command class
|
|
16
|
+
# Register a command. Accepts either a command class (built-ins, which
|
|
17
|
+
# are instantiated per dispatch) or a ready command instance
|
|
18
|
+
# (user-defined; see Commands::CustomCommand).
|
|
17
19
|
#
|
|
18
|
-
# @param
|
|
20
|
+
# @param command [Class<Commands::Base>, #execute]
|
|
19
21
|
# @return [void]
|
|
20
|
-
def register(
|
|
21
|
-
@classes <<
|
|
22
|
-
|
|
23
|
-
@commands[name] =
|
|
22
|
+
def register(command)
|
|
23
|
+
@classes << command
|
|
24
|
+
command.all_names.each do |name|
|
|
25
|
+
@commands[name] = command
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
|
|
@@ -31,10 +33,13 @@ module RubynCode
|
|
|
31
33
|
# @param ctx [Commands::Context] shared context
|
|
32
34
|
# @return [Symbol, nil] :quit if the command signals exit, nil otherwise
|
|
33
35
|
def dispatch(name, args, ctx)
|
|
34
|
-
|
|
35
|
-
return :unknown unless
|
|
36
|
+
command = @commands[name]
|
|
37
|
+
return :unknown unless command
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
# Built-ins are registered as classes (instantiate per call);
|
|
40
|
+
# user-defined commands are registered as ready instances.
|
|
41
|
+
instance = command.respond_to?(:new) ? command.new : command
|
|
42
|
+
instance.execute(args, ctx)
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
# All registered command names (for tab completion).
|