brainiac 0.0.9 → 0.0.11

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.
@@ -8,8 +8,18 @@ _brainiac() {
8
8
 
9
9
  local brainiac_dir="${BRAINIAC_DIR:-$HOME/.brainiac}"
10
10
 
11
- # Top-level commands
12
- local commands="server stop restart logs status register unregister list show brain discord cron provider role agent config path version help setup projects card-map handler"
11
+ # Top-level commands (built-in + installed plugins)
12
+ local commands="server stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects card-map handler plugin install uninstall plugins"
13
+
14
+ # Add installed plugin names as top-level commands
15
+ if [[ -f "$brainiac_dir/plugins.json" ]]; then
16
+ local plugin_names
17
+ plugin_names=$(ruby -rjson -e '
18
+ config = JSON.parse(File.read(ARGV[0]))
19
+ (config["plugins"] || []).each { |p| puts p.is_a?(Hash) ? p["name"] : p.to_s }
20
+ ' "$brainiac_dir/plugins.json" 2>/dev/null)
21
+ commands="$commands $plugin_names"
22
+ fi
13
23
 
14
24
  # Helper: list agent keys from registry
15
25
  _brainiac_agents() {
@@ -32,6 +42,13 @@ _brainiac() {
32
42
  fi
33
43
  }
34
44
 
45
+ # Helper: list project keys
46
+ _brainiac_projects() {
47
+ if [[ -f "$brainiac_dir/projects.json" ]]; then
48
+ ruby -rjson -e 'JSON.parse(File.read(ARGV[0])).each_key { |k| puts k }' "$brainiac_dir/projects.json" 2>/dev/null
49
+ fi
50
+ }
51
+
35
52
  # Determine position in command
36
53
  case $cword in
37
54
  1)
@@ -43,6 +60,14 @@ _brainiac() {
43
60
  local cmd="${words[1]}"
44
61
 
45
62
  case "$cmd" in
63
+ plugin)
64
+ case $cword in
65
+ 2)
66
+ COMPREPLY=($(compgen -W "new" -- "$cur"))
67
+ ;;
68
+ esac
69
+ ;;
70
+
46
71
  role)
47
72
  case $cword in
48
73
  2)
@@ -90,7 +115,6 @@ _brainiac() {
90
115
  4)
91
116
  local subcmd="${words[3]}"
92
117
  if [[ "$subcmd" == "env" ]]; then
93
- # Suggest --delete or existing env var names for this agent
94
118
  local agent_key="${words[2]}"
95
119
  local env_keys=""
96
120
  if [[ -f "$brainiac_dir/agents.json" ]]; then
@@ -104,7 +128,6 @@ _brainiac() {
104
128
  fi
105
129
  ;;
106
130
  5)
107
- # After --delete, suggest env var names
108
131
  if [[ "${words[3]}" == "env" && "${words[4]}" == "--delete" ]]; then
109
132
  local agent_key="${words[2]}"
110
133
  local env_keys=""
@@ -149,20 +172,6 @@ _brainiac() {
149
172
  esac
150
173
  ;;
151
174
 
152
- discord)
153
- case $cword in
154
- 2)
155
- COMPREPLY=($(compgen -W "config default map owner token agents status" -- "$cur"))
156
- ;;
157
- 3)
158
- local subcmd="${words[2]}"
159
- if [[ "$subcmd" == "token" ]]; then
160
- COMPREPLY=($(compgen -W "$(_brainiac_agents)" -- "$cur"))
161
- fi
162
- ;;
163
- esac
164
- ;;
165
-
166
175
  cron)
167
176
  case $cword in
168
177
  2)
@@ -176,8 +185,74 @@ _brainiac() {
176
185
  2)
177
186
  COMPREPLY=($(compgen -W "list default" -- "$cur"))
178
187
  ;;
188
+ 3)
189
+ if [[ "${words[2]}" == "default" ]]; then
190
+ COMPREPLY=($(compgen -W "$(_brainiac_projects)" -- "$cur"))
191
+ fi
192
+ ;;
179
193
  esac
180
194
  ;;
