samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -10,7 +10,7 @@ require_relative "placeholder"
10
10
  require_relative "index_updater"
11
11
  require_relative "merger"
12
12
  require_relative "trash"
13
- require_relative "../version"
13
+ require_relative "asset_installer"
14
14
  require_relative "../bundle_needs"
15
15
  require_relative "../model_overlay"
16
16
 
@@ -18,13 +18,15 @@ module Samagotchi
18
18
  module MemoryBundle
19
19
  # Installs a bundle into a scoped memories directory.
20
20
  #
21
- # Flow:
22
- # 1. Normalize source → directory (returns [path, owned])
23
- # 2. Read manifest.yml
24
- # 3. Copy .md files to target scope dir (skip or force)
25
- # 4. Verify checksums (strict mode)
26
- # 5. Update index.md for each entry
27
- # 6. Write provenance (.bundles/<name>/)
21
+ # Flow (#run):
22
+ # 1. Normalize source → directory, read manifest.yml (load_source)
23
+ # 2. Copy .md files to the target scope dir and update index.md
24
+ # (install_memories: plain, dry run, or an upgrade's 3-way merge)
25
+ # 3. Hooks, guardrail rules, plugin, scripts into .bundles/<name>/
26
+ # (AssetInstaller)
27
+ # 4. An upgrade prunes the files the bundle dropped
28
+ # 5. Verify checksums (strict mode), warn about missing needs
29
+ # 6. Write provenance (.bundles/<name>/manifest.json, write_provenance)
28
30
  # 7. Detect and report {{placeholders}}
29
31
  class Installer
30
32
  class InstallError < StandardError; end
@@ -51,8 +53,10 @@ module Samagotchi
51
53
  # none moved).
52
54
  def trash_dir = @trash.dir
53
55
 
54
- # The normalized source dir, kept alive past #run when an upgrade has
55
- # conflicts (the agent step reads the incoming files); nil once cleaned.
56
+ # The normalized source dir. An owned one (a git/zip/tar extract) is
57
+ # removed after #run, or kept until #cleanup_source! when an upgrade
58
+ # has conflicts (the agent step reads the incoming files); nil once
59
+ # cleaned.
56
60
  attr_reader :source_dir
57
61
 
58
62
  # Removes the normalized source dir an owned source (git/zip/tar) was
@@ -60,395 +64,49 @@ module Samagotchi
60
64
  # conflict prompt and resolve_conflicts can still read the incoming
61
65
  # files; the caller runs it once the agent step is done. Idempotent.
62
66
  def cleanup_source!
63
- return unless @defer_cleanup && @normalized_dir
67
+ return unless @defer_cleanup && @source_dir
64
68
 
65
- SourceNormalizer.cleanup(@normalized_dir)
66
- @normalized_dir = nil
69
+ SourceNormalizer.cleanup(@source_dir)
70
+ @source_dir = nil
67
71
  @defer_cleanup = false
68
72
  end
69
73
 
70
74
  def run
71
- normalized_dir = nil
72
- source_owned = false
73
- source_commit = nil
74
- manifest = nil
75
- begin
76
- normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
77
- @bundle_dir = normalized_dir
78
- @normalized_dir = normalized_dir
79
- manifest = Manifest.read(dir: normalized_dir)
80
- rescue SourceNormalizer::UnknownSourceError => e
81
- raise InstallError, "Source normalization failed: #{e.message}"
82
- rescue Manifest::ValidationError => e
83
- raise InstallError, e.message if @strict
84
-
85
- @warnings << "No manifest found — proceeding without strict manifest validation"
86
- end
87
-
88
- # Determine target scope (CLI wins over manifest).
89
- cli_scope = @scope.to_s.strip.empty? ? nil : @scope
90
- target_scope = cli_scope || manifest&.scope || "system"
91
- target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
92
- FileUtils.mkdir_p(target_dir)
93
-
94
- # Copy .md files and update index for each.
95
- @file_descriptions = manifest&.file_descriptions || {}
96
- all_files_in_bundle = []
97
- provenance = Provenance.new(name: @name)
98
- existing_provenance = provenance.read
75
+ load_source
76
+ @target_scope, @target_dir = resolve_target
77
+ @file_descriptions = @manifest&.file_descriptions || {}
78
+ @provenance = Provenance.new(name: @name)
79
+ @existing = @provenance.record
99
80
  # A plain install over an installed bundle: summary points at upgrade.
