brainiac 0.0.7 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/bin/brainiac +132 -75
  4. data/lib/brainiac/agents.rb +12 -36
  5. data/lib/brainiac/brain.rb +6 -6
  6. data/lib/brainiac/config.rb +2 -67
  7. data/lib/brainiac/handlers/discord/config.rb +1 -1
  8. data/lib/brainiac/handlers/discord/delivery.rb +1 -1
  9. data/lib/brainiac/handlers/discord/gateway.rb +1 -1
  10. data/lib/brainiac/handlers/discord/message.rb +10 -10
  11. data/lib/brainiac/handlers/discord/reactions.rb +1 -1
  12. data/lib/brainiac/handlers/github.rb +49 -169
  13. data/lib/brainiac/handlers/shared/git.rb +4 -17
  14. data/lib/brainiac/handlers/shared/inline_tags.rb +1 -1
  15. data/lib/brainiac/handlers/zoho.rb +29 -82
  16. data/lib/brainiac/helpers.rb +43 -336
  17. data/lib/brainiac/hooks.rb +86 -0
  18. data/lib/brainiac/plugins.rb +27 -2
  19. data/lib/brainiac/prompts.rb +34 -172
  20. data/lib/brainiac/restart.rb +30 -12
  21. data/lib/brainiac/routes/api.rb +2 -18
  22. data/lib/brainiac/users.rb +1 -7
  23. data/lib/brainiac/version.rb +1 -1
  24. data/lib/brainiac.rb +1 -1
  25. data/monitor/waybar/deploy_env.rb +3 -3
  26. data/monitor/xbar/plugin.rb +11 -17
  27. data/receiver.rb +9 -157
  28. data/templates/agents.json.example +1 -2
  29. data/templates/brainiac.json.example +0 -1
  30. data/templates/users.json.example +0 -3
  31. metadata +2 -10
  32. data/lib/brainiac/handlers/fizzy/assignment.rb +0 -125
  33. data/lib/brainiac/handlers/fizzy/card_index.rb +0 -389
  34. data/lib/brainiac/handlers/fizzy/comments.rb +0 -730
  35. data/lib/brainiac/handlers/fizzy/dedup.rb +0 -74
  36. data/lib/brainiac/handlers/fizzy/deploy.rb +0 -152
  37. data/lib/brainiac/handlers/fizzy/deployments.rb +0 -260
  38. data/lib/brainiac/handlers/fizzy.rb +0 -14
  39. data/lib/brainiac/planning.rb +0 -237
  40. data/templates/fizzy.json.example +0 -24
@@ -195,7 +195,7 @@ ZOHO_TRIAGE_DIR = File.join(BRAINIAC_DIR, "tmp", "zoho", "triage")
195
195
  FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
196
196
 
197
197
  ZOHO_TRIAGE_PROMPT = <<~PROMPT
198
- You are triaging a support email. Decide whether this email needs a Fizzy card or not.
198
+ You are triaging a support email. Decide whether this email needs a card or not.
199
199
 
200
200
  ## Email
201
201
  **From:** {{FROM}}
@@ -256,7 +256,7 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
256
256
  PROMPT
257
257
 
258
258
  # Dispatch an agent to triage a support email. The agent decides whether to create
259
- # a Fizzy card or just notify Discord.
259
+ # a card or just notify Discord.
260
260
  def dispatch_zoho_triage(email, rule)
261
261
  agent_name = rule["dispatch_agent"]
262
262
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
@@ -390,7 +390,7 @@ def execute_zoho_triage_decision(decision, email, rule)
390
390
  end
391
391
  end
392
392
 
393
- # Create a Fizzy card from the triage decision
393
+ # Create a card from the triage decision
394
394
  def create_zoho_triage_card(decision, email, channel_id, token)
395
395
  board_id = ZOHO_CONFIG["triage_board_id"]
396
396
  unless board_id
@@ -404,87 +404,34 @@ def create_zoho_triage_card(decision, email, channel_id, token)
404
404
  tags = ["support"]
405
405
  tags << decision["project_tag"] if decision["project_tag"]
406
406
 
