samagotchi 0.156.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +12 -7
  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/bridge.md +83 -6
  11. data/docs/internals/bundles.md +28 -18
  12. data/docs/internals/context-telemetry.md +4 -3
  13. data/docs/internals/engine.md +16 -2
  14. data/docs/internals/participants.md +3 -2
  15. data/docs/internals/remote-sessions.md +5 -2
  16. data/docs/internals/web-frontend.md +3 -2
  17. data/docs/internals/working-set.md +1 -1
  18. data/docs/memory.md +13 -2
  19. data/docs/plugins.md +35 -2
  20. data/docs/sessions.md +9 -9
  21. data/docs/sub-agent.md +17 -2
  22. data/docs/testing.md +53 -2
  23. data/lib/samagotchi/answer_command.rb +2 -1
  24. data/lib/samagotchi/archive_store.rb +4 -3
  25. data/lib/samagotchi/bootstrap_command.rb +3 -2
  26. data/lib/samagotchi/bridge/card_places.rb +10 -4
  27. data/lib/samagotchi/bridge/card_store.rb +3 -0
  28. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  29. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  30. data/lib/samagotchi/bridge.rb +31 -6
  31. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  32. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  33. data/lib/samagotchi/bundle_command/check.rb +18 -0
  34. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  35. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  36. data/lib/samagotchi/bundle_command.rb +5 -2
  37. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  38. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  39. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  40. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  41. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  42. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  43. data/lib/samagotchi/child_move.rb +6 -6
  44. data/lib/samagotchi/child_reports.rb +2 -2
  45. data/lib/samagotchi/children_status.rb +15 -21
  46. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  47. data/lib/samagotchi/config.rb +3 -0
  48. data/lib/samagotchi/context_sources.rb +5 -4
  49. data/lib/samagotchi/context_window.rb +9 -12
  50. data/lib/samagotchi/effective_model.rb +57 -22
  51. data/lib/samagotchi/engine/plugins.rb +1 -1
  52. data/lib/samagotchi/engine/recap.rb +8 -4
  53. data/lib/samagotchi/engine.rb +29 -3
  54. data/lib/samagotchi/events.rb +3 -2
  55. data/lib/samagotchi/extension_load.rb +6 -3
  56. data/lib/samagotchi/guardrails_command.rb +1 -1
  57. data/lib/samagotchi/host_address.rb +52 -0
  58. data/lib/samagotchi/host_registry.rb +57 -45
  59. data/lib/samagotchi/kernel_loop.rb +7 -3
  60. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  61. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  62. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  63. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  64. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  65. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  66. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  67. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  68. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  69. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  70. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  71. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  72. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  73. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  74. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  75. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  76. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  77. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  78. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  79. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  80. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  81. data/lib/samagotchi/memory_usage.rb +1 -1
  82. data/lib/samagotchi/model_lookup.rb +75 -0
  83. data/lib/samagotchi/model_profile.rb +1 -0
  84. data/lib/samagotchi/models_command.rb +1 -1
  85. data/lib/samagotchi/note_command.rb +1 -1
  86. data/lib/samagotchi/participant_mode.rb +2 -1
  87. data/lib/samagotchi/plugin/api.rb +41 -6
  88. data/lib/samagotchi/plugin/loader.rb +33 -10
  89. data/lib/samagotchi/plugin/sessions.rb +1 -1
  90. data/lib/samagotchi/reasoning_history.rb +14 -20
  91. data/lib/samagotchi/recap_place.rb +27 -0
  92. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  93. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  94. data/lib/samagotchi/reply_wait/target.rb +113 -0
  95. data/lib/samagotchi/reply_wait.rb +22 -2
  96. data/lib/samagotchi/sampling_settings.rb +9 -16
  97. data/lib/samagotchi/self_report.rb +34 -35
  98. data/lib/samagotchi/send_command.rb +17 -23
  99. data/lib/samagotchi/session.rb +321 -439
  100. data/lib/samagotchi/session_access/local.rb +5 -6
  101. data/lib/samagotchi/session_access/remote.rb +5 -3
  102. data/lib/samagotchi/session_field.rb +133 -0
  103. data/lib/samagotchi/session_inbox.rb +49 -1
  104. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  105. data/lib/samagotchi/session_manager/listing.rb +29 -100
  106. data/lib/samagotchi/session_manager.rb +2 -1
  107. data/lib/samagotchi/session_metrics.rb +7 -4
  108. data/lib/samagotchi/session_paths.rb +57 -0
  109. data/lib/samagotchi/session_retention.rb +2 -2
  110. data/lib/samagotchi/session_row.rb +200 -0
  111. data/lib/samagotchi/session_setup.rb +15 -12
  112. data/lib/samagotchi/sessions_command.rb +11 -129
  113. data/lib/samagotchi/steer_cut_after.rb +7 -14
  114. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  115. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  116. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  117. data/lib/samagotchi/terminal_ui.rb +3 -1
  118. data/lib/samagotchi/testing/install.rb +2 -1
  119. data/lib/samagotchi/thinking.rb +16 -21
  120. data/lib/samagotchi/tool_response.rb +5 -3
  121. data/lib/samagotchi/tool_runner.rb +18 -1
  122. data/lib/samagotchi/tools/delegate.rb +48 -43
  123. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  124. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  125. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  126. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  127. data/lib/samagotchi/update_command.rb +11 -7
  128. data/lib/samagotchi/verify_fresh.rb +7 -11
  129. data/lib/samagotchi/version.rb +1 -1
  130. data/lib/samagotchi/vision_support.rb +11 -14
  131. data/lib/samagotchi/web/app.rb +31 -21
  132. data/lib/samagotchi/web/message_display.rb +57 -7
  133. data/lib/samagotchi/web/participant_access.rb +1 -1
  134. data/lib/samagotchi/web/public/activity.js +16 -7
  135. data/lib/samagotchi/web/public/app.js +29 -8
  136. data/lib/samagotchi/web/public/bubbles.js +8 -3
  137. data/lib/samagotchi/web/public/cards_place.js +15 -0
  138. data/lib/samagotchi/web/public/commands.js +5 -1
  139. data/lib/samagotchi/web/public/data.js +4 -2
  140. data/lib/samagotchi/web/public/history_shape.js +220 -0
  141. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  142. data/lib/samagotchi/web/public/reload_check.js +71 -0
  143. data/lib/samagotchi/web/public/stage_model.js +7 -5
  144. data/lib/samagotchi/web/public/stage_view.js +7 -3
  145. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  146. data/lib/samagotchi/web/public/timing.js +43 -2
  147. data/lib/samagotchi/web/public/turn_block.js +215 -30
  148. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  149. data/lib/samagotchi/web/public/turn_events.js +10 -1
  150. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  151. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  152. data/lib/samagotchi/web/server.rb +4 -2
  153. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  154. data/lib/samagotchi/web/session_control.rb +1 -1
  155. data/lib/samagotchi/web/session_hub.rb +9 -17
  156. data/lib/samagotchi/web/session_show.rb +64 -2
  157. data/lib/samagotchi/web/session_summary.rb +36 -24
  158. data/lib/samagotchi/worker.rb +28 -5
  159. data/lib/samagotchi/worker_link.rb +3 -0
  160. metadata +20 -4
  161. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -34,6 +34,19 @@ 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 hook, rule, plugin or
