rubik 0.4.1 → 0.5.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +118 -1
  4. data/lib/rubik/ast/cache_store.rb +69 -5
  5. data/lib/rubik/ast/extraction_result.rb +6 -0
  6. data/lib/rubik/ast/graph.rb +32 -5
  7. data/lib/rubik/ast/ruby_extractor.rb +8 -2
  8. data/lib/rubik/buffer/apply_contract.rb +6 -0
  9. data/lib/rubik/buffer/manager.rb +37 -27
  10. data/lib/rubik/config/defaults.rb +7 -1
  11. data/lib/rubik/mcp/client.rb +4 -0
  12. data/lib/rubik/mcp/manager.rb +51 -35
  13. data/lib/rubik/mcp/proxy_tool.rb +17 -0
  14. data/lib/rubik/mcp/transport/stdio.rb +142 -17
  15. data/lib/rubik/orchestrator/budget_manager.rb +2 -0
  16. data/lib/rubik/orchestrator/loop.rb +1 -0
  17. data/lib/rubik/orchestrator/shell_deps.rb +4 -0
  18. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
  19. data/lib/rubik/plugin/manager.rb +39 -15
  20. data/lib/rubik/plugin/package_inventory.rb +11 -1
  21. data/lib/rubik/plugin/storage.rb +5 -4
  22. data/lib/rubik/prompt/doctrine.rb +10 -15
  23. data/lib/rubik/prompt/tool_teachings.rb +29 -35
  24. data/lib/rubik/repair/loop.rb +29 -15
  25. data/lib/rubik/repl/commands/agent_context.rb +1 -0
  26. data/lib/rubik/repl/commands/system.rb +2 -0
  27. data/lib/rubik/repl/turn_recorder.rb +3 -0
  28. data/lib/rubik/security/redactor.rb +13 -9
  29. data/lib/rubik/storage/vakanuvis/gate.rb +13 -8
  30. data/lib/rubik/storage/vakanuvis/harness.rb +115 -112
  31. data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
  32. data/lib/rubik/storage/vakanuvis/service.rb +5 -1
  33. data/lib/rubik/storage/vakanuvis/vocabulary.rb +33 -2
  34. data/lib/rubik/storage/vakanuvis.rb +8 -1
  35. data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
  36. data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
  37. data/lib/rubik/ui/capability.rb +19 -5
  38. data/lib/rubik/version.rb +1 -1
  39. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15cadac070995d7b8d1ac155c6ddfe14c4e4418e596efe6b734af6cb9240f6b4
4
- data.tar.gz: 21b3376be313a5133ca84180a36770b0c344495b12ed3c00390715d8dd1658eb
3
+ metadata.gz: 9a55adca9a39a813ea7620e63018dc98ae7c902142e9a1fa1dc5afe92637ef88
4
+ data.tar.gz: 72d7ca21a839051017fd515fe63a0cc99573054ec3a23c5fd179a6e37c623460
5
5
  SHA512:
