samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -2,14 +2,19 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "cancellation_controller"
6
+ require_relative "log"
5
7
  require_relative "message_time"
6
8
  require_relative "steer"
7
9
  require_relative "system_one/question"
10
+ require_relative "toolkit"
8
11
 
9
12
  module Samagotchi
10
- # The first-prompt toolkit classifier and its append-only audit record.
11
- # The Engine prepares a Snapshot on its turn thread; #ask only sees that
12
- # copy, so the question cannot change the running session or tool registry.
13
+ # The first-prompt toolkit classifier and its append-only record.
14
+ # The Engine prepares a Snapshot on its turn thread; #judge only sees that
15
+ # copy, so the question cannot change the running session or tool
16
+ # registry. On mode's turn applies the answer itself (Pending#wait,
17
+ # ToolkitBinding#suggest).
13
18
  class ToolkitSuggest
14
19
  FILE = "toolkit_suggestions.jsonl"
15
20
  QUESTION_ID = "toolkit"
@@ -24,6 +29,10 @@ module Samagotchi
24
29
  # built-ins registered when the ask started.
25
30
  Snapshot = Data.define(:session_id, :turn_number, :default, :candidates, :core_tools, :state)
26
31
  Suggestion = Data.define(:kit, :raw, :confidence, :decided)
32
+ # One ask's outcome: its Suggestion, what on mode would do with it
33
+ # (suggested, default, defer, below_threshold, invalid, no_judgment)
34
+ # and its log line's fields.
35
+ Judged = Data.define(:suggestion, :would, :fields)
27
36
 
28
37
  # The candidates with classifier descriptions, excluding the configured
29
38
  # default, the participants' review boundary, the defer sentinel, and a
@@ -56,8 +65,8 @@ module Samagotchi
56
65
  [question, { project: project.to_s, prompt: prompt.to_s[0, PROMPT_MAX] }]
57
66
  end
58
67
 
59
- # The single turn eligibility predicate shared by audit and future on
60
- # mode. A later prompt is eligible only inside an undecided defer window.
68
+ # The single turn eligibility predicate shared by audit and on mode.
69
+ # A later prompt is eligible only inside an undecided defer window.
61
70
  # +candidate_count+ and +last_suggestion+ may be callables: they are
62
71
  # read only once the session's own clauses pass (the kits read memory
63
72
  # files; the suggestion log is read whole), the log only on the defer
@@ -70,7 +79,7 @@ module Samagotchi
70
79
  return false if value(candidate_count).to_i < 2
71
80
 
72
81
  messages = Array(session.messages)
73
- return true unless messages.any? { |message| field(message, :role).to_s == "model" }
82
+ return true unless model_reply?(messages)
74
83
  return false unless field(value(last_suggestion), :raw) == "undecided"
75
84
 
76
85
  current_turn_number = turn_number(messages)
@@ -108,15 +117,27 @@ module Samagotchi
108
117
  end
109
118
  end
110
119
 
111
- # Runs one already-prepared ask, then appends its raw pick, what on mode
112
- # would have done, and every offered kit's #rules (the default's too),
113
- # so the report can tell a narrower kit that would also have covered
114
- # the turn. A missing judgment is recorded too.
115
- def self.ask(client:, log:, snapshot:, threshold:)
120
+ # Runs one already-prepared ask and appends its line (audit's thread).
121
+ # @return [Suggestion]
122
+ def self.ask(client:, log:, snapshot:, threshold:, cancel_controller: nil)
123
+ judged = judge(client: client, snapshot: snapshot, threshold: threshold, cancel_controller: cancel_controller)
124
+ record(log, judged)
125
+ judged.suggestion
126
+ end
127
+
128
+ # Runs one already-prepared ask: its raw pick, what on mode would do
129
+ # with it, and the log line's fields (every offered kit's #rules, the
130
+ # default's too, so the report can tell a narrower kit that would also
131
+ # have covered the turn). A missing judgment (Client#ask's nil) is a
132
+ # Judged too, would "no_judgment".
133
+ # @raise [LLM::RequestCancelled] the turn stopped while it was asked
134
+ # @return [Judged]
135
+ def self.judge(client:, snapshot:, threshold:, cancel_controller: nil)
116
136
  question, = question(default: Toolkit.new(name: snapshot.default.name),
117
137
  kits: snapshot.candidates.to_h { |candidate| [candidate.name, kit_for(candidate)] },
118
138
  project: snapshot.state.fetch(:project), prompt: snapshot.state.fetch(:prompt))
