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
@@ -48,7 +48,10 @@ module Rubik
48
48
  end
49
49
 
50
50
  def manifest_path(dir)
51
- File.join(dir, ".manifest.json")
51
+ ensure_valid_overlay_dir!(dir)
52
+ path = File.join(dir, ".manifest.json")
53
+ ensure_contained_path!(path, dir)
54
+ path
52
55
  end
53
56
 
54
57
  def load_manifest(dir)
@@ -56,7 +59,7 @@ module Rubik
56
59
  return {} unless File.exist?(path)
57
60
 
58
61
  JSON.parse(File.read(path))
59
- rescue JSON::ParserError
62
+ rescue JSON::ParserError, ArgumentError
60
63
  {}
61
64
  end
62
65
 
@@ -73,6 +76,7 @@ module Rubik
73
76
  end
74
77
 
75
78
  def record_hash(dir, filename, scope)
79
+ ensure_valid_overlay_dir!(dir)
76
80
  if manifest_state(dir) == :corrupt
77
81
  # Bozuk manifesti sessizce "heal" etmek (yalnızca yeni kaydı yazmak) diğer
78
82
  # kayıtlı overlay'leri tahliye eder; bunun yerine kaydı reddet.
@@ -80,6 +84,7 @@ module Rubik
80
84
  end
81
85
 
82
86
  path = File.join(dir, filename)
87
+ ensure_contained_path!(path, dir)
83
88
  hash = Digest::SHA256.hexdigest(normalize_content(File.binread(path)))
84
89
  manifest = load_manifest(dir)
