rubik 0.3.4 → 0.4.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +105 -2
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +9 -2
  10. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  11. data/lib/rubik/buffer/manager.rb +78 -66
  12. data/lib/rubik/config/defaults.rb +1 -0
  13. data/lib/rubik/context/git_lister.rb +3 -1
  14. data/lib/rubik/diagnostics/parser.rb +53 -1
  15. data/lib/rubik/llm/anthropic.rb +5 -2
  16. data/lib/rubik/llm/openai_compatible.rb +83 -68
  17. data/lib/rubik/llm/registry.rb +81 -74
  18. data/lib/rubik/lsp/client.rb +320 -96
  19. data/lib/rubik/lsp/manager.rb +83 -12
  20. data/lib/rubik/lsp/server_command.rb +102 -0
  21. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  22. data/lib/rubik/orchestrator/step_executor.rb +138 -154
  23. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  24. data/lib/rubik/prompt/token_estimator.rb +16 -12
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/buffer.rb +122 -88
  29. data/lib/rubik/repl/commands/mcp.rb +120 -92
  30. data/lib/rubik/repl/commands/session.rb +90 -59
  31. data/lib/rubik/repl/commands/workspace.rb +110 -94
  32. data/lib/rubik/repl/shell.rb +10 -1
  33. data/lib/rubik/runtime/discovery.rb +12 -6
  34. data/lib/rubik/security/classifier/classifier.rb +3 -1
  35. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  36. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  37. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  38. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  39. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  40. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  41. data/lib/rubik/tools/truncator.rb +21 -8
  42. data/lib/rubik/version.rb +1 -1
  43. metadata +2 -1
@@ -31,121 +31,143 @@ module Rubik
31
31
  sub = args.first&.downcase
32
32
  case sub
33
33
  when "scan"
34
- begin
35
- ctx.context_index.full_scan
36
- ctx.warm_ast_graph(async: false)
37
- ctx.out.puts "Context index and AST symbol graph updated."
38
- rescue Errno::EACCES => e
39
- root_path = begin
40
- ctx.buffer_manager.root_dir
41
- rescue
42
- Dir.pwd
43
- end
44
- ctx.out.puts Rubik::Tools::DiagnosticReporter.render_permission_denied(root_path, operation: "scan", reason: e.message)
45
- rescue StandardError => e
46
- ctx.out.puts "Context scan failed: #{e.message}"
47
- end
34
+ handle_context_scan(ctx)
48
35
  when "add"
49
- file = args[1]
50
- if file
51
- ctx.context_index.update_file(file)
52
- full_path = File.join(ctx.buffer_manager.root_dir, file)
53
- ctx.ast_graph.index_file(full_path) if File.exist?(full_path)
54
- end
55
- ctx.out.puts "Added #{file} to context index and AST graph."
36
+ handle_context_add(args, ctx)
56
37
  when "drop"
57
- file = args[1]
58
- if file
59
- ctx.context_index.files.delete(file)
60
- ctx.out.puts "Removed #{file} from context index."
61
- else
62
- ctx.out.puts "Usage: !context drop <relative_file_path>"
63
- end
38
+ handle_context_drop(args, ctx)
64
39
  when "compact"
65
- before_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
66
- spec = ctx.current_model_spec
67
- context_limit = ctx.llm_registry.context_limit_for(spec["provider"], spec["name"])
68
- max_output = ctx.llm_registry.max_output_tokens_for(spec["provider"], spec["name"])
69
- context_cfg = ctx.config.fetch("context", {})
70
-
71
- ctx.conversation_history = Rubik::Prompt::ContextCompactor.compact_history(
72
- ctx.conversation_history,
73
- context_limit: context_limit,
74
- max_output_tokens: max_output,
75
- config: context_cfg,
76
- force: true
77
- )
78
- after_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
79
- delta = before_tokens - after_tokens
80
- ctx.out.puts "Context compaction completed: #{before_tokens} -> #{after_tokens} tokens (reduced #{delta} tokens)."
40
+ handle_context_compact(ctx)
81
41
  when "stats"
