samagotchi 0.87.0 → 0.89.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/docs/bundle-authoring.md +19 -0
  4. data/docs/cli.md +44 -5
  5. data/docs/guardrails.md +6 -1
  6. data/docs/internals/bundles.md +10 -0
  7. data/docs/sessions.md +4 -4
  8. data/docs/sub-agent.md +9 -0
  9. data/lib/samagotchi/bundle_command/test_run.rb +2 -2
  10. data/lib/samagotchi/bundle_command/try/run.rb +162 -0
  11. data/lib/samagotchi/bundle_command/try/sandbox.rb +159 -0
  12. data/lib/samagotchi/bundle_command/try/trace.rb +152 -0
  13. data/lib/samagotchi/bundle_command.rb +216 -10
  14. data/lib/samagotchi/bundle_session_settings.rb +2 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_reports.rb +100 -7
  17. data/lib/samagotchi/context_providers.rb +1 -2
  18. data/lib/samagotchi/guardrails/parent_policy.rb +20 -0
  19. data/lib/samagotchi/hooks/bundle_loader.rb +2 -0
  20. data/lib/samagotchi/installed_gem.rb +11 -0
  21. data/lib/samagotchi/memory_bundle/asset_installer.rb +25 -13
  22. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  23. data/lib/samagotchi/memory_bundle/installer.rb +22 -3
  24. data/lib/samagotchi/memory_bundle/provenance.rb +21 -6
  25. data/lib/samagotchi/relay.rb +34 -2
  26. data/lib/samagotchi/relay_desk.rb +13 -0
  27. data/lib/samagotchi/session_manager.rb +2 -10
  28. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  29. data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
  30. data/lib/samagotchi/terminal_ui.rb +1 -1
  31. data/lib/samagotchi/tools/delegate_relay.rb +69 -15
  32. data/lib/samagotchi/tools/delegate_wait.rb +3 -1
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/public/notify.js +6 -2
  35. data/lib/samagotchi/web/public/question_card.js +2 -0
  36. data/lib/samagotchi/web/public/question_cards.js +25 -18
  37. data/lib/samagotchi/worker.rb +51 -9
  38. metadata +24 -1
@@ -134,8 +134,7 @@ module Samagotchi
134
134
 
135
135
  record.scripts.each do |file, sha|
136
136
  path = File.join(provenance.scripts_dir, file)
137
- recorded = MemoryBundle::Provenance.recorded_sha(sha)
138
- next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
137
+ next if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, sha)
139
138
 
140
139
  raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
141
140
  end
@@ -105,6 +105,13 @@ module Samagotchi
105
105
  STRATEGIES.key?(word) ? word : HARD
106
106
  end
107
107
 
108
+ # Whether a session's own value is set but not a level (a hand-edited
109
+ # file: read as hard); the worker says so once when it loads.
110
+ def unknown_level?(value)
111
+ word = value.to_s.strip.downcase
112
+ !word.empty? && word != UNSET && !STRATEGIES.key?(word)
113
+ end
114
+
108
115
  # What a parent may answer on a session whose own value is
109
116
  # +session_value+: config.yml's (+config+) unless the session's is
110
117
  # stricter. Config's own object when it binds, so its label stays.
@@ -243,6 +250,19 @@ module Samagotchi
243
250
  end
244
251
  end
245
252
 
253
+ # Whether a parent may usefully answer +pending+ under +strategy+:
254
+ # some option it may pick is not a deny (free text alone is a deny
255
+ # with a reason). A delegate's question it may is reported to the
256
+ # parent's model; one it may not goes to the parent's user
257
+ # (Tools::DelegateRelay.relay?). Who may answer what stays #refusal's.
258
+ def parent_decides?(pending, strategy:)
259
+ deny = deny_labels(pending)
260
+ allowed_options(pending, strategy: strategy).any? { |label| !deny.include?(label) }
261
+ end
262
+
263
+ # The deny-tagged options' labels.
264
+ def deny_labels(pending) = Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == DENY }
265
+
246
266
  # The question's deny-tagged option's label, or nil.
247
267
  def deny_label(pending)
248
268
  index = effects_of(pending).index(DENY)
@@ -126,6 +126,8 @@ module Samagotchi
126
126
  # Why the file doesn't match the sha256 recorded at install, or nil.