119
- answer = client.ask(state: snapshot.state, questions: { QUESTION_ID => question })&.fetch(QUESTION_ID, nil)
139
+ answer = client.ask(state: snapshot.state, questions: { QUESTION_ID => question },
140
+ cancel_controller: cancel_controller)&.fetch(QUESTION_ID, nil)
120
141
  raw = answer&.choice
121
142
  candidate_name = raw.to_s.delete_prefix("kit:")
122
143
  candidate = snapshot.candidates.find { |item| item.name == candidate_name } if raw.to_s.start_with?("kit:")
@@ -140,7 +161,7 @@ module Samagotchi
140
161
  else
141
162
  "suggested"
142
163
  end
143
- log.entry("suggestion", {
164
+ fields = {
144
165
  session_id: snapshot.session_id,
145
166
  turn_number: snapshot.turn_number,
146
167
  default_kit: snapshot.default.name,
@@ -151,8 +172,153 @@ module Samagotchi
151
172
  would: would,
152
173
  core_tools: snapshot.core_tools,
153
174
  kits: [snapshot.default, *snapshot.candidates].to_h { |kit| [kit.name, kit.rules] }
154
- })
155
- Suggestion.new(kit: selected, raw: raw, confidence: confidence, decided: !!decided)
175
+ }
176
+ Judged.new(suggestion: Suggestion.new(kit: selected, raw: raw, confidence: confidence, decided: !!decided),
177
+ would: would, fields: fields)
178
+ end
179
+
180
+ # One line in the suggestion log. +did+ (on mode only) is what the
181
+ # turn did with it: suggested (the session switched), default, defer,
182
+ # chosen (the user picked a toolkit first), timeout or canceled (the
183
+ # turn stopped waiting; the line is written when the ask ends).
184
+ def self.record(log, judged, did: nil)
185
+ log.entry("suggestion", did.nil? ? judged.fields : judged.fields.merge(did: did))
186
+ end
187
+
188
+ # Whether the session has a model reply: a later prompt's ask is the
189
+ # defer path, and on mode's switch applies "from this turn".
190
+ def self.model_reply?(messages)
191
+ Array(messages).any? { |message| field(message, :role).to_s == "model" }
192
+ end
193
+
194
+ # An ask's hand-over between its thread and the turn. Audit mode never
195
+ # waits (+joined+ false): the thread records its line itself. On mode's
196
+ # turn waits (#wait) until the answer, a Stop or the deadline (System
197
+ # 1's timeout from the ask's start), and records what it did with the
198
+ # answer (#record); a turn that stopped waiting leaves the line to the
199
+ # thread, which writes it with why (timeout, canceled) when the ask
200
+ # ends.
201
+ #
202
+ # The request watches the ask's own controller (#cancel_controller),
203
+ # cut only by a Stop the turn sees before it settled (#wait, #abandon
204
+ # :canceled): a Stop later in a turn that went on after a timeout
205
+ # leaves the late ask (and its timeout line) alone.
206
+ class Pending
207
+ POLL_SECONDS = 0.05
208
+
209
+ # @return [CancellationController] what the ask's request watches
210
+ attr_reader :cancel_controller
211
+
212
+ def initialize(log:, timeout_seconds:, joined:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
213
+ @log = log
214
+ @clock = clock
215
+ @deadline = clock.call + timeout_seconds.to_f
216
+ @joined = joined
217
+ @cancel_controller = CancellationController.new
218
+ @mutex = Mutex.new
219
+ @done = ConditionVariable.new
220
+ @finished = false
221
+ @recorded = false
222
+ @settled = false
223
+ @judged = nil
224
+ @gave_up = nil
225
+ end
226
+
227
+ # On the ask's thread, always (an ensure): its Judged, nil when it
228
+ # was cut (a Stop) or raised.
229
+ def finish(judged)
230
+ @mutex.synchronize do
231
+ @finished = true
232
+ @judged = judged
233
+ if !@joined
234
+ write(nil)
235
+ elsif @gave_up
236
+ write(@gave_up)
237
+ end
238
+ @done.broadcast
239
+ end
240
+ end
241
+
242
+ # On the turn thread: [:answered, Judged or nil] once the ask ended,
243
+ # [:canceled, nil] when +controller+ is cancelled (first: a Stop wins
244
+ # over an answer already in), [:timeout, nil] at the deadline. It
245
+ # waits in short slices, so a Stop is seen at once.
246
+ def wait(controller)
247
+ @mutex.synchronize do
248
+ loop do
249
+ return give_up(:canceled) if controller&.cancelled?
250
+ return [:answered, @judged] if @finished
251
+
252
+ remaining = @deadline - @clock.call
253
+ return give_up(:timeout) unless remaining.positive?
254
+
255
+ @done.wait(@mutex, [remaining, POLL_SECONDS].min)
256
+ end
257
+ end
258
+ end
259
+
260
+ # The answered ask's line, with what the turn did.
261
+ def record(did)
262
+ @mutex.synchronize do
263
+ @settled = true
264
+ write(did.to_s)
265
+ end
266
+ end
267
+
268
+ # The turn ended before it settled the ask (a Stop before or after
269
+ # the wait, a failure, an Interrupt): its line says +reason+, now or
270
+ # when the ask ends. Nothing once the turn settled it (#record, a
271
+ # timeout or Stop in #wait), so prepare_turn calls it from an ensure.
272
+ def abandon(reason)
273
+ @mutex.synchronize do
274
+ next if @settled
275
+
276
+ if @finished
277
+ @settled = true
278
+ write(reason.to_s)
279
+ else
280
+ give_up(reason.to_sym)
281
+ end
282
+ end
283
+ end
284
+
285
+ private
286
+
287
+ def give_up(reason)
288
+ @settled = true
289
+ @gave_up = reason.to_s
290
+ @cancel_controller.cancel!(:manual) if reason == :canceled
291
+ # An answer already in (a Stop wins over it): its line now.
292
+ write(@gave_up) if @finished
293
+ [reason, nil]
294
+ end
295
+
296
+ # Once: an ask that ended with nothing (cut, raised) has no line.
297
+ def write(did)
298
+ return if @recorded || @judged.nil?
299
+
300
+ @recorded = true
301
+ ToolkitSuggest.record(@log, @judged, did: did)
302
+ end
303
+ end
304
+
305
+ # The warm-up's stand-in prompt: the question (instruction + criteria)
306
+ # is the real one, so System 1's server has that prefix cached when
307
+ # the first real prompt is asked.
308
+ WARMUP_PROMPT = "hello"
309
+
310
+ # One ask with the real question and a stand-in prompt, its answer
311
+ # dropped: on mode's warm-up as an idle worker starts (Engine
312
+ # #warm_toolkit_suggestion!). Straight to the backend, so a failure is
313
+ # one info line, never Client#ask's warn, and nothing is logged as a
314
+ # suggestion.
315
+ def self.warm(client:, default:, kits:, project:)
316
+ question, state = question(default: default, kits: kits, project: project, prompt: WARMUP_PROMPT)
317
+ client.backend.ask(state: state, questions: { QUESTION_ID => question })
318
+ nil
319
+ rescue StandardError => e
320
+ Log.info(:toolkit, "toolkit_suggest_warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
321
+ nil
156
322
  end
157
323
 
158
324
  # A resolved kit (Toolkit::Resolved) as frozen, JSON-ready rules, not
@@ -25,6 +25,7 @@ require_relative "delegate_result"
25
25
  require_relative "ask_user_question"
26
26
  require_relative "forget_outputs"
27
27
  require_relative "../guardrails/parent_approvals"
28
+ require_relative "../guardrails/pair_boundary"
28
29
  require_relative "../model_profile"
29
30
 
30
31
  module Samagotchi
@@ -89,7 +90,7 @@ module Samagotchi
89
90
  end,
90
91
  Execute::NAME => lambda do |call, kctx|
91
92
  Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
92
- env: parent_env(kctx))
93
+ **participant_execute(kctx, parent_env(kctx)))
93
94
  end,
