samagotchi 0.35.0 → 0.37.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +2 -2
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +91 -12
  6. data/docs/configuration.md +8 -1
  7. data/docs/context.md +37 -0
  8. data/docs/guardrails.md +26 -3
  9. data/docs/hooks.md +1 -1
  10. data/docs/memory.md +3 -0
  11. data/docs/plugins.md +51 -4
  12. data/lib/samagotchi/broadcast/triage.rb +267 -0
  13. data/lib/samagotchi/broadcast/triage_log.rb +165 -0
  14. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  15. data/lib/samagotchi/broadcast_command.rb +273 -69
  16. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  17. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  18. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  27. data/lib/samagotchi/cli/command.rb +27 -3
  28. data/lib/samagotchi/config.rb +9 -0
  29. data/lib/samagotchi/context_command.rb +0 -17
  30. data/lib/samagotchi/engine.rb +4 -6
  31. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  32. data/lib/samagotchi/guardrails/approval.rb +5 -2
  33. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  35. data/lib/samagotchi/guardrails/rules.rb +19 -6
  36. data/lib/samagotchi/guardrails/targets.rb +83 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  41. data/lib/samagotchi/log_line.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/recap_setup.rb +38 -52
  45. data/lib/samagotchi/send_command.rb +0 -22
  46. data/lib/samagotchi/session_commands.rb +1 -0
  47. data/lib/samagotchi/tool_activity.rb +14 -3
  48. data/lib/samagotchi/tool_declarations.rb +8 -4
  49. data/lib/samagotchi/tools/builtins.rb +1 -1
  50. data/lib/samagotchi/tools/memory.rb +103 -2
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/public/question_card.js +4 -3
  53. metadata +5 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
3
4
  require_relative "config"
4
5
  require_relative "session"
5
6
  require_relative "session_inbox"
@@ -7,6 +8,9 @@ require_relative "note_delivery"
7
8
  require_relative "broadcast/recipients"
8
9
  require_relative "broadcast/scope_card"
9
10
  require_relative "broadcast/tags"
11
+ require_relative "broadcast/triage"
12
+ require_relative "broadcast/triage_model"
13
+ require_relative "broadcast/triage_log"
10
14
  require_relative "cli/command"
11
15
  require_relative "cli/flags"
12
16
 
@@ -14,8 +18,11 @@ module Samagotchi
14
18
  # `chi broadcast`: share a note with every session it may concern,
15
19
  # without picking them. Each recipient (Broadcast::Recipients) that shares
16
20
  # a tag with the note (Broadcast::Tags) gets it as a context note from
17
- # "broadcast"; --all gives it to every recipient. The rest are listed as
18
- # skipped, with why. For the user only: refused inside a chi session.
21
+ # "broadcast", and a triage model judges the rest (Broadcast::Triage);
22
+ # --all gives it to every recipient. The rest are listed as skipped, with
23
+ # why. Each broadcast's verdicts go to Broadcast::TriageLog (`chi
24
+ # broadcast log`), and `chi broadcast deliver` gives one to sessions it
25
+ # skipped. For the user only: refused inside a chi session.
19
26
  class BroadcastCommand
20
27
  include CLI::Command
21
28
 
@@ -31,6 +38,8 @@ module Samagotchi
31
38
 
32
39
  USAGE = <<~TEXT
33
40
  Usage: chi broadcast [-m TEXT] [--all] [--dry-run]
41
+ chi broadcast log [--last N] [--format json]
42
+ chi broadcast deliver BROADCAST_ID (ID|PREFIX)...
34
43
  Shares TEXT (or stdin) with the sessions it may concern, as a context
35
44
  note from "broadcast": background the model sees on its next turn,
36
45
  not a prompt. It starts no turn.
@@ -39,10 +48,20 @@ module Samagotchi
39
48
  the last broadcast.active_hours (8). One gets the note when it shares
40
49
  a tag with it: a ticket id (PAY-123) in its branch or your prompts
