rubik 0.3.3 → 0.3.5

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +102 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +99 -69
  9. data/lib/rubik/config/defaults.rb +17 -20
  10. data/lib/rubik/config.rb +0 -6
  11. data/lib/rubik/context/git_lister.rb +3 -1
  12. data/lib/rubik/llm/anthropic.rb +5 -2
  13. data/lib/rubik/llm/client.rb +102 -72
  14. data/lib/rubik/llm/openai_compatible.rb +83 -68
  15. data/lib/rubik/lsp/client.rb +320 -96
  16. data/lib/rubik/lsp/manager.rb +83 -12
  17. data/lib/rubik/lsp/server_command.rb +102 -0
  18. data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
  19. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
  21. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  22. data/lib/rubik/platform/process.rb +6 -6
  23. data/lib/rubik/platform/sanitize.rb +37 -16
  24. data/lib/rubik/prompt/doctrine.rb +4 -0
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +183 -155
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/agent_providers.rb +190 -156
  29. data/lib/rubik/repl/commands/buffer.rb +122 -88
  30. data/lib/rubik/repl/commands/lsp.rb +212 -179
  31. data/lib/rubik/repl/commands/mcp.rb +120 -92
  32. data/lib/rubik/repl/commands/session.rb +90 -59
  33. data/lib/rubik/repl/commands/workspace.rb +266 -246
  34. data/lib/rubik/repl/shell.rb +10 -1
  35. data/lib/rubik/runtime/discovery.rb +12 -6
  36. data/lib/rubik/runtime/overlay_storage.rb +59 -2
  37. data/lib/rubik/sandbox/driver/docker.rb +2 -2
  38. data/lib/rubik/session/replayer.rb +1 -2
  39. data/lib/rubik/storage/store.rb +3 -2
  40. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  41. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  42. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  43. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  44. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  45. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  46. data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
  47. data/lib/rubik/version.rb +1 -1
  48. metadata +2 -1
@@ -80,107 +80,120 @@ module Rubik
80
80
  next resume_suspended_goal(ctx) if args.length == 1 && sub == "resume"
81
81
  next list_suspended_goals(ctx) if args.length == 1 && sub == "list"
82
82
 
83
- custom_steps = nil
84
- bad_steps = false
85
- filtered_args = []
86
- i = 0
87
- while i < args.length
88
- arg = args[i]
89
- if (m = arg.match(/\A--steps=(\d+)\z/))
90
- custom_steps = m[1].to_i
91
- elsif arg.start_with?("--steps=")
92
- # --steps=X ama X sayı değil: flag metne sızmasın.
93
- bad_steps = true
94
- elsif ["--steps", "-s"].include?(arg)
95
- if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
96
- custom_steps = args[i + 1].to_i
97
- i += 1
98
- else
99
- # Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
100
- # yanlış koşu yerine).
101
- bad_steps = true
102
- end
103
- else
104
- filtered_args << arg
105
- end
106
- i += 1
107
- end
108
-
109
- if bad_steps || (custom_steps && custom_steps < 1)
83
+ parsed = parse_goal_args(args)
84
+ if !parsed[:valid]
110
85
  ctx.out.puts USAGE_GOAL
111
86
  ctx.out.puts "⚠️ [Rubik::Goal] --steps pozitif bir tam sayı bekliyor (örn. !goal --steps 200 <hedef>)"
112
87
  next
113
- end
114
-
115
- goal_text = filtered_args.join(" ").strip
116
- if goal_text.empty?
88
+ elsif parsed[:goal_text].empty?
117
89
  ctx.out.puts USAGE_GOAL
118
90
  next
119
91
  end
120
92
 
121
- ctx.recorder&.record_user_turn("!goal #{goal_text}")
122
- goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
123
- warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
124
- routed = ctx.router.route(task_type: :goal, prompt: goal_text)
125
- ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
93
+ execute_goal(ctx, parsed[:goal_text], parsed[:custom_steps])
94
+ end
95
+ end
126
96
 
