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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. 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
- configs = load_configs(ctx.project_root)
11
+ entries = load_entries(ctx.project_root)
12
12
 
13
- if configs.empty?
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 (#{configs.size}):")
19
+ ctx.renderer.info("MCP servers (#{entries.size}):")
20
20
  puts
21
21
 
22
- configs.each { |cfg| render_server(cfg) }
22
+ entries.each { |entry| render_server(entry) }
23
23
  end
24
24
 
25
25
  private
26
26
 
27
- def load_configs(project_root)
28
- MCP::Config.load(project_root)
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 render_server(cfg)
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, tool_count = probe_server(client)
45
+ status, counts = probe_server(client)
34
46
  icon = status_icon(status)
35
- tools_label = tool_count ? " (#{tool_count} tools)" : ''
47
+ label = source_label(entry.respond_to?(:source) ? entry.source : :user)
36
48
 
37
- puts " #{icon} #{cfg[:name]} [#{status}]#{tools_label}"
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
- tool_count = client.tools.size
50
- [:connected, tool_count]
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
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module CLI
@@ -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 command_class [Class<Commands::Base>]
20
+ # @param command [Class<Commands::Base>, #execute]
19
21
  # @return [void]
20
- def register(command_class)
21
- @classes << command_class
22
- command_class.all_names.each do |name|
23
- @commands[name] = command_class
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
- command_class = @commands[name]
35
- return :unknown unless command_class
36
+ command = @commands[name]
37
+ return :unknown unless command
36
38
 
37
- command_class.new.execute(args, ctx)
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).