rubik 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +54 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +78 -66
  9. data/lib/rubik/config/defaults.rb +1 -0
  10. data/lib/rubik/context/git_lister.rb +3 -1
  11. data/lib/rubik/llm/anthropic.rb +5 -2
  12. data/lib/rubik/llm/openai_compatible.rb +83 -68
  13. data/lib/rubik/lsp/client.rb +320 -96
  14. data/lib/rubik/lsp/manager.rb +83 -12
  15. data/lib/rubik/lsp/server_command.rb +102 -0
  16. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  17. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  18. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  19. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  20. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  21. data/lib/rubik/repl/commands/buffer.rb +122 -88
  22. data/lib/rubik/repl/commands/mcp.rb +120 -92
  23. data/lib/rubik/repl/commands/session.rb +90 -59
  24. data/lib/rubik/repl/commands/workspace.rb +110 -94
  25. data/lib/rubik/repl/shell.rb +10 -1
  26. data/lib/rubik/runtime/discovery.rb +12 -6
  27. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  28. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  29. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  30. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  31. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  32. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  33. data/lib/rubik/version.rb +1 -1
  34. metadata +2 -1
@@ -110,99 +110,124 @@ module Rubik
110
110
  end
111
111
  end
112
112
 
113
+ private_class_method def self.handle_list_models(ctx)
114
+ mode = ctx.config.fetch("router.mode", "auto")
115
+ def_spec = ctx.config.model_spec(:default)
116
+ fast_spec = ctx.config.model_spec(:fast)
117
+ adv_spec = ctx.config.model_spec(:advanced)
118
+
119
+ def_limit = ctx.llm_registry.context_limit_for(def_spec["provider"], def_spec["name"])
120
+ fast_limit = ctx.llm_registry.context_limit_for(fast_spec["provider"], fast_spec["name"])
121
+ adv_limit = ctx.llm_registry.context_limit_for(adv_spec["provider"], adv_spec["name"])
122
+
123
+ def_ep = def_spec["endpoint"] || def_spec["host"] || "cloud"
124
+ fast_ep = fast_spec["endpoint"] || fast_spec["host"] || "cloud"
125
+ adv_ep = adv_spec["endpoint"] || adv_spec["host"] || "cloud"
126
+
127
+ ctx.out.puts "Model Router Configuration (Mode: #{mode}):"
128
+ ctx.out.puts " • default : #{def_spec['provider']}/#{def_spec['name']} [Context: #{def_limit} tokens] (#{def_ep})"
129
+ ctx.out.puts " • fast : #{fast_spec['provider']}/#{fast_spec['name']} [Context: #{fast_limit} tokens] (#{fast_ep})"
130
+ ctx.out.puts " • advanced : #{adv_spec['provider']}/#{adv_spec['name']} [Context: #{adv_limit} tokens] (#{adv_ep})"
131
+ ctx.out.puts "Global Config : #{ctx.config.global_config_path}"
132
+ ctx.out.puts "Project Config : #{ctx.config.project_config_path}"
133
+ end
134
+
135
+ private_class_method def self.handle_route_command(clean_args, ctx, scope)
136
+ mode = clean_args[1].to_s.downcase
137
+ if ["auto", "manual"].include?(mode)
138
+ res = ctx.config.set("router.mode", mode, persist: scope)
139
+ return if Base.report_persist_failure(ctx, res, scope)
140
+
141
+ ctx.out.puts "Router mode set to #{mode} [#{Base.scope_label(scope)}]."
142
+ else
143
+ ctx.out.puts "Usage: !model route <auto|manual>"
144
+ end
145
+ end
146
+
147
+ private_class_method def self.handle_add_model(clean_args, ctx, scope)
148
+ p_name = clean_args[1]&.downcase
149
+ m_name = clean_args[2]
150
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
151
+ ctx.out.puts "Usage: !model add <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
152
+ else
153
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
154
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
155
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
156
+ ctx.refresh_llm_registry!
157
+ if res[:ok]
158
+ ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
159
+ elsif res[:reason] == :unknown_provider
160
+ ctx.out.puts "Provider '#{p_name}' not found. Add provider first via !provider add."
161
+ else
162
+ Base.report_persist_failure(ctx, res, scope)
163
+ end
164
+ end
165
+ end
166
+
167
+ private_class_method def self.handle_context_command(clean_args, ctx, scope)
168
+ if clean_args.size == 2 && clean_args[1] =~ /^\d+$/
169
+ def_spec = ctx.config.model_spec(:default)
170
+ update_context_limit(ctx, scope, def_spec["provider"], def_spec["name"],
171
+ clean_args[1].to_i, "default model #{def_spec['provider']}/#{def_spec['name']}")
172
+ elsif clean_args.size == 3 && ["default", "fast", "advanced"].include?(clean_args[1].downcase) && clean_args[2] =~ /^\d+$/
173
+ role = clean_args[1].downcase.to_sym
174
+ spec = ctx.config.model_spec(role)
175
+ update_context_limit(ctx, scope, spec["provider"], spec["name"],
176
+ clean_args[2].to_i, "#{role} model #{spec['provider']}/#{spec['name']}")
177
+ elsif clean_args.size == 4 && clean_args[3] =~ /^\d+$/
178
+ update_context_limit(ctx, scope, clean_args[1].downcase, clean_args[2],
179
+ clean_args[3].to_i, "#{clean_args[1].downcase}/#{clean_args[2]}")
180
+ else
181
+ ctx.out.puts "Usage: !model context <limit> | !model context <default|fast|advanced> <limit> | " \
182
+ "!model context <provider> <model> <limit> [--project|--global]"
183
+ end
184
+ end
185
+
186
+ private_class_method def self.handle_set_role_command(clean_args, ctx, scope)
187
+ role = clean_args[1].to_s.downcase
188
+ model_args = clean_args[2..]
189
+ if model_args.nil? || model_args.empty?
190
+ ctx.out.puts "Usage: !model set <default|fast|advanced> [<provider>] <model_name> [--project|--global]"
191
+ else
192
+ prov, mod = parse_model_args(model_args, ctx)
193
+ res = ctx.config.set_model_spec(role, provider: prov, name: mod, persist: scope)
194
+ ctx.resync_llm_route!
195
+ return if Base.report_persist_failure(ctx, res, scope)
196
+
197
+ limit = ctx.llm_registry.context_limit_for(prov, mod)
198
+ ctx.out.puts "Set model role '#{role}' to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
199
+ end
200
+ end
201
+
202
+ private_class_method def self.handle_switch_default_model(clean_args, ctx, scope)
203
+ prov, mod = parse_model_args(clean_args, ctx)
204
+ res = ctx.config.set_model_spec(:default, provider: prov, name: mod, persist: scope)
205
+ ctx.resync_llm_route!
206
+ return if Base.report_persist_failure(ctx, res, scope)
207
+
208
+ limit = ctx.llm_registry.context_limit_for(prov, mod)
209
+ ctx.out.puts "Switched default model to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
210
+ end
211
+
113
212
  private_class_method def self.register_model_command(shell)
