samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -35,11 +35,9 @@ module Samagotchi
35
35
  LAYERS = %i[stale forget].freeze
36
36
  # The layers a turn can run under so far.
37
37
  BUILT = %i[stale forget].freeze
38
- # The sentence forget_outputs' description carries (global only):
39
- # the plan's D7 line, adapted from CLM's steering example.
38
+ # The sentence forget_outputs' description carries (global only; its
39
+ # default is Config::DEFAULT_LLM_CONTEXT_POLICY).
40
40
  POLICY_SETTING = "llm_context.policy"
41
- DEFAULT_POLICY = "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note what it " \
42
- "established; keep anything you'll still edit against."
43
41
 
44
42
  APPLY_SETTING = "llm_context.apply"
45
43
  APPLY_KEY = "llm_context_apply"
@@ -150,10 +148,10 @@ module Samagotchi
150
148
  end
151
149
 
152
150
  # Where one value came from: :session, :model_setting, :host_setting
153
- # or :config, and the place as a warning names it ("the session",
154
- # "models: deepseek", "hosts entry 'box'", "llm_context.apply").
151
+ # or :config, and the place, named as the other settings name theirs
152
+ # ("session", "models: deepseek", "hosts.box", "llm_context.apply").
155
153
  Origin = Data.define(:source, :where)
156
- SESSION_ORIGIN = Origin.new(source: :session, where: "the session")
154
+ SESSION_ORIGIN = Origin.new(source: :session, where: "session")
157
155
 
158
156
  # What a turn's strategy, apply rule and budget are, each with its
159
157
  # Origin (/llm-context, /stats, the web's info bar).
@@ -171,26 +169,22 @@ module Samagotchi
171
169
  end
172
170
  end
173
171
 
174
- # The strategy for +target+'s turn.
175
- # @param target [HostRegistry::ModelTarget, nil]
176
- # @param names [Array<String>] HostRegistry#lookup_names
177
- # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
172
+ # The strategy for +lookup+'s model's turn.
173
+ # @param lookup [ModelLookup] the model's layers (EffectiveModel#lookup)
178
174
  # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
179
175
  # @param session_apply [Symbol, nil] the session's own apply rule
180
176
  # @param session_budget [Integer, nil] the session's own budget (0: off)
181
177
  # @return [Resolved]
182
- def resolve(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
183
- explain(target, names: names, models: models, session: session, session_apply: session_apply,
184
- session_budget: session_budget).resolved
178
+ def resolve(lookup, session: nil, session_apply: nil, session_budget: nil)
179
+ explain(lookup, session: session, session_apply: session_apply, session_budget: session_budget).resolved
185
180
  end
186
181
 
187
182
  # #resolve, with where each value came from.
188
183
  # @return [Explained]
189
- def explain(target, names:, models: nil, session: nil, session_apply: nil, session_budget: nil)
190
- models ||= ConfigFile.model_settings
191
- layers, source, where = layers_for(target, names, models, session)
192
- apply, apply_origin = apply_for(target, names, models, session_apply)
193
- budget, budget_origin = budget_for(target, names, models, session_budget)
184
+ def explain(lookup, session: nil, session_apply: nil, session_budget: nil)
185
+ layers, source, where = layers_for(lookup, session)
186
+ apply, apply_origin = apply_for(lookup, session_apply)
187
+ budget, budget_origin = budget_for(lookup, session_budget)
194
188
  resolved = resolved(layers, source, where).with(apply: apply, protect_steps: protect_steps,
195
189
  stale_edits: Config.get(STALE_EDITS_SETTING) == true,
196
190
  budget_tokens: budget)
@@ -198,79 +192,47 @@ module Samagotchi
198
192
  budget_tokens: budget_origin)
199
193
  end
200
194
 