127
- # Canlı hedef durumu: maestro ve alt ajanlar read_file
128
- # rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
129
- # semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
130
- # yığmaz; session dispose'u tek delete-inverse ile temizler.
131
- # event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
132
- # kronikçisinin goal kancasına akar (K3) — notice/wake yok.
133
- goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
134
- goal_state.start!(goal_text, goal_steps)
135
- ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
136
-
137
- # Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
138
- # koşuyla doğar, terminal dallarda finalize olur, provide swap
139
- # semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
140
- # OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
141
- goal_ledger = nil
142
- if Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
143
- store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
144
- dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
145
- goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
146
- goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
147
- event_bus: ctx.event_bus
148
- )
149
- ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
97
+ private_class_method def self.parse_goal_args(args)
98
+ custom_steps = nil
99
+ bad_steps = false
100
+ filtered_args = []
101
+ i = 0
102
+ while i < args.length
103
+ arg = args[i]
104
+ if (m = arg.match(/\A--steps=(\d+)\z/))
105
+ custom_steps = m[1].to_i
106
+ elsif arg.start_with?("--steps=")
107
+ # --steps=X ama X sayı değil: flag metne sızmasın.
108
+ bad_steps = true
109
+ elsif ["--steps", "-s"].include?(arg)
110
+ if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
111
+ custom_steps = args[i + 1].to_i
112
+ i += 1
113
+ else
114
+ # Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
115
+ # yanlış koşu yerine).
116
+ bad_steps = true
117
+ end
118
+ else
119
+ filtered_args << arg
150
120
  end
151
- # Askı kimliği (issue #106): koşu boyunca SABİT — registry
152
- # girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
153
- # (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
154
- goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
121
+ i += 1
122
+ end
155
123
 
156
- ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
157
- ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
124
+ goal_text = filtered_args.join(" ").strip
125
+ valid = !bad_steps && (custom_steps.nil? || custom_steps >= 1)
126
+ { custom_steps: custom_steps, goal_text: goal_text, valid: valid }
127
+ end
158
128
 
159
- orchestrator = ctx.orchestrator_loop(
160
- provider: routed[:provider],
161
- model: routed[:model],
162
- max_steps: goal_steps,
163
- warn_at_ratio: warn_ratio,
164
- context_limit: routed[:context_limit],
165
- max_output_tokens: routed[:max_output_tokens],
166
- on_step: build_goal_on_step(ctx, goal_state)
167
- )
129
+ private_class_method def self.initialize_goal_ledger(ctx, goal_text)
130
+ return nil unless Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
168
131
 
169
- # Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
170
- # Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
171
- # restore için conversation_history VE record_assistant_response
172
- # birlikte yazılmalı (başarısızlıkta da).
173
- record_goal_pair = build_goal_record_pair(ctx, goal_text)
132
+ store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
133
+ dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
134
+ goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
135
+ goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
136
+ event_bus: ctx.event_bus
137
+ )
138
+ ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
139
+ goal_ledger
140
+ end
174
141
 
175
- # Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
176
- # aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
177
- ctx.apply_staged_prune_plan!
142
+ private_class_method def self.execute_goal(ctx, goal_text, custom_steps)
143
+ ctx.recorder&.record_user_turn("!goal #{goal_text}")
144
+ goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
145
+ warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
146
+ routed = ctx.router.route(task_type: :goal, prompt: goal_text)
147
+ ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
178
148
 
