rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -15,10 +15,10 @@ module RubynCode
15
15
  ALWAYS_ALLOW = %w[
16
16
  read_file glob grep git_status git_diff git_log
17
17
  memory_search memory_write load_skill compact
18
- task web_search web_fetch
18
+ task web_search web_fetch ask_user
19
19
  ].to_set.freeze
20
20
 
21
- def self.check(tool_name:, tool_input:, tier:, deny_list:)
21
+ def self.check(tool_name:, tier:, deny_list:, tool_input: nil) # rubocop:disable Lint/UnusedMethodArgument
22
22
  return :deny if deny_list.blocks?(tool_name)
23
23
  return :allow if ALWAYS_ALLOW.include?(tool_name)
24
24
 
@@ -26,17 +26,15 @@ module RubynCode
26
26
 
27
27
  return :ask if risk == :destructive
28
28
 
29
+ resolve_by_tier(tier, risk)
30
+ end
31
+
32
+ def self.resolve_by_tier(tier, risk)
29
33
  case tier
30
- when Tier::ASK_ALWAYS
31
- :ask
32
- when Tier::ALLOW_READ
33
- risk == :read ? :allow : :ask
34
- when Tier::AUTONOMOUS
35
- risk == :external ? :ask : :allow
36
- when Tier::UNRESTRICTED
37
- :allow
38
- else
39
- :ask
34
+ when Tier::ASK_ALWAYS, nil then :ask
35
+ when Tier::ALLOW_READ then risk == :read ? :allow : :ask
36
+ when Tier::AUTONOMOUS then risk == :external ? :ask : :allow
37
+ when Tier::UNRESTRICTED then :allow
40
38
  end
41
39
  end
42
40
 
@@ -53,7 +51,7 @@ module RubynCode
53
51
  :unknown
54
52
  end
55
53
 
56
- private_class_method :resolve_risk
54
+ private_class_method :resolve_risk, :resolve_by_tier
57
55
  end
58
56
  end
59
57
  end
@@ -33,21 +33,20 @@ module RubynCode
33
33
  # @param tool_input [Hash]
34
34
  # @return [Boolean] true if the user approved
35
35
  def self.confirm_destructive(tool_name, tool_input)
36
- prompt = build_prompt
37
36
  pastel = Pastel.new
37
+ display_destructive_warning(pastel, tool_name, tool_input)
38
+
39
+ answer = build_prompt.ask(pastel.red.bold('Type "yes" to confirm this destructive action:'))
40
+ answer&.strip&.downcase == 'yes'
41
+ rescue TTY::Prompt::Reader::InputInterrupt
42
+ false
43
+ end
38
44
 
45
+ def self.display_destructive_warning(pastel, tool_name, tool_input)
39
46
  $stdout.puts pastel.red.bold('WARNING: Destructive operation requested')
40
47
  $stdout.puts pastel.red('=' * 50)
41
48
  display_tool_summary(pastel, tool_name, tool_input)
42
49
  $stdout.puts pastel.red('=' * 50)
43
-
44
- answer = prompt.ask(
45
- pastel.red.bold('Type "yes" to confirm this destructive action:')
46
- )
47
-
48
- answer&.strip&.downcase == 'yes'
49
- rescue TTY::Prompt::Reader::InputInterrupt
50
- false
51
50
  end
52
51
 
53
52
  # @api private
@@ -22,39 +22,44 @@ module RubynCode
22
22
  # @return [Symbol] :approved or :rejected
23
23
  def request(plan_text, prompt: nil)
24
24
  pastel = Pastel.new
25
- tty = build_prompt
25
+ display_plan(pastel, plan_text, prompt)
26
26
 
27
+ approved = build_prompt.yes?(
28
+ pastel.yellow.bold('Do you approve this plan?'),
29
+ default: false
30
+ )
31
+
32
+ approved ? approve(pastel) : reject(pastel)
33
+ rescue TTY::Reader::InputInterrupt
34
+ $stdout.puts pastel.red("\nPlan rejected (interrupted).")
35
+ REJECTED
36
+ end
37
+
38
+ private
39
+
40
+ def display_plan(pastel, plan_text, prompt)
27
41
  $stdout.puts
28
42
  $stdout.puts pastel.cyan.bold('Proposed Plan')
29
43
  $stdout.puts pastel.cyan('=' * 60)
30
44
  $stdout.puts plan_text
31
45
  $stdout.puts pastel.cyan('=' * 60)
32
46
  $stdout.puts
47
+ return unless prompt
33
48
 
