samagotchi 0.85.0 → 0.87.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -5
  5. data/docs/bundle-authoring.md +125 -0
  6. data/docs/cli.md +51 -3
  7. data/docs/configuration.md +6 -5
  8. data/docs/guardrails.md +112 -39
  9. data/docs/hooks.md +19 -3
  10. data/docs/internals/anthropic.md +2 -2
  11. data/docs/internals/bundles.md +11 -2
  12. data/docs/internals/context-telemetry.md +19 -1
  13. data/docs/internals/engine.md +1 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +59 -2
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +19 -7
  18. data/docs/sub-agent.md +39 -25
  19. data/docs/testing.md +7 -2
  20. data/lib/samagotchi/answer_command.rb +21 -25
  21. data/lib/samagotchi/bridge/client_event.rb +50 -0
  22. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  23. data/lib/samagotchi/bridge.rb +22 -11
  24. data/lib/samagotchi/bridge_client.rb +2 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  36. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  38. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  39. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  40. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  42. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  43. data/lib/samagotchi/client_id.rb +1 -1
  44. data/lib/samagotchi/config.rb +195 -115
  45. data/lib/samagotchi/context_status.rb +1 -1
  46. data/lib/samagotchi/continue_offer.rb +2 -1
  47. data/lib/samagotchi/engine.rb +4 -4
  48. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  49. data/lib/samagotchi/guardrails/approval.rb +12 -2
  50. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  51. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  52. data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
  53. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  54. data/lib/samagotchi/hook_routes.rb +5 -3
  55. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  56. data/lib/samagotchi/hooks/registry.rb +13 -6
  57. data/lib/samagotchi/hooks.rb +5 -0
  58. data/lib/samagotchi/kernel_loop.rb +5 -2
  59. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  61. data/lib/samagotchi/memory_bundle.rb +1 -0
  62. data/lib/samagotchi/parent_answers_command.rb +80 -0
  63. data/lib/samagotchi/parent_report.rb +122 -30
  64. data/lib/samagotchi/pending_question.rb +8 -4
  65. data/lib/samagotchi/plugin/api.rb +16 -0
  66. data/lib/samagotchi/plugin/context.rb +11 -5
  67. data/lib/samagotchi/plugin/loader.rb +5 -3
  68. data/lib/samagotchi/question_desk.rb +39 -25
  69. data/lib/samagotchi/queued_command.rb +5 -4
  70. data/lib/samagotchi/relay.rb +5 -1
  71. data/lib/samagotchi/relay_verifier.rb +4 -5
  72. data/lib/samagotchi/reply_wait.rb +14 -5
  73. data/lib/samagotchi/send_command.rb +103 -12
  74. data/lib/samagotchi/session.rb +26 -2
  75. data/lib/samagotchi/session_commands.rb +45 -8
  76. data/lib/samagotchi/session_setup.rb +10 -8
  77. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
  79. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  80. data/lib/samagotchi/terminal_ui.rb +20 -11
  81. data/lib/samagotchi/testing/boundary.rb +0 -3
  82. data/lib/samagotchi/testing/bundle.rb +7 -0
  83. data/lib/samagotchi/testing/host.rb +6 -4
  84. data/lib/samagotchi/testing/install.rb +119 -0
  85. data/lib/samagotchi/testing/recorder.rb +13 -4
  86. data/lib/samagotchi/testing.rb +1 -0
  87. data/lib/samagotchi/tools/builtins.rb +2 -1
  88. data/lib/samagotchi/tools/delegate.rb +4 -1
  89. data/lib/samagotchi/tools/delegate_wait.rb +16 -7
  90. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  91. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  92. data/lib/samagotchi/turn_flow.rb +10 -0
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +48 -10
  95. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  96. data/lib/samagotchi/web/public/ctx.js +12 -26
  97. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  98. data/lib/samagotchi/web/server.rb +4 -0
  99. data/lib/samagotchi/worker.rb +27 -5
  100. data/lib/samagotchi/worker_inbound.rb +4 -4
  101. metadata +12 -2
  102. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -15,6 +15,7 @@ module Samagotchi
15
15
  KIND = "approval"
16
16
  HEADER = "Approve tool call?"