407
- # Resolve tag IDs
408
- tag_ids = resolve_zoho_triage_tags(tags)
409
-
410
- # Create the card
411
- cmd = ["fizzy", "card", "create", "--board", board_id, "--title", title, "--description", description]
412
- cmd.push("--tag-ids", tag_ids.join(",")) unless tag_ids.empty?
413
-
414
- # Use the triage agent's env for fizzy token
415
- agent_name = "Threepio"
416
- agent_env = agent_env_for(agent_name)
417
- spawn_env = agent_env.empty? ? {} : agent_env
418
-
419
- output, status = Open3.capture2e(spawn_env, *cmd)
420
- unless status.success?
421
- LOG.error "[Zoho:Triage] Failed to create card: #{output}"
422
- notify_zoho_match(email, { "label" => "Support Email (card creation failed)", "emoji" => "❌" }.merge(rule_defaults(nil)))
423
- return
424
- end
425
-
426
- # Parse card number from response
427
- card_data = JSON.parse(output)
428
- card_number = card_data.dig("data", "number")
429
- card_url = card_data.dig("data", "url")
430
- LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
431
-
432
- # Assign to the appropriate agent
433
- assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"]
434
-
435
- # Notify Discord
436
- if channel_id && token
437
- msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
438
- msg += "**Title:** #{title}\n"
439
- msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
440
- msg += "**Tags:** #{tags.join(", ")}\n"
441
- msg += "**From:** #{email["fromAddress"]}"
442
- send_discord_message(channel_id, msg, token: token)
407
+ # Emit hook — work item plugin handles card creation (card management plugins)
408
+ results = Brainiac.emit(:create_work_item,
409
+ board_id: board_id,
410
+ title: title,
411
+ description: description,
412
+ tags: tags,
413
+ assign_to: decision["assign_to"])
414
+
415
+ card_info = results.compact.first
416
+ if card_info
417
+ card_number = card_info[:number]
418
+ card_url = card_info[:url]
419
+ LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
420
+
421
+ # Notify Discord
422
+ if channel_id && token
423
+ msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
424
+ msg += "**Title:** #{title}\n"
425
+ msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
426
+ msg += "**Tags:** #{tags.join(", ")}\n"
427
+ msg += "**From:** #{email["fromAddress"]}"
428
+ send_discord_message(channel_id, msg, token: token)
429
+ end
430
+ else
431
+ LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
432
+ notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
443
433
  end
444
434
  rescue StandardError => e
445
435
  LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
446
436
  notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
447
437
  end
448
-
449
- # Resolve tag names to IDs by querying Fizzy
450
- def resolve_zoho_triage_tags(tag_names)
451
- agent_env = agent_env_for("Threepio")
452
- spawn_env = agent_env.empty? ? {} : agent_env
453
-
454
- output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all")
455
- return [] unless status.success?
456
-
457
- all_tags = JSON.parse(output)["data"] || []
458
- tag_names.filter_map do |name|
459
- tag = all_tags.find { |t| t["title"].downcase == name.downcase }
460
- tag&.dig("id")
461
- end
462
- rescue StandardError => e
463
- LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
464
- []
465
- end
466
-
467
- # Assign a card to the appropriate agent
468
- def assign_zoho_triage_card(card_number, agent_name, spawn_env)
469
- # Resolve agent name to Fizzy user ID from fizzy.json authorized_users
470
- users = FIZZY_CONFIG["authorized_users"] || []
471
- user = users.find { |u| u["name"]&.downcase == agent_name.downcase }
472
- user_id = user&.dig("id")
473
-
474
- unless user_id
475
- LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name} (not found in fizzy.json authorized_users)"
476
- return
477
- end
478
-
479
- output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id)
480
- if status.success?
481
- LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}"
482
- else
483
- LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}"
484
- end
485
- end
486
-
487
- def rule_defaults(rule)
488
- { "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"],
489
- "notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] }
490
- end
@@ -70,19 +70,18 @@ def agent_cli_provider_for(agent_name)
70
70
  entry["cli_provider"]
71
71
  end
72
72
 
73
- # 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.
74
74
  # Returns the provider name (e.g. "grok") or nil.
75
75
  def detect_cli_provider(text: "", tags: [])