41
50
  there, a pull request (PR #42, its URL) attached to it, a link in
42
- your prompts there or its attached context. The rest are skipped.
51
+ your prompts there or its attached context. A triage model
52
+ (broadcast.triage_model, else the recap's, else default.model) reads
53
+ the note and each other session's scope card and says whether it
54
+ concerns it; one it can't judge in broadcast.triage_deadline (20 s)
55
+ gets it unchecked. A first line naming one project keeps the note to
56
+ that project's sessions.
43
57
  -m TEXT the note; without it, stdin is read
44
- --all every recipient, tag or not
58
+ --all every recipient, no tags or triage
45
59
  --dry-run show each recipient's scope card, tags and verdict; deliver nothing
60
+ It prints the broadcast's id (b-7f3a1c9e), then a line per recipient.
61
+ log: the last N broadcasts (5) and each recipient's verdict, from
62
+ the triage log; --format json for a script.
63
+ deliver: the broadcast to sessions it skipped, after all (kept in
64
+ the log as a correction).
46
65
  For you, not for an agent: refused inside a chi session.
47
66
  One session or a few by id: chi note. See docs/broadcast.md.
48
67
  TEXT
@@ -53,17 +72,33 @@ module Samagotchi
53
72
  f.value "-m", "--message", key: :text
54
73
  end
55
74
 
56
- # One recipient's verdict. +match+: the tag that delivers it (nil with
57
- # --all or none); +skip+: why it is skipped, else nil.
58
- Verdict = Data.define(:recipient, :match, :skip) do
59
- def deliver? = skip.nil?
75
+ LOG_FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS, args: false) do |f|
76
+ f.value "--last"
77
+ f.value "--format"
78
+ end
79
+
80
+ DELIVER_FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS)
81
+
82
+ # The broadcasts `chi broadcast log` shows by default.
83
+ LOG_LAST = 5
84
+
85
+ # One recipient and its Broadcast::Triage::Verdict.
86
+ Decision = Data.define(:recipient, :verdict) do
87
+ def deliver? = verdict.relevant
60
88
  end
61
89
 
62
90
  # @param argv [Array<String>] the arguments after "broadcast"
63
91
  # @param active_hours [Numeric, nil] broadcast.active_hours (config)
64
92
  # @param ticket_pattern [String, nil] broadcast.ticket_pattern (config)
93
+ # @param triage [#call, nil] (CancellationController) → a triage
94
+ # backend (#judge(note, card) → Verdict); nil: Triage::LLM on
95
+ # Broadcast::TriageModel's model
96
+ # @param log [Broadcast::TriageLog, nil] nil: the default path's
65
97
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, now: Time.now,
66
- active_hours: Config.get("broadcast.active_hours"), ticket_pattern: Config.get("broadcast.ticket_pattern"))
98
+ active_hours: Config.get("broadcast.active_hours"), ticket_pattern: Config.get("broadcast.ticket_pattern"),
99
+ triage: nil, triage_parallel: Config.get("broadcast.triage_parallel"),
100
+ triage_deadline: Config.get("broadcast.triage_deadline"), threshold: Config.get("broadcast.threshold"),
101
+ log: nil)
67
102
  @argv = argv.dup
68
103
  @stdin = stdin
69
104
  @stdout = stdout
@@ -73,18 +108,24 @@ module Samagotchi
73
108
  @now = now
74
109
  @active_hours = active_hours || 8
75
110
  @ticket = Broadcast::Tags.ticket_regexp(ticket_pattern, warn: ->(line) { @stderr.puts("chi broadcast: #{line}") })
111
+ @triage = triage
112
+ @triage_parallel = (triage_parallel || Broadcast::Triage::DEFAULT_PARALLEL).to_i
113
+ @triage_deadline = (triage_deadline || Broadcast::Triage::DEFAULT_DEADLINE).to_f
114
+ @threshold = (threshold || Broadcast::Triage::DEFAULT_THRESHOLD).to_f
115
+ @log = log || Broadcast::TriageLog.new
76
116
  end
77
117
 
78
118
  # @return [Integer] exit status: 0 done (skipped sessions included),
79
119
  # 1 refused, nothing to send to or a delivery failed, 2 usage
80
120
  def run
121
+ case @argv.first
122
+ when "log" then return run_log(@argv.drop(1))
123
+ when "deliver" then return run_deliver(@argv.drop(1))
124
+ end
125
+
81
126
  options = parse