17
17
  DENY = "Deny"
18
+ ONCE = "Allow once"
18
19
 
19
20
  module_function
20
21
 
@@ -27,7 +28,9 @@ module Samagotchi
27
28
  # write: the card shows its diff, the question text one change line
28
29
  # @param only [Array<String>, nil] offer no scope beyond these (a
29
30
  # scratch session: once, session); "once" when none is left
30
- # @return [Hash] open_question fields
31
+ # @return [Hash] open_question fields; its caller adds user_only:
32
+ # (ParentApprovals.protected_facts? on approval:, a file that
33
+ # requires this one)
31
34
  def payload(verdict, label: nil, preview: nil, only: nil)
32
35
  scopes = offered_scopes(verdict, only: only)
33
36
  targets = verdict.targets
@@ -37,6 +40,7 @@ module Samagotchi
37
40
  header: HEADER,
38
41
  multi_select: false,
39
42
  allow_freeform: true,
43
+ effects: effects(scopes),
40
44
  kind: KIND,
41
45
  approval: {
42
46
  tool: targets&.tool || verdict.call[:name].to_s,
@@ -61,6 +65,12 @@ module Samagotchi
61
65
  }
62
66
  end
63
67
 
68
+ # Each option's effect for a parent's answer (ParentPolicy): once
69
+ # permits one call, a wider scope more, Deny denies.
70
+ def effects(scopes)
71
+ scopes.map { |scope| scope == "once" ? "permit" : "permit_wide" } + ["deny"]
72
+ end
73
+
64
74
  def args_for_card(targets)
65
75
  return nil if targets.nil? || targets.command || !targets.paths.empty?
66
76
 
@@ -101,7 +111,7 @@ module Samagotchi
101
111
  def label(scope, verdict)
102
112
  place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
103
113
  case scope
104
- when "once" then "Allow once"
114
+ when "once" then ONCE
105
115
  when "session" then "Allow this call for the session"
106
116
  when "repo" then "Allow this call #{place}"
107
117
  when "rule" then "Allow rule #{verdict.rule} #{place}"
@@ -7,7 +7,18 @@ module Samagotchi
7
7
  # guardrail that silently vanished is the failure this guards against);
8
8
  # the rest are only announced.
9
9
  class LoadFailures
10
- Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
10
+ # +location+: "path:line" of the first frame of the error inside the
11
+ # bundle that failed (nil when none is), for `chi bundle check`.
12
+ Failure = Struct.new(:what, :reason, :required, :group, :location, keyword_init: true)
13
+
14
+ # "path:line" where +error+ was raised inside +dir+ (its first
15
+ # backtrace frame there; a SyntaxError's own file:line), or nil.
16
+ def self.location(error, dir)
17
+ prefix = "#{File.expand_path(dir)}/"
18
+ frame = Array(error.backtrace).find { |line| line.start_with?(prefix) }
19
+ frame ||= error.message.lines.first if error.is_a?(SyntaxError)
20
+ frame&.start_with?(prefix) ? frame[/\A[^:]+:\d+/] : nil
21
+ end
11
22
 
12
23
  def initialize
13
24
  @list = []
@@ -16,8 +27,11 @@ module Samagotchi
16
27
 
17
28
  # @param what [String] e.g. "hook guard.rb (config)"
18
29
  # @param group [Symbol, nil] what loads it again (#drop): :rules
19
- def add(what, reason, required:, group: nil)
20
- @mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
30
+ # @param location [String, nil] Failure#location
31
+ def add(what, reason, required:, group: nil, location: nil)
32
+ @mutex.synchronize do
33
+ @list << Failure.new(what: what, reason: reason, required: required, group: group, location: location)
34
+ end
21
35
  end
22
36
 
23
37
  # Forget a group's failures before it loads again.
@@ -7,29 +7,18 @@ require_relative "../hooks/loader"
7
7
  require_relative "approval"
8
8
  require_relative "verdict"
9
9
  require_relative "protected_paths"
10
- require_relative "parent_continue"
11
10
 
12
11
  module Samagotchi
13
12
  module Guardrails
