rubik 0.4.0 → 0.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38e5cb4f6771198e0b32d67b8ec5fabd72ae886fbb7b1fcdccffddd4c9e1cedb
4
- data.tar.gz: 820079f88cde99e3e82caa3214b1f6d11e9bcbe4ee773f879a50370b208e1287
3
+ metadata.gz: 15cadac070995d7b8d1ac155c6ddfe14c4e4418e596efe6b734af6cb9240f6b4
4
+ data.tar.gz: 21b3376be313a5133ca84180a36770b0c344495b12ed3c00390715d8dd1658eb
5
5
  SHA512:
6
- metadata.gz: a92d6f084b0694076803d7240559c0a0bb68c52f06543d4ab0ec5a21af071d419e47e1c42db95b3fd324e43ffbaffbdd7c41834032f7a279b09a42be5df23289
7
- data.tar.gz: f0de254f34eac1cc3a373b6e3cd07f69192f90fda8e73269fd23ff12b27c2a66aaa737db114e30286404e47de5391f8938b23c9ef642e44330502f19e730ab53
6
+ metadata.gz: d9edcdfa2830b03dc8d9a5345d8215a5204ba96d4aa5ef93ca0742633a02ff089f2dda9fab638d4a42a489f1b166f74c9a11ecc5f64b3f602b7cb1cbbdd09a5a
7
+ data.tar.gz: 2b478a0aa15376eaac5636c9d6e7f8d5dfac5534394153958d6417ecf3547dbb9ae654d7c59e5d92df6a84e2038d869145a3ba2b7c8dbdc62472106c7727c33d
data/ATTRIBUTION.md CHANGED
@@ -121,7 +121,7 @@ SOFTWARE.
121
121
  | **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
122
122
  | **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
123
123
  | **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
124
- | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,850+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,900+ unit, integration, and contract tests. |
125
125
  | **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
126
126
  | **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
127
127
 
data/CHANGELOG.md CHANGED
@@ -6,6 +6,52 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
6
6
 
