rubik 0.1.6 → 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +5 -1
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +137 -255
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/ast/extraction_result.rb +37 -0
  7. data/lib/rubik/ast/extractor.rb +35 -9
  8. data/lib/rubik/ast/graph.rb +27 -1
  9. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  10. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  11. data/lib/rubik/buffer/apply_contract.rb +78 -0
  12. data/lib/rubik/buffer/manager.rb +61 -5
  13. data/lib/rubik/cli/workspace_trust.rb +78 -0
  14. data/lib/rubik/concurrency/event_loop.rb +56 -44
  15. data/lib/rubik/concurrency/scheduler.rb +7 -24
  16. data/lib/rubik/context/git_lister.rb +6 -1
  17. data/lib/rubik/context/scanner.rb +15 -3
  18. data/lib/rubik/kernel/init.rb +15 -0
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  21. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  22. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  23. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  24. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  25. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  26. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  27. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  28. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  29. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  30. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  31. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  32. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  33. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  34. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  35. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  36. data/lib/rubik/platform/process.rb +152 -3
  37. data/lib/rubik/plugin/manager.rb +148 -71
  38. data/lib/rubik/plugin/package_inventory.rb +84 -0
  39. data/lib/rubik/plugin/requires_dag.rb +40 -0
  40. data/lib/rubik/plugin/storage.rb +100 -14
  41. data/lib/rubik/prompt/context_builder.rb +54 -20
  42. data/lib/rubik/prompt/doctrine.rb +158 -22
  43. data/lib/rubik/prompt/token_estimator.rb +5 -3
  44. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  45. data/lib/rubik/repl/commands/buffer.rb +28 -18
  46. data/lib/rubik/runtime/repair_probe.rb +98 -0
  47. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  48. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  49. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  50. data/lib/rubik/sandbox/driver/process.rb +12 -64
  51. data/lib/rubik/security/blocked_guidance.rb +19 -0
  52. data/lib/rubik/security/guard.rb +116 -7
  53. data/lib/rubik/storage/store.rb +52 -11
  54. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  55. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  56. data/lib/rubik/tools/builtins/storage.rb +8 -5
  57. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  58. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  59. data/lib/rubik/tools/builtins.rb +1 -0
  60. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  61. data/lib/rubik/version.rb +1 -1
  62. metadata +15 -1
@@ -178,7 +178,8 @@ module Rubik
178
178
  next if records.empty?
179
179
 
180
180
  haystacks = haystacks_for(t, records)
