brainiac 0.0.6 → 0.0.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -5,45 +5,6 @@
5
5
  require "English"
6
6
  CLI_PROVIDERS_DIR = File.join(BRAINIAC_DIR, "cli-providers")
7
7
 
8
- # --trust-all-tools alone doesn't bypass the non-interactive deny list in kiro-cli 1.29.8+.
9
- # Adding --trust-tools with explicit tool names ensures write/exec tools are approved.
10
- # This is now configured in the kiro provider's default_args instead of being hardcoded here.
11
- TRUSTED_TOOLS = "execute_bash,fs_write,fs_read,code,grep,glob,web_search,web_fetch,use_subagent,use_aws"
12
-
13
- # Clean up all worktrees associated with a card: the primary worktree and any
14
- # cross-agent review worktrees (e.g. glados-fizzy-123-*, threepio-fizzy-123-*).
15
- # Safe: skips worktrees with uncommitted changes.
16
- def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
17
- return unless card_number
18
-
19
- repo_dir = File.dirname(repo_path)
20
- repo_base = File.basename(repo_path)
21
- cleaned = 0
22
-
23
- # Collect all worktree dirs for this card: primary + cross-agent review
24
- candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
25
- candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
26
-
27
- candidates.uniq.each do |wt_path|
28
- status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
29
- if status_output.strip.empty?
30
- branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
31
- begin
32
- run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
33
- run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
34
- cleaned += 1
35
- LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
36
- rescue StandardError => e
37
- LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
38
- end
39
- else
40
- LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
41
- end
42
- end
43
-
44
- LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
45
- end
46
-
47
8
  # Load a CLI provider config from ~/.brainiac/cli-providers/<name>.json.
48
9
  # Returns a hash with normalized keys, or {} if not found.
49
10
  def load_cli_provider(provider_name)
@@ -109,107 +70,28 @@ def agent_cli_provider_for(agent_name)
109
70
  entry["cli_provider"]
110
71
  end
111
72
 
112
- # Detect CLI provider override from inline [cli:X] tag or Fizzy card tags.
73
+ # Detect CLI provider override from inline [cli:X] tag or card tags.
113
74
  # Returns the provider name (e.g. "grok") or nil.
114
75
  def detect_cli_provider(text: "", tags: [])
115
- # Inline tag: [cli:grok]
76
+ # Inline tag: [cli:grok] — works in any channel
116
77
  if (match = text.match(/\[cli:(\w+)\]/i))
117
78
  return match[1].downcase
118
79
  end
119
80
 
120
- # Fizzy card tags: cli-grok
121
- tags.each do |tag|
122
- name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
123
- return name.sub("cli-", "") if name.start_with?("cli-")
124
- end
81
+ # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
82
+ results = Brainiac.emit(:detect_cli_provider, text: text, tags: tags)
83
+ plugin_result = results.compact.first
84
+ return plugin_result if plugin_result
125
85
 
126
86
  nil
127
87
  end
128
88
 
129
- # Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
130
- # Symlink directories matching .worktreelink patterns instead of copying.
131
- # Both files use .gitignore syntax. Only gitignored files/dirs are processed.
132
- def apply_worktree_includes(repo_path, worktree_path)
133
- copied = 0
134
- linked = 0
135
-
136
- [".worktreeinclude", ".worktreelink"].each do |filename|
137
- config_file = File.join(repo_path, filename)
138
- next unless File.exist?(config_file)
139
-
140
- symlink_mode = filename == ".worktreelink"
141
- patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
142
- next if patterns.empty?
143
-
144
- patterns.each do |pattern|
145
- Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match|
146
- src = File.join(repo_path, match)
147
- dest = File.join(worktree_path, match)
148
- next if File.exist?(dest) || File.symlink?(dest)
149
-
150
- # Only process gitignored files/dirs
151
- _, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path)
152
- next unless st.success?
153
-
154
- FileUtils.mkdir_p(File.dirname(dest))
155
-
156
- if symlink_mode && File.directory?(src)
157
- FileUtils.ln_s(src, dest)
158
- linked += 1
159
- LOG.info "Symlinked #{match} from main repo"
160
- elsif File.file?(src)
161
- FileUtils.cp(src, dest)
162
- copied += 1
163
- end
164
- end
165
- end
166
- end
167
-
168
- LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive?
169
- end
170
-
171
- # Run a project-level hook script from .brainiac/<hook_name> if it exists.
172
- # Passes REPO_PATH (and optionally WORKTREE_PATH) as environment variables.
173
- def run_project_hook(repo_path, hook_name, extra_env: {})
174
- hook = File.join(repo_path, ".brainiac", hook_name)
175
- return unless File.exist?(hook)
176
-
177
- env = { "REPO_PATH" => repo_path }.merge(extra_env)
178
- LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}"
179
- output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
180
- if status.success?
181
- LOG.info ".brainiac/#{hook_name} completed successfully"
182
- else
183
- LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
184
- end
185
- end
186
-
187
89
  def default_project_key
