rubik 0.3.4 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +54 -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 +78 -66
  9. data/lib/rubik/config/defaults.rb +1 -0
  10. data/lib/rubik/context/git_lister.rb +3 -1
  11. data/lib/rubik/llm/anthropic.rb +5 -2
  12. data/lib/rubik/llm/openai_compatible.rb +83 -68
  13. data/lib/rubik/lsp/client.rb +320 -96
  14. data/lib/rubik/lsp/manager.rb +83 -12
  15. data/lib/rubik/lsp/server_command.rb +102 -0
  16. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  17. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  18. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  19. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  20. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  21. data/lib/rubik/repl/commands/buffer.rb +122 -88
  22. data/lib/rubik/repl/commands/mcp.rb +120 -92
  23. data/lib/rubik/repl/commands/session.rb +90 -59
  24. data/lib/rubik/repl/commands/workspace.rb +110 -94
  25. data/lib/rubik/repl/shell.rb +10 -1
  26. data/lib/rubik/runtime/discovery.rb +12 -6
  27. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  28. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  29. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  30. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  31. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  32. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  33. data/lib/rubik/version.rb +1 -1
  34. metadata +2 -1
@@ -92,7 +92,16 @@ module Rubik
92
92
  files.each do |rel_file|
93
93
  @context_index.update_file(rel_file)
94
94
  full_path = File.join(root_d, rel_file)
95
- File.exist?(full_path) ? @ast_graph.index_file(full_path) : @ast_graph.remove_file(full_path)
95
+ if File.exist?(full_path)
96
+ @ast_graph.index_file(full_path)
97
+ if @lsp_manager&.enabled?
98
+ content = Rubik::Platform.read_text(full_path)
99
+ @lsp_manager.sync_buffer_file(rel_file, content)
100
+ @lsp_manager.did_save_file(rel_file, content)
101
+ end
102
+ else
103
+ @ast_graph.remove_file(full_path)
104
+ end
96
105
  end
97
106
  @ast_graph.save_to_cache(@cache_store) if @cache_store
98
107
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "shellwords"
4
4
  require_relative "../platform"
5
+ require_relative "../lsp/server_command"
5
6
 
6
7
  module Rubik
7
8
  module Runtime
@@ -179,22 +180,27 @@ module Rubik
179
180
  end
180
181
 
181
182
  def binary_available?(cmd, root_dir: nil)
182
- return false if cmd.nil? || cmd.to_s.strip.empty?
183
+ argv = Rubik::LSP::ServerCommand.argv(cmd)
184
+ return false if argv.empty?
183
185
 
184
- bin = cmd.to_s.strip.split.first
185
- if root_dir && bin && (bin.include?("/") || (Rubik::Platform.windows? && bin.include?("\\")))
186
- full_path = File.expand_path(bin, root_dir)
186
+ bin = argv.first
187
+ if bin.include?("/") || (Rubik::Platform.windows? && bin.include?("\\"))
188
+ full_path = File.expand_path(bin, root_dir || Dir.pwd)
187
189
  return true if Rubik::Platform.executable_file?(full_path)
190
+
191
+ return false
188
192
  end
189
193
 
190
- !Rubik::Platform.which(cmd).nil?
194
+ !Rubik::Platform.which(bin).nil?
195
+ rescue ArgumentError
196
+ false
191
197
  end
192
198
 
193
199
  def probe_lsp(lang, dir = nil, root_dir: nil, custom_cmd: nil)
194
200
  effective_root = root_dir || dir || Dir.pwd
195
201
  lang_key = lang.to_s.downcase
196
202
  candidates = []
197
- candidates << custom_cmd.to_s.strip if custom_cmd && !custom_cmd.to_s.strip.empty?
203
+ candidates << custom_cmd if custom_cmd && !Rubik::LSP::ServerCommand.argv(custom_cmd).empty?
198
204
  candidates.concat(lsp_candidates(lang_key))
199
205
  candidates.uniq!
200
206
 
@@ -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"
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.3.4"
4
+ VERSION = "0.3.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır
@@ -322,6 +322,7 @@ files:
322
322
  - lib/rubik/lsp.rb
323
323
  - lib/rubik/lsp/client.rb
324
324
  - lib/rubik/lsp/manager.rb
325
+ - lib/rubik/lsp/server_command.rb
325
326
  - lib/rubik/mcp.rb
326
327
  - lib/rubik/mcp/boot_lifecycle.rb
327
328
  - lib/rubik/mcp/client.rb