14
- # What a parent agent may allow on an approval (guardrails.parent_approvals,
15
- # config.yml only): off, a deny only; once, "Allow once" too, never a
16
- # wider scope. `chi answer` checks it before it posts; the worker checks
17
- # it again for an answer marked as chi answer's (CLIENT_ID), with its own
18
- # config. A convention for an honest but eager parent, not a security
19
- # boundary: any local process can post to the Bridge, with or without
20
- # the marker.
21
- #
22
- # An option allows when Approval.settle would allow it: its index is
23
- # below the approval's scopes. The scope is read by index, never by
24
- # label. When the scopes are missing or don't fit the options, it fails
25
- # closed: only a last option labelled Deny denies.
26
- #
27
- # Whatever the setting, an approval for a call on chi's own config dir
28
- # (config.yml, memories/.bundles' rules), the hooks dir or the approval
29
- # store is the user's alone (:protected): "Allow once" on a config.yml
30
- # rewrite would be "allow everything" from then on.
13
+ # The facts a parent agent's answer is judged by (ParentPolicy): who
14
+ # answers as a parent (#parent_process?), what an approval is
15
+ # (#approval?, its #scopes) and which approvals are the user's alone
16
+ # (#protected?): a call on chi's own config dir (config.yml,
17
+ # memories/.bundles' rules), the hooks dir or the approval store, since
18
+ # "Allow once" on a config.yml rewrite would be "allow everything" from
19
+ # then on. Also chi's dirs and text for the guardrails' own rules
20
+ # (Targets, the shell rules).
31
21
  module ParentApprovals
32
- KEY = "guardrails.parent_approvals"
33
22
  # Set by chi's execute and task_create for the commands they run: a
34
23
  # chi started there answers as a parent (#parent_process?).
35
24
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
@@ -56,7 +45,7 @@ module Samagotchi
56
45
  module_function
57
46
 
58
47
  # Whether an answer typed into this process is a parent agent's (so
59
- # this setting applies to it): stdin isn't a terminal (a pipe, a file,
48
+ # ParentPolicy applies to it): stdin isn't a terminal (a pipe, a file,
60
49
  # /dev/null), or an agent marker is set. A person at chi --attach or
61
50
  # the REPL has a terminal and no marker.
62
51
  def parent_process?(env: ENV, stdin: $stdin)
@@ -65,35 +54,14 @@ module Samagotchi
65
54
  !(stdin.respond_to?(:tty?) && stdin.tty?)
66
55
  end
67
56
 
68
- # This process's setting: "once" or "off" (anything else is off).
69
- def setting
70
- Config.get(KEY).to_s == "once" ? "once" : "off"
71
- end
72
-
73
- # Why a parent may not give this answer, or nil.
74
- # @param pending [Hash] the pending question (symbol or string keys)
75
- # @param indices [Array<Integer, nil>] the selected options' places
76
- # @param setting [String, nil] "off" or "once"
77
- # @return [Symbol, nil] :protected (chi's own config: the user's
78
- # alone), :off (no allow at all), :once_only, or nil
79
- def refusal(pending, indices, setting:)
80
- return nil unless approval?(pending)
81
-
82
- scopes = scopes(pending)
83
- allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
84
- return nil if allows.empty?
85
- return :protected if protected?(pending)
86
- return :off unless setting.to_s == "once"
87
- return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
88
-
89
- :once_only
90
- end
91
-
92
57
  # Whether +pending+ asks about a call on chi's own config, hooks,
93
58
  # guardrail rules or approvals: by its rule, its paths or its command
94
59
  # (the dirs as this process's environment and config.yml name them).
95
- def protected?(pending)
96
- facts = pending.approval
60
+ def protected?(pending) = protected_facts?(pending.approval)
61
+
62
+ # #protected? on an approval's facts (Approval.payload's approval:),
63
+ # at ask time.
64
+ def protected_facts?(facts)
97
65
  return false unless facts.is_a?(Hash)
98
66
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
99
67
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
@@ -120,21 +88,6 @@ module Samagotchi
120
88
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
121
89
  end
122
90
 
