ruby-claw 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +214 -10
  4. data/exe/claw +42 -1
  5. data/lib/claw/auto_forge.rb +66 -0
  6. data/lib/claw/benchmark/benchmark.rb +79 -0
  7. data/lib/claw/benchmark/diff.rb +69 -0
  8. data/lib/claw/benchmark/report.rb +87 -0
  9. data/lib/claw/benchmark/runner.rb +91 -0
  10. data/lib/claw/benchmark/scorer.rb +69 -0
  11. data/lib/claw/benchmark/task.rb +63 -0
  12. data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
  13. data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
  14. data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
  15. data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
  16. data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
  17. data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
  18. data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
  19. data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
  20. data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
  21. data/lib/claw/benchmark/trigger.rb +68 -0
  22. data/lib/claw/chat.rb +119 -6
  23. data/lib/claw/child_runtime.rb +196 -0
  24. data/lib/claw/cli.rb +177 -0
  25. data/lib/claw/commands.rb +131 -0
  26. data/lib/claw/config.rb +5 -1
  27. data/lib/claw/console/event_logger.rb +69 -0
  28. data/lib/claw/console/public/app.js +264 -0
  29. data/lib/claw/console/public/style.css +330 -0
  30. data/lib/claw/console/server.rb +253 -0
  31. data/lib/claw/console/sse.rb +28 -0
  32. data/lib/claw/console/views/experiments.erb +8 -0
  33. data/lib/claw/console/views/index.erb +27 -0
  34. data/lib/claw/console/views/layout.erb +29 -0
  35. data/lib/claw/console/views/memory.erb +13 -0
  36. data/lib/claw/console/views/monitor.erb +15 -0
  37. data/lib/claw/console/views/prompt.erb +15 -0
  38. data/lib/claw/console/views/snapshots.erb +12 -0
  39. data/lib/claw/console/views/tools.erb +13 -0
  40. data/lib/claw/console/views/traces.erb +9 -0
  41. data/lib/claw/console.rb +5 -0
  42. data/lib/claw/evolution.rb +227 -0
  43. data/lib/claw/forge.rb +144 -0
  44. data/lib/claw/hub.rb +67 -0
  45. data/lib/claw/init.rb +199 -0
  46. data/lib/claw/knowledge.rb +36 -2
  47. data/lib/claw/memory_store.rb +2 -2
  48. data/lib/claw/plan_mode.rb +110 -0
  49. data/lib/claw/resource.rb +35 -0
  50. data/lib/claw/resources/binding_resource.rb +128 -0
  51. data/lib/claw/resources/context_resource.rb +73 -0
  52. data/lib/claw/resources/filesystem_resource.rb +107 -0
  53. data/lib/claw/resources/memory_resource.rb +74 -0
  54. data/lib/claw/resources/worktree_resource.rb +133 -0
  55. data/lib/claw/roles.rb +56 -0
  56. data/lib/claw/runtime.rb +189 -0
  57. data/lib/claw/serializer.rb +10 -7
  58. data/lib/claw/tool.rb +99 -0
  59. data/lib/claw/tool_index.rb +84 -0
  60. data/lib/claw/tool_registry.rb +100 -0
  61. data/lib/claw/trace.rb +86 -0
  62. data/lib/claw/tui/agent_executor.rb +92 -0
  63. data/lib/claw/tui/chat_panel.rb +81 -0
  64. data/lib/claw/tui/command_bar.rb +22 -0
  65. data/lib/claw/tui/file_card.rb +88 -0
  66. data/lib/claw/tui/folding.rb +80 -0
  67. data/lib/claw/tui/input_handler.rb +73 -0
  68. data/lib/claw/tui/layout.rb +34 -0
  69. data/lib/claw/tui/messages.rb +31 -0
  70. data/lib/claw/tui/model.rb +411 -0
  71. data/lib/claw/tui/object_explorer.rb +136 -0
  72. data/lib/claw/tui/status_bar.rb +30 -0
  73. data/lib/claw/tui/status_panel.rb +133 -0
  74. data/lib/claw/tui/styles.rb +58 -0
  75. data/lib/claw/tui/tui.rb +54 -0
  76. data/lib/claw/version.rb +1 -1
  77. data/lib/claw.rb +99 -1
  78. metadata +223 -7