82
- spec = ctx.current_model_spec
83
- p_name = spec["provider"]
84
- m_name = spec["name"]
85
- context_limit = ctx.llm_registry.context_limit_for(p_name, m_name)
86
- max_output = ctx.llm_registry.max_output_tokens_for(p_name, m_name)
87
- ceiling = ctx.llm_registry.effective_input_limit_for(p_name, m_name)
88
- est_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
89
- pct = ceiling.positive? ? ((est_tokens.to_f / ceiling) * 100).clamp(0.0, 100.0).round(1) : 0.0
90
- summary = ctx.context_index.status_summary
91
- ast_symbols_count = ctx.ast_graph ? ctx.ast_graph.symbols.size : 0
92
-
93
- ctx.out.puts "=== Context & Token Statistics ==="
94
- ctx.out.puts " • Model : #{m_name} (#{p_name})"
95
- ctx.out.puts " • Context Limit : #{context_limit} tokens"
96
- ctx.out.puts " • Usable Capacity : #{ceiling} tokens (Max Output: #{max_output} tokens)"
97
- ctx.out.puts " • Consumed Tokens : #{est_tokens} tokens (#{pct}% of usable capacity)"
98
- ctx.out.puts " • Active Turns : #{ctx.conversation_history.size} turn(s)"
99
- ctx.out.puts " • Indexed Files : #{summary[:file_count]} files"
100
- ctx.out.puts " • AST Symbol Count : #{ast_symbols_count} symbols"
101
- ctx.out.puts "=================================="
102
- guidance = Rubik::Tools::DiagnosticReporter.render_context_status_guidance(
103
- { model: "#{p_name}/#{m_name}", consumed_tokens: est_tokens, context_limit: ceiling },
104
- token_breakdown: { "Conversation Turns" => est_tokens }
105
- )
106
- ctx.out.puts "\n#{guidance}"
42
+ handle_context_stats(ctx)
107
43
  when "status", nil
108
- spec = ctx.current_model_spec
109
- p_name = spec["provider"]
110
- m_name = spec["name"]
111
- context_limit = ctx.llm_registry.context_limit_for(p_name, m_name)
112
- max_output = ctx.llm_registry.max_output_tokens_for(p_name, m_name)
113
- ceiling = ctx.llm_registry.effective_input_limit_for(p_name, m_name)
114
- context_cfg = ctx.config.fetch("context", {})
115
- strategy = context_cfg["compaction_strategy"] || "hybrid"
116
-
117
- est_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
118
- pct = ceiling.positive? ? ((est_tokens.to_f / ceiling) * 100).clamp(0.0, 100.0).round(1) : 0.0
119
- bar_width = 12
120
- filled = ((pct / 100.0) * bar_width).round
121
- empty = bar_width - filled
122
- bar_str = "[#{'█' * filled}#{'░' * empty}] #{pct}%"
123
-
124
- summary = ctx.context_index.status_summary
125
- ast_symbols_count = ctx.ast_graph ? ctx.ast_graph.symbols.size : 0
126
-
127
- ctx.out.puts "=== LLM Context Window & In-Memory Status ==="
128
- ctx.out.puts "Active Model: #{m_name} (#{p_name})"
129
- ctx.out.puts "Context Window Limit: #{context_limit} tokens"
130
- ctx.out.puts "Max Output Tokens: #{max_output} tokens"
131
- ctx.out.puts "Usable Input Ceiling: #{ceiling} tokens"
132
- ctx.out.puts "Estimated Prompt Tokens: #{est_tokens} / #{ceiling} tokens #{bar_str}"
133
- ctx.out.puts "Compaction Strategy: #{strategy}"
134
- ctx.out.puts "Indexed Files: #{summary[:file_count]}, AST Symbols: #{ast_symbols_count}"
135
- ctx.out.puts "============================================="
136
- if (args && (args.include?("--guidance") || args.include?("-g"))) || est_tokens > (ceiling * 0.8)
137
- guidance = Rubik::Tools::DiagnosticReporter.render_context_status_guidance(
138
- { model: "#{p_name}/#{m_name}", consumed_tokens: est_tokens, context_limit: ceiling },
139
- token_breakdown: { "Conversation History" => est_tokens }
140
- )
141
- ctx.out.puts "\n#{guidance}"
142
- end
44
+ handle_context_status(args, ctx)
143
45
  else
144
46
  ctx.out.puts USAGE_CONTEXT
145
47
  end
146
48
  end
147
49
  end
148
50
 
