samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -194,12 +194,10 @@ module Samagotchi
194
194
  def commit
195
195
  @taken.each_value do |report|
196
196
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
197
- next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
197
+ next cursor unless cursor.report_claim&.id == report.claim_id
198
198
 
199
- before = report.before.with(report_claim: nil)
200
- after = report.after.with(report_claim: nil)
201
- current = cursor.with(report_claim: nil)
202
- current == before ? after : current
199
+ current = cursor.without_claim
200
+ current == report.before.without_claim ? report.after.without_claim : current
203
201
  end
204
202
  delete_rings(report.rings)
205
203
  rescue StandardError => e
@@ -212,7 +210,7 @@ module Samagotchi
212
210
  def release
213
211
  @taken.each_value do |report|
214
212
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
215
- cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
213
+ cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
216
214
  end
217
215
  end
218
216
  @taken = {}
@@ -234,11 +232,11 @@ module Samagotchi
234
232
  return report.with(relay: nil) unless relay
235
233
 
236
234
  question = report.relay
237
- what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
235
+ label = Tools::DelegateRelay.label(question)
238
236
  line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
239
- Tools::DelegateRelay.line(what, "answered on the child", hook: true)
237
+ Tools::DelegateRelay.line(label, "answered on the child")
240
238
  elsif relay.relay_desk.settled?(report.child_id, question.id)
241
- Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
239
+ Tools::DelegateRelay.line(label, "answered on a relay already; it waits for the user on the child")
242
240
  else
243
241
  outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
244
242
  return hold_back(report) if outcome.stopped
@@ -286,7 +284,7 @@ module Samagotchi
286
284
  def release_report(report)
287
285
  @taken.delete(report.child_id)
288
286
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
289
- cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
287
+ cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
290
288
  end
291
289
  nil
292
290
  end
@@ -330,11 +328,11 @@ module Samagotchi
330
328
  timeout: 0, turn: before.turn)
331
329
  return nil unless reportable?(wait)
332
330
 
333
- claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
334
- after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
335
- turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
331
+ claim_id = before.report_claim&.id || SecureRandom.hex(16)
332
+ after = before.without_claim.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
333
+ turn: wait.status == :waiting_for_answer ? before.turn : nil)
336
334
  .with_baseline(ReplyWait.baseline_of(wait.session))
337
- claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
335
+ claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
338
336
  return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
339
337
  state_dir: @state_dir)
340
338
 
@@ -46,8 +46,26 @@ module Samagotchi
46
46
  # (#interactive?): a web tab, an attached chi TUI.
47
47
  INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
48
48
 
49
+ # A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
50
+ # runs turns for on its own, by prefix; system:reminder is "reminder",
51
+ # anything else "client".
52
+ ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
53
+ CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
54
+
49
55
  module_function
50
56
 
57
+ # What sent a turn: "delegate" (a delegating parent), "delegate_report"
58
+ # (a child's report), "context" (a context wake), "task" (a task finish
59
+ # wake), "reminder", else "client" (a person, chi send, a plugin, nil).
60
+ # @return [String]
61
+ def origin(client_id)
62
+ id = client_id.to_s
63
+ return "reminder" if id == REMINDER
64
+
65
+ ORIGIN_PREFIXES.each { |prefix, origin| return origin if id.start_with?(prefix) }
66
+ "client"
67
+ end
68
+
51
69
  # Whether +client_id+ is a web tab or a chi TUI, which see a command's
52
70
  # reply and can send the line it asks for (CacheConfirm); chi send, a
53
71
  # delegate, a plugin, chi itself and nil are not.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Config