123
- # The refusal as a line for the parent: deny, and tell the user.
124
- # Never how to allow it (the web, chi --attach): that is the user's.
125
- # @param reason [Symbol] what #refusal returned
126
- # @param typed [Boolean] the answer was typed at a ? prompt (the
127
- # REPL), not given to chi answer
128
- def message(reason, typed: false)
129
- deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
130
- case reason
131
- when :stop_only then ParentContinue.message
132
- when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
133
- when :off then "allowing a tool call is up to the user: #{deny}"
134
- else "only Allow once (#{KEY}: once) can be given here: #{deny}"
135
- end
136
- end
137
-
138
91
  # An approval: its kind says so, or it carries approval facts.
139
92
  def approval?(pending)
140
93
  pending.kind.to_s == Approval::KIND || !pending.approval.nil?
@@ -151,15 +104,6 @@ module Samagotchi
151
104
  scopes
152
105
  end
153
106
 
154
- def deny?(pending, scopes, index)
155
- return false unless index.is_a?(Integer) && index >= 0
156
-
157
- options = options(pending)
158
- return index >= scopes.size && index < options.size if scopes
159
-
160
- index == options.size - 1 && options[index].to_s == Approval::DENY
161
- end
162
-
163
107
  def options(pending) = Array(pending.options)
164
108
 