51
+ private_class_method def self.handle_context_scan(ctx)
52
+ ctx.context_index.full_scan
53
+ ctx.warm_ast_graph(async: false)
54
+ ctx.out.puts "Context index and AST symbol graph updated."
55
+ rescue Errno::EACCES => e
56
+ root_path = begin
57
+ ctx.buffer_manager.root_dir
58
+ rescue
59
+ Dir.pwd
60
+ end
61
+ ctx.out.puts Rubik::Tools::DiagnosticReporter.render_permission_denied(root_path, operation: "scan", reason: e.message)
62
+ rescue StandardError => e
63
+ ctx.out.puts "Context scan failed: #{e.message}"
64
+ end
65
+
66
+ private_class_method def self.handle_context_add(args, ctx)
67
+ file = args[1]
68
+ if file
69
+ ctx.context_index.update_file(file)
70
+ full_path = File.join(ctx.buffer_manager.root_dir, file)
71
+ ctx.ast_graph.index_file(full_path) if File.exist?(full_path)
72
+ end
73
+ ctx.out.puts "Added #{file} to context index and AST graph."
74
+ end
75
+
76
+ private_class_method def self.handle_context_drop(args, ctx)
77
+ file = args[1]
78
+ if file
79
+ ctx.context_index.files.delete(file)
80
+ ctx.out.puts "Removed #{file} from context index."
81
+ else
82
+ ctx.out.puts "Usage: !context drop <relative_file_path>"
83
+ end
84
+ end
85
+
86
+ private_class_method def self.handle_context_compact(ctx)
87
+ before_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
88
+ spec = ctx.current_model_spec
89
+ context_limit = ctx.llm_registry.context_limit_for(spec["provider"], spec["name"])
90
+ max_output = ctx.llm_registry.max_output_tokens_for(spec["provider"], spec["name"])
91
+ context_cfg = ctx.config.fetch("context", {})
92
+
93
+ ctx.conversation_history = Rubik::Prompt::ContextCompactor.compact_history(
94
+ ctx.conversation_history,
95
+ context_limit: context_limit,
96
+ max_output_tokens: max_output,
97
+ config: context_cfg,
98
+ force: true
99
+ )
100
+ after_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
101
+ delta = before_tokens - after_tokens
102
+ ctx.out.puts "Context compaction completed: #{before_tokens} -> #{after_tokens} tokens (reduced #{delta} tokens)."
103
+ end
104
+
105
+ private_class_method def self.handle_context_stats(ctx)
106
+ spec = ctx.current_model_spec
107
+ p_name = spec["provider"]
108
+ m_name = spec["name"]
109
+ context_limit = ctx.llm_registry.context_limit_for(p_name, m_name)
110
+ max_output = ctx.llm_registry.max_output_tokens_for(p_name, m_name)
111
+ ceiling = ctx.llm_registry.effective_input_limit_for(p_name, m_name)
112
+ est_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
113
+ pct = ceiling.positive? ? ((est_tokens.to_f / ceiling) * 100).clamp(0.0, 100.0).round(1) : 0.0
114
+ summary = ctx.context_index.status_summary
115
+ ast_symbols_count = ctx.ast_graph ? ctx.ast_graph.symbols.size : 0
116
+
117
+ ctx.out.puts "=== Context & Token Statistics ==="
118
+ ctx.out.puts " • Model : #{m_name} (#{p_name})"
119
+ ctx.out.puts " • Context Limit : #{context_limit} tokens"
120
+ ctx.out.puts " • Usable Capacity : #{ceiling} tokens (Max Output: #{max_output} tokens)"
121
+ ctx.out.puts " • Consumed Tokens : #{est_tokens} tokens (#{pct}% of usable capacity)"
122
+ ctx.out.puts " • Active Turns : #{ctx.conversation_history.size} turn(s)"
123
+ ctx.out.puts " • Indexed Files : #{summary[:file_count]} files"
124
+ ctx.out.puts " • AST Symbol Count : #{ast_symbols_count} symbols"
125
+ ctx.out.puts "=================================="
126
+ guidance = Rubik::Tools::DiagnosticReporter.render_context_status_guidance(
127
+ { model: "#{p_name}/#{m_name}", consumed_tokens: est_tokens, context_limit: ceiling },
128
+ token_breakdown: { "Conversation Turns" => est_tokens }
129
+ )
130
+ ctx.out.puts "\n#{guidance}"
131
+ end
132
+
133
+ private_class_method def self.handle_context_status(args, ctx)
134
+ spec = ctx.current_model_spec
135
+ p_name = spec["provider"]
136
+ m_name = spec["name"]
137
+ context_limit = ctx.llm_registry.context_limit_for(p_name, m_name)
138
+ max_output = ctx.llm_registry.max_output_tokens_for(p_name, m_name)
139
+ ceiling = ctx.llm_registry.effective_input_limit_for(p_name, m_name)
140
+ context_cfg = ctx.config.fetch("context", {})
141
+ strategy = context_cfg["compaction_strategy"] || "hybrid"
142
+
143
+ est_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
144
+ pct = ceiling.positive? ? ((est_tokens.to_f / ceiling) * 100).clamp(0.0, 100.0).round(1) : 0.0
145
+ bar_width = 12
146
+ filled = ((pct / 100.0) * bar_width).round
147
+ empty = bar_width - filled
148
+ bar_str = "[#{'█' * filled}#{'░' * empty}] #{pct}%"
149
+
150
+ summary = ctx.context_index.status_summary
151
+ ast_symbols_count = ctx.ast_graph ? ctx.ast_graph.symbols.size : 0
152
+
153
+ ctx.out.puts "=== LLM Context Window & In-Memory Status ==="
154
+ ctx.out.puts "Active Model: #{m_name} (#{p_name})"
155
+ ctx.out.puts "Context Window Limit: #{context_limit} tokens"
156
+ ctx.out.puts "Max Output Tokens: #{max_output} tokens"
157
+ ctx.out.puts "Usable Input Ceiling: #{ceiling} tokens"
158
+ ctx.out.puts "Estimated Prompt Tokens: #{est_tokens} / #{ceiling} tokens #{bar_str}"
159
+ ctx.out.puts "Compaction Strategy: #{strategy}"
160
+ ctx.out.puts "Indexed Files: #{summary[:file_count]}, AST Symbols: #{ast_symbols_count}"
161
+ ctx.out.puts "============================================="
162
+ return unless (args && (args.include?("--guidance") || args.include?("-g"))) || est_tokens > (ceiling * 0.8)
163
+
164
+ guidance = Rubik::Tools::DiagnosticReporter.render_context_status_guidance(
165
+ { model: "#{p_name}/#{m_name}", consumed_tokens: est_tokens, context_limit: ceiling },
166
+ token_breakdown: { "Conversation History" => est_tokens }
167
+ )
168
+ ctx.out.puts "\n#{guidance}"
169
+ end
170
+
149
171
  private_class_method def self.register_steps_command(shell)