195
+
196
+ install)
197
+ # Suggest known plugin names that aren't installed
198
+ if [[ $cword -eq 2 ]]; then
199
+ COMPREPLY=($(compgen -W "--path --version" -- "$cur"))
200
+ fi
201
+ ;;
202
+
203
+ uninstall)
204
+ if [[ $cword -eq 2 && -f "$brainiac_dir/plugins.json" ]]; then
205
+ local installed
206
+ installed=$(ruby -rjson -e '
207
+ config = JSON.parse(File.read(ARGV[0]))
208
+ (config["plugins"] || []).each { |p| puts p.is_a?(Hash) ? p["name"] : p.to_s }
209
+ ' "$brainiac_dir/plugins.json" 2>/dev/null)
210
+ COMPREPLY=($(compgen -W "$installed" -- "$cur"))
211
+ fi
212
+ ;;
213
+
214
+ *)
215
+ # Check if cmd is an installed plugin — delegate completion to it
216
+ if [[ -f "$brainiac_dir/plugins.json" ]]; then
217
+ local is_plugin
218
+ is_plugin=$(ruby -rjson -e '
219
+ config = JSON.parse(File.read(ARGV[0]))
220
+ names = (config["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
221
+ puts "yes" if names.include?(ARGV[1])
222
+ ' "$brainiac_dir/plugins.json" "$cmd" 2>/dev/null)
223
+
224
+ if [[ "$is_plugin" == "yes" && $cword -eq 2 ]]; then
225
+ # Get plugin subcommands via its CLI module
226
+ local subcmds
227
+ subcmds=$(ruby -rjson -e '
228
+ brainiac_dir = ENV["BRAINIAC_DIR"] || File.join(Dir.home, ".brainiac")
229
+ config = JSON.parse(File.read(File.join(brainiac_dir, "plugins.json")))
230
+ entry = (config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == ARGV[0] }
231
+ exit unless entry
232
+
233
+ module Brainiac; module Plugins; end; end
234
+
235
+ if entry.is_a?(Hash) && entry["path"]
236
+ $LOAD_PATH.unshift(File.join(entry["path"], "lib"))
237
+ end
238
+
239
+ cli_file = if entry.is_a?(Hash) && entry["path"]
240
+ File.join(entry["path"], "lib", "brainiac", "plugins", ARGV[0], "cli.rb")
241
+ end
242
+
243
+ if cli_file && File.exist?(cli_file)
244
+ load cli_file
245
+ pascal = ARGV[0].split(/[-_]/).map(&:capitalize).join
246
+ mod = Brainiac::Plugins.const_get(pascal) if Brainiac::Plugins.const_defined?(pascal)
247
+ if mod&.respond_to?(:completions)
248
+ puts mod.completions.join("\n")
249
+ end
250
+ end
251
+ ' "$cmd" 2>/dev/null)
252
+ COMPREPLY=($(compgen -W "$subcmds" -- "$cur"))
253
+ fi
254
+ fi
255
+ ;;
181
256
  esac
182
257
  }
183
258
 
data/brainiac.gemspec CHANGED
@@ -4,7 +4,8 @@ Gem::Specification.new do |s|
4
4
  s.name = "brainiac"
5
5
  s.version = Brainiac::VERSION
6
6
  s.summary = "AI agent webhook receiver and dispatcher"
7
- s.description = "Webhook receiver that listens for Fizzy, GitHub, Discord, and Zoho Mail events, then dispatches work to AI agent CLIs."
7
+ s.description = "Webhook receiver that listens for GitHub and Zoho Mail events, then dispatches work to AI agent CLIs. " \
8
+ "Additional channels (Discord, Fizzy) available via plugins."
8
9
  s.authors = ["Andy Davis"]
9
10
  s.homepage = "https://github.com/stowzilla/brainiac"
10
11
  s.license = "MIT"
@@ -16,7 +17,6 @@ Gem::Specification.new do |s|
16
17
  s.add_dependency "puma", "~> 7.2"
17
18
  s.add_dependency "rackup", "~> 2.3"
18
19
  s.add_dependency "sinatra", "~> 4.1"
19
- s.add_dependency "websocket-client-simple", "~> 0.8.0"
20
20
 
21
21
  s.add_development_dependency "minitest", "~> 5.25"
22
22
  s.add_development_dependency "rake", "~> 13.0"
@@ -172,19 +172,6 @@ DEFAULT_PROJECT = {
172
172
  "allowed_efforts" => %w[low medium high xhigh max]
173
173
  }.freeze
174
174
 
175
- # --- Discord (optional) ---
176
- # Discord is enabled when any agent in the registry has a discord_bot_token
177
- # Requires the websocket-client-simple gem.
178
-
179
- DISCORD_ENABLED = begin
180
- require "websocket-client-simple"
181
- true
182
- rescue LoadError
183
- warn "WARNING: websocket-client-simple gem not found. Discord bot disabled."
184
- warn "Install with: gem install websocket-client-simple"
185
- false
186
- end
187
-
188
175
  # --- Version check ---
189
176
 
190
177
  # Check if local brainiac is behind origin/master.
@@ -210,13 +197,13 @@ def check_brainiac_version
210
197
  { behind: true, local_sha: local_sha[0..6], remote_sha: remote_sha[0..6], commits_behind: count.strip.to_i }
211
198
  end
212
199
 
213
- # Discord user ID of the machine owner (for version-outdated notifications).
214
- # Reads from discord.json (Discord-scoped config).
215
- def owner_discord_id
216
- discord_file = File.join(BRAINIAC_DIR, "discord.json")
217
- return nil unless File.exist?(discord_file)
200
+ # Owner identifier (for version-outdated notifications).
201
+ # Reads from brainiac.json.
202
+ def owner_id
203
+ brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
204
+ return nil unless File.exist?(brainiac_config_file)
218
205
 
219
- JSON.parse(File.read(discord_file))["owner_discord_id"]
206
+ JSON.parse(File.read(brainiac_config_file))["owner_id"]
220
207
  rescue JSON::ParserError
221
208
  nil
222
209
  end
@@ -224,8 +211,9 @@ end
224
211
  # --- Dashboard auth ---
225
212
 
226
213
  DASHBOARD_TOKEN = begin
227
- discord_file = File.join(BRAINIAC_DIR, "discord.json")
228
- JSON.parse(File.read(discord_file))["dashboard_token"] if File.exist?(discord_file)
214
+ brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
215
+ config = File.exist?(brainiac_config_file) ? JSON.parse(File.read(brainiac_config_file)) : {}
216
+ config["dashboard_token"]
229
217
  rescue JSON::ParserError
230
218
  nil
231
219
  end
data/lib/brainiac/cron.rb CHANGED
@@ -200,12 +200,17 @@ end
200
200
 
201
201
  # Add a new cron job
202
202
  def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil,
203
+ notify_channel: nil, notify_target: nil,
203
204
  forum_title: nil, forum_reply_to_latest: false, repeat_count: nil)
204
205
  parsed = parse_cron_expression(schedule)
205
206
  return { error: "Invalid cron expression" } unless parsed
206
207
  return { error: "Must provide either prompt or script, not both" } if prompt && script
207
208
  return { error: "Must provide either prompt or script" } unless prompt || script
208
209
 
210
+ # Normalize: discord_channel_id is legacy shorthand for notify_channel: discord, notify_target: <id>
211
+ notify_channel ||= :discord if discord_channel_id
212
+ notify_target ||= discord_channel_id
213
+
209
214
  job = {
210
215
  id: id,
211
216
  schedule: schedule,
@@ -217,6 +222,8 @@ def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, ena
217
222
  prompt: prompt,
218
223
  script: script,
219
224
  enabled: enabled,
225
+ notify_channel: notify_channel&.to_s,
226
+ notify_target: notify_target,
220
227
  discord_channel_id: discord_channel_id,
221
228
  forum_title: forum_title,
222
229
  forum_reply_to_latest: forum_reply_to_latest,
@@ -308,7 +315,7 @@ def execute_script_job(job, project)
308
315
  log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
309
316
  FileUtils.mkdir_p(File.dirname(log_file))
310
317
 
311
- draft_file = prepare_script_discord_draft(job, timestamp) if job[:discord_channel_id]
318
+ draft_file = prepare_script_notification_draft(job, timestamp) if job[:notify_target] || job[:discord_channel_id]
312
319
 
313
320
  LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}"
314
321
 
@@ -327,16 +334,17 @@ def execute_script_job(job, project)
327
334
  end
328
335
  end
329
336
 
330
- # Prepare a Discord draft file and meta for a script job. Returns the draft file path.
331
- def prepare_script_discord_draft(job, timestamp)
332
- draft_file = File.join(DISCORD_DRAFT_DIR, "cron-script-#{timestamp}-#{job[:id]}.md")
337
+ # Prepare a notification draft file for a script job. Returns the draft file path.
338
+ def prepare_script_notification_draft(job, timestamp)
339
+ notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
340
+ FileUtils.mkdir_p(notify_dir)
341
+ draft_file = File.join(notify_dir, "cron-script-#{timestamp}-#{job[:id]}.md")
333
342
  meta_file = "#{draft_file}.meta.json"
334
343
 
335
- FileUtils.mkdir_p(File.dirname(draft_file))
336
-
337
344
  script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
338
345
  meta = {
339
- channel_id: job[:discord_channel_id],
346
+ notify_channel: (job[:notify_channel] || "discord").to_s,
347
+ notify_target: job[:notify_target] || job[:discord_channel_id],
340
348
  agent_key: script_agent_key,
341
349
  agent_name: job[:agent] || "Script",
342
350
  cron_job_id: job[:id],
@@ -353,10 +361,13 @@ def deliver_script_output(job, log_file, draft_file)
353
361
  return unless File.exist?(log_file)
354
362
 
355
363
  output = File.read(log_file).strip
364
+ has_notify = job[:notify_target] || job[:discord_channel_id]
356
365
 
357
- if job[:discord_channel_id] && draft_file && !output.empty?
366
+ if has_notify && draft_file && !output.empty?
358
367
  File.write(draft_file, output)
359
- LOG.info "[Cron] Script output written to #{draft_file} (#{output.length} chars)"
368
+ # Deliver via notification system
369
+ notify_cron_output(job, output, agent_name: job[:agent])
370
+ LOG.info "[Cron] Script output delivered via notification (#{output.length} chars)"
360
371
  elsif !output.empty?
361
372
  LOG.info "[Cron] Script output: #{output[0..200]}..."
362
373
  else
@@ -369,15 +380,22 @@ def build_cron_prompt(job, project)
369
380
  prompt = job[:prompt]
370
381
  agent_name = job[:agent]
371
382
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
383
+ has_notify = job[:notify_target] || job[:discord_channel_id]
372
384
 
373
- if job[:discord_channel_id]
374
- draft_file = File.join(DISCORD_DRAFT_DIR, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
385
+ if has_notify
386
+ notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
387
+ FileUtils.mkdir_p(notify_dir)
388
+ draft_file = File.join(notify_dir, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
375
389
  meta_file = "#{draft_file}.meta.json"
376
- FileUtils.mkdir_p(File.dirname(draft_file))
377
390
 
378
391
  agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
392
+ notify_channel = (job[:notify_channel] || "discord").to_s
393
+ notify_target = job[:notify_target] || job[:discord_channel_id]
394
+
379
395
  meta = {
380
- channel_id: job[:discord_channel_id],
396
+ notify_channel: notify_channel,
397
+ notify_target: notify_target,
398
+ channel_id: notify_target,
381
399
  agent_key: agent_key,
382
400
  agent_name: agent_name,
383
401
  cron_job_id: job[:id],
@@ -388,8 +406,8 @@ def build_cron_prompt(job, project)
388
406
  File.write(meta_file, JSON.pretty_generate(meta))
389
407
 
390
408
  full_prompt = <<~PROMPT
391
- ## Scheduled Task (Discord Posting)
392
- This is a scheduled cron job that will post to Discord channel #{job[:discord_channel_id]}.
409
+ ## Scheduled Task (Notification Posting)
410
+ This is a scheduled cron job. Output will be posted to #{notify_channel} (#{notify_target}).
393
411
 
394
412
  You were asked to: "#{prompt}"
395
413
 
@@ -397,7 +415,7 @@ def build_cron_prompt(job, project)
397
415
  Source directory: #{project["repo_path"]}
398
416
 
399
417
  **IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.**
400
- Your response will be automatically posted to Discord.
418
+ Your response will be automatically posted.
401
419
 
402
420
  #{prompt}
403
421
  PROMPT
@@ -428,16 +446,13 @@ def handle_cron_completion(job, project, agent_name, agent_config_name, log_file
428
446
  cron_exit_status = $CHILD_STATUS.exitstatus
429
447
  LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})"
430
448
 
431
- if cron_exit_status && cron_exit_status != 0 && job[:discord_channel_id]
432
- bot_token = discord_bot_tokens[agent_config_name] || discord_bot_tokens.values.first
433
- if bot_token
434
- notify_agent_crash(
435
- exit_status: cron_exit_status, log_file: log_file,
436
- agent_name: agent_name, source: :discord,
437
- source_context: { channel_id: job[:discord_channel_id], bot_token: bot_token },
438
- project_config: project
439
- )
440
- end
449
+ if cron_exit_status && cron_exit_status != 0 && job[:notify_target]
450
+ notify_agent_crash(
451
+ exit_status: cron_exit_status, log_file: log_file,
452
+ agent_name: agent_name, source: :cron,
453
+ source_context: { job: job },
454
+ project_config: project
455
+ )
441
456
  end
442
457
 
443
458
  extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
@@ -249,44 +249,19 @@ def close_uat_cards_after_deploy(project_key, project_config)
249
249
  end
250
250
 
251
251
  def send_deploy_notification(project_key, closed_cards)
252
- channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
253
- return unless channel_id
254
-
255
- token = discord_bot_tokens.values.first
256
- return unless token
257
-
258
252
  card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n")
259
253
  message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"
260
-
261
- send_discord_message(channel_id, message, token: token)
262
- rescue StandardError => e
263
- LOG.warn "Failed to send deploy notification: #{e.message}"
254
+ send_notification(:deploy, message, metadata_project: project_key)
264
255
  end
265
256
 
266
257
  def send_uat_deploy_notification(project_key)
267
- channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
268
- return unless channel_id
269
-
270
- token = discord_bot_tokens.values.first
271
- return unless token
272
-
273
258
  message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
274
- send_discord_message(channel_id, message, token: token)
275
- rescue StandardError => e
276
- LOG.warn "Failed to send UAT deploy notification: #{e.message}"
259
+ send_notification(:deploy, message, metadata_project: project_key)
277
260
  end
278
261
 
279
262
  def send_workflow_failure_notification(project_key, workflow_name, run_url)
280
- channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
281
- return unless channel_id
282
-
283
- token = discord_bot_tokens.values.first
284
- return unless token
285
-
286
263
  message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
287
- send_discord_message(channel_id, message, token: token)
288
- rescue StandardError => e
289
- LOG.warn "Failed to send workflow failure notification: #{e.message}"
264
+ send_notification(:ci_failure, message, metadata_project: project_key)
290
265
  end
291
266
 
292
267
  def handle_github_issue_opened(payload)
@@ -164,27 +164,21 @@ def format_zoho_notification(email, rule)
164
164
  parts.join("\n")
165
165
  end
166
166
 
167
- # Send the notification to the configured Discord channel.
167
+ # Send the notification to the configured channel.
168
168
  def notify_zoho_match(email, rule)
169
- channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"]
170
- unless channel_id
171
- LOG.warn "[Zoho] No discord_channel_id configured for rule '#{rule["label"]}' and no default set"
169
+ target = rule["notify_target"] || rule["discord_channel_id"] || ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
170
+ channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
171
+
172
+ unless target
173
+ LOG.warn "[Zoho] No notify_target configured for rule '#{rule["label"]}' and no default set"
172
174
  return
173
175
  end
174
176
 
175
177
  message = format_zoho_notification(email, rule)
178
+ agent = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
176
179
 
177
- tokens = discord_bot_tokens
178
- bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first
179
- token = tokens[bot_name&.downcase] || tokens.values.first
180
-
181
- unless token
182
- LOG.warn "[Zoho] No Discord bot token available to send notification"
183
- return
184
- end
185
-
186
- LOG.info "[Zoho] Sending notification to channel #{channel_id} (bot: #{bot_name})"
187
- send_discord_message(channel_id, message, token: token)
180
+ LOG.info "[Zoho] Sending notification via #{channel} to #{target}"
181
+ send_notification(:zoho_email, message, channel: channel, target: target, agent: agent)
188
182
  end
189
183
 
190
184
  # ---------------------------------------------------------------------------
@@ -360,20 +354,20 @@ end
360
354
 
361
355
  # Act on the triage decision
362
356
  def execute_zoho_triage_decision(decision, email, rule)
363
- channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"]
364
- tokens = discord_bot_tokens
365
- bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first
366
- token = tokens[bot_name&.downcase] || tokens.values.first
357
+ channel_id = rule["notify_target"] || rule["discord_channel_id"] ||
358
+ ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
359
+ notify_channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
360
+ bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
367
361
 
368
362
  case decision["decision"]
369
363
  when "create_card"
370
- create_zoho_triage_card(decision, email, channel_id, token)
364
+ create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
371
365
  when "skip"
372
366
  msg = "📧 **Support Email — No Card Needed**\n"
373
367
  msg += "**Subject:** #{email["subject"]}\n"
374
368
  msg += "**From:** #{email["fromAddress"]}\n"
375
369
  msg += "**Reason:** #{decision["reason"]}"
376
- send_discord_message(channel_id, msg, token: token) if channel_id && token
370
+ send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
377
371
  LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}"
378
372
  when "borderline"
379
373
  msg = "⚠️ **Support Email — Needs Human Decision**\n"
@@ -382,8 +376,8 @@ def execute_zoho_triage_decision(decision, email, rule)
382
376
  msg += "**Why borderline:** #{decision["reason"]}\n"
383
377
  summary = (email["summary"] || "").to_s[0..300]
384
378
  msg += "```\n#{summary}\n```" unless summary.empty?
385
- send_discord_message(channel_id, msg, token: token) if channel_id && token
386
- LOG.info "[Zoho:Triage] Borderline — posted to Discord for human decision"
379
+ send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
380
+ LOG.info "[Zoho:Triage] Borderline — posted for human decision"
387
381
  else
388
382
  LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}"
389
383
  notify_zoho_match(email, rule)
@@ -391,7 +385,7 @@ def execute_zoho_triage_decision(decision, email, rule)
391
385
  end
392
386
 
393
387
  # Create a card from the triage decision
394
- def create_zoho_triage_card(decision, email, channel_id, token)
388
+ def create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
395
389
  board_id = ZOHO_CONFIG["triage_board_id"]
396
390
  unless board_id
397
391
  LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json"
@@ -418,15 +412,13 @@ def create_zoho_triage_card(decision, email, channel_id, token)
418
412
  card_url = card_info[:url]
419
413
  LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
420
414
 
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
415
+ # Notify configured channel
416
+ msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
417
+ msg += "**Title:** #{title}\n"
418
+ msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
419
+ msg += "**Tags:** #{tags.join(", ")}\n"
420
+ msg += "**From:** #{email["fromAddress"]}"
421
+ send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
430
422
  else
431
423
  LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
432
424
  notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
@@ -186,17 +186,17 @@ def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_con
186
186
  snippet = extract_crash_snippet(log_file)
187
187
  snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
188
188
 
189
- # Try plugin-registered crash handlers first
189
+ # Emit to plugins they handle their own channel-specific delivery
190
190
  handled = Brainiac.emit(:agent_crashed,
191
191
  exit_status: exit_status, log_file: log_file, agent_name: agent_display,
192
192
  source: source, source_context: source_context, project_config: project_config,
193
193
  snippet: snippet)
194
194
 
195
- # If a plugin handled it for this source, we're done
196
- return if handled.any?(source)
195
+ # If a plugin handled it, we're done
196
+ return if handled.any?
197
197
 
198
- case source
199
- when :github
198
+ # Built-in: GitHub crash comment (doesn't need a plugin)
199
+ if source == :github
200
200
  pr_number = source_context[:pr_number]
201
201
  repo_name = source_context[:repo_name]
202
202
  return unless pr_number && repo_name
@@ -209,16 +209,6 @@ def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_con
209
209
  rescue StandardError => e
210
210
  LOG.error "[CrashNotify] Failed to post GitHub crash comment: #{e.message}"
211
211
  end
212
-
213
- when :discord
214
- channel_id = source_context[:channel_id]
215
- message_id = source_context[:message_id]
216
- bot_token = source_context[:bot_token]
217
- return unless channel_id && bot_token
218
-
219
- message = "💥 **#{agent_display} crashed** (exit code #{exit_status})\nLog: `#{log_file}`#{snippet_block}"
220
- send_discord_message(channel_id, message, token: bot_token, reply_to: message_id)
221
- LOG.info "[CrashNotify] Posted crash message to Discord channel #{channel_id}"
222
212
  end
223
213
  rescue StandardError => e
224
214
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
@@ -358,7 +348,7 @@ def handle_agent_completion(**ctx)
358
348
  end
359
349
 
360
350
  brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
361
- check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
351
+ # check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
362
352
  end
363
353
 
364
354
  def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)