201
- # A session's LLM context strategy, apply rule and budget, each with
202
- # where it came from (Explained#summary), as its worker would resolve
203
- # them; nil when they can't be (an unreadable config, a model that
204
- # resolves nowhere). Resolves from config and the registry only: no
205
- # network, no model call. What a session page, a session list's ctx %
206
- # and a spawn-time check all read.
207
- # @param session [Session, nil]
208
- # @param registry [HostRegistry, nil] the hosts to resolve against;
209
- # HostRegistry.new by default (what the caller usually has)
210
- # @return [Hash, nil] Explained#summary, symbol keys
211
- def for_session(session, registry: nil)
212
- return nil if session.nil?
213
-
214
- # Here, not at the top: config.rb requires this file before ConfigFile
215
- # exists, and effective_model.rb requires config.rb (via host_registry.rb).
216
- require_relative "effective_model"
217
- registry ||= HostRegistry.new
218
- own = session.llm_context
219
- EffectiveModel.of_session(session, registry: registry).llm_context_explained(own&.resolve_args || {})&.summary(own)
220
- rescue StandardError
221
- nil
222
- end
223
-
224
- # The budget a session's turns run under (for_session's :budget_tokens),
225
- # nil when it is off or can't be resolved. What a session list's ctx %
226
- # counts against, so a saved session's fill reads as the live meter.
227
- # @param session [Session, nil]
228
- # @param registry [HostRegistry, nil] see .for_session
229
- # @return [Integer, nil]
230
- def session_budget(session, registry: nil)
231
- for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
232
- end
233
-
234
195
  # [the budget, its Origin]: the session's own first (0 there is off,
235
196
  # whatever the model says).
236
- def budget_for(target, names, models, session_budget)
197
+ def budget_for(lookup, session_budget)
237
198
  return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
238
199
 
239
- if (setting = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models))
200
+ if (setting = lookup.model(:llm_context_budget_tokens))
240
201
  return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
241
202
  end
242
203
 
243
- budget = target&.entry&.llm_context_budget_tokens
244
- return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
204
+ budget = lookup.host(:llm_context_budget_tokens)
205
+ return [budget, Origin.new(source: :host_setting, where: "hosts.#{lookup.host_name}")] if budget
245
206
 
246
- [parse_budget(Config.get(BUDGET_SETTING), BUDGET_SETTING), Origin.new(source: :config, where: BUDGET_SETTING)]
207
+ [parse_budget(lookup.global(BUDGET_SETTING).first, BUDGET_SETTING), Origin.new(source: :config, where: BUDGET_SETTING)]
247
208
  end
248
209
 
249
- def layers_for(target, names, models, session)
210
+ def layers_for(lookup, session)
250
211
  return [session, :session, SESSION_ORIGIN.where] if session
251
212
 
252
- if (setting = ConfigFile.model_setting(names, :llm_context_strategy, models: models))
213
+ if (setting = lookup.model(:llm_context_strategy))
253
214
  return [setting.value, :model_setting, "models: #{setting.key}"]
254
215
  end
255
216
 
256
- layers = target&.entry&.llm_context_strategy
257
- return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
217
+ layers = lookup.host(:llm_context_strategy)
218
+ return [layers, :host_setting, "hosts.#{lookup.host_name}"] if layers
258
219
 
259
- [parse(Config.get(SETTING), SETTING) || [], :config, SETTING]
220
+ [parse(lookup.global(SETTING).first, SETTING) || [], :config, SETTING]
260
221
  end
261
222
 
262
223
  # [the apply rule, its Origin].
263
- def apply_for(target, names, models, session_apply)
224
+ def apply_for(lookup, session_apply)
264
225
  return [session_apply, SESSION_ORIGIN] if session_apply
265
226
 
266
- if (setting = ConfigFile.model_setting(names, :llm_context_apply, models: models))
227
+ if (setting = lookup.model(:llm_context_apply))
267
228
  return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
268
229
  end
269
230
 
270
- apply = target&.entry&.llm_context_apply
271
- return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
231
+ apply = lookup.host(:llm_context_apply)
232
+ return [apply, Origin.new(source: :host_setting, where: "hosts.#{lookup.host_name}")] if apply
272
233
 
273
- [parse_apply(Config.get(APPLY_SETTING), APPLY_SETTING) || DEFAULT_APPLY, Origin.new(source: :config, where: APPLY_SETTING)]
234
+ [parse_apply(lookup.global(APPLY_SETTING).first, APPLY_SETTING) || DEFAULT_APPLY,
235
+ Origin.new(source: :config, where: APPLY_SETTING)]
274
236
  end
275
237
 
276
238
  # llm_context.protect_steps: 0 or more (a negative one is the default).
@@ -73,6 +73,15 @@ module Samagotchi
73
73
  end
74
74
  end
75
75
 