181
- { records: records, haystacks: haystacks,
181
+ folded_fields = folded_fields_for(t, records)
182
+ { records: records, haystacks: haystacks, folded_fields: folded_fields,
182
183
  has: tokens.map { |token| haystacks.map { |h| h.include?(token) } } }
183
184
  end
184
185
  return [] if corpus.empty?
@@ -194,14 +195,17 @@ module Rubik
194
195
  df = dfs[ti]
195
196
  next 0.0 if df.zero?
196
197
 
197
- tf = c[:haystacks][i].scan(token).size
198
+ # PERF: count_occurrences using String#index avoids Array allocation of String#scan
199
+ tf = count_occurrences(c[:haystacks][i], token)
198
200
  next 0.0 if tf.zero?
199
201
 
200
202
  idf = Math.log(((total - df + 0.5) / (df + 0.5)) + 1)
201
203
  dl = c[:haystacks][i].bytesize
202
204
  idf * (tf * (BM25_K1 + 1)) / (tf + (BM25_K1 * (1 - BM25_B + (BM25_B * dl / avgdl))))
203
205
  end
204
- [score + field_tier_bonus(tokens, c[:records][i]), c[:records][i]]
206
+ # PERF: pass cached folded title and tags to avoid String folding & array join allocations per score
207
+ f_title, f_tags = c[:folded_fields][i]
208
+ [score + field_tier_bonus(tokens, f_title, f_tags), c[:records][i]]
205
209
  end
206
210
  end
207
211
  ordered = scored.sort do |(score_a, rec_a), (score_b, rec_b)|
@@ -224,22 +228,44 @@ module Rubik
224
228
  end
225
229
  end
226
230
 
231
+ # PERF: Zero-allocation string occurrence counting via String#index loop.
232
+ # Replaces String#scan(token).size to avoid array allocation per matched term.
233
+ def count_occurrences(haystack, token)
234
+ return 0 if token.empty?
235
+
236
+ count = 0
237
+ offset = 0
238
+ token_len = token.length
239
+ while (idx = haystack.index(token, offset))
240
+ count += 1
241
+ offset = idx + token_len
242
+ end
243
+ count
244
+ end
245
+
227
246
  # Terim başına alan izi: katlanmış başlıkta geçen terim +1.0,
228
247
  # yalnız katlanmış etiketlerde geçen +0.5, gövde izi 0 — bkz.
229
248
  # TITLE_TIER_BONUS/TAGS_TIER_BONUS. Yalnız AND eşiğini geçen
230
249
  # kayıtlar için hesaplanır (ek tarama yok).
231
- def field_tier_bonus(tokens, record)
232
- title = Folding.fold(record["title"])
233
- tags = Folding.fold(Array(record["tags"]).join("\n"))
234
- tokens.sum do |token|
250
+ # PERF: accepts pre-folded title & tags strings or fallback record for backward compatibility.
251
+ def field_tier_bonus(tokens, title_or_record, tags = nil)
252
+ if tags.nil? && title_or_record.is_a?(Hash)
253
+ title = Folding.fold(title_or_record["title"])
254
+ tags = Folding.fold(Array(title_or_record["tags"]).join("\n"))
255
+ else
256
+ title = title_or_record.to_s
257
+ tags = tags.to_s
258
+ end
259
+
260
+ sum = 0.0
261
+ tokens.each do |token|
235
262
  if title.include?(token)
236
- TITLE_TIER_BONUS
263
+ sum += TITLE_TIER_BONUS
237
264
  elsif tags.include?(token)
238
- TAGS_TIER_BONUS
239
- else
240
- 0.0
265
+ sum += TAGS_TIER_BONUS
241
266
  end
242
267
  end
268
+ sum
243
269
  end
244
270
 
245
271
  def set_ref(name, id)
@@ -430,6 +456,21 @@ module Rubik
430
456
  entry[:haystacks] ||= records.map { |r| haystack_of(r) }.freeze
431
457
  end
432
458
 
459
+ # PERF: Memoize folded title and tags per record in @records_cache
460
+ # to avoid re-folding and array joining on every search_ranked query.
461
+ def folded_fields_for(type, records)
462
+ entry = @records_cache[type]
463
+ unless entry
464
+ return records.map do |r|
465
+ [Folding.fold(r["title"]), Folding.fold(Array(r["tags"]).join("\n"))]
466
+ end
467
+ end
468
+
469
+ entry[:folded_fields] ||= records.map do |r|
470
+ [Folding.fold(r["title"]), Folding.fold(Array(r["tags"]).join("\n"))].freeze
471
+ end.freeze
472
+ end
473
+
433
474
  def haystack_of(record)
434
475
  Folding.fold("#{record['title']}\n#{record['body']}\n#{Array(record['tags']).join("\n")}")
435
476
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../output"
5
+ require_relative "../../orchestrator/subagent/profile_contract"
6
+ require_relative "subagent"
7
+
8
+ module Rubik
9
+ module Tools
10
+ # Tool for dynamically defining a new subagent archetype at runtime (Plan 94).
11
+ class DefineSubagentTool < BaseTool
12
+ include SubagentToolHelpers
13
+
14
+ DECLARATION = Declaration.new(
15
+ name: "define_subagent", aliases: ["register_subagent_type", "create_subagent_type"],
16
+ description: "Defines a new archetype of subagent that can be invoked via invoke_subagent.",
17
+ risk: :execute, subagent_spawn: true, maestro_blocked: false, # self-evolution plane (TOOLS.md maestro policy)
18
+ params_schema: {
19
+ type: "object",
20
+ properties: {
21
+ name: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:name),
22
+ description: "Unique name for archetype" },
23
+ description: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:description),
24
+ description: "Human-readable description" },
25
+ system_prompt: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:system_prompt),
26
+ description: "Detailed system prompt" },
27
+ requires: { type: "array", maxItems: Rubik::Orchestrator::Subagent::ProfileContract::MAX_COEFFECTS,
28
+ items: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::MAX_COEFFECT_LENGTH },
29
+ description: "Runtime coeffect keys required by this profile" },
30
+ grants: { type: "array", maxItems: Rubik::Orchestrator::Subagent::ProfileContract::MAX_GRANTS,
31
+ items: { type: "string", enum: Rubik::Orchestrator::Subagent::ProfileContract::CAPABILITY_GRANTS },
32
+ description: "Optional capabilities: write and subagent_spawn (default: none)" },
33
+ replace: { type: "boolean", description: "Explicitly allow replacing an existing dynamic archetype (default: false)" }
34
+ },
35
+ required: ["name", "description", "system_prompt"]
36
+ }
37
+ )
38
+
39
+ def initialize(supervisor: nil, runtime_context: nil)
40
+ super(DECLARATION)
41
+ @supervisor = supervisor
42
+ @runtime_context = runtime_context
43
+ end
44
+
45
+ def execute(args = {})
46
+ sup = resolve_supervisor(@supervisor, @runtime_context)
47
+ return ToolOutput.error("SubagentSupervisor is not available in runtime context.") unless sup
48
+
49
+ argument = ->(key) { args.key?(key.to_s) ? args[key.to_s] : args[key.to_sym] }
50
+ provided = ->(key) { args.key?(key.to_s) || args.key?(key.to_sym) }
51
+
52
+ owner_ctx = @runtime_context || (sup.respond_to?(:root_context) ? sup.root_context : nil)
53
+ kwargs = {
54
+ name: argument.call(:name), description: argument.call(:description),
55
+ system_prompt: argument.call(:system_prompt), replace: argument.call(:replace) || false
56
+ }
57
+ [:requires, :grants, :enable_write_tools, :enable_subagent_tools].each do |key|
58
+ kwargs[key] = argument.call(key) if provided.call(key)
59
+ end
60
+ kwargs[:context] = owner_ctx if owner_ctx
61
+
62
+ type_def = sup.type_registry.define_custom(**kwargs)
63
+ "Subagent archetype '#{type_def.name}' successfully defined and available for invoke_subagent."
64
+ rescue ArgumentError => e
65
+ ToolOutput.error(e.message)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../base"
4
4
  require_relative "../output"