188
90
  # Find the project marked as default
189
91
  default = PROJECTS.find { |_key, config| config["default"] == true }
190
92
  default ? default[0] : nil
191
93
  end
192
94
 
193
- def identify_project_by_tags(tags)
194
- return nil if PROJECTS.empty?
195
-
196
- tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
197
-
198
- PROJECTS.each do |project_key, config|
199
- project_tags = (config["fizzy_tags"] || []).map(&:downcase)
200
- return [project_key, config] if tag_names.intersect?(project_tags)
201
- end
202
-
203
- # Fall back to default project if configured
204
- default_key = default_project_key
205
- if default_key
206
- LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'"
207
- return [default_key, PROJECTS[default_key]]
208
- end
209
-
210
- nil
211
- end
212
-
213
95
  def identify_project_by_repo(repo_full_name)
214
96
  return nil if PROJECTS.empty?
215
97
 
@@ -227,54 +109,22 @@ def identify_project_by_repo(repo_full_name)
227
109
  nil
228
110
  end
229
111
 
230
- def resolve_card_number(internal_id, repo_path:)
231
- env = default_fizzy_env
232
- [nil, "--indexed-by closed"].each do |extra_flag|
233
- cmd = ["fizzy", "card", "list", "--all"]
234
- cmd << extra_flag if extra_flag
235
- output, status = Open3.capture2(env, *cmd, chdir: repo_path)
236
- next unless status.success?
237
-
238
- data = JSON.parse(output)["data"] || []
239
- match = data.find { |c| c["id"] == internal_id }
240
- if match
241
- LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}"
242
- return match["number"]
243
- end
244
- end
245
-
246
- LOG.warn "Could not resolve card number for internal_id #{internal_id}"
247
- nil
248
- rescue StandardError => e
249
- LOG.warn "resolve_card_number failed for #{internal_id}: #{e.message}"
250
- nil
251
- end
112
+ def load_work_item_map
113
+ return {} unless File.exist?(WORK_ITEM_MAP_FILE)
252
114
 
253
- def load_card_map
254
- return {} unless File.exist?(CARD_MAP_FILE)
255
-
256
- JSON.parse(File.read(CARD_MAP_FILE))
115
+ JSON.parse(File.read(WORK_ITEM_MAP_FILE))
257
116
  rescue JSON::ParserError
258
117
  {}
259
118
  end
260
119
 
261
- def save_card_map(map)
262
- File.write(CARD_MAP_FILE, JSON.pretty_generate(map))
120
+ def save_work_item_map(map)
121
+ File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
263
122
  end
264
123
 
265
124
  def slugify(title, max_length: 40)
266
125
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
267
126
  end
268
127
 
269
- def verify_signature!(request, payload_body, board_key: nil)
270
- signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
271
- halt 403, { error: "Missing signature" }.to_json unless signature
272
- secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET
273
- halt 403, { error: "No webhook secret configured" }.to_json unless secret
274
- computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
275
- halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
276
- end
277
-
278
128
  def verify_github_signature!(request, payload_body)