114
213
  shell.register_command("model",
115
214
  audience: :both,
116
215
  description: "Switch or view LLM provider/model roles (default, fast, advanced) with auto-persistence") do |args, ctx|
117
216
  scope, clean_args = Base.parse_scope(args)
118
-
119
- if clean_args.empty? || clean_args.first.downcase == "list"
120
- mode = ctx.config.fetch("router.mode", "auto")
121
- def_spec = ctx.config.model_spec(:default)
122
- fast_spec = ctx.config.model_spec(:fast)
123
- adv_spec = ctx.config.model_spec(:advanced)
124
-
125
- def_limit = ctx.llm_registry.context_limit_for(def_spec["provider"], def_spec["name"])
126
- fast_limit = ctx.llm_registry.context_limit_for(fast_spec["provider"], fast_spec["name"])
127
- adv_limit = ctx.llm_registry.context_limit_for(adv_spec["provider"], adv_spec["name"])
128
-
129
- def_ep = def_spec["endpoint"] || def_spec["host"] || "cloud"
130
- fast_ep = fast_spec["endpoint"] || fast_spec["host"] || "cloud"
131
- adv_ep = adv_spec["endpoint"] || adv_spec["host"] || "cloud"
132
-
133
- ctx.out.puts "Model Router Configuration (Mode: #{mode}):"
134
- ctx.out.puts " • default : #{def_spec['provider']}/#{def_spec['name']} [Context: #{def_limit} tokens] (#{def_ep})"
135
- ctx.out.puts " • fast : #{fast_spec['provider']}/#{fast_spec['name']} [Context: #{fast_limit} tokens] (#{fast_ep})"
136
- ctx.out.puts " • advanced : #{adv_spec['provider']}/#{adv_spec['name']} [Context: #{adv_limit} tokens] (#{adv_ep})"
137
- ctx.out.puts "Global Config : #{ctx.config.global_config_path}"
138
- ctx.out.puts "Project Config : #{ctx.config.project_config_path}"
139
- elsif clean_args.first.downcase == "route"
140
- mode = clean_args[1].to_s.downcase
141
- if ["auto", "manual"].include?(mode)
142
- res = ctx.config.set("router.mode", mode, persist: scope)
143
- next if Base.report_persist_failure(ctx, res, scope)
144
-
145
- ctx.out.puts "Router mode set to #{mode} [#{Base.scope_label(scope)}]."
146
- else
147
- ctx.out.puts "Usage: !model route <auto|manual>"
148
- end
149
- elsif clean_args.first.downcase == "add"
150
- p_name = clean_args[1]&.downcase
151
- m_name = clean_args[2]
152
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
153
- ctx.out.puts "Usage: !model add <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
154
- else
155
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
156
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
157
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
158
- ctx.refresh_llm_registry!
159
- if res[:ok]
160
- ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
161
- elsif res[:reason] == :unknown_provider
162
- ctx.out.puts "Provider '#{p_name}' not found. Add provider first via !provider add."
163
- else
164
- Base.report_persist_failure(ctx, res, scope)
165
- end
166
- end
167
- elsif ["context", "limit"].include?(clean_args.first.downcase)
168
- if clean_args.size == 2 && clean_args[1] =~ /^\d+$/
169
- def_spec = ctx.config.model_spec(:default)
170
- update_context_limit(ctx, scope, def_spec["provider"], def_spec["name"],
171
- clean_args[1].to_i, "default model #{def_spec['provider']}/#{def_spec['name']}")
172
- elsif clean_args.size == 3 && ["default", "fast", "advanced"].include?(clean_args[1].downcase) && clean_args[2] =~ /^\d+$/
173
- role = clean_args[1].downcase.to_sym
174
- spec = ctx.config.model_spec(role)
175
- update_context_limit(ctx, scope, spec["provider"], spec["name"],
176
- clean_args[2].to_i, "#{role} model #{spec['provider']}/#{spec['name']}")
177
- elsif clean_args.size == 4 && clean_args[3] =~ /^\d+$/
178
- update_context_limit(ctx, scope, clean_args[1].downcase, clean_args[2],
179
- clean_args[3].to_i, "#{clean_args[1].downcase}/#{clean_args[2]}")
180
- else
181
- ctx.out.puts "Usage: !model context <limit> | !model context <default|fast|advanced> <limit> | " \
182
- "!model context <provider> <model> <limit> [--project|--global]"
183
- end
184
- elsif clean_args.first.downcase == "set"
185
- role = clean_args[1].to_s.downcase
186
- model_args = clean_args[2..]
187
- if model_args.nil? || model_args.empty?
188
- ctx.out.puts "Usage: !model set <default|fast|advanced> [<provider>] <model_name> [--project|--global]"
189
- else
190
- prov, mod = parse_model_args(model_args, ctx)
191
- res = ctx.config.set_model_spec(role, provider: prov, name: mod, persist: scope)
192
- ctx.resync_llm_route!
193
- next if Base.report_persist_failure(ctx, res, scope)
194
-
195
- limit = ctx.llm_registry.context_limit_for(prov, mod)
196
- ctx.out.puts "Set model role '#{role}' to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
197
- end
217
+ subcmd = clean_args.first&.downcase
218
+
219
+ if clean_args.empty? || subcmd == "list"
220
+ handle_list_models(ctx)
221
+ elsif subcmd == "route"
222
+ handle_route_command(clean_args, ctx, scope)
223
+ elsif subcmd == "add"
224
+ handle_add_model(clean_args, ctx, scope)
225
+ elsif ["context", "limit"].include?(subcmd)
226
+ handle_context_command(clean_args, ctx, scope)
227
+ elsif subcmd == "set"
228
+ handle_set_role_command(clean_args, ctx, scope)
198
229
  else