94
95
  RegisterReminder::NAME => lambda do |call, kctx|
95
96
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
@@ -143,6 +144,18 @@ module Samagotchi
143
144
  SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env)).merge(xdg_absolute(env))
144
145
  end
145
146
 
147
+ # execute's env: and on_timeout: for the running turn. A participant's
148
+ # turn runs without the variables whose names hold a secret
149
+ # (Guardrails::PairBoundary.scrubbed_env), and a command still running
150
+ # at the limit is killed, never handed over as a background task that
151
+ # would outlive their turn; the owner's turns as configured.
152
+ def participant_execute(kctx, env)
153
+ mode = kctx.respond_to?(:participant_mode) ? kctx.participant_mode : nil
154
+ return { env: env } unless mode
155
+
156
+ { env: env.merge(Guardrails::PairBoundary.scrubbed_env), on_timeout: :kill }
157
+ end
158
+
146
159
  # The XDG dirs set in +env+, made absolute (nothing for an unset one).
147
160
  def xdg_absolute(env = ENV)
148
161
  Paths.absolute_xdg(env: env)
@@ -12,6 +12,7 @@ require_relative "memory_bundle/system_bundle"
12
12
  require_relative "session"
13
13
  require_relative "version"
14
14
  require_relative "web/lan"
15
+ require "json"
15
16
 