5
+ # Tells a worker or chi web when config.yml changes a setting it only
6
+ # reads at its start (Config::Entry#lifecycle: worker_start or web_start,
7
+ # and process_start): "server.port changed — applies after a worker
8
+ # restart". ConfigFile.watch_restart_settings! adds a Watcher;
9
+ # ConfigFile.read_yaml reports each new read of the file (#notify) or a
10
+ # file that doesn't parse (#unreadable), which tells nothing and warns
11
+ # once, instead of every start-only key reading as changed.
12
+ #
13
+ # Thread-safe: the watchers list sits behind one mutex; callbacks run
14
+ # outside it.
15
+ module RestartWatch
16
+ @mutex = Mutex.new
17
+ @watchers = []
18
+
19
+ # One process's start-only settings and their start values.
20
+ class Watcher
21
+ LIFECYCLES = {
22
+ worker: %i[worker_start process_start],
23
+ web: %i[web_start process_start]
24
+ }.freeze
25
+
26
+ attr_reader :path
27
+
28
+ # @param data [Hash, nil] config.yml as the process read it at start
29
+ # @param file_key [Array, nil] that read's [mtime, size]
30
+ def initialize(process:, path:, data:, file_key:, on_change:)
31
+ lifecycles = LIFECYCLES.fetch(process) { raise ArgumentError, "unknown config process #{process.inspect}" }
32
+ raise ArgumentError, "a config change callback is required" unless on_change
33
+
34
+ @process = process
35
+ @path = path
36
+ @entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
37
+ @values = @entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] }
38
+ @noticed = {}
39
+ @file_key = file_key
40
+ @on_change = on_change
41
+ end
42
+
43
+ # [callback, message] for each start-only setting the file read now
44
+ # (+data+, under +file_key+) changes: once per key per new value,
45
+ # none for a value back at its start value. Empty for a read already
46
+ # seen. Caller holds RestartWatch's mutex.
47
+ def changes(file_key, data)
48
+ return [] unless seen!(file_key)
49
+
50
+ @entries.filter_map do |entry|
51
+ value = Config.resolve(entry.key, file_data: data, env: {})
52
+ if @values[entry.key] == value
53
+ @noticed.delete(entry.key)
54
+ next
55
+ end
56
+ # Already told for this value: a later edit of another key.
57
+ next if @noticed.key?(entry.key) && @noticed[entry.key] == value
58
+
59
+ @noticed[entry.key] = value
60
+ [@on_change, "#{entry.key} changed — applies after #{restart}"]
61
+ end
62
+ end
63
+
64
+ # Notes +file_key+ as read; false when it already was. Caller holds
65
+ # RestartWatch's mutex.
66
+ def seen!(file_key)
67
+ return false if @file_key == file_key
68
+
69
+ @file_key = file_key
70
+ true
71
+ end
72
+
73
+ private
74
+
75
+ def restart = @process == :worker ? "a worker restart" : "restarting chi web"
76
+ end
77
+
78
+ module_function
79
+
80
+ # Adds a Watcher for +process+ (:worker or :web).
81
+ # @return [Proc] unsubscribes
82
+ def watch(process:, path:, data:, file_key:, &on_change)
83
+ watcher = Watcher.new(process: process, path: path, data: data, file_key: file_key, on_change: on_change)
84
+ @mutex.synchronize { @watchers << watcher }
85
+ -> { @mutex.synchronize { @watchers.delete(watcher) } }
86
+ end
87
+
88
+ # Tells each watcher of +path+ which of its start-only settings the file
89
+ # read now (+data+, under +file_key+; nil for a missing file) changes.
90
+ def notify(path, file_key, data)
91
+ notices = @mutex.synchronize do
92
+ @watchers.select { |watcher| watcher.path == path }.flat_map { |watcher| watcher.changes(file_key, data) }
93
+ end
94
+ notices.each { |callback, message| callback.call(message) }
95
+ end
96
+
97
+ # The file at +path+ (under +file_key+) doesn't parse as a mapping
98
+ # (+problem+ says why): no watcher is told anything (its settings stay
99
+ # compared with the next read that parses), and a process watching it
100
+ # warns once per problem.
101
+ def unreadable(path, file_key, problem)
102
+ watched = @mutex.synchronize do
103
+ @watchers.select { |watcher| watcher.path == path }.each { |watcher| watcher.seen!(file_key) }.any?
104
+ end
105
+ return unless watched
106
+
107
+ ConfigFile.warn_once("config: #{path} doesn't parse (#{problem}); " \
108
+ "start-only settings are checked again when it does")
109
+ end
110
+ end
111
+ end
112
+ end
@@ -10,6 +10,7 @@ require_relative "model_ref"
10
10
  require_relative "paths"