38
+ # script: AssetInstaller writes them too), as #results holds it. word is
39
+ # the report bucket (#summary): a real run's (:installed, :updated,
40
+ # :merged, :kept, :kept_pruned, :kept_shared, :kept_own (a file left as
41
+ # the user's), :up_to_date, :conflict, :removed) or a dry run's
42
+ # (:would_install, :would_update, :would_merge, :would_skip, :would_remove,
43
+ # and an upgrade's :fast_forward and :keep). state and action: the
44
+ # memory's FileState and FileActions::Action, nil where none was read.
45
+ # note: why, in words (a conflict's Behind#state, a kept file's reason).
46
+ FileResult = Data.define(:word, :state, :action, :note) do
47
+ def initialize(word:, state: nil, action: nil, note: nil) = super
48
+ end
49
+
37
50
  attr_reader :results, :warnings, :placeholder_warnings, :conflicts
38
51
 
39
52
  # Each file this run left behind (FileStanding::Behind).
@@ -65,7 +78,7 @@ module Samagotchi
65
78
  @conflicts = {}
66
79
  @behind = []
67
80
  @replaced = []
68
- @outcomes = {}
81
+ @actions = {}
69
82
  @overlays = []
70
83
  @trash = Trash.new(name)
71
84
  end
@@ -82,7 +95,7 @@ module Samagotchi
82
95
  def trash_copy_line = @trash.copy_line