100
- @reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
101
- # The bundle owns (records, upgrades, removes) only the files it
102
- # wrote: these, from an earlier install, and the ones written now.
103
- # A same-name file that was already there is the user's: skipped
104
- # and never recorded.
105
- owned_before = existing_provenance && existing_provenance[:files].is_a?(Hash) ? existing_provenance[:files].keys.map(&:to_s) : []
106
- owned = []
107
-
108
- Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
109
- file_key = File.basename(file_path)
110
- all_files_in_bundle << file_key
111
- note_overlay(file_key, target_dir)
112
- target_path = File.join(target_dir, file_key)
113
-
114
- if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
115
- # Not the bundle's (the user's, or another bundle's): no merge.
116
- skip_existing(file_key, file_path, target_path, target_scope)
117
- elsif @upgrade && existing_provenance && !@force && !@dry_run
118
- # 3-way merge path for upgrades
119
- owned << file_key
120
- base_path = provenance.base_path(file_key)
121
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
122
- case classification
123
- when :install
124
- FileUtils.cp(file_path, target_path)
125
- @results[file_key] = { status: "installed" }
126
- update_target_index(target_scope, target_path, file_key)
127
- when :fast_forward
128
- FileUtils.cp(file_path, target_path)
129
- @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
130
- update_target_index(target_scope, target_path, file_key)
131
- when :keep
132
- @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
133
- update_target_index(target_scope, target_path, file_key)
134
- when :noop
135
- @results[file_key] = { status: "skipped", reason: "already up to date" }
136
- update_target_index(target_scope, target_path, file_key)
137
- when :conflict
138
- @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
139
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
140
- @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
141
- update_target_index(target_scope, target_path, file_key)
142
- end
143
- elsif @dry_run
144
- # Dry-run: classify but do not write
145
- if File.exist?(target_path) && !@force
146
- if @upgrade && existing_provenance
147
- base_path = provenance.base_path(file_key)
148
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
149
- @results[file_key] = { status: classification.to_s }
150
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
151
- elsif FileUtils.identical?(file_path, target_path)
152
- @results[file_key] = { status: "skipped", reason: "already up to date" }
153
- else
154
- @results[file_key] = { status: "would_skip" }
155
- end
156
- else
157
- @results[file_key] = { status: "would_install" }
158
- end
159
- elsif File.exist?(target_path) && !@force
160
- # A re-install keeps what an earlier install wrote.
161
- owned << file_key if owned_before.include?(file_key)
162
- skip_existing(file_key, file_path, target_path, target_scope, owned: owned_before.include?(file_key))
163
- else
164
- FileUtils.cp(file_path, target_path) unless @dry_run
165
- owned << file_key
166
- @results[file_key] = { status: "installed" }
167
- # Update index for newly installed files.
168
- update_target_index(target_scope, target_path, file_key) unless @dry_run
169
- end
170
- end
171
-
172
- # ── Hooks: copy hooks/*.rb into <bundle_dir>/hooks/ ───────────────
173
- hooks_target = File.join(provenance.bundle_dir, "hooks")
174
- all_hooks_in_bundle = []
175
- hooks_files_for_provenance = {}
176
- if manifest
177
- hook_entries = manifest.hooks
178
- # Fallback: if manifest has no hooks but source has hooks/*.rb, discover them
179
- if hook_entries.empty?
180
- discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
181
- discovered.each do |bn|
182
- # Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
183
- hook_entries[bn] = { sha256: "", event: "", on_error: "skip", priority: 100 }
184
- end
185
- end
186
-
187
- hook_entries.each do |basename, _meta|
188
- next unless basename.is_a?(String) && !basename.empty?
189
-
190
- all_hooks_in_bundle << basename
191
- src = File.join(normalized_dir, "hooks", basename)
192
- next unless File.exist?(src)
193
-
194
- dest = File.join(hooks_target, basename)
195
-
196
- if @dry_run
197
- @results[basename] = if File.exist?(dest) && !@force
198
- { status: "would_skip" }
199
- else
200
- { status: "would_install" }
201
- end
202
- next
203
- end
204
-
205
- # Local-edit detection for upgrade path (overwrite + warn)
206
- if @upgrade && existing_provenance && File.exist?(dest) && !@force
207
- prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
208
- if prev_meta
209
- prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
210
- if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
211
- @warnings << "Hook #{basename} was locally modified; overwriting"
212
- end
213
- end
214
- end
215
-
216
- FileUtils.mkdir_p(hooks_target)
217
- unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
218
- FileUtils.cp(src, dest)
219
- @results[basename] = if unchanged
220
- { status: "skipped", reason: "already up to date" }
221
- elsif @upgrade && existing_provenance
222
- { status: "updated" }
223
- else
224
- { status: "installed" }
225
- end
226
- hooks_files_for_provenance[basename] = dest
227
- end
228
- else
229
- # No manifest (non-strict): still copy any hooks/*.rb if present
230
- Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).each do |src|
231
- basename = File.basename(src)
232
- all_hooks_in_bundle << basename
233
- dest = File.join(hooks_target, basename)
234
- if @dry_run
235
- @results[basename] = { status: "would_install" }
236
- else
237
- FileUtils.mkdir_p(hooks_target)
238
- FileUtils.cp(src, dest)
239
- @results[basename] = { status: "installed" }
240
- hooks_files_for_provenance[basename] = dest
241
- end
242
- end
243
- end
244
-
245
- # Prune stale hooks (removed from bundle) — reuse bases pruning via provenance but also remove files
246
- if @upgrade && existing_provenance && existing_provenance[:hooks] && !@dry_run
247
- existing_provenance[:hooks].keys.each do |old_key|
248
- old_key_str = old_key.to_s
249
- next if all_hooks_in_bundle.include?(old_key_str)
250
-
251
- old_dest = File.join(hooks_target, old_key_str)
252
- if File.exist?(old_dest) && !@force
253
- @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
254
- end
255
- FileUtils.rm_f(old_dest)
256
- end
257
- end
258
-
259
- # Verify hook checksums in strict mode (mirror .md block)
260
- if manifest && @strict
261
- all_hooks_in_bundle.each do |basename|
262
- next if @conflicts.key?(basename)
263
-
264
- expected = manifest.checksum_for_hook(basename)
265
- next unless expected
266
-
267
- dest = File.join(hooks_target, basename)
268
- next unless File.exist?(dest)
269
-
270
- actual = Digest::SHA256.hexdigest(File.read(dest))
271
- if actual != expected
272
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
273
- end
274
- end
275
- end
276
-
277
- # ── Guardrail rules: guardrails/*.yml into <bundle_dir>/guardrails/ ─
278
- # The bundle's set replaces what an earlier version installed.
279
- guardrails_target = provenance.guardrails_dir
280
- guardrail_files_for_provenance = {}
281
- incoming_rules = Dir.glob(File.join(normalized_dir, "guardrails", "*.yml")).sort
282
- if @dry_run
283
- incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
284
- else
285
- # Same bytes as installed: reported as up to date, not "Installed".
286
- unchanged = incoming_rules.select do |src|
287
- dest = File.join(guardrails_target, File.basename(src))
288
- File.file?(dest) && FileUtils.identical?(src, dest)
289
- end
290
- FileUtils.rm_rf(guardrails_target)
291
- unless incoming_rules.empty?
292
- FileUtils.mkdir_p(guardrails_target)
293
- incoming_rules.each do |src|
294
- dest = File.join(guardrails_target, File.basename(src))
295
- FileUtils.cp(src, dest)
296
- @results["guardrails/#{File.basename(src)}"] =
297
- unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
298
- guardrail_files_for_provenance[File.basename(src)] = dest
299
- end
300
- end
301
- end
81
+ @reinstall = @existing && !@upgrade && !@force && !@dry_run
82
+ install_memories
302
83
 
