samagotchi 0.34.0 → 0.36.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 +77 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +37 -0
  9. data/docs/guardrails.md +14 -4
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +3 -0
  12. data/docs/plugins.md +29 -3
  13. data/docs/sessions.md +6 -6
  14. data/lib/samagotchi/bridge/card_store.rb +1 -1
  15. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  16. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  17. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  18. data/lib/samagotchi/broadcast/tags.rb +154 -0
  19. data/lib/samagotchi/broadcast_command.rb +228 -0
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  34. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  35. data/lib/samagotchi/children_status.rb +2 -16
  36. data/lib/samagotchi/config.rb +6 -0
  37. data/lib/samagotchi/engine.rb +29 -5
  38. data/lib/samagotchi/git_head.rb +24 -0
  39. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  40. data/lib/samagotchi/guardrails/rules.rb +15 -4
  41. data/lib/samagotchi/guardrails/targets.rb +9 -2
  42. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  43. data/lib/samagotchi/hooks/registry.rb +11 -5
  44. data/lib/samagotchi/log_subscriber.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  46. data/lib/samagotchi/note_command.rb +13 -13
  47. data/lib/samagotchi/note_delivery.rb +42 -0
  48. data/lib/samagotchi/plugin/context.rb +4 -4
  49. data/lib/samagotchi/session_commands.rb +1 -0
  50. data/lib/samagotchi/session_manager.rb +4 -3
  51. data/lib/samagotchi/sessions_command.rb +3 -2
  52. data/lib/samagotchi/system_prompt.rb +1 -0
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  54. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  55. data/lib/samagotchi/terminal_ui.rb +14 -0
  56. data/lib/samagotchi/tool_activity.rb +14 -3
  57. data/lib/samagotchi/tool_declarations.rb +8 -4
  58. data/lib/samagotchi/tools/builtins.rb +1 -1
  59. data/lib/samagotchi/tools/memory.rb +103 -2
  60. data/lib/samagotchi/tools/send_note.rb +5 -11
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +3 -0
  63. data/lib/samagotchi/web/capabilities.rb +19 -0
  64. data/lib/samagotchi/web/public/activity.js +26 -6
  65. data/lib/samagotchi/web/public/app.js +515 -288
  66. data/lib/samagotchi/web/public/card.js +12 -3
  67. data/lib/samagotchi/web/public/command_view.js +2 -3
  68. data/lib/samagotchi/web/public/families_view.js +171 -0
  69. data/lib/samagotchi/web/public/format.js +52 -0
  70. data/lib/samagotchi/web/public/index.html +94 -21
  71. data/lib/samagotchi/web/public/live_timing.js +154 -0
  72. data/lib/samagotchi/web/public/live_turn.js +44 -0
  73. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  74. data/lib/samagotchi/web/public/strip.js +5 -2
  75. data/lib/samagotchi/web/public/turn_html.js +107 -0
  76. data/lib/samagotchi/web/public/turn_model.js +5 -4
  77. data/lib/samagotchi/web/public/turn_view.js +43 -129
  78. data/lib/samagotchi/web/session_summary.rb +4 -0
  79. data/lib/samagotchi/worker.rb +166 -307
  80. data/lib/samagotchi/worker_inbound.rb +251 -0
  81. data/lib/samagotchi/worker_wakes.rb +120 -0
  82. metadata +15 -1
@@ -201,7 +201,7 @@ module Samagotchi
201
201
  end
202
202
 
203
203
  def add_notice(event)
204
- notice = event.slice(:type, :hook, :text, :level)
204
+ notice = event.slice(:type, :hook, :text, :level, :fallback_for)
205
205
  if event[:between_turns] || !@running
206
206
  push(notice.merge(in_turn: false, turns: @turns_done))
207
207
  else
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # here, and draw it with the handler that drew it live, where it was.
11
11
  module TurnNotice