16
17
  module Samagotchi
17
18
  # `chi update`: brings an installed chi up to date in one go: the system
@@ -24,13 +25,15 @@ module Samagotchi
24
25
  include CLI::Command
25
26
 
26
27
  USAGE = <<~TEXT
27
- Usage: chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]
28
+ Usage: chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]
28
29
  Updates this chi's install: the gem (from rubygems.org), then the
29
30
  system bundle, the bundles chi ships that you installed (chi bundle
30
31
  list), and the desktop helper (macOS; rebuilt only when its sources
31
32
  changed). Edited memory files are kept and reported; nothing is
32
33
  installed that isn't already, and old gem versions stay.
33
34
  --dry-run show what would change; change nothing
35
+ --to VERSION install exactly this gem version (must be newer than this chi)
36
+ --json print update rows as one JSON document
34
37
  --no-gem don't install a newer gem (config update.gem: false)
35
38
  --no-bundles leave the shipped bundles alone (config update.bundles: false)
36
39
  --no-desktop leave the desktop helper alone (config update.desktop: false)
@@ -44,6 +47,8 @@ module Samagotchi
44
47
  FLAGS = CLI::Flags.new(args: false) do |f|
45
48
  f.switch "-h", "--help", "help"
46
49
  f.switch "--dry-run"
50
+ f.value "--to"
51
+ f.switch "--json"
47
52
  f.switch "--no-gem"
48
53
  f.switch "--no-bundles"
49
54
  f.switch "--no-desktop"
@@ -54,11 +59,11 @@ module Samagotchi
54
59
  end
55
60
  # The switches the newer gem's update gets again (hand_over).
56
61
  HANDED_OVER = { "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
57
- "--no-register" => :no_register }.freeze
62
+ "--no-register" => :no_register, "--to" => :to, "--json" => :json }.freeze
58
63
 
59
64
  # A table row. info rows (live workers, chi web) say what runs; they
60
65
  # never fail.
61
- Row = Struct.new(:component, :from, :to, :status, :failed, keyword_init: true)
66
+ Row = Struct.new(:component, :from, :to, :status, :failed, :kept, keyword_init: true)
62
67
 
63
68
  # What a row says after a change, and in a dry run.
64
69
  VERBS = { update: ["updated", "would update"], install: ["installed", "would install"],
@@ -95,6 +100,13 @@ module Samagotchi
95
100
  @stdout.puts(USAGE)
96
101
  return 0
97
102
  end
103
+ return usage_error("invalid version #{options[:to]}") if options[:to] && !Gem::Version.correct?(options[:to])
104
+
105
+ if options[:to] && !options[:gem_from] && Gem::Version.new(options[:to]) <= Gem::Version.new(VERSION)
106
+ @stderr.puts("chi update: #{options[:to]} is not newer than the running chi (#{VERSION})")
107
+ return 1
108
+ end
109
+
98
110
  unless @gem_spec
99
111
  @stderr.puts("chi update: running from a checkout (#{InstalledGem::ROOT}): chi update updates an installed chi; " \
100
112
  "git pull, or chi bundle upgrade NAME for one bundle")
@@ -104,9 +116,13 @@ module Samagotchi
104
116
  @dry_run = options[:dry_run]
105
117
  gem = gem_row(options)
106
118
  rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
107
- print_table(rows)
108
- footers(options).each { |line| @stdout.puts(line) }
109
- @stdout.puts(summary(rows))
119
+ if options[:json]
120
+ @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || []) }))
121
+ else
122
+ print_table(rows)
123
+ footers(options).each { |line| @stdout.puts(line) }
124
+ @stdout.puts(summary(rows))
125
+ end
110
126
  rows.any?(&:failed) ? 1 : 0