179
- drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
180
- goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
181
- record_pair: record_goal_pair,
182
- start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
183
- end
149
+ # Canlı hedef durumu: maestro ve alt ajanlar read_file
150
+ # rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
151
+ # semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
152
+ # yığmaz; session dispose'u tek delete-inverse ile temizler.
153
+ # event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
154
+ # kronikçisinin goal kancasına akar (K3) — notice/wake yok.
155
+ goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
156
+ goal_state.start!(goal_text, goal_steps)
157
+ ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
158
+
159
+ # Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
160
+ # koşuyla doğar, terminal dallarda finalize olur, provide swap
161
+ # semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
162
+ # OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
163
+ goal_ledger = initialize_goal_ledger(ctx, goal_text)
164
+
165
+ # Askı kimliği (issue #106): koşu boyunca SABİT — registry
166
+ # girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
167
+ # (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
168
+ goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
169
+
170
+ ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
171
+ ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
172
+
173
+ orchestrator = ctx.orchestrator_loop(
174
+ provider: routed[:provider],
175
+ model: routed[:model],
176
+ max_steps: goal_steps,
177
+ warn_at_ratio: warn_ratio,
178
+ context_limit: routed[:context_limit],
179
+ max_output_tokens: routed[:max_output_tokens],
180
+ on_step: build_goal_on_step(ctx, goal_state)
181
+ )
182
+
183
+ # Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
184
+ # Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
185
+ # restore için conversation_history VE record_assistant_response
186
+ # birlikte yazılmalı (başarısızlıkta da).
187
+ record_goal_pair = build_goal_record_pair(ctx, goal_text)
188
+
189
+ # Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
190
+ # aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
191
+ ctx.apply_staged_prune_plan!
192
+
193
+ drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
194
+ goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
195
+ record_pair: record_goal_pair,
196
+ start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
184
197
  end
185
198
 
186
199
  # ------------------------------------------------------------------
@@ -195,75 +208,16 @@ module Rubik
195
208
  # çıktısını bayt-baytına korur.
196
209
  private_class_method def self.drive_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
197
210
  goal_text:, goal_id:, record_pair:, start:)
198
- finalize_ledger = lambda do |status|
199
- next unless goal_ledger
200
-
201
- goal_ledger.finalize!(status)
202
- goal_state.ledger_id = goal_ledger.snapshot[:storage_id]
203
- end
204
-
205
- fail_goal = lambda do |reason, tokens: nil, steps: nil|
206
- sanitized = Rubik::Platform.safe_utf8(reason.to_s)
207
- steps_used = steps || goal_state.step_count
208
- goal_state.fail!(sanitized, tokens: tokens)
209
- finalize_ledger.call(:failed)
210
- forget_suspended_goal(ctx, goal_id)
211
- ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
212
- record_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: tokens)
213
- end
214
-
215
- suspend_goal = lambda do |reason, tokens: nil|
216
- attempt = suspended_attempt(ctx, goal_id) + 1
217
- goal_state.suspend!(reason, tokens: tokens)
218
- goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
219
- registry = goal_registry_for(ctx)
220
- registry.register(Rubik::Orchestrator::GoalRegistry::Entry.new(
221
- goal_id: goal_id, goal_text: goal_text, loop: orchestrator,
222
- goal_state: goal_state, ledger: goal_ledger, reason: reason.to_s,
223
- suspended_at: Time.now, attempt: attempt
224
- ))
225
- ctx.out.puts "\n⏸️ [Rubik::Goal] Suspended after #{goal_state.step_count} steps (transient provider error): #{reason}"
226
- ctx.out.puts " Loop, counters and Σ ledger are preserved — resume with `!goal resume`."
227
- record_pair.call("[Goal suspended after #{goal_state.step_count} steps: #{reason}]", tokens: tokens)
228
- end
229
-
230
211
  ctx.active_loop = orchestrator
231
212
  begin
232
213
  res = start.call(orchestrator)
