samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -34,6 +34,20 @@ module Samagotchi
34
34
  class Installer
35
35
  class InstallError < StandardError; end
36
36
 
37
+ # What the run did to one file (a memory, or a rule, plugin or script:
38
+ # AssetInstaller writes them too, and removes an older chi's hooks), as
39
+ # #results holds it. word is
40
+ # the report bucket (#summary): a real run's (:installed, :updated,
41
+ # :merged, :kept, :kept_pruned, :kept_shared, :kept_own (a file left as
42
+ # the user's), :up_to_date, :conflict, :removed) or a dry run's
43
+ # (:would_install, :would_update, :would_merge, :would_skip, :would_remove,
44
+ # and an upgrade's :fast_forward and :keep). state and action: the
45
+ # memory's FileState and FileActions::Action, nil where none was read.
46
+ # note: why, in words (a conflict's Behind#state, a kept file's reason).
47
+ FileResult = Data.define(:word, :state, :action, :note) do
48
+ def initialize(word:, state: nil, action: nil, note: nil) = super
49
+ end
50
+
37
51
  attr_reader :results, :warnings, :placeholder_warnings, :conflicts
38
52
 
39
53
  # Each file this run left behind (FileStanding::Behind).
@@ -65,7 +79,7 @@ module Samagotchi
65
79
  @conflicts = {}
66
80
  @behind = []
67
81
  @replaced = []
68
- @outcomes = {}
82
+ @actions = {}
69
83
  @overlays = []
70
84
  @trash = Trash.new(name)
71
85
  end
@@ -82,7 +96,7 @@ module Samagotchi
82
96
  def trash_copy_line = @trash.copy_line
83
97
 
84
98
  # The files this run merged, the bundle's changes joined to the user's.
85
- def merged_files = @results.select { |_, r| r[:status] == "merged" }.keys
99
+ def merged_files = with_word(:merged)
86
100
 
87
101
  # The normalized source dir. An owned one (a git/zip/tar extract) is
88
102
  # removed after #run, or kept until #cleanup_source! when an upgrade
@@ -140,33 +154,35 @@ module Samagotchi
140
154
  def summary
141
155
  lines = []
142
156
  lines << "Source changes: #{@source_change.join(" → ")}" if @source_change
143
- installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
144
- updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
157
+ installed = with_word(:installed)
158
+ updated = with_word(:updated)
145
159
  merged = merged_files
146
- would_merge = @results.select { |_, r| r[:status].to_s == "would_merge" }.keys
147
- kept = @results.select { |_, r| %w[kept keep kept_pruned].include?(r[:status].to_s) }.keys
160
+ would_merge = with_word(:would_merge)
161
+ kept = with_word(:kept, :keep, :kept_pruned)
148
162
  # A file whose bytes are already the bundle's was not left out: it is
149
163
  # up to date. Skipped keeps the files really left out -- the user's own
150
- # memory, and a dry run's would_skip (a dry run's `noop` carries no
151
- # reason, so it stays here).
152
- up_to_date = @results.select { |_, r| r[:reason] == "already up to date" }.keys
153
- skipped = @results.select do |_, r|
154
- status = r[:status].to_s
155
- status == "would_skip" || status == "noop" || (status == "skipped" && r[:reason] != "already up to date")
156
- end.keys
157
- would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
158
- fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
159
- removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
160
- would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
164
+ # memory, and a dry run's would_skip.
165
+ up_to_date = with_word(:up_to_date)
166
+ skipped = with_word(:would_skip, :kept_own)
167
+ would_install = with_word(:would_install)
168
+ would_update = with_word(:would_update)
169
+ fast_forward = with_word(:fast_forward)
170
+ # An older chi's class-file hooks this run removes: a line of their
171
+ # own, with why (AssetInstaller#retire_hooks).
172
+ retired, removed = with_word(:removed).partition { |key| @results[key].note == AssetInstaller::RETIRED }
173
+ would_retire, would_remove = with_word(:would_remove).partition { |key| @results[key].note == AssetInstaller::RETIRED }
161
174
 