6
- metadata.gz: d9edcdfa2830b03dc8d9a5345d8215a5204ba96d4aa5ef93ca0742633a02ff089f2dda9fab638d4a42a489f1b166f74c9a11ecc5f64b3f602b7cb1cbbdd09a5a
7
- data.tar.gz: 2b478a0aa15376eaac5636c9d6e7f8d5dfac5534394153958d6417ecf3547dbb9ae654d7c59e5d92df6a84e2038d869145a3ba2b7c8dbdc62472106c7727c33d
6
+ metadata.gz: f4b426b159392a724d6e66d96dfd09db13cee8882eec7f3dcf0afaaf3d683325d3a74731f0e34abf43e77c1f1655d850cecd2912e324e16da9ad84bd74c1eb71
7
+ data.tar.gz: 36090da4466990229e789cf5912cf880bf4193eb300f8823abc7a1cefed4bf6253aff44774ff9d18e19ebbfb3f874d7c70434d7ab72692ba474bd9e6ba337abf
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,900+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 4,000+ 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,121 @@ 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.5.0] - 2026-10-31
10
+
11
+ A vakanüvis minor: the memory system gains a harness-owned auto-digest
12
+ loop and a deterministic topics orientation injected at session start
13
+ and after compaction, the prompt doctrine's recall/record contradiction
14
+ is closed with a single storage write path, duck-typed AST cache stores
15
+ stop breaking `save_to_cache`, and the security redactor's clean-text
16
+ path skips its intermediate allocations.
17
+
18
+ ### Added
19
+
20
+ - **Harness-owned auto-digest of touched topics (#346, #348):** topics
21
+ recorded or updated since the last digest are tracked in the Harness
22
+ and auto-digested at goal completion and queued (spawn-free) at
23
+ shutdown, gated by the `subagents.vakanuvis.auto_digest` knob
24
+ (default on); per-topic error isolation re-arms failed or refused
25
+ topics for the next flush instead of losing them, the drain window
26
+ between store write and touched-topic tracking is closed with one
27
+ monitor section, and shutdown always joins/kills the digest worker
28
+ with an honest dmesg listing parked topics.
29
+ - **Deterministic topics orientation (#343):** session start and budget
30
+ compaction inject a compact, deterministic overview of the memory
31
+ topics so the model is oriented without a recall round-trip.
32
+
33
+ ### Fixed
34
+
35
+ - **Keywords-in-query contradiction closed (#342):** the vakanüvis
36
+ teaching now separates the recall and record directions, stops
37
+ telling the model to add Keywords to recall queries, renders a single
38
+ write-path storage variant when vakanüvis is active, and folds the
39
+ doctrine down to two core duties with the duplicated
40
+ `goal_storage_rule` wording removed (SSOT).
41
+ - **Duck-typed AST cache stores (#347):** `save_to_cache` probes
42
+ `store.method(:save).parameters` once and dispatches deterministically
43
+ instead of rescuing `ArgumentError` (which swallowed genuine errors
44
+ and double-fired side-effecting stores); 1-arity stores get the
45
+ snapshot without statuses plus a warn naming the store class, while
46
+ stores declaring the canonical kwarg signature keep the full-map
47
+ delivery — including in the clean-graph case.
48
+
49
+ ### Performance
50
+
51
+ - **Redactor clean-text fast path (#337):** `Redactor.redact` no longer
52
+ allocates an intermediate scrub/dup string before testing
53
+ `QUICK_CHECK_REGEX` on valid-encoding clean input.
54
+
55
+ ## [0.4.2] - 2026-10-29
56
+
57
+ A reliability patch: plugin file loads become transactional with
58
+ timing-safe integrity checks, MCP transport and handshake lifecycles
59
+ shed their remaining deadlocks and wedged-boot races, buffer apply
60
+ failures report faithfully, the repair loop can no longer return
61
+ unverified code as success, and vakanüvis slugs stop colliding across
62
+ scripts and long topics.
63
+
64
+ ### Fixed
65
+
66
+ - **MCP transport lifecycle deadlocks closed (#308):** server stderr is
67
+ drained on a dedicated thread with a bounded diagnostic tail (also
68
+ attached to `TimeoutError` messages), a dead transport incarnation is
69
+ reaped on re-open instead of leaking its file descriptors and waiter,
70
+ and a fast async boot can no longer park a dead thread handle that
71
+ permanently wedged the sync-boot gate after `stop_all`.
72
+ - **AST persistent cache no longer pins stale symbols (#308):** a failed
73
+ re-index is excluded from the cache snapshot so its protected stale
74
+ symbols are never validated under the current file's fresh digest;
75
+ per-entry extraction status is persisted and validated on load
76
+ (cache schema version 2, backward compatible with existing caches).
77
+ - **Plugin file loads are transactional (#334):** `load_plugin_file`
78
+ prepares the replacement before touching the running plugin so a
79
+ failed re-add no longer bricks the live plugin; reloading a previously
80
+ `:active` plugin that lands in `:pending` rolls back to the last
81
+ working version; the manifest TOCTOU window is closed by evaluating
82
+ the in-memory verified content via `TOPLEVEL_BINDING.eval`; manifest
83
+ SHA-256 checks use timing-safe `secure_compare`; and directory
84
+ creation failures are always reported with `Rubik::Log.warn`.
85
+ - **MCP handshake and boot races closed (#336):** the manager's
86
+ handshake lock is split so a slow connect no longer serializes
87
+ unrelated state reads; async boots carry epoch-tagged guards that
88
+ discard a superseded connection instead of adopting it; the stdio
89
+ transport gains a synchronized reaper and a bounded `stderr_tail`
90
+ diagnostic; and rollback paths log the client they stopped.
91
+ - **Repair loop no longer reports unverified code as success (#335):**
92
+ a missing diagnostics adapter, an empty LLM proposal, or an exception
93
+ mid-repair now undoes the buffer snapshot and returns `ok: false`
94
+ (flagging `unverified:` when syntax could not be checked) instead of
95
+ writing broken code; LSP rollback only restores clients that are
96
+ alive and running.
97
+ - **Buffer apply failures report faithfully (#333):** `apply!` commits
98
+ `last_applied` and `last_conflicts` in an `ensure` block so files
99
+ already written to disk before a failure are remembered and
100
+ `clean_buffer_message` no longer hides them; garbage-skipped edits
101
+ are tracked in `last_skipped`/`ApplyFailedError.skipped` and surfaced
102
+ in `ApplyContract.failure_lines` and tool outputs; and conflict event
103
+ payloads are frozen copies, closing the live-array aliasing mutation.
104
+ - **Vakanüvis slugs no longer collide across scripts and topics
105
+ (#332):** slug generation Unicode-folds and transliterates Turkish
106
+ characters (ç, ğ, ı, ö, ş, ü) and appends a deterministic 6-character
107
+ SHA-256 suffix when normalization is lossy, when the topic exceeds the
108
+ 40-char limit, or when punctuation-only input yields the untitled
109
+ fallback — distinct topics can no longer resolve to the same storage
110
+ key.
111
+ - **UI capability detection unwraps capture streams (#331):**
112
+ `Capability.resolve_mode`, `color?`, and `spinner?` unwrap `TeeIO`
113
+ targets so a live terminal wrapped in a capture stream renders styled
114
+ dialogs and notices again; non-empty truthy `CLICOLOR_FORCE` values
115
+ (e.g. `true`, `yes`) now force ANSI color while `NO_COLOR` keeps
116
+ precedence.
117
+
118
+ ### Performance
119
+
120
+ - **Prism `CallNode` filter lists hoisted to frozen Sets (#307):** the
121
+ Ruby extractor's hot walk no longer allocates two temporary Arrays per
122
+ call node (~140k fewer heap objects per 10k file extractions).
123
+
9
124
  ## [0.4.1] - 2026-10-29
10
125
 
11
126
  A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
@@ -892,6 +1007,8 @@ installation section.
892
1007
  - Context: serialized index mutations, snapshot-outside-lock reconcile,
893
1008
  streaming-first truncation.
894
1009
 
895
- [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...HEAD
1010
+ [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.5.0...HEAD
1011
+ [0.5.0]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...v0.5.0
1012
+ [0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
896
1013
  [0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
897
1014
  [0.1.0]: https://github.com/ozgurulukir/rubik/releases/tag/v0.1.0
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "digest"
6
6
  require_relative "symbol"
7
+ require_relative "extraction_result"
7
8
  require_relative "../platform"
8
9
 
9
10
  module Rubik
@@ -44,9 +45,11 @@ module Rubik
44
45
  File.join(@home_dir, ".rubik", "cache", project_hash, "ast_index.json")
45
46
  end
46
47
 
47
- def save(file_symbols_hash)
48
+ def save(file_symbols_hash, statuses: {})
48
49
  return false unless file_symbols_hash.is_a?(Hash)
49
50
 
51
+ statuses = {} unless statuses.is_a?(Hash)
52
+
50
53
  data = {
51
54
  "version" => SCHEMA_VERSION,
52
55
  "last_scan" => Time.now.to_i,
@@ -84,12 +87,17 @@ module Rubik
84
87
 
85
88
  # Bodies are intentionally NOT persisted: they dominate the cache
86
89
  # size and are re-extracted lazily from source via Symbol#body.
90
+ # `status` carries the issue-#147 extraction status so the
91
+ # loaded graph can keep telling "parsed clean" from "partial" —
92
+ # entries predating the field read as :ok, the only status they
93
+ # could ever have been saved under.
87
94
  data["files"][rel_path] = {
88
95
  "language" => lang,
89
96
  "mtime" => stat.mtime.to_i,
90
97
  "mtime_f" => stat.mtime.to_f,
91
98
  "size" => stat.size,
92
99
  "digest" => digest,
100
+ "status" => (statuses[file_path] || :ok).to_s,
93
101
  "symbols" => syms.map { |sym| sym.to_h(include_body: false) }
94
102
  }
95
103
  end
@@ -102,15 +110,56 @@ module Rubik
102
110
  false
103
111
  end
104
112
 
113
+ # Dispatch a cache save across duck-typed stores. The canonical #save
114
+ # contract is (hash, statuses: {}) with a FULL statuses map (every
115
+ # snapshot file present, :ok or not), but gem consumers may hand the
116
+ # graph a narrower store. The signature is probed instead of rescuing
117
+ # ArgumentError around the call — a genuine ArgumentError raised inside
118
+ # a store's save must surface, and a failing save must never be
119
+ # silently retried single-arg (double side effects).
120
+ #
121
+ # Classification: a `statuses:` keyword (or **kwargs) means the kwarg
122
+ # call; otherwise the store is treated as 1-arity and non-ok statuses
123
+ # are dropped with a warn (such entries reload as :ok). When the
124
+ # signature cannot be read at all — save resolves through
125
+ # method_missing, or the object has no save — the canonical kwarg call
126
+ # is kept: the call itself surfaces any problem, exactly as the
127
+ # pre-probe code did. respond_to_missing?-backed proxies report
128
+ # [[:rest]] and are therefore treated as 1-arity: behavioral probing
129
+ # would risk double invocation.
130
+ def self.save_via(store, snapshot, statuses)
131
+ accepts_statuses = begin
132
+ store.method(:save).parameters.any? do |param|
133
+ kind, name = param
134
+ kind == :keyrest || ([:key, :keyreq].include?(kind) && name == :statuses)
135
+ end
136
+ rescue NameError
137
+ nil
138
+ end
139
+ return store.save(snapshot, statuses: statuses) if accepts_statuses != false
140
+
141
+ non_ok = statuses.count { |_, status| status != :ok }
142
+ Rubik::Log.warn(:ast, "#{store.class}#save cannot carry extraction statuses; #{non_ok} file(s) will reload as :ok") if non_ok.positive?
143
+ store.save(snapshot)
144
+ end
145
+
105
146
  def load
147
+ load_with_statuses[0]
148
+ end
149
+
150
+ # Same entries as #load, paired with the persisted per-file extraction
151
+ # status keyed by the same expanded paths (issue #147 across the cache
152
+ # boundary: a cache-loaded file must not report "never attempted").
153
+ def load_with_statuses
106
154
  target_path = cache_path
107
- return {} unless File.exist?(target_path)
155
+ return [{}, {}] unless File.exist?(target_path)
108
156
 
109
157
  content = File.read(target_path)
110
158
  data = JSON.parse(content)
111
- return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
159
+ return [{}, {}] unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
112
160
 
113
161
  result = {}
162
+ statuses = {}
114
163
  data["files"].each do |rel_path, cached_info|
115
164
  full_path = File.expand_path(rel_path, @root_dir)
116
165
  next unless valid_entry?(full_path, cached_info)
@@ -132,14 +181,29 @@ module Rubik
132
181
  )
133
182
  end
134
183
  result[full_path] = symbols
184
+ raw_status = cached_info["status"]
185
+ # Entries predating the field had exactly one possible status;
186
+ # anything else unreadable is corrupt data, not a status — warn
187
+ # and fall back instead of materializing an arbitrary symbol that
188
+ # the symbols tool would report and save_to_cache would honor.
189
+ statuses[full_path] = if raw_status.nil?
190
+ :ok
191
+ else
192
+ status = raw_status.is_a?(String) ? raw_status.to_sym : nil
193
+ unless ExtractionResult::STATUSES.include?(status)
194
+ Rubik::Log.warn(:ast, "unknown cached extraction status #{raw_status.inspect} for #{rel_path}; treating as ok")
195
+ status = :ok
196
+ end
197
+ status
198
+ end
135
199
  end
136
200
 
137
- result
201
+ [result, statuses]
138
202
  rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, TypeError, NoMethodError
139
203
  # Corrupt-but-valid JSON shapes raise TypeError/NoMethodError inside
140
204
  # the entry loop; EACCES covers an unreadable cache file. All mean
141
205
  # "cache unusable" → rebuild from scratch.
142
- {}
206
+ [{}, {}]
143
207
  end
144
208
 
145
209
  def valid_entry?(file_path, cached_info)
@@ -33,5 +33,11 @@ module Rubik
33
33
  [:ok, :partial].include?(status)
34
34
  end
35
35
  end
36
+
37
+ class ExtractionResult
38
+ # The closed status vocabulary, shared with persistence seams: the AST
39
+ # cache persists it per entry and validates against it on load.
40
+ STATUSES = [:ok, :partial, :failed, :unavailable].freeze
41
+ end
36
42
  end
37
43
  end
@@ -89,7 +89,7 @@ module Rubik
89
89
  def load_from_cache(cache_store)
90
90
  return 0 unless cache_store
91
91
 
92
- loaded_files = cache_store.load
92
+ loaded_files, statuses = cache_store.load_with_statuses
93
93
  return 0 if loaded_files.nil? || loaded_files.empty?
94
94
 
95
95
  @mutex.synchronize do
@@ -99,6 +99,7 @@ module Rubik
99
99
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
100
100
 
101
101
  @file_symbols[file_path] = syms.dup
102
+ @extraction_status[file_path] = statuses[file_path] || :ok
102
103
  syms.each do |sym|
103
104
  @symbols[sym.name] = sym
104
105
  q_key = "#{lang}:#{file_path}##{sym.name}"
@@ -113,12 +114,33 @@ module Rubik
113
114
  def save_to_cache(cache_store)
114
115
  return false unless cache_store
115
116
 
116
- snapshot = @mutex.synchronize do
117
- @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
117
+ snapshot = nil
118
+ statuses = nil
119
+ skipped_failed = 0
120
+ @mutex.synchronize do
121
+ snapshot = @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
122
+ # Failed-extraction honesty across the cache boundary: a :failed
123
+ # file's in-memory symbols are protected stale data or
124
+ # untrustworthy best-effort fallback. Persisting them would let
125
+ # CacheStore re-stat the CURRENT file and pin them under a fresh
126
+ # digest, making the staleness permanent — skip them and let the
127
+ # next boot re-extract.
128
+ if _get_extraction_status_unlocked(file_path) == :failed
129
+ skipped_failed += 1
130
+ next
131
+ end
132
+
118
133
  copy[file_path] = symbols.dup
119
134
  end
135
+ statuses = {}
136
+ snapshot.each_key do |file_path|
137
+ statuses[file_path] = _get_extraction_status_unlocked(file_path) || :ok
138
+ end
120
139
  end
121
- cache_store.save(snapshot)
140
+ skipped = "cache snapshot excluded #{skipped_failed} file(s) with failed extractions; they re-extract on next boot"
141
+ Rubik::Log.warn(:ast, skipped) if skipped_failed.positive?
142
+
143
+ CacheStore.save_via(cache_store, snapshot, statuses)
122
144
  end
123
145
 
124
146
  def smart_context_for(symbol_name)
@@ -179,7 +201,6 @@ module Rubik
179
201
  Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
180
202
  return existing
181
203
  end
182
- Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
183
204
  end
184
205
 
185
206
  _remove_file_unlocked(file_path)
@@ -188,6 +209,12 @@ module Rubik
188
209
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
189
210
 
190
211
  extracted = result.symbols
212
+ if result.status == :failed
213
+ # Truthful accounting: the fallback symbols ARE indexed when
214
+ # nothing pre-existing needed protection — even when there are
215
+ # none, the log must not claim otherwise.
216
+ Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); indexing #{extracted.length} best-effort fallback symbols")
217
+ end
191
218
  extracted.each do |sym|
192
219
  @symbols[sym.name] = sym
193
220
  q_key = "#{lang}:#{file_path}##{sym.name}"
@@ -8,6 +8,10 @@ module Rubik
8
8
  module AST
9
9
  class RubyExtractor
10
10
  EXCLUDED_CALLS = Set.new(["def", "if", "unless", "case", "return", "raise", "puts", "p", "print", "require"]).freeze
11
+ # PERF: Hoist require and excluded call lists to frozen Sets to avoid per-node Array allocations in hot AST walks.
12
+ REQUIRE_CALLS = Set.new(["require", "require_relative"]).freeze
13
+ EXCLUDED_PRISM_CALLS = Set.new(["require", "require_relative", "raise", "puts", "p", "print"]).freeze
14
+
11
15
  begin
12
16
  require "prism"
13
17
  PRISM_AVAILABLE = true
@@ -119,7 +123,8 @@ module Rubik
119
123
 
120
124
  when Prism::CallNode
121
125
  name = node.name.to_s
122
- if ["require", "require_relative"].include?(name)
126
+ # PERF: Fast Set lookup avoids Array allocation on every CallNode
127
+ if REQUIRE_CALLS.include?(name)
123
128
  line_num = node.location.start_line
124
129
  args_node = node.arguments&.arguments
125
130
  arg = args_node&.first&.slice.to_s
@@ -153,7 +158,8 @@ module Rubik
153
158
  operator = curr.call_operator || "."
154
159
  cname = "#{receiver}#{operator}#{cname}"
155
160
  end
156
- calls << cname unless ["require", "require_relative", "raise", "puts", "p", "print"].include?(cname)
161
+ # PERF: Fast Set lookup avoids Array allocation per call site in method body
162
+ calls << cname unless EXCLUDED_PRISM_CALLS.include?(cname)
157
163
  end
158
164
  queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
159
165
  end
@@ -38,6 +38,12 @@ module Rubik
38
38
  lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
39
39
  lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
40
40
  lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
41
+ if failure.respond_to?(:skipped) && !failure.skipped.empty?
42
+ skipped_desc = failure.skipped.map do |s|
43
+ s.is_a?(Hash) ? "#{s[:path]} (#{s[:reason]})" : s.to_s
44
+ end.join(", ")
45
+ lines << " • Skipped invalid paths (anti-injection/garbage): #{skipped_desc}"
46
+ end
41
47
  if sync
42
48
  annotation = sync_annotation(failure, sync)
43
49
  lines << annotation if annotation
@@ -21,20 +21,22 @@ module Rubik
21
21
  # the underlying error. `pending` lists this run's entries that are
22
22
  # still dirty (a retry writes only those).
23
23
  class ApplyFailedError < StandardError
24
- attr_reader :kind, :applied, :failed, :pending, :conflicts
24
+ attr_reader :kind, :applied, :failed, :pending, :conflicts, :skipped
25
25
 
26
- def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
26
+ def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [], skipped: [])
27
27
  super(message)
28
28
  @kind = kind
29
29
  @applied = applied
30
30
  @failed = failed
31
31
  @pending = pending
32
32
  @conflicts = conflicts
33
+ @skipped = skipped
33
34
  end
34
35
  end
35
36
 
36
37
  class Manager
37
- attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs, :guard, :last_conflicts
38
+ attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
39
+ :guard, :last_conflicts, :last_skipped
38
40
  attr_accessor :allow_external_writes, :config
39
41
 
40
42
  def initialize(root_dir: Dir.pwd, additional_roots: [], allowed_external_dirs: [], allow_external_writes: false,
@@ -52,7 +54,8 @@ module Rubik
52
54
  @buffer = {}
53
55
  @dirty_files = {}
54
56
  @original_hashes = {}
55
- @last_conflicts = []
57
+ @last_conflicts = [].freeze
58
+ @last_skipped = [].freeze
56
59
  @last_applied = nil
57
60
  end
58
61
 
@@ -317,25 +320,35 @@ module Rubik
317
320
  @monitor.synchronize do
318
321
  applied_paths = []
319
322
  conflicts = []
323
+ skipped = []
320
324
  keys_to_apply = determine_keys_to_apply(target_paths)
321
325
  effective_policy = (policy || external_modification_policy).to_s.downcase
322
326
 
323
- keys_to_apply.each_with_index do |rel_path, index|
324
- next unless @dirty_files.key?(rel_path)
325
- next unless validate_and_clean_garbage(rel_path)
327
+ begin
328
+ keys_to_apply.each_with_index do |rel_path, index|
329
+ next unless @dirty_files.key?(rel_path)
326
330
 
327
- if external_conflict?(rel_path) && effective_policy != "overwrite"
328
- conflicts << rel_path
329
- @event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
330
- next
331
- end
331
+ unless validate_and_clean_garbage(rel_path)
332
+ skipped << { path: rel_path, reason: :path_validator_rejected }
333
+ next
334
+ end
332
335
 
333
- apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
336
+ if external_conflict?(rel_path) && effective_policy != "overwrite"
337
+ conflicts << rel_path
338
+ @event_bus&.emit(:buffer_apply_conflict, paths: conflicts.dup.freeze, path: rel_path)
339
+ next
340
+ end
341
+
342
+ apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply,
343
+ applied_paths: applied_paths, conflicts: conflicts, skipped: skipped)
344
+ end
345
+ ensure
346
+ @last_conflicts = conflicts.dup.freeze
347
+ @last_skipped = skipped.dup.freeze
348
+ @last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
334
349
  end
335
350
 
336
- @last_conflicts = conflicts
337
- @last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
338
- @event_bus&.emit(:buffer_applied, paths: applied_paths) unless applied_paths.empty?
351
+ @event_bus&.emit(:buffer_applied, paths: applied_paths.dup.freeze) unless applied_paths.empty?
339
352
  applied_paths
340
353
  end
341
354
  end
@@ -353,7 +366,8 @@ module Rubik
353
366
  @buffer.clear
354
367
  @dirty_files.clear
355
368
  @original_hashes.clear
356
- @last_conflicts.clear
369
+ @last_conflicts = [].freeze
370
+ @last_skipped = [].freeze
357
371
  @last_applied = nil
358
372
  end
359
373
  end
@@ -410,7 +424,7 @@ module Rubik
410
424
  end
411
425
  end
412
426
 
413
- def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
427
+ def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:, skipped:)
414
428
  full_path = File.expand_path(rel_path, @root_dir)
415
429
 
416
430
  # TOCTOU close: re-verify containment immediately before the
@@ -422,15 +436,11 @@ module Rubik
422
436
  containment_kind, = containment_root(full_path)
423
437
  unless containment_kind
424
438
  @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
439
  raise apply_failure(
430
440
  "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
431
441
  "(possible symlink swap since buffering). Re-verify the workspace and retry.",
432
442
  kind: :containment_blocked, applied: applied_paths, failed: rel_path,
433
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
443
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
434
444
  )
435
445
  end
436
446
 
@@ -439,13 +449,12 @@ module Rubik
439
449
  begin
440
450
  Rubik::Platform.atomic_write(full_path, content)
441
451
  rescue StandardError => e
442
- @last_conflicts = conflicts
443
452
  raise apply_failure(
444
453
  "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
445
454
  "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
446
455
  "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
447
456
  kind: :write_failed, applied: applied_paths, failed: rel_path,
448
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
457
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
449
458
  ), cause: e
450
459
  end
451
460
 
@@ -494,11 +503,12 @@ module Rubik
494
503
 
495
504
  # Build the structured partial-apply error with frozen report copies —
496
505
  # a reader must see the state at raise time, not later mutations.
497
- def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
506
+ def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:, skipped: [])
498
507
  ApplyFailedError.new(
499
508
  message, kind: kind,
500
509
  applied: applied.dup.freeze, failed: failed,
501
- pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
510
+ pending: pending.dup.freeze, conflicts: conflicts.dup.freeze,
511
+ skipped: skipped.dup.freeze
502
512
  )
503
513
  end
504
514
 
@@ -166,15 +166,21 @@ module Rubik
166
166
  # annal + wiki derleyicisi. enabled: harness kur/kurulama; distiller:
167
167
  # Düzlem 2 LLM doğumu (bütçe: flush_events x max_steps); model: nil =
168
168
  # root provider; retention: supersede|purge; root_turns: kök-turn annal'ı.
169
+ # auto_digest: goal bitişinde/kapanışta dokunulan konuların hedefli damıtımı (issue #341).
169
170
  "vakanuvis" => {
170
171
  "enabled" => true,
171
172
  "distiller" => true,
173
+ "auto_digest" => true,
172
174
  "max_steps" => 8,
173
175
  "flush_events" => 10,
174
176
  "max_digest_jobs" => 100,
175
177
  "model" => nil,
176
178
  "retention" => "supersede",
177
- "root_turns" => true
179
+ "root_turns" => true,
180
+ # Soğuk-başlangıç ve sıkıştırma sonrası yönelim enjeksiyonu (issue #340):
181
+ # sistem istemi şişirilmeden kök oturuma kısa mem.* özeti eklenir.
182
+ "orientation" => true,
183
+ "orientation_max_topics" => 10
178
184
  },
179
185
  # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi,
180
186
  # DEFAULT ON (kullanıcı kararı 2026-10-10; kill-switch: enabled=false).
@@ -147,6 +147,10 @@ module Rubik
147
147
  @transport&.running? || false
148
148
  end
149
149
 
150
+ def stderr_tail
151
+ @transport.respond_to?(:stderr_tail) ? @transport.stderr_tail : []
152
+ end
153
+
150
154
  private
151
155
 
152
156
  def ensure_running!