165
109
  def fetch(hash, key)
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../pending_question"
5
+ require_relative "approval"
6
+ require_relative "parent_approvals"
7
+
8
+ module Samagotchi
9
+ module Guardrails
10
+ # What a parent agent may answer on a session's question: one check for
11
+ # every asker (an approval, the step-limit Continue, a plugin's or a
12
+ # hook's ask_user, a relayed delegate approval). The asker tags each
13
+ # option with its effect (PendingQuestion#effects); a strategy says
14
+ # which effects a parent may pick. `chi answer` checks it before it
15
+ # posts; the worker's QuestionDesk checks it again, with its own config,
16
+ # for an answer marked as a parent agent's. A convention for an honest
17
+ # but eager parent, not a security boundary.
18
+ #
19
+ # Strategies (guardrails.parent_answers, config.yml only): hard = answer
20
+ # and deny; standard = + spend; soft (the default) = + permit. A session
21
+ # may carry its own, stricter one (Session#parent_answers): config.yml
22
+ # is the ceiling (#effective).
23
+ #
24
+ # Effects: answer (an untagged option), deny, spend (more time and
25
+ # tokens: Continue), permit (one call: Allow once), permit_wide (more
26
+ # than one call: a session, repo or rule scope). A parent never picks
27
+ # permit_wide; on a user_only question (chi's own config, hooks or
28
+ # guardrails) it picks only deny or answer. Text alone and a dismissal
29
+ # always pass.
30
+ module ParentPolicy
31
+ ANSWER = "answer"
32
+ DENY = "deny"
33
+ SPEND = "spend"
34
+ PERMIT = "permit"
35
+ PERMIT_WIDE = "permit_wide"
36
+ EFFECTS = [ANSWER, DENY, SPEND, PERMIT, PERMIT_WIDE].freeze
37
+ # The effects a parent may pick with no setting at all.
38
+ ALWAYS = [ANSWER, DENY].freeze
39
+ # Beyond-the-strategy reasons, widest first (#refusal).
40
+ WIDEST_FIRST = [PERMIT_WIDE, PERMIT, SPEND].freeze
41
+
42
+ # The step-limit question (ContinueOffer).
43
+ CONTINUE_KIND = "continue"
44
+ CONTINUE = "Continue"
45
+ STOP = "Stop"
46
+ # Its shape, for a /continue yes sent as a command (#continue_refusal).
47
+ CONTINUE_QUESTION = PendingQuestion.new(kind: CONTINUE_KIND, options: [CONTINUE, STOP], effects: [SPEND, DENY])
48
+
49
+ KEY = "guardrails.parent_answers"
50
+ # A session's own value (Session#parent_answers): it may only tighten KEY.
51
+ SESSION_KEY = "parent_answers"
52
+ HARD = "hard"
53
+ STANDARD = "standard"
54
+ SOFT = "soft"
55
+
56
+ # A strategy: its name, the effects a parent may pick under it, and
57
+ # where it comes from: :config (config.yml's) or :session (a
58
+ # session's own, stricter one: #effective).
59
+ Strategy = Data.define(:name, :allowed, :origin) do
60
+ def initialize(name:, allowed:, origin: :config) = super
61
+
62
+ def allow?(effect) = allowed.include?(effect.to_s)
63
+
64
+ # How a refusal names the setting: "guardrails.parent_answers: hard",
65
+ # or "parent_answers: hard (this session)" when the session's binds.
66
+ def label = origin == :session ? "#{SESSION_KEY}: #{name} (this session)" : "#{KEY}: #{name}"
67
+ end
68
+
69
+ STRATEGIES = {
70
+ HARD => Strategy.new(name: HARD, allowed: ALWAYS),
71
+ STANDARD => Strategy.new(name: STANDARD, allowed: [*ALWAYS, SPEND].freeze),
72
+ SOFT => Strategy.new(name: SOFT, allowed: [*ALWAYS, SPEND, PERMIT].freeze)
73
+ }.freeze
74
+
75
+ module_function
76
+
77
+ # This process's strategy (config.yml only; Config makes an invalid
78
+ # value hard and warns).
79
+ # @return [Strategy]
80
+ def current = strategy(Config.get(KEY))
81
+
82
+ # The named strategy; anything else is hard, the strictest.
83
+ # @return [Strategy]
84
+ def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
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
+ # The stricter of two strategies, by name; a tie keeps +a+.
93
+ # @return [Strategy]
94
+ def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
95
+
96
+ # A session's own value as a level: nil when it has none (nil, "",
97
+ # "default"), the level, or hard for anything else (a hand-edited
98
+ # file: the strictest, as config's invalid value; not logged, as this
99
+ # is read on every parent answer).
100
+ # @return [String, nil]
101
+ 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
106
+ end
107
+
108
+ # What a parent may answer on a session whose own value is
109
+ # +session_value+: config.yml's (+config+) unless the session's is
110
+ # stricter. Config's own object when it binds, so its label stays.
111
+ # @return [Strategy]
112
+ def effective(session_value, config: current)
113
+ level = session_level(session_value)
114
+ return config if level.nil?
115
+
116
+ own = STRATEGIES[level]
117
+ stricter(config, own).equal?(config) ? config : own.with(origin: :session)
118
+ end
119
+
120
+ # Why a session's own +value+ doesn't apply: "soft is looser than
121
+ # config.yml's guardrails.parent_answers: standard; standard applies";
122
+ # nil when it applies (or is none).
123
+ # @return [String, nil]
124
+ def capped_note(value, config: current)
125
+ level = session_level(value)
126
+ return nil if level.nil? || effective(level, config: config).name == level
127
+
128
+ "#{level} is looser than config.yml's #{config.label}; #{config.name} applies"
129
+ end
130
+
131
+ # #effective with +session+'s own value (a Session; nil: config's).
132
+ # @return [Strategy]
133
+ def for_session(session) = effective(session&.parent_answers)
134
+
135
+ # Whether setting a session's value +from+ → +to+ loosens it: unset
136
+ # (config's) is the loosest, an invalid value hard. Only the user
137
+ # loosens (/parent-answers in a UI).
138
+ def looser?(from, to) = session_rank(to) > session_rank(from)
139
+
140
+ # hard 0, standard 1, soft 2, unset 3.
141
+ def session_rank(value)
142
+ level = session_level(value)
143
+ level.nil? ? ORDER.size : ORDER.index(level)
144
+ end
145
+
146
+ # @return [Array<String>] the effects +strategy+ lets a parent pick
147
+ def allowed_effects(strategy) = strategy.allowed
148
+
149
+ # Each option's effect, in option order. Tagged questions carry them;
150
+ # an untagged one (asked by an older worker, a relayed card built
151
+ # from an older child's question) gets them derived as before:
152
+ # an approval's from its scopes, the step-limit question's from its
153
+ # labels, anything else's all answer.
154
+ # @param pending [PendingQuestion]
155
+ # @return [Array<String>]
156
+ def effects_of(pending)
157
+ options = Array(pending.options)
158
+ tags = pending.effects
159
+ return options.each_index.map { |index| tag(tags[index]) } if tags.is_a?(Array)
160
+ return approval_effects(pending, options) if ParentApprovals.approval?(pending)
161
+ return options.map { |label| continue_effect(label) } if pending.kind.to_s == CONTINUE_KIND
162
+
163
+ options.map { ANSWER }
164
+ end
165
+
166
+ # Whether only the user may pick a non-deny/answer option. Unset on an
167
+ # approval (an older worker's): worked out from its facts here. A
168
+ # relayed card: the child's flag, or this process's own look at it.
169
+ def user_only?(pending)
170
+ return true if pending.user_only == true
171
+ return false unless ParentApprovals.approval?(pending)
172
+ return false if pending.user_only == false && !pending.relay
173
+
174
+ ParentApprovals.protected?(pending)
175
+ end
176
+
177
+ # Why a parent may not give this answer, or nil.
178
+ # @param pending [PendingQuestion]
179
+ # @param indices [Array<Integer, nil>] the selected options' places
180
+ # @param strategy [Strategy]
181
+ # @return [Symbol, nil] :user_only, :permit_wide, :permit or :spend
182
+ def refusal(pending, indices, strategy:)
183
+ effects = effects_of(pending)
184
+ picked = Array(indices).map { |index| effect_at(effects, index) }
185
+ return nil if picked.empty?
186
+ return :user_only if user_only?(pending) && picked.any? { |effect| !ALWAYS.include?(effect) }
187
+
188
+ WIDEST_FIRST.each do |effect|
189
+ return effect.to_sym if picked.include?(effect) && !strategy.allow?(effect)
190
+ end
191
+ nil
192
+ end
193
+
194
+ # The refusal as a line for the parent: what to answer instead, or
195
+ # tell the user. Never how to allow it (the web, chi --attach): that
196
+ # is the user's.
197
+ # @param reason [Symbol] what #refusal returned
198
+ # @param typed [Boolean] the answer was typed at a ? prompt (the
199
+ # REPL), not given to chi answer
200
+ def message(reason, pending:, strategy:, typed: false)
201
+ deny = deny_label(pending)
202
+ case reason.to_sym
203
+ when :spend
204
+ if pending.kind.to_s == CONTINUE_KIND
205
+ return "a parent agent may only stop this turn here (#{strategy.label}): answer #{STOP} " \
206
+ "(--option #{STOP} --text WHY), or tell your user it waits"
207
+ end
208
+
209
+ "a parent agent may not pick #{tagged_labels(pending, SPEND)} here (#{strategy.label}): #{instead(deny, typed, "answer")}"
210
+ when :user_only
211
+ "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{instead(deny, typed)}"
212
+ when :permit_wide
213
+ # An approval whose scopes don't fit its options has no permit
214
+ # option: its once is still what a parent may give.
215
+ permits = tagged_labels(pending, PERMIT)
216
+ permits = Approval::ONCE if permits.empty? && ParentApprovals.approval?(pending)
217
+ if strategy.allow?(PERMIT) && !permits.empty?
218
+ "only #{permits} (#{strategy.label}) can be given here: #{instead(deny, typed)}"
219
+ else
220
+ "#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
221
+ end
222
+ else "#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
223
+ end
224
+ end
225
+
226
+ # Why a parent agent may not continue a turn with /continue yes (a
227
+ # command: chi send -m, a piped attach), or nil: the step-limit
228
+ # question's Continue, checked the same way.
229
+ # @return [String, nil] the refusal line
230
+ def continue_refusal(strategy: current)
231
+ reason = refusal(CONTINUE_QUESTION, [0], strategy: strategy)
232
+ reason && message(reason, pending: CONTINUE_QUESTION, strategy: strategy)
233
+ end
234
+
235
+ # The labels a parent may pick on +pending+ under +strategy+.
236
+ # @return [Array<String>]
237
+ def allowed_options(pending, strategy:)
238
+ user_only = user_only?(pending)
239
+ Array(pending.options).zip(effects_of(pending)).filter_map do |label, effect|
240
+ next nil if user_only && !ALWAYS.include?(effect)
241
+
242
+ label.to_s if strategy.allow?(effect)
243
+ end
244
+ end
245
+
246
+ # The question's deny-tagged option's label, or nil.
247
+ def deny_label(pending)
248
+ index = effects_of(pending).index(DENY)
249
+ index && Array(pending.options)[index].to_s
250
+ end
251
+
252
+ # What the parent does instead: deny with the question's own deny
253
+ # option and tell the user, or (none) tell the user it waits.
254
+ def instead(deny, typed, verb = "deny it")
255
+ return "tell your user it waits" unless deny
256
+
257
+ how = typed ? "n; WHY" : "--option #{deny} --text WHY"
258
+ verb == "answer" ? "answer #{deny} (#{how}), or tell your user it waits" : "#{verb} (#{how}), and tell your user"
259
+ end
260
+
261
+ def allowing(pending)
262
+ ParentApprovals.approval?(pending) ? "allowing a tool call" : "allowing it"
263
+ end
264
+
265
+ def tagged_labels(pending, effect)
266
+ Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == effect }.join(" or ")
267
+ end
268
+
269
+ # An index it can't place counts as the widest effect (fail closed).
270
+ def effect_at(effects, index)
271
+ return PERMIT_WIDE unless index.is_a?(Integer) && index >= 0 && index < effects.size
272
+
273
+ effects[index]
274
+ end
275
+
276
+ # A tag as given; an unknown one fails closed.
277
+ def tag(value)
278
+ return ANSWER if value.nil?
279
+
280
+ EFFECTS.include?(value.to_s) ? value.to_s : PERMIT_WIDE
281
+ end
282
+
283
+ # A plugin's or a hook's effects (label => effect, ask_user's
284
+ # effects:) as the options' tags, in option order; a label left out
285
+ # is answer (QuestionDesk#ask_for_hook, and the test kit's Recorder).
286
+ # @return [Array<String>, nil, false] the tags; nil for none given;
287
+ # false when invalid (not a Hash, a label the options don't have,
288
+ # an effect not in EFFECTS)
289
+ def option_tags(options, effects)
290
+ return nil if effects.nil?
291
+ return false unless effects.is_a?(Hash)
292
+
293
+ given = effects.to_h { |label, effect| [label.to_s, effect.to_s] }
294
+ return false unless (given.keys - Array(options).map(&:to_s)).empty?
295
+ return false unless (given.values - EFFECTS).empty?
296
+
297
+ Array(options).map { |label| given.fetch(label.to_s, ANSWER) }
298
+ end
299
+
300
+ # When the scopes fit the options: once permits, another scope
301
+ # permits wide, the options past them deny. When they don't (missing,
302
+ # malformed): only a last option labelled Deny denies.
303
+ def approval_effects(pending, options)
304
+ scopes = ParentApprovals.scopes(pending)
305
+ if scopes
306
+ return options.each_index.map do |index|
307
+ next DENY if index >= scopes.size
308
+
309
+ scopes[index] == "once" ? PERMIT : PERMIT_WIDE
310
+ end
311
+ end
312
+
313
+ options.each_with_index.map do |label, index|
314
+ index == options.size - 1 && label.to_s == Approval::DENY ? DENY : PERMIT_WIDE
315
+ end
316
+ end
317
+
318
+ def continue_effect(label)
319
+ case label.to_s
320
+ when CONTINUE then SPEND
321
+ when STOP then DENY
322
+ else ANSWER
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -16,10 +16,13 @@ module Samagotchi
16
16
  # command source for attached context (it runs later, ungated); chi