76
+ # The view under +llm_context+'s strategy (LLMContextStrategy::Resolved,
77
+ # a turn's TurnSettings#llm_context), none without one: what both loops
78
+ # format the conversation through, so the warm-up warms the prompt the
79
+ # next turn sends.
80
+ # @return [LLMContextView]
81
+ def self.for(llm_context)
82
+ new(strategy: llm_context&.strategy || NONE)
83
+ end
84
+
76
85
  # @param strategy [Symbol, Array<Symbol>] :none, or the layers
77
86
  def initialize(strategy: NONE)
78
87
  @layers = strategy == NONE ? [] : Array(strategy).map(&:to_sym) & LAYERS
@@ -75,6 +75,20 @@ module Samagotchi
75
75
  plugin_file: plugin_file, script_files: script_files)
76
76
  end
77
77
 
78
+ # Whether the bundle file +src+ reads through a symlink: it is one, or
79
+ # it (or its dir, hooks/ say) resolves outside +bundle_dir+. A tar,
80
+ # zip or git repo keeps links; copying through one would install any
81
+ # file the user can read. MemoryFiles skips linked memories the same
82
+ # way. ShippedUpdate's plan reads it too.
83
+ def self.linked?(src, bundle_dir)
84
+ return true if File.symlink?(src)
85
+
86
+ root = File.realpath(bundle_dir)
87
+ !File.realpath(src).start_with?("#{root}/")
88
+ rescue SystemCallError
89
+ true
90
+ end
91
+
78
92
  private
79
93
 
80
94
  # Copies the bundle's hooks/*.rb into <bundle_dir>/hooks/: the ones
@@ -107,10 +121,8 @@ module Samagotchi
107
121
  # One listed hook; an upgrade overwrites a local edit with a warning.
108
122
  def install_hook(basename, src)
109
123
  dest = File.join(@provenance.hooks_dir, basename)
110
- if @dry_run
111
- @results[basename] = { status: File.exist?(dest) && !@force ? "would_skip" : "would_install" }
112
- return
113
- end
124
+ result = result_for(src, dest, updated: @upgrade && @existing)
125
+ return @results[basename] = planned(result) if @dry_run
114
126
 
115
127
  prev_sha = @existing&.hooks&.dig(basename)&.sha256
116
128
  if @upgrade && File.exist?(dest) && !@force && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
@@ -118,16 +130,9 @@ module Samagotchi
118
130
  end
119
131
 
120
132
  FileUtils.mkdir_p(@provenance.hooks_dir)
121
- unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
122
133
  @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: prev_sha, incoming: src)
123
134
  place(src, dest)
124
- @results[basename] = if unchanged
125
- { status: "skipped", reason: "already up to date" }
126
- elsif @upgrade && @existing
127
- { status: "updated" }
128
- else
129
- { status: "installed" }
130
- end
135
+ @results[basename] = result
131
136
  @hook_files[basename] = dest
132
137
  end
133
138
 
@@ -139,13 +144,13 @@ module Samagotchi
139
144
 
140
145
  @bundle_hooks << basename
141
146
  if @dry_run
142
- @results[basename] = { status: "would_install" }
147
+ @results[basename] = Installer::FileResult.new(word: :would_install)
143
148
  else
144
149
  FileUtils.mkdir_p(@provenance.hooks_dir)
145
150
  dest = File.join(@provenance.hooks_dir, basename)
146
151
  @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: @existing&.hooks&.dig(basename)&.sha256, incoming: src)
147
152
  place(src, dest)
148
- @results[basename] = { status: "installed" }
153
+ @results[basename] = Installer::FileResult.new(word: :installed)
149
154
  @hook_files[basename] = dest
150
155
  end
151
156
  end
@@ -166,22 +171,17 @@ module Samagotchi
166
171
  end
167
172
  end
168
173
 
169
- # Strict mode: an installed hook whose sha256 differs from the one its
170
- # manifest declares warns (as verify_memory_checksums does).
174
+ # Strict mode: a hook whose sha256 differs from the one its manifest
175
+ # declares warns (as verify_memory_checksums does). The bundle's file is
176
+ # checked, the one the install copies, so a dry run checks what it
177
+ # would install, not the old hook in place.
171
178
  def verify_hook_checksums
172
179
  @bundle_hooks.each do |basename|