162
175
  lines << "Installed: #{installed.join(", ")}" unless installed.empty?
163
176
  lines << "Updated: #{updated.join(", ")}" unless updated.empty?
164
177
  lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
178
+ lines << "Would update: #{would_update.join(", ")}" unless would_update.empty?
165
179
  lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
166
180
  lines << FileStanding.merged_line(merged, @manifest&.version) unless merged.empty?
167
181
  lines << FileStanding.merged_line(would_merge, @manifest&.version, dry_run: true) unless would_merge.empty?
168
182
  lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
169
183
  lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
184
+ lines << "Removed (#{AssetInstaller::RETIRED}): #{retired.join(", ")}" unless retired.empty?
185
+ lines << "Would remove (#{AssetInstaller::RETIRED}): #{would_retire.join(", ")}" unless would_retire.empty?
170
186
  lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
171
187
  lines << "Up to date: #{up_to_date.join(", ")}" unless up_to_date.empty?
172
188
  lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
@@ -198,16 +214,19 @@ module Samagotchi
198
214
 
199
215
  private
200
216
 
217
+ # The files whose result has one of +words+, in the order they ran.
218
+ def with_word(*words) = @results.select { |_, result| words.include?(result.word) }.keys
219
+
201
220
  # A plain install over a bundle that is already installed from the same
202
- # source: it rewrote the bundle's hooks, rules, plugin, scripts and its
221
+ # source: it rewrote the bundle's rules, plugin, scripts and its
203
222
  # record, and left a memory the user wrote as it was. So the line says
204
223
  # what changed, and points at the upgrade for the memories it kept.
205
224
  def reinstall_line
206
- line = "#{@name} was already installed from #{@source}: its hooks, rules, plugin and scripts " \
225
+ line = "#{@name} was already installed from #{@source}: its rules, plugin and scripts " \
207
226
  "now match it and its record was rewritten"
208
- # `already exists` is the only reason a memory is left out of a plain
209
- # install (skip_existing): its bytes differ from the bundle's.
210
- if @results.any? { |_, r| r[:reason] == "already exists" }
227
+ # :kept_own is the only way a memory is left out of a plain install
228
+ # (its KEPT_OWN or HELD row): its bytes differ from the bundle's.
229
+ if @results.any? { |_, r| r.word == :kept_own }
211
230
  line << "; your own copies of its memories were kept (chi bundle upgrade #{@name} " \
212
231
  "merges the bundle's changes into them)"
213
232
  end
@@ -242,10 +261,10 @@ module Samagotchi
242
261
  # #ref; a path resolved); one it can't place (an older record's
243
262
  # relative path) never refuses.
244
263
  # - a name chi ships comes from a shipped dir only, unless --force
245
- # (install and upgrade): the plugin and hooks it replaces are chi's;
264
+ # (install and upgrade): the plugin and rules it replaces are chi's;
246
265
  # - a plain install over a bundle from another source is refused,
247
266
  # --force too (that overwrites files, it doesn't switch sources):
248
- # it would delete the plugin and hooks and rewrite the record;
267
+ # it would delete the plugin and rules and rewrite the record;
249
268
  # - an upgrade may switch sources (a URL and a local copy of it are
250
269
  # one bundle, an archive's file name has its version), and
251
270
  # #source_change says so (the summary's first line).
@@ -337,19 +356,32 @@ module Samagotchi
337
356
  @bundle_dir = @source_dir = prepared.dir
338
357
  @source_owned = prepared.owned
339
358
  @source_commit = prepared.commit
359
+ refuse_hooks
340
360
  @manifest = Manifest.read(dir: @bundle_dir)
341
361
  clash = @manifest.session_settings_clash
342
362
  raise InstallError, clash if clash
343
363
  rescue SourceNormalizer::UnknownSourceError => e
344
364
  raise InstallError, "Source normalization failed: #{e.message}"