233
-
234
- # Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
235
- # aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
236
- # notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
237
- # route_subagent_notice) — soruya beklerken gelen subagent
238
- # olayları loop inbox'a girer ve devam adımında boşaltılır.
239
- auto_extensions = 0
240
- max_auto = ctx.config.fetch("agent.goal_auto_continue_limit", 0).to_i
241
- while !res[:ok] && res[:error]&.include?("Max steps budget exceeded")
242
- if auto_extensions < max_auto
243
- auto_extensions += 1
244
- ctx.out.puts "\n🤖 [Rubik::Goal] Adım bütçesi doldu (#{res[:error]}). " \
245
- "Otonom devam hakkı (#{auto_extensions}/#{max_auto}) kullanılarak +20 adım ekleniyor..."
246
- goal_state.extend!(goal_state.max_steps + 20)
247
- res = orchestrator.continue_loop(20, auto: true)
248
- next
249
- end
250
-
251
- break unless ctx.config.fetch("agent.ask_continue_on_limit", true)
252
-
253
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
254
- ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
255
- break unless ["y", "yes", "evet"].include?(ans)
256
-
257
- ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
258
- goal_state.extend!(goal_state.max_steps + 20)
259
- res = orchestrator.continue_loop(20)
260
- end
214
+ res = handle_goal_budget_extensions(ctx, orchestrator: orchestrator, goal_state: goal_state, res: res)
261
215
  rescue Interrupt
262
216
  # ^C hedefi 'running' bırakmasın; eşlenmiş kayıt otursun. Interrupt
263
217
  # yeniden yükselir — kullanıcı mesajını execute_command basar.
264
218
  # Resume üstüne ^C = askıyı terk (terminal :stopped damgası).
265
219
  goal_state.stop!("Interrupted by user (^C)")
266
- finalize_ledger.call(:stopped)
220
+ finalize_goal_ledger(goal_ledger, goal_state, :stopped)
267
221
  forget_suspended_goal(ctx, goal_id)
268
222
  record_pair.call("[Goal attempt interrupted after #{goal_state.step_count} steps]")
269
223
  raise
@@ -276,18 +230,92 @@ module Rubik
276
230
  # next yerine return: rescue sonrası res'siz terminal
277
231
  # eşlemesine düşülmesin.
278
232
  if e.respond_to?(:transient?) && e.transient?
279
- 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)
280
235
  else
281
- 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)
282
238
  end
283
239
  return
284
240
  rescue StandardError => e
285
- 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}")
286
243
  return
287
244
  ensure
288
245
  ctx.active_loop = nil
289
246
  end
290
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:)
291
319
  if res[:ok]
292
320
  active = goal_active_subagent_count(ctx)
293
321
  if active.positive?
@@ -297,12 +325,12 @@ module Rubik
297
325
  # "Goal Achieved" (field incident 2026-10-05).
298
326
  reason = "Goal loop concluded with #{active} active subagent(s) still running — not marked complete"
299
327
  goal_state.stop!(reason)
300
- finalize_ledger.call(:stopped)
328
+ finalize_goal_ledger(goal_ledger, goal_state, :stopped)
301
329
  ctx.out.puts "\n⚠️ [Rubik::Goal] #{reason}."
302
330
  record_pair.call("[Goal attempt ended after #{goal_state.step_count} steps: #{reason}]", tokens: res[:tokens])
303
331
  else
304
332
  goal_state.complete!(res[:output].to_s, tokens: res[:tokens])
305
- finalize_ledger.call(:completed)
333
+ finalize_goal_ledger(goal_ledger, goal_state, :completed)
306
334
  forget_suspended_goal(ctx, goal_id)
307
335
  ctx.out.puts "\n🎉 Goal Achieved in #{res[:steps]} steps!"
308
336
  ctx.out.puts res[:output]
@@ -316,10 +344,10 @@ module Rubik
316
344
  # Başarısızlık eşlemesi yukarıdaki tek hata kanalından akar:
317
345
  # GoalState → ledger finalize → kayıtçı çifti, istisna yoluyla
318
346
  # aynı sırayla.
319
- 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])
320
349
  end
321
350
  end
322
-
323
351
  # ------------------------------------------------------------------
324
352
  # !goal resume (issue #106). İki yol, dönüşüm sırasına göre:
325
353
  # 1) Oturum kaydında canlı girdi varsa süreç-içi resume — hayatta
@@ -110,99 +110,124 @@ module Rubik
110
110
  end
111
111
  end
112
112
 