12
12
  FIELDS = {
13
- hook_notice: %i[hook text level],
13
+ hook_notice: %i[hook text level fallback_for],
14
14
  empty_answer_retry: %i[iteration attempt of stopped_by malformed],
15
15
  steer_cut: %i[iteration source],
16
16
  question_requested: %i[pending_question],
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../session"
5
+
6
+ module Samagotchi
7
+ # Loaded on first use: session_manager requires terminal_ui, which
8
+ # requires KernelLoop and so the tools (a require cycle otherwise).
9
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
10
+
11
+ module Broadcast
12
+ # A session a broadcast may concern.
13
+ # @!attribute project [String, nil] its git project's root
14
+ # @!attribute live [Boolean] a worker runs it now
15
+ # @!attribute owner [String, nil] "worker", "tui" (a chi REPL, which
16
+ # takes no notes) or nil
17
+ Recipient = Data.define(:id, :short_id, :project, :cwd, :desc, :live, :owner) do
18
+ def repl? = owner == "tui"
19
+ end
20
+
21
+ # Who a broadcast goes to: the user's own sessions, not delegate
22
+ # children (their parent relays what concerns them) and not scratch
23
+ # ones, that a worker or a chi REPL runs now or that ended a turn in
24
+ # the last +active_hours+. Forks count: they are the user's sessions.
25
+ # Archived sessions don't; test runs only when chi runs as one.
26
+ module Recipients
27
+ module_function
28
+
29
+ # @return [Array<Recipient>] newest first
30
+ def list(state_dir:, active_hours:, include_tests: Session.test_session_env?, now: Time.now)
31
+ cutoff = now - (active_hours.to_f * 3600)
32
+ SessionManager.session_summaries(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
33
+ next if row[:delegate] || row[:scratch]
34
+ next unless row[:live] || row[:owner] == "tui" || active_since?(row, cutoff, state_dir)
35
+
36
+ Recipient.new(id: row[:id], short_id: row[:short_id], project: row[:project], cwd: row[:cwd],
37
+ desc: row[:desc], live: row[:live], owner: row[:owner])
38
+ end
39
+ end
40
+
41
+ # The user's last turn there ended after +cutoff+. updated_at moves on
42
+ # every save (a note absorbed, a resume), so it counts only for a
43
+ # session with no turn recorded; it is never older than the last
44
+ # turn's end, so an older one rules the session out unread.
45
+ def active_since?(row, cutoff, state_dir)
46
+ return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
47
+
48
+ session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
49
+ last_turn = session&.last_turn
50
+ ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
51
+ (ended || time(row[:updated_at])) >= cutoff
52
+ end
53
+ private_class_method :active_since?
54
+
55
+ def time(value)
56
+ value && Time.iso8601(value.to_s)
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+ private_class_method :time
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../session"
5
+ require_relative "../context_note"
6
+ require_relative "../context_sources"
7
+ require_relative "../git_head"
8
+ require_relative "../recap_store"
9
+ require_relative "tags"
10
+
11
+ module Samagotchi
12
+ module Broadcast
13
+ # What a broadcast knows about one recipient, from local state only:
14
+ # where it works, what it was started for, its recap, its last prompt
15
+ # and its tags. Built fresh for each broadcast, never stored; P2's
16
+ # triage model reads #to_s.
17
+ # @!attribute folder [String, nil] the session's folder relative to its
18
+ # project ("../app-fix" for a worktree), nil when it is the project
19
+ # root; outside a project the whole path, ~ for home
20
+ # @!attribute tags [Array<Tag>]
21
+ ScopeCard = Data.define(:id, :project, :folder, :branch, :title, :tags, :started, :recap, :recent) do
22
+ def to_s
23
+ where = [("folder #{folder}" if folder), ("branch #{branch}" if branch)].compact.join(", ")
24
+ lines = ["project: #{project || "none"}#{" (#{where})" unless where.empty?}"]
25
+ lines << "title: #{title}" unless title.to_s.empty?
26
+ lines << "tags: #{tags.map(&:label).join(" · ")}" unless tags.empty?
27
+ lines << "started: #{started}" unless started.to_s.empty?
28
+ lines << "recap: #{recap}" unless recap.to_s.empty?
29
+ lines << "recent: #{recent}" unless recent.to_s.empty?
30
+ lines.join("\n")
31
+ end
32
+ end
33
+
34
+ # Builds a ScopeCard for a Recipient.
35
+ module ScopeCards
36
+ STARTED_CHARS = 300
37
+ RECAP_CHARS = 400
38
+ RECENT_CHARS = 200
39
+
40
+ module_function
41
+
42
+ # @param recipient [Recipient]
43
+ # @param ticket [Regexp] the ticket pattern (Tags.ticket_regexp)
44
+ # @return [ScopeCard]
45
+ def build(recipient, state_dir:, ticket:)
46
+ session = Session.load(recipient.id, state_dir: state_dir)
47
+ prompts = user_texts(session.messages)
48
+ branch = GitHead.branch(session.working_directory)
49
+ sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
50
+ .map { |attached| [attached.name, attached.source.hint] }
51
+ ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
52
+ folder: folder(session.working_directory, recipient.project), branch: branch,
53
+ title: recipient.desc, tags: Tags.of_session(branch: branch, messages: prompts, sources: sources,
54
+ ticket: ticket),
55
+ started: cut(prompts.first, STARTED_CHARS),
56
+ recap: cut(RecapStore.read(Session.session_dir(session.id, state_dir: state_dir))&.dig(:text),
57
+ RECAP_CHARS),
58
+ recent: cut(session.last_prompt, RECENT_CHARS))
59
+ end
60
+
61
+ # The user's own messages, as text (an image part leaves its text).
62
+ def user_texts(messages)
63
+ messages.filter_map do |message|
64
+ next unless message[:role].to_s == "user"
65
+
66
+ content = message[:content]
67
+ text = if content.is_a?(Array)
68
+ content.filter_map { |part| part.is_a?(Hash) ? part[:text] || part["text"] : nil }.join("\n")
69
+ else
70
+ content.to_s
71
+ end
72
+ text unless text.strip.empty?
73
+ end
74
+ end
75
+ private_class_method :user_texts
76
+
77
+ def folder(cwd, project)
78
+ return ContextNote.home_relative(cwd) if project.nil?
79
+ return nil if cwd.to_s == project.to_s
80
+
81
+ Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
82
+ rescue ArgumentError
83
+ cwd
84
+ end
85
+ private_class_method :folder
86
+
87
+ def cut(text, limit)
88
+ line = text.to_s.gsub(/\s+/, " ").strip
89
+ line.length > limit ? "#{line[0, limit - 1]}…" : line
90
+ end
91
+ private_class_method :cut
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Samagotchi
6
+ module Broadcast
7
+ # A cheap, deterministic marker found in a note or about a session: a
8
+ # ticket id, a pull request, a link. A tag the note and a session share
9
+ # delivers the note there without asking a model (the fast path).
10
+ # @!attribute kind [String] "ticket", "pr" or "link"
11
+ # @!attribute value [String] "PAY-123"; "#42" or "acme/shop#42"; a
12
+ # link's host and path ("notion.so/team/checkout-v2")
13
+ # @!attribute from [String] where it was found: "note", "branch",
14
+ # "messages" (the user's prompts) or "context <name>" (an attached
15
+ # context source)
16
+ Tag = Data.define(:kind, :value, :from) do
17
+ def label = "#{kind} #{value}"
18
+
19
+ # Same kind and value; a pr with no repo matches the same number in
20
+ # any repo.
21
+ def matches?(other)
22
+ return false unless kind == other.kind
23
+ return value == other.value unless kind == "pr"
24
+
25
+ number, repo = Tags.pr_parts(value)
26
+ other_number, other_repo = Tags.pr_parts(other.value)
27
+ number == other_number && (repo.nil? || other_repo.nil? || repo == other_repo)
28
+ end
29
+ end
30
+
31
+ # A tag of the note and the session tag it matched.
32
+ Match = Data.define(:note_tag, :session_tag) do
33
+ # For the broadcast's output: "ticket PAY-123 matches (branch)".
34
+ def reason = "#{note_tag.label} matches (#{session_tag.from})"
35
+
36
+ # For the note the session gets: "ticket PAY-123 matches your branch".
37
+ def because
38
+ tag = session_tag
39
+ case tag.from
40
+ when "branch" then "#{note_tag.label} matches your branch"
41
+ when "messages" then "#{note_tag.label} is in your user's messages too"
42
+ else "#{note_tag.label} matches your attached #{tag.from}"
43
+ end
44
+ end
45
+ end
46
+
47
+ # Tags from text, a branch and attached context, and the first match
48
+ # between a note's and a session's.
49
+ module Tags
50
+ DEFAULT_TICKET_PATTERN = '\b[A-Z][A-Z0-9]+-\d+\b'
51
+ # Words the default ticket pattern would take that are no ticket.
52
+ NOT_TICKETS = %w[UTF SHA ISO IEC RFC GPT AES RSA ECMA ES HTTP TLS SSL MD IPV X].freeze
53
+ URL_RE = %r{https?://[^\s<>()\[\]{}"'`|]+}
54
+ PR_URL_RE = %r{\A(?:www\.)?github\.com/([^/]+/[^/]+)/pull/(\d+)}i
55
+ PR_TEXT_RE = /\bPR\s?#(\d+)\b/i
56
+ PR_SOURCE_RE = /\Apr-(\d+)\z/
57
+ # The order matches are looked for in: the most specific first.
58
+ KINDS = %w[ticket pr link].freeze
59
+
60
+ module_function
61
+
62
+ # broadcast.ticket_pattern as a Regexp; nil, empty or invalid → the
63
+ # default (an invalid one is reported through +warn+).
64
+ # @return [Regexp]
65
+ def ticket_regexp(pattern, warn: nil)
66
+ return Regexp.new(DEFAULT_TICKET_PATTERN) if pattern.to_s.strip.empty?
67
+
68
+ Regexp.new(pattern.to_s)
69
+ rescue RegexpError => e
70
+ warn&.call("broadcast.ticket_pattern #{pattern.inspect} is not a valid pattern (#{e.message}); using the default")
71
+ Regexp.new(DEFAULT_TICKET_PATTERN)
72
+ end
73
+
74
+ # @return [Array<Tag>] the tags in +text+ (a note, the user's prompts)
75
+ def of_text(text, from:, ticket: ticket_regexp(nil))
76
+ text = text.to_s
77
+ found = tickets(text, ticket, from: from)
78
+ found += text.scan(PR_TEXT_RE).map { |(number)| Tag.new(kind: "pr", value: "##{number}", from: from) }
79
+ text.scan(URL_RE).each { |url| found += of_url(url, from: from) }
80
+ found.uniq
81
+ end
82
+
83
+ # A session's tags: its branch (matched whatever its case: branches
84
+ # are often lowercase), the user's messages, its attached context
85
+ # (a pr-<n> source and every source's URL hint).
86
+ # @param sources [Array<Array(String, String)>] [name, hint] pairs
87
+ # @return [Array<Tag>]
88
+ def of_session(branch:, messages:, sources:, ticket: ticket_regexp(nil))
89
+ found = tickets(branch.to_s, Regexp.new(ticket.source, ticket.options | Regexp::IGNORECASE), from: "branch")
90
+ found += messages.flat_map { |text| of_text(text, from: "messages", ticket: ticket) }
91
+ sources.each do |name, hint|
92
+ from = "context #{name}"
93
+ from_hint = hint.to_s.scan(URL_RE).flat_map { |url| of_url(url, from: from) }
94
+ number = name.to_s[PR_SOURCE_RE, 1]
95
+ # pr-<n> with no PR URL in its hint: the number alone, any repo
96
+ found << Tag.new(kind: "pr", value: "##{number}", from: from) if number && from_hint.none? { |t| t.kind == "pr" }
97
+ found += from_hint
98
+ end
99
+ found.uniq { |tag| [tag.kind, tag.value] }
100
+ end
101
+
102
+ # The first session tag a note tag matches, tickets first, then pull
103
+ # requests, then links.
104
+ # @return [Match, nil]
105
+ def match(note_tags, session_tags)
106
+ KINDS.each do |kind|
107
+ note_tags.each do |tag|
108
+ next unless tag.kind == kind
109
+
110
+ hit = session_tags.find { |other| tag.matches?(other) }
111
+ return Match.new(note_tag: tag, session_tag: hit) if hit
112
+ end
113
+ end
114
+ nil
115
+ end
116
+
117
+ # "acme/shop#42" → ["42", "acme/shop"]; "#42" → ["42", nil].
118
+ def pr_parts(value)
119
+ repo, number = value.to_s.split("#", 2)
120
+ [number, repo.to_s.empty? ? nil : repo.downcase]
121
+ end
122
+
123
+ def tickets(text, regexp, from:)
124
+ text.to_enum(:scan, regexp).filter_map do
125
+ id = Regexp.last_match(0).upcase
126
+ next if NOT_TICKETS.include?(id.split("-", 2).first)
127
+
128
+ Tag.new(kind: "ticket", value: id, from: from)
129
+ end
130
+ end
131
+ private_class_method :tickets
132
+
133
+ # A link's host and path, without the scheme, www., the query, the
134
+ # fragment or a trailing slash; a pull request URL is a pr tag too. A
135
+ # bare host ("https://github.com/") says too little to be a tag.
136
+ def of_url(url, from:)
137
+ uri = URI.parse(url.sub(/[.,;:!?]+\z/, ""))
138
+ host = uri.host.to_s.downcase.delete_prefix("www.")
139
+ path = uri.path.to_s.chomp("/")
140
+ return [] if host.empty? || path.empty?
141
+
142
+ link = "#{host}#{path}"
143
+ tags = [Tag.new(kind: "link", value: link, from: from)]
144
+ if (pr = link.match(PR_URL_RE))
145
+ tags.unshift(Tag.new(kind: "pr", value: "#{pr[1].downcase}##{pr[2]}", from: from))
146
+ end
147
+ tags
148
+ rescue URI::Error
149
+ []
150
+ end
151
+ private_class_method :of_url
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "session"
5
+ require_relative "session_inbox"
6
+ require_relative "note_delivery"
7
+ require_relative "broadcast/recipients"
8
+ require_relative "broadcast/scope_card"
9
+ require_relative "broadcast/tags"
10
+ require_relative "cli/command"
11
+ require_relative "cli/flags"
12
+
13
+ module Samagotchi
14
+ # `chi broadcast`: share a note with every session it may concern,
15
+ # without picking them. Each recipient (Broadcast::Recipients) that shares
16
+ # 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.
19
+ class BroadcastCommand
20
+ include CLI::Command
21
+
22
+ SOURCE = "broadcast"
23
+ # Bytes kept free under SessionInbox's 16 KiB for the line each note
24
+ # ends with (#body).
25
+ BODY_ROOM = 512
26
+ # The longest "because" #body quotes (a link tag can be long).
27
+ BECAUSE_CHARS = 200
28
+ # The env every execute/task call exports (Tools::Builtins): set, the
29
+ # command runs inside a chi session, i.e. an agent runs it.
30
+ PARENT_ENV = "SAMAGOTCHI_PARENT_SESSION"
31
+
32
+ USAGE = <<~TEXT
33
+ Usage: chi broadcast [-m TEXT] [--all] [--dry-run]
34
+ Shares TEXT (or stdin) with the sessions it may concern, as a context
35
+ note from "broadcast": background the model sees on its next turn,
36
+ not a prompt. It starts no turn.
37
+ Recipients: your own sessions (not delegate children, not scratch
38
+ ones) that a worker or a chi REPL runs now, or that ended a turn in
39
+ the last broadcast.active_hours (8). One gets the note when it shares
40
+ a tag with it: a ticket id (PAY-123) in its branch or your prompts
41
+ 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.
43
+ -m TEXT the note; without it, stdin is read
44
+ --all every recipient, tag or not
45
+ --dry-run show each recipient's scope card, tags and verdict; deliver nothing
46
+ For you, not for an agent: refused inside a chi session.
47
+ One session or a few by id: chi note. See docs/broadcast.md.
48
+ TEXT
49
+
50
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS, args: false) do |f|
51
+ f.switch "--all"
52
+ f.switch "--dry-run"
53
+ f.value "-m", "--message", key: :text
54
+ end
55
+
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?
60
+ end
61
+
62
+ # @param argv [Array<String>] the arguments after "broadcast"
63
+ # @param active_hours [Numeric, nil] broadcast.active_hours (config)
64
+ # @param ticket_pattern [String, nil] broadcast.ticket_pattern (config)
65
+ 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"))
67
+ @argv = argv.dup
68
+ @stdin = stdin
69
+ @stdout = stdout
70
+ @stderr = stderr
71
+ @state_dir = state_dir || Session.default_state_dir
72
+ @env = env
73
+ @now = now
74
+ @active_hours = active_hours || 8
75
+ @ticket = Broadcast::Tags.ticket_regexp(ticket_pattern, warn: ->(line) { @stderr.puts("chi broadcast: #{line}") })
76
+ end
77
+
78
+ # @return [Integer] exit status: 0 done (skipped sessions included),
79
+ # 1 refused, nothing to send to or a delivery failed, 2 usage
80
+ def run
81
+ options = parse
82
+ 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
88
+
89
+ text = utf8(options[:text] || read_stdin)
90
+ return usage_error("no note text: pass -m TEXT or pipe it in") unless text
91
+
92
+ begin
93
+ text = SessionInbox.checked_text(text)
94
+ limit = SessionInbox::NOTE_MAX_BYTES - BODY_ROOM
95
+ if text.bytesize > limit
96
+ raise SessionInbox::NoteRejected, "the note is #{text.bytesize} bytes; a broadcast takes up to #{limit} " \
97
+ "(16 KiB less room for the line chi adds)"
98
+ end
99
+ rescue SessionInbox::NoteRejected => e
100
+ error_line("chi broadcast: #{e.message}")
101
+ return 1
102
+ end
103
+
104
+ broadcast(text, all: options[:all], dry_run: options[:dry_run])
105
+ end
106
+
107
+ private
108
+
109
+ def command_name = "chi broadcast"
110
+
111
+ def parse
112
+ parsed = parse_flags(FLAGS, @argv)
113
+ parsed.is_a?(Integer) ? parsed : parsed.options
114
+ end
115
+
116
+ # @return [Integer] exit status
117
+ def broadcast(text, all:, dry_run:)
118
+ recipients = Broadcast::Recipients.list(state_dir: @state_dir, active_hours: @active_hours, now: @now)
119
+ if recipients.empty?
120
+ error_line("chi broadcast: no sessions to send to: none runs now or ended a turn in the last " \
121
+ "#{format_hours(@active_hours)} (chi sessions list --scope=all)")
122
+ return 1
123
+ end
124
+
125
+ note_tags = Broadcast::Tags.of_text(text, from: "note", ticket: @ticket)
126
+ 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)}")
131
+ @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
133
+
134
+ deliver_all(verdicts, text)
135
+ end
136
+
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?
139
+
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)
143
+ end
144
+
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(/^/, " "))
149
+ end
150
+ delivered = verdicts.count(&:deliver?)
151
+ @stdout.puts("would deliver #{delivered} · skipped #{verdicts.size - delivered}")
152
+ 0
153
+ end
154
+
155
+ def deliver_all(verdicts, text)
156
+ ok = true
157
+ lines = verdicts.map do |v|
158
+ next [v.recipient, false, "skipped", v.skip] unless v.deliver?
159
+
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?
162
+
163
+ [v.recipient, true, "delivered", "#{reason(v)}#{"; waits for its next start" if result.status == :waits}"]
164
+ rescue SessionInbox::NoteRejected, SystemCallError => e
165
+ ok = false
166
+ [v.recipient, false, "failed", e.message]
167
+ 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))
170
+ end
171
+ delivered = lines.count { |_, d| d }
172
+ @stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}")
173
+ ok ? 0 : 1
174
+ end
175
+
176
+ # What a recipient gets: the user's text first (the terminal's "note
177
+ # from broadcast: …" line shows its start), then a line saying when it
178
+ # 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)
182
+ 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
184
+
185
+ because = match.because
186
+ because = "#{because[0, BECAUSE_CHARS - 1]}…" if because.length > BECAUSE_CHARS
187
+ "#{text}\n(#{shared} with the sessions it may concern; it reached you because #{because}.)"
188
+ end
189
+
190
+ def reason(verdict)
191
+ verdict.skip || verdict.match&.reason || "--all"
192
+ end
193
+
194
+ def line(recipient, word, why, width: 9) = "#{recipient.short_id} #{word.ljust(width)} #{why}"
195
+
196
+ # The note's first line, quoted and cut, for the output's head.
197
+ def headline(text)
198
+ lines = text.lines
199
+ first = lines.first.to_s.strip
200
+ first = "#{first[0, 59]}…" if first.length > 60
201
+ first = "#{first} …" if lines.size > 1 && !first.end_with?("…")
202
+ "\"#{first}\""
203
+ end
204
+
205
+ def format_hours(hours)
206
+ value = hours.to_f
207
+ "#{value == value.round ? value.round : value} #{value == 1 ? "hour" : "hours"}"
208
+ 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
+ end
228
+ 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"