303
- # ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
304
- # Like the rules, the bundle's plugin replaces an earlier one.
305
- plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
306
- # ── Scripts: scripts/<file> into <bundle_dir>/scripts/ ────────────
307
- scripts_for_provenance = install_scripts(manifest, normalized_dir, provenance)
308
- warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
309
- warn_rules_requires_chi(manifest, incoming_rules)
84
+ installed = AssetInstaller.new(
85
+ name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
86
+ results: @results, warnings: @warnings, conflicts: @conflicts,
87
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
88
+ ).run
310
89
 
311
90
  # Files the previous version shipped and this one doesn't.
312
- if @upgrade && existing_provenance && existing_provenance[:files]
313
- prune_dropped_files(existing_provenance[:files], all_files_in_bundle, target_dir, target_scope, provenance)
314
- end
315
-
316
- # Verify checksums if we have a manifest (strict).
317
- if manifest && @strict
318
- all_files_in_bundle.each do |file_key|
319
- next if @conflicts.key?(file_key)
320
-
321
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
322
- # skipped: not copied, so a local edit isn't a bad download.
323
- next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
324
-
325
- # For dry-run, fast_forward would appear as "fast_forward", updated not yet
326
- target_path = File.join(target_dir, file_key)
327
- next unless File.exist?(target_path)
328
-
329
- expected = manifest.checksum_for(file_key)
330
- next unless expected
331
-
332
- actual = Digest::SHA256.hexdigest(File.read(target_path))
333
- if actual != expected
334
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
335
- end
336
- end
91
+ prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
92
+ verify_memory_checksums if @manifest && @strict
93
+ warn_missing_needs(@manifest) if @manifest
94
+ if @manifest && !@dry_run
95
+ write_provenance(installed)
337
96
  end