279
129
  signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
280
130
  halt 403, { error: "Missing GitHub signature" }.to_json unless signature
@@ -292,36 +142,22 @@ def run_cmd(*cmd, chdir:, env: {})
292
142
  stdout
293
143
  end
294
144
 
295
- # Trust the version manager config in a directory (supports mise and asdf)
296
- def trust_version_manager(path, chdir:)
297
- if system("which mise >/dev/null 2>&1")
298
- run_cmd("mise", "trust", path, chdir: chdir)
299
- elsif system("which asdf >/dev/null 2>&1")
300
- LOG.info "asdf detected — no explicit trust needed for #{path}"
301
- else
302
- LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}"
303
- end
304
- rescue StandardError => e
305
- LOG.warn "Could not trust version manager in #{path}: #{e.message}"
306
- end
307
-
308
145
  # Cards that have been merged to main — skip Needs Review moves for these.
309
146
  # Keyed by card number (string), value is Time. Entries expire after 10 minutes.
310
147
  MERGED_CARDS = {}
311
148
  MERGED_CARDS_MUTEX = Mutex.new
312
149
 
313
- def mark_card_merged(card_number)
150
+ def mark_work_item_merged(card_number)
314
151
  MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
315
152
  end
316
153
 
317
- def card_merged?(card_number)
154
+ def work_item_merged?(card_number)
318
155
  MERGED_CARDS_MUTEX.synchronize do
319
156
  ts = MERGED_CARDS[card_number.to_s]
320
157
  ts && (Time.now - ts < 600)
321
158
  end
322
159
  end
323
160
 
324
- # Pre-fetch a Fizzy card's body and comments so the agent doesn't have to.
325
161
  # Returns a formatted string suitable for injection into the prompt, or ''
326
162
  # if the fetch fails (agent can still fetch manually as a fallback).
327
163
  PREFETCH_COMMENT_LIMIT = 15
@@ -329,106 +165,7 @@ COMMENT_BODY_TRUNCATE_LENGTH = 500
329
165
  CARD_CONTEXT_CACHE = {}
330
166
  CARD_CONTEXT_CACHE_TTL = 60 # seconds
331
167
 
332
- def prefetch_card_context(card_number, repo_path:, agent_name: nil)
333
- return "" unless card_number
334
-
335
- # Return cached context if fresh enough
336
- cache_key = "#{card_number}-#{agent_name}"
337
- cached = CARD_CONTEXT_CACHE[cache_key]
338
- if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL
339
- LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)"
340
- return cached[:context]
341
- end
342
-
343
- env = fizzy_env_for(agent_name)
344
- parts = []
345
-
346
- card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env)
347
- return "" if card_parts.nil?
348
-
349
- parts.concat(card_parts)
350
- parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env))
351
- return "" if parts.empty?
352
-
353
- context = parts.join("\n")
354
- result = <<~CARD_CONTEXT
355
- ## Card Context (pre-fetched — do NOT re-fetch this)
356
- #{context}
357
-
358
- CARD_CONTEXT
359
-
360
- CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now }
361
- CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50
362
- result
363
- rescue StandardError => e
364
- LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.message}"
365
- ""
366
- end
367
-
368
- # Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
369
- def fetch_card_details(card_number, repo_path:, env:)
370
- card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
371
- card_data = begin
372
- JSON.parse(card_output)["data"]
373
- rescue StandardError
374
- nil
375
- end
376
- return [] unless card_data
377
-
378
- parts = []
379
- parts << "## Card ##{card_number}: #{card_data["title"]}"
380
- parts << "Status: #{card_data["status"]}" if card_data["status"]
381
- tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
382
- parts << "Tags: #{tags.join(", ")}" unless tags.empty?
383
- body = card_data.dig("body", "plain_text") || card_data["body"]
384
- parts << "\n#{body}" if body && !body.to_s.strip.empty?
385
- parts
386
- rescue StandardError => e
387
- LOG.warn "Could not pre-fetch card ##{card_number}: #{e.message}"
388
- nil
389
- end
390
-
391
168
  # Fetch recent comments for a card. Returns array of text parts.
