samagotchi 0.159.0 → 0.160.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/architecture.md +5 -6
  4. data/docs/cli.md +1 -1
  5. data/docs/guardrails.md +21 -2
  6. data/docs/internals/attached-context.md +4 -2
  7. data/docs/internals/bridge.md +7 -0
  8. data/docs/internals/engine.md +7 -1
  9. data/docs/memory.md +41 -13
  10. data/docs/scheduling.md +16 -3
  11. data/docs/sessions.md +3 -1
  12. data/lib/samagotchi/bridge.rb +11 -2
  13. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  14. data/lib/samagotchi/bundle_command.rb +1 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  17. data/lib/samagotchi/client_id.rb +4 -0
  18. data/lib/samagotchi/command_intake.rb +56 -0
  19. data/lib/samagotchi/context_absorber.rb +6 -3
  20. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  21. data/lib/samagotchi/context_candidates.rb +11 -8
  22. data/lib/samagotchi/context_command.rb +9 -3
  23. data/lib/samagotchi/context_poller.rb +5 -2
  24. data/lib/samagotchi/context_sources.rb +6 -4
  25. data/lib/samagotchi/engine/events.rb +13 -2
  26. data/lib/samagotchi/engine/guardrails.rb +3 -4
  27. data/lib/samagotchi/engine.rb +1 -0
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/events.rb +71 -0
  30. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  31. data/lib/samagotchi/hook_routes.rb +0 -1
  32. data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
  33. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  34. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  35. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  36. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  37. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  38. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  39. data/lib/samagotchi/memory_bundle/status.rb +2 -2
  40. data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
  41. data/lib/samagotchi/memory_orphans.rb +14 -4
  42. data/lib/samagotchi/memory_paths.rb +29 -6
  43. data/lib/samagotchi/memory_usage.rb +23 -12
  44. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  45. data/lib/samagotchi/plugin/sessions.rb +4 -1
  46. data/lib/samagotchi/project_command.rb +58 -20
  47. data/lib/samagotchi/project_id.rb +102 -0
  48. data/lib/samagotchi/project_id_clash.rb +62 -0
  49. data/lib/samagotchi/project_init.rb +214 -10
  50. data/lib/samagotchi/project_scope.rb +11 -2
  51. data/lib/samagotchi/schedules.rb +99 -39
  52. data/lib/samagotchi/session.rb +17 -2
  53. data/lib/samagotchi/session_access/local.rb +8 -5
  54. data/lib/samagotchi/session_access/remote.rb +2 -1
  55. data/lib/samagotchi/session_access.rb +4 -1
  56. data/lib/samagotchi/session_commands.rb +7 -3
  57. data/lib/samagotchi/session_continue.rb +10 -6
  58. data/lib/samagotchi/session_manager/spawn.rb +9 -2
  59. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  60. data/lib/samagotchi/state_format.rb +118 -0
  61. data/lib/samagotchi/tools/context_read.rb +7 -2
  62. data/lib/samagotchi/tools/delegate.rb +10 -2
  63. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  64. data/lib/samagotchi/update_command.rb +2 -2
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +5 -4
  67. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  68. data/lib/samagotchi/worker.rb +18 -6
  69. data/lib/samagotchi/worker_inbound.rb +21 -2
  70. metadata +5 -1
@@ -56,11 +56,12 @@ module Samagotchi
56
56
  # @param show_card [#call] (**card) → shows it; false/nil: it wasn't
57
57
  # shown (the UI is on another session), so no carded line
58
58
  # @return [Symbol, nil] :attached, :carded or nil
59
- def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:)
59
+ def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:, project_id: nil)
60
60
  return nil unless settings.mode == "on" && fields && fields["stated"]
61
61
  return nil unless %w[attach offer].include?(fields["would"])
62
62
 
63
- candidate = proposed(job.session_id, job.name, project_root: project_root, state_dir: state_dir) or return nil
63
+ candidate = proposed(job.session_id, job.name, project_root: project_root, project_id: project_id,
64
+ state_dir: state_dir) or return nil
64
65
  if fields["would"] == "attach"