11
11
  require_relative "llm_context_strategy"
12
12
  require_relative "host_model"
13
+ require_relative "config/restart_watch"
13
14
 
14
15
  module Samagotchi
15
16
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -556,10 +557,6 @@ module Samagotchi
556
557
  resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
557
558
  end
558
559
 
559
- def set_cli_overrides(overrides)
560
- reload!(cli_overrides: overrides)
561
- end
562
-
563
560
  # Problems with the keys of a parsed config.yml, one message per key the
564
561
  # code doesn't read (with a "did you mean" when a known key is close).
565
562
  # Every config-exposed entry in ENTRIES is known as written; names under
@@ -750,7 +747,7 @@ module Samagotchi
750
747
  cache = @yaml_cache ||= {}
751
748
  unless File.file?(path)
752
749
  cache.delete(path)
753
- notify_restart_settings(path, nil, nil)
750
+ Config::RestartWatch.notify(path, nil, nil)
754
751
  return nil
755
752
  end
756
753
  stat = File.stat(path)
@@ -758,75 +755,37 @@ module Samagotchi
758
755
  hit = cache[path]
759
756
  return hit[1] if hit && hit[0] == key
760
757
 
758
+ problem = nil
761
759
  data = begin
762
760
  YAML.safe_load_file(path, permitted_classes: [], aliases: false)
763
- rescue StandardError
761
+ rescue StandardError => e
762
+ problem = "#{e.class}: #{e.message.lines.first.to_s.strip[0, 200]}"
764
763
  nil
765
764
  end
765
+ problem ||= "its top level isn't a mapping" unless data.nil? || data.is_a?(Hash)
766
766
  data = nil unless data.is_a?(Hash)
767
767
  cache[path] = [key, data]
768
- notify_restart_settings(path, key, data)
768
+ if problem
769
+ Config::RestartWatch.unreadable(path, key, problem)
770
+ else
771
+ Config::RestartWatch.notify(path, key, data)
772
+ end
769
773
  data
770
774
  end
771
775
 
772
776
  # Calls +on_change+ with one line ("server.port changed — applies after a
773
777
  # worker restart") when config.yml changes a setting this process only
774
- # reads at its start (Config::Entry#lifecycle: worker_start or web_start,
775
- # and process_start). Checked when the file is read again (#read_yaml),
776
- # once per key per new value: none at start, none for a value back at its
777
- # start value.
778
+ # reads at its start (Config::RestartWatch). Checked when the file is
779
+ # read again (#read_yaml), once per key per new value: none at start,
780
+ # none for a value back at its start value, none while the file doesn't
781
+ # parse (that warns once).
778
782
  # @param process [Symbol] :worker or :web
779
783
  # @return [Proc] unsubscribes
780
- def watch_restart_settings!(process:, env: ENV, &on_change)
781
- lifecycles = case process
782
- when :worker then %i[worker_start process_start]
783
- when :web then %i[web_start process_start]
784
- else raise ArgumentError, "unknown config process #{process.inspect}"
785
- end
786
- raise ArgumentError, "a config change callback is required" unless on_change
787
-
784
+ def watch_restart_settings!(process:, env: ENV, &)
788
785
  path = global_path(env: env)
789
786
  data = read_yaml(env: env, path: path)
