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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +214 -10
- data/exe/claw +42 -1
- data/lib/claw/auto_forge.rb +66 -0
- data/lib/claw/benchmark/benchmark.rb +79 -0
- data/lib/claw/benchmark/diff.rb +69 -0
- data/lib/claw/benchmark/report.rb +87 -0
- data/lib/claw/benchmark/runner.rb +91 -0
- data/lib/claw/benchmark/scorer.rb +69 -0
- data/lib/claw/benchmark/task.rb +63 -0
- data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
- data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
- data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
- data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
- data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
- data/lib/claw/benchmark/trigger.rb +68 -0
- data/lib/claw/chat.rb +119 -6
- data/lib/claw/child_runtime.rb +196 -0
- data/lib/claw/cli.rb +177 -0
- data/lib/claw/commands.rb +131 -0
- data/lib/claw/config.rb +5 -1
- data/lib/claw/console/event_logger.rb +69 -0
- data/lib/claw/console/public/app.js +264 -0
- data/lib/claw/console/public/style.css +330 -0
- data/lib/claw/console/server.rb +253 -0
- data/lib/claw/console/sse.rb +28 -0
- data/lib/claw/console/views/experiments.erb +8 -0
- data/lib/claw/console/views/index.erb +27 -0
- data/lib/claw/console/views/layout.erb +29 -0
- data/lib/claw/console/views/memory.erb +13 -0
- data/lib/claw/console/views/monitor.erb +15 -0
- data/lib/claw/console/views/prompt.erb +15 -0
- data/lib/claw/console/views/snapshots.erb +12 -0
- data/lib/claw/console/views/tools.erb +13 -0
- data/lib/claw/console/views/traces.erb +9 -0
- data/lib/claw/console.rb +5 -0
- data/lib/claw/evolution.rb +227 -0
- data/lib/claw/forge.rb +144 -0
- data/lib/claw/hub.rb +67 -0
- data/lib/claw/init.rb +199 -0
- data/lib/claw/knowledge.rb +36 -2
- data/lib/claw/memory_store.rb +2 -2
- data/lib/claw/plan_mode.rb +110 -0
- data/lib/claw/resource.rb +35 -0
- data/lib/claw/resources/binding_resource.rb +128 -0
- data/lib/claw/resources/context_resource.rb +73 -0
- data/lib/claw/resources/filesystem_resource.rb +107 -0
- data/lib/claw/resources/memory_resource.rb +74 -0
- data/lib/claw/resources/worktree_resource.rb +133 -0
- data/lib/claw/roles.rb +56 -0
- data/lib/claw/runtime.rb +189 -0
- data/lib/claw/serializer.rb +10 -7
- data/lib/claw/tool.rb +99 -0
- data/lib/claw/tool_index.rb +84 -0
- data/lib/claw/tool_registry.rb +100 -0
- data/lib/claw/trace.rb +86 -0
- data/lib/claw/tui/agent_executor.rb +92 -0
- data/lib/claw/tui/chat_panel.rb +81 -0
- data/lib/claw/tui/command_bar.rb +22 -0
- data/lib/claw/tui/file_card.rb +88 -0
- data/lib/claw/tui/folding.rb +80 -0
- data/lib/claw/tui/input_handler.rb +73 -0
- data/lib/claw/tui/layout.rb +34 -0
- data/lib/claw/tui/messages.rb +31 -0
- data/lib/claw/tui/model.rb +411 -0
- data/lib/claw/tui/object_explorer.rb +136 -0
- data/lib/claw/tui/status_bar.rb +30 -0
- data/lib/claw/tui/status_panel.rb +133 -0
- data/lib/claw/tui/styles.rb +58 -0
- data/lib/claw/tui/tui.rb +54 -0
- data/lib/claw/version.rb +1 -1
- data/lib/claw.rb +99 -1
- 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 .
|
|
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 .
|
|
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, ".
|
|
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 .
|
|
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, ".
|
|
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
|