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
@@ -15,12 +15,10 @@ BRAINIAC_VERSION = Brainiac::VERSION
15
15
 
16
16
  # --- Environment & paths ---
17
17
 
18
- FIZZY_WEBHOOK_SECRET = ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
19
-
20
18
  BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
21
19
  PROJECTS_FILE = File.join(BRAINIAC_DIR, "projects.json")
22
20
  KIRO_AGENTS_DIR = File.join(Dir.home, ".kiro", "agents")
23
- CARD_MAP_FILE = File.join(BRAINIAC_DIR, "card_map.json")
21
+ WORK_ITEM_MAP_FILE = File.join(BRAINIAC_DIR, "work_items.json")
24
22
  AGENT_REGISTRY_FILE = File.join(BRAINIAC_DIR, "agents.json")
25
23
 
26
24
  # --- Master config (handler toggles, global settings) ---
@@ -80,21 +78,6 @@ KNOWLEDGE_COLLECTION = "brainiac-knowledge"
80
78
 
81
79
  ROLES_DIR = File.join(BRAINIAC_DIR, "roles")
82
80
 
83
- # --- Fizzy auth ---
84
-
85
- FIZZY_CONFIG_FILE = File.join(BRAINIAC_DIR, "fizzy.json")
86
-
87
- def load_fizzy_config
88
- return {} unless File.exist?(FIZZY_CONFIG_FILE)
89
-
90
- JSON.parse(File.read(FIZZY_CONFIG_FILE))
91
- rescue JSON::ParserError => e
92
- LOG.error "Failed to parse Fizzy config: #{e.message}"
93
- {}
94
- end
95
-
96
- FIZZY_CONFIG = load_fizzy_config
97
-
98
81
  # --- GitHub auth ---
99
82
 
100
83
  GITHUB_CONFIG_FILE = File.join(BRAINIAC_DIR, "github.json")
@@ -115,53 +98,6 @@ def github_webhook_secret
115
98
  GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
116
99
  end
117
100
 
118
- # --- Board config ---
119
-
120
- FIZZY_BOARDS = FIZZY_CONFIG["boards"] || {}
121
-
122
- def board_config(board_key)
123
- FIZZY_BOARDS[board_key.to_s]
124
- end
125
-
126
- def board_webhook_secret(board_key)
127
- config = board_config(board_key)
128
- config&.dig("webhook_secret") || FIZZY_WEBHOOK_SECRET
129
- end
130
-
131
- def board_column_id(board_key, column_name)
132
- config = board_config(board_key)
133
- config&.dig("columns", column_name.to_s)
134
- end
135
-
136
- # Find board_key by board_id (from .fizzy.yaml or payload)
137
- def board_key_for_id(board_id)
138
- FIZZY_BOARDS.each do |key, config|
139
- return key if config["board_id"] == board_id
140
- end
141
- nil
142
- end
143
-
144
- # Determine board_key for a project by reading its .fizzy.yaml
145
- def board_key_for_project(project_config)
146
- fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml")
147
- return nil unless File.exist?(fizzy_yaml)
148
-
149
- require "yaml"
150
- data = YAML.safe_load_file(fizzy_yaml)
151
- board_id = data["board"]
152
- board_key_for_id(board_id)
153
- rescue StandardError => e
154
- LOG.warn "Could not read .fizzy.yaml for board lookup: #{e.message}"
155
- nil
156
- end
157
-
158
- # Build authorized user IDs from config or env var (env var overrides)
159
- AUTHORIZED_USER_IDS = if ENV["AUTHORIZED_USER_IDS"] && !ENV["AUTHORIZED_USER_IDS"].empty?
160
- ENV["AUTHORIZED_USER_IDS"].split(",").map(&:strip)
161
- else
162
- (FIZZY_CONFIG["authorized_users"] || []).map { |u| u["id"] }
163
- end
164
-
165
101
  NOTIFICATION_COMMAND = ENV.fetch("NOTIFICATION_COMMAND", nil)
166
102
 
167
103
  # --- Projects ---
@@ -212,13 +148,12 @@ PROJECTS = load_projects_config
212
148
 