173
180
  next if @conflicts.key?(basename)
174
181
 
175
- expected = @manifest.checksum_for_hook(basename)
176
- next unless expected
177
-
178
- dest = File.join(@provenance.hooks_dir, basename)
179
- next unless File.exist?(dest)
180
-
181
- actual = FileDigest.hex(dest)
182
- if actual != expected
183
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
184
- end
182
+ warning = FileDigest.mismatch("hook #{basename}", File.join(@bundle_dir, "hooks", basename),
183
+ @manifest.checksum_for_hook(basename))
184
+ @warnings << warning if warning
185
185
  end
186
186
  end
187
187
 
@@ -190,16 +190,13 @@ module Samagotchi
190
190
  # installed are reported up to date, not "Installed".
191
191
  # @return [Hash{String => String}] basename => installed path ({} on a dry run)
192
192
  def install_guardrails(incoming_rules)
193
+ target = @provenance.guardrails_dir
194
+ results = incoming_rules.to_h { |src| [src, result_for(src, File.join(target, File.basename(src)), updated: false)] }
193
195
  if @dry_run
194
- incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
196
+ results.each { |src, result| @results["guardrails/#{File.basename(src)}"] = planned(result) }
195
197
  return {}
196
198
  end
197
199
 
198
- target = @provenance.guardrails_dir
199
- unchanged = incoming_rules.select do |src|
200
- dest = File.join(target, File.basename(src))
201
- File.file?(dest) && FileUtils.identical?(src, dest)
202
- end
203
200
  @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
204
201
  FileUtils.rm_rf(target)
205
202
  return {} if incoming_rules.empty?
@@ -208,8 +205,7 @@ module Samagotchi
208
205
  incoming_rules.to_h do |src|
209
206
  dest = File.join(target, File.basename(src))
210
207
  place(src, dest)
211
- @results["guardrails/#{File.basename(src)}"] =
212
- unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
208
+ @results["guardrails/#{File.basename(src)}"] = results[src]
213
209
  [File.basename(src), dest]
214
210
  end
215
211
  end
@@ -235,29 +231,20 @@ module Samagotchi
235
231
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
236
232
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
237
233
  end
234
+ dest = File.join(@provenance.plugin_dir, plugin[:file])
235
+ result = result_for(src, dest, updated: @upgrade && @provenance.installed?)
238
236
  if @dry_run
239
- @results[plugin[:file]] = { status: "would_install" }
237
+ @results[plugin[:file]] = planned(result)
240
238
  return nil
241
239
  end
242
240
 
243
- dest = File.join(@provenance.plugin_dir, plugin[:file])
244
- unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
245
241
  @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
246
242
  FileUtils.rm_rf(@provenance.plugin_dir)
247
243
  FileUtils.mkdir_p(@provenance.plugin_dir)
248
244
  place(src, dest)
249
- @results[plugin[:file]] = if unchanged
250
- { status: "skipped", reason: "already up to date" }
251
- elsif @upgrade && @provenance.installed?
252
- { status: "updated" }
253
- else
254
- { status: "installed" }
255
- end
256
- expected = @manifest.checksum_for_plugin
257
- actual = FileDigest.hex(dest)
258
- if @strict && !@dev && expected && actual != expected
259
- @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
260
- end
245
+ @results[plugin[:file]] = result
246
+ warning = FileDigest.mismatch("plugin #{plugin[:file]}", dest, @manifest.checksum_for_plugin) if @strict && !@dev
247
+ @warnings << warning if warning
261
248
  dest
262
249
  end
263
250
 
@@ -274,12 +261,15 @@ module Samagotchi
274
261
 
275
262
  @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
276
263
  end
277
- return {} if @dry_run
278
-
279
- unchanged = scripts.keys.select do |file|
280
- dest = File.join(@provenance.scripts_dir, file)
281
- File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
264
+ results = scripts.keys.to_h do |file|
265
+ [file, result_for(File.join(@bundle_dir, "scripts", file), File.join(@provenance.scripts_dir, file),
266
+ updated: @upgrade && @provenance.installed?)]
267
+ end
268
+ if @dry_run
269
+ results.each { |file, result| @results["scripts/#{file}"] = planned(result) }
270
+ return {}
282
271
  end
272
+
283
273
  @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
284
274
  scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
