rubyn-code 0.5.1 → 0.7.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/chisel-review` — audit the current branch's changes for
7
+ # over-engineering and report a ranked deletion list. Read-only.
8
+ class ChiselReview < Base
9
+ def self.command_name = '/chisel-review'
10
+ def self.description = 'Find over-engineering in the current diff (/chisel-review [base])'
11
+
12
+ def execute(args, ctx)
13
+ # Inspection owns the 'main' default so it lives in exactly one place.
14
+ ctx.send_message(RubynCode::Chisel::Inspection.prompt(scope: :diff, target: args.first))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubynCode
6
+ module CLI
7
+ module Commands
8
+ # Renders a user-defined slash-command body into a prompt, mirroring
9
+ # Claude Code's command templating:
10
+ #
11
+ # $ARGUMENTS → all args, space-joined
12
+ # $1 .. $9 → positional args
13
+ # !`shell cmd` → replaced with the command's (combined) output
14
+ #
15
+ # Bash substitution runs the user's own command file, so it carries the
16
+ # same trust as user hooks. Output is captured defensively and capped.
17
+ class CommandTemplate
18
+ BANG = /!`([^`]+)`/
19
+ POSITIONAL = /\$([1-9])/
20
+ MAX_BASH_OUTPUT = 16 * 1024
21
+
22
+ def initialize(body)
23
+ @body = body.to_s
24
+ end
25
+
26
+ # @param args [Array<String>] arguments passed after the command name
27
+ # @return [String] the rendered prompt
28
+ def render(args = [])
29
+ text = substitute_bash(@body)
30
+ text = text.gsub('$ARGUMENTS', args.join(' '))
31
+ text.gsub(POSITIONAL) { args[Regexp.last_match(1).to_i - 1].to_s }
32
+ end
33
+
34
+ private
35
+
36
+ def substitute_bash(text)
37
+ text.gsub(BANG) { run(Regexp.last_match(1)) }
38
+ end
39
+
40
+ def run(cmd)
41
+ out, = Open3.capture2e(cmd)
42
+ out = "#{out.byteslice(0, MAX_BASH_OUTPUT)}\n… [truncated]" if out.bytesize > MAX_BASH_OUTPUT
43
+ out.strip
44
+ rescue StandardError => e
45
+ "[command failed: #{e.message}]"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -21,7 +21,9 @@ module RubynCode
21
21
  :background_worker,
22
22
  :permission_tier,
23
23
  :plan_mode,
24
- :message_handler
24
+ :message_handler,
25
+ :hook_registry,
26
+ :checkpoint_manager
25
27
  ) do
26
28
  # Convenience: return a new Context with a message handler attached.
27
29
  # Used by commands like /review that delegate to the LLM.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # A slash command defined by a user markdown file (loaded by
7
+ # CustomLoader). Unlike the built-in commands — which are registered as
8
+ # classes — this is a ready instance: the Registry dispatches it directly.
9
+ #
10
+ # Executing it renders the template (argument / bash substitution) and
11
+ # sends the result to the agent as a normal prompt.
12
+ class CustomCommand
13
+ # @return [String] command name without the leading slash
14
+ attr_reader :name
15
+ # @return [String] one-line description for /help
16
+ attr_reader :description
17
+ # @return [String, nil] originating file path
18
+ attr_reader :source
19
+
20
+ def initialize(name:, description:, body:, source: nil)
21
+ @name = name
22
+ @description = description
23
+ @template = CommandTemplate.new(body)
24
+ @source = source
25
+ end
26
+
27
+ def command_name = "/#{@name}"
28
+ def aliases = [].freeze
29
+ def hidden? = false
30
+ def all_names = [command_name].freeze
31
+
32
+ # @param args [Array<String>]
33
+ # @param ctx [Commands::Context]
34
+ # @return [nil]
35
+ def execute(args, ctx)
36
+ ctx.send_message(@template.render(args))
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module RubynCode
6
+ module CLI
7
+ module Commands
8
+ # Discovers user-defined slash commands from markdown files, mirroring
9
+ # Claude Code's `.claude/commands/*.md`:
10
+ #
11
+ # <project>/.rubyn-code/commands/*.md (project-local, takes priority)
12
+ # ~/.rubyn-code/commands/*.md (user-global)
13
+ #
14
+ # Each `deploy.md` becomes `/deploy`. Optional YAML frontmatter supplies
15
+ # the `description`; otherwise the first non-empty line is used. The body
16
+ # is the prompt template (see CommandTemplate for substitutions).
17
+ module CustomLoader
18
+ FRONTMATTER = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
19
+ NAME = /\A[a-z0-9][a-z0-9_-]*\z/i
20
+
21
+ class << self
22
+ # @return [Array<CustomCommand>] unique commands (project overrides user)
23
+ def load_all(project_root:, home_dir: Config::Defaults::HOME_DIR)
24
+ dirs = [
25
+ project_root && File.join(project_root, '.rubyn-code', 'commands'),
26
+ File.join(home_dir, 'commands')
27
+ ].compact
28
+ dirs.flat_map { |dir| load_dir(dir) }.uniq(&:command_name)
29
+ end
30
+
31
+ def load_dir(dir)
32
+ return [] unless Dir.exist?(dir)
33
+
34
+ Dir.glob(File.join(dir, '*.md')).filter_map { |path| build(path) }
35
+ end
36
+
37
+ private
38
+
39
+ def build(path)
40
+ name = File.basename(path, '.md').strip
41
+ return nil unless name.match?(NAME)
42
+
43
+ description, body = parse(File.read(path))
44
+ description = "Custom command: /#{name}" if description.to_s.strip.empty?
45
+ CustomCommand.new(name: name, description: description, body: body, source: path)
46
+ rescue StandardError => e
47
+ RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
48
+ nil
49
+ end
50
+
51
+ def parse(content)
52
+ if (match = FRONTMATTER.match(content))
53
+ frontmatter = YAML.safe_load(match[1]) || {}
54
+ [frontmatter['description'].to_s, match[2].to_s.strip]
55
+ else
56
+ body = content.to_s.strip
57
+ [first_line(body), body]
58
+ end
59
+ end
60
+
61
+ def first_line(body)
62
+ line = body.lines.first.to_s.strip
63
+ line.sub(/\A#+\s*/, '')[0, 80]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ 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
@@ -30,24 +30,37 @@ module RubynCode
30
30
 
31
31
  def render_server(cfg)
32
32
  client = build_client(cfg)
33
- status, tool_count = probe_server(client)
33
+ status, counts = probe_server(client)
34
34
  icon = status_icon(status)
35
- tools_label = tool_count ? " (#{tool_count} tools)" : ''
36
35
 
37
- puts " #{icon} #{cfg[:name]} [#{status}]#{tools_label}"
36
+ puts " #{icon} #{cfg[:name]} [#{status}]#{capability_label(counts)}"
38
37
  render_transport_info(cfg)
39
38
  ensure
40
39
  client&.disconnect! if client&.connected?
41
40
  end
42
41
 
42
+ def capability_label(counts)
43
+ return '' unless counts
44
+
45
+ parts = []
46
+ parts << "#{counts[:tools]} tools"
47
+ parts << "#{counts[:resources]} resources" if counts[:resources].positive?
48
+ parts << "#{counts[:prompts]} prompts" if counts[:prompts].positive?
49
+ " (#{parts.join(', ')})"
50
+ end
51
+
43
52
  def build_client(cfg)
44
53
  MCP::Client.from_config(cfg)
45
54
  end
46
55
 
47
56
  def probe_server(client)
48
57
  client.connect!
49
- tool_count = client.tools.size
50
- [:connected, tool_count]
58
+ counts = {
59
+ tools: client.tools.size,
60
+ resources: client.resources.size,
61
+ prompts: client.prompts.size
62
+ }
63
+ [:connected, counts]
51
64
  rescue StandardError
52
65
  [:error, nil]
53
66
  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).
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/rewind` — restore the session to an earlier checkpoint, mirroring
7
+ # Claude Code's /rewind. A checkpoint is taken at the start of each user
8
+ # turn, capturing the conversation and the original contents of any files
9
+ # changed that turn.
10
+ #
11
+ # /rewind list checkpoints
12
+ # /rewind <id> restore code + conversation to checkpoint <id>
13
+ # /rewind <id> code restore only the files
14
+ # /rewind <id> chat restore only the conversation
15
+ class Rewind < Base
16
+ def self.command_name = '/rewind'
17
+ def self.description = 'Rewind to an earlier checkpoint (/rewind to list)'
18
+
19
+ SCOPES = { 'code' => :code, 'chat' => :chat, 'both' => :both }.freeze
20
+
21
+ def execute(args, ctx)
22
+ manager = ctx.checkpoint_manager
23
+ return ctx.renderer.info('Checkpoints are not available in this session.') unless manager
24
+
25
+ id = args.first
26
+ return list(manager, ctx) if id.nil?
27
+
28
+ restore(manager, ctx, id, args[1])
29
+ end
30
+
31
+ private
32
+
33
+ def list(manager, ctx)
34
+ checkpoints = manager.list
35
+ return ctx.renderer.info('No checkpoints yet — they are created as you work.') if checkpoints.empty?
36
+
37
+ ctx.renderer.info('Checkpoints (newest last):')
38
+ checkpoints.each do |cp|
39
+ puts " #{cp[:id].to_s.rjust(3)} #{cp[:files]} file(s) — #{cp[:label]}"
40
+ end
41
+ puts
42
+ ctx.renderer.info('Restore with: /rewind <id> [code|chat]')
43
+ nil
44
+ end
45
+
46
+ def restore(manager, ctx, id_arg, scope_arg)
47
+ scope = SCOPES.fetch(scope_arg.to_s, :both)
48
+ result = manager.restore(id_arg.to_i, ctx.conversation, scope: scope)
49
+ return ctx.renderer.warning("No checkpoint ##{id_arg}.") unless result
50
+
51
+ ctx.renderer.info(restored_message(result, scope))
52
+ { action: :rewound }
53
+ end
54
+
55
+ def restored_message(result, scope)
56
+ case scope
57
+ when :code then "⏪ Restored #{result[:files_restored]} file(s) to checkpoint ##{result[:id]}."
58
+ when :chat then "⏪ Restored conversation to checkpoint ##{result[:id]}."
59
+ else "⏪ Rewound to checkpoint ##{result[:id]} (#{result[:files_restored]} file(s) + conversation)."
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -136,7 +136,7 @@ module RubynCode
136
136
  def default_model(provider)
137
137
  return 'gpt-5.4' if provider == 'openai'
138
138
 
139
- 'claude-opus-4-6'
139
+ 'claude-opus-4-8'
140
140
  end
141
141
 
142
142
  def display_summary
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Drives a repeating execution of a payload (a prompt or slash command).
6
+ #
7
+ # Pure orchestration with injected dependencies so it can be unit-tested
8
+ # without a REPL: the caller supplies a `runner` callable that performs one
9
+ # iteration and a `sleeper` for the wait between iterations.
10
+ #
11
+ # Two modes:
12
+ # - interval mode (interval is a positive number of seconds): run, wait,
13
+ # run, ... until max_iterations or stopped.
14
+ # - self-paced mode (interval is nil): run back-to-back until the runner
15
+ # returns :stop, emits the DONE_SENTINEL, or max_iterations is hit.
16
+ class LoopRunner
17
+ DEFAULT_MAX_ITERATIONS = 50
18
+ DONE_SENTINEL = 'LOOP_DONE'
19
+ UNITS = { 's' => 1, 'm' => 60, 'h' => 3600 }.freeze
20
+
21
+ # Parse an interval token like "30s", "5m", "2h", or a bare "45"
22
+ # (seconds). Returns nil when the token is not a valid interval.
23
+ #
24
+ # @param token [String, nil]
25
+ # @return [Integer, nil] seconds, or nil
26
+ def self.parse_interval(token)
27
+ return nil if token.nil?
28
+
29
+ match = token.strip.match(/\A(\d+)\s*([smh]?)\z/i)
30
+ return nil unless match
31
+
32
+ amount = match[1].to_i
33
+ return nil if amount <= 0
34
+
35
+ amount * UNITS.fetch(match[2].downcase, 1)
36
+ end
37
+
38
+ # @param interval [Integer, nil] seconds between runs; nil => self-paced
39
+ # @param runner [#call] ->(iteration_index) => String|:stop
40
+ # @param max_iterations [Integer] hard cap on iterations
41
+ # @param sleeper [#call, nil] ->(seconds); defaults to Kernel#sleep
42
+ # @param on_iteration [#call, nil] ->(n, total) UI callback before each run
43
+ def initialize(runner:, interval: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
44
+ sleeper: nil, on_iteration: nil)
45
+ @interval = interval
46
+ @runner = runner
47
+ @max_iterations = max_iterations
48
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
49
+ @on_iteration = on_iteration
50
+ @stopped = false
51
+ end
52
+
53
+ # Request the loop to stop after the current iteration.
54
+ # @return [void]
55
+ def stop!
56
+ @stopped = true
57
+ end
58
+
59
+ # @return [Boolean]
60
+ def stopped? = @stopped
61
+
62
+ # Run the loop.
63
+ #
64
+ # @return [Integer] number of iterations completed
65
+ def run
66
+ completed = 0
67
+ @max_iterations.times do |index|
68
+ break if @stopped
69
+
70
+ @on_iteration&.call(index + 1, @max_iterations)
71
+ result = @runner.call(index)
72
+ completed += 1
73
+
74
+ break if @stopped || done?(result)
75
+
76
+ wait_before_next(index)
77
+ end
78
+ completed
79
+ rescue Interrupt
80
+ completed
81
+ end
82
+
83
+ private
84
+
85
+ def wait_before_next(index)
86
+ return unless @interval && index < @max_iterations - 1
87
+
88
+ @sleeper.call(@interval)
89
+ end
90
+
91
+ def done?(result)
92
+ return true if result == :stop
93
+
94
+ result.is_a?(String) && result.include?(DONE_SENTINEL)
95
+ end
96
+ end
97
+ end
98
+ end