127
127
  # With none recorded, only a required hook counts as a mismatch.
128
128
  def sha_mismatch(file, recorded, required:)
129
+ return nil if MemoryBundle::Provenance.unchecked?(recorded)
130
+
129
131
  expected = MemoryBundle::Provenance.recorded_sha(recorded)
130
132
  return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
131
133
 
@@ -29,6 +29,17 @@ module Samagotchi
29
29
  File.file?(path) ? path : nil
30
30
  end
31
31
 
32
+ # What Bundler changed in ENV, mapped back to the values from before it
33
+ # (nil: unset); {} outside Bundler. Child chi commands use this in gem
34
+ # mode so they load the same installed gem without inheriting this app's
35
+ # bundle.
36
+ def unbundled_env_changes(env = ENV.to_h)
37
+ return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
38
+
39
+ clean = ::Bundler.unbundled_env
40
+ (env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
41
+ end
42
+
32
43
  def real_path(path)
33
44
  File.realpath(path)
34
45
  rescue SystemCallError
@@ -17,6 +17,8 @@ module Samagotchi
17
17
  # scripts/*. Each replaces what an earlier version installed. Installer
18
18
  # runs it after the memories; it adds to the installer's results and
19
19
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
+ # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
+ # the source instead of copying it, and warns of no checksum mismatch.
20
22
  class AssetInstaller
21
23
  # What was installed: the paths Provenance#write records (each
22
24
  # empty / nil on a dry run).
@@ -32,7 +34,7 @@ module Samagotchi
32
34
  # @param results, warnings, conflicts the Installer's (results and
33
35
  # warnings are added to)
34
36
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
35
- force: false, strict: true, upgrade: false, dry_run: false)
37
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false)
36
38
  @name = name
37
39
  @bundle_dir = source_dir
38
40
  @manifest = manifest
@@ -45,6 +47,7 @@ module Samagotchi
45
47
  @strict = strict
46
48
  @upgrade = upgrade
47
49
  @dry_run = dry_run
50
+ @dev = dev
48
51
  end
49
52
 
50
53
  # @return [Installed]
@@ -52,8 +55,8 @@ module Samagotchi
52
55
  # script the bundle doesn't have
53
56
  def run
54
57
  install_hooks
55
- prune_dropped_hooks if @upgrade && @existing && !@dry_run
56
- verify_hook_checksums if @manifest && @strict
58
+ prune_dropped_hooks if (@upgrade || (@dev && @existing&.dev)) && @existing && !@dry_run
59
+ verify_hook_checksums if @manifest && @strict && !@dev
57
60
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
58
61
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
59
62
  guardrail_files = install_guardrails(incoming_rules)
@@ -109,7 +112,7 @@ module Samagotchi
109
112
 
110
113
  FileUtils.mkdir_p(@provenance.hooks_dir)
111
114
  unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
112
- FileUtils.cp(src, dest)
115
+ place(src, dest)
113
116
  @results[basename] = if unchanged
114
117
  { status: "skipped", reason: "already up to date" }
115
118
  elsif @upgrade && @existing
@@ -132,7 +135,7 @@ module Samagotchi
132
135
  else
133
136
  FileUtils.mkdir_p(@provenance.hooks_dir)
134
137
  dest = File.join(@provenance.hooks_dir, basename)
135
- FileUtils.cp(src, dest)
138
+ place(src, dest)
136
139
  @results[basename] = { status: "installed" }
137
140
  @hook_files[basename] = dest
138
141
  end
@@ -140,13 +143,13 @@ module Samagotchi
140
143
  end
141
144
 
142
145
  # An upgrade removes the hooks the previous version shipped and this
143
- # one doesn't (a local file too, with a warning).
146
+ # one doesn't (a local file too, with a warning; a dev link silently).
144
147
  def prune_dropped_hooks
145
148
  @existing.hooks.each_key do |old_key_str|
146
149
  next if @bundle_hooks.include?(old_key_str)
147
150
 
148
151
  old_dest = File.join(@provenance.hooks_dir, old_key_str)
149
- if File.exist?(old_dest) && !@force
152
+ if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
150
153
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
151
154
  end
152
155
  FileUtils.rm_f(old_dest)
@@ -193,7 +196,7 @@ module Samagotchi
193
196
  FileUtils.mkdir_p(target)
194
197
  incoming_rules.to_h do |src|