790
- entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
791
- watcher = {
792
- path: path, process: process, entries: entries,
793
- values: entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] },
794
- noticed: {},
795
- file_key: File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil,
796
- on_change: on_change
797
- }
798
- restart_watchers_mutex.synchronize { (@restart_watchers ||= []) << watcher }
799
- -> { restart_watchers_mutex.synchronize { @restart_watchers&.delete(watcher) } }
800
- end
801
-
802
- # Tells each watcher of +path+ which of its start-only settings the file
803
- # read now (+data+, under +file_key+) changes.
804
- def notify_restart_settings(path, file_key, data)
805
- notices = restart_watchers_mutex.synchronize do
806
- Array(@restart_watchers).flat_map do |watcher|
807
- next [] unless watcher[:path] == path && watcher[:file_key] != file_key
808
-
809
- watcher[:file_key] = file_key
810
- watcher[:entries].filter_map do |entry|
811
- value = Config.resolve(entry.key, file_data: data, env: {})
812
- if watcher[:values][entry.key] == value
813
- watcher[:noticed].delete(entry.key)
814
- next
815
- end
816
- # Already told for this value: a later edit of another key.
817
- next if watcher[:noticed].key?(entry.key) && watcher[:noticed][entry.key] == value
818
-
819
- watcher[:noticed][entry.key] = value
820
- restart = watcher[:process] == :worker ? "a worker restart" : "restarting chi web"
821
- [watcher[:on_change], "#{entry.key} changed — applies after #{restart}"]
822
- end
823
- end
824
- end
825
- notices.each { |callback, message| callback.call(message) }
826
- end
827
-
828
- def restart_watchers_mutex
829
- @restart_watchers_mutex ||= Mutex.new
787
+ file_key = File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil
788
+ Config::RestartWatch.watch(process: process, path: path, data: data, file_key: file_key, &)
830
789
  end
831
790
 
832
791
  # Print +message+ to stderr unless this process already did. The config
@@ -1402,14 +1402,7 @@ module Samagotchi
1402
1402
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1403
1403
  # @param event [Hash] the turn's end event (#stop_facts)
1404
1404
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1405
- client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1406
- source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
1407
- elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
1408
- elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
1409
- elsif client_id.start_with?(ClientId::TASK_PREFIX) then "task"
1410
- elsif client_id == ClientId::REMINDER then "reminder"
1411
- else "client"
1412
- end
1405
+ source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
1413
1406
  enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
1414
1407
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1415
1408
  origin: source, exhausted: limit ? true : nil, limit: limit,
@@ -1523,10 +1516,9 @@ module Samagotchi
1523
1516
  def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1524
1517
  private :guardrail_notify
1525
1518
 
1519
+ # @param question [HookQuestion]
1526
1520
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1527
- def hook_ask_user(question, options, header, allow_freeform, hook, effects: nil)
1528
- @hook_routes.ask_user(question, options, header, allow_freeform, hook, effects: effects)
1529
- end
1521
+ def hook_ask_user(question) = @hook_routes.ask_user(question)
1530
1522
  private :hook_ask_user
1531
1523
 
1532
1524
  # @return [Boolean] true when a running turn was cancelled now
@@ -2569,9 +2561,7 @@ module Samagotchi
2569
2561
  messages: -> { plugin_messages },
2570
2562
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
2571
2563
  notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