97
+ detect_placeholders
338
98
 
339
- warn_missing_needs(manifest) if manifest
340
-
341
- # Write provenance (only if we had a manifest and not dry_run). An
342
- # upgrade with conflicts writes it too: the new version, the plugin's
343
- # and hooks' new shas, and the old base for each conflicted file,
344
- # marked conflict (chi bundle status shows it). Skipping it left the
345
- # replaced plugin failing its sha check.
346
- if manifest && !@dry_run
347
- provenance_files = {}
348
- if @upgrade && existing_provenance
349
- owned.each do |file_key|
350
- target_path = File.join(target_dir, file_key)
351
- next unless File.exist?(target_path)
352
-
353
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
354
- if %w[kept noop conflict kept_pruned].include?(status)
355
- # Keep old base snapshot — do not overwrite with edited current
356
- # Use base snapshot as source if it exists to preserve old checksum
357
- base_path = provenance.base_path(file_key)
358
- provenance_files[file_key] = if File.exist?(base_path)
359
- # Keep existing provenance entry by re-using base content
360
- # We still need to include it to prevent pruning, but with base content
361
- base_path
362
- else
363
- target_path
364
- end
365
- else
366
- # installed, updated, skipped — use current target (which is incoming for updated)
367
- provenance_files[file_key] = target_path
368
- end
369
- end
370
- # Handle pruned but kept files (bundle removed file but local kept)
371
- if existing_provenance[:files]
372
- existing_provenance[:files].keys.each do |old_key|
373
- old_key_str = old_key.to_s
374
- next if all_files_in_bundle.include?(old_key_str)
375
-
376
- # If it was kept_pruned, include it with base content to prevent pruning
377
- next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
378
-
379
- base_path = provenance.base_path(old_key_str)
380
- target_path = File.join(target_dir, old_key_str)
381
- # Use base if exists to keep old checksum, else target
382
- src = File.exist?(base_path) ? base_path : target_path
383
- provenance_files[old_key_str] = src if File.exist?(src)
384
- end
385
- end
386
- else
387
- owned.each do |file_key|
388
- target_path = File.join(target_dir, file_key)
389
- next unless File.exist?(target_path)
390
-
391
- # Skipped for a local edit: keep the installed base, so the
392
- # next upgrade still sees the edit (not a fast-forward over it).
393
- base_path = provenance.base_path(file_key)
394
- kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
395
- provenance_files[file_key] = kept ? base_path : target_path
396
- end
397
- end
398
- # Build hooks metadata for provenance
399
- hooks_for_provenance = {}
400
- if manifest && manifest.hooks && !manifest.hooks.empty?
401
- hooks_for_provenance = manifest.hooks
402
- elsif hooks_files_for_provenance.any?
403
- hooks_files_for_provenance.each do |basename, path|
404
- next unless File.exist?(path)
405
-
406
- sha = Digest::SHA256.hexdigest(File.read(path))
407
- hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
408
- end
409
- end
410
- Provenance.new(name: @name).write(
411
- files: provenance_files,
412
- scope: target_scope,
413
- version: manifest.version,
414
- source_path: @source,
415
- hooks: hooks_for_provenance,
416
- trust_level: manifest.trust_level,
417
- source_commit: source_commit,
418
- hooks_files: hooks_files_for_provenance,
419
- guardrails_files: guardrail_files_for_provenance,
420
- plugin_file: plugin_file_for_provenance,
421
- requires_chi: manifest.requires_chi,
422
- needs: manifest.needs,
423
- conflicts: @conflicts.keys,
424
- scripts_files: scripts_for_provenance,
425
- context_providers: manifest.context_providers,
426
- file_descriptions: manifest.file_descriptions
427
- )
428
- end
429
-
430
- # Detect placeholders.
431
- all_files_in_bundle.each do |file_key|
432
- file_path = File.join(target_dir, file_key)
433
- next unless File.exist?(file_path)
434
-
435
- names = Placeholder.detect_in_file(file_path)
436
- if names.any?
437
- @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
438
- end
439
- end
440
-
441
- [normalized_dir, manifest]
99
+ [@bundle_dir, @manifest]
442
100
  ensure