83
96
 
84
97
  # 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
98
+ def merged_files = with_word(:merged)
86
99
 
87
100
  # The normalized source dir. An owned one (a git/zip/tar extract) is
88
101
  # removed after #run, or kept until #cleanup_source! when an upgrade
@@ -140,28 +153,26 @@ module Samagotchi
140
153
  def summary
141
154
  lines = []
142
155
  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
156
+ installed = with_word(:installed)
157
+ updated = with_word(:updated)
145
158
  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
159
+ would_merge = with_word(:would_merge)
160
+ kept = with_word(:kept, :keep, :kept_pruned)
148
161
  # A file whose bytes are already the bundle's was not left out: it is
149
162
  # 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
163
+ # memory, and a dry run's would_skip.
164
+ up_to_date = with_word(:up_to_date)
165
+ skipped = with_word(:would_skip, :kept_own)
166
+ would_install = with_word(:would_install)
167
+ would_update = with_word(:would_update)
168
+ fast_forward = with_word(:fast_forward)
169
+ removed = with_word(:removed)
170
+ would_remove = with_word(:would_remove)
161
171
 
162
172
  lines << "Installed: #{installed.join(", ")}" unless installed.empty?
163
173
  lines << "Updated: #{updated.join(", ")}" unless updated.empty?
164
174
  lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
175
+ lines << "Would update: #{would_update.join(", ")}" unless would_update.empty?
165
176
  lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
166
177
  lines << FileStanding.merged_line(merged, @manifest&.version) unless merged.empty?
167
178
  lines << FileStanding.merged_line(would_merge, @manifest&.version, dry_run: true) unless would_merge.empty?
@@ -198,6 +209,9 @@ module Samagotchi
198
209
 
199
210
  private
200
211
 
212
+ # The files whose result has one of +words+, in the order they ran.
213
+ def with_word(*words) = @results.select { |_, result| words.include?(result.word) }.keys
214
+
201
215
  # A plain install over a bundle that is already installed from the same
202
216
  # source: it rewrote the bundle's hooks, rules, plugin, scripts and its
203
217
  # record, and left a memory the user wrote as it was. So the line says
@@ -205,9 +219,9 @@ module Samagotchi
205
219
  def reinstall_line
206
220
  line = "#{@name} was already installed from #{@source}: its hooks, rules, plugin and scripts " \
207
221
  "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" }
222
+ # :kept_own is the only way a memory is left out of a plain install
223
+ # (its KEPT_OWN or HELD row): its bytes differ from the bundle's.
224
+ if @results.any? { |_, r| r.word == :kept_own }
211
225
  line << "; your own copies of its memories were kept (chi bundle upgrade #{@name} " \
212
226
  "merges the bundle's changes into them)"
213
227
  end
@@ -374,47 +388,38 @@ module Samagotchi
374
388
 
375
389
  # Copies the bundle's memories (MemoryFiles: the files: list, else
376
390
  # 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.
391
+ # their index lines. Each file's FileState and its command's row
392
+ # (FileActions: INSTALL, FORCE, UPGRADE) say what happens: a dry run
393
+ # only reads the row (preview_memory), a real run does it
394
+ # (write_memory). Sets @bundle_md (every file the bundle ships) and
395
+ # @owned: the bundle owns (records, upgrades, removes) only the files it
396
+ # wrote, these from an earlier install and the ones written now. A
397
+ # same-name file that was already there is the user's: skipped and never
398
+ # recorded (the not-owned rows).
383
399
  def install_memories
384
- owned_before = @existing ? @existing.files.keys : []
385
400
  @bundle_md = []
386
401
  @owned = []
387
402
  warn_unshipped_files
388
403
  warn_symlinked_files
389
404
  memory_files.each do |file_key|
390
- file_path = File.join(@bundle_dir, file_key)
391
405
  @bundle_md << file_key
392
406
  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