199
- prov, mod = parse_model_args(clean_args, ctx)
200
- res = ctx.config.set_model_spec(:default, provider: prov, name: mod, persist: scope)
201
- ctx.resync_llm_route!
202
- next if Base.report_persist_failure(ctx, res, scope)
203
-
204
- limit = ctx.llm_registry.context_limit_for(prov, mod)
205
- ctx.out.puts "Switched default model to #{prov}/#{mod} [Context: #{limit} tokens] [#{Base.scope_label(scope)}]."
230
+ handle_switch_default_model(clean_args, ctx, scope)
206
231
  end
207
232
  end
208
233
  end
@@ -16,6 +16,14 @@ module Rubik
16
16
  USAGE_AUTOAPPLY = "Usage: !autoapply <manual | on_test | on_finish | always | status> [--project|--global]"
17
17
 
18
18
  def self.register(shell)
19
+ register_diff_command(shell)
20
+ register_apply_command(shell)
21
+ register_undo_command(shell)
22
+ register_rollback_command(shell)
23
+ register_autoapply_command(shell)
24
+ end
25
+
26
+ private_class_method def self.register_diff_command(shell)
19
27
  shell.register_command(
20
28
  "diff",
21
29
  description: "Preview unapplied buffer changes (staged edits that are NOT yet on disk). " \
@@ -23,111 +31,137 @@ module Rubik
23
31
  "For ground-truth file content, use read_file; for git history, use run_command with git. " \
24
32
  "(colorized viewer on terminals)",
25
33
  audience: :both
26
- ) do |args, ctx|
27
- theme = ctx.theme
28
- target_path = args.first
29
- if target_path && !target_path.empty?
30
- rel = begin
31
- ctx.buffer_manager.sanitize_path(target_path)
32
- rescue
33
- target_path
34
- end
35
- if ctx.buffer_manager.dirty_files.key?(rel)
36
- full_path = File.expand_path(rel, ctx.buffer_manager.root_dir)
37
- orig_content = File.exist?(full_path) ? File.read(full_path) : ""
38
- new_content = ctx.buffer_manager.read(rel) || ""
39
- Rubik::UI::DiffViewer.render(ctx, theme, Rubik::Buffer::Diff.unified_diff(rel, orig_content, new_content))
40
- else
41
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
42
- end
43
- else
44
- diff_text = ctx.buffer_manager.diff_all
45
- if diff_text.empty?
46
- ctx.out.puts "No unapplied buffer changes."
47
- else
48
- Rubik::UI::DiffViewer.render(ctx, theme, diff_text)
49
- end
50
- end
51
- end
34
+ ) { |args, ctx| handle_diff(args, ctx) }
35
+ end
52
36
 