365
+ rescue Manifest::HooksRetired => e
366
+ # A rule, not a strictness option: refused strict or not, a dry run too.
367
+ raise InstallError, e.message
345
368
  rescue Manifest::ValidationError => e
346
369
  raise InstallError, e.message if @strict
347
370
 
348
371
  @warnings << "No manifest found — proceeding without strict manifest validation"
349
372
  end
350
373
 
374
+ # A source that ships class-file hooks (hooks/*.rb, listed or not) is
375
+ # refused before its manifest is read; its manifest's hooks: is
376
+ # Manifest.read's refusal (Manifest::HooksRetired either way).
377
+ # @raise [Manifest::HooksRetired]
378
+ def refuse_hooks
379
+ files = Manifest::HooksRetired.files_in(@bundle_dir)
380
+ raise Manifest::HooksRetired.for_files(files) unless files.empty?
381
+ end
382
+
351
383
  # A bundle whose requires_chi this chi doesn't meet is not installed at
352
- # all: --force is what installs it, and its plugin, hooks and rules then
384
+ # all: --force is what installs it, and its plugin and rules then
353
385
  # warn that they won't load (AssetInstaller). The words come from
354
386
  # Manifest.requires_chi_failure, so the requirement is said one way.
355
387
  # A dry run is refused the same way: nothing there would load either.
@@ -374,47 +406,38 @@ module Samagotchi
374
406
 
375
407
  # Copies the bundle's memories (MemoryFiles: the files: list, else
376
408
  # every *.md but README and the like) into the target dir and refreshes
377
- # their index lines: an upgrade merges (merge_memory), a dry run only
378
- # classifies (preview_memory), a plain install writes the new ones.
379
- # Sets @bundle_md (every file the bundle ships) and @owned: the bundle
380
- # owns (records, upgrades, removes) only the files it wrote, these
381
- # from an earlier install and the ones written now. A same-name file
382
- # that was already there is the user's: skipped and never recorded.
409
+ # their index lines. Each file's FileState and its command's row
410
+ # (FileActions: INSTALL, FORCE, UPGRADE) say what happens: a dry run
411
+ # only reads the row (preview_memory), a real run does it
412
+ # (write_memory). Sets @bundle_md (every file the bundle ships) and
413
+ # @owned: the bundle owns (records, upgrades, removes) only the files it
414
+ # wrote, these from an earlier install and the ones written now. A
415
+ # same-name file that was already there is the user's: skipped and never
416
+ # recorded (the not-owned rows).
383
417
  def install_memories
384
- owned_before = @existing ? @existing.files.keys : []
385
418
  @bundle_md = []
386
419
  @owned = []
387
420
  warn_unshipped_files
388
421
  warn_symlinked_files
389
422
  memory_files.each do |file_key|
390
- file_path = File.join(@bundle_dir, file_key)
391
423
  @bundle_md << file_key
392
424
  note_overlay(file_key, @target_dir)
393
- target_path = File.join(@target_dir, file_key)
394
- merging = @upgrade && @existing && !@force
395
-
396
- if merging && File.exist?(target_path) && !owned_before.include?(file_key)
397
- # Not the bundle's (the user's, or another bundle's): no merge.
398
- skip_existing(file_key, file_path, target_path, @target_scope)
399
- elsif merging && !@dry_run
400
- @owned << file_key
401
- merge_memory(file_key, file_path, target_path)
402
- elsif @dry_run
403
- preview_memory(file_key, file_path, target_path)
404
- elsif File.exist?(target_path) && !@force
405
- # A re-install keeps what an earlier install wrote.
406
- @owned << file_key if owned_before.include?(file_key)
407
- skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
408
- else
409
- # Only --force gets here with a file in place: a copy keeps it.
410
- @replaced << file_key if File.exist?(target_path)
411
- @trash.copy(target_path) if File.exist?(target_path) && !FileUtils.identical?(file_path, target_path)
412
- FileUtils.cp(file_path, target_path)
413
- @owned << file_key
414
- @outcomes[file_key] = Merger::INSTALL
415
- @results[file_key] = { status: "installed" }
416
- update_target_index(@target_scope, target_path, file_key)
417
- end
425
+ state = FileState.of(file_key, record: @existing, provenance: @provenance, target_dir: @target_dir,
426
+ source_dir: @bundle_dir)
427
+ action = FileActions.for(memory_command, state)
428
+ next preview_memory(file_key, state, action) if @dry_run
429
+
430
+ @owned << file_key unless action.record == :not_owned
431
+ write_memory(file_key, state, action)
432
+ end
433
+ end
434
+
435
+ # The table a memory's row is read from: --force (install or upgrade:
436
+ # one code path), an upgrade over a record, else a plain install.
437
+ def memory_command
438
+ if @force then :force
439
+ elsif @upgrade && @existing then :upgrade
440
+ else :install
418
441
  end