407
+ state = FileState.of(file_key, record: @existing, provenance: @provenance, target_dir: @target_dir,
408
+ source_dir: @bundle_dir)
409
+ action = FileActions.for(memory_command, state)
410
+ next preview_memory(file_key, state, action) if @dry_run
411
+
412
+ @owned << file_key unless action.record == :not_owned
413
+ write_memory(file_key, state, action)
414
+ end
415
+ end
416
+
417
+ # The table a memory's row is read from: --force (install or upgrade:
418
+ # one code path), an upgrade over a record, else a plain install.
419
+ def memory_command
420
+ if @force then :force
421
+ elsif @upgrade && @existing then :upgrade
422
+ else :install
418
423
  end
419
424
  end
420
425
 
@@ -434,42 +439,64 @@ module Samagotchi
434
439
  MemoryFiles.symlinks(@bundle_dir, @manifest).each { |name| @warnings << "#{name} is a symlink (not installed)" }
435
440
  end
436
441
 
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
444
- FileUtils.cp(file_path, target_path)
445
- @results[file_key] = { status: "installed" }
446
- when :fast_forward
447
- save_marked_file(file_key, target_path)
442
+ # One memory's row, done: the bundle's text written (a copy of other
443
+ # bytes in the trash first), the text merge's result written, the file
444
+ # kept or left behind (a conflict, recorded in @conflicts for the agent
445
+ # step), or skipped as the user's; its index line refreshed. A :decide
446
+ # cell is the upgrade's (Merger::Upgrade#decision: the text merge or the
447
+ # legacy guard, against the base the last install recorded).
448
+ def write_memory(file_key, state, action)
449
+ file_path = state.paths[:incoming]
450
+ target_path = state.paths[:current]
451
+ decision = upgrade_view.decision(file_key, state) if action.do == :decide
452
+ action = decision.action if decision
453
+ @actions[file_key] = action
454
+ @replaced << file_key if @force && state.digests.disk
455
+ result = FileResult.new(word: action.word, state: state, action: action)
456
+ case action.do
457
+ when :write_incoming
458
+ save_first(file_key, file_path, target_path) if action.save_first
448
459
  FileUtils.cp(file_path, target_path)
449
- @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
460
+ @results[file_key] = action.word == :updated ? result.with(note: "auto-merged (not edited)") : result
461
+ when :merge
462
+ if Merger.write_merged(target_path, decision.outcome, @trash)
463
+ @results[file_key] = result.with(note: "the bundle's changes joined to local edits")
464
+ else
465
+ @actions[file_key] = FileActions::BEHIND
466
+ note_conflict(file_key, Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)), state)
467
+ end
450
468
  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" }
469
+ if action.behind?
470
+ note_conflict(file_key, (decision || upgrade_view.decision(file_key, state)).outcome, state)
471
+ elsif action.word == :kept
472
+ @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
473
  else
458
- note_conflict(file_key, @outcomes[file_key] = Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)))
474
+ @results[file_key] = result
459
475
  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
476
+ when :skip
477
+ @results[file_key] = result.with(note: "already exists")
478
+ @warnings << "Skipped #{file_key} (already exists; #{FileStanding::FORCE_TAKES})"
479
+ when :refuse then refuse_unreadable(file_key, target_path)
480
+ else raise ArgumentError, "no install step for #{action.do.inspect} (#{file_key})" # the base would move with nothing written
463
481
  end
464
- update_target_index(@target_scope, target_path, file_key)
482
+ update_target_index(@target_scope, target_path, file_key, owned: action.record != :not_owned)
483
+ end
484
+
485
+ # A file chi can't read: whether it was edited can't be told, so the
486
+ # run stops (the read raises; Trash.failure_message says nothing was
487
+ # lost).
488
+ def refuse_unreadable(file_key, target_path)
489
+ FileDigest.hex(target_path)
490
+ raise InstallError, "#{file_key} can't be read"
465
491
  end
466
492
 
467
493
  # The file stays as it is, with the old base and the mark, in FileStanding's
468
494
  # words.
469
- def note_conflict(file_key, outcome)
495
+ def note_conflict(file_key, outcome, state = nil)
470
496
  behind = upgrade_view.behind(file_key, outcome, @manifest&.version)
471
497
  @behind << behind