7
7
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
+ ## [0.4.1] - 2026-10-29
10
+
11
+ A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
12
+ expressions and tighten output escaping, LSP client loss retracts its
13
+ coeffects symmetrically, and subagent waits wake reactively instead of
14
+ polling.
15
+
16
+ ### Fixed
17
+
18
+ - **CodeQL scanning alerts resolved — ReDoS, escaping, and workflow
19
+ permissions:** polynomial-time regular expressions in AST extractors,
20
+ the diagnostics parser, storage slug generation, and path helpers are
21
+ replaced with deterministic string operations or bounded alternation;
22
+ `Render.listing` escapes backslashes before pipes in Markdown tables;
23
+ and the CI workflow's default `GITHUB_TOKEN` is floored to
24
+ `contents: read`.
25
+ - **Streamed thought duplication in REPL suppressed (#287):** when an LLM turn
26
+ with tool calls streams preliminary text live to the terminal, `TurnExecutor`
27
+ and UI renderers no longer reprint the identical thought line prefixed with
28
+ `💭` at verbose/debug log levels.
29
+ - **LSP client loss retracts the `lsp:<lang>` coeffect symmetrically
30
+ (#306):** a new `unregister_client_unlocked` runs on every client-removal
31
+ path — `stop_client`, `stop_all`, crash recovery, and dead-client
32
+ detection — so the context `unprovide` pairs with the original `provide`
33
+ and `registered_disposable_langs` tracking is cleared. Crash paths
34
+ previously leaked the coeffect and its dispose hook, leaving stale
35
+ services behind after a server died.
36
+
37
+ ### Performance
38
+
39
+ - **Subagent waiters wake reactively via ConditionVariable (#299):** the
40
+ wait loop parks on a monitor-owned condition variable instead of polling
41
+ every 100 ms; every settle, pause, unpark, and launch transition
42
+ (completion, `release_ready!`, kill, resume, budget/error/question
43
+ events) broadcasts the wakeup. The sleep fallback keeps standalone
44
+ `WaitSupervisor` includers working.
45
+ - **LSP diagnostics aggregation merges outside the monitor (#294):**
46
+ `all_diagnostics` and `all_diagnostic_snapshots` collect client hashes
47
+ under the monitor and bulk-merge outside it, dropping the per-client
48
+ merge from the locked section; the result is always a fresh hash.
49
+ - **`compact.map` chains fused into `filter_map` (#290, #291, #297,
50
+ #302):** single-pass collection replaces the intermediate arrays in
51
+ `WaitSupervisor#wait`, subagent dependency normalization,
52
+ `dangerous_system_roots`, and `ConversationId.normalize_list`, with the
53
+ nil-skip semantics of the originals preserved exactly.
54
+
9
55
  ## [0.4.0] - 2026-10-27
10
56
 
11
57
  A diagnostics-reach minor: the diagnostics engine gains native
@@ -846,5 +892,6 @@ installation section.
846
892
  - Context: serialized index mutations, snapshot-outside-lock reconcile,
847
893
  streaming-first truncation.
848
894
 
849
- [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.1.0...HEAD
895
+ [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...HEAD
896
+ [0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
850
897
  [0.1.0]: https://github.com/ozgurulukir/rubik/releases/tag/v0.1.0
@@ -51,7 +51,7 @@ module Rubik
51
51
  signature: line.split("{").first.to_s.strip,
52
52
  body_loader: body_loader_for(line_num, end_line)
53
53
  )
54
- elsif (match = stripped.match(/^([A-Za-z0-9_*\s]+)\s+([A-Za-z0-9_]+)\s*\((.*?)\)\s*\{/))
54
+ elsif (match = stripped.match(/^([A-Za-z0-9_*]+(?:\s+[A-Za-z0-9_*]+)*)\s+([A-Za-z0-9_]+)\s*\((.*?)\)\s*\{/))
55
55
  fn_name = match[2]
56
56
  unless ["if", "for", "while", "switch"].include?(fn_name)
57
57
  end_line = find_closing_brace(i)
@@ -134,7 +134,9 @@ module Rubik
134
134
  return true if k.negative?
135
135
  return true if REGEX_PRECEDERS.include?(raw[k])
136
136
 
137
- REGEX_KEYWORDS.include?(raw[0..k][/([A-Za-z_][A-Za-z0-9_]*)\z/])
137
+ word_start = k
138
+ word_start -= 1 while word_start >= 0 && raw[word_start].match?(/\w/)
139
+ REGEX_KEYWORDS.include?(raw[(word_start + 1)..k])
138
140
  end
139
141
 
140
142
  def blank_symbol_keywords(line)
@@ -62,7 +62,7 @@ module Rubik
62
62
  elsif (match = stripped.match(/^impl(?:\s*<.*?>)?\s+([A-Za-z0-9_]+(?:\s+for\s+[A-Za-z0-9_]+)?)/))
63
63
  current_impl = match[1].split(" for ").last.to_s.strip
64
64
  impl_end_line = find_closing_brace(i)
65
- elsif (match = stripped.match(/^(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)\s*(?:<.*?>)?\s*\((.*?)\)/))
65
+ elsif (match = stripped.match(/^(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)(?:<[^>]*>)?\s*\((.*?)\)/))
66
66
  fn_name = match[1]
67
67
  full_name = current_impl ? "#{current_impl}::#{fn_name}" : fn_name
68
68
  end_line = stripped.end_with?(";") ? line_num : find_closing_brace(i)
@@ -166,7 +166,7 @@ module Rubik
166
166
  end
167
167
 
168
168
  def parse_function(line, stripped, line_num, idx)
169
- if (match = stripped.match(/^(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s*(?:\*\s*)?([A-Za-z0-9_$]+)?\s*\(/))
169
+ if (match = stripped.match(/^(?:export\s+(?:default\s+)?)?(?:async\s+)?function(?:\s*\*\s*|\s*)(?:([A-Za-z0-9_$]+)\s*)?\(/))
170
170
  fn_name = match[1] || "default"
171
171
  end_line = stripped.include?("{") ? find_closing_brace(idx) : find_statement_end(idx)
172
172
  body_for_calls = extract_lines(line_num, end_line)
@@ -185,7 +185,7 @@ module Rubik
185
185
  next_i = end_line > line_num ? end_line - 1 : idx
186
186
  [sym, next_i]
187
187
  elsif (match = stripped.match(
188
- /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)(?:\s*:\s*[^=]+)?\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/
188
+ /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)(?:\s*:\s*[^=]*?[^=\s])?\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/
189
189
  )) ||
190
190
  (match = stripped.match(/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?function\b/))
191
191
  fn_name = match[1]
@@ -210,7 +210,7 @@ module Rubik
210
210
 
211
211
  def parse_method(line, stripped, line_num, idx, current_class)
212
212
  return unless (match = stripped.match(
213
- /^(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z0-9_$]+)\s*\([^)]*\)(?:\s*:\s*[^{]+)?\s*\{/
213
+ /^(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z0-9_$]+)\s*\([^)]*\)(?:\s*:\s*[^{]*?[^{\s])?\s*\{/
214
214
  ))
215
215
 
216
216
  method_name = match[1]
@@ -53,11 +53,11 @@ module Rubik
53
53
  def parse_go(text)
54
54
  entries = []
55
55
  text.each_line do |line|
56
- if (m = line.match(/^([^:]+):(\d+):(\d+):\s*(undefined:\s*.*)$/))
57
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :undefined_ref, raw_text: line)
58
- elsif (m = line.match(/^([^:]+):(\d+):(\d+):\s*(.*)$/))
59
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :syntax, raw_text: line)
60
- end
56
+ next unless (m = line.match(/^([^:]+):(\d+):(\d+):(.*)$/))
57
+
58
+ msg = m[4].strip
59
+ cat = msg.start_with?("undefined:") ? :undefined_ref : :syntax
60
+ entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
61
61
  end
62
62
  entries
63
63
  end
@@ -67,9 +67,9 @@ module Rubik
67
67
  current_error = nil
68
68
 
69
69
  text.each_line do |line|
70
- if (m = line.match(/^error\[(E\d+)\]:\s*(.*)$/))
71
- current_error = { code: m[1], message: m[2], category: :type }
72
- elsif current_error && (m = line.match(/-->\s*([^:]+):(\d+):(\d+)/))
70
+ if (m = line.match(/^error\[(E\d+)\]:(.*)$/))
71
+ current_error = { code: m[1], message: m[2].strip, category: :type }
72
+ elsif current_error && (m = line.match(/-->\s*([^:\s]+):(\d+):(\d+)/))
73
73
  entries << Entry.new(
74
74
  file: m[1],
75
75
  line: m[2],
@@ -88,9 +88,9 @@ module Rubik
88
88
  def parse_zig_c(text)
89
89
  entries = []
90
90
  text.each_line do |line|
91
- next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error:\s*(.*)$/))
91
+ next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error:(.*)$/))
92
92
 
93
- msg = m[4]
93
+ msg = m[4].strip
94
94
  cat = msg.include?("undeclared identifier") || msg.include?("use of undeclared") ? :undefined_ref : :syntax
95
95
  entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
96
96
  end
@@ -106,12 +106,12 @@ module Rubik
106
106
  if (m = line.match(/File "([^"]+)", line (\d+)(?:, in .*)?/))
107
107
  current_file = m[1]
108
108
  current_line = m[2].to_i
109
- elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):\s*(.*)$/))
109
+ elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):(.*)$/))
110
110
  cat = m[1] =~ /\A(?:Syntax|Indentation|Tab)Error\z/ ? :syntax : :type