195
198
  dest = File.join(target, File.basename(src))
196
- FileUtils.cp(src, dest)
199
+ place(src, dest)
197
200
  @results["guardrails/#{File.basename(src)}"] =
198
201
  unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
199
202
  [File.basename(src), dest]
@@ -227,7 +230,7 @@ module Samagotchi
227
230
  unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
228
231
  FileUtils.rm_rf(@provenance.plugin_dir)
229
232
  FileUtils.mkdir_p(@provenance.plugin_dir)
230
- FileUtils.cp(src, dest)
233
+ place(src, dest)
231
234
  @results[plugin[:file]] = if unchanged
232
235
  { status: "skipped", reason: "already up to date" }
233
236
  elsif @upgrade && @provenance.installed?
@@ -237,7 +240,7 @@ module Samagotchi
237
240
  end
238
241
  expected = @manifest.checksum_for_plugin
239
242
  actual = Digest::SHA256.hexdigest(File.binread(dest))
240
- if @strict && expected && actual != expected
243
+ if @strict && !@dev && expected && actual != expected
241
244
  @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
242
245
  end
243
246
  dest
@@ -253,6 +256,8 @@ module Samagotchi
253
256
  src = File.join(@bundle_dir, "scripts", file)
254
257
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
255
258
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
259
+
260
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
256
261
  end
257
262
  return {} if @dry_run
258
263
 
@@ -266,21 +271,28 @@ module Samagotchi
266
271
  FileUtils.mkdir_p(@provenance.scripts_dir)
267
272
  scripts.to_h do |file, declared|
268
273
  dest = File.join(@provenance.scripts_dir, file)
269
- FileUtils.cp(File.join(@bundle_dir, "scripts", file), dest)
274
+ place(File.join(@bundle_dir, "scripts", file), dest)
270
275
  @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
271
276
  elsif @upgrade && @provenance.installed? then { status: "updated" }
272
277
  else { status: "installed" }
273
278
  end
274
- FileUtils.chmod(0o755, dest)
279
+ FileUtils.chmod(0o755, dest) unless @dev
275
280
  actual = Digest::SHA256.hexdigest(File.binread(dest))
276
281
  expected = declared.to_s.delete_prefix("sha256:")
277
- if @strict && !expected.empty? && actual != expected
282
+ if @strict && !@dev && !expected.empty? && actual != expected
278
283
  @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
279
284
  end
280
285
  [file, dest]
281
286
  end
282
287
  end
283
288
 
289
+ # Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
290
+ # existing link goes first: a copy over it would write into the source.
291
+ def place(src, dest)
292
+ FileUtils.rm_f(dest) if File.symlink?(dest)
293
+ @dev ? File.symlink(File.realpath(src), dest) : FileUtils.cp(src, dest)
294
+ end
295
+
284
296
  # Whether the bundle file +src+ reads through a symlink: it is one, or
285
297
  # it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
286
298
  # zip or git repo keeps links; copying through one would install any
@@ -22,13 +22,15 @@ module Samagotchi
22
22
  # includes a profile's recorded members, nil for a plain bundle
23
23
  # session_settings
24
24
  # the keys a session may set (chi --set), [] when none
25
+ # dev a dev install (`chi bundle install --dev`): its source is
26
+ # the folder it links to and its files carry Provenance::DEV_SHA
25
27
  # error why manifest.json couldn't be read (the rest empty), else nil
26
28
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
27
29
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
28
- :includes, :context_providers, :session_settings, :error)
30
+ :includes, :context_providers, :session_settings, :dev, :error)
29
31
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
30
32
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
31
- includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
33
+ includes: nil, context_providers: nil, session_settings: [], dev: false, error: nil }.freeze
32
34
 
33
35
  def initialize(name:, **fields)
34
36
  super(name: name, **OPTIONAL, **fields)
@@ -60,7 +62,8 @@ module Samagotchi
60
62
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
61
63
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
62
64
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
63
- session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [])
65
+ session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
66
+ dev: h["dev"] == true)
64
67
  end
65
68
 
66
69
  def self.string(value) = value&.to_s
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  # installs from a dir in it only, unless force); ShippedUpdate,
41
41
  # Profile and SystemBundle pass the one they install from.
42
42
  def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
43
- shipped_dir: SourceNormalizer::SHIPPED_DIR)
43
+ shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false)
44
44
  @source = source