285
275
  FileUtils.rm_rf(@provenance.scripts_dir)
@@ -289,20 +279,36 @@ module Samagotchi
289
279
  scripts.to_h do |file, declared|
290
280
  dest = File.join(@provenance.scripts_dir, file)
291
281
  place(File.join(@bundle_dir, "scripts", file), dest)
292
- @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
293
- elsif @upgrade && @provenance.installed? then { status: "updated" }
294
- else { status: "installed" }
295
- end
282
+ @results["scripts/#{file}"] = results[file]
296
283
  FileUtils.chmod(0o755, dest) unless @dev
297
- actual = FileDigest.hex(dest)
298
- expected = declared.to_s.delete_prefix("sha256:")
299
- if @strict && !@dev && !expected.empty? && actual != expected
300
- @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
301
- end
284
+ warning = FileDigest.mismatch("script #{file}", dest, declared.to_s.delete_prefix("sha256:")) if @strict && !@dev
285
+ @warnings << warning if warning
302
286
  [file, dest]
303
287
  end
304
288
  end
305
289
 
290
+ # What the real run reports for +src+ over +dest+: up to date when the
291
+ # bytes are the same, else updated (+updated+: an upgrade over an
292
+ # install) or installed. A dry run records #planned of it, so the two
293
+ # can't drift.
294
+ def result_for(src, dest, updated:)
295
+ word = if File.file?(dest) && FileUtils.identical?(src, dest) then :up_to_date
296
+ elsif updated then :updated
297
+ else :installed
298
+ end
299
+ Installer::FileResult.new(word: word)
300
+ end
301
+
302
+ # A dry run's word for a result: would_install, would_update, or the
303
+ # up to date result as it is (nothing would be written).
304
+ def planned(result)
305
+ case result.word
306
+ when :installed then result.with(word: :would_install)
307
+ when :updated then result.with(word: :would_update)
308
+ else result
309
+ end
310
+ end
311
+
306
312
  # A file only a block renderer's load names: the page loads it, nothing runs it.
307
313
  def page_only?(file)
308
314
  @manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
@@ -316,19 +322,8 @@ module Samagotchi
316
322
  @dev ? File.symlink(File.realpath(src), dest) : FileUtils.cp(src, dest)
317
323
  end
318
324
 
319
- # Whether the bundle file +src+ reads through a symlink: it is one, or
320
- # it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
321
- # zip or git repo keeps links; copying through one would install any
322
- # file the user can read. MemoryFiles skips linked memories the same
323
- # way.
324
- def linked?(src)
325
- return true if File.symlink?(src)
326
-
327
- root = File.realpath(@bundle_dir)
328
- !File.realpath(src).start_with?("#{root}/")
329
- rescue SystemCallError
330
- true
331
- end
325
+ # AssetInstaller.linked? for this bundle's source.
326
+ def linked?(src) = self.class.linked?(src, @bundle_dir)
332
327
 
333
328
  # linked? with a "<label> is a symlink (not installed)" warning.
334
329
  def linked_out?(src, label)
@@ -227,7 +227,8 @@ module Samagotchi
227
227
  files: files_map,
228
228
  hooks: parts.hooks.empty? ? nil : parts.hooks,
229
229
  trust_level: trust_level,
230
- plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: FileDigest.sha256(parts.plugin) },
230
+ plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: FileDigest.sha256(parts.plugin),
231
+ required: manifest&.plugin_required },
231
232
  requires_chi: manifest&.requires_chi,
232
233
  needs: manifest&.needs,