76
- # Inline tag: [cli:grok]
76
+ # Inline tag: [cli:grok] — works in any channel
77
77
  if (match = text.match(/\[cli:(\w+)\]/i))
78
78
  return match[1].downcase
79
79
  end
80
80
 
81
- # Fizzy card tags: cli-grok
82
- tags.each do |tag|
83
- name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
84
- return name.sub("cli-", "") if name.start_with?("cli-")
85
- 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
86
85
 
87
86
  nil
88
87
  end
@@ -93,26 +92,6 @@ def default_project_key
93
92
  default ? default[0] : nil
94
93
  end
95
94
 
96
- def identify_project_by_tags(tags)
97
- return nil if PROJECTS.empty?
98
-
99
- tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
100
-
101
- PROJECTS.each do |project_key, config|
102
- project_tags = (config["fizzy_tags"] || []).map(&:downcase)
103
- return [project_key, config] if tag_names.intersect?(project_tags)
104
- end
105
-
106
- # Fall back to default project if configured
107
- default_key = default_project_key
108
- if default_key
109
- LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'"
110
- return [default_key, PROJECTS[default_key]]
111
- end
112
-
113
- nil
114
- end
115
-
116
95
  def identify_project_by_repo(repo_full_name)
117
96
  return nil if PROJECTS.empty?
118
97
 
@@ -130,54 +109,22 @@ def identify_project_by_repo(repo_full_name)
130
109
  nil
131
110
  end
132
111
 
133
- def resolve_card_number(internal_id, repo_path:)
134
- env = default_fizzy_env
135
- [nil, "--indexed-by closed"].each do |extra_flag|
136
- cmd = ["fizzy", "card", "list", "--all"]
137
- cmd << extra_flag if extra_flag
138
- output, status = Open3.capture2(env, *cmd, chdir: repo_path)
139
- next unless status.success?
140
-
141
- data = JSON.parse(output)["data"] || []
142
- match = data.find { |c| c["id"] == internal_id }
143
- if match
144
- LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}"
145
- return match["number"]
146
- end
147
- end
148
-
149
- LOG.warn "Could not resolve card number for internal_id #{internal_id}"
150
- nil
151
- rescue StandardError => e
152
- LOG.warn "resolve_card_number failed for #{internal_id}: #{e.message}"
153
- nil
154
- end
155
-
156
- def load_card_map
157
- return {} unless File.exist?(CARD_MAP_FILE)
112
+ def load_work_item_map
113
+ return {} unless File.exist?(WORK_ITEM_MAP_FILE)
158
114
 
159
- JSON.parse(File.read(CARD_MAP_FILE))
115
+ JSON.parse(File.read(WORK_ITEM_MAP_FILE))
160
116
  rescue JSON::ParserError
161
117
  {}
162
118
  end
163
119
 
164
- def save_card_map(map)
165
- 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))
166
122
  end
167
123
 
168
124
  def slugify(title, max_length: 40)
169
125
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
170
126
  end
171
127
 
172
- def verify_signature!(request, payload_body, board_key: nil)
173
- signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
174
- halt 403, { error: "Missing signature" }.to_json unless signature
175
- secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET
176
- halt 403, { error: "No webhook secret configured" }.to_json unless secret
177
- computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
178
- halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
179
- end
180
-
181
128
  def verify_github_signature!(request, payload_body)
182
129
  signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
183
130
  halt 403, { error: "Missing GitHub signature" }.to_json unless signature
@@ -200,18 +147,17 @@ end
200
147
  MERGED_CARDS = {}
201
148
  MERGED_CARDS_MUTEX = Mutex.new
202
149
 
203
- def mark_card_merged(card_number)
150
+ def mark_work_item_merged(card_number)
204
151
  MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
205
152
  end
206
153
 
207
- def card_merged?(card_number)
154
+ def work_item_merged?(card_number)
208
155
  MERGED_CARDS_MUTEX.synchronize do
209
156
  ts = MERGED_CARDS[card_number.to_s]
210
157
  ts && (Time.now - ts < 600)
211
158
  end
212
159
  end
213
160
 
214
- # Pre-fetch a Fizzy card's body and comments so the agent doesn't have to.
215
161
  # Returns a formatted string suitable for injection into the prompt, or ''
