samagotchi 0.12.0 → 0.14.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +20 -7
  4. data/bin/chi +9 -9
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +79 -15
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +74 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +31 -16
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge/card_store.rb +94 -6
  17. data/lib/samagotchi/bridge.rb +14 -2
  18. data/lib/samagotchi/bridge_client.rb +8 -3
  19. data/lib/samagotchi/bundle_command.rb +18 -10
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  22. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  23. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/exit.rb +23 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  31. data/lib/samagotchi/config.rb +81 -24
  32. data/lib/samagotchi/context_window.rb +30 -5
  33. data/lib/samagotchi/desktop_command.rb +5 -3
  34. data/lib/samagotchi/engine.rb +54 -17
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  37. data/lib/samagotchi/host_registry.rb +31 -7
  38. data/lib/samagotchi/kernel_loop.rb +1 -1
  39. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  40. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  41. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  42. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  43. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  44. data/lib/samagotchi/memory_paths.rb +8 -0
  45. data/lib/samagotchi/model_profile.rb +30 -16
  46. data/lib/samagotchi/parent_report.rb +213 -0
  47. data/lib/samagotchi/plugin/api.rb +15 -2
  48. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  49. data/lib/samagotchi/question_desk.rb +29 -2
  50. data/lib/samagotchi/recap_setup.rb +15 -5
  51. data/lib/samagotchi/reply_wait.rb +5 -1
  52. data/lib/samagotchi/send_command.rb +50 -75
  53. data/lib/samagotchi/session.rb +15 -0
  54. data/lib/samagotchi/session_manager.rb +40 -7
  55. data/lib/samagotchi/sessions_command.rb +13 -3
  56. data/lib/samagotchi/system_prompt.rb +11 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
  59. data/lib/samagotchi/terminal_ui.rb +30 -3
  60. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  61. data/lib/samagotchi/tool_call_parser.rb +5 -6
  62. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  63. data/lib/samagotchi/tools/builtins.rb +14 -2
  64. data/lib/samagotchi/tools/delegate_wait.rb +32 -13
  65. data/lib/samagotchi/tools/execute.rb +8 -8
  66. data/lib/samagotchi/tools/memory.rb +3 -3
  67. data/lib/samagotchi/tools/task_create.rb +3 -2
  68. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  69. data/lib/samagotchi/utf8_default.rb +29 -0
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +22 -10
  72. data/lib/samagotchi/web/message_parts.rb +2 -1
  73. data/lib/samagotchi/web/public/activity.js +6 -1
  74. data/lib/samagotchi/web/public/app.js +39 -6
  75. data/lib/samagotchi/web/public/index.html +8 -1
  76. data/lib/samagotchi/web/public/notify.js +16 -3
  77. data/lib/samagotchi/web/public/question_cards.js +3 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  79. data/lib/samagotchi/web/public/turn_view.js +6 -9
  80. data/lib/samagotchi/web/server.rb +2 -1
  81. data/lib/samagotchi/web/session_summary.rb +3 -11
  82. metadata +8 -1
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+ require_relative "bridge_client"
6
+ require_relative "reply_wait"
7
+ require_relative "parent_report"
8
+ require_relative "guardrails/parent_approvals"
9
+ require_relative "cli/command"
10
+ require_relative "cli/flags"
11
+ require_relative "cli/parent_wait"
12
+
13
+ module Samagotchi
14
+ # `chi answer`: answer the question a session's worker waits on (exit 3
15
+ # from chi send --wait), then wait for what comes next and print it the
16
+ # same way: the reply (0), the next question (3), still running (4). It is
17
+ # how a parent agent running chi as a sub-agent answers; the web and
18
+ # chi --attach answer the same question. See docs/sub-agent.md.
19
+ #
20
+ # An approval is the user's to allow: a parent may deny it, and allow it
21
+ # only "once" when guardrails.parent_approvals (config.yml) says so. It is
22
+ # checked here and again by the worker, which sees the answer marked as
23
+ # chi answer's. That guards an honest but eager parent, not a boundary:
24
+ # the Bridge and the localhost web take answers from any local process.
25
+ class AnswerCommand
26
+ include CLI::Command
27
+ include CLI::ParentWait
28
+
29
+ USAGE = <<~TEXT
30
+ Usage: chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
31
+ chi answer ID --question QID --dismiss [--timeout S] [--format json]
32
+ Answers the question session ID waits on (chi send --wait exits 3
33
+ with it), then waits for what comes next and prints it as chi send
34
+ --wait does.
35
+ --question QID the question's id (chi send --wait prints it); a
36
+ question no longer open is not answered
37
+ --option N|LABEL
38
+ an option, by number (1-based) or label; repeat
39
+ on a multi-select question
40
+ --text T free text, where the question allows it; with an
41
+ approval, the reason for a Deny (text alone denies)
42
+ --dismiss leave it unanswered: chi does nothing it asked
43
+ about and finishes its reply
44
+ --timeout S give up waiting after S seconds (exit 4)
45
+ --format json one JSON object on stdout (see chi send --help)
46
+ An approval (a guardrail's ask) can be denied here: deny it, and
47
+ tell your user; allowing it is the user's. With
48
+ guardrails.parent_approvals: once, "Allow once" is let through.
49
+ Exit: 0 answered, 1 failed, refused or the worker is gone, 2
50
+ usage or an option the question doesn't offer, 3 a question waits,
51
+ 4 still running (--timeout), 130 Ctrl-C.
52
+ TEXT
53
+
54
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
55
+ f.value "--question"
56
+ f.value "--option", key: :options, repeat: true
57
+ f.value "--text"
58
+ f.switch "--dismiss"
59
+ f.value "--timeout"
60
+ f.value "--format"
61
+ end
62
+
63
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
64
+ @argv = argv.dup
65
+ @stdout = stdout
66
+ @stderr = stderr
67
+ @state_dir = state_dir || Session.default_state_dir
68
+ end
69
+
70
+ private
71
+
72
+ def command_name = "chi answer"
73
+
74
+ def parse
75
+ parsed = parse_flags(FLAGS, @argv, options: [])
76
+ return parsed if parsed.is_a?(Integer)
77
+
78
+ options = parsed.options
79
+ return usage_error("give one session id") unless parsed.args.size == 1
80
+ return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
81
+ if options[:dismiss]
82
+ return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
83
+ elsif options[:options].empty? && options[:text].nil?
84
+ return usage_error("nothing to answer with: --option, --text or --dismiss")
85
+ end
86
+ return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
87
+ if options[:timeout]
88
+ options[:timeout] = Float(options[:timeout], exception: false)
89
+ return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
90
+ end
91
+ options.merge(id: parsed.args.first)
92
+ end
93
+
94
+ # Answer, then wait for what comes next.
95
+ # @return [Integer] the exit status
96
+ def run_parsed(options)
97
+ id = resolve(options[:id]) or return 1
98
+ qid = options[:question].strip
99
+ session = Session.load(id, state_dir: @state_dir)
100
+ pending = session.pending_question
101
+ # Taken before the answer goes in, with the question answered as the
102
+ # one already reported: the file keeps it until the turn thread
103
+ # wakes, and it must not come back as exit 3.
104
+ cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
105
+ baseline = ReplyWait.baseline_of(session, question_id: qid)
106
+
107
+ if pending && pending[:id].to_s == qid
108
+ selected = selection(pending, options) or return @selection_status
109
+ refusal = approval_refusal(pending, selected, id)
110
+ return failure(refusal) if refusal
111
+
112
+ posted = post(id, qid, selected, options)
113
+ return posted if posted.is_a?(Integer)
114
+ elsif session.status != Session::STATUS_RUNNING
115
+ return failure("no question #{qid} waits in #{id[0, 8]}; nothing to answer")
116
+ else
117
+ not_open(qid)
118
+ end
119
+ # Answered here, or no longer open (answered elsewhere, or a newer
120
+ # question): what comes next either way.
121
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
122
+ end
123
+
124
+ # The labels to send. A number is an option's place (1-based); an
125
+ # exact label wins over a number.
126
+ # @return [Array<String>, nil] nil after a usage error
127
+ def selection(pending, options)
128
+ offered = Array(pending[:options]).map(&:to_s)
129
+ labels = options[:options].map do |given|
130
+ next given if offered.include?(given)
131
+
132
+ index = Integer(given, exception: false)
133
+ next offered[index - 1] if index&.between?(1, offered.size)
134
+
135
+ listed = offered.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join(", ")
136
+ @selection_status = usage_failure("no option #{given}; the options: #{listed}")
137
+ return nil
138
+ end
139
+ if labels.uniq.size > 1 && !pending[:multi_select]
140
+ @selection_status = usage_failure("one option only: the question is single-select")
141
+ return nil
142
+ end
143
+ labels.uniq
144
+ end
145
+
146
+ # Why a parent may not give this answer to an approval, or nil
147
+ # (Guardrails::ParentApprovals, with this process's config.yml). A
148
+ # Deny, text alone or a dismissal always passes: each one denies. The
149
+ # worker checks it again with its own config.
150
+ def approval_refusal(pending, selected, id)
151
+ offered = Array(pending[:options]).map(&:to_s)
152
+ indices = selected.map { |label| offered.index(label) }
153
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
154
+ reason && Guardrails::ParentApprovals.message(reason)
155
+ end
156
+
157
+ # Post the answer (or the dismissal) to the live worker's Bridge.
158
+ # @return [Integer, nil] an exit status when it didn't go in; nil to
159
+ # wait (it went in, or the question was no longer open: 409)
160
+ def post(id, qid, selected, options)
161
+ client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
162
+ return failure(worker_gone(id)) unless client
163
+
164
+ reply = nil
165
+ 2.times do
166
+ reply = if options[:dismiss]
167
+ client.dismiss_question(id: qid)
168
+ else
169
+ client.answer(id: qid, selected: selected, freeform: options[:text],
170
+ client_id: Guardrails::ParentApprovals::CLIENT_ID)
171
+ end
172
+ break unless reply.status == 408
173
+ end
174
+
175
+ case reply.status
176
+ when 200 then nil
177
+ when 409 then not_open(qid)
178
+ when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
179
+ # The worker's own guardrails.parent_approvals refused the allow.
180
+ when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
181
+ when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
182
+ when 404
183
+ return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
184
+
185
+ cant = options[:dismiss] ? "dismiss a question" : "take an answer"
186
+ failure(BridgeClient.stale_worker_message(id, cant: cant))
187
+ else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
188
+ end
189
+ rescue Errno::ETIMEDOUT
190
+ failure("the worker did not take the answer in time; the question is still open: try again")
191
+ rescue SystemCallError, IOError, SocketError
192
+ failure(worker_gone(id))
193
+ end
194
+
195
+ # The question was answered elsewhere (the web first), or another one
196
+ # waits now: said, then what comes next is waited for.
197
+ # @return [nil]
198
+ def not_open(qid)
199
+ error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
200
+ nil
201
+ end
202
+
203
+ def worker_gone(id)
204
+ "the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
205
+ end
206
+
207
+ # @return [Integer] 1, after the line
208
+ def failure(line)
209
+ error_line("#{command_name}: #{line}")
210
+ 1
211
+ end
212
+
213
+ # The Bridge refused the answer itself (an option not offered, two on a
214
+ # single-select question): a usage error, the question still open.
215
+ def usage_failure(line)
216
+ error_line("#{command_name}: #{line}")
217
+ CLI::Exit::USAGE
218
+ end
219
+ end
220
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../atomic_file"
3
6
  require_relative "../events"
4
7
 
5
8
  module Samagotchi
@@ -20,32 +23,70 @@ module Samagotchi
20
23
  # A card with an earlier card's id replaces it where it was. Each entry
21
24
  # says where it belongs as the recap does: +turns_since+, the turns
22
25
  # completed after it (after its own turn for a card shown during one),
23
- # and +current+ for a card of the turn running now. Turns count from
24
- # this worker's start; the ones before it are in no entry. A card that
26
+ # and +current+ for a card of the turn running now. A card that
25
27
  # isn't the turn's but comes while one runs (an anytime command's, such
26
28
  # as /btw) goes after that turn: its prompt is already in the history.
27
29
  # A failed turn leaves no prompt, so its cards stay before the next one.
30
+ #
31
+ # Notices (hook notices, load warnings, "asking again" rows) and cards
32
+ # (with questions) are capped apart, +capacity+ each, so a burst of
33
+ # notices can't push out a card. An open question (an approval too) is
34
+ # never pushed out: the oldest other card or answered question goes.
35
+ #
36
+ # With a +path+ (the Bridge's: FILE in the session's folder) the store
37
+ # saves its entries and turn count there on each change, and a later
38
+ # worker's store starts from them, so a turn's cards and notices outlive
39
+ # the worker that showed them; the web reads the file (.saved) for a
40
+ # session no worker runs. Turns count on from the saved count: the
41
+ # placement is relative (turns since), so only the turns after an entry
42
+ # matter. The load warnings stay the worker's own (each start announces
43
+ # its own), and a question still open when the worker went waits for no
44
+ # one: neither is saved or seeded. Same caps as in memory. An entry from
45
+ # an earlier worker lists with +earlier+: true (an attached TUI's resync
46
+ # onto a new worker doesn't print those again).
28
47
  class CardStore
29
48
  CAPACITY = 20
49
+ NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
50
+ FILE = "cards.json"
51
+ # The events after which the file is written again: the ones that
52
+ # change an entry or the turn count.
53
+ SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
54
+ turn_failed] + Events::TURN_KEPT).freeze
55
+
56
+ # What a session's saved file lists, as a store started from it lists
57
+ # (no turn running): the web's cards for a session no worker runs.
58
+ # @param session_dir [String]
59
+ # @return [Array<Hash>] [] without a readable file
60
+ def self.saved(session_dir)
61
+ new(path: File.join(session_dir, FILE)).list
62
+ end
30
63
 