419
442
  end
420
443
 
@@ -434,42 +457,64 @@ module Samagotchi
434
457
  MemoryFiles.symlinks(@bundle_dir, @manifest).each { |name| @warnings << "#{name} is a symlink (not installed)" }
435
458
  end
436
459
 
437
- # An upgrade's 3-way merge of one file (Merger::Upgrade against the
438
- # base the last install recorded); a clean text merge replaces the file;
439
- # a conflict leaves it and is recorded in @conflicts for the agent step.
440
- def merge_memory(file_key, file_path, target_path)
441
- outcome = @outcomes[file_key] = upgrade_view.outcome(file_key)
442
- case outcome.status
443
- when :install
460
+ # One memory's row, done: the bundle's text written (a copy of other
461
+ # bytes in the trash first), the text merge's result written, the file
462
+ # kept or left behind (a conflict, recorded in @conflicts for the agent
463
+ # step), or skipped as the user's; its index line refreshed. A :decide
464
+ # cell is the upgrade's (Merger::Upgrade#decision: the text merge or the
465
+ # legacy guard, against the base the last install recorded).
466
+ def write_memory(file_key, state, action)
467
+ file_path = state.paths[:incoming]
468
+ target_path = state.paths[:current]
469
+ decision = upgrade_view.decision(file_key, state) if action.do == :decide
470
+ action = decision.action if decision
471
+ @actions[file_key] = action
472
+ @replaced << file_key if @force && state.digests.disk
473
+ result = FileResult.new(word: action.word, state: state, action: action)
474
+ case action.do
475
+ when :write_incoming
476
+ save_first(file_key, file_path, target_path) if action.save_first
444
477
  FileUtils.cp(file_path, target_path)
445
- @results[file_key] = { status: "installed" }
446
- when :fast_forward
447
- save_marked_file(file_key, target_path)
448
- FileUtils.cp(file_path, target_path)
449
- @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
478
+ @results[file_key] = action.word == :updated ? result.with(note: "auto-merged (not edited)") : result
479
+ when :merge
480
+ if Merger.write_merged(target_path, decision.outcome, @trash)
481
+ @results[file_key] = result.with(note: "the bundle's changes joined to local edits")
482
+ else
483
+ @actions[file_key] = FileActions::BEHIND
484
+ note_conflict(file_key, Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)), state)
485
+ end
450
486
  when :keep
451
- @results[file_key] = { status: "kept", reason: outcome == Merger::KEEP ? "bundle unchanged, local edits preserved" : "local edits already hold the bundle's changes" }
452
- when :noop
453
- @results[file_key] = { status: "skipped", reason: "already up to date" }
454
- when :merged
455
- if Merger.write_merged(target_path, outcome, @trash)
456
- @results[file_key] = { status: "merged", reason: "the bundle's changes joined to local edits" }
487
+ if action.behind?
488
+ note_conflict(file_key, (decision || upgrade_view.decision(file_key, state)).outcome, state)
489
+ elsif action.word == :kept
490
+ @results[file_key] = result.with(note: decision.nil? || decision.outcome == Merger::KEEP ? "bundle unchanged, local edits preserved" : "local edits already hold the bundle's changes")
457
491
  else