113
+ private_class_method def self.handle_list_models(ctx)
114
+ mode = ctx.config.fetch("router.mode", "auto")
115
+ def_spec = ctx.config.model_spec(:default)
116
+ fast_spec = ctx.config.model_spec(:fast)
117
+ adv_spec = ctx.config.model_spec(:advanced)
118
+
119
+ def_limit = ctx.llm_registry.context_limit_for(def_spec["provider"], def_spec["name"])
120
+ fast_limit = ctx.llm_registry.context_limit_for(fast_spec["provider"], fast_spec["name"])
121
+ adv_limit = ctx.llm_registry.context_limit_for(adv_spec["provider"], adv_spec["name"])
122
+
123
+ def_ep = def_spec["endpoint"] || def_spec["host"] || "cloud"
124
+ fast_ep = fast_spec["endpoint"] || fast_spec["host"] || "cloud"
125
+ adv_ep = adv_spec["endpoint"] || adv_spec["host"] || "cloud"
126
+
127
+ ctx.out.puts "Model Router Configuration (Mode: #{mode}):"
128
+ ctx.out.puts " • default : #{def_spec['provider']}/#{def_spec['name']} [Context: #{def_limit} tokens] (#{def_ep})"
129
+ ctx.out.puts " • fast : #{fast_spec['provider']}/#{fast_spec['name']} [Context: #{fast_limit} tokens] (#{fast_ep})"
130
+ ctx.out.puts " • advanced : #{adv_spec['provider']}/#{adv_spec['name']} [Context: #{adv_limit} tokens] (#{adv_ep})"
131
+ ctx.out.puts "Global Config : #{ctx.config.global_config_path}"
132
+ ctx.out.puts "Project Config : #{ctx.config.project_config_path}"
133
+ end
134
+
135
+ private_class_method def self.handle_route_command(clean_args, ctx, scope)
136
+ mode = clean_args[1].to_s.downcase
137
+ if ["auto", "manual"].include?(mode)
138
+ res = ctx.config.set("router.mode", mode, persist: scope)
139
+ return if Base.report_persist_failure(ctx, res, scope)
140
+
141
+ ctx.out.puts "Router mode set to #{mode} [#{Base.scope_label(scope)}]."
142
+ else
143
+ ctx.out.puts "Usage: !model route <auto|manual>"
144
+ end
145
+ end
146
+
147
+ private_class_method def self.handle_add_model(clean_args, ctx, scope)
148
+ p_name = clean_args[1]&.downcase
149
+ m_name = clean_args[2]
150
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
151
+ ctx.out.puts "Usage: !model add <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
152
+ else
153
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
154
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
155
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
156
+ ctx.refresh_llm_registry!
157
+ if res[:ok]
158
+ ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
159
+ elsif res[:reason] == :unknown_provider
160
+ ctx.out.puts "Provider '#{p_name}' not found. Add provider first via !provider add."
161
+ else
162
+ Base.report_persist_failure(ctx, res, scope)
163
+ end
164
+ end
165
+ end
166
+
167
+ private_class_method def self.handle_context_command(clean_args, ctx, scope)
168
+ if clean_args.size == 2 && clean_args[1] =~ /^\d+$/
169
+ def_spec = ctx.config.model_spec(:default)
170
+ update_context_limit(ctx, scope, def_spec["provider"], def_spec["name"],
171
+ clean_args[1].to_i, "default model #{def_spec['provider']}/#{def_spec['name']}")
172
+ elsif clean_args.size == 3 && ["default", "fast", "advanced"].include?(clean_args[1].downcase) && clean_args[2] =~ /^\d+$/
173
+ role = clean_args[1].downcase.to_sym
174
+ spec = ctx.config.model_spec(role)
175
+ update_context_limit(ctx, scope, spec["provider"], spec["name"],
176
+ clean_args[2].to_i, "#{role} model #{spec['provider']}/#{spec['name']}")
177
+ elsif clean_args.size == 4 && clean_args[3] =~ /^\d+$/
178
+ update_context_limit(ctx, scope, clean_args[1].downcase, clean_args[2],
179
+ clean_args[3].to_i, "#{clean_args[1].downcase}/#{clean_args[2]}")
180
+ else
181
+ ctx.out.puts "Usage: !model context <limit> | !model context <default|fast|advanced> <limit> | " \
182
+ "!model context <provider> <model> <limit> [--project|--global]"
183
+ end
184
+ end
185
+
186
+ private_class_method def self.handle_set_role_command(clean_args, ctx, scope)
187
+ role = clean_args[1].to_s.downcase
188
+ model_args = clean_args[2..]
189
+ if model_args.nil? || model_args.empty?
190
+ ctx.out.puts "Usage: !model set <default|fast|advanced> [<provider>] <model_name> [--project|--global]"
191
+ else
192
+ prov, mod = parse_model_args(model_args, ctx)
193
+ res = ctx.config.set_model_spec(role, provider: prov, name: mod, persist: scope)
194
+ ctx.resync_llm_route!
195
+ return if Base.report_persist_failure(ctx, res, scope)
196
+
197
+ limit = ctx.llm_registry.context_limit_for(prov, mod)
198
+ ctx.out.puts "Set model role '#{role}' to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
199
+ end
200
+ end
201
+
202
+ private_class_method def self.handle_switch_default_model(clean_args, ctx, scope)
203
+ prov, mod = parse_model_args(clean_args, ctx)
204
+ res = ctx.config.set_model_spec(:default, provider: prov, name: mod, persist: scope)
205
+ ctx.resync_llm_route!
206
+ return if Base.report_persist_failure(ctx, res, scope)
207
+
208
+ limit = ctx.llm_registry.context_limit_for(prov, mod)
209
+ ctx.out.puts "Switched default model to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
210
+ end
211
+
113
212
  private_class_method def self.register_model_command(shell)