31
- def initialize(capacity: CAPACITY)
64
+ # @param path [String, nil] the file the entries are saved in and
65
+ # started from; nil keeps them in memory only
66
+ def initialize(capacity: CAPACITY, path: nil)
32
67
  @capacity = capacity
68
+ @path = path
33
69
  @mutex = Mutex.new
34
70
  @entries = []
35
71
  @turns_done = 0
36
72
  @running = false
37
73
  @iteration = nil
38
74
  @calls = 0
75
+ seed
39
76
  end
40
77
 
41
78
  def call(event)
42
- @mutex.synchronize { fold(event) }
79
+ @mutex.synchronize do
80
+ fold(event)
81
+ save if SAVED_ON.include?(event[:type])
82
+ end
43
83
  rescue StandardError
44
84
  nil # never break the running turn
45
85
  end
46
86
 
47
87
  # @return [Array<Hash>] oldest first: the card's (or notice's) fields
48
- # plus turns_since: and current:, and during: true for a card that
88
+ # plus turns_since: and current:, earlier: true for one an earlier
89
+ # worker saved, and during: true for a card that
49
90
  # came while the running turn runs but isn't that turn's (it goes
50
91
  # after the running turn's prompt)
51
92
  def list
@@ -63,6 +104,42 @@ module Samagotchi
63
104
 