85
90
  manifest[filename] = {
@@ -93,6 +98,11 @@ module Rubik
93
98
  def verify_integrity(file_path, dir, bootstrap_eligible: false)
94
99
  return :trusted if trust_mode?
95
100
 
101
+ unless safe_path_under?(file_path, dir)
102
+ warn "[SECURITY] Path traversal attempt detected for overlay '#{file_path}'. Skipping."
103
+ return :rejected
104
+ end
105
+
96
106
  basename = File.basename(file_path)
97
107
  manifest = load_manifest(dir)
98
108
  current_content = File.exist?(file_path) ? File.binread(file_path) : ""
@@ -140,6 +150,8 @@ module Rubik
140
150
 
141
151
  filename = "#{clean_name.gsub(':', '_')}.rb"
142
152
  file_path = File.join(target_dir, filename)
153
+ ensure_contained_path!(file_path, target_dir)
154
+
143
155
  header = <<~RUBY
144
156
  # frozen_string_literal: true
145
157
  #{COMMAND_SENTINEL} !#{clean_name}
@@ -164,6 +176,7 @@ module Rubik
164
176
  target_dir = scope.to_sym == :global ? global_commands_dir : project_commands_dir
165
177
  filename = "#{clean_name.gsub(':', '_')}.rb"
166
178
  file_path = File.join(target_dir, filename)
179
+ ensure_contained_path!(file_path, target_dir)
167
180
 
168
181
  raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
169
182
 
@@ -190,8 +203,10 @@ module Rubik
190
203
 
191
204
  file_path = cmd_meta[:path]
192
205
  cmd_name = cmd_meta[:name]
206
+ target_dir = cmd_meta[:scope].to_sym == :global ? global_commands_dir : project_commands_dir
193
207
 
194
208
  begin
209
+ ensure_contained_path!(file_path, target_dir)
195
210
  code = File.read(file_path)
196
211
  eval_command(shell, cmd_name, code, cmd_meta[:scope])
197
212
  loaded << cmd_name
@@ -249,6 +264,8 @@ module Rubik
249
264
 
250
265
  filename = "#{clean_name}.rb"
251
266
  file_path = File.join(target_dir, filename)
267
+ ensure_contained_path!(file_path, target_dir)
268
+
252
269
  schema_json = params_schema ? JSON.dump(params_schema) : "{}"
253
270
  header = <<~RUBY
254
271
  # frozen_string_literal: true
@@ -278,6 +295,7 @@ module Rubik
278
295
  target_dir = scope.to_sym == :global ? global_tools_dir : project_tools_dir
279
296
  filename = "#{clean_name}.rb"
280
297
  file_path = File.join(target_dir, filename)
298
+ ensure_contained_path!(file_path, target_dir)
281
299
 
282
300
  raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
283
301
 
@@ -301,6 +319,10 @@ module Rubik
301
319
  list_tools.each do |t_meta|
302
320
  next if t_meta[:integrity] == :rejected
303
321
 
322
+ file_path = t_meta[:path]
323
+ target_dir = t_meta[:scope].to_sym == :global ? global_tools_dir : project_tools_dir
324
+ ensure_contained_path!(file_path, target_dir)
325
+
304
326
  code = File.read(t_meta[:path])
305
327
  tool_inst = eval_tool(t_meta[:name], code, t_meta[:scope])
306
328
  if tool_inst
@@ -357,6 +379,41 @@ module Rubik
357
379
 
358
380
  private
359
381
 
382
+ def ensure_valid_overlay_dir!(dir)
383
+ allowed = [
384
+ [project_commands_dir, @root_dir],
385
+ [global_commands_dir, @home_dir],
386
+ [project_tools_dir, @root_dir],
387
+ [global_tools_dir, @home_dir]
388
+ ]
389
+ valid = allowed.any? do |allowed_dir, trusted_root|
390
+ Platform.path_equal?(dir, allowed_dir) && Platform.resolved_path_under?(dir, trusted_root)
391
+ end
392
+ return if valid
393
+
394
+ raise ArgumentError, "Invalid overlay directory: #{dir}"
395
+ end
396
+
397
+ def ensure_contained_path!(path, parent_dir)
398
+ raise ArgumentError, "Path traversal attempt detected: #{path}" unless safe_path_under?(path, parent_dir)
399
+ end
400
+
401
+ def safe_path_under?(path, parent_dir)
402
+ return false if path.nil? || parent_dir.nil?
403
+
404
+ ensure_valid_overlay_dir!(parent_dir)
405
+
406
+ # Lexical containment check first
407
+ return false unless Platform.path_under?(path, parent_dir)
408
+
409
+ # If path exists or parent exists, also check resolved path containment
410
+ return Platform.resolved_path_under?(path, parent_dir) if File.exist?(path) || File.exist?(parent_dir)
411
+
412
+ true
413
+ rescue StandardError
414
+ false
415
+ end
416
+
360
417
  # Bootstrap kabul taramasında kullanılacak guard: kwarg'la geçilen örnek
361
418
  # (üretimde service catalog'un tek operatör-policy guard'ı) ya da lazy
362
419
  # default. Lazy çözüm bilinçli: overlay_storage.rb standalone require
@@ -14,7 +14,7 @@ module Rubik
14
14
  DEFAULT_IMAGE = "rubik-sandbox:latest"
15
15
 
16
16
  def available?
17
- !Rubik::Platform.which("docker").nil? && system("docker ps > #{Rubik::Platform.dev_null} 2>&1")
17
+ !Rubik::Platform.which("docker").nil? && system("docker", "ps", out: File::NULL, err: File::NULL)
18
18
  end
19
19
 
20
20
  def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
@@ -48,7 +48,7 @@ module Rubik
48
48
  error_message: "Docker sandbox error"
49
49
  )
50
50
  ensure
51
- system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
51
+ system("docker", "rm", "-f", container_name, out: File::NULL, err: File::NULL)
52
52
  end
53
53
 