213
149
  DEFAULT_PROJECT = {
214
150
  "repo_path" => ENV.fetch("REPO_PATH", Dir.pwd),
215
- "fizzy_tags" => [],
216
151
  "github_repo" => ENV.fetch("GITHUB_REPO", nil),
217
152
  # CLI defaults below are overridden by ~/.brainiac/cli-providers/*.json when a
218
153
  # cli_provider is configured on the project or agent. These only apply as a
219
154
  # last-resort fallback when no provider config exists.
220
155
  "agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"),
221
- "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --no-interactive"),
156
+ "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"),
222
157
  "agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model",
223
158
  "agent_model" => ENV.fetch("AGENT_MODEL", nil),
224
159
  "agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort",
@@ -120,7 +120,7 @@ def discord_mention_roster
120
120
  DISCORD_BOTS.each do |agent_key, info|
121
121
  next unless info[:user_id]
122
122
 
123
- display = fizzy_display_name(agent_key) || agent_key.capitalize
123
+ display = agent_display_name(agent_key) || agent_key.capitalize
124
124
  lines << " - #{display}: `<@#{info[:user_id]}>`"
125
125
  end
126
126
  end
@@ -124,7 +124,7 @@ def deliver_to_channel_thread(response, channel_id, message_id, agent_key, agent
124
124
 
125
125
  # Tier 3: No thread exists anywhere — create one.
126
126
  unless thread_id
127
- display_name = fizzy_display_name(agent_key)
127
+ display_name = agent_display_name(agent_key)
128
128
  thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
129
129
  if thread && thread["id"]
130
130
  thread_id = thread["id"]
@@ -15,7 +15,7 @@ DISCORD_ALL_READY_LOGGED = { done: false }
15
15
 
16
16
  def start_discord_gateway_for(agent_key, bot_token)
17
17
  Thread.new do
18
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
18
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
19
19
  bot_user_id = nil
20
20
 
21
21
  loop do
@@ -39,7 +39,7 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
39
39
  reply_context = build_reply_context(referenced_message)
40
40
  discord_user = author["username"]
41
41
  discord_user_id = author["id"]
42
- agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
42
+ agent_name = agent_display_name(agent_key) || agent_key.capitalize
43
43
 
44
44
  channel_info, is_thread, is_dm = ensure_channel_info(channel_info, is_thread, is_dm, channel_id, bot_token)
45
45
  parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
@@ -142,7 +142,7 @@ def dispatch_discord_session(agent_key:, agent_name:, bot_token:, channel_id:, m
142
142
  root_message = find_root_message(message, channel_id, bot_token)
143
143
  card_id = is_thread ? "discord-#{parent_channel_id}-#{channel_id}" : "discord-#{channel_id}-#{root_message[:id]}"
144
144
  thread_root_context = build_thread_root_context(is_thread, root_message, parent_channel_id, channel_id, bot_token)
145
- planning_info = detect_planning_mode(text: clean_content, tags: [], card_internal_id: card_id, card_number: nil)
145
+ planning_info = false
146
146
  brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
147
147
 
148
148
  should_resume, thread_worktree_path, thread_cli_provider, thread_model, thread_effort = manage_discord_worktree(
@@ -348,16 +348,16 @@ def validate_cross_agent_dispatch(sender_agent_key, agent_key, mentioned, conten
348
348
  return false unless mentioned
349
349
 
350
350
  if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
351
- sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
352
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
353
- LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — Fizzy card creation/assignment (handled by webhook)"
351
+ sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
352
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
353
+ LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — card creation/assignment (handled by webhook)"
354
354
  return false
355
355
  end
356
356
 
357
357
  depth_key = "discord-#{channel_id}"
358
358
  unless agent_dispatch_allowed?(depth_key)
359
- sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
360
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
359
+ sender_display = agent_display_name(sender_agent_key) || sender_agent_key.capitalize
360
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
361
361
  LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
362
362
  return false
363
363
  end
@@ -425,7 +425,7 @@ def should_stand_down?(in_own_thread, mentioned, is_reply_to_bot, is_bot, agent_
425
425
  end
426
426
 
427
427
  if other_bot_mentioned
428
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
428
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
429
429
  LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
430
430
  end
431
431
 
@@ -435,7 +435,7 @@ end
435
435
  def download_attachments(message, message_id, agent_key)
436
436
  attachments = message["attachments"] || []
437
437
  paths = []
438
- agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
438
+ agent_display = agent_display_name(agent_key) || agent_key.capitalize
439
439
 
440
440
  attachments.each do |att|
441
441
  url = att["url"]
@@ -645,7 +645,7 @@ def manage_discord_worktree(agent_key:, agent_name:, channel_id:, message_id:, i
645
645
  end
646
646
 
647
647
  def pre_create_discord_thread(agent_key, agent_name, channel_id, message_id, clean_content, bot_token)
648
- display_name = fizzy_display_name(agent_key)
648
+ display_name = agent_display_name(agent_key)
649
649
  thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
650
650
 
651
651
  unless thread && thread["id"]
@@ -27,7 +27,7 @@ def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
27
27
  emoji = reaction_data["emoji"]
28
28
  emoji_name = emoji["name"]
29
29
 
30
- agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
30
+ agent_name = agent_display_name(agent_key) || agent_key.capitalize
31
31
 
32
32
  # Ignore reactions from bots (including self)
33
33
  return if user_id == bot_user_id
@@ -5,14 +5,9 @@
5
5
  # Fallback column ID for backwards compatibility when no board config exists
6
6
  DEFAULT_UAT_COLUMN_ID = "03fsmglsr6az06ppyotawsti8"
7
7
 
8
- def uat_column_id(project_config)
9
- bk = board_key_for_project(project_config)
10
- (bk && board_column_id(bk, "uat")) || DEFAULT_UAT_COLUMN_ID
11
- end
12
-
13
- # Find a Fizzy card by matching the PR's head branch to a branch in the card map.
14
- def find_card_by_branch(branch)
15
- map = load_card_map
8
+ # Find a work item by matching the PR's head branch to a branch in the card map.
9
+ def find_work_item_by_branch(branch)
10
+ map = load_work_item_map
16
11
  map.each do |internal_id, info|
17
12
  next unless info["branch"] == branch
18
13
 
@@ -21,14 +16,14 @@ def find_card_by_branch(branch)
21
16
  nil
22
17
  end
23
18
 
24
- # Track a newly opened PR in the card map by matching its branch.
25
- def track_pr_in_card_map(payload)
19
+ # Track a newly opened PR in the work item map by matching its branch.
20
+ def track_pr_in_work_items(payload)
26
21
  pr = payload["pull_request"]
27
22
  branch = pr.dig("head", "ref")
28
23
  pr_number = pr["number"]
29
24
  pr_url = pr["html_url"]
30
25
 
31
- result = find_card_by_branch(branch)
26
+ result = find_work_item_by_branch(branch)
32
27
  unless result
33
28
  LOG.info "[PR Track] No card found for branch #{branch}"
34
29
  return
@@ -41,9 +36,9 @@ def track_pr_in_card_map(payload)
41
36
  prs << { "number" => pr_number, "url" => pr_url }
42
37
  card_info["prs"] = prs
43
38
 
44
- map = load_card_map
39
+ map = load_work_item_map
45
40
  map[internal_id] = card_info
46
- save_card_map(map)
41
+ save_work_item_map(map)
47
42
  LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
48
43
  end
49
44
 
@@ -57,15 +52,6 @@ rescue StandardError => e
57
52
  end
58
53
 
59
54
  # Check if a PR link is already present in the card's comments.
60
- def pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env)
61
- output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: chdir, env: env)
62
- data = JSON.parse(output)
63
- comments = data["data"] || []
64
- comments.any? { |c| (c.dig("body", "plain_text") || "").include?(pr_url) }
65
- rescue StandardError => e
66
- LOG.warn "Could not check existing comments for card ##{card_number}: #{e.message}"
67
- false
68
- end
69
55
 
70
56
  def handle_github_pr_merged(payload)
71
57
  pr = payload["pull_request"]
@@ -90,9 +76,9 @@ def handle_github_pr_merged(payload)
90
76
  project_key, project_config = project_result
91
77
  repo_path = project_config["repo_path"]
92
78
 
93
- result = find_card_by_branch(branch)
79
+ result = find_work_item_by_branch(branch)
94
80
  unless result
95
- LOG.info "No Fizzy card found for branch #{branch}"
81
+ LOG.info "No card found for branch #{branch}"
96
82
  return [200, { status: "ignored", reason: "no matching card" }.to_json]
97
83
  end
98
84
 
@@ -113,38 +99,21 @@ rescue StandardError => e
113
99
  end
114
100
 
115
101
  def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
116
- card_agent = card_info["agent"]
117
- card_fizzy_env = fizzy_env_for(card_agent)
118
-
119
- unless pr_link_already_commented?(card_number, pr_url, chdir: repo_path, env: card_fizzy_env)
120
- comment_body = "<p>PR merged into main: <a href=\"#{pr_url}\">#{pr_title}</a></p><p>Branch: <code>#{branch}</code></p>"
121
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", comment_body, chdir: repo_path, env: card_fizzy_env)
122
- end
123
-
124
- mark_card_merged(card_number)
125
- run_cmd("fizzy", "card", "column", card_number.to_s, "--column", uat_column_id(project_config), chdir: repo_path, env: card_fizzy_env)
126
- record_self_move(card_number)
127
- cleanup_card_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
128
- clear_deployment_for_card(card_number)
129
-
130
- agent_name = card_agent || agent_name_for(project_config)
131
- card_title = card_info["title"] || pr_title
132
- dispatch_uat_agent(card_number, card_title, pull_request["number"].to_s, agent_name, project_key, project_config, repo_path)
133
- end
134
-
135
- def dispatch_uat_agent(card_number, card_title, pr_number, agent_name, project_key, project_config, repo_path)
136
- prompt = render_prompt(PROMPT_GITHUB_UAT,
137
- { "CARD_NUMBER" => card_number, "CARD_TITLE" => card_title, "PR_NUMBER" => pr_number },
138
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
139
- card_title: card_title, project_key: project_key),
140
- agent_name: agent_name, channel: :fizzy,
141
- board_key: board_key_for_project(project_config))
142
-
143
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path,
144
- log_name: "uat-#{card_number}", agent_name: agent_name,
145
- source: :fizzy, source_context: { card_number: card_number }, skip_column_move: true)
146
- register_session("card-#{card_number}", pid, log_file: log_file, agent_name: agent_name)
147
- LOG.info "Dispatched #{agent_name} for UAT testing steps on card ##{card_number}"
102
+ mark_work_item_merged(card_number)
103
+ cleanup_work_item_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
104
+
105
+ # Emit hook plugins handle their own post-merge actions
106
+ # (e.g., card plugin posts PR link, moves card column, dispatches UAT agent)
107
+ Brainiac.emit(:pr_merged,
108
+ card_number: card_number,
109
+ card_info: card_info,
110
+ branch: branch,
111
+ pull_request: pull_request,
112
+ pr_url: pr_url,
113
+ pr_title: pr_title,
114
+ project_key: project_key,
115
+ project_config: project_config,
116
+ repo_path: repo_path)
148
117
  end
149
118
 
150
119
  def handle_github_issue_comment(payload)
@@ -173,9 +142,9 @@ def handle_github_issue_comment(payload)
173
142
  chdir: project_config["repo_path"])
174
143
  branch = JSON.parse(pr_data)["branch"]
175
144
 
176
- result = find_card_by_branch(branch)
145
+ result = find_work_item_by_branch(branch)
177
146
  unless result
178
- LOG.info "No Fizzy card found for PR ##{pr_number} (branch: #{branch})"
147
+ LOG.info "No card found for PR ##{pr_number} (branch: #{branch})"
179
148
  return [200, { status: "ignored", reason: "no matching card" }.to_json]
180
149
  end
181
150
 
@@ -265,54 +234,18 @@ rescue StandardError => e
265
234
  end
266
235
 
267
236
  def close_uat_cards_after_deploy(project_key, project_config)
268
- repo_path = project_config["repo_path"]
269
- output = run_cmd("fizzy", "card", "list", "--column", uat_column_id(project_config), "--all",
270
- chdir: repo_path, env: default_fizzy_env)
271
- card_list = JSON.parse(output)["data"] || []
272
-
273
- if card_list.empty?
274
- LOG.info "No cards in UAT column — nothing to close"
275
- return [200, { status: "processed", action: "no_uat_cards", project: project_key }.to_json]
276
- end
277
-
278
- closed_cards = close_and_cleanup_uat_cards(card_list, repo_path)
279
- send_deploy_notification(project_key, closed_cards) if closed_cards.any?
280
-
281
- LOG.info "Prod deploy complete — closed #{closed_cards.size} UAT cards: #{closed_cards.map { |c| c[:number] }.join(", ")}"
282
- [200, { status: "processed", action: "prod_deploy_closed_uat",
283
- closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
284
- end
285
-
286
- def close_and_cleanup_uat_cards(card_list, repo_path)
287
- closed_cards = []
288
- map = load_card_map
289
-
290
- card_list.each do |card|
291
- card_number = card["number"]
292
- next unless card_number
293
-
294
- map_entry = map.values.find { |info| info["number"] == card_number }
295
- agent_name = map_entry["agent"] if map_entry
296
- env = agent_name ? fizzy_env_for(agent_name) : default_fizzy_env
297
-
298
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s,
299
- "--body", "<p>✅ Deployed to production. Closing card.</p>", chdir: repo_path, env: env)
300
- run_cmd("fizzy", "card", "close", card_number.to_s, chdir: repo_path, env: env)
301
-
302
- cleanup_card_worktrees(card_number, repo_path: repo_path,
303
- primary_worktree: map_entry&.dig("worktree"), primary_branch: map_entry&.dig("branch"))
304
-
305
- if map_entry
306
- internal_id = map.key(map_entry)
307
- map.delete(internal_id)
308
- end
237
+ # Emit hook — work item plugins handle closing cards after production deploy
238
+ results = Brainiac.emit(:production_deployed, project_key: project_key, project_config: project_config)
239
+ closed_cards = results.flatten.compact
309
240
 
310
- closed_cards << { number: card_number, url: card["url"], title: card["title"] }
311
- LOG.info "Closed UAT card ##{card_number} after prod deploy (agent: #{agent_name || "default"})"
241
+ if closed_cards.any?
242
+ send_deploy_notification(project_key, closed_cards)
243
+ LOG.info "Prod deploy complete — closed #{closed_cards.size} cards"
244
+ else
245
+ LOG.info "Prod deploy processed — no cards closed (plugin may not be installed)"
312
246
  end
313
247
 
314
- save_card_map(map) if closed_cards.any?
315
- closed_cards
248
+ [200, { status: "processed", action: "prod_deploy", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
316
249
  end
317
250
 
318
251
  def send_deploy_notification(project_key, closed_cards)
@@ -385,7 +318,7 @@ def handle_github_pr_review_submitted(payload)
385
318
  project_key, project_config = project_result
386
319
  repo_path = project_config["repo_path"]
387
320
 
388
- result = find_card_by_branch(branch)
321
+ result = find_work_item_by_branch(branch)
389
322
  return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
390
323
 
391
324
  internal_id, card_info = result
@@ -417,13 +350,9 @@ def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, revi
417
350
  end
418
351
 
419
352
  agent_name = agent_name_for(project_config)
420
- Thread.new do
421
- status_comment = "<p>🔄 Code review received from @#{reviewer}. Updates in progress...</p>"
422
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", status_comment,
423
- chdir: repo_path, env: fizzy_env_for(agent_name))
424
- rescue StandardError => e
425
- LOG.warn "Could not post status update to card ##{card_number}: #{e.message}"
426
- end
353
+ # Notify work item system that a review was received (plugin posts status comment)
354
+ Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
355
+ agent_name: agent_name, project_config: project_config, repo_path: repo_path)
427
356
 
428
357
  review_context = build_review_context(reviewer, review, pr_number, repo_name)
429
358
  worktree = card_info["worktree"]
@@ -462,7 +391,7 @@ def handle_github_pr_synchronized(payload)
462
391
  pr = payload["pull_request"]
463
392
  branch = pr.dig("head", "ref")
464
393
 
465
- result = find_card_by_branch(branch)
394
+ result = find_work_item_by_branch(branch)
466
395
  return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
467
396
 
468
397
  _internal_id, card_info = result
@@ -471,65 +400,16 @@ def handle_github_pr_synchronized(payload)
471
400
 
472
401
  return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
473
402
 
474
- state = load_deployment_state
475
- config = DEPLOYMENTS_CONFIG["environments"] || {}
476
- env_key = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
477
-
478
- return [200, { status: "ignored", reason: "card not deployed" }.to_json] unless env_key
403
+ # Emit hook — deployment plugin handles sync-deploy logic
404
+ results = Brainiac.emit(:pr_synchronized, card_number: card_number, card_info: card_info,
405
+ worktree: worktree, pull_request: pr, branch: branch)
479
406
 
480
- env_owner = config.dig(env_key, "owner")
481
- return [200, { status: "ignored", reason: "not env owner" }.to_json] unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
482
-
483
- return [200, { status: "ignored", reason: "deploy cooldown" }.to_json] if on_deploy_cooldown?(env_key)
484
-
485
- touch_deploy_cooldown(env_key)
486
-
487
- system("git", "pull", "--ff-only", chdir: worktree)
488
- deploy_script = File.join(worktree, "scripts", "deploy.sh")
489
- return [200, { status: "ignored", reason: "no deploy script" }.to_json] unless File.exist?(deploy_script)
490
-
491
- LOG.info "[PR Sync] Auto-deploying card ##{card_number} to #{env_key} (PR updated)"
492
- mark_deploying(env_key, worktree_path: worktree)
493
- run_pr_sync_deploy(env_key, card_number, worktree, config)
494
-
495
- [200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json]
407
+ if results.any?
408
+ [200, { status: "processed", action: "pr_sync", card: card_number }.to_json]
409
+ else
410
+ [200, { status: "ignored", reason: "no deployment plugin" }.to_json]
411
+ end
496
412
  rescue StandardError => e
497
413
  LOG.error "[PR Sync] Error: #{e.message}"
498
414
  [500, { error: e.message }.to_json]
499
415
  end
500
-
501
- def run_pr_sync_deploy(env_key, card_number, worktree, config)
502
- Thread.new do
503
- deploy_env = {}
504
- aws_profile = config.dig(env_key, "aws_profile")
505
- deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
506
- deploy_script = File.join(worktree, "scripts", "deploy.sh")
507
-
508
- stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
509
-
510
- if status.success?
511
- deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
512
- LOG.info "[PR Sync] Deploy to #{env_key} succeeded for card ##{card_number}"
513
- elsif terraform_lock_error?(stdout, stderr)
514
- retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
515
- else
516
- record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
517
- LOG.error "[PR Sync] Deploy to #{env_key} failed for card ##{card_number}"
518
- end
519
- end
520
- end
521
-
522
- def retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
523
- infra_dir = File.join(worktree, "infrastructure/#{env_key}")
524
- FileUtils.rm_f(File.join(infra_dir, ".terraform.lock.hcl"))
525
- Open3.capture3("terraform", "init", "-upgrade", chdir: infra_dir)
526
- stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
527
-
528
- if status.success?
529
- deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
530
- LOG.info "[PR Sync] Deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
531
- else
532
- record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
533
- LOG.error "[PR Sync] Deploy to #{env_key} failed (after retry) for card ##{card_number}"
534
- end
535
- end
@@ -156,30 +156,17 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
156
156
  worktree_path
157
157
  end
158
158
 
159
- # Find an existing worktree for a card by scanning the filesystem.
160
- def find_worktree_for_card(card_number, repo_path:)
161
- return nil unless card_number
162
-
163
- repo_dir = File.dirname(repo_path)
164
- repo_base = File.basename(repo_path)
165
- candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
166
- return nil if candidates.empty?
167
-
168
- worktree = candidates.first
169
- branch = File.basename(worktree).sub("#{repo_base}--", "")
170
- { worktree: worktree, branch: branch }
171
- end
172
-
173
- # Clean up all worktrees associated with a card (primary + cross-agent review).
159
+ # Clean up all worktrees associated with a work item (primary + cross-agent review).
174
160
  # Safe: skips worktrees with uncommitted changes.
175
- def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
161
+ def cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
176
162
  return unless card_number
177
163
 
178
164
  repo_dir = File.dirname(repo_path)
179
165
  repo_base = File.basename(repo_path)
180
166
  cleaned = 0
181
167
 
182
- candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
168
+ # Find worktrees that contain the work item number in their name (any naming convention)
169
+ candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{card_number}*")).select { |d| File.directory?(d) }
183
170
  candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
184
171
 
185
172
  candidates.uniq.each do |wt_path|
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Shared inline tag parsing for handler messages.
4
4
  #
5
- # Both Discord and Fizzy messages can contain inline tags like:
5
+ # Messages from any channel can contain inline tags like:
6
6
  # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
7
7
  #
8
8
  # This module provides a single parser that extracts all tags and returns