17
17
  # broadcast, which is the user's; the bundle's asks before a memory
18
18
  # that reaches the system prompt, or a model overlay, is written (a
19
- # parent model would be writing a model's prompt). Only the user may
20
- # allow them (ParentApprovals).
19
+ # parent model would be writing a model's prompt); and the bundle's
20
+ # asks before answering chi's questions around chi answer (a parent
21
+ # allowing one would answer a grandchild's question as the user).
22
+ # Only the user may allow them (ParentApprovals.protected?,
23
+ # ParentPolicy's user_only).
21
24
  PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
22
- model-overlay-write].freeze
25
+ model-overlay-write chi-answer-piped chi-answer-http].freeze
23
26
  SCOPES = %w[once session repo rule].freeze
24
27
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
25
28
 
@@ -36,7 +36,9 @@ module Samagotchi
36
36
  def runtime
37
37
  Hooks::Runtime.new(
38
38
  notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
39
- ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { ask_user(question, options, header, allow_freeform, hook) },
39
+ ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
40
+ ask_user(question, options, header, allow_freeform, hook, effects: effects)
41
+ },
40
42
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
41
43
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
42
44
  stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
@@ -86,8 +88,8 @@ module Samagotchi
86
88
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
87
89
  # answer, cancelled) is nil too.