65
66
  judge_attach(candidate, job: job, p: fields["p"], state_dir: state_dir, log: log, show_card: show_card)
66
67
  else
@@ -72,8 +73,9 @@ module Samagotchi
72
73
  end
73
74
 
74
75
  # The candidate +name+ when it is still proposed in the session.
75
- def proposed(session_id, name, project_root:, state_dir:)
76
- rows = ContextCandidates.with_status(session_id, project_root: project_root, state_dir: state_dir)
76
+ def proposed(session_id, name, project_root:, state_dir:, project_id: nil)
77
+ rows = ContextCandidates.with_status(session_id, project_root: project_root, project_id: project_id,
78
+ state_dir: state_dir)
77
79
  rows.find { |candidate, status| candidate.name == name && status == "proposed" }&.first
78
80
  end
79
81
 
@@ -128,7 +130,8 @@ module Samagotchi
128
130
  end
129
131
 
130
132
  def find(session, name, state_dir:)
131
- ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
133
+ ContextCandidates.with_status(session.id, project_root: session.project_root, project_id: session.project_id,
134
+ state_dir: state_dir)
132
135
  .find { |candidate, _status| candidate.name == name }
133
136
  end
134
137
 
@@ -58,18 +58,19 @@ module Samagotchi
58
58
 
59
59
  # The session's candidates with their status, by name.
60
60
  # @return [Array<Array(ContextSources::Candidate, String)>]
61
- def with_status(session_id, project_root:, state_dir: nil)
61
+ def with_status(session_id, project_root:, project_id: nil, state_dir: nil)
62
62
  own = ContextSources.session_location(session_id, state_dir: state_dir)
63
63
  candidates = own.candidates
64
64
  return [] if candidates.empty?
65
65
 
66
- attached = ContextSources.attached(session_id, project_root: project_root, state_dir: state_dir, shadowed: true)
66
+ attached = ContextSources.attached(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir,
67
+ shadowed: true)
67
68
  candidates.map { |candidate| [candidate, status(candidate, own: own, attached: attached)] }
68
69
  end
69
70
 
70
71
  # The candidates section of /context, nil for none.
71
- def listing(session_id, project_root:, state_dir: nil)
72
- rows = with_status(session_id, project_root: project_root, state_dir: state_dir)
72
+ def listing(session_id, project_root:, project_id: nil, state_dir: nil)
73
+ rows = with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
73
74
  return nil if rows.empty?
74
75
 
75
76
  lines = rows.map { |candidate, state| "- #{candidate.name} · #{state} · #{candidate.title}" }
@@ -171,8 +172,8 @@ module Samagotchi
171
172
  end
172
173
 
173
174
  # The proposed candidates (no source of theirs attached, not declined).
174
- def proposed(session_id, project_root:, state_dir: nil)
175
- with_status(session_id, project_root: project_root, state_dir: state_dir)
175
+ def proposed(session_id, project_root:, project_id: nil, state_dir: nil)
176
+ with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
176
177
  .filter_map { |candidate, state| candidate if state == "proposed" }
177
178
  end
178
179
 
@@ -194,7 +195,8 @@ module Samagotchi
194
195
  # The earlier owner prompts too: a proposal that came while one ran
195
196
  # (a one-shot's plugins never see a running turn's prompt).
196
197
  texts = owner_texts(session.messages) + (prompt.is_a?(String) ? [prompt] : [])
197
- found = hits(texts, proposed(session.id, project_root: session.project_root, state_dir: state_dir))
198
+ found = hits(texts, proposed(session.id, project_root: session.project_root, project_id: session.project_id,
199
+ state_dir: state_dir))
198
200
  names = found.filter_map { |hit| attach(hit, own: own, state_dir: state_dir) }
199
201
  first_fetch(own, unfetched(own, names), cwd: session.working_directory, seconds: seconds, cancelled: cancelled)
200
202
  names
@@ -213,7 +215,8 @@ module Samagotchi
213
215
  names = own.candidates.map(&:name)
214
216
  return 0 if names.empty?
215
217
 
216
- absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root)
218
+ absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root,
219
+ project_id: session.project_id)
217
220
  batch = absorber.pending or return 0