54
54
  {
@@ -18,9 +18,8 @@ module Rubik
18
18
  def replay_file(session_file_path)
19
19
  return false unless File.exist?(session_file_path)
20
20
 
21
- lines = File.readlines(session_file_path)
22
21
  @shell&.instance_variable_set(:@replaying, true)
23
- lines.each do |line|
22
+ File.foreach(session_file_path) do |line|
24
23
  next if line.strip.empty?
25
24
 
26
25
  event = begin
@@ -499,10 +499,11 @@ module Rubik
499
499
  # (elle düzenleme: "42", ["x"]) tüm tipi zehirlemesin.
500
500
  skipped = 0
501
501
  records = File.foreach(path, encoding: "UTF-8").filter_map do |line|
502
- next if line.strip.empty?
502
+ line.strip!
503
+ next if line.empty?
503
504
 
504
505
  parsed = begin
505
- JSON.parse(line.scrub)
506
+ JSON.parse(line.valid_encoding? ? line : line.scrub)
506
507
  rescue JSON::ParserError
507
508
  nil
508
509
  end
@@ -238,6 +238,10 @@ module Rubik
238
238
  # talebi olarak sunar. Tümü geçerse sayfanın eski gap notu
239
239
  # supersede edilir (öz-onarım kapanışı). Düzlem 1 sayfa metnini
240
240
  # ASLA düzenlemez — düzeltme daima bir sonraki derlemeye aittir.
241
+ def active_recall_hits(store, **)
242
+ store.search_ranked(**).reject { |record| record["status"] == Vocabulary::STATUS_SUPERSEDED }
243
+ end
244
+
241
245
  def run_recall_smoke(gate, proposal, page_id)
242
246
  checks = proposal.fetch("checks", [])
243
247
  return if checks.empty?
@@ -247,8 +251,8 @@ module Rubik
247
251
  # koşulmalı — ilk RECALL_LIMIT kayıt. 6. sıradaki sayfa maestro'ya
248
252
  # görünmüyorsa iska sayılmalı (öz-onarım döngüsü kör kalmasın).
249
253
  missed = checks.reject do |check|
250
- gate.store.search_ranked(query: check, type: Harness::RECALL_TYPES)
251
- .first(Harness::RECALL_LIMIT).any? { |r| r["id"] == page_id }
254
+ active_recall_hits(gate.store, query: check, type: Harness::RECALL_TYPES)
255
+ .first(Harness::RECALL_LIMIT).any? { |r| r["id"] == page_id }
252
256
  end
253
257
  if missed.empty?
254
258
  resolved = gate.store.get(type: "note", id: gap_ref)
@@ -21,10 +21,15 @@ module Rubik
21
21
  # supervisor, event_bus ve sözlü girdiler dışarıdan verilir —
22
22
  # Harness'e ASLA geri dokunmaz.
23
23
  class Gate
24
+ DEFAULT_MAX_DIGEST_JOBS = 100
24
25
  attr_reader :store, :supervisor, :dmesg, :session_slug, :max_steps, :model
25
26
 
26
27
  def initialize(store:, max_steps:, session_slug:, supervisor: nil, dmesg: nil,
27
- event_bus: nil, enabled: true, model: nil)
28
+ event_bus: nil, enabled: true, model: nil, max_digest_jobs: DEFAULT_MAX_DIGEST_JOBS)
29
+ raise ArgumentError, "max_digest_jobs must be a positive integer" unless max_digest_jobs.is_a?(Integer) && max_digest_jobs.positive?
30
+
31
+ @max_digest_jobs = max_digest_jobs
32
+ @digest_targets = {} # accepted targets retain their slot through harvest/retry
28
33
  @store = store
29
34
  @supervisor = supervisor
30
35
  @dmesg = dmesg
@@ -37,7 +42,7 @@ module Rubik
37
42
  @distillers = {}
38
43
  @lock = Mutex.new
39
44
  # v2 §4-V5: kirli işareti — son başarılı derlemeden bu yana KALICI
40
- # içerik (memory+wiki+note satır ayak izi) değişmediyse toplu
45
+ # içerik (memory+wiki+note içerik ayak izi) değişmediyse toplu
41
46
  # damıtıcı doğmaz. Turn satırları ayak izini değiştirmez: saf
42
47
  # turn akışında derleme boşa yakılmaz.
43
48
  @compile_footprint = nil
@@ -54,19 +59,22 @@ module Rubik
54
59
  target = Vocabulary.wiki_ref(query)
55
60
  # v2 §4-V5: aynı hedefli iş zaten kuyrukta ya da uçuştaysa makbuz
56
61
  # 'already queued' varyantına düşer — çift doğum yok.