458
- note_conflict(file_key, @outcomes[file_key] = Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)))
492
+ @results[file_key] = result
459
493
  end
460
- when :conflict
461
- note_conflict(file_key, outcome)
462
- else raise ArgumentError, "unknown outcome status #{outcome.status.inspect} for #{file_key}" # the base would move with nothing written
494
+ when :skip
495
+ @results[file_key] = result.with(note: "already exists")
496
+ @warnings << "Skipped #{file_key} (already exists; #{FileStanding::FORCE_TAKES})"
497
+ when :refuse then refuse_unreadable(file_key, target_path)
498
+ else raise ArgumentError, "no install step for #{action.do.inspect} (#{file_key})" # the base would move with nothing written
463
499
  end
464
- update_target_index(@target_scope, target_path, file_key)
500
+ update_target_index(@target_scope, target_path, file_key, owned: action.record != :not_owned)
501
+ end
502
+
503
+ # A file chi can't read: whether it was edited can't be told, so the
504
+ # run stops (the read raises; Trash.failure_message says nothing was
505
+ # lost).
506
+ def refuse_unreadable(file_key, target_path)
507
+ FileDigest.hex(target_path)
508
+ raise InstallError, "#{file_key} can't be read"
465
509
  end
466
510
 
467
511
  # The file stays as it is, with the old base and the mark, in FileStanding's
468
512
  # words.
469
- def note_conflict(file_key, outcome)
513
+ def note_conflict(file_key, outcome, state = nil)
470
514
  behind = upgrade_view.behind(file_key, outcome, @manifest&.version)
471
515
  @behind << behind
472
- @results[file_key] = { status: "conflict", reason: behind.state(dry_run: @dry_run) }
516
+ @results[file_key] = FileResult.new(word: :conflict, state: state, action: state && FileActions::BEHIND,
517
+ note: behind.state(dry_run: @dry_run))
473
518
  @conflicts[file_key] = upgrade_view.paths(file_key)
474
519
  @warnings << "#{behind.line(dry_run: @dry_run)} (#{FileStanding::FORCE_TAKES})"
475
520
  end
@@ -488,58 +533,57 @@ module Samagotchi
488
533
  @upgrade_view ||= Merger::Upgrade.new(provenance: @provenance, target_dir: @target_dir, source_dir: @bundle_dir)
489
534
  end
490
535
 
491
- # A marked file that equals its base, and the update goes ahead: the base
492
- # is known to be real (this chi's mark, or ShippedHistory), or no line of the
493
- # file goes. A copy is kept, and it says so. (Merger::Upgrade#outcome stops
494
- # an older chi's mark otherwise.)
495
- def save_marked_file(file_key, target_path)
496
- return unless @existing.files.dig(file_key, :conflict)
536
+ # A save_first cell: the file's other bytes go to the trash before the
537
+ # bundle's text replaces them. Under an upgrade that is a marked file
538
+ # that equals its base, and the update goes ahead: the base is known to
539
+ # be real (this chi's mark, or ShippedHistory), or no line of the file
540
+ # goes (Merger::Upgrade's legacy guard stops an older chi's mark
541
+ # otherwise), and it says so.
542
+ def save_first(file_key, file_path, target_path)
543
+ return unless File.exist?(target_path) && !FileUtils.identical?(file_path, target_path)
497
544
 
498
545
  @trash.copy(target_path)
546
+ return if @force
547
+
499
548
  @warnings << "#{file_key} was marked as kept but matched its recorded base, so it took the update; your copy is in the trash"
500
549
  end
501
550
 
