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
@@ -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