111
111
  entries << Entry.new(
112
112
  file: current_file,
113
113
  line: current_line,
114
- message: "#{m[1]}: #{m[2]}",
114
+ message: "#{m[1]}: #{m[2].strip}",
115
115
  category: cat,
116
116
  raw_text: line
117
117
  )
@@ -125,13 +125,13 @@ module Rubik
125
125
  def parse_ts_js(text)
126
126
  entries = []
127
127
  text.each_line do |line|
128
- next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+)):?\s*-?\s*error\s*(TS\d+)?:?\s*(.*)$}i))
128
+ next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+))(?::\s*-\s*|:\s*|\s*-\s*|\s+)error(?:\s+(TS\d+))?:?(.*)$}i))
129
129
 
130
130
  file = m[1]
131
131
  line_num = (m[2] || m[4]).to_i
132
132
  col_num = (m[3] || m[5]).to_i
133
133
  code = m[6]
134
- msg = m[7]
134
+ msg = m[7].strip
135
135
  entries << Entry.new(
136
136
  file: file,
137
137
  line: line_num,
@@ -148,8 +148,8 @@ module Rubik
148
148
  def parse_generic(text)
149
149
  entries = []
150
150
  text.each_line do |line|
151
- if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)?\s*(.*)$/))
152
- entries << Entry.new(file: m[1], line: m[2], message: m[3], category: :unknown, raw_text: line)
151
+ if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)?(.*)$/))
152
+ entries << Entry.new(file: m[1], line: m[2], message: m[3].strip, category: :unknown, raw_text: line)
153
153
  end