218
221
  picked = batch.deliveries.select { |delivery| names.include?(delivery.name) }
219
222
  picked.each { |delivery| engine.add_context_note(session, delivery.note) if delivery.note }
@@ -80,7 +80,12 @@ module Samagotchi
80
80
  # Where a command acts: a session (its own sources, then its project's)
81
81
  # or a project's sources. +label+ names it in the output, +cwd+ is
82
82
  # where its session's sources run (ContextPoller's choice).
83
- Target = Data.define(:session_id, :project_root, :cwd, :label) do
83
+ # +project_id+ is the project's id (Session#project_id, a marked
84
+ # project's ProjectLookup#id): with +project_root+ it keys the project's
85
+ # sources (MemoryPaths.key_for).
86
+ Target = Data.define(:session_id, :project_root, :cwd, :label, :project_id) do
87
+ def initialize(session_id:, project_root:, cwd:, label:, project_id: nil) = super
88
+
84
89
  def project? = session_id.nil?
85
90
  end
86
91
 
@@ -421,14 +426,15 @@ module Samagotchi
421
426
  root = found.root
422
427
  # Its sources run in the root, or in this checkout when the root is a
423
428
  # git dir (ContextSources.project_cwd).
424
- Target.new(session_id: nil, project_root: root, cwd: found.top || @cwd,
429
+ Target.new(session_id: nil, project_root: root, project_id: found.id, cwd: found.top || @cwd,
425
430
  label: "project #{File.basename(root)}")
426
431
  end
427
432
 
428
433
  def session_target(given)
429
434
  id = @access.resolve_id(given)
430
435
  session = @access.show(id)
431
- Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
436
+ Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
437
+ cwd: session.working_directory, label: id[0, 8])
432
438
  rescue ArgumentError => e
433
439
  error_line("#{command_name}: #{e.message}")
434
440
  nil
@@ -23,12 +23,14 @@ module Samagotchi
23
23
  TICK_SECONDS = 5
24
24
 
25
25
  # @param project_root [String, nil] Session#project_root
26
+ # @param project_id [String, nil] Session#project_id
26
27
  # @param cwd [String] the session's folder (a session source runs there)
27
28
  # @param on_change [#call] after a fetch brought a new text or failed
28
- def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, tick: TICK_SECONDS)
29
+ def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, project_id: nil, tick: TICK_SECONDS)
29
30
  @session_id = session_id
30
31
  @state_dir = state_dir
31
32
  @project_root = project_root
33
+ @project_id = project_id
32
34
  @cwd = cwd
33
35
  @on_change = on_change
34
36
  @tick = tick
@@ -56,7 +58,8 @@ module Samagotchi
56
58
  # One pass (the thread's loop body; specs call it).
57
59
  # @param first [Boolean] the worker's first pass
58
60
  def poll(first: false)
59
- ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir).each do |attached|
61
+ ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
62
+ state_dir: @state_dir).each do |attached|
60
63
  break if @stopping
61
64
  next if attached.source.push? || @own.muted?(attached.name)
62
65
 
@@ -634,19 +634,21 @@ module Samagotchi
634
634
 
635
635
  # The project a session's sources come from: nil when it is in no repo.
636
636
  # @param project_root [String, nil] Session#project_root
637
- def project_location_for(project_root, state_dir: nil)
637
+ # @param project_id [String, nil] Session#project_id (a marked project's
638
+ # id keys its folder: MemoryPaths.key_for)
639
+ def project_location_for(project_root, project_id: nil, state_dir: nil)
638
640
  return nil if project_root.to_s.empty?
639
641
 
640
- project_location(MemoryPaths.key_for_root(project_root), state_dir: state_dir)
642
+ project_location(MemoryPaths.key_for(root: project_root, id: project_id), state_dir: state_dir)
641
643
  end
642
644
 
643
645
  # A session's sources: its own, then the project's it doesn't shadow
644
646
  # (+shadowed+ lists those too, marked).
645
647
  # @return [Array<Attached>]
646
- def attached(session_id, project_root:, state_dir: nil, shadowed: false)
648
+ def attached(session_id, project_root:, project_id: nil, state_dir: nil, shadowed: false)
647
649
  own = session_location(session_id, state_dir: state_dir)
