samagotchi 0.33.0 → 0.35.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +6 -1
  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 +5 -0
  9. data/docs/guardrails.md +35 -3
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +17 -1
  12. data/docs/plugins.md +78 -3
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +15 -9
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  18. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  19. data/lib/samagotchi/broadcast/tags.rb +154 -0
  20. data/lib/samagotchi/broadcast_command.rb +228 -0
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -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/delegated.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  35. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  36. data/lib/samagotchi/children_status.rb +143 -0
  37. data/lib/samagotchi/config.rb +6 -0
  38. data/lib/samagotchi/cut_policy.rb +58 -0
  39. data/lib/samagotchi/engine.rb +45 -108
  40. data/lib/samagotchi/engine_peers.rb +30 -0
  41. data/lib/samagotchi/git_head.rb +24 -0
  42. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  43. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  44. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  45. data/lib/samagotchi/guardrails/context.rb +32 -0
  46. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  47. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +11 -5
  50. data/lib/samagotchi/kernel_loop.rb +9 -22
  51. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  52. data/lib/samagotchi/log_subscriber.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  54. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  55. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  56. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  57. data/lib/samagotchi/memory_paths.rb +10 -0
  58. data/lib/samagotchi/note_command.rb +13 -13
  59. data/lib/samagotchi/note_delivery.rb +42 -0
  60. data/lib/samagotchi/plugin/api.rb +2 -2
  61. data/lib/samagotchi/plugin/context.rb +4 -4
  62. data/lib/samagotchi/plugin/sessions.rb +45 -1
  63. data/lib/samagotchi/process_group.rb +2 -5
  64. data/lib/samagotchi/session_manager.rb +4 -3
  65. data/lib/samagotchi/sessions_command.rb +3 -2
  66. data/lib/samagotchi/steer_cut.rb +125 -0
  67. data/lib/samagotchi/system_prompt.rb +21 -8
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  69. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  70. data/lib/samagotchi/terminal_ui.rb +14 -0
  71. data/lib/samagotchi/tool_declarations.rb +9 -5
  72. data/lib/samagotchi/tools/builtins.rb +2 -2
  73. data/lib/samagotchi/tools/delegate.rb +103 -14
  74. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  75. data/lib/samagotchi/tools/send_note.rb +5 -11
  76. data/lib/samagotchi/turn_note.rb +9 -1
  77. data/lib/samagotchi/version.rb +1 -1
  78. data/lib/samagotchi/web/app.rb +3 -0
  79. data/lib/samagotchi/web/capabilities.rb +19 -0
  80. data/lib/samagotchi/web/public/activity.js +26 -6
  81. data/lib/samagotchi/web/public/app.js +515 -288
  82. data/lib/samagotchi/web/public/card.js +12 -3
  83. data/lib/samagotchi/web/public/command_view.js +2 -3
  84. data/lib/samagotchi/web/public/families_view.js +171 -0
  85. data/lib/samagotchi/web/public/format.js +52 -0
  86. data/lib/samagotchi/web/public/index.html +94 -21
  87. data/lib/samagotchi/web/public/live_timing.js +154 -0
  88. data/lib/samagotchi/web/public/live_turn.js +44 -0
  89. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  90. data/lib/samagotchi/web/public/strip.js +5 -2
  91. data/lib/samagotchi/web/public/turn_html.js +107 -0
  92. data/lib/samagotchi/web/public/turn_model.js +5 -4
  93. data/lib/samagotchi/web/public/turn_view.js +43 -129
  94. data/lib/samagotchi/web/session_summary.rb +4 -0
  95. data/lib/samagotchi/worker.rb +173 -309
  96. data/lib/samagotchi/worker_inbound.rb +251 -0
  97. data/lib/samagotchi/worker_wakes.rb +120 -0
  98. metadata +24 -1