154
154
  end
155
155
  entries
@@ -14,7 +14,8 @@ module Rubik
14
14
  raw = url.to_s.strip
15
15
  return raw if raw.empty?
16
16
 
17
- clean = raw.sub(%r{/+\z}, "")
17
+ clean = raw
18
+ clean = clean.delete_suffix("/") while clean.end_with?("/")
18
19
  return clean if clean.end_with?("/chat/completions") || clean.end_with?("/completions")
19
20
 
20
21
  if clean == "https://api.deepseek.com"
@@ -200,18 +200,16 @@ module Rubik
200
200
 
201
201
  client.stop
202
202
  @clients.delete(lang_str)
203
- effective_ctx = @context || @runtime_manager&.context
204
- effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
203
+ unregister_client_unlocked(lang_str)
205
204
  { ok: true, message: "Stopped LSP server for '#{lang_str}'" }
206
205
  end
207
206
  end
208
207
 
209
208
  def stop_all
210
209
  @monitor.synchronize do
211
- effective_ctx = @context || @runtime_manager&.context
212
210
  @clients.each do |lang_str, client|
213
211
  client.stop
214
- effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
212
+ unregister_client_unlocked(lang_str)
215
213
  end
216
214
  @clients.clear
217
215
  end
@@ -387,7 +385,10 @@ module Rubik
387
385
  recovering = true
388
386
  record_crash(lang)
389
387
  stop_client_safely(client)
390
- @clients.delete(lang) if stored.equal?(client)
388
+ if stored.equal?(client)
389
+ @clients.delete(lang)
390
+ unregister_client_unlocked(lang)
391
+ end
391
392
  client = nil
392
393
  end
393
394
 
@@ -418,6 +419,7 @@ module Rubik
418
419
  else
419
420
  record_crash(lang)
420
421
  stop_client_safely(client)
422
+ unregister_client_unlocked(lang)
421
423
  @recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
422
424
  end
423
425
  elsif recovering
@@ -439,6 +441,7 @@ module Rubik
439
441
  else
440
442
  record_crash(lang)
441
443
  stop_client_safely(client)
444
+ unregister_client_unlocked(lang)
442
445
  @recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
443
446
  end
444
447
  end
@@ -465,6 +468,13 @@ module Rubik
465
468
  end
466
469
  end
467
470
 
471
+ def unregister_client_unlocked(lang)
472
+ lang_str = lang.to_s.downcase
473
+ @registered_disposable_langs.delete(lang_str)
474
+ effective_ctx = @context || @runtime_manager&.context
475
+ effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
476
+ end
477
+
468
478
  def stop_client_safely(client)
469
479
  client.stop