150
172
  shell.register_command("steps", description: "Manage or view agentic step budget (default|goal|plan|<n>|status)",
151
173
  usage: USAGE_STEPS, audience: :both) do |args, ctx|
@@ -208,75 +208,16 @@ module Rubik
208
208
  # çıktısını bayt-baytına korur.
209
209
  private_class_method def self.drive_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
210
210
  goal_text:, goal_id:, record_pair:, start:)
211
- finalize_ledger = lambda do |status|
212
- next unless goal_ledger
213
-
214
- goal_ledger.finalize!(status)
215
- goal_state.ledger_id = goal_ledger.snapshot[:storage_id]
216
- end
217
-
218
- fail_goal = lambda do |reason, tokens: nil, steps: nil|
219
- sanitized = Rubik::Platform.safe_utf8(reason.to_s)
220
- steps_used = steps || goal_state.step_count
221
- goal_state.fail!(sanitized, tokens: tokens)
222
- finalize_ledger.call(:failed)
223
- forget_suspended_goal(ctx, goal_id)
224
- ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
225
- record_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: tokens)
226
- end
227
-
228
- suspend_goal = lambda do |reason, tokens: nil|
229
- attempt = suspended_attempt(ctx, goal_id) + 1
230
- goal_state.suspend!(reason, tokens: tokens)
231
- goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
232
- registry = goal_registry_for(ctx)
233
- registry.register(Rubik::Orchestrator::GoalRegistry::Entry.new(
234
- goal_id: goal_id, goal_text: goal_text, loop: orchestrator,
235
- goal_state: goal_state, ledger: goal_ledger, reason: reason.to_s,
236
- suspended_at: Time.now, attempt: attempt
237
- ))
238
- ctx.out.puts "\n⏸️ [Rubik::Goal] Suspended after #{goal_state.step_count} steps (transient provider error): #{reason}"
239
- ctx.out.puts " Loop, counters and Σ ledger are preserved — resume with `!goal resume`."
240
- record_pair.call("[Goal suspended after #{goal_state.step_count} steps: #{reason}]", tokens: tokens)
241
- end
242
-
243
211
  ctx.active_loop = orchestrator
244
212
  begin
245
213
  res = start.call(orchestrator)