392
- def fetch_card_comments(card_number, repo_path:, env:)
393
- comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
394
- comments_data = JSON.parse(comments_output)["data"] || []
395
- return [] if comments_data.empty?
396
-
397
- parts = []
398
- total = comments_data.size
399
- comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT)
400
- parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}"
401
- comments_data.each do |c|
402
- author = c.dig("creator", "name") || "Unknown"
403
- body = c.dig("body", "plain_text") || ""
404
- cid = c["id"]
405
- next if body.strip.empty?
406
-
407
- body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH
408
- parts << "\n### #{author} (comment ID: #{cid})\n#{body}"
409
- end
410
- parts
411
- rescue StandardError => e
412
- LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
413
- []
414
- end
415
-
416
- def scrub_invalid_attachments!(dir)
417
- attachments_dir = File.join(dir, ".fizzy-attachments")
418
- return unless File.directory?(attachments_dir)
419
-
420
- Dir.glob(File.join(attachments_dir, "*")).each do |file_path|
421
- next unless File.file?(file_path)
422
-
423
- file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path)
424
- unless file_type.strip.start_with?("image/")
425
- LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})"
426
- FileUtils.rm_f(file_path)
427
- end
428
- end
429
- rescue StandardError => e
430
- LOG.error "Error scrubbing attachments in #{dir}: #{e.message}"
431
- end
432
169
 
433
170
  # Extract the last N meaningful lines from an agent log for crash reporting.
434
171
  def extract_crash_snippet(log_file, max_lines: 20)
@@ -442,33 +179,23 @@ rescue StandardError => e
442
179
  end
443
180
 
444
181
  # Notify the originating channel that an agent crashed.
445
- # source: :fizzy, :github, :discord
182
+ # source: :github, :discord, or plugin-registered sources
446
183
  # source_context: hash with channel-specific info needed to post the notification
447
184
  def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
448
185
  agent_display = agent_name || "Agent"
449
186
  snippet = extract_crash_snippet(log_file)
450
187
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
451
188
 
452
- case source
453
- when :fizzy
454
- card_number = source_context[:card_number]
455
- return unless card_number
456
-
457
- repo_path = project_config&.dig("repo_path") || Dir.pwd
458
- body = "<p>💥 <strong>#{agent_display} crashed</strong> (exit code #{exit_status})</p>" \
459
- "<p>Log: <code>#{log_file}</code></p>"
460
- if snippet
461
- escaped = snippet[-1500..].gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
462
- body += "<pre>#{escaped}</pre>"
463
- end
464
- begin
465
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
466
- chdir: repo_path, env: fizzy_env_for(agent_display))
467
- LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}"
468
- rescue StandardError => e
469
- LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.message}"
470
- end
189
+ # Try plugin-registered crash handlers first
190
+ handled = Brainiac.emit(:agent_crashed,
191
+ exit_status: exit_status, log_file: log_file, agent_name: agent_display,
192
+ source: source, source_context: source_context, project_config: project_config,
193
+ snippet: snippet)
194
+
195
+ # If a plugin handled it for this source, we're done
196
+ return if handled.any?(source)
471
197
 
198
+ case source
472
199
  when :github
473
200
  pr_number = source_context[:pr_number]
474
201
  repo_name = source_context[:repo_name]
@@ -497,67 +224,6 @@ rescue StandardError => e
497
224
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
498
225
  end
499
226
 