data/lib/claw/chat.rb CHANGED
@@ -19,6 +19,7 @@ module Claw
19
19
 
20
20
  CONT_PROMPT = "\e[2m \e[0m"
21
21
  EXIT_COMMANDS = /\A(exit|quit|bye|q)\z/i
22
+ SLASH_COMMANDS = %w[snapshot rollback diff history status evolve].freeze
22
23
 
23
24
  HISTORY_FILE = File.join(Dir.home, ".claw_history")
24
25
  HISTORY_MAX = 1000
@@ -28,6 +29,7 @@ module Claw
28
29
  load_history
29
30
  load_compiled_methods(caller_binding)
30
31
  restore_runtime(caller_binding)
32
+ @runtime = init_reversible_runtime(caller_binding)
31
33
  puts "#{DIM}Claw agent · type 'exit' to quit#{RESET}"
32
34
  puts
33
35
 
@@ -37,7 +39,9 @@ module Claw
37
39
  next if input.strip.empty?
38
40
  break if input.strip.match?(EXIT_COMMANDS)
39
41
 
40
- if input.start_with?("!")
42
+ if input.start_with?("/")
43
+ handle_slash_command(input.strip)
44
+ elsif input.start_with?("!")
41
45
  eval_ruby(caller_binding, input[1..].strip)
42
46
  elsif ruby_syntax?(input)
43
47
  eval_ruby(caller_binding, input) { run_claw(caller_binding, input) }
@@ -65,7 +69,7 @@ module Claw
65
69
  end
66
70
  private_class_method :load_history
67
71
 
68
- # Reload mana def compiled methods from .mana_cache/ on session start.
72
+ # Reload mana def compiled methods from .ruby-mana/cache/ on session start.
69
73
  # These are pre-compiled Ruby methods that don't need LLM calls.
70
74
  def self.load_compiled_methods(caller_binding)
71
75
  cache_dir = Mana::Compiler.cache_dir
@@ -86,20 +90,20 @@ module Claw
86
90
  end
87
91
  private_class_method :load_compiled_methods
88
92
 
89
- # Save Ruby runtime state (variables + method definitions) to .mana/
93
+ # Save Ruby runtime state (variables + method definitions) to .ruby-claw/
90
94
  def self.save_runtime(caller_binding)
91
95
  return unless Claw.config.persist_session
92
- dir = File.join(Dir.pwd, ".mana")
96
+ dir = File.join(Dir.pwd, ".ruby-claw")
93
97
  Claw::Serializer.save(caller_binding, dir)
94
98
  rescue => e
95
99
  $stderr.puts "#{DIM} ⚠ could not save runtime: #{e.message}#{RESET}" if Mana.config.verbose
96
100
  end
97
101
  private_class_method :save_runtime
98
102
 
99
- # Restore Ruby runtime state from .mana/
103
+ # Restore Ruby runtime state from .ruby-claw/
100
104
  def self.restore_runtime(caller_binding)
101
105
  return unless Claw.config.persist_session
102
- dir = File.join(Dir.pwd, ".mana")
106
+ dir = File.join(Dir.pwd, ".ruby-claw")
103
107
  return unless File.exist?(File.join(dir, "values.json")) || File.exist?(File.join(dir, "definitions.rb"))
104
108
 
105
109
  warnings = Claw::Serializer.restore(caller_binding, dir)
@@ -110,6 +114,100 @@ module Claw
110
114
  end
111
115
  private_class_method :restore_runtime
112
116
 