64
105
  private
65
106
 
107
+ def save
108
+ return unless @path
109
+
110
+ entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
111
+ # A session that never showed one gets no file (the turn count
112
+ # only places entries relative to each other).
113
+ return if entries.empty? && !File.exist?(@path)
114
+
115
+ FileUtils.mkdir_p(File.dirname(@path))
116
+ AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
117
+ rescue StandardError
118
+ nil # a card that can't be saved still shows live
119
+ end
120
+
121
+ # The entries an earlier worker saved (a missing or broken file:
122
+ # none), their types as symbols again; a card that came during that
123
+ # worker's last turn stays where it was, as after a failed turn.
124
+ def seed
125
+ return unless @path && File.file?(@path)
126
+
127
+ data = JSON.parse(File.read(@path), symbolize_names: true)
128
+ return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
129
+
130
+ @turns_done = data[:turns]
131
+ @entries = data[:entries].filter_map do |entry|
132
+ next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
133
+
134
+ entry = entry.merge(type: entry[:type].to_sym, earlier: true)
135
+ entry unless entry[:type] == :guardrail_warning || open_question?(entry)
136
+ end
137
+ settle_during(after_turn: false)
138
+ rescue StandardError
139
+ @turns_done = 0
140
+ @entries = []
141
+ end
142
+
66
143
  def fold(event)