37
+ private_class_method def self.register_apply_command(shell)
53
38
  shell.register_command("apply", description: "Apply dirty buffer changes to disk") do |args, ctx|
54
- target_path = args.first
55
- begin
56
- if target_path && !target_path.empty?
57
- applied = ctx.buffer_manager.apply!(target_path)
58
- conflicts = ctx.buffer_manager.last_conflicts || []
59
- if applied.empty? && conflicts.empty?
60
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
61
- else
62
- ctx.sync_context_and_ast(applied) unless applied.empty?
63
- ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
64
- end
65
- else
66
- applied = ctx.buffer_manager.apply!
67
- conflicts = ctx.buffer_manager.last_conflicts || []
68
- if applied.empty? && conflicts.empty?
69
- ctx.out.puts "No unapplied buffer changes."
70
- else
71
- ctx.sync_context_and_ast(applied) unless applied.empty?
72
- ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
73
- end
74
- end
75
- conflicts.each do |c|
76
- ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
77
- end
78
- rescue Rubik::Buffer::ApplyFailedError => e
79
- # Partial apply (issue #146): the report names what DID reach
80
- # disk before the failure — a mixed outcome must not be
81
- # presented as a plain success or a plain failure.
82
- lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
83
- ctx.out.puts "❌ #{lines.first}"
84
- lines[1..].each { |line| ctx.out.puts line }
85
- end
39
+ handle_apply(args, ctx)
86
40
  end