111
127
  end
112
128
 
@@ -133,22 +149,32 @@ module Samagotchi
133
149
  row.to = VERSION
134
150
  return done(row, :update)
135
151
  end
136
- if (why = off(options, "gem"))
137
- return skipped(row, why)
138
- end
139
- return skipped(row, "under Bundler: bundle update samagotchi") if @bundler
152
+ why = off(options, "gem") || ("under Bundler: bundle update samagotchi" if @bundler)
153
+ # --to asks for that exact version: not installing it is a failure, not a skip.
154
+ return options[:to] ? failed(row, "skipped: #{why}") : skipped(row, why) if why
140
155
 
141
156
  latest = begin
142
- @gem_update.latest
157
+ if options[:to]
158
+ if ENV[GemUpdate::LOCAL_ENV].to_s.empty?
159
+ GemUpdate::Latest.new(version: options[:to], source: "samagotchi")
160
+ else
161
+ local = @gem_update.latest
162
+ return failed(row, "local gem file is #{local.version}, not #{options[:to]}") unless local.version == options[:to]
163
+
164
+ local
165
+ end
166
+ else
167
+ @gem_update.latest
168
+ end
143
169
  rescue GemUpdate::Error => e
144
170
  return row.tap { |r| r.status = "couldn't check (#{e.message})" }
145
171
  end
146
- return up_to_date(row) unless @gem_update.newer?(latest)
172
+ return up_to_date(row) unless options[:to] || @gem_update.newer?(latest)
147
173
 
148
174
  row.to = latest.version
149
175
  return done(row, :update) if @dry_run
150
176
 
151
- @stdout.puts("installing samagotchi #{latest.version}…")
177
+ (options[:json] ? @stderr : @stdout).puts("installing samagotchi #{latest.version}…")
152
178
  output, ok = @gem_update.install(latest)
153
179
  return failed(row, output.lines.map(&:strip).reject(&:empty?).last.to_s) unless ok
154
180
 
@@ -161,7 +187,8 @@ module Samagotchi
161
187
  wrapper = InstalledGem.wrapper(@gem_spec)
162
188
  return done(row, :update, "run chi update again to sync with it") unless wrapper
163
189
 
164
- passed = HANDED_OVER.select { |_, key| options[key] }.keys
190
+ passed = options[:to] ? ["--to", options[:to]] : []
191
+ passed += HANDED_OVER.except("--to").select { |_, key| options[key] }.keys
165
192
  @stdout.flush
166
193
  @exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
167
194
  done(row, :update) # only when exec is a spec's stand-in
@@ -180,7 +207,7 @@ module Samagotchi
180
207
  row = Row.new(component: "system bundle", from: result.from, to: result.to)
181
208
  case result.status
182
209
  when :installed then done(row, :install)
183
- when :updated then done(row, :update, kept(result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
210
+ when :updated then done(row, :update, kept(row, result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
184
211
  when :restored then done(row, :restore).tap { |r| r.to = nil }
185
212
  when :up_to_date then up_to_date(row)
186
213
  when :newer_installed then skipped(row, "newer than this chi's #{result.to}: left").tap { |r| r.to = nil }
@@ -203,7 +230,7 @@ module Samagotchi
203
230
  row = Row.new(component: update.name, from: update.from, to: update.to)
204
231
  case update.status
205
232
  when :would_update, :updated
206
- notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
233
+ notes = [adds(update.adds), kept(row, update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
207
234
  done(row, :update, notes.empty? ? nil : notes.join("; "))
208
235
  when :up_to_date then up_to_date(row)
209
236
  when :skipped then skipped(row, update.note)
@@ -325,11 +352,12 @@ module Samagotchi
325
352
  members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
326
353
  end
327
354
 
328
- def kept(files, bundle)
329
- return nil if files.empty?
355
+ def kept(row, files, bundle)
356
+ row.kept = files.dup
357
+ return nil if row.kept.empty?
330
358
 
331
359
  verb = @dry_run ? "would keep" : "kept"
332
- "#{verb} your edits in #{files.join(", ")}: chi bundle diff #{bundle} #{files.first}"
360
+ "#{verb} your edits in #{row.kept.join(", ")}: chi bundle diff #{bundle} #{row.kept.first}"
333
361
  end
334
362
 
335
363
  def replaced(files)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.122.0"
4
+ VERSION = "0.124.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end