57
- in_flight = @lock.synchronize do
58
- busy = @pending_digests.any? { |j| j[:target] == target } ||
59
- @distillers.any? { |_id, j| j[:target] == target }
60
- unless busy
61
- @pending_digests << { query: query, target: target,
62
- transcript_uris: source_transcript_uris }
63
- end
64
- busy
62
+ admission = @lock.synchronize do
63
+ next :duplicate if @digest_targets.key?(target)
64
+ next :full if @digest_targets.size >= @max_digest_jobs
65
+
66
+ @pending_digests << { query: query, target: target, transcript_uris: source_transcript_uris }
67
+ @digest_targets[target] = true
68
+ :accepted
65
69
  end
66
- if in_flight
70
+ if admission == :duplicate
67
71
  return "Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
68
72
  "compiling it — recall it later with vakanuvis(action: 'recall', query: '...')."
69
73
  end
74
+ if admission == :full
75
+ return "Digest not accepted for '#{target}': capacity #{@max_digest_jobs} is full. " \
76
+ "Existing jobs are preserved; retry after a digest finishes."
77
+ end
70
78
 
71
79
  spawned = spawn_job!(batch: false)
72
80
  DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?)
@@ -122,6 +130,7 @@ module Rubik
122
130
  # gerçek değeri yaz (future harvest sembolleri sessiz kalmasın).
123
131
  Vakanuvis.log_via(@dmesg, :warn, "distiller ended with unexpected outcome=#{outcome.inspect} — dropped, store untouched")
124
132
  end
133
+ @lock.synchronize { @digest_targets.delete(job[:target]) } unless [:incomplete, :crashed].include?(outcome)
125
134
  outcome
126
135
  end
127
136
 
@@ -213,7 +222,7 @@ module Rubik
213
222
  end
214
223
 
215
224
  def restore_job(job)
216
- @pending_digests.unshift(job) if job[:target]
225
+ @pending_digests.push(job) if job[:target]
217
226
  end
218
227
 
219
228
  def confirm(inst_id, job)
@@ -224,11 +233,23 @@ module Rubik
224
233
  @lock.synchronize { @compile_footprint.nil? || @compile_footprint != footprint }
225
234
  end
226
235
 
227
- # Kalıcı içerik ayak izi: kart + sayfa + not (annal dahil) satır
228
- # sayıları. Turn satırları mevcut annal notuna satır ekler, ayak
229
- # izini değiştirmez — saf turn akışında derleme boşa yakılmaz.
236
+ # Kalıcı içerik ayak izi: sabit id + içerik hash'i + durum.
237
+ # Annal'ın yalnız turn satırları ayıklanır; diğer not değişiklikleri
238
+ # ve mevcut kart/sayfa güncellemeleri derleyiciyi yeniden kirletir.
230
239
  def footprint
231
- ["memory", "wiki", "note"].sum { |t| @store.list(type: t).size }
240
+ ["memory", "wiki", "note"].flat_map do |type|
241
+ @store.list(type: type).sort_by(&:id).map do |summary|
242
+ record = @store.get(type: type, id: summary.id)
243
+ next [type, summary.id, nil] unless record
244
+
245
+ hash = record["hash"]
246
+ if type == "note" && (Vocabulary::ANNAL_TAGS - Array(record["tags"])).empty?
247
+ body = record["body"].to_s.lines.grep_v(/\A- \[\d+\] turn .*\(event=turn\.\d+\.[^)]*\)\s*\z/).join.rstrip
248
+ hash = Digest::SHA256.hexdigest([record["title"], body, record["tags"], record["links"]].to_json)
249
+ end
250
+ [type, summary.id, hash, record["status"]]
251
+ end
252
+ end
232
253
  end
233
254
 
234
255
  # Kaynak disiplini (v2 §4-V4): yalnız status=completed transcript
@@ -63,7 +63,9 @@ module Rubik
63
63
  # (kurucu enjeksiyonu — gate Harness'e geri dokunmaz).
64
64
  @gate = DistillGate::Gate.new(store: @store, supervisor: @supervisor, dmesg: @dmesg,
65
65
  event_bus: @event_bus, enabled: @distiller_enabled,
66
- max_steps: @max_steps, model: @model, session_slug: @session_slug)
66
+ max_steps: @max_steps, model: @model, session_slug: @session_slug,
67
+ max_digest_jobs: fetch_cfg("subagents.vakanuvis.max_digest_jobs",
68
+ DistillGate::Gate::DEFAULT_MAX_DIGEST_JOBS))
67
69
  end