67
144
  case event[:type]
68
145
  when :turn_started
@@ -144,7 +221,18 @@ module Samagotchi
144
221
 
145
222
  def push(entry)
146
223
  @entries << entry
147
- @entries.shift while @entries.size > @capacity
224
+ notice = notice?(entry)
225
+ kind = @entries.select { |e| notice?(e) == notice }
226
+ return if kind.size <= @capacity
227
+
228
+ evict = kind.find { |e| !open_question?(e) }
229
+ @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
230
+ end
231
+
232
+ def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
233
+
234
+ def open_question?(entry)
235
+ entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
148
236
  end
149
237
  end
150
238
  end
@@ -18,6 +18,7 @@ require_relative "bridge/sse_writer"
18
18
  require_relative "bridge/turn_accumulator"
19
19
  require_relative "session"
20
20
  require_relative "engine"
21
+ require_relative "guardrails/parent_approvals"
21
22
  require_relative "session_commands"
22
23
  require_relative "image_store"
23
24
  require_relative "log"
@@ -94,7 +95,9 @@ module Samagotchi
94
95
  @port = port
95
96
  @ring = RingBuffer.new(capacity: ring_capacity)
96
97
  @accumulator = TurnAccumulator.new
97
- @cards = CardStore.new
98
+ # Saved in the session's folder: a later worker, and the web without
99
+ # one, show the cards where they were.
100
+ @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
98
101
  @pending_card = PendingCard.new(session_dir)