216
162
  # if the fetch fails (agent can still fetch manually as a fallback).
217
163
  PREFETCH_COMMENT_LIMIT = 15
@@ -219,106 +165,7 @@ COMMENT_BODY_TRUNCATE_LENGTH = 500
219
165
  CARD_CONTEXT_CACHE = {}
220
166
  CARD_CONTEXT_CACHE_TTL = 60 # seconds
221
167
 
222
- def prefetch_card_context(card_number, repo_path:, agent_name: nil)
223
- return "" unless card_number
224
-
225
- # Return cached context if fresh enough
226
- cache_key = "#{card_number}-#{agent_name}"
227
- cached = CARD_CONTEXT_CACHE[cache_key]
228
- if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL
229
- LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)"
230
- return cached[:context]
231
- end
232
-
233
- env = fizzy_env_for(agent_name)
234
- parts = []
235
-
236
- card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env)
237
- return "" if card_parts.nil?
238
-
239
- parts.concat(card_parts)
240
- parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env))
241
- return "" if parts.empty?
242
-
243
- context = parts.join("\n")
244
- result = <<~CARD_CONTEXT
245
- ## Card Context (pre-fetched — do NOT re-fetch this)
246
- #{context}
247
-
248
- CARD_CONTEXT
249
-
250
- CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now }
251
- CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50
252
- result
253
- rescue StandardError => e
254
- LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.message}"
255
- ""
256
- end
257
-
258
- # Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
259
- def fetch_card_details(card_number, repo_path:, env:)
260
- card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
261
- card_data = begin
262
- JSON.parse(card_output)["data"]
263
- rescue StandardError
264
- nil
265
- end
266
- return [] unless card_data
267
-
268
- parts = []
269
- parts << "## Card ##{card_number}: #{card_data["title"]}"
270
- parts << "Status: #{card_data["status"]}" if card_data["status"]
271
- tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
272
- parts << "Tags: #{tags.join(", ")}" unless tags.empty?
273
- body = card_data.dig("body", "plain_text") || card_data["body"]
274
- parts << "\n#{body}" if body && !body.to_s.strip.empty?
275
- parts
276
- rescue StandardError => e
277
- LOG.warn "Could not pre-fetch card ##{card_number}: #{e.message}"
278
- nil
279
- end
280
-
281
168
  # Fetch recent comments for a card. Returns array of text parts.
282
- def fetch_card_comments(card_number, repo_path:, env:)
283
- comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
284
- comments_data = JSON.parse(comments_output)["data"] || []
285
- return [] if comments_data.empty?
286
-
287
- parts = []
288
- total = comments_data.size
289
- comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT)
290
- parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}"
291
- comments_data.each do |c|
292
- author = c.dig("creator", "name") || "Unknown"
293
- body = c.dig("body", "plain_text") || ""
294
- cid = c["id"]
295
- next if body.strip.empty?
296
-
297
- body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH
298
- parts << "\n### #{author} (comment ID: #{cid})\n#{body}"
299
- end
300
- parts
301
- rescue StandardError => e
302
- LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
303
- []
304
- end
305
-
306
- def scrub_invalid_attachments!(dir)
307
- attachments_dir = File.join(dir, ".fizzy-attachments")
308
- return unless File.directory?(attachments_dir)
309
-
310
- Dir.glob(File.join(attachments_dir, "*")).each do |file_path|
311
- next unless File.file?(file_path)
312
-
313
- file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path)
314
- unless file_type.strip.start_with?("image/")
315
- LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})"
316
- FileUtils.rm_f(file_path)
317
- end
318
- end
319
- rescue StandardError => e
320
- LOG.error "Error scrubbing attachments in #{dir}: #{e.message}"
321
- end
322
169
 
323
170
  # Extract the last N meaningful lines from an agent log for crash reporting.
324
171
  def extract_crash_snippet(log_file, max_lines: 20)
@@ -332,33 +179,23 @@ rescue StandardError => e
332
179
  end
333
180
 
334
181
  # Notify the originating channel that an agent crashed.
335
- # source: :fizzy, :github, :discord
182
+ # source: :github, :discord, or plugin-registered sources
336
183
  # source_context: hash with channel-specific info needed to post the notification