41
+ end
87
42
 
43
+ private_class_method def self.register_undo_command(shell)
88
44
  shell.register_command("undo", description: "Revert the last snapshot", audience: :both) do |_args, ctx|
89
- if ctx.snapshot_store.undo(ctx.buffer_manager)
90
- ctx.out.puts "Undo successful."
91
- else
92
- ctx.out.puts "No snapshots to undo."
93
- end
45
+ handle_undo(ctx)
94
46
  end
47
+ end
95
48
 
49
+ private_class_method def self.register_rollback_command(shell)
96
50
  shell.register_command("rollback", description: "Rollback to a specific snapshot ID", usage: USAGE_ROLLBACK, audience: :both) do |args, ctx|
97
- sid = args.first
98
- if sid.nil? || sid.empty?
99
- ctx.out.puts USAGE_ROLLBACK
100
- elsif ctx.snapshot_store.rollback(sid, ctx.buffer_manager)
101
- ctx.out.puts "Rollback to snapshot #{sid} successful."
102
- else
103
- ctx.out.puts "Snapshot #{sid} not found."
104
- end
51
+ handle_rollback(args, ctx)
105
52
  end
53
+ end
106
54
 
55
+ private_class_method def self.register_autoapply_command(shell)
107
56
  shell.register_command("autoapply",
108
57
  description: "Get or set buffer auto-apply policy (manual|on_test|on_finish|always)",
109
58
  usage: USAGE_AUTOAPPLY, audience: :both) do |args, ctx|
110
- scope, clean_args = Base.parse_scope(args)
111
- sub = clean_args.first&.downcase
112
-
113
- if sub.nil? || sub == "status"
114
- cur = ctx.config.fetch("buffer.auto_apply", "manual")
115
- can_m = ctx.config.fetch("buffer.model_can_apply", true)
116
- ctx.out.puts "Buffer Auto-Apply Policy:"
117
- ctx.out.puts " • Mode : #{cur} (manual | on_test | on_finish | always)"
118
- ctx.out.puts " • Model Can Apply : #{can_m}"
119
- ctx.out.puts " • Prompt Indicator : #{ctx.config.fetch('buffer.prompt_indicator', true)}"
120
- ctx.out.puts " • Reminder Interval: #{ctx.config.fetch('buffer.reminder_interval', 3)} turns"
121
- elsif ["manual", "on_test", "on_finish", "always"].include?(sub)
122
- res = ctx.config.set("buffer.auto_apply", sub, persist: scope)
123
- next if Base.report_persist_failure(ctx, res, scope)
124
-
125
- ctx.out.puts "Buffer auto-apply policy set to '#{sub}' [#{Base.scope_label(scope)}]."
59
+ handle_autoapply(args, ctx)
60
+ end
61
+ end
62
+
63
+ private_class_method def self.handle_diff(args, ctx)
64
+ theme = ctx.theme
65
+ target_path = args.first
66
+ if target_path && !target_path.empty?
67
+ rel = begin
68
+ ctx.buffer_manager.sanitize_path(target_path)
69
+ rescue
70
+ target_path
71
+ end
72
+ if ctx.buffer_manager.dirty_files.key?(rel)
73
+ full_path = File.expand_path(rel, ctx.buffer_manager.root_dir)
74
+ orig_content = File.exist?(full_path) ? File.read(full_path) : ""
75
+ new_content = ctx.buffer_manager.read(rel) || ""
76
+ Rubik::UI::DiffViewer.render(ctx, theme, Rubik::Buffer::Diff.unified_diff(rel, orig_content, new_content))
77
+ else
78
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
79
+ end
80
+ else
81
+ diff_text = ctx.buffer_manager.diff_all
82
+ if diff_text.empty?
83
+ ctx.out.puts "No unapplied buffer changes."
126
84
  else