82
127
  return options if options.is_a?(Integer)
83
-
84
- unless @env[PARENT_ENV].to_s.empty?
85
- error_line("chi broadcast: chi broadcast is for your user, not an agent")
86
- return 1
87
- end
128
+ return refused if agent?
88
129
 
89
130
  text = utf8(options[:text] || read_stdin)
90
131
  return usage_error("no note text: pass -m TEXT or pipe it in") unless text
@@ -113,6 +154,15 @@ module Samagotchi
113
154
  parsed.is_a?(Integer) ? parsed : parsed.options
114
155
  end
115
156
 
157
+ # Inside a chi session (any SAMAGOTCHI_PARENT_SESSION, "chi" included):
158
+ # an agent runs it.
159
+ def agent? = !@env[PARENT_ENV].to_s.empty?
160
+
161
+ def refused
162
+ error_line("chi broadcast: chi broadcast is for your user, not an agent")
163
+ 1
164
+ end
165
+
116
166
  # @return [Integer] exit status
117
167
  def broadcast(text, all:, dry_run:)
118
168
  recipients = Broadcast::Recipients.list(state_dir: @state_dir, active_hours: @active_hours, now: @now)
@@ -124,73 +174,245 @@ module Samagotchi
124
174
 
125
175
  note_tags = Broadcast::Tags.of_text(text, from: "note", ticket: @ticket)
126
176
  cards = recipients.to_h { |r| [r.id, Broadcast::ScopeCards.build(r, state_dir: @state_dir, ticket: @ticket)] }
127
- verdicts = recipients.map { |r| verdict(r, cards.fetch(r.id), note_tags, all: all) }
128
- verdicts = verdicts.select(&:deliver?) + verdicts.reject(&:deliver?)
129
-
130
- @stdout.puts("broadcast#{" (dry run: nothing is delivered)" if dry_run} #{headline(text)}")
177
+ id = "b-#{SecureRandom.hex(4)}" unless dry_run
178
+ @stdout.puts("broadcast #{id || "(dry run: nothing is delivered)"} #{headline(text)}")
131
179
  @stdout.puts("note tags: #{note_tags.empty? ? "none" : note_tags.map(&:label).join(" · ")}") if dry_run
132
- return dry_run(verdicts, cards) if dry_run
180
+ @stdout.flush
181
+ decisions = decide(text, recipients, cards, note_tags, all: all)
182
+ decisions = decisions.select(&:deliver?) + decisions.reject(&:deliver?)
183
+ @stdout.puts("triage model: #{@triage_choice.target&.label || "none"} (#{@triage_choice.setting})") if dry_run && @triage_choice
184
+ return dry_run(decisions, cards) if dry_run
185
+
186
+ deliver_all(Broadcast::TriageLog::Record.new(id: id, at: @now, text: text, note_tags: note_tags.map(&:label),
187
+ triage_model: @triage_choice&.target&.label, recipients: [],
188
+ corrections: []), decisions, cards)
189
+ end
190
+
191
+ # Every recipient's verdict: a chi REPL takes no notes, --all takes the
192
+ # rest, else Broadcast::Triage (tags, the scope line, the model).
193
+ # @return [Array<Decision>] in +recipients+' order
194
+ def decide(text, recipients, cards, note_tags, all:)
195
+ fixed = recipients.to_h do |r|
196
+ verdict = if r.repl?
197
+ Broadcast::Triage::Verdict.new(relevant: false, p: nil, reason: "open in a chi REPL", by: "repl")
198
+ elsif all
199
+ Broadcast::Triage::Verdict.new(relevant: true, p: nil, reason: "--all", by: "all")
200
+ end
201
+ [r.id, verdict]
202
+ end
203
+ judged = recipients.reject { |r| fixed[r.id] }.map { |r| cards.fetch(r.id) }
204
+ triaged = Broadcast::Triage.verdicts(text, judged, note_tags: note_tags, new_backend: method(:triage_backend),
205
+ parallel: @triage_parallel, deadline: @triage_deadline)
206
+ recipients.map { |r| Decision.new(recipient: r, verdict: fixed[r.id] || triaged.fetch(r.id)) }
207
+ end
208
+
209
+ # One triage backend for a thread (Triage.judge_all): the injected
210
+ # one, or Triage::LLM on the triage model, resolved once; with no
211
+ # model to ask, one that delivers unchecked and says why.
212
+ def triage_backend(cancel)
213
+ return @triage.call(cancel) if @triage
133
214
 