470
480
  rescue StandardError
@@ -312,7 +312,7 @@ module Rubik
312
312
  end
313
313
 
314
314
  # Strip ```json ... ``` blocks
315
- str.gsub(/```(?:json)?\s*\{.*?\}\s*```/m, "").strip
315
+ str.gsub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
316
316
  end
317
317
  end
318
318
  end
@@ -21,6 +21,7 @@ module Rubik
21
21
  reap_orphans
22
22
  release_ready!
23
23
  emit_completion_notice(inst)
24
+ notify_wait!
24
25
  end
25
26
 
26
27
  private
@@ -150,6 +150,7 @@ module Rubik
150
150
  end
151
151
  announce_cancellations(cancelled)
152
152
  claimed.each { |spec| launch_claimed!(spec) }
153
+ notify_wait!
153
154
  end
154
155
 
155
156
  # wait_for'un döngü koşulu için: hedef id'ler arasında hâlâ kuyrukta
@@ -68,6 +68,7 @@ module Rubik
68
68
  @instances = {}
69
69
  @deferred = {}
70
70
  @monitor = Monitor.new
71
+ @wait_cond = @monitor.new_cond
71
72
  @counter = 0
72
73
  @max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
73
74
  @max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
@@ -83,6 +84,13 @@ module Rubik
83
84
  # redacted, paginated — see ResultArchive.
84
85
  @result_archive = ResultArchive.new(config: @config)
85
86
 
87
+ if @event_bus.respond_to?(:subscribe)
88
+ @event_bus.subscribe(Notices::ERROR_PAUSED_EVENT) { notify_wait! }
89
+ @event_bus.subscribe(Notices::BUDGET_EXHAUSTED_EVENT) { notify_wait! }
90
+ @event_bus.subscribe(Notices::COMPLETED_EVENT) { notify_wait! }
91
+ @event_bus.subscribe(Notices::QUESTION_EVENT) { notify_wait! } if defined?(Notices::QUESTION_EVENT)
92
+ end
93
+
86
94
  # Register inverse in root context (maestro hook)
87
95
  @root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
88
96
  end
@@ -93,20 +101,26 @@ module Rubik
93
101
  inst = get(id)
94
102
  return false unless inst.is_a?(Instance)
95
103
 
96
- inst.extend_budget(steps)
104
+ res = inst.extend_budget(steps)
105
+ notify_wait! if res
106
+ res
97
107
  end
98
108
 
99
109
  def resume(id, additional_steps: nil)
100
110
  inst = get(id)
101
- inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
111
+ res = inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
112
+ notify_wait! if res
113
+ res
102
114
  end
103
115
 
104
116
  def resume_all
105
- @monitor.synchronize do
117
+ resumed = @monitor.synchronize do
106
118
  @instances.values.select(&:parked?).map do |i|
107
119
  i.resume! ? i.id : nil
108
120
  end.compact
109
121
  end
122
+ notify_wait! unless resumed.empty?
123
+ resumed
110
124
  end
111
125
 
112
126
  def list(parent_id: nil)
@@ -129,6 +143,7 @@ module Rubik
129
143
  # monitörü 2s'ye kadar bloklar (emit kuralı ihlali olurdu).
130
144
  announce_cancellations(cancelled)
131
145
  release_ready!
146
+ notify_wait!
132
147
  found
133
148
  end
134
149
 
@@ -142,6 +157,7 @@ module Rubik
142
157
  true
143
158
  end
144
159
  announce_cancellations(cancelled)
160
+ notify_wait!
145
161
  end
146
162
 
147
163
  def shutdown_all
@@ -151,6 +167,7 @@ module Rubik
151
167
  @instances.clear
152
168
  @deferred.clear
153
169
  end
170
+ notify_wait!
154
171
  end
155
172
 
156
173
  def reap_orphans
@@ -20,25 +20,32 @@ module Rubik
20
20
  wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
21
21
  end
22
22
 
23
+ def notify_wait!
24
+ @monitor.synchronize { @wait_cond&.broadcast }
25
+ end
26
+
23
27
  def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