127
- ctx.out.puts USAGE_AUTOAPPLY
85
+ Rubik::UI::DiffViewer.render(ctx, theme, diff_text)
128
86
  end
129
87
  end
130
88
  end
89
+
90
+ private_class_method def self.handle_apply(args, ctx)
91
+ target_path = args.first
92
+ begin
93
+ if target_path && !target_path.empty?
94
+ applied = ctx.buffer_manager.apply!(target_path)
95
+ conflicts = ctx.buffer_manager.last_conflicts || []
96
+ if applied.empty? && conflicts.empty?
97
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
98
+ else
99
+ ctx.sync_context_and_ast(applied) unless applied.empty?
100
+ ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
101
+ end
102
+ else
103
+ applied = ctx.buffer_manager.apply!
104
+ conflicts = ctx.buffer_manager.last_conflicts || []
105
+ if applied.empty? && conflicts.empty?
106
+ ctx.out.puts "No unapplied buffer changes."
107
+ else
108
+ ctx.sync_context_and_ast(applied) unless applied.empty?
109
+ ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
110
+ end
111
+ end
112
+ conflicts.each do |c|
113
+ ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
114
+ end
115
+ rescue Rubik::Buffer::ApplyFailedError => e
116
+ # Partial apply (issue #146): the report names what DID reach
117
+ # disk before the failure — a mixed outcome must not be
118
+ # presented as a plain success or a plain failure.
119
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
120
+ ctx.out.puts "❌ #{lines.first}"
121
+ lines[1..].each { |line| ctx.out.puts line }
122
+ end
123
+ end
124
+
125
+ private_class_method def self.handle_undo(ctx)
126
+ if ctx.snapshot_store.undo(ctx.buffer_manager)
127
+ ctx.out.puts "Undo successful."
128
+ else
129
+ ctx.out.puts "No snapshots to undo."
130
+ end
131
+ end
132
+
133
+ private_class_method def self.handle_rollback(args, ctx)
134
+ sid = args.first
135
+ if sid.nil? || sid.empty?
136
+ ctx.out.puts USAGE_ROLLBACK
137
+ elsif ctx.snapshot_store.rollback(sid, ctx.buffer_manager)
138
+ ctx.out.puts "Rollback to snapshot #{sid} successful."
139
+ else
140
+ ctx.out.puts "Snapshot #{sid} not found."
141
+ end
142
+ end
143
+
144
+ private_class_method def self.handle_autoapply(args, ctx)
145
+ scope, clean_args = Base.parse_scope(args)
146
+ sub = clean_args.first&.downcase
147
+
148
+ if sub.nil? || sub == "status"
149
+ cur = ctx.config.fetch("buffer.auto_apply", "manual")
150
+ can_m = ctx.config.fetch("buffer.model_can_apply", true)
151
+ ctx.out.puts "Buffer Auto-Apply Policy:"
152
+ ctx.out.puts " • Mode : #{cur} (manual | on_test | on_finish | always)"
153
+ ctx.out.puts " • Model Can Apply : #{can_m}"
154
+ ctx.out.puts " • Prompt Indicator : #{ctx.config.fetch('buffer.prompt_indicator', true)}"
155
+ ctx.out.puts " • Reminder Interval: #{ctx.config.fetch('buffer.reminder_interval', 3)} turns"
156
+ elsif ["manual", "on_test", "on_finish", "always"].include?(sub)
157
+ res = ctx.config.set("buffer.auto_apply", sub, persist: scope)
158
+ return if Base.report_persist_failure(ctx, res, scope)
159
+
160
+ ctx.out.puts "Buffer auto-apply policy set to '#{sub}' [#{Base.scope_label(scope)}]."
161
+ else
162
+ ctx.out.puts USAGE_AUTOAPPLY
163
+ end
164
+ end
131
165
  end
132
166
  end
133
167
  end