500
- # Append an italic PR/branch footer to the agent's most recent Fizzy comment.
501
- def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
502
- repo_path = project_config["repo_path"]
503
- project_config["github_repo"]
504
- env = fizzy_env_for(agent_name)
505
-
506
- # Find branch and tracked PRs from card_map
507
- card_map = load_card_map
508
- card_info = card_map.values.find { |v| v["number"] == card_number }
509
- branch = card_info&.dig("branch")
510
- return unless branch
511
-
512
- prs = card_info&.dig("prs") || []
513
-
514
- # Build footer parts
515
- parts = []
516
- parts << "Branch: <code>#{branch}</code>"
517
- prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" }
518
- return if parts.empty?
519
-
520
- footer_html = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>"
521
-
522
- # Find agent's most recent comment
523
- begin
524
- output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
525
- comments = (JSON.parse(output)["data"] || []).reverse
526
- agent_display = fizzy_display_name(agent_name)
527
- comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") }
528
- return unless comment
529
-
530
- existing_html = comment.dig("body", "html") || ""
531
- # Don't double-append if footer already present
532
- return if existing_html.include?("Branch: <code>#{branch}</code>")
533
-
534
- # Strip Fizzy's outer wrapper — it re-wraps on update
535
- inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "")
536
- updated_html = "#{inner}\n#{footer_html}"
537
- run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s,
538
- "--body", updated_html, chdir: repo_path, env: env)
539
- LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}"
540
- rescue StandardError => e
541
- LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.message}"
542
- end
543
- end
544
-
545
- def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
546
- return unless card_number
547
-
548
- board_key = board_key_for_project(project_config)
549
- column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name]
550
- return unless column_id
551
-
552
- repo_path = project_config["repo_path"]
553
- env = fizzy_env_for(agent_name || AI_AGENT_NAME)
554
- run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
555
- record_self_move(card_number)
556
- LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})"
557
- rescue StandardError => e
558
- LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.message}"
559
- end
560
-
561
227
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
562
228
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
563
229
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
@@ -570,8 +236,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
570
236
  # that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
571
237
  should_resume = resume && resolved["resume_flag"]
572
238
 
573
- ensure_fizzy_yaml!(chdir, project_config)
574
- Thread.new { scrub_invalid_attachments!(chdir) }
239
+ # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
240
+ Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
575
241
 
576
242
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
577
243
  log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
@@ -616,18 +282,6 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
616
282
  [pid, log_file]
617
283
  end
618
284
 
619
- # Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
620
- def ensure_fizzy_yaml!(chdir, project_config)
621
- fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
622
- return if File.exist?(fizzy_yaml_dest)
623
-
624
- fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
625
- return unless File.exist?(fizzy_yaml_src)
626
-
627
- FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
628
- LOG.info "Copied .fizzy.yaml to #{chdir}"
629
- end
630
-
631
285
  # Write agent prompt to a temp file, return path.
632
286
  def write_agent_prompt_file(prompt, log_name, timestamp)
633
287
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
@@ -676,10 +330,18 @@ def handle_agent_completion(**ctx)
676
330
  )
677
331
  end
678
332
 
679
- fizzy_card = ctx[:card_number] || ctx[:source_context][:card_number]
680
- handle_fizzy_post_session(fizzy_card, agent_exit_status, agent_signaled, ctx[:agent_name], ctx[:chdir], ctx[:source], ctx[:source_context],
681
- ctx[:project_config], ctx[:skip_column_move])
682
- handle_plan_finalization(ctx[:prompt_file], ctx[:agent_name], ctx[:project_config])
333
+ # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
334
+ Brainiac.emit(:agent_completed,
335
+ card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
336
+ exit_status: agent_exit_status,
337
+ signaled: agent_signaled,
338
+ agent_name: ctx[:agent_name],
339
+ chdir: ctx[:chdir],
340
+ source: ctx[:source],
341
+ source_context: ctx[:source_context],
342
+ project_config: ctx[:project_config],
343
+ skip_column_move: ctx[:skip_column_move],
344
+ prompt_file: ctx[:prompt_file])
683
345
 
684
346
  qmd_out, qmd_status = Open3.capture2e("qmd", "update")
685
347
  if qmd_status.success?
@@ -699,64 +361,6 @@ def handle_agent_completion(**ctx)
699
361
  check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
700
362
  end
701
363
 