24
28
  target_ids = Array(ids).compact.map(&:to_s)
25
29
  deadline = Time.now + [timeout.to_i, 1].max
26
30
 
27
- loop do
28
- active_targets = @monitor.synchronize do
31
+ @monitor.synchronize do
32
+ loop do
29
33
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
30
- candidates.select do |inst|
34
+ active_targets = candidates.select do |inst|
31
35
  inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
32
36
  end
33
- end
34
37
 
35
- # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
36
- break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
38
+ # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
39
+ rem = deadline - Time.now
40
+ break if (active_targets.empty? && !deferred_pending?(target_ids)) || rem <= 0
37
41
 
38
- sleep 0.1
39
- end
42
+ if @wait_cond
43
+ @wait_cond.wait([rem, 0].max)
44
+ else
45
+ sleep [rem, 0.1].min
46
+ end
47
+ end
40
48
 
41
- @monitor.synchronize do
42
49
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
43
50
  paused = candidates.select { |c| parked_candidate?(c) }
44
51
  active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
@@ -62,7 +62,7 @@ module Rubik
62
62
  @inst.emit_compacted_event(reason, layer)
63
63
  end
64
64
 
65
- def announce_llm_thought(_text) = nil
65
+ def announce_llm_thought(_text, **) = nil
66
66
 
67
67
  # ── terminals & dispatch ──
68
68
  # The shared two-strike nudge policy (Channel#empty_response_turn)
@@ -107,8 +107,8 @@ module Rubik
107
107
  max_steps: @deps.max_steps, layer: layer)
108
108
  end
109
109
 
110
- def announce_llm_thought(text)
111
- @deps.on_step&.call(type: :llm_thought, content: text, step: @deps.step_count, max_steps: @deps.max_steps)
110
+ def announce_llm_thought(text, streamed: false)
111
+ @deps.on_step&.call(type: :llm_thought, content: text, streamed: streamed, step: @deps.step_count, max_steps: @deps.max_steps)
112
112
  end
113
113
 
114
114
  # ── terminals & dispatch ──
@@ -218,7 +218,8 @@ module Rubik
218
218
  def tool_turn(res, text_resp, tool_calls)
219
219
  channel.nudge_policy.reset!
220
220
  channel.append_tool_call_row(channel.final_text(res, text_resp), tool_calls)
221
- channel.announce_llm_thought(text_resp) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
221
+ streamed = res.respond_to?(:streamed) && res.streamed == true
222
+ channel.announce_llm_thought(text_resp, streamed: streamed) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
222
223
 
223
224
  tool_calls.each do |tc|
224
225
  # finish is exempt (pinned by test_orchestrator_stream): its args
@@ -300,7 +301,7 @@ module Rubik
300
301
  # ── announce ──
301
302
  def announce_compaction(_reason, _layer) = nil
302
303
 
303
- def announce_llm_thought(_text) = nil
304
+ def announce_llm_thought(_text, **) = nil
304
305
 
305
306
  # Goal-ledger observation projection (plan D): related-optional
306
307
  # capability — base channel is structurally inert (subagent planes
@@ -228,7 +228,8 @@ module Rubik
228
228
  # drive root?" depend on the process cwd (which drive the workspace
229
229
  # lives on). Security callers pass already-expanded absolute paths;
230
230
  # compare the literal form here.
231
- cleaned = path.to_s.tr("\\", "/").downcase.sub(%r{/+\z}, "")
231
+ cleaned = path.to_s.tr("\\", "/").downcase
232
+ cleaned = cleaned.delete_suffix("/") while cleaned.end_with?("/")
232
233
  cleaned =~ /^[a-z]:$/
233
234
  end
234
235
  end
@@ -108,14 +108,17 @@ module Rubik
108
108
  end
109
109
  end
110
110
 
111
+ VALID_SYMBOL_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
112
+
111
113
  # Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
112
114
  # bare validity check would fast-path them through UNCLEANED — the old