502
- # A dry run's verdict for one file, nothing written: an upgrade
503
- # classifies as merge_memory would (its status is the classification).
504
- def preview_memory(file_key, file_path, target_path)
505
- unless File.exist?(target_path) && !@force
506
- @results[file_key] = { status: "would_install" }
507
- return
508
- end
551
+ # A dry run's word for an action's (the real run's): what would happen,
552
+ # nothing written. A conflict is reported as the real run would.
553
+ DRY_WORDS = { installed: :would_install, updated: :fast_forward, kept: :keep, merged: :would_merge,
554
+ kept_own: :would_skip, up_to_date: :up_to_date }.freeze
509
555
 
510
- if @upgrade && @existing
511
- outcome = upgrade_view.outcome(file_key)
512
- return note_conflict(file_key, outcome) if outcome.conflict?
556
+ # A dry run's verdict for one file: its row read, not done (a :decide
557
+ # cell's text merge runs on temp copies). --force would install every
558
+ # file, unread.
559
+ def preview_memory(file_key, state, action)
560
+ return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
561
+ return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
513
562
 
514
- @results[file_key] = { status: outcome.merged? ? "would_merge" : outcome.status.to_s }
515
- elsif FileUtils.identical?(file_path, target_path)
516
- @results[file_key] = { status: "skipped", reason: "already up to date" }
517
- else
518
- @results[file_key] = { status: "would_skip" }
519
- end
563
+ decision = upgrade_view.decision(file_key, state) if action.do == :decide || action.behind?
564
+ action = decision.action if decision
565
+ return note_conflict(file_key, decision.outcome, state) if action.behind?
566
+
567
+ @results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
520
568
  end
521
569
 
522
- # Strict mode: a memory written from the bundle whose sha256 differs
523
- # from its manifest's warns. One that wasn't copied (kept, skipped, a
524
- # dry run's keep and would_skip: a local edit isn't a bad download) or
525
- # was merged (merged, a dry run's would_merge) isn't checked.
570
+ # Strict mode: a memory that takes the bundle's text (Action#takes_source?:
571
+ # a new or missing file, --force, an upgrade's or a re-install's
572
+ # fast-forward, a merge into a local edit) warns when the bundle's file
573
+ # differs from its manifest's sha256. One kept or skipped isn't checked
574
+ # (it takes nothing from the bundle). What is checked is the source
575
+ # file, the bytes the write or merge takes, so a dry run checks what it
576
+ # would bring in, never the user's bytes on disk. A dev install follows
577
+ # its own tree; chi bundle check flags its stale shas instead.
526
578
  def verify_memory_checksums
527
- @bundle_md.each do |file_key|
528
- next if @conflicts.key?(file_key)
529
-
530
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
531
- next if %w[kept keep kept_pruned fast_forward updated skipped would_skip merged would_merge].include?(status)
579
+ return if @dev
532
580
 
533
- target_path = File.join(@target_dir, file_key)
534
- next unless File.exist?(target_path)
535
-
536
- expected = @manifest.checksum_for(file_key)
537
- next unless expected
581
+ @bundle_md.each do |file_key|
582
+ result = @results[file_key]
583
+ next unless result&.action&.takes_source?
538
584
 
539
- actual = FileDigest.hex(target_path)
540
- if actual != expected
541
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
542
- end
585
+ warning = FileDigest.mismatch(file_key, result.state.paths[:incoming], @manifest.checksum_for(file_key))
586
+ @warnings << warning if warning
543
587
  end
544
588
  end
545
589
 
@@ -556,7 +600,7 @@ module Samagotchi
556
600
 
557
601
  # The install's record (Provenance#write), only with a manifest and
558
602
  # not on a dry run. An upgrade with conflicts writes it too: the new
559
- # version, the plugin's and hooks' new shas, and the old base for each
603
+ # version, the plugin's new sha, and the old base for each
560
604
  # conflicted file, marked conflict (chi bundle status shows it).
561
605
  # Skipping it left the replaced plugin failing its sha check.
562
606
  # @param installed [AssetInstaller::Installed]
@@ -566,13 +610,11 @@ module Samagotchi
566
610
  scope: @target_scope,
567
611
  version: @manifest.version,
568
612
  source_path: @source,