337
184
  def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
338
185
  agent_display = agent_name || "Agent"
339
186
  snippet = extract_crash_snippet(log_file)
340
187
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
341
188
 
342
- case source
343
- when :fizzy
344
- card_number = source_context[:card_number]
345
- return unless card_number
346
-
347
- repo_path = project_config&.dig("repo_path") || Dir.pwd
348
- body = "<p>💥 <strong>#{agent_display} crashed</strong> (exit code #{exit_status})</p>" \
349
- "<p>Log: <code>#{log_file}</code></p>"
350
- if snippet
351
- escaped = snippet[-1500..].gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
352
- body += "<pre>#{escaped}</pre>"
353
- end
354
- begin
355
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
356
- chdir: repo_path, env: fizzy_env_for(agent_display))
357
- LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}"
358
- rescue StandardError => e
359
- LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.message}"
360
- 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)
361
197
 
198
+ case source
362
199
  when :github
363
200
  pr_number = source_context[:pr_number]
364
201
  repo_name = source_context[:repo_name]
@@ -387,67 +224,6 @@ rescue StandardError => e
387
224
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
388
225
  end
389
226
 
390
- # Append an italic PR/branch footer to the agent's most recent Fizzy comment.
391
- def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
392
- repo_path = project_config["repo_path"]
393
- project_config["github_repo"]
394
- env = fizzy_env_for(agent_name)
395
-
396
- # Find branch and tracked PRs from card_map
397
- card_map = load_card_map
398
- card_info = card_map.values.find { |v| v["number"] == card_number }
399
- branch = card_info&.dig("branch")
400
- return unless branch
401
-
402
- prs = card_info&.dig("prs") || []
403
-
404
- # Build footer parts
405
- parts = []
406
- parts << "Branch: <code>#{branch}</code>"
407
- prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" }
408
- return if parts.empty?
409
-
410
- footer_html = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>"
411
-
412
- # Find agent's most recent comment
413
- begin
414
- output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
415
- comments = (JSON.parse(output)["data"] || []).reverse
416
- agent_display = fizzy_display_name(agent_name)
417
- comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") }
418
- return unless comment
419
-
420
- existing_html = comment.dig("body", "html") || ""
421
- # Don't double-append if footer already present
422
- return if existing_html.include?("Branch: <code>#{branch}</code>")
423
-
424
- # Strip Fizzy's outer wrapper — it re-wraps on update
425
- inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "")
426
- updated_html = "#{inner}\n#{footer_html}"
427
- run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s,
428
- "--body", updated_html, chdir: repo_path, env: env)
429
- LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}"
430
- rescue StandardError => e
431
- LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.message}"
432
- end
433
- end
434
-
435
- def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
436
- return unless card_number
437
-
438
- board_key = board_key_for_project(project_config)
439
- column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name]
440
- return unless column_id
441
-
442
- repo_path = project_config["repo_path"]
443
- env = fizzy_env_for(agent_name || AI_AGENT_NAME)
444
- run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
445
- record_self_move(card_number)
446
- LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})"
447
- rescue StandardError => e
448
- LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.message}"
449
- end
450
-
451
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,
452
228
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
453
229
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
@@ -460,8 +236,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
460
236
  # that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
461
237
  should_resume = resume && resolved["resume_flag"]
462
238
 
463
- ensure_fizzy_yaml!(chdir, project_config)
464
- 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)
465
241
 
466
242
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
467
243
  log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
@@ -506,18 +282,6 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
506
282
  [pid, log_file]
507
283
  end
508
284
 
509
- # Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
510
- def ensure_fizzy_yaml!(chdir, project_config)
511
- fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
512
- return if File.exist?(fizzy_yaml_dest)
513
-
514
- fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
515
- return unless File.exist?(fizzy_yaml_src)
516
-
517
- FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
518
- LOG.info "Copied .fizzy.yaml to #{chdir}"
519
- end
520
-
521
285
  # Write agent prompt to a temp file, return path.
522
286
  def write_agent_prompt_file(prompt, log_name, timestamp)
523
287
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
@@ -566,10 +330,18 @@ def handle_agent_completion(**ctx)
566
330
  )