648
650
  list = own.sources.map { |source| Attached.new(source: source, location: own, shadowed: false) }
649
- project = project_location_for(project_root, state_dir: state_dir)
651
+ project = project_location_for(project_root, project_id: project_id, state_dir: state_dir)
650
652
  return list unless project
651
653
 
652
654
  names = list.map(&:name)
@@ -79,8 +79,7 @@ module Samagotchi
79
79
  announce({ type: :guardrail_warning, message: message }) if message
80
80
  plugins = @plugin_failures.message
81
81
  announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
82
- memory = MemoryOrphans.message
83
- announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
82
+ memory_load_warnings.each { |event| announce(event) }
84
83
  @extension_load.held_events.each do |event|
85
84
  announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
86
85
  end
@@ -88,6 +87,18 @@ module Samagotchi
88
87
  nil
89
88
  end
90
89
 
90
+ # The session-start memory warnings (label "memory"), for both load
91
+ # announcements (a worker's, a one-shot's first turn): an orphaned
92
+ # project memory folder (MemoryOrphans), a copied project sharing its
93
+ # id (ProjectIdClash, which also claims the id folder for this root).
94
+ # @return [Array<Hash>] the :guardrail_warning events
95
+ def memory_load_warnings
96
+ [MemoryOrphans.message, ProjectIdClash.check&.message].compact.map do |message|
97
+ { type: :guardrail_warning, message: message, label: "memory" }
98
+ end
99
+ end
100
+ private :memory_load_warnings
101
+
91
102
  # Run the block holding back the cards and between-turns notices it
92
103
  # announces on this thread, so the caller announces them after its own
93
104
  # event (a worker's command: its command_ran first, so a UI prints the
@@ -46,8 +46,8 @@ module Samagotchi
46
46
 
47
47
  # Once per Engine, on its first turn: what failed to load, so every UI
48
48
  # (the attached TUI, the web, a one-shot's stderr) shows it: the guardrails, then the plugins
49
- # (label: "plugins"; the UIs say guardrails without one), an orphaned
50
- # project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
49
+ # (label: "plugins"; the UIs say guardrails without one), the memory
50
+ # warnings (label: "memory": #memory_load_warnings), then the notices and cards plugins showed as they loaded.
51
51
  def announce_guardrail_failures(on_event)
52
52
  return if @guardrail_failures_announced
53
53
 
@@ -56,8 +56,7 @@ module Samagotchi
56
56
  emit_event(on_event, { type: :guardrail_warning, message: message }) if message
57
57
  plugins = @plugin_failures.message
58
58
  emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
59
- memory = MemoryOrphans.message
60
- emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
59
+ memory_load_warnings.each { |event| emit_event(on_event, event) }
61
60
  @extension_load.held_events.each { |event| emit_event(on_event, event) }
62
61
  end
63
62
  private :announce_guardrail_failures
@@ -83,6 +83,7 @@ require_relative "guardrails"
83
83
  require_relative "reminder_store"
84
84
  require_relative "reminder_queue"
85
85
  require_relative "memory_orphans"
86
+ require_relative "project_id_clash"
86
87
  require_relative "tools/memory"
87
88
  require_relative "muted_memories"
88
89
  require_relative "used_memories"
@@ -17,6 +17,7 @@ module Samagotchi
17
17
  def session_id = engine.session&.id
18
18
  def cwd = engine.session&.working_directory
19
19
  def project_root = engine.session&.project_root
20
+ def project_id = engine.session&.project_id
20
21
  def state_dir = engine.peer_state_dir
21
22
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
22
23
  def cancel_reason = engine.active_cancel_controller&.reason
@@ -22,6 +22,77 @@ module Samagotchi
22
22
  STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
23
23
  SYNTHETIC = %i[merged_input].freeze
24
24
 