702
- def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move)
703
- return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
704
-
705
- unless skip_column_move || card_merged?(fizzy_card)
706
- move_card_to_column(fizzy_card, "needs_review", project_config: project_config, agent_name: agent_name)
707
- end
708
-
709
- append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
710
-
711
- return unless source_context[:deploy_intent]
712
-
713
- auto_deploy_after_session(
714
- deploy_intent: source_context[:deploy_intent],
715
- card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first,
716
- card_number: fizzy_card,
717
- worktree_path: chdir,
718
- agent_name: agent_name
719
- )
720
- end
721
-
722
- def handle_plan_finalization(prompt_file, agent_name, project_config)
723
- return unless File.exist?(prompt_file)
724
-
725
- prompt_content = File.read(prompt_file)
726
- card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/)
727
- return unless card_id_match
728
-
729
- card_id = card_id_match[1]
730
- plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
731
- return unless File.exist?(plan_file)
732
-
733
- LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..."
734
- card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil
735
- project_key = PROJECTS.find { |_k, v| v == project_config }&.first
736
-
737
- result = finalize_plan(
738
- card_id: card_id, card_number: card_num,
739
- agent_name: agent_name || AI_AGENT_NAME,
740
- project_key: project_key, repo_path: project_config["repo_path"]
741
- )
742
-
743
- if result[:success]
744
- LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created"
745
- else
746
- LOG.error "[Planning] Failed to finalize plan: #{result[:error]}"
747
- end
748
- end
749
-
750
- # Capture git HEAD and working tree status for a directory.
751
- # Returns [head_sha, status_porcelain] or [nil, nil] on failure.
752
- def capture_git_state(chdir)
753
- head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
754
- status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
755
- [head.strip, status.strip]
756
- rescue StandardError
757
- [nil, nil]
758
- end
759
-
760
364
  def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
761
365
  return unless project_key_for_restart == "brainiac" && head_before
762
366
 
@@ -768,18 +372,6 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
768
372
  end
769
373
  end
770
374
 
771
- def authorized?(payload)
772
- creator_id = payload.dig("creator", "id")
773
- AUTHORIZED_USER_IDS.include?(creator_id)
774
- end
775
-
776
- def human_mentioned?(user_id)
777
- return false unless FIZZY_CONFIG["authorized_users"]
778
-
779
- user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id }
780
- user && user["human"]
781
- end
782
-
783
375
  def detect_model(project_config, tags: [], text: "")
784
376
  resolved = resolve_project_cli_config(project_config)
785
377
  allowed_models = resolved["allowed_models"] || {}
@@ -798,7 +390,7 @@ def detect_model(project_config, tags: [], text: "")
798
390
  resolved["agent_model"]
799
391
  end
800
392
 
801
- # Detect effort level from inline tags [effort:high] or Fizzy card tags (effort-high).
393
+ # Detect effort level from inline tags [effort:high] or card tags (effort-high).
802
394
  # Returns the effort level string (e.g. "high") or nil.
803
395
  # If the requested level isn't supported by the current model, returns the closest
804
396
  # lower level from allowed_efforts.
@@ -806,20 +398,16 @@ def detect_effort(project_config, tags: [], text: "")
806
398
  resolved = resolve_project_cli_config(project_config)
807
399
  allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
808
400
 
809
- # Inline tag: [effort:high]
401
+ # Inline tag: [effort:high] — works in any channel
810
402
  if (match = text.match(/\[effort:(\w+)\]/i))
811
403
  level = match[1].downcase
812
404
  return resolve_effort_level(level, allowed) if allowed.include?(level)
813
405
  end
814
406
 
815
- # Fizzy card tags: effort-high, effort-max
816
- tags.each do |tag|
817
- name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
818
- if name.start_with?("effort-")
819
- level = name.sub("effort-", "")
820
- return resolve_effort_level(level, allowed) if allowed.include?(level)
821
- end
822
- end
407
+ # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
408
+ results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
409
+ plugin_result = results.compact.first
410
+ return resolve_effort_level(plugin_result, allowed) if plugin_result
823
411
 
824
412
  resolved["agent_effort"]
825
413
  end