2572
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
2573
- hook_ask_user(question, options, header, allow_freeform, hook, effects: effects)
2574
- },
2564
+ ask_user: ->(question) { hook_ask_user(question) },
2575
2565
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
2576
2566
  cancelled: lambda {
2577
2567
  (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
@@ -32,17 +32,30 @@ module Samagotchi
32
32
 
33
33
  # Where command_word's word is in +words+ (nil when there is none).
34
34
  def command_index(words)
35
- i = 0
35
+ each_argument_index(words).find { |i| !assignment?(words[i]) }
36
+ end
37
+
38
+ # Each index of +words+ from +from+ on that is no redirection and no
39
+ # spaced redirection's target (the "/tmp/x" of "> /tmp/x"); an
40
+ # Enumerator without a block.
41
+ def each_argument_index(words, from = 0)
42
+ return enum_for(:each_argument_index, words, from) unless block_given?
43
+
44
+ i = from
36
45
  while i < words.size
37
46
  word = words[i]
38
47
  if word.match?(REDIRECT)
39
- i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
40
- elsif !word.match?(/\A[A-Za-z_]\w*=/)
41
- return i
48
+ i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
49
+ next
42
50
  end
51
+ yield i
43
52
  i += 1
44
53
  end
45
- nil
54
+ end
55
+
56
+ # Whether a word is a NAME=value assignment.
57
+ def assignment?(word)
58
+ word.match?(/\A[A-Za-z_]\w*=/)
46
59
  end
47
60
 
48
61
  # Whether a cat or tee command sends its output to a file (a file
@@ -62,8 +75,21 @@ module Samagotchi
62
75
  # ">& -". A >& with any other target does write one: bash reads
63
76
  # ">& file" and ">&file" as "&>file".
64
77
  def stdout_file?(words)
78
+ redirects_to_file?(words, /\A1?(>>?)(&?)(.*)\z/m)
79
+ end
80
+
81
+ # Whether one of +words+ sends any fd's output to a file (3>run.sh,
82
+ # 2>>log, {fd}>run.sh, 3<>run.sh as well as stdout_file?'s forms),
83
+ # /dev/null and fd dups left out as there.
84
+ def output_file?(words)
85
+ redirects_to_file?(words, /\A(?:\d*|\{\w+\})(>>?|<>)(&?)(.*)\z/m)
86
+ end
87
+
88
+ # stdout_file? and output_file?: a word matching +pattern+ (its
89
+ # captures: the operator, an &, the target) that writes a file.
90
+ def redirects_to_file?(words, pattern)
65
91
  words.each_with_index do |word, i|
66
- match = word.match(/\A1?(>>?)(&?)(.*)\z/m)
92
+ match = word.match(pattern)
67
93
  next unless match
68
94
 
69
95
  amp = match[2]
@@ -84,18 +110,7 @@ module Samagotchi
84
110
  verb = command_index(words)
85
111
  return false unless verb
86
112
 
87
- i = verb + 1
88
- while i < words.size
89
- word = words[i]
90
- if word.match?(REDIRECT)
91
- i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
92
- next
93
- end
94
- return true if filename_word?(word)
95
-
96
- i += 1
97
- end
98
- false
113
+ each_argument_index(words, verb + 1).any? { |i| filename_word?(words[i]) }
99
114
  end
100
115
 
101
116
  # Whether a tee argument names a file: not an option, a heredoc's tag,
@@ -139,18 +154,7 @@ module Samagotchi
139
154
  # their spaced targets), the <<TAG words and the heredoc bodies left
140
155
  # out.
141
156
  def arguments(words)
142
- args = []
143
- i = 0
144
- while i < words.size
145
- word = words[i]
146
- if word.match?(REDIRECT)
147
- i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
148
- next
149
- end
150
- args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
151
- i += 1
152
- end
153
- args
157
+ each_argument_index(words).map { |i| words[i] }.reject { |word| assignment?(word) || heredoc_word?(word) }
154
158
  end
155
159
 
156
160
  # Whether a word is a heredoc's <<TAG or one of its bodies.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # The parent-answers level words and their one reading (#parse_level),
6
+ # apart from the rest of ParentPolicy so Session (its parent_answers
7
+ # setter) reads them without loading the guardrails.
8
+ module ParentPolicy
9
+ HARD = "hard"
10
+ STANDARD = "standard"
11
+ SOFT = "soft"
12
+ # Strictest first: a session's own level may only move left of
13
+ # config.yml's.
14
+ ORDER = [HARD, STANDARD, SOFT].freeze
15
+ # A session value that means "none of its own" (config's).
16
+ UNSET = "default"
17
+
18
+ # A level word as written anywhere (a session file, /parent-answers,
19
+ # --parent-answers), stripped and case-blind.
20
+ # @param word [String, Symbol, nil]
21
+ # @return [String, Symbol] the level, :unset (nil, "" or "default":
22
+ # config's) or :invalid
23
+ def self.parse_level(word)
24
+ text = word.to_s.strip.downcase
25
+ return :unset if text.empty? || text == UNSET
26
+
27
+ ORDER.include?(text) ? text : :invalid
28
+ end
29
+ end
30
+ end
31
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../config"
4
4
  require_relative "../pending_question"
5
5
  require_relative "approval"
6
+ require_relative "parent_levels"
6
7
  require_relative "parent_approvals"
7
8
 
8
9
  module Samagotchi
@@ -49,9 +50,8 @@ module Samagotchi
49
50
  KEY = "guardrails.parent_answers"
50
51
  # A session's own value (Session#parent_answers): it may only tighten KEY.
51
52
  SESSION_KEY = "parent_answers"
52
- HARD = "hard"
53
- STANDARD = "standard"
54
- SOFT = "soft"
53
+ # The level words (HARD, STANDARD, SOFT, ORDER, UNSET) and
54
+ # #parse_level: parent_levels.rb.
55
55
 
56
56
  # A strategy: its name, the effects a parent may pick under it, and
57
57
  # where it comes from: :config (config.yml's) or :session (a
@@ -83,12 +83,6 @@ module Samagotchi
83
83
  # @return [Strategy]
84
84
  def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
85
85
 
86
- # Strictest first: a session's own level may only move left of
87
- # config.yml's.
88
- ORDER = [HARD, STANDARD, SOFT].freeze
89
- # A session value that means "none of its own" (config's).
90
- UNSET = "default"
91
-
92
86
  # The stricter of two strategies, by name; a tie keeps +a+.
93
87
  # @return [Strategy]
94
88
  def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
@@ -99,18 +93,16 @@ module Samagotchi
99
93
  # is read on every parent answer).
100
94
  # @return [String, nil]
101
95
  def session_level(value)
102
- word = value.to_s.strip.downcase
103
- return nil if word.empty? || word == UNSET
104
-
105
- STRATEGIES.key?(word) ? word : HARD
96
+ case (level = parse_level(value))
97
+ when :unset then nil
98
+ when :invalid then HARD
99
+ else level
100
+ end
106
101
  end
107
102
 
108
103
  # Whether a session's own value is set but not a level (a hand-edited
109
104
  # 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
105
+ def unknown_level?(value) = parse_level(value) == :invalid
114
106
 
115
107
  # What a parent may answer on a session whose own value is
116
108
  # +session_value+: config.yml's (+config+) unless the session's is
@@ -150,9 +142,6 @@ module Samagotchi
150
142
  level.nil? ? ORDER.size : ORDER.index(level)
151
143
  end
152
144
 
153
- # @return [Array<String>] the effects +strategy+ lets a parent pick
154
- def allowed_effects(strategy) = strategy.allowed
155
-
156
145
  # Each option's effect, in option order. Tagged questions carry them;
157
146
  # an untagged one (asked by an older worker, a relayed card built
158
147
  # from an older child's question) gets them derived as before:
@@ -95,6 +95,7 @@ module Samagotchi
95
95
 
96
96
  bodies = lexer.heredoc_spans.map { |doc| doc[:span] }
97
97
  cuts = []
98
+ redirected = false
98
99
  each_command(tokens) do |indices|
99
100
  words = indices.map { |i| tokens[i][1] }
100
101
  indices.each_with_index do |i, position|
@@ -103,12 +104,13 @@ module Samagotchi
103
104
 
104
105
  span = lexer.spans[i]
105
106
  cut = if bodies.include?(span)
106
- body_cut(text, span, words) if value == HEREDOC
107
+ body_cut(text, span, words, redirected) if value == HEREDOC
107
108
  elsif value == SUBST
108
109
  substitution_cut(text, tokens, i, span, words, position)
109
110
  end
110
111
  cuts << cut if cut
111
112
  end
113
+ redirected ||= exec_redirect?(tokens, indices, words)
112
114
  end
113
115
  cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
114
116
  out[span] = replacement
@@ -134,25 +136,44 @@ module Samagotchi
134
136
 
135
137
  # [span, what it becomes] for a data heredoc's body at +span+ (its
136
138
  # command's +words+), or nil when the body stays in the code text: a
137
- # cat or tee writing a file may be run later (D1).
138
- def self.body_cut(text, span, words)
139
- return nil unless cut_body?(words)
139
+ # cat or tee writing a file may be run later (D1), and so may one
140
+ # printing after an earlier exec sent the shell's output to a file
141
+ # (+redirected+).
142
+ def self.body_cut(text, span, words, redirected)
143
+ return nil unless cut_body?(words, redirected)
140
144
 
141
145
  [span, terminator_line(text[span])]
142
146
  end
143
147
  private_class_method :body_cut
144
148
 
145
149
  # Whether a body a data reader reads leaves the code text: a cat or tee
146
- # sending it to a file keeps it (D1); a body any other command reads as
150
+ # sending it to a file keeps it (D1), as does any cat or tee once an
151
+ # exec redirected the shell's output (+redirected+: its stdout, or the
152
+ # fd a >&3 names, may be a file); a body any other command reads as
147
153
  # data is one the lexer's data_sink? approved.
148
- def self.cut_body?(words)
154
+ def self.cut_body?(words, redirected)
149
155
  verb = DataReaders.command_word(words)
150
156
  return true unless DataReaders::SINKS.include?(File.basename(verb.to_s))
151
157
 
152
- !DataReaders.writes_file?(words)
158
+ !redirected && !DataReaders.writes_file?(words)
153
159
  end
154
160
  private_class_method :cut_body?
155
161
 
162
+ # Whether the simple command at +indices+ may be an exec that redirects
163
+ # the shell's own output to a file (exec >run.sh, exec 3>run.sh,
164
+ # exec {fd}>run.sh, exec 3<>run.sh): what a later cat or tee prints may
165
+ # land there. Any exec word counts, not only the command word ({ exec
166
+ # …; }, then exec …, command exec …, a function's body), and so does an
167
+ # exec backgrounded (exec &>run.sh, which the lexer reads as "exec &"
168
+ # and ">run.sh"): the conservative reading.
169
+ def self.exec_redirect?(tokens, indices, words)
170
+ return false unless words.include?("exec")
171
+
172
+ following = tokens[indices.last + 1]
173
+ DataReaders.output_file?(words) || following == [:op, "&"]
174
+ end
175
+ private_class_method :exec_redirect?
176
+
156
177
  # [span, what it becomes] for the body of a "$(cat <<'EOF' … EOF)" given
157
178
  # as a message (-m, --body, …) of a data reader (D2), or nil: eval,
158
179
  # bash -c, a command piped on and a body that runs stay.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A hook's or a plugin's question (event[:ask_user], ctx.ask_user) as one
5
+ # value on its way to the question flow: built at the plugin-facing edge
6
+ # (Hooks::Registry's event helper, Plugin::Context#ask_user), passed
7
+ # through the hook runtime and the plugin host (Hooks::Runtime#ask_user,
8
+ # Plugin::Host#ask_user), Engine#hook_ask_user and HookRoutes#ask_user to
9
+ # QuestionDesk#ask_for_hook. The bundle test kit records it as asked
10
+ # (Testing::Recorder#questions).
11
+ #
12
+ # question, options, header, allow_freeform: as the hook gave them
13
+ # (QuestionDesk checks the options); hook: the asking hook's or plugin's
14
+ # label; effects: what an option does for a parent agent's answer, by its
15
+ # label (label => effect, as the hook gave it; nil for none). QuestionDesk
16
+ # turns it into the pending question's per-option tags
17
+ # (Guardrails::ParentPolicy.option_tags, PendingQuestion#effects).
18
+ HookQuestion = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
+ def initialize(effects: nil, **) = super
20
+ end
21
+ end