25
+ # The client-facing lifetime of each event. Rows are drawn in the
26
+ # conversation and survive reload; state updates the live UI; transient
27
+ # is live-only; internal is not sent to clients.
28
+ CLASSIFICATION = {
29
+ after_generation: :internal,
30
+ after_tool_call: :internal,
31
+ after_turn: :internal,
32
+ answer_display: :state,
33
+ before_generation: :internal,
34
+ before_tool_call: :internal,
35
+ before_turn: :internal,
36
+ card: :row,
37
+ command_queued: :state,
38
+ command_ran: :state,
39
+ context_added: :row, # Reloaded from the session messages, not a turn fold or card store.
40
+ context_status: :state,
41
+ continue_offered: :state,
42
+ continue_resolved: :state,
43
+ empty_answer_retry: :row,
44
+ generation_cancelled: :state,
45
+ generation_chunk: :transient,
46
+ generation_completed: :state,
47
+ generation_progress: :internal,
48
+ generation_retrying: :state, # Updates live retry status; a restart also removes invalidated chunks.
49
+ generation_started: :state,
50
+ guardrail_warning: :state, # Worker startup state; not a persistent conversation notice.
51
+ hook_notice: :row,
52
+ input_merged: :state,
53
+ llm_context_edited: :row,
54
+ merged_input: :row,
55
+ objectives_updated: :state,
56
+ participants_changed: :state,
57
+ pending_input_merged: :row,
58
+ plugin_init_finished: :state,
59
+ plugin_init_started: :state,
60
+ plugin_init_wait: :state,
61
+ prompt_restored: :state,
62
+ question_answered: :state,
63
+ question_cancelled: :state,
64
+ question_relay: :state,
65
+ question_requested: :state,
66
+ reasoning_history_refused: :internal,
67
+ recap_ready: :state,
68
+ reminder_injected: :row,
69
+ reset: :state,
70
+ sampling_refused: :internal,
71
+ session_end: :internal,
72
+ session_start: :internal,
73
+ side_usage: :state,
74
+ steer_cut: :row,
75
+ stream_closed: :state,
76
+ stream_resumed: :transient,
77
+ stream_retrying: :transient,
78
+ thinking_dropped: :transient,
79
+ thinking_refused: :internal,
80
+ tool_call_completed: :row,
81
+ tool_call_started: :row,
82
+ tool_dispatch_completed: :state,
83
+ tool_dispatch_started: :state,
84
+ turn_canceled: :row,
85
+ turn_completed: :row,
86
+ turn_dequeued: :state,
87
+ turn_enqueued: :state,
88
+ turn_failed: :row,
89
+ turn_started: :row,
90
+ used_memories_updated: :state,
91
+ verdict_recorded: :state,
92
+ worker_changed: :state,
93
+ working_set_updated: :state
94
+ }.freeze
95
+
25
96
  # Whether +event+ ends a turn that stays in the conversation: completed,
26
97
  # canceled, or failed with its work kept (turn_failed's kept_steps).
27
98
  def self.kept_turn?(event)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
4
5
  require "fileutils"
5
6
  require "time"
@@ -13,7 +14,9 @@ module Samagotchi
13
14
  # written aside and renamed under an exclusive flock (several chi
14
15
  # processes share it). An entry relaxes an ask, never a deny:
15
16
  # session — this exact call (tool + normalized command / paths) in
16
- # this session;
17
+ # this session; a command's in its place too (the
18
+ # worktree or folder it ran in, and a digest of its env:),
19
+ # since the same text acts on whatever they point at;
17
20
  # repo — this exact call in this repo (the cwd outside a repo);
18
21
  # rule — anything this rule (from this source) asks about here.
19
22
  # "This repo" is the repository (repo: its common git dir), so an
@@ -74,11 +77,14 @@ module Samagotchi
74
77
  here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
75
78
  session_id = verdict.context&.session_id
76
79
  session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
80
+ command_place = session_place(verdict)
77
81
  list.find do |e|
78
82
  next false if session_only && e["scope"] != "session"
79
83
 
80
84
  case e["scope"]
81
- when "session" then session_id && e["session_id"] == session_id && e["key"] == key
85
+ when "session"
86
+ session_id && e["session_id"] == session_id && e["key"] == key &&
87
+ (command_place.nil? || e.slice("repo_root", "env") == command_place)
82
88
  when "repo" then here.call(e) && e["key"] == key