68
70
 
69
71
  class << self
@@ -90,9 +92,13 @@ module Rubik
90
92
  # 2 saniyede durmazsa (yavaş bir flush/spawn içinde) kill gelir ve
91
93
  # işlenemeyen olay sayısı dmesg'e düşer — sessiz kayıp yok.
92
94
  def shutdown
93
- @monitor.synchronize { @stopped = true }
94
- @monitor.synchronize { @busy += 1 } # STOP işaretinin de işleniş sayılır
95
- @queue.push(STOP)
95
+ @monitor.synchronize do
96
+ return if @stopped
97
+
98
+ @stopped = true
99
+ @busy += 1 # STOP işaretinin de işleniş sayılır
100
+ @queue.push(STOP)
101
+ end
96
102
  @worker&.join(2)
97
103
  if @worker&.alive?
98
104
  Vakanuvis.log_via(@dmesg, :warn, "worker did not stop in 2s — killing with #{queue_size} event(s) unprocessed")
@@ -114,17 +120,19 @@ module Rubik
114
120
  # ---- olay giriş kapıları (kworker thread'inde çağrılır) -----------
115
121
 
116
122
  def enqueue_event(kind, payload)
117
- return false if stopped?
118
-
119
123
  unless payload.is_a?(Hash)
120
124
  Vakanuvis.log_via(@dmesg, :warn, "discarded non-hash #{kind} event (#{payload.class})")
121
125
  return false
122
126
  end
123
127
 
124
- drop_oldest! if @queue.size >= @max_queue
125
- @monitor.synchronize { @busy += 1 }
126
- @queue.push([kind, payload])
127
- true
128
+ @monitor.synchronize do
129
+ return false if @stopped
130
+
131
+ drop_oldest! if @queue.size >= @max_queue
132
+ @busy += 1
133
+ @queue.push([kind, payload])
134
+ true
135
+ end
128
136
  end
129
137
 
130
138
  def queue_size = @queue.size
@@ -142,7 +150,7 @@ module Rubik
142
150
 
143
151
  allowed = Array(types).map(&:to_s)
144
152
  allowed = RECALL_TYPES if allowed.empty?
145
- hits = @store.search_ranked(query: q, type: allowed).first(RECALL_LIMIT)
153
+ hits = DistillGate.active_recall_hits(@store, query: q, type: allowed).first(RECALL_LIMIT)
146
154
  unless hits.empty?
147
155
  lines = hits.map { |r| recall_line(r) }
148
156
  header = "Recalled #{hits.size} entit#{hits.size == 1 ? 'y' : 'ies'} for '#{AnnalWriter.one_line(q, 80)}':"
@@ -186,8 +194,8 @@ module Rubik
186
194
  "recall shows compiled pages; a submitted digest appears here until it compiles."
187
195
  end
188
196
 
189
- "Digest queue: #{targets[:queued].size} queued (#{targets[:queued].join(', ')}), " \
190
- "#{targets[:in_flight].size} in flight (#{targets[:in_flight].join(', ')})."
197
+ "Digest queue: #{targets[:queued].size} queued (#{digest_target_summary(targets[:queued])}), " \
198
+ "#{targets[:in_flight].size} in flight (#{digest_target_summary(targets[:in_flight])})."
191
199
  end
192
200
 
193
201
  # Soğuk-başlangıç yönelimi (issue #99 P2): ilk sorgu sözcük dağı
@@ -214,6 +222,12 @@ module Rubik
214
222
 
215
223
  private
216
224
 
225
+ def digest_target_summary(targets)
226
+ shown = targets.first(10)
227
+ shown << "+#{targets.size - 10} targets" if targets.size > 10
228
+ shown.join(", ")
229
+ end
230
+
217
231
  # v2 §4-V2 + issue #99 P2: AND boşluğu tek tip körlük değil — en
218
232
  # yakın kısmi eşleşmeler dürüstçe "(fallback)" damgasıyla sunulur
219
233
  # (gerçek eşleşme değildir), transcript izleri kapsam ipucu olarak