114
213
  shell.register_command("model",
115
214
  audience: :both,
116
215
  description: "Switch or view LLM provider/model roles (default, fast, advanced) with auto-persistence") do |args, ctx|
117
216
  scope, clean_args = Base.parse_scope(args)
118
-
119
- if clean_args.empty? || clean_args.first.downcase == "list"
120
- mode = ctx.config.fetch("router.mode", "auto")
121
- def_spec = ctx.config.model_spec(:default)
122
- fast_spec = ctx.config.model_spec(:fast)
123
- adv_spec = ctx.config.model_spec(:advanced)
124
-
125
- def_limit = ctx.llm_registry.context_limit_for(def_spec["provider"], def_spec["name"])
126
- fast_limit = ctx.llm_registry.context_limit_for(fast_spec["provider"], fast_spec["name"])
127
- adv_limit = ctx.llm_registry.context_limit_for(adv_spec["provider"], adv_spec["name"])
128
-
129
- def_ep = def_spec["endpoint"] || def_spec["host"] || "cloud"
130
- fast_ep = fast_spec["endpoint"] || fast_spec["host"] || "cloud"
131
- adv_ep = adv_spec["endpoint"] || adv_spec["host"] || "cloud"
132
-
133
- ctx.out.puts "Model Router Configuration (Mode: #{mode}):"
134
- ctx.out.puts " • default : #{def_spec['provider']}/#{def_spec['name']} [Context: #{def_limit} tokens] (#{def_ep})"
135
- ctx.out.puts " • fast : #{fast_spec['provider']}/#{fast_spec['name']} [Context: #{fast_limit} tokens] (#{fast_ep})"
136
- ctx.out.puts " • advanced : #{adv_spec['provider']}/#{adv_spec['name']} [Context: #{adv_limit} tokens] (#{adv_ep})"
137
- ctx.out.puts "Global Config : #{ctx.config.global_config_path}"
138
- ctx.out.puts "Project Config : #{ctx.config.project_config_path}"
139
- elsif clean_args.first.downcase == "route"
140
- mode = clean_args[1].to_s.downcase
141
- if ["auto", "manual"].include?(mode)
142
- res = ctx.config.set("router.mode", mode, persist: scope)
143
- next if Base.report_persist_failure(ctx, res, scope)
144
-
145
- ctx.out.puts "Router mode set to #{mode} [#{Base.scope_label(scope)}]."
146
- else
147
- ctx.out.puts "Usage: !model route <auto|manual>"
148
- end
149
- elsif clean_args.first.downcase == "add"
150
- p_name = clean_args[1]&.downcase
151
- m_name = clean_args[2]
152
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
153
- ctx.out.puts "Usage: !model add <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
154
- else
155
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
156
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
157
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
158
- ctx.refresh_llm_registry!
159
- if res[:ok]
160
- ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
161
- elsif res[:reason] == :unknown_provider
162
- ctx.out.puts "Provider '#{p_name}' not found. Add provider first via !provider add."
163
- else
164
- Base.report_persist_failure(ctx, res, scope)
165
- end
166
- end
167
- elsif ["context", "limit"].include?(clean_args.first.downcase)
168
- if clean_args.size == 2 && clean_args[1] =~ /^\d+$/
169
- def_spec = ctx.config.model_spec(:default)
170
- update_context_limit(ctx, scope, def_spec["provider"], def_spec["name"],
171
- clean_args[1].to_i, "default model #{def_spec['provider']}/#{def_spec['name']}")
172
- elsif clean_args.size == 3 && ["default", "fast", "advanced"].include?(clean_args[1].downcase) && clean_args[2] =~ /^\d+$/
173
- role = clean_args[1].downcase.to_sym
174
- spec = ctx.config.model_spec(role)
175
- update_context_limit(ctx, scope, spec["provider"], spec["name"],
176
- clean_args[2].to_i, "#{role} model #{spec['provider']}/#{spec['name']}")
177
- elsif clean_args.size == 4 && clean_args[3] =~ /^\d+$/
178
- update_context_limit(ctx, scope, clean_args[1].downcase, clean_args[2],
179
- clean_args[3].to_i, "#{clean_args[1].downcase}/#{clean_args[2]}")
180
- else
181
- ctx.out.puts "Usage: !model context <limit> | !model context <default|fast|advanced> <limit> | " \
182
- "!model context <provider> <model> <limit> [--project|--global]"
183
- end
184
- elsif clean_args.first.downcase == "set"
185
- role = clean_args[1].to_s.downcase
186
- model_args = clean_args[2..]
187
- if model_args.nil? || model_args.empty?
188
- ctx.out.puts "Usage: !model set <default|fast|advanced> [<provider>] <model_name> [--project|--global]"
189
- else
190
- prov, mod = parse_model_args(model_args, ctx)
191
- res = ctx.config.set_model_spec(role, provider: prov, name: mod, persist: scope)
192
- ctx.resync_llm_route!
193
- next if Base.report_persist_failure(ctx, res, scope)
194
-
195
- limit = ctx.llm_registry.context_limit_for(prov, mod)
196
- ctx.out.puts "Set model role '#{role}' to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
197
- end
217
+ subcmd = clean_args.first&.downcase
218
+
219
+ if clean_args.empty? || subcmd == "list"
220
+ handle_list_models(ctx)
221
+ elsif subcmd == "route"
222
+ handle_route_command(clean_args, ctx, scope)
223
+ elsif subcmd == "add"
224
+ handle_add_model(clean_args, ctx, scope)
225
+ elsif ["context", "limit"].include?(subcmd)
226
+ handle_context_command(clean_args, ctx, scope)
227
+ elsif subcmd == "set"
228
+ handle_set_role_command(clean_args, ctx, scope)
198
229
  else
199
- prov, mod = parse_model_args(clean_args, ctx)
200
- res = ctx.config.set_model_spec(:default, provider: prov, name: mod, persist: scope)
201
- ctx.resync_llm_route!
202
- next if Base.report_persist_failure(ctx, res, scope)
203
-
204
- limit = ctx.llm_registry.context_limit_for(prov, mod)
205
- ctx.out.puts "Switched default model to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
230
+ handle_switch_default_model(clean_args, ctx, scope)
206
231
  end
207
232
  end
208
233
  end