83
89
  when "rule"
84
90
  # One rule's grant can't answer for a second rule's ask on the call.
@@ -94,7 +100,9 @@ module Samagotchi
94
100
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
95
101
  "acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
96
102
  case scope
97
- when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
103
+ when "session"
104
+ entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
105
+ entry.merge!(session_place(verdict) || {})
98
106
  when "repo"
99
107
  entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
100
108
  when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
@@ -131,6 +139,22 @@ module Samagotchi
131
139
  t&.repo_root || t&.cwd || verdict.context&.cwd
132
140
  end
133
141
 
142
+ # Where a session entry of a command holds: the call's worktree root
143
+ # (or folder: #place_for; never the common git dir, which every
144
+ # worktree shares) and, when it sets one, a sha256 of its env: (never
145
+ # the env: it may hold a secret). nil for a call with no command:
146
+ # its paths are its place. An entry without them (stored before) no
147
+ # longer matches: one more ask.
148
+ # @return [Hash, nil]
149
+ def self.session_place(verdict)
150
+ t = verdict.targets
151
+ return nil unless t&.command
152
+
153
+ env = t.env.to_h
154
+ digest = env.empty? ? nil : Digest::SHA256.hexdigest(JSON.generate(env.map { |k, v| [k.to_s, v.to_s] }.sort))
155
+ { "repo_root" => place_for(verdict), "env" => digest }.compact
156
+ end
157
+
134
158
  private
135
159
 
136
160
  # @param locked [Boolean] the caller holds the store's lock
@@ -24,7 +24,6 @@ module Samagotchi
24
24
  # (a chi.on handler :log, or :deny for an on_error: :fail_closed one).
25
25
  HOOK_FAILURE_EFFECTS = {
26
26
  log: "it's skipped, and only the log has its next failures",
27
- skip: "it's skipped, and its next failures are silent",
28
27
  deny: "it denies every tool call it guards, and its next failures are in each denial's reason"
29
28
  }.freeze
30
29
 
@@ -115,7 +115,11 @@ module Samagotchi
115
115
  files.each do |file|
116
116
  @results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
117
117
  end
118
- return if @dry_run
118
+ if @dry_run
119
+ edits = Trash.edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
120
+ @warnings << "Would copy to the trash first: #{edits.join(", ")}" unless edits.empty?
121
+ return
122
+ end
119
123
 
120
124
  @trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
121
125
  FileUtils.rm_rf(dir)
@@ -162,9 +166,9 @@ module Samagotchi
162
166
  # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
163
167
  # bundle), one result per incoming file (a dry run's DRY_WORDS of it,
164
168
  # and nothing written), an edit copied to the trash first (save_first),
165
- # then the folder replaced. A file the source dropped goes without a
166
- # word. A plugin or script whose sha256 differs from the declared one
167
- # warns (not a dev link: it is the source).
169
+ # then the folder replaced. A removed file is named in the summary.
170
+ # A plugin or script whose sha256 differs from the declared one warns
171
+ # (not a dev link: it is the source).
168
172
  # @param incoming [Hash{String => String}] name => the source file, in
169
173
  # the order the results list them
170
174
  # @return [Hash{String => String}] name => installed path ({} on a dry run)
@@ -173,21 +177,36 @@ module Samagotchi
173
177
  command = @upgrade && @provenance.installed? ? :upgrade : :install
174
178
  states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
175
179
  .to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
180
+ if @dry_run
181
+ copies = states.filter_map { |_, (asset, action)| asset.label if action&.save_first }
182
+ @warnings << "Would copy to the trash first: #{copies.join(", ")}" unless copies.empty?
183
+ end
176
184
  results = incoming.keys.to_h do |name|
177
185
  asset, action = states.fetch(name)