45
45
  @shipped_dir = shipped_dir
46
46
  @prepared = prepared
@@ -50,6 +50,7 @@ module Samagotchi
50
50
  @strict = strict
51
51
  @upgrade = upgrade
52
52
  @dry_run = dry_run
53
+ @dev = dev
53
54
  @results = {}
54
55
  @warnings = []
55
56
  @placeholder_warnings = []
@@ -98,7 +99,7 @@ module Samagotchi
98
99
  installed = AssetInstaller.new(
99
100
  name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
100
101
  results: @results, warnings: @warnings, conflicts: @conflicts,
101
- force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
102
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
102
103
  ).run
103
104
 
104
105
  # Files the previous version shipped and this one doesn't.
@@ -172,10 +173,14 @@ module Samagotchi
172
173
  def check_source
173
174
  refuse_case_twin
174
175
  refuse_shipped_name
176
+ refuse_dev_mismatch
175
177
  now = incoming_identity
176
178
  return if now.nil?
177
179
 
178
180
  was = @existing && SourceIdentity.of(@existing.source)
181
+ if @dev && @existing&.dev && was != now
182
+ raise InstallError, "#{@name} is a dev install of #{@existing.source}; uninstall it before installing from #{@source}"
183
+ end
179
184
  return if was.nil? || was == now
180
185
  return @source_change = [@existing.source, @source.to_s] if @upgrade
181
186
 
@@ -191,6 +196,19 @@ module Samagotchi
191
196
  raise InstallError, "#{@name} differs only in case from the installed #{twin}" if twin
192
197
  end
193
198
 
199
+ # A dev install (--dev, linked to its source) and a normal one don't
200
+ # replace each other: neither an upgrade nor a plain install over a
201
+ # dev bundle, nor --dev over a normal one.
202
+ def refuse_dev_mismatch
203
+ return if @existing.nil? || @existing.dev == @dev
204
+
205
+ if @existing.dev
206
+ raise InstallError, "#{@name} is a dev install of #{@existing.source}: it follows its source already " \
207
+ "(to leave dev mode: chi bundle uninstall #{@name}, then chi bundle install without --dev)"
208
+ end
209
+ raise InstallError, "#{@name} is installed normally; `chi bundle uninstall #{@name}` first, then chi bundle install --dev"
210
+ end
211
+
194
212
  # A shipped name (in any case) from anything but this chi's own
195
213
  # shipped dir. A recorded source may be an older gem's (SourceIdentity
196
214
  # matches the path); an incoming one must be a dir in SHIPPED_DIR, so
@@ -421,7 +439,8 @@ module Samagotchi
421
439
  scripts_files: installed.script_files,
422
440
  context_providers: @manifest.context_providers,
423
441
  file_descriptions: @manifest.file_descriptions,
424
- session_settings: @manifest.session_settings
442
+ session_settings: @manifest.session_settings,
443
+ dev: @dev
425
444
  )
426
445
  end
427
446
 
@@ -116,9 +116,20 @@ module Samagotchi
116
116
  Digest::SHA256.hexdigest(File.binread(path))
117
117
  end
118
118
 
119
- # Whether the file's content is the one recorded (either format).
119
+ # What a dev install (`chi bundle install --dev`) records instead of
120
+ # a sha256: its files are links to the source, which change under it.
121
+ # Not a hex digest, so no file can be mistaken for it.
122
+ DEV_SHA = "sha256:dev"
123
+
124
+ # Whether +recorded+ is DEV_SHA: the file isn't checked.
125
+ def self.unchecked?(recorded)
126
+ recorded_sha(recorded) == recorded_sha(DEV_SHA)
127
+ end
128
+
129
+ # Whether the file's content is the one recorded (either format); a
130
+ # dev install's (DEV_SHA) always is.
120
131
  def self.sha_matches?(path, recorded)
121
- file_sha(path) == recorded_sha(recorded)
132
+ unchecked?(recorded) || file_sha(path) == recorded_sha(recorded)
122
133
  end
123
134
 
124
135
  # Writes provenance for an installation: merges into existing manifest.json
@@ -139,9 +150,11 @@ module Samagotchi
139
150
  # @param scripts_files [Hash] file name => installed script; the
140
151
  # sha256 of each is recorded (ContextFetch checks it before a run)