5
5
  require_relative "../../runtime"
6
+ require_relative "../../runtime/repair_probe"
7
+ require_relative "../../buffer/apply_contract"
6
8
 
7
9
  module Rubik
8
10
  module Tools
@@ -33,7 +35,9 @@ module Rubik
33
35
  def execute(args = {})
34
36
  return ToolOutput.error("Buffer manager is not connected.") unless @buffer_manager
35
37
 
36
- if @shell&.config && @shell.config.fetch("buffer.model_can_apply", true) == false
38
+ shell_config = @shell.respond_to?(:config) ? @shell.config : nil
39
+ manager_config = @buffer_manager.respond_to?(:config) ? @buffer_manager.config : nil
40
+ unless Rubik::Buffer::ApplyContract.model_can_apply?(shell_config: shell_config, manager_config: manager_config)
37
41
  return ToolOutput.error("Model buffer application is disabled by configuration " \
38
42
  "(buffer.model_can_apply: false). The human operator will review and apply changes.")
39
43
  end
@@ -43,7 +47,15 @@ module Rubik
43
47
  policy = force ? "overwrite" : nil
44
48
 
45
49
  if @buffer_manager.dirty?
46
- applied = @buffer_manager.apply!(policy: policy)
50
+ applied = nil
51
+ begin
52
+ applied = @buffer_manager.apply!(policy: policy)
53
+ rescue Rubik::Buffer::ApplyFailedError => e
54
+ # Partial apply: files in e.applied ARE on disk — sync the model's
55
+ # view for them and report the remainder honestly (issue #146).
56
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { @shell&.sync_context_and_ast(paths) })
57
+ return ToolOutput.error(lines.join("\n"))
58
+ end
47
59
  conflicts = @buffer_manager.last_conflicts || []
48
60
  @shell&.sync_context_and_ast(applied) unless applied.empty?
49
61
 
@@ -68,30 +80,16 @@ module Rubik
68
80
  messages.join("\n")
69
81
  end
70
82
  else
71
- clean_buffer_message
83
+ Rubik::Buffer::ApplyContract.clean_buffer_message(@buffer_manager.last_applied, auto_apply_mode)
72
84
  end
73
85
  end
74
86
 
75
87
  private
76
88
 