34
- if prompt
35
- $stdout.puts pastel.yellow(prompt)
36
- $stdout.puts
37
- end
49
+ $stdout.puts pastel.yellow(prompt)
50
+ $stdout.puts
51
+ end
38
52
 
39
- approved = tty.yes?(
40
- pastel.yellow.bold('Do you approve this plan?'),
41
- default: false
42
- )
53
+ def approve(pastel)
54
+ $stdout.puts pastel.green('Plan approved.')
55
+ APPROVED
56
+ end
43
57
 
44
- if approved
45
- $stdout.puts pastel.green('Plan approved.')
46
- APPROVED
47
- else
48
- $stdout.puts pastel.red('Plan rejected.')
49
- REJECTED
50
- end
51
- rescue TTY::Reader::InputInterrupt
52
- $stdout.puts pastel.red("\nPlan rejected (interrupted).")
58
+ def reject(pastel)
59
+ $stdout.puts pastel.red('Plan rejected.')
53
60
  REJECTED
54
61
  end
55
62
 
56
- private
57
-
58
63
  # Builds a TTY::Prompt instance configured for non-destructive interrupt handling.
59
64
  #
60
65
  # @return [TTY::Prompt]
@@ -19,30 +19,30 @@ module RubynCode
19
19
  class << self
20
20
  def parse(content, filename: nil)
21
21
  match = FRONTMATTER_PATTERN.match(content)
22
+ match ? parse_with_frontmatter(match) : parse_without_frontmatter(content, filename)
23
+ end
22
24
 
23
- if match
24
- frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
25
- body = match[2].to_s.strip
25
+ def parse_with_frontmatter(match)
26
+ frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
27
+ new(
28
+ name: frontmatter['name'].to_s,
29
+ description: frontmatter['description'].to_s,
30
+ tags: Array(frontmatter['tags']),
31
+ body: match[2].to_s.strip
32
+ )
33
+ end
26
34
 
27
- new(
28
- name: frontmatter['name'].to_s,
29
- description: frontmatter['description'].to_s,
30
- tags: Array(frontmatter['tags']),
31
- body: body
32
- )
33
- else
34
- body = content.to_s.strip
35
- title = extract_title(body)
36
- derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
37
- tags = derive_tags(derived_name, body)
35
+ def parse_without_frontmatter(content, filename)
36
+ body = content.to_s.strip
37
+ title = extract_title(body)
38
+ derived_name = filename ? File.basename(filename, '.*').tr('_', '-') : title_to_name(title)
38
39
 
39
- new(
40
- name: derived_name,
41
- description: title,
42
- tags: tags,
43
- body: body
44
- )
45
- end
40
+ new(
41
+ name: derived_name,
42
+ description: title,
43
+ tags: derive_tags(derived_name, body),
44
+ body: body
45
+ )
46
46
  end
47
47
 
48
48
  def parse_file(path)
@@ -53,6 +53,15 @@ module RubynCode
53
53
  parse(content, filename: path)
54
54
  end
55
55
 
56
+ TAG_RULES = [
57
+ ['ruby', /\bruby\b/i],
58
+ ['rails', /\brails\b/i],
59
+ ['rspec', /\brspec\b/i],
60
+ ['testing', /\b(?:test|spec|minitest)\b/i],
61
+ ['patterns', /\b(?:pattern|design|solid)\b/i],
62
+ ['refactoring', /\brefactor/i]
63
+ ].freeze
64
+
56
65
  private
57
66
 
58
67
  def extract_title(body)
@@ -65,14 +74,9 @@ module RubynCode
65
74
  end
66
75
 
67
76
  def derive_tags(name, body)
68
- tags = []
69
- tags << 'ruby' if body.match?(/\bruby\b/i) || name.include?('ruby')
70
- tags << 'rails' if body.match?(/\brails\b/i) || name.include?('rails')
71
- tags << 'rspec' if body.match?(/\brspec\b/i) || name.include?('rspec')
72
- tags << 'testing' if body.match?(/\b(test|spec|minitest)\b/i)
73
- tags << 'patterns' if body.match?(/\b(pattern|design|solid)\b/i)
74
- tags << 'refactoring' if body.match?(/\brefactor/i)
75
- tags.uniq
77
+ TAG_RULES.each_with_object([]) do |(tag, pattern), tags|
78
+ tags << tag if body.match?(pattern) || name.include?(tag)
79
+ end.uniq
76
80
  end
77
81
  end