246
-
247
- # Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
248
- # aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
249
- # notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
250
- # route_subagent_notice) — soruya beklerken gelen subagent
251
- # olayları loop inbox'a girer ve devam adımında boşaltılır.
252
- auto_extensions = 0
253
- max_auto = ctx.config.fetch("agent.goal_auto_continue_limit", 0).to_i
254
- while !res[:ok] && res[:error]&.include?("Max steps budget exceeded")
255
- if auto_extensions < max_auto
256
- auto_extensions += 1
257
- ctx.out.puts "\n🤖 [Rubik::Goal] Adım bütçesi doldu (#{res[:error]}). " \
258
- "Otonom devam hakkı (#{auto_extensions}/#{max_auto}) kullanılarak +20 adım ekleniyor..."
259
- goal_state.extend!(goal_state.max_steps + 20)
260
- res = orchestrator.continue_loop(20, auto: true)
261
- next
262
- end
263
-
264
- break unless ctx.config.fetch("agent.ask_continue_on_limit", true)
265
-
266
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
267
- ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
268
- break unless ["y", "yes", "evet"].include?(ans)
269
-
270
- ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
271
- goal_state.extend!(goal_state.max_steps + 20)
272
- res = orchestrator.continue_loop(20)
273
- end
214
+ res = handle_goal_budget_extensions(ctx, orchestrator: orchestrator, goal_state: goal_state, res: res)
274
215
  rescue Interrupt
275
216
  # ^C hedefi 'running' bırakmasın; eşlenmiş kayıt otursun. Interrupt
276
217
  # yeniden yükselir — kullanıcı mesajını execute_command basar.
277
218
  # Resume üstüne ^C = askıyı terk (terminal :stopped damgası).
278
219
  goal_state.stop!("Interrupted by user (^C)")
279
- finalize_ledger.call(:stopped)
220
+ finalize_goal_ledger(goal_ledger, goal_state, :stopped)
280
221
  forget_suspended_goal(ctx, goal_id)
281
222
  record_pair.call("[Goal attempt interrupted after #{goal_state.step_count} steps]")
282
223
  raise
@@ -289,18 +230,92 @@ module Rubik
289
230
  # next yerine return: rescue sonrası res'siz terminal
290
231
  # eşlemesine düşülmesin.
291
232
  if e.respond_to?(:transient?) && e.transient?
292
- suspend_goal.call(e.message)
233
+ suspend_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state, goal_ledger: goal_ledger,
234
+ goal_text: goal_text, goal_id: goal_id, record_pair: record_pair, reason: e.message)
293
235
  else
294
- fail_goal.call(e.message)
236
+ fail_goal_turn(ctx, goal_state: goal_state, goal_ledger: goal_ledger, goal_id: goal_id,
237
+ record_pair: record_pair, reason: e.message)
295
238
  end
296
239
  return
297
240
  rescue StandardError => e
298
- fail_goal.call("#{e.class}: #{e.message}")
241
+ fail_goal_turn(ctx, goal_state: goal_state, goal_ledger: goal_ledger, goal_id: goal_id,
242
+ record_pair: record_pair, reason: "#{e.class}: #{e.message}")
299
243
  return
300
244
  ensure
301
245
  ctx.active_loop = nil
302
246
  end
303
247
 