77
- # "Buffer is clean" alone reads as "nothing happened" when an earlier
78
- # apply (apply_buffer itself or the auto-apply policy) already flushed
79
- # the edits to disk. Surface the last successful apply so the model can
80
- # verify against reality instead of re-editing or force-applying
81
- # (issue #97 finding 6). When the mode cannot be read, the origin stays
82
- # deliberately two-sided rather than inventing a specific cause.
83
- def clean_buffer_message
84
- last = @buffer_manager.last_applied
85
- return "No unapplied buffer changes to write (buffer is clean)." unless last.is_a?(Hash) && Array(last[:paths]).any?
86
-
87
- mode = @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch) ? @shell.config.fetch("buffer.auto_apply", "manual") : nil
88
- origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
89
- "the auto-apply policy (buffer.auto_apply: #{mode})"
90
- else
91
- "an earlier apply_buffer call or the auto-apply policy"
92
- end
93
- "No unapplied buffer changes to write (buffer is clean): #{last[:paths].size} file(s) were already written to disk " \
94
- "at #{last[:at].utc.iso8601} by #{origin}: #{last[:paths].join(', ')}."
89
+ def auto_apply_mode
90
+ return nil unless @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch)
91
+
92
+ @shell.config.fetch("buffer.auto_apply", "manual")
95
93
  end
96
94
  end
97
95
 
@@ -117,6 +115,10 @@ module Rubik
117
115
  type: "string",
118
116
  description: "Replacement or custom command string (optional, probed automatically if auto_probe: true)"
119
117
  },
118
+ probe_args: {
119
+ type: "array", items: { type: "string" },
120
+ description: "Explicit non-mutating functional probe arguments appended to the exact proposed command; required for unknown executables"
121
+ },
120
122
  auto_probe: {
121
123
  type: "boolean",
122
124
  description: "Whether to automatically probe system binaries and candidate commands (default: true)"
@@ -187,7 +189,7 @@ module Rubik
187
189
  error_diagnostic = nil
188
190
 
189
191
  begin
190
- verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx)
192
+ verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: args["probe_args"] || args[:probe_args])
191
193
  rescue StandardError => e
