samagotchi 0.22.0 → 0.24.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -46,7 +46,8 @@ module Samagotchi
46
46
  type = event[:type]&.to_sym
47
47
  return unless type
48
48
 
49
- if respond_to?(handler = :"on_#{type}", true)
49
+ handler = :"on_#{type}"
50
+ if respond_to?(handler, true)
50
51
  send(handler, event)
51
52
  elsif ANNOUNCED.key?(type)
52
53
  log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
@@ -108,8 +109,9 @@ module Samagotchi
108
109
  stopped_by: event[:stopped_by])
109
110
  return unless event[:stopped_by]
110
111
 
111
- # A plugin cut this generation (stop_generation); the turn goes on.
112
- log(:info, :generation_stopped, iteration: event[:iteration], bundle: event[:stopped_by],
112
+ # A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
113
+ # "steer") cut this generation; the turn goes on.
114
+ log(:info, :generation_stopped, iteration: event[:iteration], by: event[:stopped_by],
113
115
  reason: event[:stop_reason].to_s[0, 200], thinking_chars: event[:thinking_chars], ms: ms)
114
116
  end
115
117
 
@@ -65,8 +65,9 @@ module Samagotchi
65
65
  candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
66
66
  missing = allow.reject { |k| candidates_by_basename.key?(k) }
67
67
  unless missing.empty?
68
- raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
68
+ raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
69
69
  end
70
+
70
71
  candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
71
72
  else
72
73
  candidates = leave_out_owned(candidates, resolved_name)
@@ -107,6 +108,7 @@ module Samagotchi
107
108
  src = File.join(prov.hooks_dir, basename)
108
109
  # Skip if hook file missing on disk
109
110
  next unless File.exist?(src)
111
+
110
112
  # meta may be symbol-keyed
111
113
  m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
112
114
  sha = (m["sha256"] || m["checksum"] || "").to_s
@@ -245,7 +247,7 @@ module Samagotchi
245
247
  def summary
246
248
  lines = []
247
249
  lines << "Built #{@built_files.size} file(s) to #{@out_path}"
248
- lines << "Files: #{@built_files.join(', ')}" unless @built_files.empty?
250
+ lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
249
251
  lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
250
252
  lines.concat(@warnings) unless @warnings.empty?
251
253
  lines.join("\n")
@@ -275,6 +277,7 @@ module Samagotchi
275
277
 
276
278
  def normalize_scope(val)
277
279
  return nil if val.nil? || val.to_s.strip.empty?
280
+
278
281
  v = val.to_s.strip.downcase
279
282
  %w[project system].include?(v) ? v : nil
280
283
  end
@@ -295,7 +298,7 @@ module Samagotchi
295
298
  def default_name(scope)
296
299
  if scope == "project"
297
300
  base = File.basename(MemoryPaths.project_root).gsub(/[^A-Za-z0-9_-]/, "-").downcase
298
- base = base.gsub(/-+/, "-").gsub(/\A-+|-+\z/, "")
301
+ base = base.squeeze("-").gsub(/\A-+|-+\z/, "")
299
302
  base = "project" if base.empty?
300
303
  "chi_#{base}_memories"
301
304
  else
@@ -356,6 +359,7 @@ module Samagotchi
356
359
  if File.exist?(expanded) && !File.directory?(expanded)
357
360
  raise BuildError, "Output path exists and is not a directory: #{expanded}"
358
361
  end
362
+
359
363
  [expanded, :dir]
360
364
  end
361
365
  end
@@ -365,6 +369,7 @@ module Samagotchi
365
369
  if File.exist?(out_path)
366
370
  raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
367
371
  end
372
+
368
373
  # Use zip CLI like source.rb does with unzip
369
374
  Dir.chdir(staging) do
370
375
  result = system("zip", "-r", out_path, ".")
@@ -377,6 +382,7 @@ module Samagotchi
377
382
  if File.exist?(out_path)
378
383
  raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
379
384
  end
385
+
380
386
  # Use tar CLI
381
387
  Dir.chdir(staging) do
382
388
  cmd = case format
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "fileutils"
3
4
  require "digest"
4
5
  require_relative "../atomic_file"
@@ -16,6 +17,7 @@ module Samagotchi
16
17
  def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
17
18
  index_path = index_path_for(scope)
18
19
  return true unless index_path
20
+
19
21
  locked_write(File.dirname(index_path)) do |content|
20
22
  new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
21
23
  next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
@@ -32,20 +34,25 @@ module Samagotchi
32
34
  end
33
35
  true
34
36
  end
37
+
35
38
  def self.managed_pattern(name)
36
39
  /^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
37
40
  end
41
+
38
42
  def self.remove_index(scope, entry_name)
39
43
  index_path = index_path_for(scope)
40
44
  return true unless index_path && File.exist?(index_path)
45
+
41
46
  locked_write(File.dirname(index_path)) do |content|
42
47
  pattern = managed_pattern(entry_name)
43
48
  next nil unless content&.match?(pattern)
49
+
44
50
  # Clean up extra blank lines: collapse 3+ newlines to 2
45
51
  content.gsub(pattern, "").gsub(/\n{3,}/, "\n\n")
46
52
  end
47
53
  true
48
54
  end
55
+
49
56
  # Read-modify-write of <dir>/index.md under an exclusive flock on a sidecar
50
57
  # <dir>/.index.lock, so sessions in several worktrees of one repository
51
58
  # (one shared project folder) don't drop each other's lines. Yields the
@@ -61,6 +68,7 @@ module Samagotchi
61
68
  Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
62
69
  end
63
70
  end
71
+
64
72
  def self.managed_line(name, scope, byte_count, description, source = nil)
65
73
  line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
66
74
  line += " · from #{source}" unless source.to_s.strip.empty?
@@ -68,6 +76,7 @@ module Samagotchi
68
76
  line += " — #{desc}" unless desc.to_s.empty?
69
77
  line
70
78
  end
79
+
71
80
  def self.extract_description(line)
72
81
  if line =~ /\s—\s(.*)\s*\z/
73
82
  $1
@@ -75,6 +84,7 @@ module Samagotchi
75
84
  $1
76
85
  end
77
86
  end
87
+
78
88
  # The bundle a managed line names ("· from <bundle>", before any
79
89
  # " — description"), or nil.
80
90
  def self.extract_source(line)
@@ -86,14 +96,16 @@ module Samagotchi
86
96
  "Managed entries below are auto-maintained by memory_write " \
87
97
  "(name, scope, last-written date, size). Free-form sections are preserved."
88
98
  end
99
+
89
100
  def self.date_str
90
101
  require "date"
91
102
  Date.today.iso8601
92
103
  end
104
+
93
105
  def self.index_path_for(scope)
94
106
  base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
95
107
  base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
96
108
  end
97
109
  end
98
110
  end
99
- end
111
+ end
@@ -80,11 +80,9 @@ module Samagotchi
80
80
  rescue SourceNormalizer::UnknownSourceError => e
81
81
  raise InstallError, "Source normalization failed: #{e.message}"
82
82
  rescue Manifest::ValidationError => e
83
- if @strict
84
- raise InstallError, e.message
85
- else
86
- @warnings << "No manifest found — proceeding without strict manifest validation"
87
- end
83
+ raise InstallError, e.message if @strict
84
+
85
+ @warnings << "No manifest found — proceeding without strict manifest validation"
88
86
  end
89
87
 
90
88
  # Determine target scope (CLI wins over manifest).
@@ -185,8 +183,9 @@ module Samagotchi
185
183
  end
186
184
  end
187
185
 
188
- hook_entries.each do |basename, meta|
186
+ hook_entries.each do |basename, _meta|
189
187
  next unless basename.is_a?(String) && !basename.empty?
188
+
190
189
  all_hooks_in_bundle << basename
191
190
  src = File.join(normalized_dir, "hooks", basename)
192
191
  next unless File.exist?(src)
@@ -194,11 +193,11 @@ module Samagotchi
194
193
  dest = File.join(hooks_target, basename)
195
194
 
196
195
  if @dry_run
197
- if File.exist?(dest) && !@force
198
- @results[basename] = { status: "would_skip" }
199
- else
200
- @results[basename] = { status: "would_install" }
201
- end
196
+ @results[basename] = if File.exist?(dest) && !@force
197
+ { status: "would_skip" }
198
+ else
199
+ { status: "would_install" }
200
+ end
202
201
  next
203
202
  end
204
203
 
@@ -207,10 +206,8 @@ module Samagotchi
207
206
  prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
208
207
  if prev_meta
209
208
  prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
210
- if File.exist?(dest)
211
- if !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
212
- @warnings << "Hook #{basename} was locally modified; overwriting"
213
- end
209
+ if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
210
+ @warnings << "Hook #{basename} was locally modified; overwriting"
214
211
  end
215
212
  end
216
213
  end
@@ -249,11 +246,12 @@ module Samagotchi
249
246
  existing_provenance[:hooks].keys.each do |old_key|
250
247
  old_key_str = old_key.to_s
251
248
  next if all_hooks_in_bundle.include?(old_key_str)
249
+
252
250
  old_dest = File.join(hooks_target, old_key_str)
253
251
  if File.exist?(old_dest) && !@force
254
252
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
255
253
  end
256
- FileUtils.rm_f(old_dest) if File.exist?(old_dest)
254
+ FileUtils.rm_f(old_dest)
257
255
  end
258
256
  end
259
257
 
@@ -261,14 +259,16 @@ module Samagotchi
261
259
  if manifest && @strict
262
260
  all_hooks_in_bundle.each do |basename|
263
261
  next if @conflicts.key?(basename)
262
+
264
263
  expected = manifest.checksum_for_hook(basename)
265
- if expected
266
- dest = File.join(hooks_target, basename)
267
- next unless File.exist?(dest)
268
- actual = Digest::SHA256.hexdigest(File.read(dest))
269
- if actual != expected
270
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
271
- end
264
+ next unless expected
265
+
266
+ dest = File.join(hooks_target, basename)
267
+ next unless File.exist?(dest)
268
+
269
+ actual = Digest::SHA256.hexdigest(File.read(dest))
270
+ if actual != expected
271
+ @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
272
  end
273
273
  end
274
274
  end
@@ -313,18 +313,21 @@ module Samagotchi
313
313
  if manifest && @strict
314
314
  all_files_in_bundle.each do |file_key|
315
315
  next if @conflicts.key?(file_key)
316
+
316
317
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
317
318
  # skipped: not copied, so a local edit isn't a bad download.
318
319
  next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
320
+
319
321
  # For dry-run, fast_forward would appear as "fast_forward", updated not yet
320
322
  target_path = File.join(target_dir, file_key)
321
323
  next unless File.exist?(target_path)
324
+
322
325
  expected = manifest.checksum_for(file_key)
323
- if expected
324
- actual = Digest::SHA256.hexdigest(File.read(target_path))
325
- if actual != expected
326
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
327
- end
326
+ next unless expected
327
+
328
+ actual = Digest::SHA256.hexdigest(File.read(target_path))
329
+ if actual != expected
330
+ @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
328
331
  end
329
332
  end
330
333
  end
@@ -342,18 +345,19 @@ module Samagotchi
342
345
  owned.each do |file_key|
343
346
  target_path = File.join(target_dir, file_key)
344
347
  next unless File.exist?(target_path)
348
+
345
349
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
346
350
  if %w[kept noop conflict kept_pruned].include?(status)
347
351
  # Keep old base snapshot — do not overwrite with edited current
348
352
  # Use base snapshot as source if it exists to preserve old checksum
349
353
  base_path = provenance.base_path(file_key)
350
- if File.exist?(base_path)
351
- # Keep existing provenance entry by re-using base content
352
- # We still need to include it to prevent pruning, but with base content
353
- provenance_files[file_key] = base_path
354
- else
355
- provenance_files[file_key] = target_path
356
- end
354
+ provenance_files[file_key] = if File.exist?(base_path)
355
+ # Keep existing provenance entry by re-using base content
356
+ # We still need to include it to prevent pruning, but with base content
357
+ base_path
358
+ else
359
+ target_path
360
+ end
357
361
  else
358
362
  # installed, updated, skipped — use current target (which is incoming for updated)
359
363
  provenance_files[file_key] = target_path
@@ -364,14 +368,15 @@ module Samagotchi
364
368
  existing_provenance[:files].keys.each do |old_key|
365
369
  old_key_str = old_key.to_s
366
370
  next if all_files_in_bundle.include?(old_key_str)
371
+
367
372
  # If it was kept_pruned, include it with base content to prevent pruning
368
- if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
369
- base_path = provenance.base_path(old_key_str)
370
- target_path = File.join(target_dir, old_key_str)
371
- # Use base if exists to keep old checksum, else target
372
- src = File.exist?(base_path) ? base_path : target_path
373
- provenance_files[old_key_str] = src if File.exist?(src)
374
- end
373
+ next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
374
+
375
+ base_path = provenance.base_path(old_key_str)
376
+ target_path = File.join(target_dir, old_key_str)
377
+ # Use base if exists to keep old checksum, else target
378
+ src = File.exist?(base_path) ? base_path : target_path
379
+ provenance_files[old_key_str] = src if File.exist?(src)
375
380
  end
376
381
  end
377
382
  else
@@ -393,6 +398,7 @@ module Samagotchi
393
398
  elsif hooks_files_for_provenance.any?
394
399
  hooks_files_for_provenance.each do |basename, path|
395
400
  next unless File.exist?(path)
401
+
396
402
  sha = Digest::SHA256.hexdigest(File.read(path))
397
403
  hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
398
404
  end
@@ -418,6 +424,7 @@ module Samagotchi
418
424
  all_files_in_bundle.each do |file_key|
419
425
  file_path = File.join(target_dir, file_key)
420
426
  next unless File.exist?(file_path)
427
+
421
428
  names = Placeholder.detect_in_file(file_path)
422
429
  if names.any?
423
430
  @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
@@ -451,15 +458,15 @@ module Samagotchi
451
458
  removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
452
459
  would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
453
460
 
454
- lines << "Installed: #{installed.join(', ')}" unless installed.empty?
455
- lines << "Updated: #{updated.join(', ')}" unless updated.empty?
456
- lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
457
- lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
458
- lines << "Removed (no longer in the bundle): #{removed.join(', ')}#{trash_dir ? " (moved to #{trash_dir})" : ""}" unless removed.empty?
459
- lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
460
- lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
461
- lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
462
- lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
461
+ lines << "Installed: #{installed.join(", ")}" unless installed.empty?
462
+ lines << "Updated: #{updated.join(", ")}" unless updated.empty?
463
+ lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
464
+ lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
465
+ lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
466
+ lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
467
+ lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
468
+ lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
469
+ lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
463
470
  lines.concat(@warnings) unless @warnings.empty?
464
471
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
465
472
  lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
@@ -494,6 +501,7 @@ module Samagotchi
494
501
  previous_files.each do |old_key, meta|
495
502
  key = old_key.to_s
496
503
  next if bundle_files.include?(key)
504
+
497
505
  target = File.join(target_dir, key)
498
506
  next unless File.exist?(target)
499
507
 
@@ -504,6 +512,7 @@ module Samagotchi
504
512
  elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
505
513
  @results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
506
514
  next if @dry_run
515
+
507
516
  @trash.move(target)
508
517
  remove_target_index(scope, key)
509
518
  else
@@ -518,6 +527,7 @@ module Samagotchi
518
527
  def installed_unchanged?(target, meta, base_path)
519
528
  recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
520
529
  return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
530
+
521
531
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
522
532
  end
523
533
 
@@ -577,12 +587,12 @@ module Samagotchi
577
587
  FileUtils.mkdir_p(provenance.plugin_dir)
578
588
  FileUtils.cp(src, dest)
579
589
  @results[plugin[:file]] = if unchanged
580
- { status: "skipped", reason: "already up to date" }
581
- elsif @upgrade && provenance.read
582
- { status: "updated" }
583
- else
584
- { status: "installed" }
585
- end
590
+ { status: "skipped", reason: "already up to date" }
591
+ elsif @upgrade && provenance.read
592
+ { status: "updated" }
593
+ else
594
+ { status: "installed" }
595
+ end
586
596
  expected = manifest.checksum_for_plugin
587
597
  actual = Digest::SHA256.hexdigest(File.binread(dest))
588
598
  if @strict && expected && actual != expected
@@ -30,8 +30,10 @@ module Samagotchi
30
30
 
31
31
  Dir.children(dir).sort.filter_map do |source|
32
32
  next unless File.file?(File.join(dir, source, "manifest.yml"))
33
+
33
34
  m = Manifest.read(dir: File.join(dir, source))
34
35
  next if m.name == SystemBundle::BUNDLE_NAME
36
+
35
37
  Shipped.new(name: m.name, source: source, version: m.version, description: m.description, includes: m.includes)
36
38
  rescue Manifest::ValidationError, Psych::Exception
37
39
  nil
@@ -70,8 +72,10 @@ module Samagotchi
70
72
  end
71
73
 
72
74
  def newer?(candidate, current)
73
- candidate, current = candidate.to_s, current.to_s
75
+ candidate = candidate.to_s
76
+ current = current.to_s
74
77
  return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
78
+
75
79
  Gem::Version.new(candidate) > Gem::Version.new(current)
76
80
  end
77
81
  end
@@ -64,6 +64,7 @@ module Samagotchi
64
64
  def self.read(dir:)
65
65
  manifest_path = File.join(dir, "manifest.yml")
66
66
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
67
+
67
68
  new(path: manifest_path)
68
69
  end
69
70
 
@@ -72,6 +73,7 @@ module Samagotchi
72
73
  def checksum_for(file_key)
73
74
  raw = @files[file_key]
74
75
  return nil unless raw
76
+
75
77
  raw.start_with?("sha256:") ? raw[7..] : raw
76
78
  end
77
79
 
@@ -79,8 +81,10 @@ module Samagotchi
79
81
  def checksum_for_hook(basename)
80
82
  raw = @hooks[basename]
81
83
  return nil unless raw
84
+
82
85
  sha = raw[:sha256] || raw["sha256"]
83
86
  return nil unless sha
87
+
84
88
  sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
85
89
  end
86
90
 
@@ -99,18 +103,18 @@ module Samagotchi
99
103
  return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
100
104
 
101
105
  "it requires chi #{requirement} (this is chi #{version})"
102
- rescue ArgumentError, Gem::Requirement::BadRequirementError => e
106
+ rescue ArgumentError => e # Gem::Requirement::BadRequirementError is one
103
107
  "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
104
108
  end
105
109
 
106
110
  # Writes a fresh manifest.yml from computed checksums at `dir`.
107
- def self.write(dir:, name:, version:, scope: nil, description: "", files:, hooks: nil, trust_level: nil,
111
+ def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
108
112
  plugin: nil, requires_chi: nil, needs: nil)
109
113
  FileUtils.mkdir_p(dir)
110
114
  manifest = {
111
115
  "name" => name,
112
116
  "version" => version,
113
- "description" => description,
117
+ "description" => description
114
118
  }
115
119
  manifest["scope"] = scope if scope
116
120
  manifest["files"] = files # { "file.md" => "sha256:abc..." }
@@ -167,19 +171,23 @@ module Samagotchi
167
171
  def require_str(hash, key)
168
172
  val = hash[key]
169
173
  return val if val.is_a?(String) && !val.empty?
174
+
170
175
  raise ValidationError, "manifest missing required field: #{key}"
171
176
  end
172
177
 
173
178
  def normalize_scope(val)
174
179
  return nil if val.nil?
180
+
175
181
  v = val.to_s.strip.downcase
176
182
  %w[project system].include?(v) ? v : nil
177
183
  end
178
184
 
179
185
  def parse_files(raw)
180
186
  return {} unless raw.is_a?(Hash)
187
+
181
188
  raw.each_with_object({}) do |(k, v), acc|
182
189
  next unless k.is_a?(String) && !k.empty?
190
+
183
191
  str = v.to_s
184
192
  acc[k] = if str.start_with?("sha256:")
185
193
  str
@@ -234,11 +242,13 @@ module Samagotchi
234
242
 
235
243
  def parse_hooks(raw)
236
244
  return {} unless raw.is_a?(Hash)
245
+
237
246
  raw.each_with_object({}) do |(k, v), acc|
238
247
  next unless k.is_a?(String) && !k.empty?
239
248
  next unless v.is_a?(Hash)
249
+
240
250
  sha = (v["sha256"] || v[:sha256] || "").to_s
241
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
251
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
242
252
  event = (v["event"] || v[:event] || "").to_s.strip
243
253
  on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
244
254
  on_error = "skip" if on_error.empty?
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "digest"
3
4
 
4
5
  module Samagotchi
@@ -33,18 +34,19 @@ module Samagotchi
33
34
  incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
34
35
 
35
36
  if current_digest == incoming_digest
36
- return :noop
37
+ :noop
37
38
  elsif current_digest == base_digest
38
- return :fast_forward
39
+ :fast_forward
39
40
  elsif incoming_digest == base_digest
40
- return :keep
41
+ :keep
41
42
  else
42
- return :conflict
43
+ :conflict
43
44
  end
44
45
  end
45
46
 
46
47
  def self.current_modified?(base_path, current_path)
47
48
  return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
49
+
48
50
  Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
49
51
  end
50
52
  end
@@ -154,11 +154,11 @@ module Samagotchi
154
154
  if existing && existing[:files]
155
155
  existing[:files].keys.each do |old_key|
156
156
  old_key_str = old_key.to_s
157
- unless files.key?(old_key_str) || files.key?(old_key)
158
- old_base = File.join(bases_dir, old_key_str)
159
- FileUtils.rm_f(old_base) if File.exist?(old_base)
160
- merged_entries.delete(old_key_str)
161
- end
157
+ next if files.key?(old_key_str) || files.key?(old_key)
158
+
159
+ old_base = File.join(bases_dir, old_key_str)
160
+ FileUtils.rm_f(old_base)
161
+ merged_entries.delete(old_key_str)
162
162
  end
163
163
  end
164
164
 
@@ -172,9 +172,10 @@ module Samagotchi
172
172
  hooks.each do |k, v|
173
173
  next unless k.is_a?(String) && !k.empty?
174
174
  next unless v.is_a?(Hash)
175
+
175
176
  hv = v.transform_keys(&:to_sym)
176
177
  sha = (hv[:sha256] || "").to_s
177
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
178
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
178
179
  normalized_hooks[k] = {
179
180
  sha256: sha,
180
181
  event: (hv[:event] || "").to_s,
@@ -189,7 +190,7 @@ module Samagotchi
189
190
  old_key_str = old_key.to_s
190
191
  unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
191
192
  old_base = File.join(bases_dir, old_key_str)
192
- FileUtils.rm_f(old_base) if File.exist?(old_base)
193
+ FileUtils.rm_f(old_base)
193
194
  end
194
195
  end
195
196
  end
@@ -204,12 +205,12 @@ module Samagotchi
204
205
  meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
205
206
  end
206
207
  hooks_map[k] = meta.transform_keys(&:to_s)
207
- if src && File.exist?(src.to_s)
208
- begin
209
- File.write(File.join(bases_dir, k), File.read(src.to_s))
210
- rescue StandardError
211
- nil
212
- end
208
+ next unless src && File.exist?(src.to_s)
209
+
210
+ begin
211
+ File.write(File.join(bases_dir, k), File.read(src.to_s))
212
+ rescue StandardError
213
+ nil
213
214
  end
214
215
  end
215
216
 
@@ -256,6 +257,7 @@ module Samagotchi
256
257
  conflicts.each do |file_key, info|
257
258
  key = file_key.to_s
258
259
  next unless raw["files"].key?(key)
260
+
259
261
  src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
260
262
  next unless src
261
263
 
@@ -270,6 +272,7 @@ module Samagotchi
270
272
  def read
271
273
  manifest_path = File.join(@bundle_dir, "manifest.json")
272
274
  return nil unless File.exist?(manifest_path)
275
+
273
276
  JSON.parse(File.read(manifest_path), symbolize_names: true)
274
277
  end
275
278