472
- @results[file_key] = { status: "conflict", reason: behind.state(dry_run: @dry_run) }
498
+ @results[file_key] = FileResult.new(word: :conflict, state: state, action: state && FileActions::BEHIND,
499
+ note: behind.state(dry_run: @dry_run))
473
500
  @conflicts[file_key] = upgrade_view.paths(file_key)
474
501
  @warnings << "#{behind.line(dry_run: @dry_run)} (#{FileStanding::FORCE_TAKES})"
475
502
  end
@@ -488,58 +515,54 @@ module Samagotchi
488
515
  @upgrade_view ||= Merger::Upgrade.new(provenance: @provenance, target_dir: @target_dir, source_dir: @bundle_dir)
489
516
  end
490
517
 
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)
518
+ # A save_first cell: the file's other bytes go to the trash before the
519
+ # bundle's text replaces them. Under an upgrade that is a marked file
520
+ # that equals its base, and the update goes ahead: the base is known to
521
+ # be real (this chi's mark, or ShippedHistory), or no line of the file
522
+ # goes (Merger::Upgrade's legacy guard stops an older chi's mark
523
+ # otherwise), and it says so.
524
+ def save_first(file_key, file_path, target_path)
525
+ return unless File.exist?(target_path) && !FileUtils.identical?(file_path, target_path)
497
526
 
498
527
  @trash.copy(target_path)
528
+ return if @force
529
+
499
530
  @warnings << "#{file_key} was marked as kept but matched its recorded base, so it took the update; your copy is in the trash"
500
531
  end
501
532
 
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
533
+ # A dry run's word for an action's (the real run's): what would happen,
534
+ # nothing written. A conflict is reported as the real run would.
535
+ DRY_WORDS = { installed: :would_install, updated: :fast_forward, kept: :keep, merged: :would_merge,
536
+ kept_own: :would_skip, up_to_date: :up_to_date }.freeze
509
537
 
510
- if @upgrade && @existing
511
- outcome = upgrade_view.outcome(file_key)
512
- return note_conflict(file_key, outcome) if outcome.conflict?
538
+ # A dry run's verdict for one file: its row read, not done (a :decide
539
+ # cell's text merge runs on temp copies). --force would install every
540
+ # file, unread.
541
+ def preview_memory(file_key, state, action)
542
+ return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
543
+ return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
513
544
 
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
545
+ decision = upgrade_view.decision(file_key, state) if action.do == :decide || action.behind?
546
+ action = decision.action if decision
547
+ return note_conflict(file_key, decision.outcome, state) if action.behind?
548
+
549
+ @results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
520
550
  end
521
551
 
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.
552
+ # Strict mode: a memory that takes the bundle's text (Action#takes_source?:
553
+ # a new or missing file, --force, an upgrade's or a re-install's
554
+ # fast-forward, a merge into a local edit) warns when the bundle's file
555
+ # differs from its manifest's sha256. One kept or skipped isn't checked
556
+ # (it takes nothing from the bundle). What is checked is the source
557
+ # file, the bytes the write or merge takes, so a dry run checks what it
558
+ # would bring in, never the user's bytes on disk.
526
559
  def verify_memory_checksums
527
560
  @bundle_md.each do |file_key|
528
- next if @conflicts.key?(file_key)
561
+ result = @results[file_key]
562
+ next unless result&.action&.takes_source?
529
563
 
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)
532
-
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
538
-
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
564
+ warning = FileDigest.mismatch(file_key, result.state.paths[:incoming], @manifest.checksum_for(file_key))
565
+ @warnings << warning if warning
543
566
  end
544
567
  end
545
568
 
@@ -573,6 +596,7 @@ module Samagotchi
573
596
  hooks_files: installed.hook_files,
574
597
  guardrails_files: installed.guardrail_files,
575
598
  plugin_file: installed.plugin_file,
599
+ plugin_required: @manifest.plugin&.dig(:required) == true,
576
600
  requires_chi: @manifest.requires_chi,
577
601
  needs: @manifest.needs,
578
602
  marks: @behind + held_conflict_marks,
@@ -600,7 +624,7 @@ module Samagotchi
600
624
  def held_conflict_marks
601
625
  return [] if @upgrade || @existing.nil?
602
626
 