141
152
  # @param context_providers [Array<ContextProviders::Provider>]
153
+ # @param dev [Boolean] a dev install: its files are links to the
154
+ # source, recorded as DEV_SHA (no sha256 check) with "dev": true
142
155
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
143
156
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
144
- scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [])
157
+ scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], dev: false)
145
158
  FileUtils.mkdir_p(@bundle_dir)
146
159
  bases_dir = File.join(@bundle_dir, "bases")
147
160
  FileUtils.mkdir_p(bases_dir)
@@ -196,6 +209,7 @@ module Samagotchi
196
209
  normalized_hooks.each do |k, hook|
197
210
  src = hooks_files[k] || hooks_files[k.to_sym]
198
211
  hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
212
+ hook = hook.with(sha256: DEV_SHA) if dev
199
213
  hooks_map[k] = hook.to_record
200
214
  next unless src && File.exist?(src.to_s)
201
215
 
@@ -216,9 +230,10 @@ module Samagotchi
216
230
  "hooks" => hooks_map
217
231
  }
218
232
  manifest_data["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
233
+ manifest_data["dev"] = true if dev
219
234
  if guardrails_files.is_a?(Hash) && !guardrails_files.empty?
220
235
  manifest_data["guardrails"] = guardrails_files.sort.to_h do |basename, path|
221
- [basename.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.read(path.to_s))}" }]
236
+ [basename.to_s, { "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(File.read(path.to_s))}" }]
222
237
  end
223
238
  end
224
239
  manifest_data["source_commit"] = source_commit.to_s if source_commit && !source_commit.to_s.empty?
@@ -226,7 +241,7 @@ module Samagotchi
226
241
  if plugin_file
227
242
  content = File.binread(plugin_file.to_s)
228
243
  manifest_data["plugin"] = { "file" => File.basename(plugin_file.to_s),
229
- "sha256" => "sha256:#{Digest::SHA256.hexdigest(content)}" }
244
+ "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(content)}" }
230
245
  FileUtils.mkdir_p(File.join(bases_dir, "plugin"))
231
246
  File.binwrite(plugin_base_path(File.basename(plugin_file.to_s)), content)
232
247
  end
@@ -235,7 +250,7 @@ module Samagotchi
235
250
  manifest_data["includes"] = includes.map(&:to_s) if includes.is_a?(Array)
236
251
  unless scripts_files.empty?
237
252
  manifest_data["scripts"] = scripts_files.sort.to_h do |file, path|
238
- [file.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.binread(path.to_s))}" }]
253
+ [file.to_s, { "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(File.binread(path.to_s))}" }]
239
254
  end
240
255
  end
241
256
  manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
@@ -16,6 +16,8 @@ module Samagotchi
16
16
  # How much of the child's task the card keeps, and the question line.
17
17
  TASK_CHARS = 200
18
18
  TASK_LINE_CHARS = 80
19
+ # A hook's question (QuestionDesk#ask_for_hook), relayed as such (P4).
20
+ HOOK_KIND = "hook"
19
21
 
20
22
  module_function
21
23
 
@@ -33,6 +35,14 @@ module Samagotchi
33
35
  asked = inner ? inner[:asked].to_s : pending.question.to_s
34
36
  task = inner ? inner[:task].to_s : task_of(child)
35
37
  who = chain.reverse.join(" → ")
38
+ relay = { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
39
+ task: task, chain: chain, asked: asked, more: more.to_i }
40
+ if pending.kind.to_s == HOOK_KIND
41
+ # The asking hook's own header, kept down a chain (a relay of a relay).
42
+ relay[:header] = inner ? inner[:header].to_s : pending.header.to_s
43
+ return hook_card(pending, who, more, relay)
44
+ end
45
+
36
46
  {
37
47
  question: question_text(who, task, asked),
38
48
  options: relabel(Array(pending.options), facts[:scopes], who),
@@ -43,11 +53,33 @@ module Samagotchi
43
53
  **{ effects: pending.effects, user_only: pending.user_only }.compact,
44
54
  kind: Guardrails::Approval::KIND,
45
55
  approval: facts,
46
- relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
47
- task: task, chain: chain, asked: asked, more: more.to_i }
56
+ relay: relay
48
57
  }
49
58
  end
50
59
 