134
- deliver_all(verdicts, text)
215
+ choice = triage_choice
216
+ return UncheckedBackend.new("no triage model: #{choice.problem}") unless choice.target
217
+
218
+ Broadcast::Triage::LLM.new(target: choice.target, timeout: @triage_deadline, threshold: @threshold,
219
+ cancel_controller: cancel)
135
220
  end
136
221
 
137
- def verdict(recipient, card, note_tags, all:)
138
- return Verdict.new(recipient: recipient, match: nil, skip: "open in a chi REPL") if recipient.repl?
222
+ def triage_choice
223
+ (@triage_lock ||= Mutex.new).synchronize do
224
+ @triage_choice ||= Broadcast::TriageModel.resolve.tap do |choice|
225
+ error_line("chi broadcast: no triage model: #{choice.problem}; delivering unchecked") unless choice.target
226
+ end
227
+ end
228
+ end
139
229
 
140
- match = Broadcast::Tags.match(note_tags, card.tags)
141
- skip = "no tag match (not checked: no triage yet)" unless all || match
142
- Verdict.new(recipient: recipient, match: match, skip: skip)
230
+ # A triage backend with no model: every card it gets is delivered
231
+ # unchecked, +why+ in the reason.
232
+ UncheckedBackend = Data.define(:why) do
233
+ def judge(_note, _card) = Broadcast::Triage.unchecked(why)
143
234
  end
144
235
 
145
- def dry_run(verdicts, cards)
146
- verdicts.each do |v|
147
- @stdout.puts(line(v.recipient, v.deliver? ? "would get it" : "skipped", reason(v), width: 12))
148
- @stdout.puts(cards.fetch(v.recipient.id).to_s.gsub(/^/, " "))
236
+ def dry_run(decisions, cards)
237
+ decisions.each do |d|
238
+ @stdout.puts(line(d.recipient, d.deliver? ? "would get it" : "skipped", d.verdict.reason, width: 12))
239
+ @stdout.puts(cards.fetch(d.recipient.id).to_s.gsub(/^/, " "))
149
240
  end
150
- delivered = verdicts.count(&:deliver?)
151
- @stdout.puts("would deliver #{delivered} · skipped #{verdicts.size - delivered}")
241
+ delivered = decisions.count(&:deliver?)
242
+ @stdout.puts("would deliver #{delivered} · skipped #{decisions.size - delivered}#{unchecked_summary(decisions)}")
152
243
  0
153
244
  end
154
245
 
155
- def deliver_all(verdicts, text)
246
+ # Delivers +broadcast+ (a TriageLog::Record with no recipients yet) per
247
+ # +decisions+, prints a line per recipient and the summary, and logs it.
248
+ def deliver_all(broadcast, decisions, cards)
156
249
  ok = true
157
- lines = verdicts.map do |v|
158
- next [v.recipient, false, "skipped", v.skip] unless v.deliver?
250
+ lines = decisions.map do |d|
251
+ next [d, "skipped", d.verdict.reason] unless d.deliver?
159
252
 
160
- result = NoteDelivery.deliver(v.recipient.id, text: body(text, v.match), source: SOURCE, state_dir: @state_dir)
161
- next [v.recipient, false, "skipped", "open in a chi REPL"] unless result.delivered?
253
+ result = NoteDelivery.deliver(d.recipient.id, text: body(broadcast.text, d.verdict), source: SOURCE,
254
+ state_dir: @state_dir)
255
+ next [d, "skipped", "open in a chi REPL"] unless result.delivered?
162
256
 
163
- [v.recipient, true, "delivered", "#{reason(v)}#{"; waits for its next start" if result.status == :waits}"]
257
+ [d, "delivered", "#{d.verdict.reason}#{"; waits for its next start" if result.status == :waits}"]
164
258
  rescue SessionInbox::NoteRejected, SystemCallError => e
165
259
  ok = false