603
- kept = @owned.select { |key| @results.dig(key, :reason) == "already exists" && @existing.files.dig(key, :conflict) }
627
+ kept = @owned.select { |key| @actions[key].record == :hold_mark }
604
628
  kept.map { |key| held_mark(key) }
605
629
  end
606
630
 
@@ -624,26 +648,26 @@ module Samagotchi
624
648
  @owned.each do |file_key|
625
649
  next unless File.exist?(File.join(@target_dir, file_key))
626
650
 
627
- files[file_key] = next_base(file_key, @outcomes.fetch(file_key))
651
+ files[file_key] = next_base(file_key, @actions.fetch(file_key))
628
652
  end
629
653
  @existing.files.each_key do |old_key_str|
630
654
  next if @bundle_md.include?(old_key_str)
631
- next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
655
+ next unless @results[old_key_str]&.word == :kept_pruned
632
656
 
633
657
  files[old_key_str] = recorded_base(@provenance.base_path(old_key_str))
634
658
  end
635
659
  files
636
660
  end
637
661
 
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
662
+ # The file an action's next_base names. nil for a kept file with no
663
+ # base: the user's file must not become one, or the next upgrade
664
+ # fast-forwards over it.
665
+ def next_base(file_key, action)
666
+ case action.next_base
643
667
  when :recorded then recorded_base(@provenance.base_path(file_key))
644
668
  when :on_disk then File.join(@target_dir, file_key)
645
669
  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
670
+ else raise ArgumentError, "unknown base #{action.next_base.inspect} for #{file_key}" # nil would drop the base silently
647
671
  end
648
672
  end
649
673
 
@@ -656,11 +680,9 @@ module Samagotchi
656
680
  # sees the edit (not a fast-forward over it).
657
681
  def install_provenance_files
658
682
  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)
683
+ next unless File.exist?(File.join(@target_dir, file_key))
661
684
 
662
- kept = @existing && @results.dig(file_key, :reason) == "already exists"
663
- map[file_key] = kept ? recorded_base(@provenance.base_path(file_key)) : target_path
685
+ map[file_key] = next_base(file_key, @actions.fetch(file_key))
664
686
  end
665
687
  files.merge(dropped_owned_files)
666
688
  end
@@ -682,58 +704,52 @@ module Samagotchi
682
704
  end
683
705
  end
684
706
 
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.
707
+ # A file the previous version installed that this one doesn't ship:
708
+ # its FileState's dropped kind and the UPGRADE (or FORCE) row. An
709
+ # unedited one is moved to the trash (with its index line removed), as
710
+ # is any with --force; an edited one is kept with a note, and one
711
+ # another bundle lists too stays.
706
712
  def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
707
- previous_files.each do |key, meta|
713
+ previous_files.each_key do |key|
708
714
  next if bundle_files.include?(key)
709
715
 
710
716
  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" }
717
+ state = FileState.of(key, record: @existing, provenance: provenance, target_dir: target_dir, source_dir: @bundle_dir,
718
+ shipped: false, claimants: ->(k) { Provenance.claimants(k, scope: scope, except: @name) })
719
+ action = if state.kind == :unreadable
720
+ unreadable_dropped(state, target)
721
+ else
722
+ FileActions.for(@force ? :force : :upgrade, state)
723
+ end
724
+ result = FileResult.new(word: action.word, state: state, action: action)
725
+ case action.word
726
+ when :kept_shared
727
+ others = state.shared_with.join(", ")
728
+ @results[key] = result.with(note: "bundle #{others} has it too")
729
+ @warnings << "Kept #{key}: no longer in the bundle, but bundle #{others} has it too"
730
+ when :removed
731
+ @results[key] = result.with(word: @dry_run ? :would_remove : :removed, note: "no longer in the bundle")
719
732
  next if @dry_run
720
733
 
721
734
  @trash.move(target)
722
735
  remove_target_index(scope, key)
723
- else
724
- @results[key] = { status: "kept_pruned", reason: "local edits preserved" }
736
+ when :kept_pruned
737
+ @results[key] = result.with(note: "local edits preserved")
725
738
  @warnings << "Kept #{key}: no longer in the bundle but edited locally (#{FileStanding::FORCE_MOVES})"
726
739
  end