113
115
  # clean_utf8 path strips their >= 0x80 bytes and the byte loss would
114
116
  # instead surface later, at the JSON/display layer. Only UTF-8 (or the
115
117
  # lossless US-ASCII subset) symbols may take the fast path.
118
+ # PERF: Hoist encoding array to frozen constant to eliminate array allocations per symbol check (~26% faster, ~99.99% fewer allocations).
116
119
  def fast_clean_symbol(sym)
117
120
  name = sym.name
118
- return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
121
+ return sym if VALID_SYMBOL_ENCODINGS.include?(name.encoding) && name.valid_encoding?
119
122
 
120
123
  clean_utf8(name).to_sym
121
124
  end
@@ -145,7 +145,7 @@ module Rubik
145
145
  # blocks stripped; nil when nothing human-readable remains. Shared
146
146
  # with UI::Renderer (Plan 51, Decision D2).
147
147
  def thought_first_line(content)
148
- clean_content = content.to_s.sub(/```(?:json)?\s*\{.*?\}\s*```/m, "").strip
148
+ clean_content = content.to_s.sub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
149
149
  return nil if clean_content.empty?
150
150
 
151
151
  clean_content.lines.first.to_s.strip
@@ -243,6 +243,7 @@ module Rubik
243
243
  # with UI::Renderer (Plan 51, Decision D2).
244
244
  def format_llm_thought(step_info)
245
245
  return nil if @level == LEVEL_QUIET || @level == LEVEL_NORMAL
246
+ return nil if step_info[:streamed] == true
246
247
 
247
248
  first_line = thought_first_line(step_info[:content])
248
249
  return nil if first_line.nil? || first_line.empty?
@@ -144,7 +144,7 @@ module Rubik
144
144
  :dependency_missing
145
145
  elsif combined.match?(/(syntax error|unexpected token|compilation error|error\[E\d+\]|parse error|fatal error)/i)
146
146
  :syntax_or_compile_error