443
101
  # An owned source (git/zip/tar) is cleaned up here, unless an upgrade
444
102
  # kept conflicts: the agent step still needs the incoming files, so
445
103
  # the caller runs #cleanup_source! when it is done.
446
- if source_owned
104
+ if @source_owned
447
105
  if @conflicts.any? && !@dry_run
448
106
  @defer_cleanup = true
449
107
  else
450
- SourceNormalizer.cleanup(normalized_dir)
451
- @normalized_dir = nil
108
+ SourceNormalizer.cleanup(@source_dir)
109
+ @source_dir = nil
452
110
  end
453
111
  end
454
112
  end
@@ -483,6 +141,217 @@ module Samagotchi
483
141
 
484
142
  private
485
143
 
144
+ # The source normalized to a dir (@bundle_dir; an owned one, extracted
145
+ # from git/zip/tar, is cleaned up after #run) and its manifest.yml
146
+ # (@manifest, nil when it has none and the install isn't strict).
147
+ def load_source
148
+ @bundle_dir, @source_owned, @source_commit = SourceNormalizer.normalize(@source)
149
+ @source_dir = @bundle_dir
150
+ @manifest = Manifest.read(dir: @bundle_dir)
151
+ rescue SourceNormalizer::UnknownSourceError => e
152
+ raise InstallError, "Source normalization failed: #{e.message}"
153
+ rescue Manifest::ValidationError => e
154
+ raise InstallError, e.message if @strict
155
+
156
+ @warnings << "No manifest found — proceeding without strict manifest validation"
157
+ end
158
+
159
+ # The scope the files go to (the CLI's wins over the manifest's) and
160
+ # its dir, created.
161
+ # @return [Array(String, String)] scope, dir
162
+ def resolve_target
163
+ cli_scope = @scope.to_s.strip.empty? ? nil : @scope
164
+ scope = cli_scope || @manifest&.scope || "system"
165
+ dir = MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
166
+ FileUtils.mkdir_p(dir)
167
+ [scope, dir]
168
+ end
169
+
170
+ # Copies the bundle's *.md files into the target dir and refreshes
171
+ # their index lines: an upgrade merges (merge_memory), a dry run only
172
+ # classifies (preview_memory), a plain install writes the new ones.
173
+ # Sets @bundle_md (every file the bundle ships) and @owned: the bundle
174
+ # owns (records, upgrades, removes) only the files it wrote, these
175
+ # from an earlier install and the ones written now. A same-name file
176
+ # that was already there is the user's: skipped and never recorded.
177
+ def install_memories
178
+ owned_before = @existing ? @existing.files.keys : []
179
+ @bundle_md = []
180
+ @owned = []
181
+ Dir.glob(File.join(@bundle_dir, "*.md")).each do |file_path|
182
+ file_key = File.basename(file_path)
183
+ @bundle_md << file_key
184
+ note_overlay(file_key, @target_dir)
185
+ target_path = File.join(@target_dir, file_key)
186
+ merging = @upgrade && @existing && !@force
187
+
188
+ if merging && File.exist?(target_path) && !owned_before.include?(file_key)
189
+ # Not the bundle's (the user's, or another bundle's): no merge.
190
+ skip_existing(file_key, file_path, target_path, @target_scope)
191
+ elsif merging && !@dry_run
192
+ @owned << file_key
193
+ merge_memory(file_key, file_path, target_path)
194
+ elsif @dry_run
195
+ preview_memory(file_key, file_path, target_path)
196
+ elsif File.exist?(target_path) && !@force
197
+ # A re-install keeps what an earlier install wrote.
198
+ @owned << file_key if owned_before.include?(file_key)
199
+ skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
200
+ else
201
+ FileUtils.cp(file_path, target_path)
202
+ @owned << file_key
203
+ @results[file_key] = { status: "installed" }
204
+ update_target_index(@target_scope, target_path, file_key)
205
+ end
206
+ end
207
+ end
208
+
209
+ # An upgrade's 3-way merge of one file (Merger.classify against the
210
+ # base the last install recorded); a conflict leaves the local file
211
+ # and is recorded in @conflicts for the agent step.
212
+ def merge_memory(file_key, file_path, target_path)
213
+ base_path = @provenance.base_path(file_key)
214
+ case Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
215
+ when :install
216
+ FileUtils.cp(file_path, target_path)
217
+ @results[file_key] = { status: "installed" }
218
+ when :fast_forward
219
+ FileUtils.cp(file_path, target_path)
220
+ @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
221
+ when :keep
222
+ @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
223
+ when :noop
224
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
225
+ when :conflict
226
+ @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
227
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
228
+ @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
229
+ end
230
+ update_target_index(@target_scope, target_path, file_key)
231
+ end
232
+
233
+ # A dry run's verdict for one file, nothing written: an upgrade
234
+ # classifies as merge_memory would (its status is the classification).
235
+ def preview_memory(file_key, file_path, target_path)
236
+ unless File.exist?(target_path) && !@force
237
+ @results[file_key] = { status: "would_install" }
238
+ return
239
+ end
240
+
241
+ if @upgrade && @existing
242
+ base_path = @provenance.base_path(file_key)
243
+ classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
244
+ @results[file_key] = { status: classification.to_s }
245
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
246
+ elsif FileUtils.identical?(file_path, target_path)
247
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
248
+ else
249
+ @results[file_key] = { status: "would_skip" }
250
+ end
251
+ end
252
+
253
+ # Strict mode: a memory written from the bundle whose sha256 differs
254
+ # from its manifest's warns. One that wasn't copied (kept, skipped:
255
+ # a local edit isn't a bad download) or was merged isn't checked.
256
+ def verify_memory_checksums
257
+ @bundle_md.each do |file_key|
258
+ next if @conflicts.key?(file_key)
259
+
260
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
261
+ next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
262
+
263
+ target_path = File.join(@target_dir, file_key)
264
+ next unless File.exist?(target_path)
265
+
266
+ expected = @manifest.checksum_for(file_key)
267
+ next unless expected
268
+
269
+ actual = Digest::SHA256.hexdigest(File.read(target_path))
270
+ if actual != expected
271
+ @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
+ end
273
+ end
274
+ end
275
+
276
+ # One line per memory with {{placeholders}} left to fill in.
277
+ def detect_placeholders
278
+ @bundle_md.each do |file_key|
279
+ file_path = File.join(@target_dir, file_key)
280
+ next unless File.exist?(file_path)
281
+
282
+ names = Placeholder.detect_in_file(file_path)
283
+ @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}" if names.any?
284
+ end
285
+ end
286
+
287
+ # The install's record (Provenance#write), only with a manifest and
288
+ # not on a dry run. An upgrade with conflicts writes it too: the new
289
+ # version, the plugin's and hooks' new shas, and the old base for each
290
+ # conflicted file, marked conflict (chi bundle status shows it).
291
+ # Skipping it left the replaced plugin failing its sha check.
292
+ # @param installed [AssetInstaller::Installed]
293
+ def write_provenance(installed)
294
+ @provenance.write(
295
+ files: @upgrade && @existing ? upgrade_provenance_files : install_provenance_files,
296
+ scope: @target_scope,
297
+ version: @manifest.version,
298
+ source_path: @source,
299
+ # AssetInstaller added the discovered hooks/*.rb to manifest.hooks.
300
+ hooks: @manifest.hooks,
301
+ trust_level: @manifest.trust_level,
302
+ source_commit: @source_commit,
303
+ hooks_files: installed.hook_files,
304
+ guardrails_files: installed.guardrail_files,
305
+ plugin_file: installed.plugin_file,
306
+ requires_chi: @manifest.requires_chi,
307
+ needs: @manifest.needs,
308
+ conflicts: @conflicts.keys,
309
+ scripts_files: installed.script_files,
310
+ context_providers: @manifest.context_providers,
311
+ file_descriptions: @manifest.file_descriptions
312
+ )
313
+ end
314
+
315
+ # file key => the file whose content becomes its base. A file the
316
+ # upgrade left as the user has it (kept, noop, conflict, kept_pruned)
317
+ # keeps its old base, so the next upgrade still sees the local edit;
318
+ # the rest (installed, updated, skipped) take what is on disk now. A
319
+ # file the bundle dropped but the user kept stays recorded too.
320
+ def upgrade_provenance_files
321
+ files = {}
322
+ @owned.each do |file_key|
323
+ target_path = File.join(@target_dir, file_key)
324
+ next unless File.exist?(target_path)
325
+
326
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
327
+ base_path = @provenance.base_path(file_key)
328
+ files[file_key] = %w[kept noop conflict kept_pruned].include?(status) && File.exist?(base_path) ? base_path : target_path
329
+ end
330
+ @existing.files.each_key do |old_key_str|
331
+ next if @bundle_md.include?(old_key_str)
332
+ next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
333
+
334
+ base_path = @provenance.base_path(old_key_str)
335
+ src = File.exist?(base_path) ? base_path : File.join(@target_dir, old_key_str)
336
+ files[old_key_str] = src if File.exist?(src)
337
+ end
338
+ files
339
+ end
340
+
341
+ # file key => its base's content source for a plain install. Skipped
342
+ # for a local edit: keep the installed base, so the next upgrade still
343
+ # sees the edit (not a fast-forward over it).
344
+ def install_provenance_files
345
+ @owned.each_with_object({}) do |file_key, files|
346
+ target_path = File.join(@target_dir, file_key)
347
+ next unless File.exist?(target_path)
348
+
349
+ base_path = @provenance.base_path(file_key)
350
+ kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
351
+ files[file_key] = kept ? base_path : target_path
352
+ end
353
+ end
354
+
486
355
  # A file already in the target dir that the install doesn't write: the