88
90
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
89
- def ask_user(question, options, header, allow_freeform, hook)
90
- @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook)
91
+ def ask_user(question, options, header, allow_freeform, hook, effects: nil)
92
+ @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook, effects: effects)
91
93
  end
92
94
 
93
95
  # Cancel the running turn (reason :hook), after a notice that says why.
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../guardrails/load_failures"
6
7
  require_relative "../memory_bundle/bundle_hook"
7
8
  require_relative "../memory_bundle/provenance"
8
9
  require_relative "../memory_bundle/manifest"
@@ -90,7 +91,8 @@ module Samagotchi
90
91
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
91
92
 
92
93
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
93
- failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed)
94
+ failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed,
95
+ location: Guardrails::LoadFailures.location(e, File.dirname(hooks_dir)))
94
96
  end
95
97
  end
96
98
  loaded
@@ -111,8 +111,8 @@ module Samagotchi
111
111
  # callables, each given the hook's label. +notify+ takes
112
112
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
113
113
  # the line stands in for, HookRoutes#notify) and shows one line to the
114
- # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
115
- # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
114
+ # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
115
+ # effects: when the hook gave them) and returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
116
116
  # cancels the running turn (true when it did); +steer+ takes (text:,
117
117
  # hook:) and puts the text into the running turn (Engine#steer, true
