brainiac 0.0.5 → 0.0.7

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +385 -19
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +15 -38
  7. data/lib/brainiac/config.rb +40 -11
  8. data/lib/brainiac/handlers/discord/api.rb +196 -0
  9. data/lib/brainiac/handlers/discord/config.rb +134 -0
  10. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  11. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  12. data/lib/brainiac/handlers/discord/message.rb +933 -0
  13. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  14. data/lib/brainiac/handlers/discord.rb +14 -1892
  15. data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
  16. data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
  17. data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
  18. data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
  19. data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
  20. data/lib/brainiac/handlers/fizzy.rb +12 -1290
  21. data/lib/brainiac/handlers/github.rb +149 -212
  22. data/lib/brainiac/handlers/shared/git.rb +203 -0
  23. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  24. data/lib/brainiac/handlers/zoho.rb +79 -76
  25. data/lib/brainiac/helpers.rb +2 -121
  26. data/lib/brainiac/planning.rb +2 -2
  27. data/lib/brainiac/plugins.rb +129 -0
  28. data/lib/brainiac/restart.rb +94 -0
  29. data/lib/brainiac/routes/api.rb +427 -0
  30. data/lib/brainiac/version.rb +1 -1
  31. data/lib/brainiac/zoho_mail_api.rb +2 -1
  32. data/lib/brainiac.rb +7 -0
  33. data/monitor/daemon.rb +4 -27
  34. data/monitor/shared.rb +247 -0
  35. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
  36. data/monitor/waybar/setup.rb +232 -0
  37. data/monitor/waybar/status.rb +51 -0
  38. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  39. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  40. data/monitor/xbar/plugin.rb +155 -0
  41. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  42. data/receiver.rb +91 -450
  43. data/templates/brainiac.json.example +9 -0
  44. data/templates/cli-providers/kiro.json.example +8 -2
  45. data/templates/plugins.json.example +3 -0
  46. metadata +30 -20
  47. data/lib/user_registry.rb +0 -159
  48. data/monitor/menubar.rb +0 -295
  49. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  50. data/monitor/setup-waybar-deployments.rb +0 -96
  51. data/monitor/setup-waybar-module.rb +0 -113
  52. data/monitor/setup-xbar-plugin.rb +0 -35
  53. data/monitor/view-logs.rb +0 -119
  54. data/monitor/waybar-config-updater.rb +0 -56
  55. data/monitor/waybar-deployments.rb +0 -239
  56. data/monitor/waybar.rb +0 -146
  57. data/monitor/xbar.3s.rb +0 -179
  58. /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
  59. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  60. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -0,0 +1,730 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fizzy comment handler — routes incoming comments to the appropriate dispatch path.