@@ -226,9 +240,8 @@ module Rubik
226
240
  # Süpersede edilmiş sayılar "en yakın kısmi eşleşme" olarak
227
241
  # sunulmaz (review M1) — emekli bir sayfayı okumaya davet etmek,
228
242
  # issue #100'ün "iki kopya, biri bozuk" bulgusunun aynısıdır.
229
- relaxed = @store.search_ranked(query: q, type: allowed, require_all: false)
230
- .reject { |r| r["status"] == Vocabulary::STATUS_SUPERSEDED }
231
- .first(RECALL_FALLBACK_LIMIT)
243
+ relaxed = DistillGate.active_recall_hits(@store, query: q, type: allowed, require_all: false)
244
+ .first(RECALL_FALLBACK_LIMIT)
232
245
  transcripts = @store.search(query: q, type: "transcript")
233
246
  if relaxed.empty? && transcripts.empty?
234
247
  return "No chronicled knowledge matches '#{AnnalWriter.one_line(q, 80)}'. Recall is keyword-AND: retry with " \
@@ -319,13 +332,15 @@ module Rubik
319
332
  end
320
333
 
321
334
  def handle_subagent(payload)
322
- # Önce kronik (Düzlem 1 garantisi), sonra hasat (Düzlem 2): hasat
323
- # patlarsa bile olayın annal satırı yazılmış olur — deterministik
324
- # katman LLM katmanının hatasından zarar görmez.
335
+ # Önce kronik, sonra hasat; iki düzlemin hataları birbirini
336
+ # engellemez. Annal yazılamasa da digest tamamlanır/geri kuyruğa alınır.
325
337
  seq = next_seq
326
338
  line = AnnalWriter.subagent_line(seq, payload)
327
- append_session([[event_key(payload), line]])
328
- @gate.completion_event(payload)
339
+ begin
340
+ append_session([[event_key(payload), line]])
341
+ ensure
342
+ @gate.completion_event(payload)
343
+ end
329
344
  maintain if flush_due?
330
345
  end
331
346
 
@@ -136,10 +136,12 @@ module Rubik
136
136
  # `storage put type=memory` ile açılmış) satırları disipline
137
137
  # sokar — başlıktan slug türet, en yeni satıra mem.<slug> ref'i
138
138
  # bağla (bir sonraki record() artık O satırı günceller), aynı
139
- # slug'a düşen eski kopyaları supersede et. Deterministik: sıra
139
+ # slug'a düşen eski kopyaları supersede et. Mevcut canonical ref
140
+ # her zaman korunur; kaçak kopyalar onun lehine emekli edilir. Deterministik: sıra
140
141
  # ve seçim yalnızca [row] üzerinden.
141
142
  def sweep_rogue_memory(store)
142
- bound_ids = store.refs.select { |ref, _id| Vocabulary.memory_ref?(ref) }.values
143
+ bindings = store.refs.select { |ref, _id| Vocabulary.memory_ref?(ref) }
144
+ bound_ids = bindings.values
143
145
  rows = store.list(type: "memory").reject do |s|
144
146
  bound_ids.include?(s.id) || s.status == Vocabulary::STATUS_SUPERSEDED
145
147
  end
@@ -147,11 +149,15 @@ module Rubik
147
149
 
148
150
  swept = 0
149
151
  rows.group_by { |s| Vocabulary.slug(s.title) }.each do |slug, group|
150
- winner = group.max_by(&:row)
151
- store.set_ref(Vocabulary.memory_ref(slug), winner.id)
152
- group.reject { |s| s.id == winner.id }.each do |s|
152
+ ref = Vocabulary.memory_ref(slug)
153
+ winner_id = bindings[ref]
154
+ unless winner_id
155
+ winner_id = group.max_by(&:row).id
156
+ store.set_ref(ref, winner_id)
157
+ end
158
+ group.reject { |s| s.id == winner_id }.each do |s|
153
159
  rogue = store.get(type: "memory", id: s.id)
154
- Vakanuvis.supersede_record(store, rogue, by: winner.id) if rogue
160
+ Vakanuvis.supersede_record(store, rogue, by: winner_id) if rogue
155
161
  end