117
+ # --- Reversible Runtime ---
118
+
119
+ # Initialize the reversible runtime with all resource types.
120
+ def self.init_reversible_runtime(caller_binding)
121
+ runtime = Claw::Runtime.new
122
+
123
+ # Register context (mana conversation state)
124
+ context = Mana::Context.current
125
+ runtime.register("context", Claw::Resources::ContextResource.new(context))
126
+
127
+ # Register memory (claw long-term facts)
128
+ memory = Claw.memory
129
+ runtime.register("memory", Claw::Resources::MemoryResource.new(memory)) if memory
130
+
131
+ # Register binding (local variables)
132
+ runtime.register("binding", Claw::Resources::BindingResource.new(
133
+ caller_binding,
134
+ on_exclude: ->(name, e) {
135
+ puts "#{DIM} ⚠ #{name} excluded from runtime: #{e.message}#{RESET}"
136
+ }
137
+ ))
138
+
139
+ # Register filesystem (.ruby-claw/ directory)
140
+ claw_dir = File.join(Dir.pwd, ".ruby-claw")
141
+ if File.directory?(claw_dir)
142
+ runtime.register("filesystem", Claw::Resources::FilesystemResource.new(claw_dir))
143
+ end
144
+
145
+ # Initial snapshot
146
+ runtime.snapshot!(label: "session_start")
147
+ puts "#{DIM} ✓ runtime initialized (#{runtime.resources.size} resources)#{RESET}"
148
+ runtime
149
+ rescue => e
150
+ puts "#{DIM} ⚠ runtime init failed: #{e.message}#{RESET}"
151
+ nil
152
+ end
153
+ private_class_method :init_reversible_runtime
154
+
155
+ # Handle /command inputs — delegates to Claw::Commands and renders output.
156
+ def self.handle_slash_command(input)
157
+ cmd, *args = input.sub(/\A\//, "").split(" ", 2)
158
+ arg = args.first
159
+
160
+ result = Claw::Commands.dispatch(cmd, arg, runtime: @runtime)
161
+ render_command_result(result, cmd)
162
+ end
163
+ private_class_method :handle_slash_command
164
+
165
+ # Render a Commands result Hash to the terminal.
166
+ def self.render_command_result(result, cmd)
167
+ case result[:type]
168
+ when :success
169
+ puts "#{DIM} ✓ #{result[:message]}#{RESET}"
170
+ when :error
171
+ puts "#{ERROR_COLOR}#{result[:message]}#{RESET}"
172
+ if result[:data].is_a?(Hash) && result[:data][:available]
173
+ puts "#{DIM}Available: #{result[:data][:available].join(', ')}#{RESET}"
174
+ end
175
+ when :info
176
+ puts "#{DIM}#{result[:message]}#{RESET}"
177
+ when :data
178
+ case cmd
179
+ when "diff"
180
+ data = result[:data]
181
+ puts "#{DIM}Diff ##{data[:from]} → ##{data[:to]}:#{RESET}"
182
+ data[:diffs].each do |name, d|
183
+ puts "#{BOLD} #{name}:#{RESET}"
184
+ d.each_line { |l| puts " #{l.rstrip}" }
185
+ end
186
+ when "history"
187
+ result[:data][:snapshots].each do |s|
188
+ puts "#{DIM} ##{s[:id]} #{s[:label] || '(unlabeled)'} — #{s[:timestamp]}#{RESET}"
189
+ end
190
+ when "status"
191
+ puts result[:data][:markdown]
192
+ when "evolve"
193
+ evo = result[:data]
194
+ case evo[:status]
195
+ when :accept
196
+ puts "#{RESULT_COLOR} ✓ accepted: #{evo[:proposal]}#{RESET}"
197
+ puts "#{DIM} #{evo[:rationale]}#{RESET}" if evo[:rationale]
198
+ when :reject
199
+ puts "#{TOOL_COLOR} ✗ rejected: #{evo[:proposal] || 'n/a'}#{RESET}"
200
+ puts "#{DIM} #{evo[:reason]}#{RESET}"
201
+ when :skip
202
+ puts "#{DIM} · skipped: #{evo[:reason]}#{RESET}"
203
+ end
204
+ else
205
+ puts "#{DIM}#{result[:message]}#{RESET}"
206
+ end
207
+ end
208
+ end
209
+ private_class_method :render_command_result
210
+
113
211
  # Track method definitions for session persistence
114
212
  def self.track_definition(caller_binding, code, method_name)
115
213
  receiver = caller_binding.receiver
@@ -227,6 +325,9 @@ module Claw
227
325
  puts "#{CLAW_PREFIX}#{display}" if display
228
326
  end
229
327
 
328
+ # Write execution trace
329
+ write_trace(engine.trace_data) if engine.trace_data
330
+
230
331
  # Schedule compaction after each exchange
231
332
  Claw.memory&.schedule_compaction
232
333
  append_interaction_log(input, result)
@@ -237,6 +338,18 @@ module Claw
237
338
  end
238
339
  private_class_method :run_claw
239
340
 
341
+ # --- Trace writing ---
342
+
343
+ def self.write_trace(trace_data)
344
+ claw_dir = File.join(Dir.pwd, ".ruby-claw")
345
+ return unless File.directory?(claw_dir)
346
+
347
+ Claw::Trace.write(trace_data, claw_dir)
348
+ rescue => e
349
+ $stderr.puts "#{DIM} ⚠ trace write failed: #{e.message}#{RESET}" if Mana.config.verbose
350
+ end
351
+ private_class_method :write_trace
352
+
240
353
  # --- Interaction logging ---
241
354
 
242
355
  def self.append_interaction_log(input, result)
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Claw
6
+ # A child agent that runs in isolation and can merge results back to parent.
7
+ #
8
+ # Lifecycle: created → running → completed/failed/cancelled
9
+ # Isolation: separate binding (deep-copied vars), separate memory (copied facts),
10
+ # optional git worktree for filesystem.
11
+ class ChildRuntime
12
+ STATES = %i[created running completed failed cancelled].freeze
13
+
14
+ CancelledError = Class.new(StandardError)
15
+
16
+ attr_reader :id, :state, :result, :error, :runtime, :prompt
17
+
18
+ def initialize(parent:, prompt:, vars: {}, role: nil, model: nil)
19
+ @id = SecureRandom.hex(4)
20
+ @parent = parent
21
+ @prompt = prompt
22
+ @vars = vars
23
+ @role = role
24
+ @model = model
25
+ @state = :created
26
+ @result = nil
27
+ @error = nil
28
+ @thread = nil
29
+ @cancelled = false
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ # Start execution in a background thread.
34
+ def start!
35
+ raise "Already started" unless @state == :created
36
+
37
+ @state = :running
38
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ @thread = Thread.new { execute }
40
+ self
41
+ end
42
+
43
+ # Block until the child completes (or timeout).
44
+ #
45
+ # @param timeout [Numeric, nil] seconds to wait
46
+ # @return [ChildRuntime] self
47
+ def join(timeout: nil)
48
+ @thread&.join(timeout)
49
+ self
50
+ end
51
+
52
+ # Request cancellation. The child checks this flag between iterations.
53
+ def cancel!
54
+ @mutex.synchronize do
55
+ @cancelled = true
56
+ @state = :cancelled
57
+ end
58
+ end
59
+
60
+ # Elapsed time in milliseconds since start.
61
+ def elapsed_ms
62
+ return 0 unless @started_at
63
+
64
+ t = @finished_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ ((t - @started_at) * 1000).round
66
+ end
67
+
68
+ # Compare child's current state against its initial snapshot.
69
+ # Only callable after the child has completed.
70
+ #
71
+ # @return [Hash] resource diffs
72
+ def diff
73
+ raise "Cannot diff while child is running" if @state == :running
74
+ return {} unless @runtime
75
+
76
+ initial = @runtime.snapshots.first&.id
77
+ return {} unless initial
78
+
79
+ current_id = @runtime.snapshot!(label: "diff_check")
80
+ @runtime.diff(initial, current_id)
81
+ end
82
+
83
+ # Merge child resource changes back to parent.
84
+ #
85
+ # @param only [Array<Symbol>, nil] resource names to merge (nil = all)
86
+ def merge!(only: nil)
87
+ raise "Child not completed" unless @state == :completed
88
+ raise "No child runtime" unless @runtime
89
+
90
+ targets = if only
91
+ @runtime.resources.select { |n, _| only.include?(n.to_sym) }
92
+ else
93
+ @runtime.resources
94
+ end
95
+
96
+ targets.each do |name, child_res|
97
+ parent_res = @parent.resources[name]
98
+ next unless parent_res
99
+
100
+ parent_res.merge_from!(child_res)
101
+ end
102
+
103
+ @parent.record_event(
104
+ action: "child_merged",
105
+ target: @id,
106
+ detail: "merged #{targets.keys.join(', ')}"
107
+ )
108
+ end
109
+
110
+ # Is the child still running?
111
+ def running?
112
+ @state == :running
113
+ end
114
+
115
+ # Is the child done (completed, failed, or cancelled)?
116
+ def done?
117
+ %i[completed failed cancelled].include?(@state)
118
+ end
119
+
120
+ private
121
+
122
+ def execute
123
+ # 1. Create isolated binding with deep-copied variables
124
+ child_binding = Object.new.instance_eval { binding }
125
+ @vars.each do |k, v|
126
+ copied = begin
127
+ Marshal.load(Marshal.dump(v))
128
+ rescue TypeError
129
+ v # fallback for non-marshalable objects (procs, etc.)
130
+ end
131
+ child_binding.local_variable_set(k, copied)
132
+ end
133
+
134
+ # 2. Create child runtime with isolated resources
135
+ @runtime = Claw::Runtime.new
136
+
137
+ # Binding resource
138
+ @runtime.register("binding", Resources::BindingResource.new(child_binding))
139
+
140
+ # Context resource (fresh for child thread)
141
+ context = Mana::Context.new
142
+ @runtime.register("context", Resources::ContextResource.new(context))
143
+
144
+ # Memory resource (copy parent's long-term facts)
145
+ parent_memory_res = @parent.resources["memory"]
146
+ if parent_memory_res
147
+ child_memory = Claw::Memory.new
148
+ parent_mem = parent_memory_res.instance_variable_get(:@memory)
149
+ if parent_mem
150
+ parent_mem.long_term.each do |fact|
151
+ child_memory.remember(fact[:content])
152
+ end
153
+ end
154
+ @runtime.register("memory", Resources::MemoryResource.new(child_memory))
155
+ end
156
+
157
+ # Filesystem resource (worktree if parent has filesystem)
158
+ parent_fs = @parent.resources["filesystem"]
159
+ if parent_fs && parent_fs.is_a?(Resources::FilesystemResource)
160
+ worktree = Resources::WorktreeResource.new(
161
+ parent_path: parent_fs.path,
162
+ branch_name: "child-#{@id}"
163
+ )
164
+ @runtime.register("filesystem", worktree)
165
+ end
166
+
167
+ @runtime.snapshot!(label: "child_start")
168
+
169
+ # 3. Configure role
170
+ if @role
171
+ Claw::Roles.switch!(@role)
172
+ end
173
+
174
+ # 4. Execute via Mana engine
175
+ engine = Mana::Engine.new(child_binding)
176
+ raise CancelledError if @cancelled
177
+
178
+ raw = engine.execute(@prompt) do |_type, *_args|
179
+ raise CancelledError if @cancelled
180
+ end
181
+
182
+ @result = raw
183
+ @mutex.synchronize { @state = :completed unless @cancelled }
184
+ rescue CancelledError
185
+ @mutex.synchronize { @state = :cancelled }
186
+ rescue => e
187
+ @error = e
188
+ @mutex.synchronize { @state = :failed unless @cancelled }
189
+ ensure
190
+ @finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ # Cleanup worktree if created
192
+ fs = @runtime&.resources&.dig("filesystem")
193
+ fs.cleanup! if fs.respond_to?(:cleanup!)
194
+ end
195
+ end
196
+ end
data/lib/claw/cli.rb ADDED
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Headless CLI execution for non-interactive subcommands.
5
+ # Shares command logic with TUI via Claw::Commands.
6
+ module CLI
7
+ # Run a CLI subcommand without entering TUI.
8
+ #
9
+ # @param cmd [Symbol] command name
10
+ # @param args [Array<String>] arguments
11
+ def self.run(cmd, *args)
12
+ case cmd
13
+ when :status
14
+ runtime = init_headless_runtime
15
+ result = Commands.dispatch("status", nil, runtime: runtime)
16
+ render(result, "status")
17
+
18
+ when :history
19
+ runtime = init_headless_runtime
20
+ result = Commands.dispatch("history", nil, runtime: runtime)
21
+ render(result, "history")
22
+
23
+ when :rollback
24
+ runtime = init_headless_runtime
25
+ result = Commands.dispatch("rollback", args.first, runtime: runtime)
26
+ render(result, "rollback")
27
+
28
+ when :trace
29
+ render_trace(args.first)
30
+
31
+ when :evolve
32
+ runtime = init_headless_runtime
33
+ result = Commands.dispatch("evolve", nil, runtime: runtime)
34
+ render(result, "evolve")
35
+
36
+ when :benchmark
37
+ run_benchmark(args)
38
+
39
+ when :console
40
+ run_console(args)
41
+
42
+ else
43
+ $stderr.puts "Unknown command: #{cmd}"
44
+ exit 1
45
+ end
46
+ end
47
+
48
+ # --- Headless runtime ---
49
+
50
+ def self.init_headless_runtime
51
+ binding_obj = Object.new.instance_eval { binding }
52
+ runtime = Claw::Runtime.new
53
+
54
+ context = Mana::Context.current
55
+ runtime.register("context", Claw::Resources::ContextResource.new(context))
56
+
57
+ memory = Claw.memory
58
+ runtime.register("memory", Claw::Resources::MemoryResource.new(memory)) if memory
59
+
60
+ runtime.register("binding", Claw::Resources::BindingResource.new(binding_obj))
61
+
62
+ claw_dir = File.join(Dir.pwd, ".ruby-claw")
63
+ if File.directory?(claw_dir)
64
+ runtime.register("filesystem", Claw::Resources::FilesystemResource.new(claw_dir))
65
+ end
66
+
67
+ runtime.snapshot!(label: "cli_start")
68
+ runtime
69
+ end
70
+ private_class_method :init_headless_runtime
71
+
72
+ # --- Output rendering ---
73
+
74
+ def self.render(result, cmd)
75
+ case result[:type]
76
+ when :success
77
+ puts "✓ #{result[:message]}"
78
+ when :error
79
+ $stderr.puts "error: #{result[:message]}"
80
+ exit 1
81
+ when :info
82
+ puts result[:message]
83
+ when :data
84
+ case cmd
85
+ when "status"
86
+ render_markdown(result[:data][:markdown])
87
+ when "history"
88
+ result[:data][:snapshots].each do |s|
89
+ puts " ##{s[:id]} #{s[:label] || '(unlabeled)'} — #{s[:timestamp]}"
90
+ end
91
+ when "evolve"
92
+ evo = result[:data]
93
+ case evo[:status]
94
+ when :accept then puts "✓ accepted: #{evo[:proposal]}"
95
+ when :reject then puts "✗ rejected: #{evo[:proposal] || 'n/a'}"
96
+ when :skip then puts "· skipped: #{evo[:reason]}"
97
+ end
98
+ else
99
+ puts result[:message]
100
+ end
101
+ end
102
+ end
103
+ private_class_method :render
104
+
105
+ def self.render_trace(task_id)
106
+ claw_dir = File.join(Dir.pwd, ".ruby-claw")
107
+ traces_dir = File.join(claw_dir, "traces")
108
+ unless Dir.exist?(traces_dir)
109
+ $stderr.puts "No traces directory"
110
+ exit 1
111
+ end
112
+
113
+ files = Dir.glob(File.join(traces_dir, "*.md")).sort
114
+ if task_id
115
+ file = files.find { |f| File.basename(f).include?(task_id) }
116
+ unless file
117
+ $stderr.puts "Trace not found: #{task_id}"
118
+ exit 1
119
+ end
120
+ render_markdown(File.read(file))
121
+ else
122
+ files.last(10).each { |f| puts File.basename(f) }
123
+ end
124
+ end
125
+ private_class_method :render_trace
126
+
127
+ def self.render_markdown(text)
128
+ begin
129
+ require "glamour"
130
+ puts Glamour.render(text)
131
+ rescue LoadError
132
+ puts text
133
+ end
134
+ end
135
+ private_class_method :render_markdown
136
+
137
+ def self.run_benchmark(args)
138
+ subcmd = args.first
139
+ case subcmd
140
+ when "run"
141
+ require_relative "benchmark/benchmark"
142
+ Claw::Benchmark.run!
143
+ when "diff"
144
+ require_relative "benchmark/benchmark"
145
+ Claw::Benchmark.diff!(args[1], args[2])
146
+ else
147
+ puts "Usage: claw benchmark run | claw benchmark diff <a> <b>"
148
+ end
149
+ end
150
+ private_class_method :run_benchmark
151
+
152
+ def self.run_console(args)
153
+ claw_dir = File.join(Dir.pwd, ".ruby-claw")
154
+ unless File.directory?(claw_dir)
155
+ $stderr.puts "No .ruby-claw/ directory — run `claw init` first"
156
+ exit 1
157
+ end
158
+
159
+ port = Claw.config.console_port
160
+ args.each_with_index { |a, i| port = args[i + 1].to_i if a == "--port" && args[i + 1] }
161
+
162
+ runtime = init_headless_runtime
163
+ FileUtils.mkdir_p(File.join(claw_dir, "log"))
164
+
165
+ Console::Server.setup(
166
+ claw_dir: claw_dir,
167
+ runtime: runtime,
168
+ memory: Claw.memory,
169
+ port: port
170
+ )
171
+
172
+ puts "Claw Console starting on http://127.0.0.1:#{port}"
173
+ Console::Server.run!
174
+ end
175
+ private_class_method :run_console
176
+ end
177
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Pure-function slash commands. Each method returns a structured result Hash
5
+ # instead of printing directly, making commands testable and reusable
6
+ # across TUI, CLI, and tests.
7
+ #
8
+ # Result format: { type: :success | :error | :info | :data, message: String, data: Any }
9
+ module Commands
10
+ COMMANDS = %w[snapshot rollback diff history status evolve role forge].freeze
11
+
12
+ # Dispatch a slash command by name. Returns a result Hash.
13
+ #
14
+ # @param cmd [String] command name (without /)
15
+ # @param arg [String, nil] argument string
16
+ # @param runtime [Claw::Runtime] the runtime instance
17
+ # @param claw_dir [String, nil] path to .ruby-claw/ directory
18
+ # @return [Hash] { type:, message:, data: }
19
+ def self.dispatch(cmd, arg, runtime:, claw_dir: nil)
20
+ unless COMMANDS.include?(cmd)
21
+ return { type: :error, message: "Unknown command: /#{cmd}",
22
+ data: { available: COMMANDS.map { |c| "/#{c}" } } }
23
+ end
24
+
25
+ unless runtime
26
+ return { type: :error, message: "Runtime not initialized" }
27
+ end
28
+
29
+ send("cmd_#{cmd}", arg, runtime: runtime, claw_dir: claw_dir)
30
+ rescue => e
31
+ { type: :error, message: "#{e.class}: #{e.message}" }
32
+ end
33
+
34
+ # --- Individual commands ---
35
+
36
+ def self.cmd_snapshot(arg, runtime:, **)
37
+ id = runtime.snapshot!(label: arg || "manual")
38
+ { type: :success, message: "snapshot ##{id} created#{arg ? " (#{arg})" : ""}", data: { id: id } }
39
+ end
40
+
41
+ def self.cmd_rollback(arg, runtime:, **)
42
+ unless arg
43
+ return { type: :error, message: "Usage: /rollback <id>" }
44
+ end
45
+ snap_id = arg.to_i
46
+ runtime.rollback!(snap_id)
47
+ { type: :success, message: "rolled back to snapshot ##{snap_id}", data: { id: snap_id } }
48
+ end
49
+
50
+ def self.cmd_diff(arg, runtime:, **)
51
+ snaps = runtime.snapshots
52
+ if snaps.size < 2 && !arg
53
+ return { type: :error, message: "Need at least 2 snapshots to diff" }
54
+ end
55
+
56
+ ids = arg ? arg.split.map(&:to_i) : [snaps[-2].id, snaps[-1].id]
57
+ if ids.size < 2
58
+ return { type: :error, message: "Usage: /diff <id_a> <id_b>" }
59
+ end
60
+
61
+ diffs = runtime.diff(ids[0], ids[1])
62
+ { type: :data, message: "Diff ##{ids[0]} → ##{ids[1]}",
63
+ data: { from: ids[0], to: ids[1], diffs: diffs } }
64
+ end
65
+
66
+ def self.cmd_history(_, runtime:, **)
67
+ snaps = runtime.snapshots
68
+ if snaps.empty?
69
+ return { type: :info, message: "No snapshots" }
70
+ end
71
+
72
+ items = snaps.map { |s| { id: s.id, label: s.label, timestamp: s.timestamp } }
73
+ { type: :data, message: "#{items.size} snapshots", data: { snapshots: items } }
74
+ end
75
+
76
+ def self.cmd_status(_, runtime:, **)
77
+ { type: :data, message: "Runtime status", data: { markdown: runtime.to_md } }
78
+ end
79
+
80
+ def self.cmd_evolve(_, runtime:, claw_dir: nil, **)
81
+ claw_dir ||= File.join(Dir.pwd, ".ruby-claw")
82
+ unless File.directory?(claw_dir)
83
+ return { type: :error, message: "No .ruby-claw/ directory — run `claw init` first" }
84
+ end
85
+
86
+ evo = Claw::Evolution.new(runtime: runtime, claw_dir: claw_dir)
87
+ result = evo.evolve
88
+ { type: :data, message: "Evolution cycle completed", data: result }
89
+ end
90
+
91
+ def self.cmd_role(arg, runtime:, claw_dir: nil, **)
92
+ claw_dir ||= File.join(Dir.pwd, ".ruby-claw")
93
+
94
+ unless arg
95
+ current = Claw::Roles.current
96
+ available = Claw::Roles.list(claw_dir)
97
+ return { type: :data, message: "Roles",
98
+ data: { current: current&.dig(:name), available: available } }
99
+ end
100
+
101
+ Claw::Roles.switch!(arg, claw_dir)
102
+ { type: :success, message: "Switched to role: #{arg}" }
103
+ end
104
+
105
+ def self.cmd_forge(arg, runtime:, claw_dir: nil, **)
106
+ unless arg && !arg.strip.empty?
107
+ return { type: :error, message: "Usage: /forge <method_name>" }
108
+ end
109
+
110
+ claw_dir ||= File.join(Dir.pwd, ".ruby-claw")
111
+ # Get binding from runtime's binding resource
112
+ binding_res = runtime.resources["binding"]
113
+ unless binding_res
114
+ return { type: :error, message: "No binding resource available" }
115
+ end
116
+
117
+ caller_binding = binding_res.instance_variable_get(:@binding)
118
+ result = Claw::Forge.promote(arg.strip, binding: caller_binding, claw_dir: claw_dir)
119
+
120
+ if result[:success]
121
+ { type: :success, message: result[:message] }
122
+ else
123
+ { type: :error, message: result[:message] }
124
+ end
125
+ end
126
+
127
+ # Make individual commands private from external dispatch
128
+ private_class_method :cmd_snapshot, :cmd_rollback, :cmd_diff,
129
+ :cmd_history, :cmd_status, :cmd_evolve, :cmd_role, :cmd_forge
130
+ end
131
+ end