4
+ #
5
+ # This is the main routing logic for Fizzy card comments:
6
+ # - Deploy shortcuts (dev01, dev02, etc.)
7
+ # - Cancel commands
8
+ # - Cross-agent mentions (@Galen on Kaylee's card)
9
+ # - Follow-up comments on existing worktrees
10
+ # - New mentions on untracked cards
11
+
12
+ # Context struct that accumulates state as a comment flows through the routing pipeline.
13
+ # Replaces long keyword-arg lists between sub-handlers.
14
+ CommentContext = Struct.new(
15
+ :eventable, :plain_text, :card_internal_id, :card_info,
16
+ :comment_id, :creator_name, :creator_is_agent,
17
+ :mentioned_agent, :agent_name, :is_cross_agent_mention,
18
+ :project_config, :project_key, :card_number, :worktree,
19
+ :model, :effort, :deploy_intent, :cli_provider_override,
20
+ :comment_vars, :card_tags, :worktree_override,
21
+ keyword_init: true
22
+ )
23
+
24
+ def handle_comment(payload)
25
+ eventable = payload["eventable"] || {}
26
+ plain_text = eventable.dig("body", "plain_text") || ""
27
+ card_internal_id = eventable.dig("card", "id")
28
+
29
+ return handle_deploy_comment(eventable, plain_text.strip.downcase, card_internal_id) if plain_text.strip.match?(/\Adev\d+\z/i)
30
+
31
+ mentioned_agent = detect_mentioned_agent(plain_text)
32
+ gate_result = check_mention_gates(mentioned_agent, plain_text)
33
+ return gate_result if gate_result
34
+
35
+ creator_name, creator_is_agent, is_api_sourced = extract_creator_info(payload, eventable)
36
+ unless creator_is_agent || is_api_sourced
37
+ auth_result = authorize_human_comment(eventable, card_internal_id, creator_name, plain_text)
38
+ return auth_result if auth_result
39
+ end
40
+
41
+ agent_result = validate_agent_comment(creator_is_agent, is_api_sourced, creator_name, mentioned_agent, card_internal_id)
42
+ return agent_result if agent_result
43
+
44
+ card_info = load_card_map[card_internal_id]
45
+ comment_id = eventable["id"]
46
+
47
+ return [200, { status: "ignored", reason: "not relevant" }.to_json] unless mentioned_agent || card_info
48
+
49
+ project_config, project_key = resolve_fizzy_project(card_info, card_internal_id, eventable)
50
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_config
51
+
52
+ tags = parse_inline_tags(plain_text)
53
+
54
+ agent_name, is_cross_agent_mention = resolve_comment_agent(
55
+ mentioned_agent: mentioned_agent, card_info: card_info, card_internal_id: card_internal_id,
56
+ eventable: eventable, project_config: project_config, creator_is_agent: creator_is_agent
57
+ )
58
+ return [200, { status: "ignored", reason: "no assigned agent" }.to_json] unless agent_name
59
+
60
+ cooldown_key = "card-#{card_info ? (card_info["number"] || card_internal_id) : card_internal_id}-#{agent_name.downcase}"
61
+ if on_comment_cooldown?(cooldown_key)
62
+ LOG.info "Skipping comment on #{cooldown_key} — within #{COMMENT_COOLDOWN}s cooldown"
63
+ return [200, { status: "ignored", reason: "comment cooldown" }.to_json]
64
+ end
65
+ touch_comment_cooldown(cooldown_key)
66
+
67
+ ctx = build_comment_context(
68
+ eventable: eventable, plain_text: plain_text, tags: tags, card_internal_id: card_internal_id,
69
+ card_info: card_info, comment_id: comment_id, creator_name: creator_name,
70
+ creator_is_agent: creator_is_agent, mentioned_agent: mentioned_agent,
71
+ agent_name: agent_name, is_cross_agent_mention: is_cross_agent_mention,
72
+ project_config: project_config, project_key: project_key
73
+ )
74
+
75
+ # --- Route to appropriate sub-handler ---
76
+ if is_cross_agent_mention
77
+ handle_cross_agent_mention(ctx)
78
+ elsif card_info || ctx.worktree_override
79
+ handle_existing_card_comment(ctx)
80
+ else
81
+ handle_new_mention(ctx)
82
+ end
83
+ end
84
+
85
+ # --- Early-exit helpers ---
86
+
87
+ def build_comment_context(eventable:, plain_text:, tags:, card_internal_id:, card_info:, comment_id:, creator_name:,
88
+ creator_is_agent:, mentioned_agent:, agent_name:, is_cross_agent_mention:,
89
+ project_config:, project_key:)
90
+ deploy_intent = tags[:deploy_intent]
91
+ LOG.info "[Deploy] Detected [deploy#{":#{deploy_intent}" unless deploy_intent == :auto}] tag on card #{card_internal_id}" if deploy_intent
92
+
93
+ card_tags = eventable.dig("card", "tags") || []
94
+ clean_text = tags[:clean_text]
95
+
96
+ CommentContext.new(
97
+ eventable: eventable, plain_text: clean_text, card_internal_id: card_internal_id,
98
+ card_info: card_info, comment_id: comment_id, creator_name: creator_name,
99
+ creator_is_agent: creator_is_agent, mentioned_agent: mentioned_agent,
100
+ agent_name: agent_name, is_cross_agent_mention: is_cross_agent_mention,
101
+ project_config: project_config, project_key: project_key,
102
+ model: detect_model(project_config, text: plain_text),
103
+ effort: detect_effort(project_config, tags: card_tags, text: plain_text),
104
+ deploy_intent: deploy_intent,
105
+ cli_provider_override: detect_cli_provider(text: plain_text, tags: card_tags),
106
+ card_tags: card_tags,
107
+ worktree_override: resolve_worktree_override(tags, project_config),
108
+ comment_vars: {
109
+ "COMMENT_CREATOR" => creator_name || "Unknown",
110
+ "COMMENT_ID" => comment_id.to_s,
111
+ "COMMENT_BODY" => clean_text
112
+ }
113
+ )
114
+ end
115
+
116
+ def check_mention_gates(mentioned_agent, plain_text)
117
+ mentioned_user_ids = detect_mentioned_user_ids(plain_text)
118
+ if mentioned_user_ids.any? { |id| human_mentioned?(id) }
119
+ LOG.info "[Fizzy] Human @mentioned in comment, skipping agent dispatch"
120
+ return [200, { status: "ignored", reason: "human mentioned" }.to_json]
121
+ end
122
+
123
+ if mentioned_agent && !local_agent_names.include?(mentioned_agent)
124
+ LOG.info "[Fizzy] Ignoring mention of non-local agent #{mentioned_agent}"
125
+ return [200, { status: "ignored", reason: "non-local agent mentioned" }.to_json]
126
+ end
127
+
128
+ nil
129
+ end
130
+
131
+ def extract_creator_info(payload, eventable)
132
+ creator_name = eventable.dig("creator", "name")
133
+ creator_is_agent = comment_from_agent?(creator_name)
134
+ creator_is_agent ||= comment_from_agent?(payload.dig("creator", "name"))
135
+
136
+ source = eventable["source"] || payload["source"]
137
+ is_api_sourced = source && source != "web"
138
+
139
+ [creator_name, creator_is_agent, is_api_sourced]
140
+ end
141
+
142
+ def authorize_human_comment(eventable, card_internal_id, creator_name, plain_text)
143
+ creator_id = eventable.dig("creator", "id")
144
+
145
+ unless AUTHORIZED_USER_IDS.include?(creator_id)
146
+ notify_unauthorized("comment_created", creator_name, "card #{card_internal_id}")
147
+ return [200, { status: "ignored", reason: "unauthorized" }.to_json]
148
+ end
149
+
150
+ record_human_comment(card_internal_id)
151
+
152
+ cancel_keywords = %w[cancel stop halt abort kill ❌]
153
+ return handle_cancel_command(eventable, card_internal_id) if cancel_keywords.include?(plain_text.strip.downcase)
154
+
155
+ nil
156
+ end
157
+
158
+ def validate_agent_comment(creator_is_agent, is_api_sourced, creator_name, mentioned_agent, card_internal_id)
159
+ return nil unless creator_is_agent || is_api_sourced
160
+
161
+ card_info = load_card_map[card_internal_id]
162
+ card_assigned_agent = card_info&.dig("agent")
163
+
164
+ agent_is_assigned = card_assigned_agent && card_assigned_agent.downcase == (creator_name || "").downcase
165
+ agent_is_mentioned = mentioned_agent && mentioned_agent.downcase == (creator_name || "").downcase
166
+
167
+ unless agent_is_assigned || agent_is_mentioned
168
+ LOG.info "Blocking agent comment from #{creator_name} on card #{card_internal_id}: not assigned and not mentioned"
169
+ return [200, { status: "ignored", reason: "agent not assigned or mentioned" }.to_json]
170
+ end
171
+
172
+ # Agent-to-agent loop prevention
173
+ if mentioned_agent && mentioned_agent.downcase != (creator_name || "").downcase
174
+ unless agent_dispatch_allowed?(card_internal_id)
175
+ LOG.info "Blocking agent-to-agent dispatch on card #{card_internal_id}: " \
176
+ "depth limit reached (#{creator_name} → @#{mentioned_agent})"
177
+ return [200, { status: "ignored", reason: "agent-to-agent depth limit" }.to_json]
178
+ end
179
+ LOG.info "Allowing agent-to-agent dispatch on card #{card_internal_id}: #{creator_name} → @#{mentioned_agent}"
180
+ elsif !mentioned_agent
181
+ LOG.info "Ignoring self-comment from #{creator_name} on card #{card_internal_id}"
182
+ return [200, { status: "ignored", reason: "self-comment" }.to_json]
183
+ end
184
+
185
+ nil
186
+ end
187
+
188
+ def resolve_worktree_override(tags, project_config)
189
+ return nil unless tags[:worktree_override]
190
+
191
+ override_branch = tags[:worktree_override]
192
+ repo_path = project_config["repo_path"]
193
+ candidate = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{override_branch}")
194
+
195
+ if File.directory?(candidate)
196
+ LOG.info "Worktree override requested: #{override_branch} -> #{candidate}"
197
+ { "branch" => override_branch, "worktree" => candidate }
198
+ else
199
+ LOG.warn "Worktree override branch '#{override_branch}' not found at #{candidate}, ignoring"
200
+ nil
201
+ end
202
+ end
203
+
204
+ # --- Comment sub-handlers ---
205
+
206
+ # --- Comment sub-handlers ---
207
+
208
+ def handle_cancel_command(eventable, card_internal_id)
209
+ killed = 0
210
+ card_number_for_cancel = load_card_map.dig(card_internal_id, "number")
211
+ prefixes = ["card-#{card_internal_id}"]
212
+ prefixes << "card-#{card_number_for_cancel}" if card_number_for_cancel
213
+
214
+ ACTIVE_SESSIONS_MUTEX.synchronize do
215
+ ACTIVE_SESSIONS.keys.select { |k| prefixes.any? { |p| k == p || k.start_with?("#{p}-") } }.each do |key|
216
+ info = ACTIVE_SESSIONS[key]
217
+ next unless info
218
+
219
+ begin
220
+ Process.kill("KILL", info[:pid])
221
+ LOG.info "[Fizzy] Cancelled session #{key} (PID: #{info[:pid]})"
222
+ rescue Errno::ESRCH, Errno::EPERM => e
223
+ LOG.warn "[Fizzy] Could not kill #{key}: #{e.message}"
224
+ end
225
+ archive_session(key, info)
226
+ ACTIVE_SESSIONS.delete(key)
227
+ killed += 1
228
+ end
229
+ end
230
+
231
+ comment_id_for_cancel = eventable["id"]
232
+ card_info_for_cancel = load_card_map[card_internal_id]
233
+ if card_info_for_cancel && card_number_for_cancel && comment_id_for_cancel
234
+ repo = (card_info_for_cancel["project"] && PROJECTS.dig(card_info_for_cancel["project"], "repo_path")) ||
235
+ DEFAULT_PROJECT["repo_path"]
236
+ Thread.new do
237
+ run_cmd("fizzy", "reaction", "create", "--card", card_number_for_cancel.to_s,
238
+ "--comment", comment_id_for_cancel.to_s, "--content", "🛑",
239
+ chdir: repo, env: default_fizzy_env)
240
+ rescue StandardError => e
241
+ LOG.warn "[Fizzy] Could not add 🛑 reaction: #{e.message}"
242
+ end
243
+ end
244
+
245
+ LOG.info "[Fizzy] Cancel command received for card #{card_number_for_cancel || card_internal_id}: killed #{killed} session(s)"
246
+ [200, { status: "cancelled", card: card_number_for_cancel || card_internal_id, sessions_killed: killed }.to_json]
247
+ end
248
+
249
+ def resolve_fizzy_project(card_info, card_internal_id, eventable)
250
+ if card_info
251
+ if card_info["project"]
252
+ project_key = card_info["project"]
253
+ project_config = PROJECTS[project_key] || DEFAULT_PROJECT
254
+ else
255
+ card_tags = eventable.dig("card", "tags") || []
256
+ project_result = identify_project_by_tags(card_tags)
257
+ if project_result
258
+ project_key, project_config = project_result
259
+ card_info["project"] = project_key
260
+ map = load_card_map
261
+ map[card_internal_id] = card_info
262
+ save_card_map(map)
263
+ LOG.info "Backfilled project '#{project_key}' for card #{card_internal_id} in card map"
264
+ else
265
+ LOG.warn "No project found for card #{card_internal_id}"
266
+ return [nil, nil]
267
+ end
268
+ end
269
+ else
270
+ card_tags = eventable.dig("card", "tags") || []
271
+ project_result = identify_project_by_tags(card_tags)
272
+ if project_result
273
+ project_key, project_config = project_result
274
+ else
275
+ LOG.warn "No project found for mentioned card #{card_internal_id}"
276
+ return [nil, nil]
277
+ end
278
+ end
279
+
280
+ [project_config, project_key]
281
+ end
282
+
283
+ def resolve_comment_agent(mentioned_agent:, card_info:, card_internal_id:, eventable:, project_config:, creator_is_agent:)
284
+ card_assigned_agent = card_info&.dig("agent")
285
+
286
+ # Resolve assigned agent from payload or API if missing
287
+ card_assigned_agent = resolve_assigned_agent(card_info, card_internal_id, eventable, project_config) if card_assigned_agent.nil?
288
+
289
+ if mentioned_agent
290
+ agent_name = mentioned_agent
291
+ is_cross_agent_mention = !card_assigned_agent || card_assigned_agent != mentioned_agent
292
+ else
293
+ unless card_assigned_agent
294
+ LOG.info "Skipping card #{card_internal_id} — no assigned agent and no mention"
295
+ return [nil, false]
296
+ end
297
+ agent_name = card_assigned_agent
298
+ is_cross_agent_mention = false
299
+ end
300
+
301
+ [agent_name, is_cross_agent_mention]
302
+ end
303
+
304
+ def resolve_assigned_agent(card_info, card_internal_id, eventable, project_config)
305
+ card_assignees = eventable.dig("card", "assignees") || []
306
+ webhook_agent = card_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }
307
+
308
+ webhook_agent = resolve_agent_via_api(card_info, card_internal_id, project_config) if webhook_agent.nil? && project_config
309
+
310
+ return nil unless webhook_agent
311
+
312
+ map = load_card_map
313
+ map[card_internal_id] ||= {}
314
+ map[card_internal_id]["agent"] = webhook_agent
315
+ save_card_map(map)
316
+ LOG.info "Backfilled agent '#{webhook_agent}' into card map for #{card_internal_id}"
317
+ webhook_agent
318
+ end
319
+
320
+ def resolve_agent_via_api(card_info, card_internal_id, project_config)
321
+ api_card_number = card_info&.dig("number") || card_internal_id
322
+ return nil unless api_card_number
323
+
324
+ output = run_cmd("fizzy", "card", "show", api_card_number.to_s,
325
+ chdir: project_config["repo_path"], env: default_fizzy_env)
326
+ api_assignees = begin
327
+ JSON.parse(output).dig("data", "assignees") || []
328
+ rescue StandardError
329
+ []
330
+ end
331
+ agent = api_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }
332
+ LOG.info "Resolved assigned agent '#{agent}' via Fizzy API for card ##{api_card_number}" if agent
333
+ agent
334
+ rescue StandardError => e
335
+ LOG.warn "Fizzy API fallback failed for card ##{api_card_number}: #{e.message}"
336
+ nil
337
+ end
338
+
339
+ # Handle cross-agent mention (agent tagged on another agent's card)
340
+ def handle_cross_agent_mention(ctx)
341
+ card_assigned_agent = ctx.card_info&.dig("agent")
342
+ return [200, { status: "ignored", reason: "card creation announcement" }.to_json] if cross_agent_announcement?(ctx)
343
+
344
+ card_number = ctx.card_info&.dig("number")
345
+ card_number ||= resolve_card_number(ctx.card_internal_id, repo_path: ctx.project_config["repo_path"])
346
+ card_key = "card-#{card_number || ctx.card_internal_id}-#{ctx.agent_name.downcase}"
347
+ if ctx.creator_is_agent && session_active?(card_key)
348
+ return [200, { status: "ignored", reason: "session wait timeout" }.to_json] unless wait_for_session?(card_key)
349
+ elsif session_active?(card_key)
350
+ return [200, { status: "ignored", reason: "session already active" }.to_json]
351
+ end
352
+
353
+ LOG.info "Cross-agent mention: #{ctx.agent_name} tagged on #{card_assigned_agent}'s card " \
354
+ "##{card_number || ctx.card_internal_id} (project: #{ctx.project_key})"
355
+ record_agent_dispatch(ctx.card_internal_id) if ctx.creator_is_agent
356
+
357
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")
358
+
359
+ review_worktree_path, review_branch = setup_cross_agent_worktree(ctx, card_number)
360
+ card_context = prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"], agent_name: ctx.agent_name)
361
+
362
+ prompt = render_prompt(PROMPT_CROSS_AGENT_REVIEW,
363
+ ctx.comment_vars.merge(
364
+ "CARD_NUMBER" => card_number || "N/A",
365
+ "CARD_INTERNAL_ID" => ctx.card_internal_id,
366
+ "CARD_ID" => card_number || ctx.card_internal_id,
367
+ "CARD_AGENT" => card_assigned_agent,
368
+ "WORKTREE_PATH" => review_worktree_path,
369
+ "BRANCH" => review_branch
370
+ ),
371
+ brain_context: build_brain_context(
372
+ agent_name: ctx.agent_name, card_number: card_number,
373
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
374
+ ),
375
+ card_context: card_context,
376
+ agent_name: ctx.agent_name)
377
+
378
+ pid, log_file = run_agent(prompt,
379
+ project_config: ctx.project_config, chdir: review_worktree_path,
380
+ log_name: "review-#{ctx.agent_name.downcase}-#{card_number || ctx.card_internal_id}",
381
+ model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
382
+ card_number: card_number, comment_id: ctx.comment_id,
383
+ source: :fizzy, source_context: { card_number: card_number },
384
+ cli_provider: ctx.cli_provider_override)
385
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
386
+
387
+ [200, { status: "cross_agent_review", agent: ctx.agent_name, card_agent: card_assigned_agent,
388
+ card: card_number, card_internal_id: ctx.card_internal_id,
389
+ project: ctx.project_key, worktree: review_worktree_path }.to_json]
390
+ end
391
+
392
+ # Handle comment on a card that's already in the card map (or has a worktree override)
393
+ def handle_existing_card_comment(ctx)
394
+ effective_info = ctx.worktree_override ? (ctx.card_info || {}).merge(ctx.worktree_override) : ctx.card_info
395
+ card_number = effective_info["number"]
396
+ worktree = effective_info["worktree"]
397
+
398
+ card_number = resolve_and_save_card_number(ctx.card_internal_id, ctx.project_config) if card_number.nil?
399
+ worktree = find_and_save_worktree(ctx.card_internal_id, card_number, ctx.project_config) if !(worktree && File.directory?(worktree)) && card_number
400
+
401
+ work_dir = worktree && File.directory?(worktree) ? worktree : ctx.project_config["repo_path"]
402
+ card_key = "card-#{card_number || ctx.card_internal_id}"
403
+
404
+ # Session management (wait, supersede, or queue)
405
+ queued = handle_session_conflict(ctx, card_key, card_number, work_dir)
406
+ return queued if queued
407
+
408
+ LOG.info "Follow-up comment on card #{card_number || ctx.card_internal_id} " \
409
+ "(project: #{ctx.project_key}), worktree: #{work_dir}"
410
+
411
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)
412
+
413
+ result = dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
414
+ [200, result.to_json]
415
+ end
416
+
417
+ # Handle mention on a card with no existing card_info (exploration)
418
+ def handle_new_mention(ctx)
419
+ card_data = ctx.eventable["card"] || {}
420
+ card_number = card_data["number"]
421
+ card_title = card_data["title"] || "exploration"
422
+
423
+ if card_number.nil?
424
+ map_entry = load_card_map[ctx.card_internal_id]
425
+ card_number = if map_entry && map_entry["number"]
426
+ map_entry["number"]
427
+ else
428
+ resolve_card_number(ctx.card_internal_id, repo_path: ctx.project_config["repo_path"])
429
+ end
430
+ end
431
+
432
+ LOG.info "#{ctx.agent_name} mentioned on card (internal_id: #{ctx.card_internal_id}, " \
433
+ "project: #{ctx.project_key}), creating exploration worktree"
434
+ record_agent_dispatch(ctx.card_internal_id) if ctx.creator_is_agent
435
+
436
+ card_key = "card-#{card_number || ctx.card_internal_id}"
437
+ return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
438
+
439
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")
440
+
441
+ repo_path = ctx.project_config["repo_path"]
442
+ worktree_path, branch = resolve_or_create_worktree(ctx, card_number, card_title, repo_path)
443
+
444
+ map = load_card_map
445
+ map[ctx.card_internal_id] = {
446
+ "number" => card_number, "branch" => branch, "worktree" => worktree_path,
447
+ "project" => ctx.project_key, "agent" => ctx.agent_name
448
+ }
449
+ save_card_map(map)
450
+
451
+ prompt = build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)
452
+
453
+ pid, log_file = run_agent(prompt,
454
+ project_config: ctx.project_config, chdir: worktree_path,
455
+ log_name: "mention-#{card_number || ctx.card_internal_id}",
456
+ model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
457
+ card_number: card_number, comment_id: ctx.comment_id,
458
+ source: :fizzy, cli_provider: ctx.cli_provider_override,
459
+ source_context: { card_number: card_number })
460
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
461
+
462
+ [200, { status: "responded", card_internal_id: ctx.card_internal_id, card_number: card_number,
463
+ branch: branch, worktree: worktree_path, project: ctx.project_key }.to_json]
464
+ end
465
+
466
+ # Dispatch a follow-up comment to the agent.
467
+ def dispatch_followup_comment(ctx, card_key:, card_number:, work_dir:)
468
+ card_tags = ctx.eventable.dig("card", "tags") || []
469
+ effort = detect_effort(ctx.project_config, tags: card_tags, text: ctx.plain_text)
470
+
471
+ is_worktree = work_dir != ctx.project_config["repo_path"]
472
+ resolved = resolve_project_cli_config(ctx.project_config,
473
+ cli_provider_override: ctx.cli_provider_override,
474
+ agent_name: ctx.agent_name)
475
+ should_resume = is_worktree && resolved["resume_flag"]
476
+
477
+ prompt = if should_resume
478
+ LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || ctx.card_internal_id}"
479
+ render_resume_prompt(
480
+ comment_body: ctx.plain_text, comment_creator: ctx.comment_vars["COMMENT_CREATOR"],
481
+ comment_id: ctx.comment_id, card_number: card_number, agent_name: ctx.agent_name
482
+ )
483
+ else
484
+ build_followup_prompt(ctx, card_number, card_tags, work_dir)
485
+ end
486
+
487
+ pid, log_file = run_agent(prompt,
488
+ project_config: ctx.project_config, chdir: work_dir,
489
+ log_name: "followup-#{card_number || ctx.card_internal_id}",
490
+ model: ctx.model, effort: effort, agent_name: ctx.agent_name,
491
+ card_number: card_number, comment_id: ctx.comment_id,
492
+ source: :fizzy, cli_provider: ctx.cli_provider_override, resume: is_worktree,
493
+ source_context: {
494
+ card_number: card_number, card_internal_id: ctx.card_internal_id,
495
+ deploy_intent: ctx.deploy_intent
496
+ })
497
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
498
+
499
+ Thread.new { move_card_to_column(card_number, "right_now", project_config: ctx.project_config, agent_name: ctx.agent_name) }
500
+
501
+ { status: "follow_up", card: card_number, card_internal_id: ctx.card_internal_id,
502
+ worktree: work_dir, project: ctx.project_key }
503
+ end
504
+
505
+ # --- Shared helpers ---
506
+
507
+ def card_announcement?(text)
508
+ text.match?(/created\s+card\s+#?\d+/i) ||
509
+ text.match?(/assigned\s+.*card\s+#?\d+/i) ||
510
+ text.match?(/card\s+#?\d+.*assigned/i)
511
+ end
512
+
513
+ def cross_agent_announcement?(ctx)
514
+ return false unless ctx.creator_is_agent && card_announcement?(ctx.plain_text)
515
+
516
+ LOG.info "Ignoring cross-agent mention from #{ctx.comment_vars["COMMENT_CREATOR"]} " \
517
+ "on card #{ctx.card_internal_id} — card creation/assignment (handled by webhook)"
518
+ true
519
+ end
520
+
521
+ def react_to_comment(card_number, comment_id, project_config, agent_name, emoji, chdir: nil)
522
+ return unless card_number
523
+
524
+ work_dir = chdir || project_config["repo_path"]
525
+ Thread.new do
526
+ run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
527
+ "--comment", comment_id.to_s, "--content", emoji,
528
+ chdir: work_dir, env: fizzy_env_for(agent_name))
529
+ rescue StandardError => e
530
+ LOG.warn "Could not add #{emoji} reaction to comment: #{e.message}"
531
+ end
532
+ end
533
+
534
+ def resolve_and_save_card_number(card_internal_id, project_config)
535
+ card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
536
+ if card_number
537
+ map = load_card_map
538
+ map[card_internal_id] ||= {}
539
+ map[card_internal_id]["number"] = card_number
540
+ save_card_map(map)
541
+ end
542
+ card_number
543
+ end
544
+
545
+ def find_and_save_worktree(card_internal_id, card_number, project_config)
546
+ found = find_worktree_for_card(card_number, repo_path: project_config["repo_path"])
547
+ return nil unless found
548
+
549
+ map = load_card_map
550
+ map[card_internal_id] ||= {}
551
+ map[card_internal_id].merge!("worktree" => found[:worktree], "branch" => found[:branch])
552
+ save_card_map(map)
553
+ LOG.info "Found worktree by card number scan: #{found[:worktree]}"
554
+ found[:worktree]
555
+ end
556
+
557
+ def handle_session_conflict(ctx, card_key, card_number, work_dir)
558
+ if ctx.creator_is_agent && session_active?(card_key)
559
+ return [200, { status: "ignored", reason: "session wait timeout" }.to_json] unless wait_for_session?(card_key)
560
+ elsif session_active?(card_key)
561
+ prev = find_supersedable_session(card_key)
562
+ return queue_followup(ctx, card_key, card_number, work_dir) unless prev
563
+
564
+ LOG.info "Superseding session on card #{card_number || ctx.card_internal_id} " \
565
+ "(pid: #{prev[:pid]}) — human follow-up within #{SUPERSEDE_WINDOW}s"
566
+ kill_session(prev[:session_key])
567
+
568
+ end
569
+ nil
570
+ end
571
+
572
+ def queue_followup(ctx, card_key, card_number, work_dir)
573
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)
574
+
575
+ Thread.new do
576
+ unless wait_for_session?(card_key)
577
+ LOG.warn "Giving up on queued follow-up for card #{card_number || ctx.card_internal_id}"
578
+ next
579
+ end
580
+ dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
581
+ end
582
+
583
+ [200, { status: "queued", card: card_number, card_internal_id: ctx.card_internal_id,
584
+ reason: "waiting for active session" }.to_json]
585
+ end
586
+
587
+ def setup_cross_agent_worktree(ctx, card_number)
588
+ repo_path = ctx.project_config["repo_path"]
589
+ card_title = ctx.card_info&.dig("title") || ctx.eventable.dig("card", "title") || "review"
590
+ review_branch = "#{ctx.agent_name.downcase}/fizzy-#{card_number}-#{slugify(card_title)}"
591
+ review_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{review_branch.tr("/", "-")}")
592
+
593
+ debounced_repo_fetch(repo_path)
594
+
595
+ if File.directory?(review_worktree_path)
596
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
597
+ FileUtils.rm_rf(review_worktree_path) unless worktree_list.include?(review_worktree_path)
598
+ end
599
+
600
+ create_review_worktree(repo_path, review_branch, review_worktree_path, ctx.card_info) unless File.directory?(review_worktree_path)
601
+
602
+ [review_worktree_path, review_branch]
603
+ end
604
+
605
+ def create_review_worktree(repo_path, review_branch, review_worktree_path, card_info)
606
+ card_branch = card_info&.dig("branch")
607
+ branch_exists = card_branch && system("git", "rev-parse", "--verify", card_branch,
608
+ chdir: repo_path, out: File::NULL, err: File::NULL)
609
+ base_ref = branch_exists ? card_branch : "origin/#{get_default_branch(repo_path)}"
610
+
611
+ if system("git", "rev-parse", "--verify", review_branch, chdir: repo_path, out: File::NULL, err: File::NULL)
612
+ run_cmd("git", "branch", "-D", review_branch, chdir: repo_path)
613
+ end
614
+
615
+ run_cmd("git", "worktree", "add", "-b", review_branch, review_worktree_path, base_ref, chdir: repo_path)
616
+ trust_version_manager(review_worktree_path, chdir: review_worktree_path)
617
+ apply_worktree_includes(repo_path, review_worktree_path)
618
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => review_worktree_path })
619
+ LOG.info "Created cross-agent review worktree at #{review_worktree_path} (base: #{base_ref})"
620
+ end
621
+
622
+ def resolve_or_create_worktree(ctx, card_number, card_title, repo_path)
623
+ # Check for existing worktree in card map or on disk
624
+ existing_map_entry = load_card_map[ctx.card_internal_id]
625
+ if existing_map_entry && existing_map_entry["branch"] && existing_map_entry["worktree"] &&
626
+ File.directory?(existing_map_entry["worktree"])
627
+ LOG.info "Reusing existing worktree from card map: #{existing_map_entry["worktree"]}"
628
+ return [existing_map_entry["worktree"], existing_map_entry["branch"]]
629
+ end
630
+
631
+ if card_number
632
+ found = find_worktree_for_card(card_number, repo_path: repo_path)
633
+ if found
634
+ LOG.info "Found existing worktree by card number scan: #{found[:worktree]}"
635
+ return [found[:worktree], found[:branch]]
636
+ end
637
+ end
638
+
639
+ branch = card_number ? "fizzy-#{card_number}-#{slugify(card_title)}" : "fizzy-explore-#{ctx.card_internal_id[0..7]}"
640
+ debounced_repo_fetch(repo_path)
641
+ worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
642
+ [worktree_path, branch]
643
+ end
644
+
645
+ def build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)
646
+ planning_info = detect_planning_mode(text: ctx.plain_text, tags: ctx.card_tags,
647
+ card_internal_id: ctx.card_internal_id, card_number: card_number)
648
+
649
+ if planning_info
650
+ render_planning_prompt(PROMPT_MENTION,
651
+ ctx.comment_vars.merge(
652
+ "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => planning_info[:card_id],
653
+ "CARD_NUMBER" => card_number || "N/A",
654
+ "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
655
+ "BRANCH" => branch
656
+ ),
657
+ brain_context: build_brain_context(
658
+ agent_name: ctx.agent_name, card_title: card_title, card_number: card_number,
659
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
660
+ ),
661
+ card_context: prefetch_card_context(card_number, repo_path: worktree_path,
662
+ agent_name: ctx.agent_name),
663
+ agent_name: ctx.agent_name)
664
+ else
665
+ card_id = card_number || ctx.card_internal_id
666
+ render_prompt(PROMPT_MENTION,
667
+ ctx.comment_vars.merge(
668
+ "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id,
669
+ "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number || ctx.card_internal_id
670
+ ),
671
+ brain_context: build_brain_context(
672
+ agent_name: ctx.agent_name, card_title: card_title, card_number: card_number,
673
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
674
+ ),
675
+ card_context: prefetch_card_context(card_number, repo_path: worktree_path,
676
+ agent_name: ctx.agent_name),
677
+ agent_name: ctx.agent_name)
678
+ end
679
+ end
680
+
681
+ def build_followup_prompt(ctx, card_number, card_tags, work_dir)
682
+ planning_info = detect_planning_mode(text: ctx.plain_text, tags: card_tags,
683
+ card_internal_id: ctx.card_internal_id, card_number: card_number)
684
+
685
+ if planning_info
686
+ build_planning_followup_prompt(ctx, card_number, planning_info[:card_id], work_dir)
687
+ elsif work_dir != ctx.project_config["repo_path"]
688
+ render_prompt(PROMPT_FOLLOWUP_WORKTREE,
689
+ ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
690
+ brain_context: build_brain_context(
691
+ agent_name: ctx.agent_name, card_number: card_number,
692
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
693
+ ),
694
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
695
+ agent_name: ctx.agent_name)
696
+ else
697
+ render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
698
+ ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => ctx.card_internal_id),
699
+ brain_context: build_brain_context(
700
+ agent_name: ctx.agent_name, project_key: ctx.project_key,
701
+ comment_body: ctx.plain_text, source: :fizzy
702
+ ),
703
+ card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
704
+ agent_name: ctx.agent_name),
705
+ agent_name: ctx.agent_name)
706
+ end
707
+ end
708
+
709
+ def build_planning_followup_prompt(ctx, card_number, card_id, work_dir)
710
+ if work_dir == ctx.project_config["repo_path"]
711
+ render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
712
+ ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id),
713
+ brain_context: build_brain_context(
714
+ agent_name: ctx.agent_name, project_key: ctx.project_key,
715
+ comment_body: ctx.plain_text, source: :fizzy
716
+ ),
717
+ card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
718
+ agent_name: ctx.agent_name),
719
+ agent_name: ctx.agent_name)
720
+ else
721
+ render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
722
+ ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
723
+ brain_context: build_brain_context(
724
+ agent_name: ctx.agent_name, card_number: card_number,
725
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
726
+ ),
727
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
728
+ agent_name: ctx.agent_name)
729
+ end
730
+ end