147
- elsif combined.match?(/(failures:|assertions|tests failed|failed tests|FAILED \(|FAIL:|\d+ failures, \d+ errors)/i)
147
+ elsif combined.match?(/(failures:|assertions|tests failed|failed tests|FAILED \(|FAIL:|\b\d+ failures, \d+ errors\b)/i)
148
148
  :test_failure
149
149
  else
150
150
  :unknown_failure
@@ -164,7 +164,7 @@ module Rubik
164
164
  def self.segment_annotation(segment)
165
165
  rule = segment[:rule] || "unknown"
166
166
  reason = segment[:reason].to_s.strip
167
- return "#{rule} — option #{segment[:option]} overrides to DANGEROUS" if segment[:option] && reason.match?(/overridden by option .+ to dangerous\z/i)
167
+ return "#{rule} — option #{segment[:option]} overrides to DANGEROUS" if segment[:option] && reason.match?(/overridden by option \S+ to dangerous\z/i)
168
168
 
169
169
  # Base-rule reasons restate matched_rule ("base rule: rm (…)") — the
170
170
  # rule is already displayed; keep only the elevation evidence.
@@ -14,19 +14,19 @@ module Rubik
14
14
  /AIzaSy[a-zA-Z0-9_-]{33}/,
15
15
  /\bbearer\s+[a-zA-Z0-9_\-.]{20,}/i,
16
16
  /-----BEGIN [A-Z0-9_\s-]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9_\s-]+PRIVATE KEY-----/,
17
- %r{(https?://[^:]+:)([^@]+)@}
17
+ %r{(https?://[^\s/@:]+:)([^\s/@:]+)@}
18
18
  ].freeze
19
19
 
20
20
  ASSIGNMENT_KEY_REGEX = /\b(password|passwd|api_key|secret)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
21
21
  ASSIGNMENT_TOKEN_REGEX = /\b(token)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
22
22
 
23
23
  # PERF: Pre-compiled constants to avoid re-allocation inside hot loops
24
- URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
24
+ URL_PATTERN = %r{(https?://[^\s/@:]+:)([^\s/@:]+)@}
25
25
  URL_MASK = "\\1#{MASK_STRING}@".freeze
26
26
  STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
27
27
  # Skip static replacement passes for assignment-only and URL-only inputs.
28
28
  STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
29
- QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
29
+ QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^\s/@:]+:|password|passwd|api_key|secret|token}i
30
30
 
31
31
  # Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
32
32
  # aile): sır görünümlü anahtarların değerleri desen eşleşmese bile
@@ -48,7 +48,9 @@ module Rubik
48
48
 
49
49
  # [a-z0-9._-] dışını tireye çevirir; boş kalırsa "default".
50
50
  def slugify(name)
51
- slug = name.to_s.downcase.gsub(/[^a-z0-9._-]+/, "-").gsub(/\A[-.]+|[-.]+\z/, "")
51
+ slug = name.to_s.downcase.gsub(/[^a-z0-9._-]+/, "-")
52
+ slug = slug.delete_prefix("-").delete_prefix(".") while slug.start_with?("-", ".")
53
+ slug = slug.delete_suffix("-").delete_suffix(".") while slug.end_with?("-", ".")
52
54
  slug.empty? ? "default" : slug
53
55
  end
54
56
 
@@ -38,7 +38,7 @@ module Rubik
38
38
  return header << "_(empty — no #{type} entities yet)_\n" if summaries.empty?
39
39
 
40
40
  summaries.each do |s|
41
- title = s[:title].to_s.gsub("|", "\\|").slice(0, 80)
41
+ title = s[:title].to_s.gsub("\\", "\\\\\\\\").gsub("|", "\\|").slice(0, 80)
42
42
  header << "| #{s[:row]} | `#{s[:id]}` | #{s[:status]} | #{s[:updated_at]} | #{title} |\n"
43
43
  end
44
44
  header
@@ -42,8 +42,11 @@ module Rubik
42
42
  # çözümleyicisi "wiki.42" gibi iki parçalı tip.satır formlarını ref
43
43
  # alias'tan ÖNCE dener — çarpışma yanlış satırı ezmesi demekti.
44
44
  def slug(text)
45
- s = text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
46
- s = s[0, SLUG_MAX].to_s.gsub(/\A-+|-+\z/, "")
45
+ s = text.to_s.downcase.gsub(/[^a-z0-9]+/, "-")
46
+ s = s.delete_prefix("-") while s.start_with?("-")
47
+ s = s.delete_suffix("-") while s.end_with?("-")
48
+ s = s[0, SLUG_MAX].to_s
49
+ s = s.delete_suffix("-") while s.end_with?("-")
47
50
  s = "n#{s}" if s.match?(/\A\d+\z/)
48
51
  s.empty? ? "untitled" : s
49
52
  end
@@ -55,7 +55,8 @@ module Rubik
55
55
  filter = raw.to_s.strip.tr("\\", "/").sub(%r{^\./}, "")
56
56
  return ["", nil] if filter.empty? || filter == "." || filter == "/"
57
57
 
58
- norm_root = root_dir.to_s.tr("\\", "/").sub(%r{/+\z}, "")
58
+ norm_root = root_dir.to_s.tr("\\", "/")
59
+ norm_root = norm_root.delete_suffix("/") while norm_root.end_with?("/")
59
60
  if filter.downcase == norm_root.downcase
60
61
  return ["", nil]
61
62
  elsif filter.downcase.start_with?("#{norm_root.downcase}/")
@@ -172,6 +172,7 @@ module Rubik
172
172
  def render_llm_thought(step_info)
173
173
  @spinner.stop
174
174
  return nil if level < Rubik::REPL::StepFormatter::LEVEL_VERBOSE
175
+ return nil if step_info[:streamed] == true
175
176
 
176
177
  first_line = @legacy.thought_first_line(step_info[:content])
177
178
  return nil if first_line.nil? || first_line.empty?
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.4.0"
4
+ VERSION = "0.4.1"
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.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır