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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
module Resources
|
|
5
|
+
# Reversible resource wrapping a Ruby Binding.
|
|
6
|
+
#
|
|
7
|
+
# Tracks all local variables by default. Uses binding diff to detect changes
|
|
8
|
+
# after each eval. Non-serializable variables are automatically excluded
|
|
9
|
+
# with a warning.
|
|
10
|
+
#
|
|
11
|
+
# Serialization: MarshalMd.dump for human-readable Markdown snapshots.
|
|
12
|
+
class BindingResource
|
|
13
|
+
include Claw::Resource
|
|
14
|
+
|
|
15
|
+
attr_reader :tracked, :excluded
|
|
16
|
+
|
|
17
|
+
# @param binding [Binding] the binding to track
|
|
18
|
+
# @param on_exclude [Proc, nil] called with (name, error) when a variable is excluded
|
|
19
|
+
def initialize(binding, on_exclude: nil)
|
|
20
|
+
@binding = binding
|
|
21
|
+
@tracked = {} # name => last known Marshal blob
|
|
22
|
+
@excluded = {} # name => reason string
|
|
23
|
+
@on_exclude = on_exclude
|
|
24
|
+
scan_binding
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Snapshot all tracked variables. Returns a frozen Hash of { name => Marshal blob }.
|
|
28
|
+
def snapshot!
|
|
29
|
+
scan_binding
|
|
30
|
+
@tracked.transform_values(&:dup).freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Restore tracked variables from a snapshot token.
|
|
34
|
+
def rollback!(token)
|
|
35
|
+
token.each do |name, blob|
|
|
36
|
+
value = MarshalMd.load(blob)
|
|
37
|
+
@binding.local_variable_set(name, value)
|
|
38
|
+
end
|
|
39
|
+
# Remove variables that exist now but didn't exist in the snapshot
|
|
40
|
+
current_vars = @binding.local_variables.map(&:to_s)
|
|
41
|
+
snapshot_vars = token.keys.map(&:to_s)
|
|
42
|
+
(current_vars - snapshot_vars).each do |name|
|
|
43
|
+
# Can't remove local variables in Ruby, but we can set them to nil
|
|
44
|
+
@binding.local_variable_set(name.to_sym, nil) if @tracked.key?(name)
|
|
45
|
+
end
|
|
46
|
+
@tracked = token.transform_values(&:dup)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Human-readable diff between two snapshot tokens.
|
|
50
|
+
def diff(token_a, token_b)
|
|
51
|
+
all_keys = (token_a.keys + token_b.keys).uniq
|
|
52
|
+
lines = []
|
|
53
|
+
|
|
54
|
+
all_keys.each do |name|
|
|
55
|
+
in_a = token_a.key?(name)
|
|
56
|
+
in_b = token_b.key?(name)
|
|
57
|
+
|
|
58
|
+
if in_a && in_b
|
|
59
|
+
if token_a[name] != token_b[name]
|
|
60
|
+
val_a = safe_inspect(MarshalMd.load(token_a[name]))
|
|
61
|
+
val_b = safe_inspect(MarshalMd.load(token_b[name]))
|
|
62
|
+
lines << "~ #{name}: #{val_a} → #{val_b}"
|
|
63
|
+
end
|
|
64
|
+
elsif in_b
|
|
65
|
+
val = safe_inspect(MarshalMd.load(token_b[name]))
|
|
66
|
+
lines << "+ #{name} = #{val}"
|
|
67
|
+
else
|
|
68
|
+
val = safe_inspect(MarshalMd.load(token_a[name]))
|
|
69
|
+
lines << "- #{name} = #{val}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines.empty? ? "(no changes)" : lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Render current tracked variables as Markdown.
|
|
77
|
+
def to_md
|
|
78
|
+
scan_binding
|
|
79
|
+
lines = []
|
|
80
|
+
lines << "#{@tracked.size} tracked, #{@excluded.size} excluded"
|
|
81
|
+
@tracked.each do |name, blob|
|
|
82
|
+
val = safe_inspect(MarshalMd.load(blob))
|
|
83
|
+
lines << "- `#{name}` = #{val}"
|
|
84
|
+
end
|
|
85
|
+
@excluded.each do |name, reason|
|
|
86
|
+
lines << "- `#{name}` (excluded: #{reason})"
|
|
87
|
+
end
|
|
88
|
+
lines.join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Merge changed variables from another BindingResource into this one.
|
|
92
|
+
# Only variables that differ from the other's initial snapshot are merged.
|
|
93
|
+
def merge_from!(other)
|
|
94
|
+
other.tracked.each do |name, blob|
|
|
95
|
+
value = MarshalMd.load(blob)
|
|
96
|
+
@binding.local_variable_set(name.to_sym, value)
|
|
97
|
+
@tracked[name] = blob
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Scan the binding for new/changed variables.
|
|
102
|
+
# Call this after each eval to pick up changes.
|
|
103
|
+
def scan_binding
|
|
104
|
+
@binding.local_variables.each do |sym|
|
|
105
|
+
name = sym.to_s
|
|
106
|
+
next if @excluded.key?(name)
|
|
107
|
+
|
|
108
|
+
value = @binding.local_variable_get(sym)
|
|
109
|
+
begin
|
|
110
|
+
blob = MarshalMd.dump(value)
|
|
111
|
+
@tracked[name] = blob
|
|
112
|
+
rescue TypeError => e
|
|
113
|
+
@excluded[name] = e.message
|
|
114
|
+
@tracked.delete(name)
|
|
115
|
+
@on_exclude&.call(name, e)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def safe_inspect(value)
|
|
123
|
+
str = value.inspect
|
|
124
|
+
str.length > 80 ? "#{str[0, 77]}..." : str
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
module Resources
|
|
5
|
+
# Reversible resource wrapping Mana::Context (messages + summaries).
|
|
6
|
+
# Snapshots are deep copies of the messages and summaries arrays.
|
|
7
|
+
class ContextResource
|
|
8
|
+
include Claw::Resource
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Deep-copy messages and summaries arrays.
|
|
15
|
+
def snapshot!
|
|
16
|
+
{
|
|
17
|
+
messages: deep_copy(@context.messages),
|
|
18
|
+
summaries: deep_copy(@context.summaries)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Replace messages and summaries with the snapshot's copies.
|
|
23
|
+
def rollback!(token)
|
|
24
|
+
@context.messages.replace(deep_copy(token[:messages]))
|
|
25
|
+
@context.summaries.replace(deep_copy(token[:summaries]))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Human-readable diff between two snapshots.
|
|
29
|
+
def diff(token_a, token_b)
|
|
30
|
+
msgs_a = token_a[:messages].size
|
|
31
|
+
msgs_b = token_b[:messages].size
|
|
32
|
+
sums_a = token_a[:summaries].size
|
|
33
|
+
sums_b = token_b[:summaries].size
|
|
34
|
+
|
|
35
|
+
lines = []
|
|
36
|
+
lines << "messages: #{msgs_a} → #{msgs_b}" if msgs_a != msgs_b
|
|
37
|
+
|
|
38
|
+
# Show added messages
|
|
39
|
+
if msgs_b > msgs_a
|
|
40
|
+
token_b[:messages][msgs_a..].each do |msg|
|
|
41
|
+
role = msg[:role] || msg["role"]
|
|
42
|
+
content = msg[:content] || msg["content"]
|
|
43
|
+
preview = content.is_a?(String) ? content[0, 80] : "(#{content.size} blocks)"
|
|
44
|
+
lines << " + [#{role}] #{preview}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
lines << "summaries: #{sums_a} → #{sums_b}" if sums_a != sums_b
|
|
49
|
+
lines.empty? ? "(no changes)" : lines.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Render current context state as Markdown.
|
|
53
|
+
def to_md
|
|
54
|
+
msgs = @context.messages.size
|
|
55
|
+
sums = @context.summaries.size
|
|
56
|
+
"#{msgs} messages, #{sums} summaries"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Merge context from another ContextResource.
|
|
60
|
+
# Generally a no-op — child conversation history is rarely useful for parent.
|
|
61
|
+
# If called, appends the other's summaries to this context.
|
|
62
|
+
def merge_from!(other)
|
|
63
|
+
# no-op by default; child context is independent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def deep_copy(obj)
|
|
69
|
+
MarshalMd.load(MarshalMd.dump(obj))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Claw
|
|
7
|
+
module Resources
|
|
8
|
+
# Reversible resource for the .ruby-claw/ directory using git.
|
|
9
|
+
# Each snapshot is a git commit; rollback checks out a previous commit.
|
|
10
|
+
# Tracks the entire directory — no exclusions.
|
|
11
|
+
class FilesystemResource
|
|
12
|
+
include Claw::Resource
|
|
13
|
+
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def initialize(path)
|
|
17
|
+
@path = File.expand_path(path)
|
|
18
|
+
init_git_repo unless git_initialized?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Create a git commit capturing the current state. Returns the commit SHA.
|
|
22
|
+
def snapshot!
|
|
23
|
+
git("add", "-A")
|
|
24
|
+
# Check if there are staged changes
|
|
25
|
+
status = git("status", "--porcelain")
|
|
26
|
+
if status.strip.empty?
|
|
27
|
+
# Nothing to commit — return current HEAD
|
|
28
|
+
git("rev-parse", "HEAD").strip
|
|
29
|
+
else
|
|
30
|
+
git("commit", "-m", "snapshot #{Time.now.iso8601}", "--allow-empty-message")
|
|
31
|
+
git("rev-parse", "HEAD").strip
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Restore directory to a previous commit.
|
|
36
|
+
def rollback!(token)
|
|
37
|
+
# Remove all tracked files, then restore from target commit.
|
|
38
|
+
# Either step may have nothing to do (empty tree), so we allow failures.
|
|
39
|
+
git_try("rm", "-rf", "--quiet", ".")
|
|
40
|
+
git_try("checkout", token, "--", ".")
|
|
41
|
+
git("clean", "-fd")
|
|
42
|
+
git("reset", "HEAD")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Git diff between two commits.
|
|
46
|
+
def diff(token_a, token_b)
|
|
47
|
+
result = git("diff", "--stat", token_a, token_b)
|
|
48
|
+
result.strip.empty? ? "(no changes)" : result.strip
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Summary of recent git history.
|
|
52
|
+
def to_md
|
|
53
|
+
log = git("log", "--oneline", "-10")
|
|
54
|
+
log.strip.empty? ? "(no commits)" : log.strip
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Merge changes from another FilesystemResource (e.g., child worktree).
|
|
58
|
+
# Performs a git merge from the other's current HEAD.
|
|
59
|
+
def merge_from!(other)
|
|
60
|
+
other_path = other.respond_to?(:path) ? other.path : nil
|
|
61
|
+
raise "Cannot determine path for merge source" unless other_path
|
|
62
|
+
|
|
63
|
+
# Determine the branch name to merge
|
|
64
|
+
other_branch = if other.is_a?(Resources::WorktreeResource)
|
|
65
|
+
other.branch_name
|
|
66
|
+
else
|
|
67
|
+
other.instance_eval { git("rev-parse", "--abbrev-ref", "HEAD").strip }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
remote_name = "child_#{object_id}"
|
|
71
|
+
git_try("remote", "add", remote_name, other_path)
|
|
72
|
+
git("fetch", remote_name)
|
|
73
|
+
git_try("merge", "#{remote_name}/#{other_branch}", "--no-edit", "-m", "merge from child")
|
|
74
|
+
git_try("remote", "remove", remote_name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def git_initialized?
|
|
80
|
+
File.directory?(File.join(@path, ".git"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def init_git_repo
|
|
84
|
+
FileUtils.mkdir_p(@path)
|
|
85
|
+
git("init")
|
|
86
|
+
# Create initial empty commit so HEAD exists
|
|
87
|
+
git("commit", "--allow-empty", "-m", "init")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def git(*args)
|
|
91
|
+
cmd = ["git", "-C", @path] + args
|
|
92
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
93
|
+
unless status.success?
|
|
94
|
+
raise "git #{args.first} failed: #{stderr.strip}"
|
|
95
|
+
end
|
|
96
|
+
stdout
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Like git() but ignores failures (for operations that may have nothing to do).
|
|
100
|
+
def git_try(*args)
|
|
101
|
+
git(*args)
|
|
102
|
+
rescue RuntimeError
|
|
103
|
+
# intentionally swallowed
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
module Resources
|
|
5
|
+
# Reversible resource wrapping Claw::Memory's long_term array.
|
|
6
|
+
# On rollback, restores the in-memory array and syncs to MEMORY.md.
|
|
7
|
+
class MemoryResource
|
|
8
|
+
include Claw::Resource
|
|
9
|
+
|
|
10
|
+
def initialize(memory)
|
|
11
|
+
@memory = memory
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Deep-copy the long_term array.
|
|
15
|
+
def snapshot!
|
|
16
|
+
MarshalMd.load(MarshalMd.dump(@memory.long_term))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Restore long_term array and sync to disk.
|
|
20
|
+
def rollback!(token)
|
|
21
|
+
restored = MarshalMd.load(MarshalMd.dump(token))
|
|
22
|
+
@memory.long_term.replace(restored)
|
|
23
|
+
# Sync to MEMORY.md so file matches in-memory state
|
|
24
|
+
store = @memory.send(:store)
|
|
25
|
+
ns = @memory.send(:namespace)
|
|
26
|
+
store.write(ns, @memory.long_term)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Human-readable diff between two snapshots.
|
|
30
|
+
def diff(token_a, token_b)
|
|
31
|
+
ids_a = token_a.map { |m| m[:id] }.to_set
|
|
32
|
+
ids_b = token_b.map { |m| m[:id] }.to_set
|
|
33
|
+
|
|
34
|
+
added = token_b.select { |m| !ids_a.include?(m[:id]) }
|
|
35
|
+
removed = token_a.select { |m| !ids_b.include?(m[:id]) }
|
|
36
|
+
|
|
37
|
+
lines = []
|
|
38
|
+
added.each { |m| lines << "+ [#{m[:id]}] #{m[:content]}" }
|
|
39
|
+
removed.each { |m| lines << "- [#{m[:id]}] #{m[:content]}" }
|
|
40
|
+
lines.empty? ? "(no changes)" : lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Merge new memories from another MemoryResource.
|
|
44
|
+
# Appends facts from other that don't already exist (by id) in this memory.
|
|
45
|
+
def merge_from!(other)
|
|
46
|
+
existing_ids = @memory.long_term.map { |m| m[:id] }.to_set
|
|
47
|
+
other_memory = other.instance_variable_get(:@memory)
|
|
48
|
+
other_memory.long_term.each do |m|
|
|
49
|
+
unless existing_ids.include?(m[:id])
|
|
50
|
+
@memory.long_term << MarshalMd.load(MarshalMd.dump(m))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
# Sync to disk
|
|
54
|
+
store = @memory.send(:store)
|
|
55
|
+
ns = @memory.send(:namespace)
|
|
56
|
+
store.write(ns, @memory.long_term)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Render current memory state as Markdown.
|
|
60
|
+
def to_md
|
|
61
|
+
count = @memory.long_term.size
|
|
62
|
+
if count == 0
|
|
63
|
+
"(empty)"
|
|
64
|
+
else
|
|
65
|
+
lines = ["#{count} memories:"]
|
|
66
|
+
@memory.long_term.each do |m|
|
|
67
|
+
lines << "- [#{m[:id]}] #{m[:content]}"
|
|
68
|
+
end
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Claw
|
|
7
|
+
module Resources
|
|
8
|
+
# Git worktree-based filesystem resource for child agent isolation.
|
|
9
|
+
# Creates a separate worktree from the parent repo so the child can
|
|
10
|
+
# make changes without affecting the parent's working directory.
|
|
11
|
+
class WorktreeResource
|
|
12
|
+
include Claw::Resource
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :branch_name
|
|
15
|
+
|
|
16
|
+
def initialize(parent_path:, branch_name:)
|
|
17
|
+
@parent_path = File.expand_path(parent_path)
|
|
18
|
+
@branch_name = branch_name
|
|
19
|
+
@path = "#{@parent_path}-#{branch_name}"
|
|
20
|
+
@cleaned_up = false
|
|
21
|
+
|
|
22
|
+
create_worktree!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Snapshot via git commit in the worktree.
|
|
26
|
+
def snapshot!
|
|
27
|
+
git("add", "-A")
|
|
28
|
+
status = git("status", "--porcelain")
|
|
29
|
+
if status.strip.empty?
|
|
30
|
+
git("rev-parse", "HEAD").strip
|
|
31
|
+
else
|
|
32
|
+
git("commit", "-m", "snapshot #{Time.now.iso8601}", "--allow-empty-message")
|
|
33
|
+
git("rev-parse", "HEAD").strip
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Rollback to a previous commit in the worktree.
|
|
38
|
+
def rollback!(token)
|
|
39
|
+
git_try("rm", "-rf", "--quiet", ".")
|
|
40
|
+
git_try("checkout", token, "--", ".")
|
|
41
|
+
git("clean", "-fd")
|
|
42
|
+
git("reset", "HEAD")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Git diff between two commits.
|
|
46
|
+
def diff(token_a, token_b)
|
|
47
|
+
result = git("diff", "--stat", token_a, token_b)
|
|
48
|
+
result.strip.empty? ? "(no changes)" : result.strip
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Summary of recent history.
|
|
52
|
+
def to_md
|
|
53
|
+
log = git("log", "--oneline", "-10")
|
|
54
|
+
log.strip.empty? ? "(no commits)" : log.strip
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Merge from another worktree or filesystem resource.
|
|
58
|
+
def merge_from!(other)
|
|
59
|
+
other_path = other.respond_to?(:path) ? other.path : nil
|
|
60
|
+
raise "Cannot determine path for merge source" unless other_path
|
|
61
|
+
|
|
62
|
+
# Determine the branch name to merge
|
|
63
|
+
other_branch = if other.is_a?(WorktreeResource)
|
|
64
|
+
other.branch_name
|
|
65
|
+
else
|
|
66
|
+
# For FilesystemResource, get the current branch name
|
|
67
|
+
other.instance_eval { git("rev-parse", "--abbrev-ref", "HEAD").strip }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
remote_name = "merge_#{other.object_id}"
|
|
71
|
+
git_try("remote", "add", remote_name, other_path)
|
|
72
|
+
git("fetch", remote_name)
|
|
73
|
+
git_try("merge", "#{remote_name}/#{other_branch}", "--no-edit", "-m", "merge from #{other.class.name}")
|
|
74
|
+
git_try("remote", "remove", remote_name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Remove the worktree and delete the branch.
|
|
78
|
+
def cleanup!
|
|
79
|
+
return if @cleaned_up
|
|
80
|
+
@cleaned_up = true
|
|
81
|
+
|
|
82
|
+
# Remove worktree
|
|
83
|
+
parent_git("worktree", "remove", @path, "--force")
|
|
84
|
+
|
|
85
|
+
# Delete branch
|
|
86
|
+
parent_git_try("branch", "-D", @branch_name)
|
|
87
|
+
rescue => e
|
|
88
|
+
# Best effort cleanup
|
|
89
|
+
FileUtils.rm_rf(@path) if Dir.exist?(@path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def create_worktree!
|
|
95
|
+
# Ensure parent has a commit (needed for worktree)
|
|
96
|
+
parent_git_try("rev-parse", "HEAD")
|
|
97
|
+
|
|
98
|
+
# Create worktree with a new branch
|
|
99
|
+
parent_git("worktree", "add", @path, "-b", @branch_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def git(*args)
|
|
103
|
+
cmd = ["git", "-C", @path] + args
|
|
104
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
105
|
+
unless status.success?
|
|
106
|
+
raise "git #{args.first} failed in worktree: #{stderr.strip}"
|
|
107
|
+
end
|
|
108
|
+
stdout
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def git_try(*args)
|
|
112
|
+
git(*args)
|
|
113
|
+
rescue RuntimeError
|
|
114
|
+
# intentionally swallowed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parent_git(*args)
|
|
118
|
+
cmd = ["git", "-C", @parent_path] + args
|
|
119
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
120
|
+
unless status.success?
|
|
121
|
+
raise "git #{args.first} failed in parent: #{stderr.strip}"
|
|
122
|
+
end
|
|
123
|
+
stdout
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parent_git_try(*args)
|
|
127
|
+
parent_git(*args)
|
|
128
|
+
rescue RuntimeError
|
|
129
|
+
# intentionally swallowed
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/claw/roles.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
# Agent role management. Roles are Markdown files in .ruby-claw/roles/
|
|
5
|
+
# that define the agent's system prompt identity.
|
|
6
|
+
#
|
|
7
|
+
# Single-agent: /role data_analyst to switch identity.
|
|
8
|
+
# Multi-agent (V8): child agents reference roles via role: parameter.
|
|
9
|
+
module Roles
|
|
10
|
+
# List available role names.
|
|
11
|
+
#
|
|
12
|
+
# @param claw_dir [String] path to .ruby-claw/
|
|
13
|
+
# @return [Array<String>] role names (without extension)
|
|
14
|
+
def self.list(claw_dir = ".ruby-claw")
|
|
15
|
+
roles_dir = File.join(claw_dir, "roles")
|
|
16
|
+
return [] unless Dir.exist?(roles_dir)
|
|
17
|
+
|
|
18
|
+
Dir.glob(File.join(roles_dir, "*.md")).map { |f| File.basename(f, ".md") }.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load a role's content by name.
|
|
22
|
+
#
|
|
23
|
+
# @param name [String] role name (e.g., "data_analyst")
|
|
24
|
+
# @param claw_dir [String] path to .ruby-claw/
|
|
25
|
+
# @return [String] role file content
|
|
26
|
+
# @raise [RuntimeError] if role file not found
|
|
27
|
+
def self.load(name, claw_dir = ".ruby-claw")
|
|
28
|
+
normalized = name.tr(" ", "_").downcase
|
|
29
|
+
path = File.join(claw_dir, "roles", "#{normalized}.md")
|
|
30
|
+
raise "Role not found: #{name} (expected #{path})" unless File.exist?(path)
|
|
31
|
+
|
|
32
|
+
File.read(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Switch the current agent's role (thread-local).
|
|
36
|
+
#
|
|
37
|
+
# @param name [String] role name
|
|
38
|
+
# @param claw_dir [String] path to .ruby-claw/
|
|
39
|
+
def self.switch!(name, claw_dir = ".ruby-claw")
|
|
40
|
+
content = load(name, claw_dir)
|
|
41
|
+
Thread.current[:claw_role] = { name: name, content: content }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the current role.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash, nil] { name:, content: } or nil
|
|
47
|
+
def self.current
|
|
48
|
+
Thread.current[:claw_role]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clear the current role.
|
|
52
|
+
def self.clear!
|
|
53
|
+
Thread.current[:claw_role] = nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|