178
186
  [name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
179
187
  end
188
+ dropped = states.filter_map do |name, (asset, action)|
189
+ next unless action&.do == :remove
190
+
191
+ [result_key(kind_of_file, name),
192
+ Installer::FileResult.new(word: @dry_run ? :would_remove : :removed, state: asset, action: action,
193
+ note: "no longer in the bundle")]
194
+ end
180
195
  if @dry_run
181
196
  results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
197
+ dropped.each { |key, result| @results[key] = result }
182
198
  return {}
183
199
  end
184
200
 
185
201
  states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
186
202
  FileUtils.rm_rf(dir)
187
- return {} if incoming.empty?
203
+ if incoming.empty?
204
+ dropped.each { |key, result| @results[key] = result }
205
+ return {}
206
+ end
188
207
 
189
208
  FileUtils.mkdir_p(dir)
190
- incoming.to_h do |name, src|
209
+ installed = incoming.to_h do |name, src|
191
210
  dest = File.join(dir, name)
192
211
  place(src, dest)
193
212
  @results[result_key(kind_of_file, name)] = results[name]
@@ -198,6 +217,8 @@ module Samagotchi
198
217
  end
199
218
  [name, dest]
200
219
  end
220
+ dropped.each { |key, result| @results[key] = result }
221
+ installed
201
222
  end
202
223
 
203
224
  # The kind's folder and the record's shas for it.
@@ -2,38 +2,18 @@
2
2
 
3
3
  module Samagotchi
4
4
  module MemoryBundle
5
- # One class-file hook's metadata, as an older chi's installed record
6
- # (manifest.json) keeps it (chi no longer installs or loads them:
7
- # Manifest::HooksRetired; it reads them to retire, uninstall and name
8
- # them, and the trash selection takes #sha256): the file's sha256
9
- # ("sha256:<hex>", "" when none is recorded), the events it registers
10
- # for (manifest `event:` for one, `events:` a list; none: it isn't
11
- # loaded), on_error (skip, log or fail_closed) and priority. The one
12
- # place the defaults live.
13
- class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
14
- DEFAULT_ON_ERROR = "skip"
15
- DEFAULT_PRIORITY = 100
5
+ # One class-file hook's sha256 in an older chi's installed record; its
6
+ # other fields are ignored now that class-file hooks are retired.
7
+ BundleHook = Data.define(:sha256) do
8
+ def initialize(sha256: "") = super
16
9
 
17
- # @param event [String, nil] one event (manifest `event:`)
18
- # @param events [Array<String>, String, nil] the events (manifest
19
- # `events:`); `event:` and `events:` together are joined, blanks
20
- # and repeats dropped
21
- def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
22
- names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
23
- super(sha256: sha256, events: names, on_error: on_error, priority: priority)
24
- end
25
-
26
- # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
27
- # symbol keys (a read record); missing or blank values get the
28
- # defaults
10
+ # @param raw [Hash, BundleHook, nil] string or symbol keys; legacy
11
+ # metadata beyond sha256 is ignored
29
12
  def self.parse(raw)
30
13
  return raw if raw.is_a?(BundleHook)
31
14
 
32
15
  h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
33
- on_error = h["on_error"].to_s.strip
34
- new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
35
- on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
36
- priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
16
+ new(sha256: prefixed(h["sha256"]))
37
17
  end
38
18
 
39
19
  # "sha256:<hex>" from either form, "" for none.
@@ -180,14 +180,13 @@ module Samagotchi
180
180
  %i[missing any] => ASSET_GONE
181
181
  }.freeze
182
182
 
183
- # An upgrade (--force too) over the record: a file it replaces is
184
- # updated, except a rule (an upgrade lists a replaced rule as installed:
185
- # the corpus's rule-installed).
183
+ # An upgrade (--force too) over the record: a file it replaces is updated.
186
184
  ASSET_UPGRADE = ASSET_INSTALL.merge(
187
185
  %i[pristine any] => ASSET_WRITE.with(word: :updated),
188
186
  %i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
189
187
  %i[linked any] => ASSET_WRITE.with(word: :updated),
190
- %i[pristine rule] => ASSET_WRITE, %i[edited rule] => ASSET_WRITE.with(save_first: true),
188
+ %i[pristine rule] => ASSET_WRITE.with(word: :updated),
189
+ %i[edited rule] => ASSET_WRITE.with(word: :updated, save_first: true),
191
190
  %i[linked rule] => ASSET_WRITE
192
191
  ).freeze