156
162
  swept += group.size
157
163
  end
@@ -112,8 +112,12 @@ module Rubik
112
112
  description: "Language or service identifier (e.g., 'ruby', 'python', 'rust', 'go', 'zig', 'c', 'bash')"
113
113
  },
114
114
  proposed_command: {
115
- type: "string",
116
- description: "Replacement or custom command string (optional, probed automatically if auto_probe: true)"
115
+ oneOf: [{ type: "string" },
116
+ { type: "object", properties: { executable: { type: "string" },
117
+ args: { type: "array", items: { type: "string" } } },
118
+ required: ["executable", "args"], additionalProperties: false },
119
+ { type: "array", items: { type: "string" }, minItems: 1 }],
120
+ description: "Replacement command: a legacy string or, for LSP, {executable, args} / argv array. Omit to probe automatically."
117
121
  },
118
122
  probe_args: {
119
123
  type: "array", items: { type: "string" },
@@ -142,7 +146,14 @@ module Rubik
142
146
  def execute(args = {})
143
147
  target = (args["target"] || args[:target]).to_s.downcase.strip
144
148
  lang = (args["language_or_type"] || args[:language_or_type]).to_s.downcase.strip
145
- proposed_cmd = (args["proposed_command"] || args[:proposed_command]).to_s.strip
149
+ raw_command = args["proposed_command"] || args[:proposed_command]
150
+ if raw_command.is_a?(Hash) || raw_command.is_a?(Array)
151
+ return ToolOutput.error("Structured command argv is supported only for LSP repairs.") unless target == "lsp"
152
+
153
+ proposed_cmd = raw_command
154
+ else
155
+ proposed_cmd = raw_command.to_s.strip
156
+ end
146
157
  auto_probe = if args["auto_probe"].nil?
147
158
  args[:auto_probe].nil? || args[:auto_probe]
148
159
  else
@@ -152,7 +163,7 @@ module Rubik
152
163
  return ToolOutput.error("Target and language_or_type must be provided.") if target.empty? || lang.empty?
153
164
 
154
165
  # 1. Automatic Probing if proposed_cmd is not specified or auto_probe is requested
155
- if proposed_cmd.empty? && auto_probe
166
+ if !command_provided?(proposed_cmd) && auto_probe
156
167
  candidate = case target
157
168
  when "lsp"
158
169
  Rubik::Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir)
@@ -179,7 +190,7 @@ module Rubik
179
190
  end
180
191
  end
181
192
 
182
- return ToolOutput.error("No command provided or discovered for #{target} (#{lang}).") if proposed_cmd.empty?
193
+ return ToolOutput.error("No command provided or discovered for #{target} (#{lang}).") unless command_provided?(proposed_cmd)
183
194
 
184
195
  # 2. Pre-flight Verification in Isolated Sub-Context (ctx.extend())
185
196
  root_ctx = @runtime_manager&.context || Rubik::Runtime::Context.new
@@ -313,11 +324,11 @@ module Rubik
313
324
  end
314
325
 
315
326
  if @config.nil? && ["lsp", "test_runner", "linter"].include?(target)
316
- return "⚠️ Promoted '#{proposed_cmd}' for #{target} (#{lang}) — no config is attached, " \
327
+ return "⚠️ Promoted '#{display_command(proposed_cmd)}' for #{target} (#{lang}) — no config is attached, " \
317
328
  "so the command is active for this session only and was not saved."
318
329
  end
319
330
 
320
- "✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{proposed_cmd}'. " \
331
+ "✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{display_command(proposed_cmd)}'. " \
321
332
  "Service is now active in the live context."
322
333
  end
323
334
 
@@ -326,6 +337,22 @@ module Rubik
326
337
  def verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
327
338
  Rubik::Runtime::RepairProbe.new(root_dir: @root_dir).verify(target, lang, proposed_cmd, sub_ctx, probe_args: probe_args)
328
339
  end