@@ -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
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: coordinator
3
+ version: 0.1.2
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"
6
+ trust_level: reviewed
7
+ files:
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"
11
+ plugin:
12
+ file: plugin.rb
13
+ sha256: sha256:5173faeb6311984ad4f2a40b9052dcc577a90790fc5c5a8409b95d4f114e66a7
14
+ requires_chi: ">= 0.34.0"
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The coordinator bundle (docs/plugins.md, The coordinator bundle): chi as a
4
+ # coordinator of parallel work. Its skill_coordinator memory holds the steps
5
+ # (split the work, one worktree and a wait: false delegate per task, check
6
+ # each report, ask before each merge); this plugin adds two commands.
7
+ #
8
+ # /children [all] shows this session's children as a card, newest first:
9
+ # state, branch, the last reply and whether it was reported (ChildrenStatus
10
+ # through ctx.sessions.children), with a Stop button per running or waiting
11
+ # child and Refresh. `all` adds archived children. The card has one id, so
12
+ # Refresh and Stop replace it. It is read on demand: no thread, no polling.
13
+ #
14
+ # /children stop <id> stops one of this session's own children
15
+ # (ctx.sessions.stop, by session id) and shows the card again.
16
+ #
17
+ # /coordinate <goal> asks the model, in this session, to follow the skill
18
+ # for the goal. A REPL session (--no-shared) takes no messages: the request
19
+ # is shown for the user to send.
20
+ #
21
+ # A child is asked before it changes anything outside its worktree by chi
22
+ # itself (the core child-boundary guardrail), in every mode: nothing to set up.
23
+ class Plugin
24
+ CARD_ID = "children"
25
+ # A card takes 6 actions: Refresh and up to 5 Stops.
26
+ MAX_STOPS = 5
27
+ STOPPABLE = %w[running waiting].freeze
28
+ TEXT_CHARS = 80
29
+ USAGE = "usage: /children [all] | /children stop <id>"
30
+ COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each"
31
+
32
+ def initialize(_settings = {}); end
33
+
34
+ def register(chi)
35
+ chi.command "/children", "this session's child sessions: state, branch, last reply; all, stop <id>",
36
+ anytime: true do |args, ctx|
37
+ children_command(args.to_s.strip, ctx)
38
+ end
39
+ chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree",
40
+ anytime: true do |args, ctx|
41
+ coordinate(args.to_s.strip, ctx)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def children_command(args, ctx)
48
+ verb, rest = args.split(/\s+/, 2)
49
+ case verb
50
+ when nil then show(ctx, all: false)
51
+ when "all" then rest.to_s.empty? ? show(ctx, all: true) : USAGE
52
+ when "stop" then rest.to_s.strip.empty? ? USAGE : stop(rest.strip, ctx)
53
+ else USAGE
54
+ end
55
+ end
56
+
57
+ # The card, made or replaced; nil, so the command adds no text of its own.
58
+ def show(ctx, all:, note: nil)
59
+ children = ctx.sessions.children(all: all)
60
+ title = "children of #{ctx.session_id.to_s[0, 8]}"
61
+ title += " (#{children.size})" unless children.empty?
62
+ body = children.empty? ? "no children" : children.map { |child| line(child) }.join("\n")
63
+ body = "#{note}\n\n#{body}" if note
64
+ refresh = all ? "/children all" : "/children"
65
+ stops = children.select { |child| STOPPABLE.include?(child[:state]) }.first(MAX_STOPS)
66
+ .map { |child| { label: "Stop #{child[:short_id]}", command: "/children stop #{child[:short_id]}" } }
67
+ ctx.card(id: CARD_ID, title: title, body: body, actions: [{ label: "Refresh", command: refresh }, *stops])
68
+ nil
69
+ end
70
+
71
+ # - `ab12cd34` · running · fix/flaky · "fix the flaky spec…"
72
+ # - `9a8b7c6d` · done · feat/x · reported · "All 12 specs pass…"
73
+ # - `77aa66bb` · waiting (approval) · open it: chi --attach 77aa66bb
74
+ def line(child)
75
+ parts = ["`#{child[:short_id]}`", child[:waiting] ? "waiting (#{child[:waiting]})" : child[:state]]
76
+ parts << child[:branch] if child[:branch]
77
+ parts << "fork" unless child[:delegate]
78
+ parts << "archived" if child[:archived]
79
+ if child[:waiting]
80
+ parts << "open it: chi --attach #{child[:short_id]}"
81
+ elsif child[:last_reply]
82
+ parts << (child[:reported] ? "reported" : "not reported yet")
83
+ parts << quote(child[:last_reply])
84
+ elsif !child[:title].to_s.strip.empty?
85
+ parts << quote(child[:title])
86
+ end
87
+ "- #{parts.join(" · ")}"
88
+ end
89
+
90
+ def stop(id, ctx)
91
+ stopped = ctx.sessions.stop(id)
92
+ show(ctx, all: false, note: "stopped #{stopped[0, 8]}")
93
+ rescue Samagotchi::Plugin::Sessions::Error => e
94
+ "/children stop: #{e.message}"
95
+ end
96
+
97
+ def coordinate(goal, ctx)
98
+ return COORDINATE_USAGE if goal.empty?
99
+
100
+ request = "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}"
101
+ begin
102
+ ctx.sessions.send(ctx.session_id, request)
103
+ rescue Samagotchi::Plugin::Sessions::Error => e
104
+ return "/coordinate: #{e.message}. Send this yourself:\n\n#{request}"
105
+ end
106
+ "asked chi to coordinate it; a running turn gets it at its next step"
107
+ end
108
+
109
+ def quote(text)
110
+ line = text.to_s.gsub(/\s+/, " ").strip
111
+ line = "#{line[0, TEXT_CHARS - 1]}…" if line.length > TEXT_CHARS
112
+ "\"#{line}\""
113
+ end
114
+ end
@@ -0,0 +1,60 @@
1
+ # Skill: coordinator
2
+
3
+ Use when the user hands over work with several independent parts, or asks to "run it in parallel",
4
+ "delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
5
+
6
+ ## Steps
7
+ 1. Find the repository's default branch, <default> below: `git symbolic-ref --short
8
+ refs/remotes/origin/HEAD` (drop the `origin/`), else `main` or `master`, whichever exists; ask the
9
+ user if unsure. Split the work into independent tasks (at most session.max_children at once,
10
+ default 4). Tell the user the split in a few lines and go on unless they object.
11
+ 2. For each task: from your own folder, `git worktree add ../<repo>-<task> -b <type>/<task>` with
12
+ execute; then `delegate` with `wait: false`, `cwd:` that worktree's absolute path, and a
13
+ self-contained task. The task starts with "Work only in <absolute worktree path>: every cd, read,
14
+ write, test and commit goes there, never the project root" and says what to report (the result,
15
+ evidence: commands and what they printed, what isn't done) and "commit on the branch, don't merge,
16
+ don't push".
17
+ 3. Tell the user what started (one line per child: its id, branch and task) and end your turn or keep
18
+ talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
19
+ you are idle.
20
+ 4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
21
+ `git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree).
22
+ Say the result in one or two lines: done and verified / done with doubts / failed.
23
+ 5. Then one of: a follow-up (`delegate session: <id>`, narrower, `wait: false`; then end your turn:
24
+ its report comes by itself, so don't wait for it with delegate_result or task_wait), stop it
25
+ (`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
26
+ can use /children), or tell the user it is ready to merge.
27
+ 6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?".
28
+ Before merging, in your own folder: `git branch --show-current` must print <default> (if it
29
+ doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
30
+ `git log --oneline -3 <default>` and `git merge-base --is-ancestor <branch> <default>`; if the branch is already in, say so and don't merge
31
+ again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
32
+ child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
33
+ user; don't rebase it from here (the branch is checked out in the child's worktree). Children
34
+ never merge. Never push unless asked.
35
+ 7. Clean up only a branch that was merged or that the user said to drop: stop its child
36
+ (`chi sessions stop <session id>`), then `git worktree remove ../<repo>-<task>`, then
37
+ `git branch -d <branch>` (in that order: a branch checked out in a worktree can't be deleted). If
38
+ `-d` refuses, say why to the user; never `-D` unless they say so. Remove only worktrees and
39
+ branches you created in this session. Every other branch stays as it is, its child, worktree and
40
+ branch too (the user said "keep it", or didn't say): leave them and tell the user they remain.
41
+ 8. When all are done, report: what merged, what didn't and why, what you cleaned up and what remains,
42
+ follow-ups found but not fixed. Report only what the commands' output showed: a step you planned
43
+ but didn't run, or whose output you didn't see, is not done; say so.
44
+
45
+ ## Gotchas
46
+ - Children are asked before changing anything outside their worktree (your checkout, a sibling's):
47
+ the user approves it on the child's card. Still check where commits landed (step 4).
48
+ - Never kill processes by a pid from a file or an old note; stop children with `chi sessions stop
49
+ <session id>` (execute) and background tasks with task_stop (task ids only: it doesn't stop a child).
50
+ - A child's tool approvals go to the user on the child's card; tell the user when a report says a
51
+ child waits for one.
52
+ - A report is the child's claim: verify it (step 4) before telling the user it is done, and check
53
+ where its commits landed: `git log --oneline <default>..<branch>` has them and `git status` in your own
54
+ folder is clean. A child that worked in your folder instead of its worktree: tell the user.
55
+ - Don't send a follow-up to a child that is still running; wait for its report.
56
+ - Don't start more children than the user asked for.
57
+
58
+ ## Changelog
59
+ - 2026-10-06 created (coordinator bundle 0.1.0)
60
+ - 2026-10-06 clean up only merged or dropped branches; report only what commands showed (0.1.2)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.32.0"
7
+ requires_chi: ">= 0.34.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -12,3 +12,4 @@ includes:
12
12
  - skills
13
13
  - source-links
14
14
  - github-pr
15
+ - coordinator
@@ -128,6 +128,17 @@ rules:
128
128
  scopes: [once]
129
129
  reason: attaches a command chi will run every few minutes, outside the guardrails
130
130
 
131
+ # ── chi broadcast: for the user, not an agent ─────────────────────────
132
+ # chi broadcast refuses to run inside a session (SAMAGOTCHI_PARENT_SESSION
133
+ # is set there), but an agent can unset that; this asks every time, and
134
+ # only the user may allow it. Quoted words and continuations still match.
135
+ - id: chi-broadcast
136
+ tool: shell
137
+ command: '\bchi["'']?[\s\\]+["'']?broadcast\b'
138
+ verdict: ask
139
+ scopes: [once]
140
+ reason: shares a note with every session it may concern; chi broadcast is for the user, not an agent
141
+
131
142
  # ── answering chi's own questions around chi answer ───────────────────
132
143
  # A parent agent answers a child's approval with chi answer, which holds it
133
144
  # to guardrails.parent_approvals. These shapes go around that: chi --attach
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.6.0
3
+ version: 0.7.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
8
  # skip_read_only:, touches:, rm: and modes: need 0.20.0
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.4
3
+ version: 0.4.5
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:05543038e2950a5391655184f57a1028133eda8ca05f9e46e5cd2eddc3e9e608
11
- requires_chi: ">= 0.8.0"
10
+ sha256: sha256:1ebedc86cd21c918766c794015b6bd16c397c0f7e2734d939d8310d446b8a9a1
11
+ requires_chi: ">= 0.33.0"
@@ -31,6 +31,7 @@
31
31
  require "digest"
32
32
  require "json"
33
33
  require "open3"
34
+ require "samagotchi/process_group"
34
35
  require "time"
35
36
  require "shellwords"
36
37
  require "tmpdir"
@@ -59,6 +60,9 @@ class Plugin
59
60
  class Dead < Error; end
60
61
 
61
62
  POLL_SECONDS = 0.1
63
+ # How long close waits for the server after closing its stdin, after
64
+ # TERM, and after KILL.
65
+ STOP_GRACE_SECONDS = 1
62
66
 
63
67
  attr_reader :pid
64
68
 
@@ -136,14 +140,15 @@ class Plugin
136
140
  end
137
141
 
138
142
  # End the process: stdin closed first (most servers leave then), then
139
- # TERM and KILL to its process group.
143
+ # TERM and KILL to its process group (chi's ProcessGroup), a second's
144
+ # grace each.
140
145
  def close
141
146
  @mutex.synchronize { @closing = true }
142
147
  [@stdin].each { |io| io.close unless io.closed? }
143
- unless @wait.join(1)
144
- signal("TERM")
145
- signal("KILL") unless @wait.join(1)
146
- @wait.join(1)
148
+ unless @wait.join(STOP_GRACE_SECONDS)
149
+ Samagotchi::ProcessGroup.stop(@pid, grace: STOP_GRACE_SECONDS, poll: STOP_GRACE_SECONDS,
150
+ stopped: -> { !@wait.alive? }, wait: ->(seconds) { @wait.join(seconds) })
151
+ @wait.join(STOP_GRACE_SECONDS)
147
152
  end
148
153
  [@stdout, @stderr].each { |io| io.close unless io.closed? }
149
154
  [@reader, @err_reader].each { |t| t.join(1) }
@@ -234,12 +239,6 @@ class Plugin
234
239
  @on_exit&.call(reason) unless closing
235
240
  end
236
241
 
237
- def signal(name)
238
- Process.kill(name, -@pid)
239
- rescue Errno::ESRCH, Errno::EPERM
240
- nil
241
- end
242
-
243
242
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
244
243
  end
245
244
 
@@ -8,7 +8,9 @@ require "open3"
8
8
  # web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
9
9
  # through event[:present]: display only, the model's text stays as it was,
10
10
  # and it survives a reload). Every UI also gets one line right after the
11
- # turn, the terminals' only view of the links:
11
+ # turn, the terminals' only view of the links (marked fallback_for:
12
+ # :display when the answer links every URL it names, so the web with
13
+ # markdown on leaves it out; needs chi 0.35.0):
12
14
  #
13
15
  # sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
14
16
  #
@@ -48,8 +50,8 @@ require "open3"
48
50
  # of these stays text (warned at load).
49
51
  #
50
52
  # The note is not stored in the conversation: it is an event, replayed by a
51
- # UI only while the session's worker lives (a reload keeps it; a stopped
52
- # worker loses it). The links are stored as the answer's display. A ref in
53
+ # UI with the session's cards (a reload keeps it, after the worker stopped
54
+ # too: cards.json). The links are stored as the answer's display. A ref in
53
55
  # code (a `span` or a fenced block) or in a markdown link is not linked in
54
56
  # the answer. The hook is inert until sources are configured.
55
57
  class SourceLinks
@@ -114,17 +116,34 @@ class SourceLinks
114
116
  return if text.nil? || text.empty?
115
117
 
116
118
  hits = occurrences(text[0, MAX_SCAN])
117
- if @note
118
- found = collect(hits)
119
- event[:notify]&.call(line(found), level: :info) unless found.empty?
120
- end
121
- # The answer as shown: the model's text unless an earlier hook changed
122
- # it (then its offsets differ, so it is scanned again).
123
- event[:present]&.call { |shown| link(shown, shown == text ? hits : nil) }
119
+ linked = present_links(event[:present], text, hits)
120
+ return unless @note
121
+
122
+ found = collect(hits)
123
+ return if found.empty?
124
+
125
+ # The display links every URL the note names: the note only stands in
126
+ # for it, and a UI that renders the display's links leaves it out.
127
+ covered = linked && found.all? { |_name, _ref, url| linked.key?(url.downcase) }
128
+ event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
124
129
  end
125
130
 
126
131
  private
127
132
 
133
+ # Links the refs in the answer as shown: the model's text unless an
134
+ # earlier hook changed it (then its offsets differ, so it is scanned
135
+ # again). The URLs it linked (downcased, as #collect dedups), or nil when
136
+ # the display didn't take them (no event[:present], nothing to present, or
137
+ # the presenter rejected the text).
138
+ def present_links(present, text, hits)
139
+ return nil unless present
140
+
141
+ linked = {}
142
+ ours = nil
143
+ shown = present.call { |display| ours = link(display, display == text ? hits : nil, linked) }
144
+ ours.nil? || shown != ours ? nil : linked
145
+ end
146
+
128
147
  # The content of the last `role: "model"` message, or nil. Handles both
129
148
  # string- and symbol-keyed messages. A turn that ended without a visible
130
149
  # answer stores a `kind: turn_note` system message instead, so the role
@@ -198,8 +217,8 @@ class SourceLinks
198
217
 
199
218
  # +text+ with each ref as a markdown link, every occurrence; the part
200
219
  # beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
201
- # the caller has it.
202
- def link(text, hits = nil)
220
+ # the caller has it; +linked+ gets each linked URL, downcased.
221
+ def link(text, hits = nil, linked = {})
203
222
  head = text[0, MAX_SCAN]
204
223
  hits ||= occurrences(head)
205
224
  out = +""
@@ -208,6 +227,7 @@ class SourceLinks
208
227
  next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
209
228
 
210
229
  out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
230
+ linked[hit[:url].downcase] = true
211
231
  pos = hit[:finish]
212
232
  end
213
233
  return text if pos.zero?
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.4
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
+ requires_chi: ">= 0.35.0"
7
8
  files:
8
- source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
+ source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
9
10
  hooks:
10
11
  source_links.rb:
11
- sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
12
+ sha256: sha256:8ce4d11659f4bc151167f99e47299c0f6ef246797c9167c609c1699e3edea2e5
12
13
  event: after_turn
13
14
  on_error: log
14
15
  priority: 90
@@ -1,5 +1,5 @@
1
1
  # Source links
2
2
 
3
- The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note, which every UI shows (the terminals only see this line). The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it while the session's worker lives (a page reload keeps it; a stopped worker loses it).
3
+ The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note (the terminals only see this line). The web leaves it out when markdown is on and the answer links every ref it names; otherwise every UI shows it. The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it with the session's cards (a page reload keeps it, after the worker stopped too).
4
4
 
5
5
  The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
@@ -5,6 +5,6 @@ Another chi session (the parent) gave you this task. It reads only your last mes
5
5
  - Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
6
6
  - Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
7
7
  - The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
8
- - Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
8
+ - Work in the directory you were started in: every cd, read, edit, test and commit stays in it (in a linked worktree, never the repository's other checkouts). Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
9
9
  - You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
10
10
  - If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.