60
+ # A hook's question (P4: one its parent can't usefully answer): its
61
+ # kind, hook, options and effects as the child asked them, and no
62
+ # approval facts (ParentApprovals.approval? stays false on it, so the
63
+ # policy judges it by its own tags, as on the child).
64
+ def hook_card(pending, who, more, relay)
65
+ {
66
+ question: question_text(who, relay[:task], relay[:asked]),
67
+ options: Array(pending.options).map(&:to_s),
68
+ header: hook_header(who, relay[:header], more),
69
+ multi_select: !!pending.multi_select,
70
+ allow_freeform: !!pending.allow_freeform,
71
+ **{ effects: pending.effects, user_only: pending.user_only, hook: pending.hook }.compact,
72
+ kind: HOOK_KIND,
73
+ relay: relay
74
+ }
75
+ end
76
+
77
+ # delegate ab12 asks: Cost cap (+1 more delegate waiting)
78
+ def hook_header(who, header, more)
79
+ text = "delegate #{who} asks: #{header.to_s.strip.empty? ? "a question" : header}"
80
+ more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
81
+ end
82
+
51
83
  # delegate ab12 ("fix the flaky spec in …") asks:
52
84
  # execute: git push origin main
53
85
  # in /path/repo (repo samagotchi, branch main)
@@ -63,6 +63,19 @@ module Samagotchi
63
63
  end
64
64
  end
65
65
 
66
+ # Whether the parent's user (or a parent agent) answered or dismissed a
67
+ # relay of this child's question: it isn't relayed again (a refused
68
+ # answer leaves it on the child's card). A closed one (a Stop, the
69
+ # child gone) is not settled. Forgotten after KEEP_SECONDS or a restart.
70
+ def settled?(child_id, child_question_id)
71
+ @mutex.synchronize do
72
+ prune
73
+ @relays.each_value.any? do |relay|
74
+ relay[:state] == "answered" && relay[:child_id] == child_id.to_s && relay[:child_question_id] == child_question_id.to_s
75
+ end
76
+ end
77
+ end
78
+
66
79
  # Whether a relay is open, or settled recently enough that its child
67
80
  # may still ask about it: a restart would drop them.
68
81
  def active?
@@ -263,21 +263,13 @@ module Samagotchi
263
263
  # A gem-mode worker activates the newest installed chi itself: under
264
264
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
265
265
  # hold it to the bundle's version, and its badge would never clear.
266
- child_env.merge!(unbundled_env_changes) if InstalledGem.spec
266
+ child_env.merge!(InstalledGem.unbundled_env_changes) if InstalledGem.spec
267
267
  opts[:env] = child_env unless child_env.empty?
268
268
  opts
269
269
  end
270
270
 
271
- # What Bundler changed in ENV (RUBYOPT, BUNDLE_*), mapped back to the
272
- # values from before it (nil: unset); {} outside Bundler.
273
- private_class_method def self.unbundled_env_changes(env = ENV.to_h)
274
- return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
275
-
276
- clean = ::Bundler.unbundled_env
277
- (env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
278
- end
279
-
280
271
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
272
+ WORKER_ENV_PREFIX = "SAMAGOTCHI_WORKER_"
281
273
  # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
282
274
  SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
283
275
  SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
@@ -195,7 +195,7 @@ module Samagotchi
195
195
 
196
196
  verb = flags.size == 1 ? "applies" : "apply"
197
197
  names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
198
- err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
198
+ err.puts "(#{names} #{verb} to a new session; #{session_id} keeps its own)"
199
199
  end
200
200
 
201
201
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
@@ -96,7 +96,8 @@ module Samagotchi
96
96
  case reason.to_s
97
97
  when "answered_on_child" then "(answered in #{@delegate})"
98
98
  when "child_gone" then "(#{@delegate}'s worker is gone)"
99
- when "dismissed" then "(denied)"
99
+ # A relayed hook question's dismissal: the hook decides what it means.
100
+ when "dismissed" then approval? ? "(denied)" : "(dismissed)"
100
101
  else "(left open in #{@delegate})"
101
102
  end
102
103
  end
@@ -737,7 +737,7 @@ module Samagotchi
737
737
  return if @toolkit_flags.empty?
738
738
 
739
739
  names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
740
- warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
740
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
741
741
  end
742
742
  private :warn_toolkit_flags_ignored
743
743