340
+
341
+ def command_provided?(command)
342
+ if command.is_a?(String)
343
+ !command.strip.empty?
344
+ elsif command.is_a?(Hash) || command.is_a?(Array)
345
+ !Rubik::LSP::ServerCommand.argv(command).empty?
346
+ else
347
+ false
348
+ end
349
+ rescue ArgumentError
350
+ false
351
+ end
352
+
353
+ def display_command(command)
354
+ command.is_a?(Hash) || command.is_a?(Array) ? Rubik::LSP::ServerCommand.display(command) : command.to_s
355
+ end
329
356
  end
330
357
 
331
358
  class FinishTool < BaseTool
@@ -39,16 +39,32 @@ module Rubik
39
39
  @lsp_manager.sync_buffer_file_if_needed(path, bm) if bm && @lsp_manager.respond_to?(:sync_buffer_file_if_needed)
40
40
 
41
41
  if path.empty?
42
- all = @lsp_manager.all_diagnostics
43
- total_issues = all.values.flatten.size
42
+ all = @lsp_manager.all_diagnostic_snapshots
43
+ total_issues = all.values.sum { |snapshot| snapshot[:diagnostics].size }
44
+ pending = all.values.count { |snapshot| snapshot[:state] == :pending }
45
+ unversioned = all.values.count { |snapshot| snapshot[:state] == :unversioned }
44
46
  if total_issues.zero?
45
- "No active LSP diagnostics across workspace (all clean)."
47
+ if pending.positive?
48
+ "LSP analysis pending for #{pending} workspace file(s); no clean result is available yet."
49
+ elsif unversioned.positive?
50
+ "No diagnostics were reported for #{unversioned} workspace file(s), but the server omitted document versions; freshness is unverified."
51
+ elsif all.empty?
52
+ "No active LSP diagnostics have been published for open workspace documents."
53
+ else
54
+ "No LSP diagnostics were reported for #{all.size} analyzed workspace file(s)."
55
+ end
46
56
  else
47
57
  lines = ["=== Active Workspace LSP Diagnostics (#{total_issues} issue(s)) ==="]
48
- all.each do |fpath, diags|
58
+ all.each do |fpath, snapshot|
59
+ diags = snapshot[:diagnostics]
49
60
  next if diags.empty?
50
61
 
51
- lines << "\nFile: #{fpath} (#{diags.size}):"
62
+ freshness = if snapshot[:state] == :pending
63
+ "PENDING; previous results may be stale"
64
+ else
65
+ (snapshot[:state] == :unversioned ? "VERSION UNVERIFIED" : "current")
66
+ end
67
+ lines << "\nFile: #{fpath} (#{diags.size}; #{freshness}):"
52
68
  diags.each do |d|
53
69
  sev = d["severity"] == 1 ? "ERROR" : "WARN"
54
70
  start_line = (d.dig("range", "start", "line") || 0) + 1
@@ -58,11 +74,23 @@ module Rubik
58
74
  lines.join("\n")
59
75
  end
60
76
  else
61
- diags = @lsp_manager.diagnostics_for(path)
77
+ snapshot = @lsp_manager.diagnostics_snapshot_for(path)
78
+ diags = snapshot[:diagnostics]
62
79
  if diags.empty?
63
- append_buffer_tip("No LSP diagnostics or errors for '#{path}'.", path)
80
+ message = case snapshot[:state]
81
+ when :pending then "LSP analysis is pending for '#{path}'; no clean result is available yet."
82
+ when :unversioned then "No LSP diagnostics were reported for '#{path}', but the server omitted document versions; freshness is unverified."
83
+ when :fresh then "No LSP diagnostics or errors for '#{path}'."
84
+ else "No LSP diagnostic result is available for '#{path}'."
85
+ end
86
+ append_buffer_tip(message, path)
64
87
  else
65
- lines = ["=== LSP Diagnostics for #{path} (#{diags.size} issue(s)) ==="]
88
+ freshness = if snapshot[:state] == :pending
89
+ "pending; previous results may be stale"
90
+ else
91
+ (snapshot[:state] == :unversioned ? "version unverified" : "current")
92
+ end
93
+ lines = ["=== LSP Diagnostics for #{path} (#{diags.size} issue(s); #{freshness}) ==="]
66
94
  diags.each do |d|
67
95
  sev = case d["severity"]
68
96
  when 1 then "ERROR"