569
- # AssetInstaller added the discovered hooks/*.rb to manifest.hooks.
570
- hooks: @manifest.hooks,
571
613
  trust_level: @manifest.trust_level,
572
614
  source_commit: @source_commit,
573
- hooks_files: installed.hook_files,
574
615
  guardrails_files: installed.guardrail_files,
575
616
  plugin_file: installed.plugin_file,
617
+ plugin_required: @manifest.plugin&.dig(:required) == true,
576
618
  requires_chi: @manifest.requires_chi,
577
619
  needs: @manifest.needs,
578
620
  marks: @behind + held_conflict_marks,
@@ -600,7 +642,7 @@ module Samagotchi
600
642
  def held_conflict_marks
601
643
  return [] if @upgrade || @existing.nil?
602
644
 
603
- kept = @owned.select { |key| @results.dig(key, :reason) == "already exists" && @existing.files.dig(key, :conflict) }
645
+ kept = @owned.select { |key| @actions[key].record == :hold_mark }
604
646
  kept.map { |key| held_mark(key) }
605
647
  end
606
648
 
@@ -624,26 +666,26 @@ module Samagotchi
624
666
  @owned.each do |file_key|
625
667
  next unless File.exist?(File.join(@target_dir, file_key))
626
668
 
627
- files[file_key] = next_base(file_key, @outcomes.fetch(file_key))
669
+ files[file_key] = next_base(file_key, @actions.fetch(file_key))
628
670
  end
629
671
  @existing.files.each_key do |old_key_str|
630
672
  next if @bundle_md.include?(old_key_str)
631
- next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
673
+ next unless @results[old_key_str]&.word == :kept_pruned
632
674
 
633
675
  files[old_key_str] = recorded_base(@provenance.base_path(old_key_str))
634
676
  end
635
677
  files
636
678
  end
637
679
 
638
- # The file an Outcome's base names. nil for a kept file with no base:
639
- # the user's file must not become one, or the next upgrade fast-forwards
640
- # over it.
641
- def next_base(file_key, outcome)
642
- case outcome.base
680
+ # The file an action's next_base names. nil for a kept file with no
681
+ # base: the user's file must not become one, or the next upgrade
682
+ # fast-forwards over it.
683
+ def next_base(file_key, action)
684
+ case action.next_base
643
685
  when :recorded then recorded_base(@provenance.base_path(file_key))
644
686
  when :on_disk then File.join(@target_dir, file_key)
645
687
  when :incoming then File.join(@bundle_dir, file_key)
646
- else raise ArgumentError, "unknown base #{outcome.base.inspect} for #{file_key}" # nil would drop the base silently
688
+ else raise ArgumentError, "unknown base #{action.next_base.inspect} for #{file_key}" # nil would drop the base silently
647
689
  end
648
690
  end
649
691
 
@@ -656,11 +698,9 @@ module Samagotchi
656
698
  # sees the edit (not a fast-forward over it).
657
699
  def install_provenance_files
658
700
  files = @owned.each_with_object({}) do |file_key, map|
659
- target_path = File.join(@target_dir, file_key)
660
- next unless File.exist?(target_path)
701
+ next unless File.exist?(File.join(@target_dir, file_key))
661
702
 
662
- kept = @existing && @results.dig(file_key, :reason) == "already exists"
663
- map[file_key] = kept ? recorded_base(@provenance.base_path(file_key)) : target_path
703
+ map[file_key] = next_base(file_key, @actions.fetch(file_key))
664
704
  end
665
705
  files.merge(dropped_owned_files)
666
706
  end
@@ -682,58 +722,52 @@ module Samagotchi
682
722
  end
683
723
  end
684
724
 