78
82
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Skills
5
+ # Manages skill TTL (time-to-live) and size caps. Skills injected into
6
+ # context are tracked with a turn counter; once a skill exceeds its
7
+ # TTL it is marked for ejection during the next compaction pass.
8
+ class TtlManager
9
+ DEFAULT_TTL = 5 # turns
10
+ MAX_SKILL_TOKENS = 800 # tokens
11
+ CHARS_PER_TOKEN = 4
12
+
13
+ Entry = Data.define(:name, :loaded_at_turn, :ttl, :token_count, :last_referenced_turn) do
14
+ def expired?(current_turn)
15
+ current_turn - last_referenced_turn > ttl
16
+ end
17
+ end
18
+
19
+ attr_reader :entries
20
+
21
+ def initialize
22
+ @entries = {}
23
+ @current_turn = 0
24
+ end
25
+
26
+ # Advance the turn counter. Call this once per agent loop iteration.
27
+ def tick!
28
+ @current_turn += 1
29
+ end
30
+
31
+ # Register a loaded skill with optional TTL override.
32
+ #
33
+ # @param name [String] skill name
34
+ # @param content [String] skill content
35
+ # @param ttl [Integer] turns before expiry (default 5)
36
+ # @return [String] content, possibly truncated to size cap
37
+ def register(name, content, ttl: DEFAULT_TTL)
38
+ truncated = enforce_size_cap(content)
39
+ token_count = estimate_tokens(truncated)
40
+
41
+ @entries[name] = Entry.new(
42
+ name: name,
43
+ loaded_at_turn: @current_turn,
44
+ ttl: ttl,
45
+ token_count: token_count,
46
+ last_referenced_turn: @current_turn
47
+ )
48
+
49
+ truncated
50
+ end
51
+
52
+ # Mark a skill as recently referenced (resets its TTL countdown).
53
+ def touch(name)
54
+ return unless @entries.key?(name)
55
+
56
+ @entries[name] = @entries[name].with(last_referenced_turn: @current_turn)
57
+ end
58
+
59
+ # Returns names of skills that have exceeded their TTL.
60
+ def expired_skills
61
+ @entries.select { |_, entry| entry.expired?(@current_turn) }.keys
62
+ end
63
+
64
+ # Remove expired skills and return their names.
65
+ def eject_expired!
66
+ expired = expired_skills
67
+ expired.each { |name| @entries.delete(name) }
68
+ expired
69
+ end
70
+
71
+ # Returns total tokens used by currently loaded skills.
72
+ def total_tokens
73
+ @entries.values.sum(&:token_count)
74
+ end
75
+
76
+ # Returns stats for the analytics dashboard.
77
+ def stats
78
+ {
79
+ loaded_skills: @entries.size,
80
+ total_tokens: total_tokens,
81
+ expired: expired_skills.size,
82
+ current_turn: @current_turn
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def enforce_size_cap(content)
89
+ max_chars = MAX_SKILL_TOKENS * CHARS_PER_TOKEN
90
+ return content if content.length <= max_chars
91
+
92
+ content[0, max_chars] + "\n... [skill truncated to #{MAX_SKILL_TOKENS} tokens]"
93
+ end
94
+
95
+ def estimate_tokens(text)
96
+ (text.bytesize.to_f / CHARS_PER_TOKEN).ceil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -32,40 +32,35 @@ module RubynCode
32
32
  end
33
33
 
34
34
  def run
35
- conversation = build_conversation
36
- executor = build_executor
37
- tool_defs = build_tool_definitions
35
+ state = { conversation: build_conversation, executor: build_executor,
36
+ tool_defs: build_tool_definitions, final_text: '' }
38
37
 
39
- iteration = 0
40
- final_text = ''
41
-
42
- loop do
43
- break if iteration >= @max_iterations
44
-
45
- response = request_llm(conversation, tool_defs)
46
- iteration += 1
47
-
48
- text_content = extract_text(response)
49
- tool_calls = extract_tool_calls(response)
38
+ @max_iterations.times do
39
+ result = run_single_iteration(state)
40
+ break if result == :done
41
+ end
50
42
 
51
- if tool_calls.empty?
52
- final_text = text_content
53
- break
54
- end
43
+ Summarizer.call(state[:final_text])
44
+ end
55
45
 
56
- conversation << { role: 'assistant', content: response }
46
+ private
57
47
 
58
- tool_results = execute_tools(executor, tool_calls)
59
- conversation << { role: 'user', content: tool_results }
48
+ def run_single_iteration(state)
49
+ response = request_llm(state[:conversation], state[:tool_defs])
50
+ text_content = extract_text(response)
51
+ tool_calls = extract_tool_calls(response)
60
52
 
61
- final_text = text_content unless text_content.empty?
53
+ if tool_calls.empty?
54
+ state[:final_text] = text_content
55
+ return :done
62
56
  end
63
57
 
64
- Summarizer.call(final_text)
58
+ state[:conversation] << { role: 'assistant', content: response }
59
+ state[:conversation] << { role: 'user', content: execute_tools(state[:executor], tool_calls) }
60
+ state[:final_text] = text_content unless text_content.empty?
61
+ :continue
65
62
  end
66
63
 
67
- private
68
-
69
64
  def build_conversation
70
65
  [
71
66
  { role: 'user', content: @prompt }
@@ -113,30 +113,35 @@ module RubynCode
113
113
  # @return [Array<String>]
114
114
  # @raise [RuntimeError] if the graph contains a cycle
115
115
  def topological_sort
116
- in_degree = Hash.new(0)
117
- all_nodes = Set.new
116
+ all_nodes = collect_all_nodes
117
+ in_degree = compute_in_degrees(all_nodes)
118
118
 
119
- @forward.each do |task_id, deps|
120
- all_nodes.add(task_id)
121
- deps.each do |dep_id|
122
- all_nodes.add(dep_id)
123
- in_degree[dep_id] # touch to initialize
124
- in_degree[task_id] += 1 # task_id depends on dep_id, so task_id has higher in-degree
125
- end
126
- end
119
+ sorted = kahn_sort(all_nodes, in_degree)
120
+ raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
127
121
 
128
- # Nodes with no dependencies come first
129
- # Note: in our graph, forward[task_id] = set of tasks task_id depends ON,
130
- # so the "edges" for topological sort point from dep -> task_id.
131
- in_degree_corrected = Hash.new(0)
132
- all_nodes.each { |n| in_degree_corrected[n] = 0 }
122
+ sorted
123
+ end
124
+
125
+ private
133
126
 
127
+ def collect_all_nodes
128
+ nodes = Set.new
134
129
  @forward.each do |task_id, deps|
135
- # task_id depends on each dep, meaning dep must come before task_id
136
- in_degree_corrected[task_id] += deps.size
130
+ nodes.add(task_id)
131
+ deps.each { |dep_id| nodes.add(dep_id) }
137
132
  end
133
+ nodes
134
+ end
135
+
136
+ def compute_in_degrees(all_nodes)
137
+ in_degree = Hash.new(0)
138
+ all_nodes.each { |n| in_degree[n] = 0 }
139
+ @forward.each { |task_id, deps| in_degree[task_id] += deps.size }
140
+ in_degree
141
+ end
138
142
 
139
- queue = all_nodes.select { |n| in_degree_corrected[n].zero? }
143
+ def kahn_sort(all_nodes, in_degree)
144
+ queue = all_nodes.select { |n| in_degree[n].zero? }
140
145
  sorted = []
141
146
 
142
147
  until queue.empty?
@@ -144,18 +149,14 @@ module RubynCode
144
149
  sorted << node
145
150
 
146
151
  @reverse[node].each do |dependent|
147
- in_degree_corrected[dependent] -= 1
148
- queue << dependent if in_degree_corrected[dependent].zero?
152
+ in_degree[dependent] -= 1
153
+ queue << dependent if in_degree[dependent].zero?
149
154
  end
150
155
  end
151
156
 
152
- raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
153
-
154
157
  sorted
155
158
  end
156
159
 
157
- private
158
-
159
160
  def ensure_table
160
161
  @db.execute(<<~SQL)
161
162
  CREATE TABLE IF NOT EXISTS task_dependencies (
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class AskUser < Base
9
+ TOOL_NAME = 'ask_user'
10
+ DESCRIPTION = 'Ask the user a question and wait for their response. ' \
11
+ 'Use this when you need clarification, want to confirm a plan before executing, ' \
12
+ 'or are stuck and need guidance. The question is displayed and the user\'s answer ' \
13
+ 'is returned as the tool result.'
14
+ PARAMETERS = {
15
+ question: {
16
+ type: :string,
17
+ description: 'The question to ask the user',
18
+ required: true
19
+ }
20
+ }.freeze
21
+ RISK_LEVEL = :read # Never needs approval — it IS the approval mechanism
22
+
23
+ attr_writer :prompt_callback
24
+
25
+ def execute(question:)
26
+ if @prompt_callback
27
+ @prompt_callback.call(question)
28
+ elsif $stdin.respond_to?(:tty?) && $stdin.tty?
29
+ # Interactive fallback: prompt on stdin
30
+ $stdout.puts
31
+ $stdout.puts " #{question}"
32
+ $stdout.print ' > '
33
+ $stdout.flush
34
+ $stdin.gets&.strip || '[no response]'
35
+ else
36
+ # Non-interactive (piped input, -p mode, daemon) — can't ask
37
+ '[non-interactive session — cannot ask user. Make your best judgment and proceed.]'
38
+ end
39
+ end
40
+ end
41
+
42
+ Registry.register(AskUser)
43
+ end
44
+ end
@@ -30,7 +30,8 @@ module RubynCode
30
30
  return 'Error: Background worker not available. Use bash tool instead.' unless @background_worker
31
31
 
32
32
  job_id = @background_worker.run(command, timeout: timeout)
33
- "Background job started: #{job_id}\nCommand: #{command}\nTimeout: #{timeout}s\nResults will appear automatically when complete."
33
+ "Background job started: #{job_id}\nCommand: #{command}\n" \
34
+ "Timeout: #{timeout}s\nResults will appear automatically when complete."
34
35
  end
35
36
  end
36
37
 
@@ -57,7 +57,8 @@ module RubynCode
57
57
  end
58
58
 
59
59
  unless expanded.start_with?(project_root)
60
- raise PermissionDeniedError, "Path traversal denied: #{path} resolves outside project root"
60
+ raise PermissionDeniedError,
61
+ "Path traversal denied: #{path} resolves outside project root"
61
62
  end
62
63
 
63
64
  expanded
@@ -67,7 +68,8 @@ module RubynCode
67
68
  return output if output.nil? || output.length <= max
68
69
 
69
70
  half = max / 2
70
- "#{output[0, half]}\n\n... [truncated #{output.length - max} characters] ...\n\n#{output[-half, half]}"
71
+ middle = "\n\n... [truncated #{output.length - max} characters] ...\n\n"
72
+ "#{output[0, half]}#{middle}#{output[-half, half]}"
71
73
  end
72
74
 
73
75
  private
@@ -82,34 +84,30 @@ module RubynCode
82
84
  stdout = +''
83
85
  stderr = +''
84
86
 
85
- out_reader = Thread.new do
86
- stdout << stdout_io.read
87
- rescue StandardError
88
- nil
89
- end
90
- err_reader = Thread.new do
91
- stderr << stderr_io.read
92
- rescue StandardError
93
- nil
94
- end
87
+ out_reader = Thread.new { stdout << stdout_io.read rescue nil } # rubocop:disable Style/RescueModifier
88
+ err_reader = Thread.new { stderr << stderr_io.read rescue nil } # rubocop:disable Style/RescueModifier
95
89
 
96
- timed_out = false
97
- unless wait_thr.join(timeout)
98
- timed_out = true
99
- begin
100
- Process.kill('TERM', wait_thr.pid)
101
- rescue StandardError
102
- nil
103
- end
104
- sleep 0.1
105
- begin
106
- Process.kill('KILL', wait_thr.pid)
107
- rescue StandardError
108
- nil
109
- end
110
- wait_thr.join(5)
111
- end
90
+ wait_for_process(wait_thr, timeout)
91
+ finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
92
+
93
+ [stdout, stderr, wait_thr.value]
94
+ end
95
+
96
+ def wait_for_process(wait_thr, timeout)
97
+ return if wait_thr.join(timeout)
112
98
 
99
+ kill_process(wait_thr.pid)
100
+ wait_thr.join(5)
101
+ raise Error, "Command timed out after #{timeout}s"
102
+ end
103
+
104
+ def kill_process(pid)
105
+ Process.kill('TERM', pid) rescue nil # rubocop:disable Style/RescueModifier
106
+ sleep 0.1
107
+ Process.kill('KILL', pid) rescue nil # rubocop:disable Style/RescueModifier
108
+ end
109
+
110
+ def finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
113
111
  out_reader.join(5)
114
112
  err_reader.join(5)
115
113
  [stdout_io, stderr_io].each do |io|
@@ -117,10 +115,6 @@ module RubynCode
117
115
  rescue StandardError
118
116
  nil
119
117
  end
120
-
121
- raise Error, "Command timed out after #{timeout}s" if timed_out
122
-
123
- [stdout, stderr, wait_thr.value]
124
118
  end
125
119
 
126
120
  def read_file_safely(path)
@@ -9,7 +9,8 @@ module RubynCode
9
9
  module Tools
10
10
  class Bash < Base
11
11
  TOOL_NAME = 'bash'
12
- DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables.'
12
+ DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns ' \
13
+ 'and scrubs sensitive environment variables.'
13
14
  PARAMETERS = {
14
15
  command: { type: :string, required: true, description: 'The shell command to execute' },
15
16
  timeout: { type: :integer, required: false, default: 120, description: 'Timeout in seconds (default: 120)' }