rubyn-code 0.5.0 → 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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  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/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/megaplan` REPL command — mirrors the VS Code chat's chat-resident
7
+ # megaplan entry. Loads the megaplan skill (from the shared
8
+ # skills catalog) into the conversation, flips plan mode ON so the
9
+ # agent is restricted to read-only tools, and kicks off a
10
+ # conversational interview.
11
+ #
12
+ # The interview itself is chat-style here (multi-turn natural-
13
+ # language Q&A), not the structured question-card UI the VS Code
14
+ # extension uses. Same skill content driving both surfaces.
15
+ class Megaplan < Base
16
+ def self.command_name = '/megaplan'
17
+ def self.description = 'Plan a feature in phases (interview, then numbered phase docs)'
18
+ def self.aliases = ['/mega-plan'].freeze
19
+
20
+ def execute(args, ctx)
21
+ content = ctx.skill_loader.load('megaplan')
22
+ ctx.conversation.add_user_message("<skill>#{content}</skill>")
23
+ ctx.renderer.info('Megaplan mode — interviewer with read-only tools 🧠')
24
+
25
+ ctx.send_message(build_prompt(args.join(' ').strip))
26
+
27
+ { action: :set_plan_mode, enabled: true }
28
+ rescue StandardError => e
29
+ ctx.renderer.error("Megaplan error: #{e.message}")
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def build_prompt(feature)
36
+ base = <<~PROMPT.strip
37
+ Conduct a megaplan interview, following the megaplan skill loaded above.
38
+ Stay strictly read-only: you may inspect files, search code, and check
39
+ git state, but do NOT edit, write, run shell mutations, or call any
40
+ destructive tool. Ask ONE question at a time. When you have enough,
41
+ output the final phase breakdown as a numbered outline.
42
+ PROMPT
43
+ return base if feature.empty?
44
+
45
+ "#{base}\n\nThe feature to plan: #{feature}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ 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 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
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Expands `@path/to/file` mentions in user input into inline file content,
6
+ # mirroring Claude Code / Codex `@`-mentions. The original text is kept and
7
+ # the referenced files are appended as labeled, fenced blocks so the agent
8
+ # sees both the request and the files it points at.
9
+ #
10
+ # Conservative by design: only existing, readable, reasonably-sized regular
11
+ # files inside the project are expanded. Unresolved mentions (and things
12
+ # that merely look like mentions, e.g. email addresses) are left untouched.
13
+ class MentionExpander
14
+ # A mention is an "@" that doesn't follow a word char or another "@"
15
+ # (so emails like foo@bar.com don't match), followed by a path-ish run.
16
+ MENTION = /(?<![\w@])@([^\s@]+)/
17
+ TRAILING_PUNCT = /[).,;:!?'"]+\z/
18
+ MAX_FILE_BYTES = 64 * 1024
19
+ MAX_FILES = 10
20
+
21
+ # @param project_root [String]
22
+ def initialize(project_root:)
23
+ @project_root = project_root
24
+ end
25
+
26
+ # @param input [String] raw user input
27
+ # @return [Array(String, Array<String>)] expanded text + resolved rel paths
28
+ def expand(input)
29
+ return [input, []] unless input.is_a?(String) && input.include?('@')
30
+
31
+ resolved = scan(input)
32
+ return [input, []] if resolved.empty?
33
+
34
+ blocks = resolved.map { |rel, abs| file_block(rel, abs) }
35
+ ["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
36
+ end
37
+
38
+ private
39
+
40
+ # @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
41
+ def scan(input)
42
+ seen = {}
43
+ input.scan(MENTION).each do |(raw)|
44
+ rel = raw.sub(TRAILING_PUNCT, '')
45
+ next if rel.empty?
46
+
47
+ abs = resolve(rel)
48
+ next unless abs && !seen.key?(abs)
49
+
50
+ seen[abs] = rel
51
+ break if seen.size >= MAX_FILES
52
+ end
53
+ seen.map { |abs, rel| [rel, abs] }
54
+ end
55
+
56
+ # Resolve a mention to an absolute path inside the project, or nil.
57
+ def resolve(rel)
58
+ abs = File.expand_path(rel, @project_root)
59
+ return nil unless inside_project?(abs)
60
+ return nil unless File.file?(abs)
61
+
62
+ abs
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ def inside_project?(abs)
68
+ root = File.expand_path(@project_root)
69
+ abs == root || abs.start_with?("#{root}#{File::SEPARATOR}")
70
+ end
71
+
72
+ def file_block(rel, abs)
73
+ body = read_truncated(abs)
74
+ <<~BLOCK.strip
75
+ @#{rel}:
76
+ ```
77
+ #{body}
78
+ ```
79
+ BLOCK
80
+ end
81
+
82
+ def read_truncated(abs)
83
+ content = File.read(abs, MAX_FILE_BYTES + 1)
84
+ return content if content.bytesize <= MAX_FILE_BYTES
85
+
86
+ "#{content.byteslice(0, MAX_FILE_BYTES)}\n… [truncated]"
87
+ rescue StandardError => e
88
+ "[could not read file: #{e.message}]"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pastel'
4
- require 'rouge'
5
4
 
6
5
  module RubynCode
7
6
  module CLI
8
7
  class Renderer
9
8
  def initialize
10
9
  @pastel = Pastel.new
11
- @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
12
10
  end
13
11
 
14
12
  attr_writer :yolo
@@ -177,6 +175,9 @@ module RubynCode
177
175
  end
178
176
 
179
177
  def render_code_block(code, lang)
178
+ # Lazy so REPL boot never pays for rouge until a code block is rendered.
179
+ require 'rouge'
180
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
180
181
  lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new
181
182
  highlighted = @rouge_formatter.format(lexer.lex(code))
182
183
  border = @pastel.dim(' │ ')
@@ -31,10 +31,10 @@ module RubynCode
31
31
  def run
32
32
  @version_check = VersionCheck.new(renderer: @renderer)
33
33
  @version_check.start
34
+ @auto_suggest = Skills::AutoSuggest.new(project_root: @project_root)
35
+ @auto_suggest.start
34
36
 
35
37
  @renderer.welcome
36
- @version_check.notify
37
- check_skill_suggestions!
38
38
 
39
39
  at_exit { shutdown! }
40
40
 
@@ -45,17 +45,20 @@ module RubynCode
45
45
 
46
46
  private
47
47
 
48
- def check_skill_suggestions!
49
- suggest = Skills::AutoSuggest.new(project_root: @project_root)
50
- message = suggest.check
48
+ # Surface results of the background startup checks (version, skill
49
+ # suggestions) between prompts — never blocking input on the network.
50
+ def flush_startup_notices!
51
+ @version_check&.notify
52
+ message = @auto_suggest&.pending_message
51
53
  @renderer.info(message) if message
52
54
  rescue StandardError
53
- # Never block session start on suggestion failure
55
+ # Never block the prompt on startup notices
54
56
  end
55
57
 
56
58
  def run_input_loop
57
59
  while @running
58
60
  begin
61
+ flush_startup_notices!
59
62
  input = read_input
60
63
  break if input.nil?
61
64
 
@@ -109,26 +112,26 @@ module RubynCode
109
112
 
110
113
  # -- sequential steps with interrupt rescue
111
114
  def handle_message(input)
115
+ # Checkpoint before the turn (raw input as label), then expand
116
+ # @-mentions so the agent sees referenced file contents.
117
+ @checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
118
+ input = expand_mentions(input)
112
119
  @spinner.start
113
120
  @streaming_first_chunk = true
114
121
 
115
122
  response = @agent_loop.send_message(input)
116
123
 
117
124
  @spinner.stop
118
- if @streaming_first_chunk
119
- @renderer.display(response)
120
- else
121
- @stream_formatter&.flush
122
- @stream_formatter = nil
123
- puts
124
- end
125
-
125
+ render_response(response)
126
126
  save_session!
127
+ response
127
128
  rescue Interrupt
128
129
  @spinner.stop
129
130
  puts
130
131
  @renderer.warning('Interrupted — session state preserved')
132
+ @current_loop&.stop! # Ctrl-C during a /loop iteration stops the loop
131
133
  save_session!
134
+ nil
132
135
  rescue BudgetExceededError => e
133
136
  @spinner.error
134
137
  @renderer.error("Budget exceeded: #{e.message}")
@@ -137,6 +140,26 @@ module RubynCode
137
140
  @renderer.error("Error: #{e.message}")
138
141
  end
139
142
 
143
+ def render_response(response)
144
+ if @streaming_first_chunk
145
+ @renderer.display(response)
146
+ else
147
+ @stream_formatter&.flush
148
+ @stream_formatter = nil
149
+ puts
150
+ end
151
+ end
152
+
153
+ # Expand @path file mentions into inline content before the agent sees
154
+ # the message. Surfaces what was attached so the user knows.
155
+ def expand_mentions(input)
156
+ expanded, paths = @mention_expander.expand(input)
157
+ @renderer.info("📎 Attached: #{paths.join(', ')}") unless paths.empty?
158
+ expanded
159
+ rescue StandardError
160
+ input
161
+ end
162
+
140
163
  def setup_readline!
141
164
  completions = @command_registry.completions
142
165
 
@@ -23,8 +23,26 @@ module RubynCode
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
24
  Commands::Model, Commands::NewSession, Commands::Mcp,
25
25
  Commands::Provider, Commands::InstallSkills,
26
- Commands::RemoveSkills, Commands::Skills
26
+ Commands::RemoveSkills, Commands::Skills,
27
+ Commands::Megaplan, Commands::Goal, Commands::Loop,
28
+ Commands::Agents, Commands::Learning, Commands::Rewind,
29
+ Commands::Chisel, Commands::ChiselReview, Commands::ChiselAudit,
30
+ Commands::ChiselDebt, Commands::ChiselGain
27
31
  ].each { |cmd| @command_registry.register(cmd) }
32
+ register_custom_commands!
33
+ end
34
+
35
+ # Load user-defined slash commands from .rubyn-code/commands/*.md and
36
+ # ~/.rubyn-code/commands/*.md. Built-in commands always win — a custom
37
+ # file can't shadow /help, /quit, etc.
38
+ def register_custom_commands!
39
+ Commands::CustomLoader.load_all(project_root: @project_root).each do |cmd|
40
+ next if @command_registry.known?(cmd.command_name)
41
+
42
+ @command_registry.register(cmd)
43
+ end
44
+ rescue StandardError => e
45
+ RubynCode::Debug.warn("Custom command load failed: #{e.message}")
28
46
  end
29
47
 
30
48
  def handle_command(command)
@@ -66,7 +84,9 @@ module RubynCode
66
84
  background_worker: @background_worker,
67
85
  permission_tier: @permission_tier,
68
86
  plan_mode: @plan_mode,
69
- message_handler: method(:handle_message)
87
+ message_handler: method(:handle_message),
88
+ hook_registry: @hook_registry,
89
+ checkpoint_manager: @checkpoint_manager
70
90
  )
71
91
  end
72
92
 
@@ -84,11 +104,66 @@ module RubynCode
84
104
  apply_provider(provider, rest[:model])
85
105
  in { action: :spawn_teammate, name: String => name, role: String => role }
86
106
  spawn_teammate(name, role)
107
+ in { action: :run_loop, interval:, max: Integer => max, payload: String => payload }
108
+ run_loop(interval, max, payload)
109
+ in { action: :rewound }
110
+ save_session!
87
111
  else
88
112
  # Unknown result hash — ignore
89
113
  end
90
114
  end
91
115
 
116
+ # Drive a /loop. Owned by the REPL so it runs on the main thread (Ctrl-C
117
+ # stops it) and can re-dispatch slash-command payloads.
118
+ def run_loop(interval, max, payload)
119
+ announce_loop(interval, max)
120
+ payload = decorate_self_paced(payload) unless interval
121
+ @current_loop = CLI::LoopRunner.new(
122
+ interval: interval, max_iterations: max,
123
+ runner: ->(_i) { run_loop_payload(payload) },
124
+ on_iteration: ->(n, total) { @renderer.system_message("🔁 loop #{n}/#{total}") }
125
+ )
126
+ completed = @current_loop.run
127
+ @renderer.info("🔁 Loop finished after #{completed} iteration#{'s' unless completed == 1}.")
128
+ rescue Interrupt
129
+ @renderer.warning('Loop interrupted.')
130
+ ensure
131
+ @current_loop = nil
132
+ end
133
+
134
+ def announce_loop(interval, max)
135
+ cadence = interval ? "every #{format_interval(interval)}" : 'self-paced'
136
+ @renderer.info("🔁 Looping #{cadence} (up to #{max}×). Press Ctrl-C to stop.")
137
+ end
138
+
139
+ # Execute one iteration. Slash-command payloads are re-dispatched; plain
140
+ # prompts go through the agent. Returns the agent's text so self-paced
141
+ # loops can detect the LOOP_DONE sentinel.
142
+ def run_loop_payload(payload)
143
+ if payload.start_with?('/')
144
+ name, *rest = payload.split
145
+ dispatch_slash_command(name, rest)
146
+ ''
147
+ else
148
+ handle_message(payload).to_s
149
+ end
150
+ end
151
+
152
+ def decorate_self_paced(payload)
153
+ return payload if payload.start_with?('/')
154
+
155
+ "#{payload}\n\n(You are in a self-paced loop. Keep handling this " \
156
+ 'recurring task. When it no longer needs to run, end your reply ' \
157
+ "with #{CLI::LoopRunner::DONE_SENTINEL}.)"
158
+ end
159
+
160
+ def format_interval(seconds)
161
+ return "#{seconds / 3600}h" if (seconds % 3600).zero? && seconds >= 3600
162
+ return "#{seconds / 60}m" if (seconds % 60).zero? && seconds >= 60
163
+
164
+ "#{seconds}s"
165
+ end
166
+
92
167
  def start_new_session(new_id)
93
168
  @session_id = new_id
94
169
  @skills_injected = false # re-inject skills on next message