685
- # A file already in the target dir that the install doesn't write: the
686
- # same bytes are "already up to date", others are skipped with a
687
- # --force hint (a dry run: would_skip). Its index line is refreshed
688
- # (not on a dry run).
689
- def skip_existing(file_key, file_path, target_path, scope, owned: false)
690
- if FileUtils.identical?(file_path, target_path)
691
- @results[file_key] = { status: "skipped", reason: "already up to date" }
692
- elsif @dry_run
693
- @results[file_key] = { status: "would_skip" }
694
- return
695
- else
696
- @results[file_key] = { status: "skipped", reason: "already exists" }
697
- @warnings << "Skipped #{file_key} (already exists; #{FileStanding::FORCE_TAKES})"
698
- end
699
- update_target_index(scope, target_path, file_key, owned: owned) unless @dry_run
700
- end
701
-
702
- # A file the previous version installed that this one doesn't ship is
703
- # moved to the trash (with its index line removed) when it still
704
- # matches what was installed, or with --force; one the user edited is
705
- # kept with a note.
725
+ # A file the previous version installed that this one doesn't ship:
726
+ # its FileState's dropped kind and the UPGRADE (or FORCE) row. An
727
+ # unedited one is moved to the trash (with its index line removed), as
728
+ # is any with --force; an edited one is kept with a note, and one
729
+ # another bundle lists too stays.
706
730
  def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
707
- previous_files.each do |key, meta|
731
+ previous_files.each_key do |key|
708
732
  next if bundle_files.include?(key)
709
733
 
710
734
  target = File.join(target_dir, key)
711
- next unless File.exist?(target)
712
-
713
- others = Provenance.claimants(key, scope: scope, except: @name)
714
- if others.any?
715
- @results[key] = { status: "kept_shared", reason: "bundle #{others.join(", ")} has it too" }
716
- @warnings << "Kept #{key}: no longer in the bundle, but bundle #{others.join(", ")} has it too"
717
- elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
718
- @results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
735
+ state = FileState.of(key, record: @existing, provenance: provenance, target_dir: target_dir, source_dir: @bundle_dir,
736
+ shipped: false, claimants: ->(k) { Provenance.claimants(k, scope: scope, except: @name) })
737
+ action = if state.kind == :unreadable
738
+ unreadable_dropped(state, target)
739
+ else
740
+ FileActions.for(@force ? :force : :upgrade, state)
741
+ end
742
+ result = FileResult.new(word: action.word, state: state, action: action)
743
+ case action.word
744
+ when :kept_shared
745
+ others = state.shared_with.join(", ")
746
+ @results[key] = result.with(note: "bundle #{others} has it too")
747
+ @warnings << "Kept #{key}: no longer in the bundle, but bundle #{others} has it too"
748
+ when :removed
749
+ @results[key] = result.with(word: @dry_run ? :would_remove : :removed, note: "no longer in the bundle")
719
750
  next if @dry_run
720
751
 
721
752
  @trash.move(target)
722
753
  remove_target_index(scope, key)
723
- else
724
- @results[key] = { status: "kept_pruned", reason: "local edits preserved" }
754
+ when :kept_pruned
755
+ @results[key] = result.with(note: "local edits preserved")
725
756
  @warnings << "Kept #{key}: no longer in the bundle but edited locally (#{FileStanding::FORCE_MOVES})"
726
757
  end
727
758
  end
728
759
  end
729
760
 
730
- # Whether the file on disk is what the previous install wrote: its
731
- # recorded checksum, else the base snapshot. Unknown counts as edited.
732
- def installed_unchanged?(target, meta, base_path)
733
- recorded = meta[:checksum]
734
- return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
761
+ # A dropped file chi can't read, in the order the rows are asked: kept
762
+ # when another bundle lists it, moved unread with --force; otherwise
763
+ # whether it was edited can't be told, and the read raises (the run
764
+ # stops, nothing lost).
765
+ def unreadable_dropped(state, target)
766
+ return FileActions::KEPT_SHARED if state.shared_with.any?
767
+ return FileActions::REMOVE if @force
735
768
 
736
- File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
769
+ FileDigest.hex(target)
770
+ raise InstallError, "#{state.key} can't be read"
737
771
  end
738
772
 
739
773
  # One warning per manifest key this chi's parser never reads (docs/cli.md's