166
- [v.recipient, false, "failed", e.message]
260
+ [d, "failed", e.message]
167
261
  end
168
- lines.sort_by.with_index { |(_, delivered), i| [delivered ? 0 : 1, i] }.each do |recipient, _, word, why|
169
- @stdout.puts(line(recipient, word, why))
262
+ lines.sort_by.with_index { |(_, word), i| [word == "delivered" ? 0 : 1, i] }.each do |d, word, why|
263
+ @stdout.puts(line(d.recipient, word, why))
170
264
  end
171
- delivered = lines.count { |_, d| d }
172
- @stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}")
265
+ delivered = lines.count { |_, word| word == "delivered" }
266
+ @stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}#{unchecked_summary(decisions)}")
267
+ write_log(broadcast, lines, cards)
173
268
  ok ? 0 : 1
174
269
  end
175
270
 
271
+ # The broadcast's verdicts into the triage log; a log that can't be
272
+ # written is a warning, not a failed broadcast.
273
+ def write_log(broadcast, lines, cards)
274
+ entries = lines.map do |d, word, why|
275
+ verdict = d.verdict
276
+ Broadcast::TriageLog::Entry.new(session: d.recipient.id, result: word, p: verdict.p, reason: why, by: verdict.by,
277
+ tags: cards.fetch(d.recipient.id).tags.map(&:label))
278
+ end
279
+ @log.append_broadcast(id: broadcast.id, at: broadcast.at, text: broadcast.text, note_tags: broadcast.note_tags,
280
+ triage_model: broadcast.triage_model, recipients: entries)
281
+ rescue SystemCallError => e
282
+ error_line("chi broadcast: the triage log #{@log.path} couldn't be written: #{e.message}")
283
+ end
284
+
285
+ # `chi broadcast log`. @return [Integer] exit status
286
+ def run_log(argv)
287
+ parsed = parse_flags(LOG_FLAGS, argv)
288
+ return parsed if parsed.is_a?(Integer)
289
+ return refused if agent?
290
+
291
+ options = parsed.options
292
+ last = options[:last] ? Integer(options[:last], exception: false) : LOG_LAST
293
+ return usage_error("--last takes a positive number") unless last&.positive?
294
+ return usage_error("--format takes json") unless [nil, "json"].include?(options[:format])
295
+
296
+ records = @log.records.last(last)
297
+ if options[:format] == "json"
298
+ @stdout.puts(JSON.pretty_generate(records.map(&:to_json_hash)))
299
+ return 0
300
+ end
301
+ @stdout.puts("no broadcasts in the log yet (#{@log.path})") if records.empty?
302
+ records.each { |record| print_record(record) }
303
+ 0
304
+ end
305
+
306
+ def print_record(record)
307
+ @stdout.puts("#{record.id} #{record.at.localtime.strftime("%Y-%m-%d %H:%M")} #{headline(record.text)}")
308
+ record.recipients.each { |e| @stdout.puts(" #{e.session[0, 8]} #{e.result.ljust(9)} #{e.reason}") }
309
+ delivered = record.recipients.count { |e| e.result == "delivered" }
310
+ @stdout.puts(" delivered #{delivered} · skipped #{record.recipients.size - delivered}")
311
+ record.corrections.each do |c|
312
+ sessions = c.sessions.map { |d| "#{d.session[0, 8]}#{" (#{d.result})" unless d.result == "delivered"}" }
313
+ @stdout.puts(" #{c.at.localtime.strftime("%Y-%m-%d %H:%M")} delivered anyway: #{sessions.join(", ")}")
314
+ end
315
+ end
316
+
317
+ # `chi broadcast deliver BROADCAST_ID SESSION...`: the logged note to
318
+ # sessions it skipped. @return [Integer] exit status
319
+ def run_deliver(argv)
320
+ parsed = parse_flags(DELIVER_FLAGS, argv)
321
+ return parsed if parsed.is_a?(Integer)
322
+ return refused if agent?
323
+
324
+ given, *sessions = parsed.args
325
+ return usage_error("give a broadcast id and the sessions to deliver it to") if given.nil? || sessions.empty?
326
+
327
+ record = begin
328
+ @log.find(given)
329
+ rescue Broadcast::TriageLog::NotFound => e
330
+ error_line("chi broadcast: #{e.message}")
331
+ return 1
332
+ end
333
+ ids = sessions.map { |given| resolve_session(given) }
334
+ results = ids.compact.uniq.map { |id| deliver_anyway(record, id) }
335
+ unless results.empty?
336
+ begin
337
+ @log.append_correction(id: record.id, at: @now, sessions: results)
338
+ rescue SystemCallError => e
339
+ error_line("chi broadcast: the triage log #{@log.path} couldn't be written: #{e.message}")
340
+ end
341
+ end
342
+ # Done when every session named has the note now.
343
+ ids.all? && results.all? { |d| ["delivered", "had it"].include?(d.result) } ? 0 : 1
344
+ end
345
+
346
+ # The full id +given+ names (an id or the start of one), or nil after
347
+ # saying why there is none.
348
+ def resolve_session(given)
349
+ id = Session.resolve_id(given, state_dir: @state_dir)
350
+ Session.load(id, state_dir: @state_dir) # raises ArgumentError when there is no such session
351
+ id
352
+ rescue ArgumentError => e
353
+ error_line("chi broadcast: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
354
+ nil
355
+ rescue SystemCallError => e
356
+ error_line("chi broadcast: session #{given} can't be read: #{e.message}")
357
+ nil
358
+ end
359
+
360
+ # @param id [String] a full session id (#resolve_session)
361
+ # @return [Broadcast::TriageLog::Delivery]
362
+ def deliver_anyway(record, id)
363
+ if record.delivered?(id)
364
+ @stdout.puts(line_for(id, "skipped", "it got #{record.id} already"))
365
+ return Broadcast::TriageLog::Delivery.new(session: id, result: "had it")
366
+ end
367
+
368
+ result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir)
369
+ unless result.delivered?
370
+ @stdout.puts(line_for(id, "skipped", "open in a chi REPL"))
371
+ return Broadcast::TriageLog::Delivery.new(session: id, result: "refused")
372
+ end
373
+
374
+ @stdout.puts(line_for(id, "delivered", "by hand#{"; waits for its next start" if result.status == :waits}"))
375
+ Broadcast::TriageLog::Delivery.new(session: id, result: "delivered")
376
+ rescue SessionInbox::NoteRejected, SystemCallError => e
377
+ @stdout.puts(line_for(id, "failed", e.message))
378
+ Broadcast::TriageLog::Delivery.new(session: id, result: "failed")
379
+ end
380
+
381
+ # A logged broadcast passed on by hand: as #body words it, dated when
382
+ # it was shared.
383
+ def passed_on_body(record)
384
+ shared = "Shared by your user on #{record.at.localtime.strftime("%Y-%m-%d %H:%M")}"
385
+ "#{record.text}\n(#{shared} with the sessions it may concern; your user passed it on to this session.)"
386
+ end
387
+
388
+ def line_for(id, word, why) = "#{id[0, 8]} #{word.ljust(9)} #{why}"
389
+
390
+ # " · 2 unchecked: triage deadline" when some were delivered without a
391
+ # verdict (the desktop helper shows only this line), else "".
392
+ def unchecked_summary(decisions)
393
+ unchecked = decisions.map(&:verdict).select(&:unchecked?)
394
+ return "" if unchecked.empty?
395
+
396
+ whys = unchecked.map { |v| v.reason.delete_prefix("unchecked: ").sub(/\s*[("].*\z/m, "") }.uniq
397
+ " · #{unchecked.size} unchecked: #{whys.join(", ")}"
398
+ end
399
+
176
400
  # What a recipient gets: the user's text first (the terminal's "note
177
401
  # from broadcast: …" line shows its start), then a line saying when it
178
402
  # was shared (a session with no worker may read it days later; the
179
- # note's header has the time only) and why it reached this session. No
180
- # instruction: the system prompt says what a broadcast note asks.
181
- def body(text, match)
403
+ # note's header has the time only) and, for a tag match, why it reached
404
+ # this session. No instruction: the system prompt says what a broadcast
405
+ # note asks.
406
+ def body(text, verdict)
182
407
  shared = "Shared by your user on #{@now.localtime.strftime("%Y-%m-%d %H:%M")}"
183
- return "#{text}\n(#{shared} with every active session.)" unless match
408
+ return "#{text}\n(#{shared} with every active session.)" if verdict.by == "all"
409
+ return "#{text}\n(#{shared} with the sessions it may concern.)" unless verdict.match
184
410
 
185
- because = match.because
411
+ because = verdict.match.because
186
412
  because = "#{because[0, BECAUSE_CHARS - 1]}…" if because.length > BECAUSE_CHARS
187
413
  "#{text}\n(#{shared} with the sessions it may concern; it reached you because #{because}.)"
188
414
  end
189
415
 
190
- def reason(verdict)
191
- verdict.skip || verdict.match&.reason || "--all"
192
- end
193
-
194
416
  def line(recipient, word, why, width: 9) = "#{recipient.short_id} #{word.ljust(width)} #{why}"
195
417
 
196
418
  # The note's first line, quoted and cut, for the output's head.
@@ -206,23 +428,5 @@ module Samagotchi
206
428
  value = hours.to_f
207
429
  "#{value == value.round ? value.round : value} #{value == 1 ? "hour" : "hours"}"
208
430
  end
209
-
210
- # Only a pipe or a file is read, as chi note does: a terminal or a
211
- # socket a launcher passes down may never close.
212
- def read_stdin
213
- return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
214
-
215
- if @stdin.respond_to?(:stat)
216
- stat = @stdin.stat
217
- return nil unless stat.pipe? || stat.file?
218
- end
219
-
220
- @stdin.read
221
- end
222
-
223
- # The note as UTF-8 whatever the locale says (see NoteCommand#utf8).
224
- def utf8(text)
225
- text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
226
- end
227
431
  end
228
432
  end
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  name: coordinator
3
- version: 0.1.2
3
+ version: 0.2.0
4
4
  scope: system
5
- description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, ask before each merge), /coordinate <goal> and /children"
5
+ description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, ask before each merge, a handoff memory to resume from), /coordinate <goal>, /coordinate resume and /children"
6
6
  trust_level: reviewed
7
7
  files:
8
8
  skill_coordinator.md:
9
- sha256: sha256:b21067c3f152c8dfd1b1f70001c6654ad1911616543a36b625727fb13d4ce3f6
10
- description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, ask the user before each merge"
9
+ sha256: sha256:a24b91ae27737027125cc0d627bf31edbacdd1be842f4daf82ddf062c9160413
10
+ description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, ask the user before each merge; resume from a handoff_* memory"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:5173faeb6311984ad4f2a40b9052dcc577a90790fc5c5a8409b95d4f114e66a7
14
- requires_chi: ">= 0.34.0"
13
+ sha256: sha256:2b11a24f22398f4d00132c2342f7c02eacfc6cec0e517e8241a17241affc8ca7
14
+ # the handoff memory needs memory_write's description-only form and remove: (0.36.0)
15
+ requires_chi: ">= 0.36.0"
@@ -18,6 +18,13 @@
18
18
  # for the goal. A REPL session (--no-shared) takes no messages: the request
19
19
  # is shown for the user to send.
20
20
  #
21
+ # /coordinate resume picks up a coordinator handoff: the open handoff_*
22
+ # memories of this project (an index line whose description doesn't start
23
+ # with DONE, its file there). One: the model is asked to resume it (the
24
+ # skill's step 0); several: a card with a Resume action each
25
+ # (/coordinate resume <name>); none: says so. It reads the project's
26
+ # memories folder, nothing else.
27
+ #
21
28
  # A child is asked before it changes anything outside its worktree by chi
22
29
  # itself (the core child-boundary guardrail), in every mode: nothing to set up.
23
30
  class Plugin
@@ -27,7 +34,12 @@ class Plugin
27
34
  STOPPABLE = %w[running waiting].freeze
28
35
  TEXT_CHARS = 80
29
36
  USAGE = "usage: /children [all] | /children stop <id>"
30
- COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each"
37
+ COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each; " \
38
+ "/coordinate resume [name] — pick up an open handoff"
39
+ RESUME_CARD_ID = "coordinate-resume"
40
+ # A card takes 6 actions.
41
+ MAX_RESUMES = 6
42
+ HANDOFF_LINE = /^- \*\*(handoff_[^*\s]+)\*\*(.*)$/
31
43
 
32
44
  def initialize(_settings = {}); end
33
45
 
@@ -36,7 +48,8 @@ class Plugin
36
48
  anytime: true do |args, ctx|
37
49
  children_command(args.to_s.strip, ctx)
38
50
  end
39
- chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree",
51
+ chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree; " \
52
+ "resume: pick up an open handoff",
40
53
  anytime: true do |args, ctx|
41
54
  coordinate(args.to_s.strip, ctx)
42
55
  end
@@ -97,13 +110,68 @@ class Plugin
97
110
  def coordinate(goal, ctx)
98
111
  return COORDINATE_USAGE if goal.empty?
99
112
 
100
- request = "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}"
113
+ verb, rest = goal.split(/\s+/, 2)
114
+ # "resume" and at most a name; "resume the old work" is a goal.
115
+ return resume(rest.to_s.strip, ctx) if verb == "resume" && !rest.to_s.strip.match?(/\s/)
116
+
117
+ ask(ctx, "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}",
118
+ "asked chi to coordinate it; a running turn gets it at its next step")
119
+ end
120
+
121
+ # /coordinate resume [name]
122
+ def resume(name, ctx)
123
+ open = open_handoffs(ctx)
124
+ if name.empty?
125
+ return "no open coordinator handoff (handoff_* memory) in this project" if open.empty?
126
+ return resume_card(open, ctx) if open.size > 1
127
+
128
+ name = open.first[:name]
129
+ end
130
+ name = "handoff_#{name}" unless name.start_with?("handoff_")
131
+ unless open.any? { |handoff| handoff[:name] == name }
132
+ return "/coordinate resume: no open handoff #{name} in this project (#{open.empty? ? "none is open" : "open: #{open.map { |h| h[:name] }.join(", ")}"})"
133
+ end
134
+
135
+ ask(ctx, "Read the skill_coordinator memory and resume the coordinator handoff #{name}: follow the skill's " \
136
+ "Resume (step 0) before anything else.",
137
+ "asked chi to resume #{name}; a running turn gets it at its next step")
138
+ end
139
+
140
+ def resume_card(open, ctx)
141
+ body = open.map { |handoff| "- #{handoff[:name]}#{" — #{handoff[:description]}" unless handoff[:description].empty?}" }
142
+ actions = open.first(MAX_RESUMES).map do |handoff|
143
+ { label: "Resume #{handoff[:name].delete_prefix("handoff_")}", command: "/coordinate resume #{handoff[:name]}" }
144
+ end
145
+ ctx.card(id: RESUME_CARD_ID, title: "open handoffs (#{open.size})", body: body.join("\n"), actions: actions)
146
+ nil
147
+ end
148
+
149
+ # The project's handoff_* memories whose index line isn't DONE, in index
150
+ # order: [{name:, description:}]. A line without its file is left out.
151
+ def open_handoffs(ctx)
152
+ dir = Samagotchi::MemoryPaths.scope_dir("project", cwd: ctx.cwd)
153
+ index = File.join(dir.to_s, "index.md")
154
+ return [] unless File.file?(index)
155
+
156
+ File.read(index, encoding: "UTF-8").each_line.filter_map do |line|
157
+ match = line.chomp.match(HANDOFF_LINE) or next
158
+ description = match[2].split(" — ", 2)[1].to_s.strip
159
+ next if description.match?(/\ADONE\b/i)
160
+ next unless File.file?(File.join(dir, "#{match[1]}.md"))
161
+
162
+ { name: match[1], description: description }
163
+ end
164
+ rescue SystemCallError
165
+ []
166
+ end
167
+
168
+ def ask(ctx, request, done)
101
169
  begin
102
170
  ctx.sessions.send(ctx.session_id, request)
103
171
  rescue Samagotchi::Plugin::Sessions::Error => e
104
172
  return "/coordinate: #{e.message}. Send this yourself:\n\n#{request}"
105
173
  end
106
- "asked chi to coordinate it; a running turn gets it at its next step"
174
+ done
107
175
  end
108
176
 
109
177
  def quote(text)