567
331
  end
568
332
 
569
- fizzy_card = ctx[:card_number] || ctx[:source_context][:card_number]
570
- handle_fizzy_post_session(fizzy_card, agent_exit_status, agent_signaled, ctx[:agent_name], ctx[:chdir], ctx[:source], ctx[:source_context],
571
- ctx[:project_config], ctx[:skip_column_move])
572
- 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])
573
345
 
574
346
  qmd_out, qmd_status = Open3.capture2e("qmd", "update")
575
347
  if qmd_status.success?
@@ -589,55 +361,6 @@ def handle_agent_completion(**ctx)
589
361
  check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
590
362
  end
591
363
 
592
- def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move)
593
- return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
594
-
595
- unless skip_column_move || card_merged?(fizzy_card)
596
- move_card_to_column(fizzy_card, "needs_review", project_config: project_config,
597
- agent_name: agent_name)
598
- end
599
-
600
- append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
601
-
602
- return unless source_context[:deploy_intent]
603
-
604
- auto_deploy_after_session(
605
- deploy_intent: source_context[:deploy_intent],
606
- card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first,
607
- card_number: fizzy_card,
608
- worktree_path: chdir,
609
- agent_name: agent_name
610
- )
611
- end
612
-
613
- def handle_plan_finalization(prompt_file, agent_name, project_config)
614
- return unless File.exist?(prompt_file)
615
-
616
- prompt_content = File.read(prompt_file)
617
- card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/)
618
- return unless card_id_match
619
-
620
- card_id = card_id_match[1]
621
- plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
622
- return unless File.exist?(plan_file)
623
-
624
- LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..."
625
- card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil
626
- project_key = PROJECTS.find { |_k, v| v == project_config }&.first
627
-
628
- result = finalize_plan(
629
- card_id: card_id, card_number: card_num,
630
- agent_name: agent_name || AI_AGENT_NAME,
631
- project_key: project_key, repo_path: project_config["repo_path"]
632
- )
633
-
634
- if result[:success]
635
- LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created"
636
- else
637
- LOG.error "[Planning] Failed to finalize plan: #{result[:error]}"
638
- end
639
- end
640
-
641
364
  def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
642
365
  return unless project_key_for_restart == "brainiac" && head_before
643
366
 
@@ -649,18 +372,6 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
649
372
  end
650
373
  end
651
374
 
652
- def authorized?(payload)
653
- creator_id = payload.dig("creator", "id")
654
- AUTHORIZED_USER_IDS.include?(creator_id)
655
- end
656
-
657
- def human_mentioned?(user_id)
658
- return false unless FIZZY_CONFIG["authorized_users"]
659
-
660
- user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id }
661
- user && user["human"]
662
- end
663
-
664
375
  def detect_model(project_config, tags: [], text: "")
665
376
  resolved = resolve_project_cli_config(project_config)
666
377
  allowed_models = resolved["allowed_models"] || {}
@@ -679,7 +390,7 @@ def detect_model(project_config, tags: [], text: "")
679
390
  resolved["agent_model"]
680
391
  end
681
392
 
682
- # 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).
683
394
  # Returns the effort level string (e.g. "high") or nil.
684
395
  # If the requested level isn't supported by the current model, returns the closest
685
396
  # lower level from allowed_efforts.
@@ -687,20 +398,16 @@ def detect_effort(project_config, tags: [], text: "")
687
398
  resolved = resolve_project_cli_config(project_config)
688
399
  allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
689
400
 
690
- # Inline tag: [effort:high]
401
+ # Inline tag: [effort:high] — works in any channel
691
402
  if (match = text.match(/\[effort:(\w+)\]/i))
692
403
  level = match[1].downcase
693
404
  return resolve_effort_level(level, allowed) if allowed.include?(level)
694
405
  end
695
406
 
696
- # Fizzy card tags: effort-high, effort-max
697
- tags.each do |tag|
698
- name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
699
- if name.start_with?("effort-")
700
- level = name.sub("effort-", "")
701
- return resolve_effort_level(level, allowed) if allowed.include?(level)
702
- end
703
- 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
704
411
 
705
412
  resolved["agent_effort"]
706
413
  end