727
740
  end
728
741
  end
729
742
 
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?
743
+ # A dropped file chi can't read, in the order the rows are asked: kept
744
+ # when another bundle lists it, moved unread with --force; otherwise
745
+ # whether it was edited can't be told, and the read raises (the run
746
+ # stops, nothing lost).
747
+ def unreadable_dropped(state, target)
748
+ return FileActions::KEPT_SHARED if state.shared_with.any?
749
+ return FileActions::REMOVE if @force
735
750
 
736
- File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
751
+ FileDigest.hex(target)
752
+ raise InstallError, "#{state.key} can't be read"
737
753
  end
738
754
 
739
755
  # One warning per manifest key this chi's parser never reads (docs/cli.md's
@@ -26,6 +26,8 @@ module Samagotchi
26
26
  # plugin: # optional — the bundle's plugin.rb (docs/plugins.md)
27
27
  # file: plugin.rb
28
28
  # sha256: sha256:0a1b...
29
+ # required: true # optional: a guardrail; when it fails to load
30
+ # # chi denies every tool call (docs/hooks.md)
29
31
  # requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
30
32
  # requires_chi_next: true # optional, shipped bundles — ignored here:
31
33
  # # `rake release:bump` raises requires_chi to
@@ -100,7 +102,8 @@ module Samagotchi
100
102
  "settings.types" => "0.92.0",
101
103
  "hooks.events" => "0.98.0",
102
104
  "settings" => "0.143.0",
103
- "block_renderers" => "0.151.0"
105
+ "block_renderers" => "0.151.0",
106
+ "plugin.required" => "0.157.0"
104
107
  }.freeze
105
108
 
106
109
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
@@ -249,6 +252,7 @@ module Samagotchi
249
252
  found["scripts"] = SINCE["scripts"] unless @scripts.empty?
250
253
  found["context_providers"] = SINCE["context_providers"] unless @context_providers.empty?
251
254
  found["block_renderers"] = SINCE["block_renderers"] unless @block_renderers.empty?
255
+ found["plugin.required"] = SINCE["plugin.required"] if @plugin&.dig(:required) && SINCE.key?("plugin.required")
252
256
  found["session_settings"] = SINCE["session_settings"] unless @session_settings.empty?
253
257
  found["settings"] = SINCE["settings"] unless @settings.nil?
254
258
  found["files.description"] = SINCE["files.description"] if raw["files"].is_a?(Hash) && raw["files"].values.any?(Hash)
@@ -281,6 +285,7 @@ module Samagotchi
281
285
  if plugin
282
286
  sha = plugin[:sha256].to_s
283
287
  manifest["plugin"] = { "file" => plugin[:file].to_s, "sha256" => sha.start_with?("sha256:") ? sha : "sha256:#{sha}" }
288
+ manifest["plugin"]["required"] = true if plugin[:required]
284
289
  end
285
290
  manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
286
291
  needs = parse_needs(needs)
@@ -471,8 +476,10 @@ module Samagotchi
471
476
  end
472
477
  end
473
478
 
474
- # plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
475
- # or nil; a file that isn't a plain .rb name is a validation error.
479
+ # plugin: {file:, sha256:, required:} → {file:, sha256:} (sha "" when
480
+ # not given) with required: true when it says so, or nil; a file that
481
+ # isn't a plain .rb name, or a required: that isn't true or false, is a
482
+ # validation error.
476
483
  def parse_plugin(raw)
477
484
  return nil if raw.nil?
478
485
  raise ValidationError, "plugin: must be a mapping with file: and sha256:" unless raw.is_a?(Hash)
@@ -484,7 +491,10 @@ module Samagotchi
484
491
 
485
492
  sha = (raw["sha256"] || raw[:sha256]).to_s.strip
486
493
  sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
487
- { file: file, sha256: sha }
494
+ required = raw.key?("required") ? raw["required"] : raw[:required]
495
+ raise ValidationError, "plugin required: must be true or false, not #{required.inspect}" unless [nil, true, false].include?(required)
496
+
497
+ required ? { file: file, sha256: sha, required: true } : { file: file, sha256: sha }
488
498
  end
489
499
 
490
500
  # scripts: {file => sha256} → the same, "sha256:" prefixed; {} when absent.