233
234
  scripts: parts.scripts.transform_values { |src| FileDigest.sha256(src) },
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_state"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # What each command that acts on a bundle memory does in each FileState:
8
+ # one table per command, so a reader sees one command on one screen. A cell
9
+ # is keyed [kind, mark] or [kind, :any]; a dropped file another bundle
10
+ # lists too is [kind, :shared] (FileState#shared_with, asked only there).
11
+ # A dry run reads its command's row and doesn't execute it.
12
+ #
13
+ # A new kind or a new command adds a row in each table it reaches
14
+ # (docs/internals/bundles.md).
15
+ module FileActions
16
+ # do: what happens to the file's bytes
17
+ # :write_incoming the bundle's text replaces it (or is put there)
18
+ # :merge the text merge's result replaces it (Merger::Outcome)
19
+ # :decide not a function of kind and mark: the caller asks
20
+ # Merger::Upgrade#decision, and FileActions.decided
21
+ # turns its Outcome into the action
22
+ # :keep left as it is (already the bundle's, or kept)
23
+ # :skip left as it is, the bundle doesn't take it
24
+ # :move_to_trash moved to the trash (a removal)
25
+ # :refuse the command stops
26
+ # :restore the bundle's text put back (SystemBundle at its version)
27
+ # save_first: a copy of the old bytes goes to the trash before a write
28
+ # (when they differ from what is written)
29
+ # next_base: :on_disk (the file as the command leaves it), :incoming,
30
+ # :recorded (the base it has; none stays none), :none (no entry left)
31
+ # -- Merger::Outcome#base's vocabulary
32
+ # word: the report bucket the file is on (Installer#summary)
33
+ # record: the entry afterwards: :own (rebuilt, no mark), :keep_entry
34
+ # (kept with its base, no mark), :mark (marked behind), :hold_mark (as
35
+ # it was, mark and all), :clear_mark, :drop, :not_owned (never recorded)
36
+ Action = Data.define(:do, :save_first, :next_base, :word, :record) do
37
+ def initialize(do:, word:, next_base: :recorded, record: :own, save_first: false) = super
38
+
39
+ # The file stays behind the bundle, marked (a conflict to the report).
40
+ def behind? = record == :mark
41
+
42
+ # The bundle's text goes into the file: written (installed, forced or
43
+ # fast-forwarded) or merged into a local edit. The source file is the
44
+ # download, so the manifest's sha checks it (Installer's integrity
45
+ # check). :restore is SystemBundle's; it checks its own copy.
46
+ def takes_source? = %i[write_incoming merge].include?(self.do)
47
+ end
48
+
49
+ WRITE = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk)
50
+ UP_TO_DATE = Action.new(do: :keep, word: :up_to_date, next_base: :on_disk)
51
+ USER_SAME = Action.new(do: :keep, word: :up_to_date, next_base: :none, record: :not_owned)
52
+ USERS_OWN = Action.new(do: :skip, word: :kept_own, next_base: :none, record: :not_owned)
53
+ KEPT_OWN = Action.new(do: :skip, word: :kept_own, record: :keep_entry)
54
+ HELD = Action.new(do: :skip, word: :kept_own, record: :hold_mark)
55
+ FAST_FORWARD = Action.new(do: :write_incoming, word: :updated, next_base: :on_disk)
56
+ KEEP = Action.new(do: :keep, word: :kept)
57
+ # No base: no merge is possible, so the file stays behind, marked.
58
+ BEHIND = Action.new(do: :keep, word: :behind, record: :mark)
59
+ DECIDE = Action.new(do: :decide, word: nil)
60
+ REFUSE = Action.new(do: :refuse, word: nil, record: :hold_mark)
61
+ FORCED = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk, save_first: true)
62
+ # The bundle dropped it: a plain install keeps it and its entry (the
63
+ # upgrade it points at moves it), an upgrade moves it unless edited.
64
+ DROPPED_KEPT = Action.new(do: :keep, word: nil, record: :keep_entry)
65
+ DROPPED_GONE = Action.new(do: :skip, word: nil, next_base: :none, record: :drop)
66
+ REMOVE = Action.new(do: :move_to_trash, word: :removed, next_base: :none, record: :drop)
67
+ KEPT_PRUNED = Action.new(do: :keep, word: :kept_pruned, record: :keep_entry)
68
+ KEPT_SHARED = Action.new(do: :keep, word: :kept_shared, next_base: :none, record: :drop)
69
+
70
+ # Rows every installing command shares: files the bundle doesn't own.
71
+ NOT_OWNED = {
72
+ %i[new any] => WRITE, %i[user_same any] => USER_SAME, %i[user_file any] => USERS_OWN,
73
+ %i[missing any] => WRITE, %i[level any] => UP_TO_DATE, %i[unreadable any] => REFUSE
74
+ }.freeze
75
+
76
+ # A plain install (a re-install over the record, or a fresh one).
77
+ INSTALL = NOT_OWNED.merge(
78
+ # A file never edited takes the source's text, as an upgrade's
79
+ # fast-forward does (D4); an edited one stays (an upgrade merges it).
80
+ %i[pristine none] => FAST_FORWARD, %i[pristine_no_base none] => FAST_FORWARD,
81
+ %i[edited none] => KEPT_OWN, %i[diverged none] => KEPT_OWN, %i[edited_no_base none] => KEPT_OWN,
82
+ # A marked file it skips has missed the bundle's change as much as before.
83
+ %i[pristine any] => HELD, %i[edited any] => HELD, %i[diverged any] => HELD,
84
+ %i[pristine_no_base any] => HELD, %i[edited_no_base any] => HELD,
85
+ %i[dropped_pristine any] => DROPPED_KEPT, %i[dropped_edited any] => DROPPED_KEPT,
86
+ %i[dropped_missing any] => DROPPED_GONE
87
+ ).freeze
88
+
89
+ # install --force and upgrade --force: the bundle's text everywhere, an
90
+ # edit copied to the trash first. Its dropped rows are upgrade --force's
91
+ # (a plain install --force never prunes: its dropped files are INSTALL's).
92
+ FORCE = NOT_OWNED.merge(
93
+ # A file that already has the bundle's text is written all the same
94
+ # (nothing to save): it is installed, and checked, as any other.
95
+ %i[new any] => FORCED, %i[user_same any] => FORCED, %i[user_file any] => FORCED, %i[level any] => FORCED,
96
+ %i[pristine any] => FORCED, %i[edited any] => FORCED, %i[diverged any] => FORCED,
97
+ %i[pristine_no_base any] => FORCED, %i[edited_no_base any] => FORCED,
98
+ %i[dropped_pristine any] => REMOVE, %i[dropped_edited any] => REMOVE, %i[dropped_missing any] => DROPPED_GONE,
99
+ %i[dropped_pristine shared] => KEPT_SHARED, %i[dropped_edited shared] => KEPT_SHARED
100
+ ).freeze
101
+
102
+ # An upgrade's 3-way view (Merger::Upgrade#outcome).
103
+ UPGRADE = NOT_OWNED.merge(
104
+ %i[pristine none] => FAST_FORWARD,
105
+ # A marked file that equals its base takes the update; a copy is kept.
106
+ %i[pristine current] => FAST_FORWARD.with(save_first: true),
107
+ # An older chi's mark: fast-forward unless it takes a line out and
108
+ # ShippedHistory can't prove the base (the legacy guard).
109
+ %i[pristine legacy] => DECIDE,
110
+ %i[edited any] => KEEP,
111
+ # The text merge: merged, a conflict, or already merged.
112
+ %i[diverged any] => DECIDE,
113
+ # No base: no merge. One never edited (the checksum vouches for it)
114
+ # takes the update as a file equal to its base would (U4); a marked
115
+ # one, or an edit, stays behind.
116
+ %i[pristine_no_base none] => FAST_FORWARD,
117
+ %i[pristine_no_base any] => BEHIND, %i[edited_no_base any] => BEHIND,
118
+ %i[dropped_pristine any] => REMOVE, %i[dropped_edited any] => KEPT_PRUNED, %i[dropped_missing any] => DROPPED_GONE,
119
+ %i[dropped_pristine shared] => KEPT_SHARED, %i[dropped_edited shared] => KEPT_SHARED
120
+ ).freeze
121
+
122
+ # Remerge: the retry of a marked file at the same version. Unmarked
123
+ # files are never asked. Before the table, a mark whose source moved
124
+ # stays behind as it is (FileState#source_moved?).
125
+ REMERGE = {
126
+ %i[level any] => Action.new(do: :keep, word: :settled, next_base: :on_disk, record: :clear_mark),
127
+ %i[edited any] => Action.new(do: :keep, word: :settled, record: :clear_mark),
128
+ %i[diverged any] => DECIDE,
129
+ # It matches its old base: the next upgrade takes the update.
130
+ %i[pristine current] => BEHIND,
131
+ %i[pristine legacy] => DECIDE,
132
+ %i[pristine_no_base any] => BEHIND, %i[edited_no_base any] => BEHIND,
133
+ # Not shipped now: behind, nothing to merge.
134
+ %i[dropped_pristine any] => BEHIND, %i[dropped_edited any] => BEHIND,
135
+ %i[dropped_missing any] => Action.new(do: :skip, word: nil, record: :hold_mark),
136
+ # The next upgrade or a restore puts it back.
137
+ %i[missing any] => Action.new(do: :skip, word: nil, record: :hold_mark),
138
+ %i[unreadable any] => Action.new(do: :keep, word: :behind, record: :hold_mark)
139
+ }.freeze
140
+
141
+ # SystemBundle at its version: a memory not on disk is put back, one the
142
+ # record doesn't own too (research Q2, today's behaviour).
143
+ RESTORE = {
144
+ %i[missing any] => Action.new(do: :restore, word: :restored, next_base: :on_disk),
145
+ %i[new any] => Action.new(do: :restore, word: :restored, next_base: :on_disk)
146
+ }.freeze
147
+
148
+ # Uninstall, on the source-less kinds: an edit refuses (Uninstaller's
149
+ # --force moves it instead); a file another bundle lists stays.
150
+ UNINSTALL = {
151
+ %i[pristine any] => REMOVE, %i[pristine_no_base any] => REMOVE,
152
+ %i[edited any] => REFUSE, %i[edited_no_base any] => REFUSE, %i[unreadable any] => REFUSE,
153
+ %i[missing any] => DROPPED_GONE,
154
+ %i[pristine shared] => KEPT_SHARED, %i[pristine_no_base shared] => KEPT_SHARED,
155
+ %i[edited shared] => KEPT_SHARED, %i[edited_no_base shared] => KEPT_SHARED
156
+ }.freeze
157
+
158
+ TABLES = { install: INSTALL, force: FORCE, upgrade: UPGRADE, remerge: REMERGE, restore: RESTORE,
159
+ uninstall: UNINSTALL }.freeze
160
+ # The kinds whose row depends on another bundle listing the file.
161
+ SHARED_ASKED = %i[dropped_pristine dropped_edited pristine pristine_no_base edited edited_no_base].freeze
162
+
163
+ # The command's action for +state+: [kind, :shared] when another bundle
164
+ # lists a file that has such a row, else [kind, mark], then [kind, :any].
165
+ # @param command [Symbol] a TABLES key
166
+ # @return [Action, nil] nil: the command never meets the state
167
+ def self.for(command, state)
168
+ table = TABLES.fetch(command)
169
+ if SHARED_ASKED.include?(state.kind) && table.key?([state.kind, :shared]) && state.shared_with.any?
170
+ return table.fetch([state.kind, :shared])
171
+ end
172
+
173
+ table[[state.kind, state.mark]] || table[[state.kind, :any]]
174
+ end
175
+
176
+ # A :decide cell's answer: the action Merger::Upgrade#decision's Outcome
177
+ # stands for. Remerge never fast-forwards (the next upgrade does) and
178
+ # settles a file with nothing left to miss.
179
+ # @param outcome [Merger::Outcome]
180
+ # @param command [Symbol] :upgrade or :remerge
181
+ def self.decided(outcome, command = :upgrade)
182
+ case outcome.status
183
+ when :merged then Action.new(do: :merge, word: :merged, next_base: :incoming, save_first: true)
184
+ when :conflict then BEHIND
185
+ when :fast_forward then command == :remerge ? BEHIND : FAST_FORWARD.with(save_first: true)
186
+ when :keep, :noop
187
+ command == :remerge ? Action.new(do: :keep, word: :settled, next_base: outcome.base, record: :clear_mark) : KEEP.with(next_base: outcome.base)
188
+ else raise ArgumentError, "no action for outcome #{outcome.status.inspect}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -26,6 +26,19 @@ module Samagotchi
26
26
  hex = hex(path)
27
27
  hex && "sha256:#{hex}"
28
28
  end
29
+
30
+ # The integrity check's one warning: +path+'s bytes against the sha
31
+ # hex a manifest declares for it. nil when they match, or when there is
32
+ # nothing to check (no sha declared, no file).
33
+ # @param label [String] what the file is ("a.md", "hook guard.rb")
34
+ def self.mismatch(label, path, expected)
35
+ return nil if expected.nil? || expected.empty?
36
+
37
+ actual = hex(path)
38
+ return nil if actual.nil? || actual == expected
39
+
40
+ "Checksum mismatch for #{label}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
41
+ end
29
42
  end
30
43
  end
31
44
  end