99
102
  @heartbeat_interval = heartbeat_interval
100
103
  @epoch = SecureRandom.hex(4)
@@ -496,6 +499,10 @@ module Samagotchi
496
499
  qid = fetched(parsed, "id")
497
500
  selected = fetched(parsed, "selected")
498
501
  freeform = fetched(parsed, "freeform")
502
+ # chi answer's marker: a parent agent, held to this worker's
503
+ # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
504
+ # any local process may post here, with or without it.
505
+ client_id = fetched(parsed, "client_id")
499
506
  if qid.to_s.strip.empty?
500
507
  return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
501
508
  end
@@ -504,11 +511,15 @@ module Samagotchi
504
511
  return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
505
512
 
506
513
  begin
507
- result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
514
+ result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
508
515
  [{}, 200, { status: "answered", session_id: session_id, answer: result }]
509
516
  rescue Engine::QuestionNotPending => e
510
517
  # Another client answered first, or the question was cancelled.
511
518
  [{}, 409, { error: "question_not_pending", detail: e.message }]
519
+ rescue QuestionDesk::Refused => e
520
+ Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
521
+ [{}, 403, { error: "parent_approval_refused",
522
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
512
523
  rescue ArgumentError => e
513
524
  [{}, 400, { error: "invalid_answer", detail: e.message }]
514
525
  end
@@ -888,6 +899,7 @@ module Samagotchi
888
899
  202 => "Accepted",
889
900
  204 => "No Content",
890
901
  400 => "Bad Request",
902
+ 403 => "Forbidden",
891
903
  404 => "Not Found",
892
904
  409 => "Conflict",
893
905
  500 => "Internal Server Error",
@@ -108,10 +108,15 @@ module Samagotchi
108
108
 
109
109
  # POST /session/:id/answer. 200 = recorded, 409 = another client answered
110
110
  # first (or the question is gone), 400 = invalid selection, 408
111
- # deadline_passed = read too late and dropped (see DEADLINE_SHARE).
111
+ # deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
112
+ # parent_approval_refused = a parent's allow (client_id cli:answer)
113
+ # beyond the worker's guardrails.parent_approvals.
114
+ # @param client_id [String, nil] who answers (chi answer marks its own)
112
115
  # @return [Response]
113
- def answer(id:, selected:, freeform: nil)
114
- post("answer", { id: id, selected: selected, freeform: freeform, deadline: deadline }, read_body: true)
116
+ def answer(id:, selected:, freeform: nil, client_id: nil)
117
+ body = { id: id, selected: selected, freeform: freeform, deadline: deadline }
118
+ body[:client_id] = client_id if client_id
119
+ post("answer", body, read_body: true)
115
120
  end
116
121
 
117
122
  # POST /session/:id/question/dismiss: leave the question unanswered.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cli/flags"
4
+ require_relative "cli/exit"
4
5
  require_relative "memory_bundle"
5
6
 
6
7
  module Samagotchi
@@ -109,7 +110,7 @@ module Samagotchi
109
110
  when "build" then build(@argv[1..])
110
111
  else
111
112
  @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
112
- return 2
113
+ return CLI::Exit::USAGE
113
114
  end
114
115
  end
115
116
 
@@ -124,7 +125,7 @@ module Samagotchi
124
125
  force = opts.fetch(:force, false)
125
126
  if source.nil? || source.empty?
126
127
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
127
- return 2
128
+ return CLI::Exit::USAGE
128
129
  end
129
130
  expanded_source = expand_source(source)
130
131
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -148,7 +149,7 @@ module Samagotchi
148
149
  agent = opts[:agent]
149
150
  if source.nil? || source.empty?
150
151
  @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
151
- return 2
152
+ return CLI::Exit::USAGE
152
153
  end
153
154
  expanded_source = expand_source(source)
154
155
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -217,7 +218,11 @@ module Samagotchi
217
218
 
218
219
  # An upgrade that kept edited files: launch an agent to merge them
219
220
  # (--agent, or yes on a terminal), or say how to take the bundle's.
220
- # @return [Integer] 0 resolved, 2 kept the edits
221
+ # An upgrade that kept edited files exits 2, as a usage error does
222
+ # (scripts already read it so): not CLI::Exit::USAGE by meaning.
223
+ CONFLICTS_KEPT = 2
224
+
225
+ # @return [Integer] 0 resolved, CONFLICTS_KEPT kept the edits
221
226
  def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
222
227
  @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
223
228
  installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
@@ -227,13 +232,13 @@ module Samagotchi
227
232
  ans && ans.strip.downcase.start_with?("y")
228
233
  elsif agent.nil?
229
234
  @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
230
- return 2
235
+ return CONFLICTS_KEPT
231
236
  else
232
237
  agent
233
238
  end
234
239
  unless launch
235
240
  @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
236
- return 2
241
+ return CONFLICTS_KEPT
237
242
  end
238
243
 
239
244
  prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
@@ -258,7 +263,7 @@ module Samagotchi
258
263
  force = opts.fetch(:force, false)
259
264
  if bundle_name.nil? || bundle_name.empty?
260
265
  @stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
261
- return 2
266
+ return CLI::Exit::USAGE
262
267
  end
263
268
  data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
264
269
  return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
@@ -355,6 +360,8 @@ module Samagotchi
355
360
  exists = File.exist?(hook_path) ? "ok" : "missing"
356
361
  @stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
357
362
  end
363
+ @stdout.puts " requires_chi: #{st[:provenance][:requires_chi]}" if st[:hooks_requires_failure]
364
+ @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
358
365
  end
359
366
  if (plugin = st[:plugin])
360
367
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
@@ -379,6 +386,7 @@ module Samagotchi
379
386
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
380
387
  plugin = st[:plugin]
381
388
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
389
+ mods += 1 if st[:hooks_requires_failure]
382
390
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
383
391
  includes = st[:provenance][:includes]
384
392
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
@@ -393,7 +401,7 @@ module Samagotchi
393
401
  bname = args[0]
394
402
  file_arg = args[1]
395
403
  if bname.nil? || bname.empty?
396
- @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 2
404
+ @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return CLI::Exit::USAGE
397
405
  end
398
406
  prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
399
407
  data = prov.read
@@ -534,7 +542,7 @@ module Samagotchi
534
542
  # Validate scope if given
535
543
  if scope && !%w[system project].include?(scope.to_s.strip.downcase)
536
544
  @stderr.puts "Invalid scope '#{scope}', expected system or project"
537
- return 2
545
+ return CLI::Exit::USAGE
538
546
  end
539
547
  begin
540
548
  builder = Samagotchi::MemoryBundle::Builder.new(
@@ -573,7 +581,7 @@ module Samagotchi
573
581
  end
574
582
  if parsed.error
575
583
  @stderr.puts "Unknown bundle #{sub} flag: #{parsed.error.arg}"
576
- return 2
584
+ return CLI::Exit::USAGE
577
585
  end
578
586
  parsed
579
587
  end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.1.3
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:c5aa4c49989723ec2370184cee36f4c8515d9df818b2c45422a5efb6c4ba31a7
9
+ sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
10
10
  requires_chi: ">= 0.3.0"