487
356
  # same bytes are "already up to date", others are skipped with a
488
357
  # --force hint (a dry run: would_skip). Its index line is refreshed
@@ -505,8 +374,7 @@ module Samagotchi
505
374
  # matches what was installed, or with --force; one the user edited is
506
375
  # kept with a note.
507
376
  def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
508
- previous_files.each do |old_key, meta|
509
- key = old_key.to_s
377
+ previous_files.each do |key, meta|
510
378
  next if bundle_files.include?(key)
511
379
 
512
380
  target = File.join(target_dir, key)
@@ -532,7 +400,7 @@ module Samagotchi
532
400
  # Whether the file on disk is what the previous install wrote: its
533
401
  # recorded checksum, else the base snapshot. Unknown counts as edited.
534
402
  def installed_unchanged?(target, meta, base_path)
535
- recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
403
+ recorded = meta[:checksum]
536
404
  return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
537
405
 
538
406
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
@@ -555,110 +423,6 @@ module Samagotchi
555
423
  end
556
424
  end
557
425
 
558
- # The hook loader skips every hook of a bundle whose requires_chi this
559
- # chi doesn't meet (Hooks::BundleLoader); say so at install, as
560
- # install_plugin does for the plugin.
561
- def warn_hooks_requires_chi(manifest, hook_files)
562
- return if manifest.nil? || hook_files.empty?
563
-
564
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
565
- @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
566
- end
567
- end
568
-
569
- # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
570
- # requires_chi this chi doesn't meet, and a session that never loaded
571
- # them records a required load failure (the gate refuses guarded calls
572
- # until chi is updated): say so at install, as the hooks warning does.
573
- def warn_rules_requires_chi(manifest, rule_files)
574
- return if manifest.nil? || rule_files.empty?
575
-
576
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
577
- @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
578
- "(chi update), guarded tool calls are refused"
579
- end
580
- end
581
-
582
- # Copy the manifest's plugin file into the bundle's plugin/ dir,
583
- # warning when its sha256 differs from the declared one or this chi
584
- # doesn't meet requires_chi (the Engine then won't load it).
585
- # @return [String, nil] the installed file (nil: none, or a dry run)
586
- def install_plugin(manifest, source_dir, provenance)
587
- plugin = manifest&.plugin
588
- unless plugin
589
- FileUtils.rm_rf(provenance.plugin_dir) unless @dry_run
590
- return nil
591
- end
592
-
593
- src = File.join(source_dir, plugin[:file])
594
- raise InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
595
-
596
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
597
- @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
598
- end
599
- if @dry_run
600
- @results[plugin[:file]] = { status: "would_install" }
601
- return nil
602
- end
603
-
604
- dest = File.join(provenance.plugin_dir, plugin[:file])
605
- unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
606
- FileUtils.rm_rf(provenance.plugin_dir)
607
- FileUtils.mkdir_p(provenance.plugin_dir)
608
- FileUtils.cp(src, dest)
609
- @results[plugin[:file]] = if unchanged
610
- { status: "skipped", reason: "already up to date" }
611
- elsif @upgrade && provenance.read
612
- { status: "updated" }
613
- else
614
- { status: "installed" }
615
- end
616
- expected = manifest.checksum_for_plugin
617
- actual = Digest::SHA256.hexdigest(File.binread(dest))
618
- if @strict && expected && actual != expected
619
- @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
620
- end
621
- dest
622
- end
623
-
624
- # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
625
- # folder replaced: a script the new version dropped goes). A declared
626
- # sha256 that differs warns, as a plugin's does.
627
- # @return [Hash{String => String}] file name => installed path
628
- def install_scripts(manifest, source_dir, provenance)
629
- scripts = manifest&.scripts || {}
630
- scripts.each_key do |file|
631
- next if File.file?(File.join(source_dir, "scripts", file))
632
-
633
- raise InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
634
- end
635
- return {} if @dry_run
636
-
637
- unchanged = scripts.keys.select do |file|
638
- dest = File.join(provenance.scripts_dir, file)
639
- File.file?(dest) && FileUtils.identical?(File.join(source_dir, "scripts", file), dest)
640
- end
641
- FileUtils.rm_rf(provenance.scripts_dir)
642
- return {} if scripts.empty?
643
-
644
- FileUtils.mkdir_p(provenance.scripts_dir)
645
- scripts.to_h do |file, declared|
646
- dest = File.join(provenance.scripts_dir, file)
647
- FileUtils.cp(File.join(source_dir, "scripts", file), dest)
648
- @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
649
- elsif @upgrade && provenance.read then { status: "updated" }
650
- else { status: "installed" }
651
- end
652
- FileUtils.chmod(0o755, dest)
653
- actual = Digest::SHA256.hexdigest(File.binread(dest))
654
- expected = declared.to_s.delete_prefix("sha256:")
655
- if @strict && !expected.empty? && actual != expected
656
- @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
657
- end
658
- [file, dest]
659
- end
660
- end
661
-
662
426
  # A model overlay (<name>.<key>.md, docs/memory.md) loads only with
663
427
  # its base, under the matching model: it gets no index line (every
664
428
  # model would see it as a memory of its own). Its base is looked for