192
194
  error_diagnostic = Rubik::Runtime::DiagnosticEngine.diagnose(
193
195
  cmd: proposed_cmd,
@@ -200,66 +202,114 @@ module Rubik
200
202
 
201
203
  return ToolOutput.error("Verification failed for #{target} (#{lang}):\n#{error_diagnostic&.format_for_llm}") unless verified
202
204
 
203
- # 3. Live Context Promotion & Subsystem Configuration (Zero-Downtime Hot Swapping)
204
- case target
205
- when "lsp"
206
- cfg_key = "lsp.servers.#{lang}"
207
- prev_cmd = @config&.fetch(cfg_key)
208
- had_client = @lsp_manager ? !@lsp_manager.client_for(lang).nil? : false
209
- @config&.set(cfg_key, proposed_cmd)
210
- if @lsp_manager
211
- @lsp_manager.stop_client(lang)
212
- start_res = @lsp_manager.start_client(lang)
213
- unless start_res.is_a?(Hash) && start_res[:ok] == true
214
- # Failure Reversibility (AGENTS.md §1.A): roll back the in-memory
215
- # state; disk stays clean because persist happens only after a
216
- # verified start (atomic promotion).
217
- @lsp_manager.stop_client(lang)
218
- @config&.set(cfg_key, prev_cmd)
219
- @lsp_manager.start_client(lang) if had_client
220
- cause = start_res.is_a?(Hash) ? start_res[:error] : "unexpected start_client response"
221
- return ToolOutput.error("Failed to start LSP server for #{lang} ('#{proposed_cmd}'); " \
222
- "previous configuration restored. Cause: #{cause}")
205
+ # Live changes belong to a disposable transaction. Successful project
206
+ # persistence is an operator decision and outlives lifecycle cleanup.
207
+ promotion_ctx = root_ctx.extend
208
+ released = false
209
+ @repair_scopes ||= {}
210
+ cfg_key = { "lsp" => "lsp.servers", "test_runner" => "test.runners",
211
+ "linter" => "linter.commands" }[target]
212
+ cfg_key = "#{cfg_key}.#{lang}" if cfg_key
213
+ prev_cmd = @config&.fetch(cfg_key) if cfg_key
214
+ service_key = "#{{ 'test_runner' => 'runner' }.fetch(target, target)}:#{lang}"
215
+ previous_services = root_ctx.services
216
+ phase = "Promotion"
217
+ begin
218
+ promotion_ctx.on_dispose do
219
+ next if released
220
+
221
+ @repair_scopes.delete([root_ctx, service_key]) if @repair_scopes[[root_ctx, service_key]].equal?(promotion_ctx)
222
+ unless root_ctx.disposed?
223
+ if previous_services.key?(service_key)
224
+ restored = target == "lsp" ? @lsp_manager&.clients&.[](lang) : nil
225
+ root_ctx.provide(service_key, restored || previous_services[service_key])
226
+ else
227
+ root_ctx.unprovide(service_key)
228
+ end
223
229
  end
224
- active_client = @lsp_manager.client_for(lang)
225
- root_ctx.provide("lsp:#{lang}", active_client || proposed_cmd)
226
- else
227
- root_ctx.provide("lsp:#{lang}", proposed_cmd)
228
230
  end
229
- # Persist to project config only after the live promotion succeeded.
230
- persist_res = @config&.set(cfg_key, proposed_cmd, persist: :project)
231
- when "test_runner"
232
- persist_res = @config&.set("test.runners.#{lang}", proposed_cmd, persist: :project)
233
- if @adapter_registry
234
- adapter = @adapter_registry.get(lang)
235
- if adapter
236
- adapter.config ||= @config
237
- adapter.custom_test_cmd = proposed_cmd
238
- else
239
- require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
240
- new_adapter = Rubik::Adapter::GenericAdapter.new(lang, test_cmd: proposed_cmd, guard: @guard)
241
- new_adapter.config = @config
242
- @adapter_registry.register(lang, new_adapter, ctx: root_ctx)
231
+ if cfg_key && @config
232
+ promotion_ctx.on_dispose do
233
+ next if released
234
+
235
+ prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
243
236
  end
237
+ @config.set(cfg_key, proposed_cmd)
244
238
  end
245
- root_ctx.provide("runner:#{lang}", proposed_cmd)
246
- when "linter"
247
- persist_res = @config&.set("linter.commands.#{lang}", proposed_cmd, persist: :project)
248
- if @adapter_registry
249
- adapter = @adapter_registry.get(lang)
250
- if adapter
251
- adapter.config ||= @config
252
- adapter.custom_format_cmd = proposed_cmd
253
- else
254
- require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
255
- new_adapter = Rubik::Adapter::GenericAdapter.new(lang, format_cmd: proposed_cmd, guard: @guard)
256
- new_adapter.config = @config
257
- @adapter_registry.register(lang, new_adapter, ctx: root_ctx)
239
+
240
+ case target
241
+ when "lsp"
242
+ raise "LSP manager and config are required for live activation" unless @lsp_manager && @config
243
+
244
+ had_client = !@lsp_manager.clients[lang].nil?
245
+ # The manager owns its client table. Restore the old configured
246
+ # client on rollback; probe clients never enter that table.
247
+ promotion_ctx.on_dispose do
248
+ next if released
249
+
250
+ @lsp_manager.stop_client(lang)
251
+ prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
252
+ if had_client && !root_ctx.disposed?
253
+ restored = @lsp_manager.start_client(lang)
254
+ raise "Previous LSP client could not be restarted: #{restored}" unless restored[:ok]
255
+ end
256
+ end
257
+ @lsp_manager.stop_client(lang)
258
+ start_res = @lsp_manager.start_client(lang)
259
+ raise "Failed to start LSP server: #{start_res}" unless start_res.is_a?(Hash) && start_res[:ok]
260
+
261
+ service = @lsp_manager.clients[lang]
262
+ raise "LSP manager reported success without an active client" unless service
263
+ when "test_runner", "linter"
264
+ if @adapter_registry
265
+ adapter = @adapter_registry.get(lang)
266
+ if adapter
267
+ field = target == "test_runner" ? :custom_test_cmd : :custom_format_cmd
268
+ old_value = adapter.public_send(field)
269
+ old_config = adapter.config
270
+ promotion_ctx.on_dispose do
271
+ next if released
272
+
273
+ adapter.public_send("#{field}=", old_value)
274
+ adapter.config = old_config
275
+ end
276
+ adapter.config ||= @config
277
+ adapter.public_send("#{field}=", proposed_cmd)
278
+ else
279
+ require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
280
+ options = target == "test_runner" ? { test_cmd: proposed_cmd } : { format_cmd: proposed_cmd }
281
+ adapter = Rubik::Adapter::GenericAdapter.new(lang, **options, guard: @guard)
282
+ adapter.config = @config
283
+ promotion_ctx.on_dispose do
284
+ @adapter_registry.unregister(lang) if !released && @adapter_registry.get(lang).equal?(adapter)
285
+ end
286
+ @adapter_registry.register(lang, adapter)
287
+ end
258
288
  end
289
+ service = proposed_cmd
290
+ when "command"
291
+ service = proposed_cmd
259
292
  end
260
- root_ctx.provide("linter:#{lang}", proposed_cmd)
261
- when "command"
262
- root_ctx.provide("command:#{lang}", proposed_cmd)
293
+
294
+ root_ctx.provide(service_key, service)
295
+ phase = "Persistence"
296
+ persist_res = @config.set(cfg_key, proposed_cmd, persist: :project) if cfg_key && @config
297
+ raise persist_res[:error].to_s if persist_res.is_a?(Hash) && !persist_res[:ok]
298
+ rescue StandardError => e
299
+ promotion_ctx.dispose
300
+ rollback_errors = promotion_ctx.last_dispose_errors
301
+ detail = rollback_errors.empty? ? "previous session state restored" : "rollback incomplete: #{rollback_errors}"
302
+ return ToolOutput.error("#{phase} failed for #{target} (#{lang}): #{e.message}; #{detail}")
303
+ end
304
+
305
+ # Keep one lifetime inverse per repaired key, preserving the original
306
+ # baseline across repeated repairs without retaining every transaction.
307
+ scope_key = [root_ctx, service_key]
308
+ if @repair_scopes.key?(scope_key)
309
+ released = true
310
+ promotion_ctx.dispose
311
+ else
312
+ @repair_scopes[scope_key] = promotion_ctx
263
313
  end
264
314
 
265
315
  if @config.nil? && ["lsp", "test_runner", "linter"].include?(target)
@@ -267,71 +317,14 @@ module Rubik
267
317
  "so the command is active for this session only and was not saved."
268
318
  end
269
319
 
270
- if persist_res.is_a?(Hash) && !persist_res[:ok]
271
- return "⚠️ Promoted '#{proposed_cmd}' for #{target} (#{lang}) — active in this session, " \
272
- "but saving to the project config failed: #{persist_res[:error]}"
273
- end
274
-
275
320
  "✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{proposed_cmd}'. " \
276
- "Service is now active and hot-swapped in the live context with zero downtime."
321
+ "Service is now active in the live context."
277
322
  end
278
323
 
279
324
  private
280
325
 
281
- def verify_binary(target, lang, proposed_cmd, sub_ctx)
282
- case target
283
- when "lsp"
284
- client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
285
- if client.available?
286
- sub_ctx.provide("lsp_probe:#{lang}", client)
287
- [true, nil]
288
- else
289
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
290
- cmd: proposed_cmd,
291
- exit_code: 127,
292
- stderr: "Pre-flight check failed: LSP binary '#{client.binary_name}' not available on system"
293
- )]
294
- end
295
- when "test_runner"
296
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
297
- sub_ctx.provide("runner_probe:#{lang}", proposed_cmd)
298
- [true, nil]
299
- else
300
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
301
- cmd: proposed_cmd,
302
- exit_code: 127,
303
- stderr: "Pre-flight check failed: Test runner executable not found in PATH"
304
- )]
305
- end
306
- when "linter"
307
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
308
- sub_ctx.provide("linter_probe:#{lang}", proposed_cmd)
309
- [true, nil]
310
- else
311
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
312
- cmd: proposed_cmd,
313
- exit_code: 127,
314
- stderr: "Pre-flight check failed: Linter/formatter executable not found in PATH"
315
- )]
316
- end
317
- when "command"
318
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
319
- sub_ctx.provide("command_probe:#{lang}", proposed_cmd)
320
- [true, nil]
321
- else
322
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
323
- cmd: proposed_cmd,
324
- exit_code: 127,
325
- stderr: "Pre-flight check failed: Command executable not found in PATH"
326
- )]
327
- end
328
- else
329
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
330
- cmd: proposed_cmd,
331
- exit_code: 1,
332
- stderr: "Unknown target component type: #{target}"
333
- )]
334
- end
326
+ def verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
327
+ Rubik::Runtime::RepairProbe.new(root_dir: @root_dir).verify(target, lang, proposed_cmd, sub_ctx, probe_args: probe_args)
335
328
  end