248
+ handle_goal_completion(ctx, res: res, goal_state: goal_state, goal_ledger: goal_ledger,
249
+ goal_id: goal_id, record_pair: record_pair)
250
+ end
251
+
252
+ private_class_method def self.finalize_goal_ledger(goal_ledger, goal_state, status)
253
+ return unless goal_ledger
254
+
255
+ goal_ledger.finalize!(status)
256
+ goal_state.ledger_id = goal_ledger.snapshot[:storage_id]
257
+ end
258
+
259
+ private_class_method def self.fail_goal_turn(ctx, goal_state:, goal_ledger:, goal_id:, record_pair:,
260
+ reason:, tokens: nil, steps: nil)
261
+ sanitized = Rubik::Platform.safe_utf8(reason.to_s)
262
+ steps_used = steps || goal_state.step_count
263
+ goal_state.fail!(sanitized, tokens: tokens)
264
+ finalize_goal_ledger(goal_ledger, goal_state, :failed)
265
+ forget_suspended_goal(ctx, goal_id)
266
+ ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
267
+ record_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: tokens)
268
+ end
269
+
270
+ private_class_method def self.suspend_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
271
+ goal_text:, goal_id:, record_pair:, reason:, tokens: nil)
272
+ attempt = suspended_attempt(ctx, goal_id) + 1
273
+ goal_state.suspend!(reason, tokens: tokens)
274
+ goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
275
+ registry = goal_registry_for(ctx)
276
+ registry.register(Rubik::Orchestrator::GoalRegistry::Entry.new(
277
+ goal_id: goal_id, goal_text: goal_text, loop: orchestrator,
278
+ goal_state: goal_state, ledger: goal_ledger, reason: reason.to_s,
279
+ suspended_at: Time.now, attempt: attempt
280
+ ))
281
+ ctx.out.puts "\n⏸️ [Rubik::Goal] Suspended after #{goal_state.step_count} steps (transient provider error): #{reason}"
282
+ ctx.out.puts " Loop, counters and Σ ledger are preserved — resume with `!goal resume`."
283
+ record_pair.call("[Goal suspended after #{goal_state.step_count} steps: #{reason}]", tokens: tokens)
284
+ end
285
+
286
+ private_class_method def self.handle_goal_budget_extensions(ctx, orchestrator:, goal_state:, res:)
287
+ # Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
288
+ # aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
289
+ # notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
290
+ # route_subagent_notice) — soruya beklerken gelen subagent
291
+ # olayları loop inbox'a girer ve devam adımında boşaltılır.
292
+ auto_extensions = 0
293
+ max_auto = ctx.config.fetch("agent.goal_auto_continue_limit", 0).to_i
294
+ while !res[:ok] && res[:error]&.include?("Max steps budget exceeded")
295
+ if auto_extensions < max_auto
296
+ auto_extensions += 1
297
+ ctx.out.puts "\n🤖 [Rubik::Goal] Adım bütçesi doldu (#{res[:error]}). " \
298
+ "Otonom devam hakkı (#{auto_extensions}/#{max_auto}) kullanılarak +20 adım ekleniyor..."
299
+ goal_state.extend!(goal_state.max_steps + 20)
300
+ res = orchestrator.continue_loop(20, auto: true)
301
+ next
302
+ end
303
+
304
+ break unless ctx.config.fetch("agent.ask_continue_on_limit", true)
305
+
306
+ ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
307
+ ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
308
+ break unless ["y", "yes", "evet"].include?(ans)
309
+
310
+ ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
311
+ goal_state.extend!(goal_state.max_steps + 20)
312
+ res = orchestrator.continue_loop(20)
313
+ end
314
+ res
315
+ end
316
+
317
+ private_class_method def self.handle_goal_completion(ctx, res:, goal_state:, goal_ledger:,
318
+ goal_id:, record_pair:)
304
319
  if res[:ok]
305
320
  active = goal_active_subagent_count(ctx)
306
321
  if active.positive?
@@ -310,12 +325,12 @@ module Rubik
310
325
  # "Goal Achieved" (field incident 2026-10-05).
311
326
  reason = "Goal loop concluded with #{active} active subagent(s) still running — not marked complete"
312
327
  goal_state.stop!(reason)
313
- finalize_ledger.call(:stopped)
328
+ finalize_goal_ledger(goal_ledger, goal_state, :stopped)
314
329
  ctx.out.puts "\n⚠️ [Rubik::Goal] #{reason}."
315
330
  record_pair.call("[Goal attempt ended after #{goal_state.step_count} steps: #{reason}]", tokens: res[:tokens])
316
331
  else
317
332
  goal_state.complete!(res[:output].to_s, tokens: res[:tokens])
318
- finalize_ledger.call(:completed)
333
+ finalize_goal_ledger(goal_ledger, goal_state, :completed)
319
334
  forget_suspended_goal(ctx, goal_id)
320
335
  ctx.out.puts "\n🎉 Goal Achieved in #{res[:steps]} steps!"
321
336
  ctx.out.puts res[:output]
@@ -329,10 +344,10 @@ module Rubik
329
344
  # Başarısızlık eşlemesi yukarıdaki tek hata kanalından akar:
330
345
  # GoalState → ledger finalize → kayıtçı çifti, istisna yoluyla
331
346
  # aynı sırayla.
332
- fail_goal.call(res[:error], tokens: res[:tokens], steps: res[:steps])
347
+ fail_goal_turn(ctx, goal_state: goal_state, goal_ledger: goal_ledger, goal_id: goal_id,
348
+ record_pair: record_pair, reason: res[:error], tokens: res[:tokens], steps: res[:steps])
333
349
  end
334
350
  end
335
-
336
351
  # ------------------------------------------------------------------
337
352
  # !goal resume (issue #106). İki yol, dönüşüm sırasına göre:
338
353
  # 1) Oturum kaydında canlı girdi varsa süreç-içi resume — hayatta