brainiac-fizzy 0.0.1

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.
@@ -0,0 +1,749 @@
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_work_item_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_work_item_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_work_item_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_work_item_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_work_item_map
261
+ map[card_internal_id] = card_info
262
+ save_work_item_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_work_item_map
313
+ map[card_internal_id] ||= {}
314
+ map[card_internal_id]["agent"] = webhook_agent
315
+ save_work_item_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
+ dispatch_cross_agent_review(ctx, card_key: card_key, card_number: card_number,
360
+ card_assigned_agent: card_assigned_agent)
361
+ end
362
+
363
+ def dispatch_cross_agent_review(ctx, card_key:, card_number:, card_assigned_agent:)
364
+ review_worktree_path, review_branch = setup_cross_agent_worktree(ctx, card_number)
365
+ prompt = build_cross_agent_prompt(ctx, card_number, card_assigned_agent, review_worktree_path, review_branch)
366
+
367
+ pid, log_file = run_agent(prompt,
368
+ project_config: ctx.project_config, chdir: review_worktree_path,
369
+ log_name: "review-#{ctx.agent_name.downcase}-#{card_number || ctx.card_internal_id}",
370
+ model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
371
+ card_number: card_number, comment_id: ctx.comment_id,
372
+ source: :fizzy, source_context: { card_number: card_number },
373
+ cli_provider: ctx.cli_provider_override)
374
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
375
+
376
+ [200, { status: "cross_agent_review", agent: ctx.agent_name, card_agent: card_assigned_agent,
377
+ card: card_number, card_internal_id: ctx.card_internal_id,
378
+ project: ctx.project_key, worktree: review_worktree_path }.to_json]
379
+ end
380
+
381
+ def build_cross_agent_prompt(ctx, card_number, card_assigned_agent, worktree_path, branch)
382
+ card_context = prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"], agent_name: ctx.agent_name)
383
+
384
+ render_prompt(PROMPT_CROSS_AGENT_REVIEW,
385
+ ctx.comment_vars.merge(
386
+ "CARD_NUMBER" => card_number || "N/A",
387
+ "CARD_INTERNAL_ID" => ctx.card_internal_id,
388
+ "CARD_ID" => card_number || ctx.card_internal_id,
389
+ "CARD_AGENT" => card_assigned_agent,
390
+ "WORKTREE_PATH" => worktree_path,
391
+ "BRANCH" => branch
392
+ ),
393
+ brain_context: build_brain_context(
394
+ agent_name: ctx.agent_name, card_number: card_number,
395
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
396
+ ),
397
+ card_context: card_context,
398
+ agent_name: ctx.agent_name)
399
+ end
400
+
401
+ # Handle comment on a card that's already in the card map (or has a worktree override)
402
+ def handle_existing_card_comment(ctx)
403
+ effective_info = ctx.worktree_override ? (ctx.card_info || {}).merge(ctx.worktree_override) : ctx.card_info
404
+ card_number = effective_info["number"]
405
+ worktree = effective_info["worktree"]
406
+
407
+ card_number = resolve_and_save_card_number(ctx.card_internal_id, ctx.project_config) if card_number.nil?
408
+ worktree = find_and_save_worktree(ctx.card_internal_id, card_number, ctx.project_config) if !(worktree && File.directory?(worktree)) && card_number
409
+
410
+ work_dir = worktree && File.directory?(worktree) ? worktree : ctx.project_config["repo_path"]
411
+ card_key = "card-#{card_number || ctx.card_internal_id}"
412
+
413
+ # Session management (wait, supersede, or queue)
414
+ queued = handle_session_conflict(ctx, card_key, card_number, work_dir)
415
+ return queued if queued
416
+
417
+ LOG.info "Follow-up comment on card #{card_number || ctx.card_internal_id} " \
418
+ "(project: #{ctx.project_key}), worktree: #{work_dir}"
419
+
420
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)
421
+
422
+ result = dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
423
+ [200, result.to_json]
424
+ end
425
+
426
+ # Handle mention on a card with no existing card_info (exploration)
427
+ def handle_new_mention(ctx)
428
+ card_data = ctx.eventable["card"] || {}
429
+ card_number = card_data["number"]
430
+ card_title = card_data["title"] || "exploration"
431
+
432
+ if card_number.nil?
433
+ map_entry = load_work_item_map[ctx.card_internal_id]
434
+ card_number = if map_entry && map_entry["number"]
435
+ map_entry["number"]
436
+ else
437
+ resolve_card_number(ctx.card_internal_id, repo_path: ctx.project_config["repo_path"])
438
+ end
439
+ end
440
+
441
+ LOG.info "#{ctx.agent_name} mentioned on card (internal_id: #{ctx.card_internal_id}, " \
442
+ "project: #{ctx.project_key}), creating exploration worktree"
443
+ record_agent_dispatch(ctx.card_internal_id) if ctx.creator_is_agent
444
+
445
+ card_key = "card-#{card_number || ctx.card_internal_id}"
446
+ return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
447
+
448
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")
449
+
450
+ worktree_path, branch = setup_new_mention_worktree(ctx, card_number, card_title)
451
+ dispatch_new_mention(ctx, card_key: card_key, card_number: card_number,
452
+ card_title: card_title, branch: branch, worktree_path: worktree_path)
453
+ end
454
+
455
+ def setup_new_mention_worktree(ctx, card_number, card_title)
456
+ repo_path = ctx.project_config["repo_path"]
457
+ worktree_path, branch = resolve_or_create_worktree(ctx, card_number, card_title, repo_path)
458
+
459
+ map = load_work_item_map
460
+ map[ctx.card_internal_id] = {
461
+ "number" => card_number, "branch" => branch, "worktree" => worktree_path,
462
+ "project" => ctx.project_key, "agent" => ctx.agent_name
463
+ }
464
+ save_work_item_map(map)
465
+
466
+ [worktree_path, branch]
467
+ end
468
+
469
+ def dispatch_new_mention(ctx, card_key:, card_number:, card_title:, branch:, worktree_path:)
470
+ prompt = build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)
471
+
472
+ pid, log_file = run_agent(prompt,
473
+ project_config: ctx.project_config, chdir: worktree_path,
474
+ log_name: "mention-#{card_number || ctx.card_internal_id}",
475
+ model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
476
+ card_number: card_number, comment_id: ctx.comment_id,
477
+ source: :fizzy, cli_provider: ctx.cli_provider_override,
478
+ source_context: { card_number: card_number })
479
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
480
+
481
+ [200, { status: "responded", card_internal_id: ctx.card_internal_id, card_number: card_number,
482
+ branch: branch, worktree: worktree_path, project: ctx.project_key }.to_json]
483
+ end
484
+
485
+ # Dispatch a follow-up comment to the agent.
486
+ def dispatch_followup_comment(ctx, card_key:, card_number:, work_dir:)
487
+ card_tags = ctx.eventable.dig("card", "tags") || []
488
+ effort = detect_effort(ctx.project_config, tags: card_tags, text: ctx.plain_text)
489
+
490
+ is_worktree = work_dir != ctx.project_config["repo_path"]
491
+ resolved = resolve_project_cli_config(ctx.project_config,
492
+ cli_provider_override: ctx.cli_provider_override,
493
+ agent_name: ctx.agent_name)
494
+ should_resume = is_worktree && resolved["resume_flag"]
495
+
496
+ prompt = if should_resume
497
+ LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || ctx.card_internal_id}"
498
+ render_resume_prompt(
499
+ comment_body: ctx.plain_text, comment_creator: ctx.comment_vars["COMMENT_CREATOR"],
500
+ comment_id: ctx.comment_id, card_number: card_number, agent_name: ctx.agent_name
501
+ )
502
+ else
503
+ build_followup_prompt(ctx, card_number, card_tags, work_dir)
504
+ end
505
+
506
+ pid, log_file = run_agent(prompt,
507
+ project_config: ctx.project_config, chdir: work_dir,
508
+ log_name: "followup-#{card_number || ctx.card_internal_id}",
509
+ model: ctx.model, effort: effort, agent_name: ctx.agent_name,
510
+ card_number: card_number, comment_id: ctx.comment_id,
511
+ source: :fizzy, cli_provider: ctx.cli_provider_override, resume: is_worktree,
512
+ source_context: {
513
+ card_number: card_number, card_internal_id: ctx.card_internal_id,
514
+ deploy_intent: ctx.deploy_intent
515
+ })
516
+ register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)
517
+
518
+ Thread.new { move_card_to_column(card_number, "right_now", project_config: ctx.project_config, agent_name: ctx.agent_name) }
519
+
520
+ { status: "follow_up", card: card_number, card_internal_id: ctx.card_internal_id,
521
+ worktree: work_dir, project: ctx.project_key }
522
+ end
523
+
524
+ # --- Shared helpers ---
525
+
526
+ def card_announcement?(text)
527
+ text.match?(/created\s+card\s+#?\d+/i) ||
528
+ text.match?(/assigned\s+.*card\s+#?\d+/i) ||
529
+ text.match?(/card\s+#?\d+.*assigned/i)
530
+ end
531
+
532
+ def cross_agent_announcement?(ctx)
533
+ return false unless ctx.creator_is_agent && card_announcement?(ctx.plain_text)
534
+
535
+ LOG.info "Ignoring cross-agent mention from #{ctx.comment_vars["COMMENT_CREATOR"]} " \
536
+ "on card #{ctx.card_internal_id} — card creation/assignment (handled by webhook)"
537
+ true
538
+ end
539
+
540
+ def react_to_comment(card_number, comment_id, project_config, agent_name, emoji, chdir: nil)
541
+ return unless card_number
542
+
543
+ work_dir = chdir || project_config["repo_path"]
544
+ Thread.new do
545
+ run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
546
+ "--comment", comment_id.to_s, "--content", emoji,
547
+ chdir: work_dir, env: fizzy_env_for(agent_name))
548
+ rescue StandardError => e
549
+ LOG.warn "Could not add #{emoji} reaction to comment: #{e.message}"
550
+ end
551
+ end
552
+
553
+ def resolve_and_save_card_number(card_internal_id, project_config)
554
+ card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
555
+ if card_number
556
+ map = load_work_item_map
557
+ map[card_internal_id] ||= {}
558
+ map[card_internal_id]["number"] = card_number
559
+ save_work_item_map(map)
560
+ end
561
+ card_number
562
+ end
563
+
564
+ def find_and_save_worktree(card_internal_id, card_number, project_config)
565
+ found = find_worktree_for_card(card_number, repo_path: project_config["repo_path"])
566
+ return nil unless found
567
+
568
+ map = load_work_item_map
569
+ map[card_internal_id] ||= {}
570
+ map[card_internal_id].merge!("worktree" => found[:worktree], "branch" => found[:branch])
571
+ save_work_item_map(map)
572
+ LOG.info "Found worktree by card number scan: #{found[:worktree]}"
573
+ found[:worktree]
574
+ end
575
+
576
+ def handle_session_conflict(ctx, card_key, card_number, work_dir)
577
+ if ctx.creator_is_agent && session_active?(card_key)
578
+ return [200, { status: "ignored", reason: "session wait timeout" }.to_json] unless wait_for_session?(card_key)
579
+ elsif session_active?(card_key)
580
+ prev = find_supersedable_session(card_key)
581
+ return queue_followup(ctx, card_key, card_number, work_dir) unless prev
582
+
583
+ LOG.info "Superseding session on card #{card_number || ctx.card_internal_id} " \
584
+ "(pid: #{prev[:pid]}) — human follow-up within #{SUPERSEDE_WINDOW}s"
585
+ kill_session(prev[:session_key])
586
+
587
+ end
588
+ nil
589
+ end
590
+
591
+ def queue_followup(ctx, card_key, card_number, work_dir)
592
+ react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)
593
+
594
+ Thread.new do
595
+ unless wait_for_session?(card_key)
596
+ LOG.warn "Giving up on queued follow-up for card #{card_number || ctx.card_internal_id}"
597
+ next
598
+ end
599
+ dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
600
+ end
601
+
602
+ [200, { status: "queued", card: card_number, card_internal_id: ctx.card_internal_id,
603
+ reason: "waiting for active session" }.to_json]
604
+ end
605
+
606
+ def setup_cross_agent_worktree(ctx, card_number)
607
+ repo_path = ctx.project_config["repo_path"]
608
+ card_title = ctx.card_info&.dig("title") || ctx.eventable.dig("card", "title") || "review"
609
+ review_branch = "#{ctx.agent_name.downcase}/fizzy-#{card_number}-#{slugify(card_title)}"
610
+ review_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{review_branch.tr("/", "-")}")
611
+
612
+ debounced_repo_fetch(repo_path)
613
+
614
+ if File.directory?(review_worktree_path)
615
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
616
+ FileUtils.rm_rf(review_worktree_path) unless worktree_list.include?(review_worktree_path)
617
+ end
618
+
619
+ create_review_worktree(repo_path, review_branch, review_worktree_path, ctx.card_info) unless File.directory?(review_worktree_path)
620
+
621
+ [review_worktree_path, review_branch]
622
+ end
623
+
624
+ def create_review_worktree(repo_path, review_branch, review_worktree_path, card_info)
625
+ card_branch = card_info&.dig("branch")
626
+ branch_exists = card_branch && system("git", "rev-parse", "--verify", card_branch,
627
+ chdir: repo_path, out: File::NULL, err: File::NULL)
628
+ base_ref = branch_exists ? card_branch : "origin/#{get_default_branch(repo_path)}"
629
+
630
+ if system("git", "rev-parse", "--verify", review_branch, chdir: repo_path, out: File::NULL, err: File::NULL)
631
+ run_cmd("git", "branch", "-D", review_branch, chdir: repo_path)
632
+ end
633
+
634
+ run_cmd("git", "worktree", "add", "-b", review_branch, review_worktree_path, base_ref, chdir: repo_path)
635
+ trust_version_manager(review_worktree_path, chdir: review_worktree_path)
636
+ apply_worktree_includes(repo_path, review_worktree_path)
637
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => review_worktree_path })
638
+ LOG.info "Created cross-agent review worktree at #{review_worktree_path} (base: #{base_ref})"
639
+ end
640
+
641
+ def resolve_or_create_worktree(ctx, card_number, card_title, repo_path)
642
+ # Check for existing worktree in card map or on disk
643
+ existing_map_entry = load_work_item_map[ctx.card_internal_id]
644
+ if existing_map_entry && existing_map_entry["branch"] && existing_map_entry["worktree"] &&
645
+ File.directory?(existing_map_entry["worktree"])
646
+ LOG.info "Reusing existing worktree from card map: #{existing_map_entry["worktree"]}"
647
+ return [existing_map_entry["worktree"], existing_map_entry["branch"]]
648
+ end
649
+
650
+ if card_number
651
+ found = find_worktree_for_card(card_number, repo_path: repo_path)
652
+ if found
653
+ LOG.info "Found existing worktree by card number scan: #{found[:worktree]}"
654
+ return [found[:worktree], found[:branch]]
655
+ end
656
+ end
657
+
658
+ branch = card_number ? "fizzy-#{card_number}-#{slugify(card_title)}" : "fizzy-explore-#{ctx.card_internal_id[0..7]}"
659
+ debounced_repo_fetch(repo_path)
660
+ worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
661
+ [worktree_path, branch]
662
+ end
663
+
664
+ def build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)
665
+ planning_info = detect_planning_mode(text: ctx.plain_text, tags: ctx.card_tags,
666
+ card_internal_id: ctx.card_internal_id, card_number: card_number)
667
+
668
+ if planning_info
669
+ render_planning_prompt(PROMPT_MENTION,
670
+ ctx.comment_vars.merge(
671
+ "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => planning_info[:card_id],
672
+ "CARD_NUMBER" => card_number || "N/A",
673
+ "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
674
+ "BRANCH" => branch
675
+ ),
676
+ brain_context: build_brain_context(
677
+ agent_name: ctx.agent_name, card_title: card_title, card_number: card_number,
678
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
679
+ ),
680
+ card_context: prefetch_card_context(card_number, repo_path: worktree_path,
681
+ agent_name: ctx.agent_name),
682
+ agent_name: ctx.agent_name)
683
+ else
684
+ card_id = card_number || ctx.card_internal_id
685
+ render_prompt(PROMPT_MENTION,
686
+ ctx.comment_vars.merge(
687
+ "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id,
688
+ "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number || ctx.card_internal_id
689
+ ),
690
+ brain_context: build_brain_context(
691
+ agent_name: ctx.agent_name, card_title: card_title, 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: worktree_path,
695
+ agent_name: ctx.agent_name),
696
+ agent_name: ctx.agent_name)
697
+ end
698
+ end
699
+
700
+ def build_followup_prompt(ctx, card_number, card_tags, work_dir)
701
+ planning_info = detect_planning_mode(text: ctx.plain_text, tags: card_tags,
702
+ card_internal_id: ctx.card_internal_id, card_number: card_number)
703
+
704
+ if planning_info
705
+ build_planning_followup_prompt(ctx, card_number, planning_info[:card_id], work_dir)
706
+ elsif work_dir != ctx.project_config["repo_path"]
707
+ render_prompt(PROMPT_FOLLOWUP_WORKTREE,
708
+ ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
709
+ brain_context: build_brain_context(
710
+ agent_name: ctx.agent_name, card_number: card_number,
711
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
712
+ ),
713
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
714
+ agent_name: ctx.agent_name)
715
+ else
716
+ render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
717
+ ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => ctx.card_internal_id),
718
+ brain_context: build_brain_context(
719
+ agent_name: ctx.agent_name, project_key: ctx.project_key,
720
+ comment_body: ctx.plain_text, source: :fizzy
721
+ ),
722
+ card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
723
+ agent_name: ctx.agent_name),
724
+ agent_name: ctx.agent_name)
725
+ end
726
+ end
727
+
728
+ def build_planning_followup_prompt(ctx, card_number, card_id, work_dir)
729
+ if work_dir == ctx.project_config["repo_path"]
730
+ render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
731
+ ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id),
732
+ brain_context: build_brain_context(
733
+ agent_name: ctx.agent_name, project_key: ctx.project_key,
734
+ comment_body: ctx.plain_text, source: :fizzy
735
+ ),
736
+ card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
737
+ agent_name: ctx.agent_name),
738
+ agent_name: ctx.agent_name)
739
+ else
740
+ render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
741
+ ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
742
+ brain_context: build_brain_context(
743
+ agent_name: ctx.agent_name, card_number: card_number,
744
+ project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
745
+ ),
746
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
747
+ agent_name: ctx.agent_name)
748
+ end
749
+ end