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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29ee7f0d84218a3bf3d2fe0699c2c37d7f260f6406231a46338f169410ae5c60
4
- data.tar.gz: a600c600071a3267b8ab4cdfd0b2f755a2068f2cd8289949ae57e41383355d1b
3
+ metadata.gz: b8a3ecbfb98ba136f13800cc4a2d9ab68703b30c0086a3297c005abe6991d060
4
+ data.tar.gz: 72e1fff12c37f318f5e1b817d0ef88601110a83099f05edbd10c3d294564027c
5
5
  SHA512:
6
- metadata.gz: fabc3ee65ba68ea6244727ba265232c1b05ac2bd06eee192a376dbfc6b6da746070bac6f129b750011803c2945f092096b5cd122065dd79bde224fcf9018892f
7
- data.tar.gz: 30313695e55c4ae537d9d3acb27b1dbc7365d6db817e93b487e7612ce7c2921235deb7e92db6b2232e33b71569b65e1190f9c8c948bef8090fce093412fcc14c
6
+ metadata.gz: 7a17907eee5524e8053a9f73eb946ebe8766001a64ca17db048240837c1f154f7d2ba3650ce765e8ab830dc6da8840a1ea15e147490a03af1f5203d33b678294
7
+ data.tar.gz: 54d72614c2c6fd31a0ff482144c7953148d780516ea748f248027a6943d0b91c40fdfd64d1867036109f8b666bc3ce9ecb58516f0418f3c775335f249fead2b5
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,800+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,850+ 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
@@ -10,6 +10,108 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.3.5] - 2026-10-24
14
+
15
+ A vakanuvis-durability patch: the chronicler's digest pipeline stops
16
+ losing in-flight work and serving stale reads, LSP diagnostics and
17
+ document sync get hardened across the board, and AST call
18
+ extraction runs measurably cheaper.
19
+
20
+ ### Fixed
21
+
22
+ - **vakanuvis recall and digest are durable (#266, #267, #268,
23
+ #270, #271, #272):** in-place memory and wiki edits now register
24
+ as durable content updates for digest dirty tracking; superseded
25
+ records no longer surface as exact recall hits or pass smoke
26
+ checks; annal write failures and harness shutdown no longer
27
+ strand targeted digest jobs or events enqueued after the STOP
28
+ marker; the pending digest queue is bounded when the distiller is
29
+ unavailable; and the rogue-memory sweep preserves canonical
30
+ `mem.*` aliases, superseding raw duplicates in the canonical
31
+ line's favor.
32
+ - **LSP diagnostics and sync hardened (#252, closes #245–#251):**
33
+ diagnostic freshness is tracked per canonical document — stale
34
+ versioned publications are rejected and pending or unversioned
35
+ analysis is reported accurately; document state and LSP frame
36
+ writes are serialized, and transport failures propagate to wake
37
+ pending requests; text-document synchronization capabilities are
38
+ honored (unsupported dynamic registrations rejected, save
39
+ notifications sent when supported); server selection is separated
40
+ from document language IDs, with crash history retained across
41
+ short-lived restarts; and structured executable/argv server
42
+ configuration is supported through discovery, repair and client
43
+ startup without invoking a shell.
44
+ - **Anthropic API key resolution hardened (#261):** the reported
45
+ hardcoded-secret finding is confirmed a false positive (the
46
+ literal env-var name in error messages); `resolve_api_key` now
47
+ falls back to `ANTHROPIC_API_KEY` defensively when the configured
48
+ environment-variable name is blank or empty.
49
+
50
+ ### Performance
51
+
52
+ - **AST call extraction is cheaper (#277):** the Go, Rust and
53
+ TypeScript extractors use zero-capture lookahead regexes and
54
+ frozen keyword-exclusion Sets — 35–80% fewer allocations and
55
+ ~25–27% faster in hot call-extraction paths, verified
56
+ output-identical.
57
+
58
+ ### Changed
59
+
60
+ - **No-behavior-change decompositions (#254–#265):** oversized
61
+ methods across the OpenAI-compatible provider, goal-turn driving,
62
+ the MCP/workspace/session/model/context command registrations,
63
+ buffer registration and apply, subagent spawn and adapter process
64
+ management were split into helpers — same behavior, reviewable
65
+ units.
66
+
67
+ ## [0.3.4] - 2026-10-23
68
+
69
+ A security-hardening patch: three injection/traversal findings in
70
+ child-process enumeration, the docker sandbox driver and overlay
71
+ storage are closed, scan-heavy paths got faster, and a wave of
72
+ oversized REPL/LLM methods was decomposed with no behavior change.
73
+
74
+ ### Fixed
75
+
76
+ - **Command injection in child-process enumeration (#238):**
77
+ `direct_child_pids` interpolated the target pid into backtick
78
+ shells (`pgrep -P #{pid}` / `ps --ppid #{pid}`); both lookups now
79
+ run through array-parameterized `Open3.capture2` calls, so pid
80
+ values never reach a shell for re-parsing.
81
+ - **Command injection in the docker sandbox driver (#237):**
82
+ `DockerDriver#available?` and container cleanup built command
83
+ strings with interpolation (`docker rm -f #{container_name}`);
84
+ both now use array-form `system`, keeping shell metacharacters
85
+ inert.
86
+ - **Path traversal in OverlayStorage (#232):** overlay directories
87
+ are validated against an allowlist of the four known overlay dirs
88
+ resolved under their trusted roots, manifests and every
89
+ command/tool write path are containment-checked, and traversal
90
+ attempts are rejected loudly with a `[SECURITY]` warning instead
91
+ of escaping the overlay root.
92
+
93
+ ### Performance
94
+
95
+ - **Faster scanning internals (#220, #225, #227, #228, #244):**
96
+ storage indexing parses JSON lines with less string churn;
97
+ `Session::Replayer` streams with `File.foreach`;
98
+ `Buffer::Manager#all_files` prunes `.git` traversal;
99
+ `FuzzyMatcher#find_closest_subsequence` tightens its inner loop;
100
+ and `clean_utf8_deep` gains an allocation-free fast path
101
+ (Symbol#name validation, Hash-based cycle tracking, ~3.3x on
102
+ clean input). `Buffer::Manager#diff_all` also reads through
103
+ `Platform.read_text`, so on-disk content is UTF-8-tagged
104
+ consistently (#226).
105
+
106
+ ### Changed
107
+
108
+ - **No-behavior-change decompositions (#231, #233, #234, #235,
109
+ #239, #240, #242, #243):** oversized REPL command registrations
110
+ (workspace allow-list, goal, agent providers, allow-cmd, LSP),
111
+ the LLM client's `post_json`, `GoalLedger::Patch#normalize_op`
112
+ and config `Defaults` scalar validation were split into helper
113
+ methods — same behavior, reviewable units.
114
+
13
115
  ## [0.3.3] - 2026-10-21
14
116
 
15
117
  A field-incident patch: subagent runs get a lifetime step ceiling,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
+ require_relative "../log"
5
6
 
6
7
  module Rubik
7
8
  module Adapter
@@ -19,17 +20,19 @@ module Rubik
19
20
  pid = wait_thr.pid
20
21
  begin
21
22
  stdin.close
22
- rescue
23
- nil
23
+ rescue StandardError => e
24
+ Rubik::Log.debug(:adapter, "stdin close failed during process execution: #{e.class}: #{e.message}")
24
25
  end
25
26
  t_out = Thread.new do
26
27
  stdout.read
27
- rescue
28
+ rescue StandardError => e
29
+ Rubik::Log.debug(:adapter, "stdout read failed during process execution: #{e.class}: #{e.message}")
28
30
  ""
29
31
  end
30
32
  t_err = Thread.new do
31
33
  stderr.read
32
- rescue
34
+ rescue StandardError => e
35
+ Rubik::Log.debug(:adapter, "stderr read failed during process execution: #{e.class}: #{e.message}")
33
36
  ""
34
37
  end
35
38
  begin
@@ -40,13 +43,13 @@ module Rubik
40
43
  ensure
41
44
  begin
42
45
  t_out.kill
43
- rescue
44
- nil
46
+ rescue StandardError => e
47
+ Rubik::Log.debug(:adapter, "t_out kill failed: #{e.class}: #{e.message}")
45
48
  end
46
49
  begin
47
50
  t_err.kill
48
- rescue
49
- nil
51
+ rescue StandardError => e
52
+ Rubik::Log.debug(:adapter, "t_err kill failed: #{e.class}: #{e.message}")
50
53
  end
51
54
  end
52
55
  end
@@ -8,6 +8,10 @@ module Rubik
8
8
  class GoExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -105,9 +109,12 @@ module Rubik
105
109
  )
106
110
  end
107
111
 
112
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
113
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~25% speedup, ~80% fewer allocations).
108
114
  def extract_calls(body, self_name)
109
- calls = body.scan(/\b([A-Za-z0-9_]+)\s*\(/).flatten.uniq
110
- calls.reject { |c| c == self_name || ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"].include?(c) }
115
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
116
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
117
+ calls
111
118
  end
112
119
 
113
120
  def find_closing_brace(start_idx)
@@ -8,6 +8,10 @@ module Rubik
8
8
  class RustExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -91,9 +95,12 @@ module Rubik
91
95
 
92
96
  private
93
97
 
98
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
99
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~80% fewer allocations).
94
100
  def extract_calls(body, self_name)
95
- calls = body.scan(/\b([A-Za-z0-9_]+)(?:!|\s*\()/).flatten.uniq
96
- calls.reject { |c| c == self_name || ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"].include?(c) }
101
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=!|\s*\()/).uniq
102
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
103
+ calls
97
104
  end
98
105
 
99
106
  def find_closing_brace(start_idx)
@@ -8,6 +8,14 @@ module Rubik
8
8
  class TypeScriptExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ [
13
+ "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
14
+ "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
15
+ "await", "super", "class", "interface", "type", "const", "let", "var"
16
+ ]
17
+ ).freeze
18
+
11
19
  def self.extract(file_path, content = nil)
12
20
  new(file_path, content).extract
13
21
  end
@@ -227,14 +235,12 @@ module Rubik
227
235
  [sym, next_i]
228
236
  end
229
237
 
238
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
239
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~35% fewer allocations).
230
240
  def extract_calls(body, self_name)
231
- calls = body.scan(/\b([A-Za-z0-9_$]+)\s*\(/).flatten.uniq
232
- keywords = [
233
- "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
234
- "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
235
- "await", "super", "class", "interface", "type", "const", "let", "var"
236
- ]
237
- calls.reject { |c| c == self_name || keywords.include?(c) }
241
+ calls = body.scan(/\b[A-Za-z0-9_$]+(?=\s*\()/).uniq
242
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
243
+ calls
238
244
  end
239
245
 
240
246
  def find_closing_brace(start_idx)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "find"
4
5
  require "digest"
5
6
  require "pathname"
6
7
  require "monitor"
@@ -265,10 +266,27 @@ module Rubik
265
266
  # for callers that only need a fuzzy-match candidate list.
266
267
  def all_files
267
268
  buffer_keys = @monitor.synchronize { @buffer.keys }
269
+ return buffer_keys unless File.directory?(@root_dir)
270
+
268
271
  disk_files = begin
269
- Dir.glob("**/*", File::FNM_DOTMATCH, base: @root_dir).reject do |f|
270
- f.start_with?(".git") || File.directory?(File.expand_path(f, @root_dir))
272
+ files = []
273
+ root_size = @root_dir.bytesize
274
+ Find.find(@root_dir) do |path|
275
+ next if path.bytesize == root_size
276
+
277
+ rel = path.byteslice((root_size + 1)..-1)
278
+ rel = rel.tr("\\", "/") if File::ALT_SEPARATOR
279
+
280
+ if rel.start_with?(".git")
281
+ Find.prune if File.directory?(path)
282
+ next
283
+ end
284
+
285
+ next if File.directory?(path)
286
+
287
+ files << rel
271
288
  end
289
+ files
272
290
  rescue SystemCallError
273
291
  []
274
292
  end
@@ -282,7 +300,7 @@ module Rubik
282
300
 
283
301
  diffs = snapshot.map do |rel_path, new_content|
284
302
  full_path = File.expand_path(rel_path, @root_dir)
285
- orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(File.read(full_path)) : ""
303
+ orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(Rubik::Platform.read_text(full_path)) : ""
286
304
  Diff.unified_diff(rel_path, orig_content, new_content)
287
305
  end
288
306
 
@@ -299,82 +317,20 @@ module Rubik
299
317
  @monitor.synchronize do
300
318
  applied_paths = []
301
319
  conflicts = []
302
- keys_to_apply = if target_paths
303
- Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
304
- else
305
- @dirty_files.keys.dup
306
- end
307
-
320
+ keys_to_apply = determine_keys_to_apply(target_paths)
308
321
  effective_policy = (policy || external_modification_policy).to_s.downcase
309
322
 
310
323
  keys_to_apply.each_with_index do |rel_path, index|
311
324
  next unless @dirty_files.key?(rel_path)
325
+ next unless validate_and_clean_garbage(rel_path)
312
326
 
313
- unless PathValidator.valid?(rel_path)
314
- @dirty_files.delete(rel_path)
315
- @buffer.delete(rel_path)
316
- @original_hashes.delete(rel_path)
317
- @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
318
- next
319
- end
320
-
321
- baseline = @original_hashes[rel_path]
322
- current_disk = disk_hash(rel_path)
323
-
324
- conflict = if baseline
325
- current_disk != baseline
326
- else
327
- !current_disk.nil?
328
- end
329
-
330
- if conflict && effective_policy != "overwrite"
327
+ if external_conflict?(rel_path) && effective_policy != "overwrite"
331
328
  conflicts << rel_path
332
329
  @event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
333
330
  next
334
331
  end
335
332
 
336
- full_path = File.expand_path(rel_path, @root_dir)
337
-
338
- # TOCTOU close: re-verify containment immediately before the
339
- # atomic write — a symlink planted between write-time sanitize
340
- # and apply! must not redirect the write outside the workspace.
341
- # Fail loud: an untrustworthy path is an attack signal or a
342
- # mid-flight root misconfiguration, not a content problem; the
343
- # entry stays dirty for a retry after remediation.
344
- containment_kind, = containment_root(full_path)
345
- unless containment_kind
346
- @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
347
- # Commit what this run has seen so "apply! rewrites
348
- # last_conflicts on every run" holds for readers (AutoApply's
349
- # report_conflicts) even on the failure path.
350
- @last_conflicts = conflicts
351
- raise apply_failure(
352
- "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
353
- "(possible symlink swap since buffering). Re-verify the workspace and retry.",
354
- kind: :containment_blocked, applied: applied_paths, failed: rel_path,
355
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
356
- )
357
- end
358
-
359
- content = @buffer[rel_path]
360
-
361
- begin
362
- Rubik::Platform.atomic_write(full_path, content)
363
- rescue StandardError => e
364
- @last_conflicts = conflicts
365
- raise apply_failure(
366
- "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
367
- "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
368
- "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
369
- kind: :write_failed, applied: applied_paths, failed: rel_path,
370
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
371
- ), cause: e
372
- end
373
-
374
- @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
375
- @dirty_files.delete(rel_path)
376
- @buffer.delete(rel_path)
377
- applied_paths << rel_path
333
+ apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
378
334
  end
379
335
 
380
336
  @last_conflicts = conflicts
@@ -425,6 +381,80 @@ module Rubik
425
381
 
426
382
  private
427
383
 
384
+ def determine_keys_to_apply(target_paths)
385
+ if target_paths
386
+ Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
387
+ else
388
+ @dirty_files.keys.dup
389
+ end
390
+ end
391
+
392
+ def validate_and_clean_garbage(rel_path)
393
+ return true if PathValidator.valid?(rel_path)
394
+
395
+ @dirty_files.delete(rel_path)
396
+ @buffer.delete(rel_path)
397
+ @original_hashes.delete(rel_path)
398
+ @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
399
+ false
400
+ end
401
+
402
+ def external_conflict?(rel_path)
403
+ baseline = @original_hashes[rel_path]
404
+ current_disk = disk_hash(rel_path)
405
+
406
+ if baseline
407
+ current_disk != baseline
408
+ else
409
+ !current_disk.nil?
410
+ end
411
+ end
412
+
413
+ def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
414
+ full_path = File.expand_path(rel_path, @root_dir)
415
+
416
+ # TOCTOU close: re-verify containment immediately before the
417
+ # atomic write — a symlink planted between write-time sanitize
418
+ # and apply! must not redirect the write outside the workspace.
419
+ # Fail loud: an untrustworthy path is an attack signal or a
420
+ # mid-flight root misconfiguration, not a content problem; the
421
+ # entry stays dirty for a retry after remediation.
422
+ containment_kind, = containment_root(full_path)
423
+ unless containment_kind
424
+ @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
425
+ # Commit what this run has seen so "apply! rewrites
426
+ # last_conflicts on every run" holds for readers (AutoApply's
427
+ # report_conflicts) even on the failure path.
428
+ @last_conflicts = conflicts
429
+ raise apply_failure(
430
+ "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
431
+ "(possible symlink swap since buffering). Re-verify the workspace and retry.",
432
+ kind: :containment_blocked, applied: applied_paths, failed: rel_path,
433
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
434
+ )
435
+ end
436
+
437
+ content = @buffer[rel_path]
438
+
439
+ begin
440
+ Rubik::Platform.atomic_write(full_path, content)
441
+ rescue StandardError => e
442
+ @last_conflicts = conflicts
443
+ raise apply_failure(
444
+ "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
445
+ "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
446
+ "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
447
+ kind: :write_failed, applied: applied_paths, failed: rel_path,
448
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
449
+ ), cause: e
450
+ end
451
+
452
+ @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
453
+ @dirty_files.delete(rel_path)
454
+ @buffer.delete(rel_path)
455
+ applied_paths << rel_path
456
+ end
457
+
428
458
  # Single source of boundary containment for both the sanitize_path
429
459
  # entry check and the apply! re-verification. Returns [kind, dir] with
430
460
  # kind :root / :external (dir is the LEXICAL matched root/external
@@ -162,35 +162,26 @@ module Rubik
162
162
  "maestro_gate" => true,
163
163
  "auto_pause_on_network_error" => true,
164
164
  "reactive_wakeup" => true,
165
- # Vakanüvis arka plan kronikçisi (storage/vakanuvis; plan
166
- # 2026-10-04 K1-K9): deterministik annal + wiki derleyicisi.
167
- # enabled: Düzlem 1 harness'i kur/kurulama; distiller: Düzlem 2
168
- # LLM doğumu (token üst sınırı flush_events x max_steps);
169
- # flush_events: damıtıcı doğumu için olay eşiği; model: nil =
170
- # root provider; retention: supersede (iz bırak) | purge (sil);
171
- # root_turns: REPL kök-turn'leri de annal'a yazılsın mı
172
- # (root-turn planı 2026-10-04; kapalıyken abonelik kurulmaz).
165
+ # Vakanüvis arka plan kronikçisi (plan 2026-10-04 K1-K9): deterministik
166
+ # annal + wiki derleyicisi. enabled: harness kur/kurulama; distiller:
167
+ # Düzlem 2 LLM doğumu (bütçe: flush_events x max_steps); model: nil =
168
+ # root provider; retention: supersede|purge; root_turns: kök-turn annal'ı.
173
169
  "vakanuvis" => {
174
170
  "enabled" => true,
175
171
  "distiller" => true,
176
172
  "max_steps" => 8,
177
173
  "flush_events" => 10,
174
+ "max_digest_jobs" => 100,
178
175
  "model" => nil,
179
176
  "retention" => "supersede",
180
177
  "root_turns" => true
181
178
  },
182
- # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi.
183
- # DEFAULT ON (kullanıcı kararı 2026-10-10) — deterministik yığın
184
- # (trim_conversation_window! / ContextCompactor / EmergencyCompactor)
185
- # her zaman güvenlik ağıdır; subagent kanalları bu özellikten asla
186
- # etkilenmez; opt-out: "enabled" => false (kill-switch). enabled:
187
- # harness'i kur (prune_context aracı + !prune komutu açılır);
188
- # trigger_ratio: turn sonu pencere tahmini girdi tavanının bu
189
- # oranına ulaşınca LLM budayıcı doğar (0.85'lik kör trim'den ÖNCE
190
- # anlamsal sıkıştırma); target_ratio: plan yalnızca sonuç bu
191
- # orana sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi
192
- # (nil = root provider); min_turns_between: otomatik doğum oranı
193
- # sınırlayıcısı (turn sayısı).
179
+ # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi,
180
+ # DEFAULT ON (kullanıcı kararı 2026-10-10; kill-switch: enabled=false).
181
+ # trigger_ratio: tahmini girdi tavanının bu oranında LLM budayıcı doğar
182
+ # (kör trim'den ÖNCE anlamsal sıkıştırma); target_ratio: plan sadece
183
+ # sonuca sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi (nil =
184
+ # root provider); min_turns_between: otomatik doğum oranı sınırlayıcısı.
194
185
  "pruner" => {
195
186
  "enabled" => true,
196
187
  "trigger_ratio" => 0.6,
@@ -216,6 +207,12 @@ module Rubik
216
207
 
217
208
  private
218
209
 
210
+ def validate_prompt_grace_value!(key, value)
211
+ return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
212
+
213
+ raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
214
+ end
215
+
219
216
  def validate_schema(config, schema = DEFAULT_CONFIG, prefix = "", strict: false)
220
217
  raise ArgumentError, "Configuration must be a Hash, got #{config.class}" unless config.is_a?(Hash)
221
218
 
data/lib/rubik/config.rb CHANGED
@@ -163,12 +163,6 @@ module Rubik
163
163
 
164
164
  private
165
165
 
166
- def validate_prompt_grace_value!(key, value)
167
- return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
168
-
169
- raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
170
- end
171
-
172
166
  # save! returns itself for no-write scopes; persist outcomes are hashes.
173
167
  def persist_result(res)
174
168
  res.is_a?(Hash) ? res : { ok: true }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../log"
4
5
 
5
6
  module Rubik
6
7
  module Context
@@ -27,7 +28,8 @@ module Rubik
27
28
  res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
28
29
  res.uniq!
29
30
  res
30
- rescue StandardError
31
+ rescue StandardError => e
32
+ Rubik::Log.debug(:context, "git ls-files execution failed for #{root_dir}: #{e.class}: #{e.message}")
31
33
  nil
32
34
  end
33
35
 
@@ -56,8 +56,11 @@ module Rubik
56
56
  return opts[:api_key] if opts.key?(:api_key) && !opts[:api_key].nil?
57
57
  return @api_key if @api_key && !@api_key.to_s.empty?
58
58
 
59
- env_name = opts[:api_key_env] || @api_key_env || "ANTHROPIC_API_KEY"
60
- ENV.fetch(env_name, nil)
59
+ env_name = opts[:api_key_env]
60
+ env_name = @api_key_env if env_name.nil? || env_name.to_s.empty?
61
+ env_name = "ANTHROPIC_API_KEY" if env_name.nil? || env_name.to_s.empty?
62
+
63
+ ENV.fetch(env_name.to_s, nil)
61
64
  end
62
65
 
63
66
  def complete(messages, opts = {})