336
329
  end
337
330
 
@@ -24,9 +24,12 @@ module Rubik
24
24
  DECLARATION = Declaration.new(
25
25
  name: "storage", aliases: ["rubik_storage"],
26
26
  description: "Central rubik://storage database for goals, plans, memory, wiki, transcripts, reviews, " \
27
- "and notes. Actions: 'put' (create/update; returns id rubik://storage URI), 'get', 'list', " \
28
- "'search', 'set_ref' (alias), 'delete'. Keep agent workfiles HERE — not in the worktree. " \
29
- "Read back any entity with read_file path 'rubik://storage/<type>/<id>'.",
27
+ "and notes. Actions: 'put' (create/update work artifacts — goal, plan, review, note; " \
28
+ "returns id and rubik://storage URI), 'get', 'list', 'search', 'set_ref' (alias), 'delete'. " \
29
+ "When `vakanuvis` is in your toolbelt, write durable `memory` via `vakanuvis(action: 'record')` " \
30
+ "and compiled `wiki` via `vakanuvis(action: 'digest')`, reserving direct `put` on `memory`/`wiki` " \
31
+ "for roles or sessions where `vakanuvis` is unavailable. Keep agent workfiles HERE — not in " \
32
+ "the worktree. Read back any entity with read_file path 'rubik://storage/<type>/<id>'.",
30
33
  risk: :write, archetypes: [:coder], write_grant: true,
31
34
  maestro_blocked: false, # storage plane stays direct at root (TOOLS.md maestro policy)
32
35
  params_schema: {
@@ -35,12 +38,12 @@ module Rubik
35
38
  action: {
36
39
  type: "string",
37
40
  enum: ["put", "get", "list", "search", "set_ref", "delete"],
38
- description: "Operation (default: 'list')."
41
+ description: "Operation (default: 'list'). Use 'put' for goal/plan/review/note workfiles (or fallback writes when vakanuvis is unavailable)."
39
42
  },
40
43
  type: {
41
44
  type: "string",
42
45
  enum: Rubik::Storage::Store::TYPES,
43
- description: "Entity type: goal, plan, memory, wiki, transcript, review, or note."
46
+ description: "Entity type: goal, plan, memory, wiki, transcript, review, or note (prefer vakanuvis record/digest for memory/wiki writes when available)."
44
47
  },
45
48
  title: { type: "string", description: "Entity title (required for put)." },
46
49
  body: { type: "string", description: "Entity body (markdown text)." },
@@ -101,20 +101,32 @@ module Rubik
101
101
  return ToolOutput.error("Failed to invoke subagent(s): #{e.message}")
102
102
  end
103
103
 
104
- launched, queued = results.partition { |outcome, _obj| outcome == :launched }
105
- lines = ["Launched #{launched.size} background subagent(s):"] +
106
- launched.map do |_o, i|
107
- " • [#{i.id}] '#{i.role}' (#{i.type_name}) - #{i.state.to_s.upcase} | transcript: #{i.transcript_ref}"
108
- end
104
+ launched, remaining = results.partition { |outcome, _obj| outcome == :launched }
105
+ queued, failed = remaining.partition { |outcome, _obj| outcome == :queued }
106
+ lines = launched.empty? ? ["No subagents launched."] : ["Launched #{launched.size} background subagent(s):"]
107
+ lines.concat(launched.map do |_o, i|
108
+ " • [#{i.id}] '#{i.role}' (#{i.type_name}) - #{i.state.to_s.upcase} | transcript: #{i.transcript_ref}"
109
+ end)
109
110
  unless queued.empty?
110
111
  rows = queued.map { |_o, s| " • [#{s.id}] '#{s.role}' - QUEUED (waiting on: #{s.depends_on.join(', ')})" }
111
112
  lines << ("\nQueued #{queued.size} subagent(s) on unmet dependencies (no slot, no cost until released): " + rows.join("\n"))
112
113
  end
114
+ unless failed.empty?
115
+ rows = failed.map do |_outcome, spec|
116
+ reason = spec.respond_to?(:last_error) && spec.last_error
117
+ reason = "cancelled before launch" if reason.to_s.empty?
118
+ " • [#{spec.id}] '#{spec.role}' - FAILED TO START (#{reason})"
119
+ end
120
+ lines << ("\n#{failed.size} subagent(s) failed to start: " + rows.join("\n"))
121
+ end
113
122
  all_ids = (launched + queued).map { |_o, obj| obj.id }
114
123
 
115
- lines << "\nSubagents run asynchronously in the background. Conclude your turn to yield the prompt to the user, or call " \
116
- "#{Rubik::Orchestrator::Subagent::Notices.wait_hint(all_ids)} only if you strictly need results in this turn (queued ids block until the chain settles)."
117
- lines.join("\n")
124
+ unless all_ids.empty?
125
+ lines << "\nSubagents run asynchronously in the background. Conclude your turn to yield the prompt to the user, or call " \
126
+ "#{Rubik::Orchestrator::Subagent::Notices.wait_hint(all_ids)} only if you strictly need results in this turn (queued ids block until the chain settles)."
127
+ end
128
+ result = lines.join("\n")
129
+ failed.empty? ? result : ToolOutput.error(result)
118
130
  end
119
131
 
120
132
  private
@@ -315,50 +327,5 @@ module Rubik
315
327
  "Result: #{res[0, 1500]}... [truncated — full result: #{more}]"
316
328
  end
317
329
  end
318
-
319
- # Tool for dynamically defining a new subagent archetype at runtime (Plan 94).
320
- class DefineSubagentTool < BaseTool
321
- include SubagentToolHelpers
322
-
323
- DECLARATION = Declaration.new(
324
- name: "define_subagent", aliases: ["register_subagent_type", "create_subagent_type"],
325
- description: "Defines a new archetype of subagent that can be invoked via invoke_subagent.",
326
- risk: :execute, subagent_spawn: true, maestro_blocked: false, # self-evolution plane (TOOLS.md maestro policy)
327
- params_schema: {
328
- type: "object",
329
- properties: {
330
- name: { type: "string", description: "Unique name for archetype" },
331
- description: { type: "string", description: "Human-readable description" },
332
- system_prompt: { type: "string", description: "Detailed system prompt" },
333
- enable_write_tools: { type: "boolean", description: "Equip subagent with write tools (default: false)" },
334
- enable_subagent_tools: { type: "boolean", description: "Allow subagent to spawn subagents (default: false)" }
335
- },
336
- required: ["name", "description", "system_prompt"]
337
- }
338
- )
339
- def initialize(supervisor: nil, runtime_context: nil)
340
- super(DECLARATION)
341
- @supervisor = supervisor
342
- @runtime_context = runtime_context
343
- end
344
-
345
- def execute(args = {})
346
- sup = resolve_supervisor(@supervisor, @runtime_context)
347
- return ToolOutput.error("SubagentSupervisor is not available in runtime context.") unless sup
348
-
349
- name = (args["name"] || args[:name]).to_s.strip
350
- desc = (args["description"] || args[:description]).to_s.strip
351
- prompt = (args["system_prompt"] || args[:system_prompt]).to_s.strip
352
- return ToolOutput.error("Parameters 'name', 'description', and 'system_prompt' are required.") if name.empty? || desc.empty? || prompt.empty?
353
-
354
- type_def = sup.type_registry.define_custom(
355
- name: name, description: desc, system_prompt: prompt,
356
- enable_write_tools: args["enable_write_tools"] || args[:enable_write_tools] || false,
357
- enable_subagent_tools: args["enable_subagent_tools"] || args[:enable_subagent_tools] || false
358
- )
359
-
360
- "Subagent archetype '#{type_def.name}' successfully defined and available for invoke_subagent."
361
- end
362
- end
363
330
  end
364
331
  end
@@ -176,7 +176,12 @@ module Rubik
176
176
  description: "Storage scope: session (in-memory only), project (.rubik/tools), global (~/.rubik/tools)" },
177
177
  risk: { type: "string", enum: ["read", "write", "execute"], default: "execute",
178
178
  description: "Risk level of tool (optional; read/write/execute, default: execute)" },
179
- params_schema: { type: "object", description: "JSON Schema definition of the tool parameters" },
179
+ params_schema: {
180
+ type: "object",
181
+ description: "JSON Schema definition of the tool parameters (provide properties/required when the tool " \
182
+ "accepts arguments; defaults to an empty object schema `{ type: 'object', properties: {} }` " \
183
+ "for tools that take no arguments)"
184
+ },
180
185
  code: { type: "string", description: "Ruby implementation of tool handler: ->(args) { ... } or class extending Rubik::Tools::BaseTool" },
181
186
  test_args: { type: "object", description: "Optional test arguments to perform pre-flight verification in an isolated sandbox" }
182
187
  },