118
118
  # when queued); +stop_generation+ takes (reason:, hook:) and cuts the
@@ -270,9 +270,11 @@ module Samagotchi
270
270
  # to the user; fallback_for: :display marks a line that stands in
271
271
  # for the answer's display (event[:present]), which a UI that
272
272
  # renders the display's links leaves out (HookRoutes#notify)
273
- # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
273
+ # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false, effects: nil)
274
274
  # -> {selected:, freeform:, selected_indices:} or nil (no one to
275
- # ask, cancelled, bad options)
275
+ # ask, cancelled, bad options or effects); effects: tags the
276
+ # options a parent agent may not simply pick, by label
277
+ # ({ "Run it" => :permit }, see Plugin::Context#ask_user)
276
278
  # event[:stop_turn].call(reason) -> true when the turn was cancelled;
277
279
  # in a before_tool_call event it also denies the call; from
278
280
  # after_turn / session_end it does nothing (false)
@@ -347,6 +349,10 @@ module Samagotchi
347
349
  end
348
350
  end
349
351
 
352
+ # The events bundle hooks are registered for (any symbol Api#on took).
353
+ # @return [Array<Symbol>]
354
+ def bundle_events = @mutex.synchronize { @bundle_hooks.keys }
355
+
350
356
  # @return [Integer] total number of registered hooks (bundle + plain)
351
357
  def size
352
358
  @mutex.synchronize do
@@ -383,9 +389,10 @@ module Samagotchi
383
389
  @runtime&.notify&.call(**notice)
384
390
  nil
385
391
  }
386
- event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
392
+ event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
387
393
  @runtime&.ask_user&.call(question: question, options: options, header: header,
388
- allow_freeform: allow_freeform, hook: event[:hook])
394
+ allow_freeform: allow_freeform, hook: event[:hook],
395
+ **(effects.nil? ? {} : { effects: effects }))
389
396
  }
390
397
  event[:stop_turn] ||= lambda { |reason|
391
398
  next false if TURN_OVER_EVENTS.include?(event[:type])
@@ -26,5 +26,10 @@ module Samagotchi
26
26
  # event also has context: and targets: (docs/guardrails.md)
27
27
  # :after_tool_call — after tool execution, before result injection
28
28
  module Hooks
29
+ # Every event a hook can be registered for, in a turn's order
30
+ # (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
31
+ # warns on one not here.
32
+ EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
33
+ after_tool_call after_turn session_end].freeze
29
34
  end
30
35
  end