193
192
 
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # legacy_base: true on an older mark that has since got its incoming_sha (.legacy_mark?);
27
27
  # why, missed, missed_places: FileStanding::Behind's, nil on an older mark;
28
28
  # merged_version, merged_at, merged_trash: a merge no report has shown (Provenance#note_merged)
29
- # hooks basename => BundleHook
29
+ # hooks basename => BundleHook (sha256; other legacy fields ignored)
30
30
  # guardrails basename => recorded sha256 ("" when none)
31
31
  # scripts file name => recorded sha256 ("" when none)
32
32
  # plugin PluginRef, nil without one
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # Installs a bundle into a scoped memories directory.
21
21
  #
22
22
  # Flow (#run):
23
- # 1. Normalize source → directory, read manifest.yml (load_source)
23
+ # 1. Normalize source → directory, refuse retired hooks, read manifest.yml (load_source)
24
24
  # 2. Refuse a bundle whose requires_chi this chi doesn't meet,
25
25
  # unless force (refuse_too_new)
26
26
  # 3. Copy .md files to the target scope dir and update index.md
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  @bundle_dir = @source_dir = prepared.dir
357
357
  @source_owned = prepared.owned
358
358
  @source_commit = prepared.commit
359
- refuse_hooks
359
+ Manifest::HooksRetired.refuse_source_files(@bundle_dir)
360
360
  @manifest = Manifest.read(dir: @bundle_dir)
361
361
  clash = @manifest.session_settings_clash
362
362
  raise InstallError, clash if clash
@@ -371,15 +371,6 @@ module Samagotchi
371
371
  @warnings << "No manifest found — proceeding without strict manifest validation"
372
372
  end
373
373
 
374
- # A source that ships class-file hooks (hooks/*.rb, listed or not) is
375
- # refused before its manifest is read; its manifest's hooks: is
376
- # Manifest.read's refusal (Manifest::HooksRetired either way).
377
- # @raise [Manifest::HooksRetired]
378
- def refuse_hooks
379
- files = Manifest::HooksRetired.files_in(@bundle_dir)
380
- raise Manifest::HooksRetired.for_files(files) unless files.empty?
381
- end
382
-
383
374
  # A bundle whose requires_chi this chi doesn't meet is not installed at
384
375
  # all: --force is what installs it, and its plugin and rules then
385
376
  # warn that they won't load (AssetInstaller). The words come from
@@ -557,6 +548,7 @@ module Samagotchi
557
548
  # cell's text merge runs on temp copies). --force would install every
558
549
  # file, unread.
559
550
  def preview_memory(file_key, state, action)
551
+ preview_trash_copy(file_key, state, action)
560
552
  return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
561
553
  return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
562
554
 
@@ -564,9 +556,20 @@ module Samagotchi
564
556
  action = decision.action if decision
565
557
  return note_conflict(file_key, decision.outcome, state) if action.behind?
566
558
 
559
+ preview_trash_copy(file_key, state, action)
567
560
  @results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
568
561
  end
569
562
 
563
+ def preview_trash_copy(file_key, state, action)
564
+ return unless action.save_first
565
+
566
+ current = state.paths[:current]
567
+ incoming = state.paths[:incoming]
568
+ return unless File.file?(current) && !FileUtils.identical?(incoming, current)
569
+
570
+ @warnings << "Would copy to the trash first: #{file_key}"
571
+ end
572
+
570
573
  # Strict mode: a memory that takes the bundle's text (Action#takes_source?:
571
574
  # a new or missing file, --force, an upgrade's or a re-install's
572
575
  # fast-forward, a merge into a local edit) warns when the bundle's file
@@ -83,6 +83,12 @@ module Samagotchi
83
83
 
84
84
  # The source's hooks/*.rb, relative and sorted ([] for none).
85
85
  def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
86
+
87
+ # Refuse a source's class-file hooks before parsing its manifest.
88
+ def self.refuse_source_files(dir)
89
+ files = files_in(dir)
90
+ raise for_files(files) unless files.empty?
91
+ end
86
92
  end
87
93
 
88
94
  # A plugin file is a plain .rb name in the bundle's top directory.