brainiac 0.0.6 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -1,1292 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # --- Card duplicate detection (card_published / card_triaged) ---
4
-
5
- def handle_card_published(payload)
6
- eventable = payload["eventable"] || {}
7
- card_number = eventable["number"]
8
- title = eventable["title"] || ""
9
- creator_name = payload.dig("creator", "name")
10
- creator_id = payload.dig("creator", "id")
11
- tags = eventable["tags"] || []
12
-
13
- # Creator-based routing: only the machine whose local human created the card
14
- # handles dedup. Requires `"local": true` on the human in fizzy.json authorized_users.
15
- # If no local humans are configured, skip dedup entirely to avoid duplicate warnings
16
- # from multiple machines.
17
- local_humans = FIZZY_CONFIG.fetch("authorized_users", []).select { |u| u["human"] && u["local"] }
18
- if local_humans.empty?
19
- LOG.info "[CardIndex] No local humans configured — skipping dedup, indexing only"
20
- CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
21
- CARD_INDEX.save
22
- CARD_INDEX.schedule_qmd_reindex
23
- return [200, { status: "indexed", card: card_number }.to_json]
24
- end
25
- is_local_creator = local_humans.any? { |u| u["id"] == creator_id }
26
-
27
- unless is_local_creator
28
- LOG.info "[CardIndex] Ignoring card ##{card_number} — creator '#{creator_name}' is not a local human"
29
- # Still index it so we can compare against it later
30
- CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
31
- CARD_INDEX.save
32
- CARD_INDEX.schedule_qmd_reindex
33
- return [200, { status: "indexed", card: card_number }.to_json]
34
- end
35
-
36
- # Check for duplicates before indexing
37
- similar = CARD_INDEX.find_similar_cards(title, exclude_number: card_number, tags: tags) if card_number
38
-
39
- # Index the new card
40
- CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
41
- CARD_INDEX.save
42
- CARD_INDEX.schedule_qmd_reindex
43
-
44
- if similar&.any?
45
- best = similar.first
46
- LOG.info "[CardIndex] Potential duplicate: ##{card_number} '#{title}' ≈ ##{best[:number]} '#{best[:title]}' (score: #{best[:score].round(2)})"
47
-
48
- # Post a comment on the new card warning about the potential duplicate
49
- project_result = identify_project_by_tags(tags)
50
- if project_result
51
- _project_key, project_config = project_result
52
- repo_path = project_config["repo_path"]
53
-
54
- Thread.new do
55
- method_label = { trigram: "📝", semantic: "🧠", both: "📝🧠" }
56
- dupes = similar.map do |s|
57
- icon = method_label[s[:method]] || "📝"
58
- "##{s[:number]} \"#{s[:title]}\" (#{(s[:score] * 100).round}% #{icon})"
59
- end.join("\n- ")
60
- body = "⚠️ **Possible duplicate detected:**\n- #{dupes}\n\n_📝 = text similarity, 🧠 = semantic similarity_"
61
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body, chdir: repo_path, env: default_fizzy_env)
62
- LOG.info "[CardIndex] Posted duplicate warning on card ##{card_number}"
63
- rescue StandardError => e
64
- LOG.warn "[CardIndex] Failed to post duplicate warning: #{e.message}"
65
- end
66
- end
67
-
68
- [200, { status: "duplicate_detected", card: card_number, similar: similar.map { |s| { number: s[:number], score: s[:score].round(2) } } }.to_json]
69
- else
70
- LOG.info "[CardIndex] Card ##{card_number} '#{title}' indexed, no duplicates found"
71
- [200, { status: "indexed", card: card_number }.to_json]
72
- end
73
- end
74
-
75
- def get_default_branch(repo_path)
76
- default_branch = run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: repo_path).strip
77
- begin
78
- run_cmd("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: repo_path).strip.sub("origin/",
79
- "")
80
- rescue StandardError
81
- default_branch
82
- end
83
- end
84
-
85
- # --- Debounced repo git fetch ---
86
- # Avoids fetching the same repo multiple times within a short window (e.g. rapid card assignments).
87
- # Uses fetch instead of checkout+pull so the main repo's working tree is never touched —
88
- # worktrees branch from origin/<default> directly, avoiding conflicts with local changes.
89
- REPO_LAST_FETCH = {}
90
- REPO_FETCH_DEBOUNCE = 300 # 5 minutes
91
-
92
- def debounced_repo_fetch(repo_path)
93
- last = REPO_LAST_FETCH[repo_path]
94
- if last && (Time.now - last) < REPO_FETCH_DEBOUNCE
95
- LOG.info "Skipping git fetch for #{repo_path} — fetched #{(Time.now - last).to_i}s ago"
96
- return
97
- end
98
-
99
- run_cmd("git", "fetch", "origin", chdir: repo_path)
100
-
101
- REPO_LAST_FETCH[repo_path] = Time.now
102
- end
103
-
104
- def handle_card_assigned(payload)
105
- eventable = payload["eventable"] || {}
106
- assignees = eventable["assignees"] || []
107
-
108
- # Check if any LOCAL agent was assigned. Only agents marked "local" in the
109
- # registry (or discovered from kiro-cli configs) should pick up assignments.
110
- # This prevents multiple machines from dispatching the same card.
111
- local_names = local_agent_names
112
- assigned_agent = assignees.map { |a| a["name"] }.find { |name| local_names.include?(name) }
113
-
114
- assignee_names = assignees.map { |a| a["name"] }.join(", ")
115
- LOG.info "[Fizzy] Card assigned to: [#{assignee_names}], local agents: [#{local_names.join(", ")}]"
116
-
117
- unless assigned_agent
118
- LOG.info "[Fizzy] No local agent matched. Assignees: [#{assignee_names}], Local: [#{local_names.join(", ")}]"
119
- return [200, { status: "ignored", reason: "wrong assignee" }.to_json]
120
- end
121
-
122
- unless authorized?(payload)
123
- creator_name = payload.dig("creator", "name") || "Unknown"
124
- notify_unauthorized("card_assigned", creator_name, "card ##{eventable["number"]}")
125
- return [200, { status: "ignored", reason: "unauthorized" }.to_json]
126
- end
127
-
128
- card_number = eventable["number"]
129
- card_internal_id = eventable["id"]
130
- title = eventable["title"] || "untitled"
131
- tags = eventable["tags"] || []
132
-
133
- # Identify project by tags
134
- project_result = identify_project_by_tags(tags)
135
- unless project_result
136
- LOG.warn "No project found for card ##{card_number} with tags: #{tags.map { |t| t.is_a?(Hash) ? t["name"] : t }.join(", ")}"
137
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
138
- end
139
-
140
- project_key, project_config = project_result
141
- repo_path = project_config["repo_path"]
142
-
143
- branch = "fizzy-#{card_number}-#{slugify(title)}"
144
- model = detect_model(project_config, tags: tags)
145
- effort = detect_effort(project_config, tags: tags)
146
- cli_provider_override = detect_cli_provider(tags: tags)
147
-
148
- card_key = "card-#{card_number}"
149
- if session_active?(card_key)
150
- LOG.info "Skipping card ##{card_number} — agent session already active"
151
- return [200, { status: "ignored", reason: "session already active" }.to_json]
152
- end
153
-
154
- LOG.info "Card ##{card_number} assigned to #{assigned_agent} for project '#{project_key}', creating worktree: #{branch} (model: #{model || "default"})"
155
-
156
- # React in background — don't block the dispatch path
157
- Thread.new do
158
- emoji = "👍"
159
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--content", emoji, chdir: repo_path, env: fizzy_env_for(assigned_agent))
160
- LOG.info "Added #{emoji} reaction to card ##{card_number} as #{assigned_agent}"
161
- rescue StandardError => e
162
- LOG.warn "Could not add reaction to card: #{e.message}"
163
- end
164
-
165
- # Fetch latest from origin before creating worktree (doesn't touch working tree)
166
- debounced_repo_fetch(repo_path)
167
-
168
- # Create worktree (handle existing branch)
169
- worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
170
-
171
- # Get current worktree list once
172
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
173
-
174
- # Check if worktree directory exists but is orphaned (not tracked by git)
175
- if File.directory?(worktree_path)
176
- is_tracked = worktree_list.include?(worktree_path)
177
-
178
- if is_tracked
179
- LOG.info "Worktree directory #{worktree_path} is tracked by git"
180
- else
181
- LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
182
- begin
183
- FileUtils.rm_rf(worktree_path)
184
- LOG.info "Successfully removed orphaned directory"
185
- rescue StandardError => e
186
- LOG.error "Failed to remove orphaned directory: #{e.message}"
187
- raise
188
- end
189
- end
190
- end
191
-
192
- # Check if branch already exists
193
- branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
194
-
195
- if branch_exists
196
- LOG.info "Branch #{branch} already exists, checking for existing worktree"
197
-
198
- # Check if worktree already exists for this branch (refresh the list after potential cleanup)
199
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
200
-
201
- # Parse worktree list - format is: worktree <path>\nHEAD <sha>\nbranch <ref>\n\n
202
- has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
203
-
204
- if has_worktree && File.directory?(worktree_path)
205
- LOG.info "Reusing existing worktree at #{worktree_path}"
206
- else
207
- # Branch exists but no worktree, create worktree from existing branch
208
- LOG.info "Creating worktree from existing branch #{branch}"
209
- run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
210
- end
211
- else
212
- # Branch doesn't exist, create new branch and worktree from origin
213
- LOG.info "Creating new branch #{branch} and worktree"
214
- default_branch = get_default_branch(repo_path)
215
- run_cmd("git", "worktree", "add", "-b", branch, worktree_path, "origin/#{default_branch}", chdir: repo_path)
216
- end
217
-
218
- # Trust version manager in the new worktree
219
- trust_version_manager(worktree_path, chdir: worktree_path)
220
-
221
- # Copy gitignored files and symlink directories per .worktreeinclude / .worktreelink
222
- apply_worktree_includes(repo_path, worktree_path)
223
-
224
- # Run project-level worktree-setup hook for anything .worktreeinclude/.worktreelink doesn't cover
225
- run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
226
-
227
- map = load_card_map
228
- map[card_internal_id] = {
229
- "number" => card_number,
230
- "branch" => branch,
231
- "worktree" => worktree_path,
232
- "project" => project_key,
233
- "agent" => assigned_agent
234
- }
235
- save_card_map(map)
236
-
237
- agent_name = assigned_agent
238
-
239
- card_context = prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name)
240
-
241
- # Detect planning mode
242
- planning_info = detect_planning_mode(
243
- text: title,
244
- tags: tags,
245
- card_internal_id: card_internal_id,
246
- card_number: card_number
247
- )
248
-
249
- prompt = if planning_info
250
- # Planning mode
251
- card_id = planning_info[:card_id]
252
- LOG.info "[Planning] Planning mode active for card ##{card_number}"
253
-
254
- render_planning_prompt(PROMPT_CARD_ASSIGNED,
255
- { "CARD_NUMBER" => card_number,
256
- "CARD_TITLE" => title,
257
- "BRANCH" => branch,
258
- "CARD_ID" => card_id,
259
- "COMMENT_CREATOR" => assigned_agent },
260
- brain_context: build_brain_context(agent_name: agent_name, card_title: title, card_number: card_number, project_key: project_key,
261
- source: :fizzy),
262
- card_context: card_context,
263
- agent_name: agent_name)
264
- else
265
- render_prompt(PROMPT_CARD_ASSIGNED,
266
- { "CARD_NUMBER" => card_number,
267
- "CARD_TITLE" => title,
268
- "BRANCH" => branch,
269
- "CARD_ID" => card_number,
270
- "COMMENT_CREATOR" => assigned_agent },
271
- brain_context: build_brain_context(agent_name: agent_name, card_title: title, card_number: card_number, project_key: project_key,
272
- source: :fizzy),
273
- card_context: card_context,
274
- agent_name: agent_name)
275
- end
276
-
277
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "assigned-#{card_number}", model: model, effort: effort, agent_name: agent_name,
278
- card_number: card_number, source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
279
- register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: assigned_agent)
280
-
281
- # Move card to Right Now — agent is starting work
282
- Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: assigned_agent) }
283
-
284
- [200, { status: "processed", card: card_number, branch: branch, project: project_key, agent: assigned_agent }.to_json]
285
- end
286
-
287
- # Deploy a card's worktree to a dev environment via comment shortcut.
288
- # Comment is just "dev02" etc. — no agent dispatch, reactions only.
289
- def handle_deploy_comment(eventable, env_key, card_internal_id)
290
- comment_id = eventable["id"]
291
- card_info = load_card_map[card_internal_id]
292
-
293
- # Validate environment exists in deployments config (check early, before any worktree work)
294
- deploy_config = DEPLOYMENTS_CONFIG["environments"] || {}
295
- unless deploy_config.key?(env_key)
296
- LOG.warn "[Deploy] Unknown environment: #{env_key}"
297
- return [200, { status: "ignored", reason: "unknown environment" }.to_json]
298
- end
299
-
300
- # Check environment ownership — only deploy if this machine owns the env
301
- env_owner = deploy_config[env_key]["owner"]
302
- unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
303
- LOG.info "[Deploy] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
304
- return [200, { status: "ignored", reason: env_owner ? "owned by #{env_owner}" : "no owner configured" }.to_json]
305
- end
306
-
307
- worktree = card_info&.dig("worktree")
308
- card_number = card_info&.dig("number")
309
-
310
- # If worktree doesn't exist locally, try to clone the branch from origin
311
- if worktree.nil? || !File.directory?(worktree)
312
- result = clone_branch_for_deploy(eventable, card_internal_id, card_info)
313
- unless result
314
- LOG.warn "[Deploy] Could not resolve or clone branch for card #{card_internal_id}"
315
- return [200, { status: "ignored", reason: "no worktree and could not clone branch" }.to_json]
316
- end
317
- worktree = result[:worktree]
318
- card_number = result[:card_number]
319
- end
320
-
321
- deploy_script = File.join(worktree, "scripts", "deploy.sh")
322
- unless File.exist?(deploy_script)
323
- LOG.warn "[Deploy] No deploy script at #{deploy_script}"
324
- return [200, { status: "ignored", reason: "no deploy script" }.to_json]
325
- end
326
-
327
- LOG.info "[Deploy] Deploying card ##{card_number} worktree to #{env_key}"
328
-
329
- # Mark environment as deploying (for waybar yellow/orange border)
330
- mark_deploying(env_key, worktree_path: worktree)
331
-
332
- # React with 🚀 (deploying) and run deploy in background
333
- Thread.new do
334
- # Add pending reaction
335
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
336
- "--comment", comment_id.to_s, "--content", "🚀",
337
- chdir: worktree, env: default_fizzy_env)
338
-
339
- # Build deploy environment (inject AWS_PROFILE if configured)
340
- deploy_env = {}
341
- aws_profile = DEPLOYMENTS_CONFIG.dig("environments", env_key, "aws_profile")
342
- deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
343
-
344
- # Run deploy (with terraform lock file retry)
345
- stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
346
-
347
- if !status.success? && terraform_lock_error?(stdout, stderr)
348
- LOG.info "[Deploy] Terraform lock file mismatch for card ##{card_number} — retrying with init -upgrade"
349
- infra_dir = File.join(worktree, "infrastructure", env_key)
350
- lock_file = File.join(infra_dir, ".terraform.lock.hcl")
351
- FileUtils.rm_f(lock_file)
352
- Open3.capture3(deploy_env, "terraform", "init", "-upgrade", chdir: infra_dir) if File.directory?(infra_dir)
353
- stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
354
- end
355
-
356
- if status.success?
357
- LOG.info "[Deploy] Successfully deployed card ##{card_number} to #{env_key}"
358
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
359
- "--comment", comment_id.to_s, "--content", "✅",
360
- chdir: worktree, env: default_fizzy_env)
361
- deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "fizzy-comment")
362
- else
363
- LOG.error "[Deploy] Failed deploying card ##{card_number} to #{env_key}: #{stderr}"
364
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
365
- "--comment", comment_id.to_s, "--content", "❌",
366
- chdir: worktree, env: default_fizzy_env)
367
- record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
368
- end
369
- rescue StandardError => e
370
- LOG.error "[Deploy] Error deploying card ##{card_number} to #{env_key}: #{e.message}"
371
- begin
372
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
373
- "--comment", comment_id.to_s, "--content", "❌",
374
- chdir: worktree, env: default_fizzy_env)
375
- rescue StandardError => inner
376
- LOG.warn "[Deploy] Could not add failure reaction: #{inner.message}"
377
- end
378
- end
379
-
380
- [200, { status: "deploying", card: card_number, env: env_key }.to_json]
381
- end
382
-
383
- # Clone a remote branch locally for deploy when the worktree doesn't exist on this machine.
384
- # Returns { worktree:, card_number: } on success, nil on failure.
385
- def clone_branch_for_deploy(eventable, card_internal_id, card_info)
386
- # Resolve project from card tags
387
- card_tags = eventable.dig("card", "tags") || []
388
- project_result = identify_project_by_tags(card_tags)
389
- unless project_result
390
- LOG.warn "[Deploy] Cannot identify project for card #{card_internal_id}"
391
- return nil
392
- end
393
- project_key, project_config = project_result
394
- repo_path = project_config["repo_path"]
395
-
396
- # Resolve card number
397
- card_number = card_info&.dig("number")
398
- card_number ||= resolve_card_number(card_internal_id, repo_path: repo_path)
399
- unless card_number
400
- LOG.warn "[Deploy] Cannot resolve card number for #{card_internal_id}"
401
- return nil
402
- end
403
-
404
- # Fetch latest and find the branch on origin matching fizzy-<card_number>-*
405
- debounced_repo_fetch(repo_path)
406
- branches = run_cmd("git", "branch", "-r", "--list", "origin/fizzy-#{card_number}-*", chdir: repo_path).strip
407
- branch = branches.lines.map(&:strip).first&.sub("origin/", "")
408
- unless branch
409
- LOG.warn "[Deploy] No remote branch matching fizzy-#{card_number}-* found"
410
- return nil
411
- end
412
-
413
- # Create worktree from the remote branch
414
- worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
415
-
416
- unless File.directory?(worktree_path)
417
- branch_exists_locally = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
418
- if branch_exists_locally
419
- run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
420
- else
421
- run_cmd("git", "worktree", "add", "--track", "-b", branch, worktree_path, "origin/#{branch}", chdir: repo_path)
422
- end
423
-
424
- trust_version_manager(worktree_path, chdir: worktree_path)
425
- apply_worktree_includes(repo_path, worktree_path)
426
- run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
427
- end
428
-
429
- # Update card map
430
- map = load_card_map
431
- map[card_internal_id] ||= {}
432
- map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key)
433
- save_card_map(map)
434
-
435
- LOG.info "[Deploy] Cloned branch #{branch} into worktree #{worktree_path} for card ##{card_number}"
436
- { worktree: worktree_path, card_number: card_number }
437
- rescue StandardError => e
438
- LOG.error "[Deploy] Failed to clone branch for card #{card_internal_id}: #{e.message}"
439
- nil
440
- end
441
-
442
- def handle_comment(payload)
443
- eventable = payload["eventable"] || {}
444
- plain_text = eventable.dig("body", "plain_text") || ""
445
- card_internal_id = eventable.dig("card", "id")
446
-
447
- # --- Deploy shortcut: comment is just "dev02" (or any dev\d+) ---
448
- return handle_deploy_comment(eventable, plain_text.strip.downcase, card_internal_id) if plain_text.strip.match?(/\Adev\d+\z/i)
449
-
450
- # Detect which agent (if any) is @mentioned in the comment
451
- mentioned_agent = detect_mentioned_agent(plain_text)
452
-
453
- # Check if any humans are @mentioned — if so, skip agent dispatch
454
- mentioned_user_ids = detect_mentioned_user_ids(plain_text)
455
- if mentioned_user_ids.any? { |id| human_mentioned?(id) }
456
- LOG.info "[Fizzy] Human @mentioned in comment, skipping agent dispatch"
457
- return [200, { status: "ignored", reason: "human mentioned" }.to_json]
458
- end
459
-
460
- # If an agent is mentioned but not local to this machine, ignore the comment.
461
- # This prevents multiple machines from dispatching the same agent mention.
462
- if mentioned_agent && !local_agent_names.include?(mentioned_agent)
463
- LOG.info "[Fizzy] Ignoring mention of non-local agent #{mentioned_agent}"
464
- return [200, { status: "ignored", reason: "non-local agent mentioned" }.to_json]
465
- end
466
-
467
- mentioned = !mentioned_agent.nil?
468
-
469
- creator_name = eventable.dig("creator", "name")
470
- creator_id = eventable.dig("creator", "id")
471
- creator_is_agent = comment_from_agent?(creator_name)
472
-
473
- # Also check the top-level event creator in case the payload structure differs
474
- event_creator_name = payload.dig("creator", "name")
475
- creator_is_agent ||= comment_from_agent?(event_creator_name)
476
-
477
- # Ignore comments created via API (likely by us via fizzy CLI)
478
- source = eventable["source"] || payload["source"]
479
- is_api_sourced = source && source != "web"
480
-
481
- # --- Authorization check (must happen before agent logic) ---
482
- # Human comments must be from authorized users
483
- unless creator_is_agent || is_api_sourced
484
- unless AUTHORIZED_USER_IDS.include?(creator_id)
485
- notify_unauthorized("comment_created", creator_name, "card #{card_internal_id}")
486
- return [200, { status: "ignored", reason: "unauthorized" }.to_json]
487
- end
488
- # Human comment — reset the dispatch depth counter for this card
489
- record_human_comment(card_internal_id)
490
-
491
- # --- Cancel detection (human-only, before any dispatch logic) ---
492
- cancel_keywords = %w[cancel stop halt abort kill ❌]
493
- if cancel_keywords.include?(plain_text.strip.downcase)
494
- killed = 0
495
- card_number_for_cancel = load_card_map.dig(card_internal_id, "number")
496
- prefixes = ["card-#{card_internal_id}"]
497
- prefixes << "card-#{card_number_for_cancel}" if card_number_for_cancel
498
-
499
- ACTIVE_SESSIONS_MUTEX.synchronize do
500
- ACTIVE_SESSIONS.keys.select { |k| prefixes.any? { |p| k == p || k.start_with?("#{p}-") } }.each do |key|
501
- info = ACTIVE_SESSIONS[key]
502
- next unless info
503
-
504
- begin
505
- Process.kill("KILL", info[:pid])
506
- LOG.info "[Fizzy] Cancelled session #{key} (PID: #{info[:pid]})"
507
- rescue Errno::ESRCH, Errno::EPERM => e
508
- LOG.warn "[Fizzy] Could not kill #{key}: #{e.message}"
509
- end
510
- archive_session(key, info)
511
- ACTIVE_SESSIONS.delete(key)
512
- killed += 1
513
- end
514
- end
515
-
516
- # Add 🛑 reaction to the cancel comment
517
- comment_id_for_cancel = eventable["id"]
518
- card_info_for_cancel = load_card_map[card_internal_id]
519
- if card_info_for_cancel && card_number_for_cancel && comment_id_for_cancel
520
- repo = (card_info_for_cancel["project"] && PROJECTS.dig(card_info_for_cancel["project"], "repo_path")) || DEFAULT_PROJECT["repo_path"]
521
- Thread.new do
522
- run_cmd("fizzy", "reaction", "create", "--card", card_number_for_cancel.to_s, "--comment", comment_id_for_cancel.to_s, "--content", "🛑",
523
- chdir: repo, env: default_fizzy_env)
524
- rescue StandardError => e
525
- LOG.warn "[Fizzy] Could not add 🛑 reaction: #{e.message}"
526
- end
527
- end
528
-
529
- LOG.info "[Fizzy] Cancel command received for card #{card_number_for_cancel || card_internal_id}: killed #{killed} session(s)"
530
- return [200, { status: "cancelled", card: card_number_for_cancel || card_internal_id, sessions_killed: killed }.to_json]
531
- end
532
- end
533
-
534
- # --- Agent comment validation ---
535
- # Agents can only act on cards where they're assigned or explicitly @mentioned.
536
- # This prevents agents from hijacking unrelated cards.
537
- if creator_is_agent || is_api_sourced
538
- card_info = load_card_map[card_internal_id]
539
- card_assigned_agent = card_info&.dig("agent")
540
-
541
- # Agent is allowed if:
542
- # 1. They're assigned to this card, OR
543
- # 2. They're explicitly @mentioned in this comment
544
- agent_is_assigned = card_assigned_agent && card_assigned_agent.downcase == (creator_name || "").downcase
545
- agent_is_mentioned = mentioned_agent && mentioned_agent.downcase == (creator_name || "").downcase
546
-
547
- unless agent_is_assigned || agent_is_mentioned
548
- LOG.info "Blocking agent comment from #{creator_name} on card #{card_internal_id}: not assigned and not mentioned"
549
- return [200, { status: "ignored", reason: "agent not assigned or mentioned" }.to_json]
550
- end
551
-
552
- # --- Agent-to-agent loop prevention ---
553
- # If the agent is @mentioning a *different* agent, check dispatch depth
554
- if mentioned_agent && mentioned_agent.downcase != (creator_name || "").downcase
555
- unless agent_dispatch_allowed?(card_internal_id)
556
- LOG.info "Blocking agent-to-agent dispatch on card #{card_internal_id}: depth limit reached (#{creator_name} → @#{mentioned_agent})"
557
- return [200, { status: "ignored", reason: "agent-to-agent depth limit" }.to_json]
558
- end
559
- LOG.info "Allowing agent-to-agent dispatch on card #{card_internal_id}: #{creator_name} → @#{mentioned_agent}"
560
- # Fall through — this agent mention will be processed below
561
- elsif !mentioned_agent
562
- # Agent comment with no @mention — this is a self-comment, ignore it
563
- LOG.info "Ignoring self-comment from #{creator_name} on card #{card_internal_id}"
564
- return [200, { status: "ignored", reason: "self-comment" }.to_json]
565
- end
566
- # If mentioned_agent == creator_name, that's the agent mentioning themselves,
567
- # which is weird but harmless — let it through (will be handled as self-comment below)
568
- end
569
-
570
- comment_id = eventable["id"]
571
- card_info = load_card_map[card_internal_id]
572
-
573
- return [200, { status: "ignored", reason: "not relevant" }.to_json] unless mentioned || card_info
574
-
575
- # Get project config from card_info or detect from tags
576
- project_config = nil
577
- project_key = nil
578
-
579
- if card_info
580
- if card_info["project"]
581
- project_key = card_info["project"]
582
- project_config = PROJECTS[project_key] || DEFAULT_PROJECT
583
- else
584
- # card_info exists but was registered before project tracking — resolve from tags
585
- card_tags = eventable.dig("card", "tags") || []
586
- project_result = identify_project_by_tags(card_tags)
587
- if project_result
588
- project_key, project_config = project_result
589
- # Backfill the project key into the card map
590
- card_info["project"] = project_key
591
- map = load_card_map
592
- map[card_internal_id] = card_info
593
- save_card_map(map)
594
- LOG.info "Backfilled project '#{project_key}' for card #{card_internal_id} in card map"
595
- else
596
- LOG.warn "No project found for card #{card_internal_id}"
597
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
598
- end
599
- end
600
- elsif mentioned
601
- # Try to detect project from card tags
602
- card_tags = eventable.dig("card", "tags") || []
603
- project_result = identify_project_by_tags(card_tags)
604
- if project_result
605
- project_key, project_config = project_result
606
- else
607
- LOG.warn "No project found for mentioned card #{card_internal_id}"
608
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
609
- end
610
- end
611
-
612
- # Check for [deploy] or [deploy:envN] tag — triggers auto-deploy after agent session
613
- deploy_intent = nil
614
- if (deploy_match = plain_text.match(/\[deploy(?::([^\]]+))?\]/i))
615
- deploy_intent = deploy_match[1]&.strip&.downcase || :auto # :auto means "auto-detect env"
616
- plain_text = plain_text.sub(deploy_match[0], "").strip
617
- LOG.info "[Deploy] Detected [deploy#{":#{deploy_intent}" unless deploy_intent == :auto}] tag on card #{card_internal_id}"
618
- end
619
-
620
- # Strip [effort:X] tag from prompt content (detect_effort reads from original text via tags + inline)
621
- effort_text_for_detection = plain_text
622
- plain_text = plain_text.sub(/\[effort:\w+\]/i, "").strip
623
-
624
- # Check for [worktree:branch-name] override in comment text — lets you direct
625
- # Galen to a specific branch/worktree instead of the one in the card map.
626
- worktree_override = nil
627
- if (wt_match = plain_text.match(/\[worktree:([^\]]+)\]/))
628
- override_branch = wt_match[1].strip
629
- repo_path_for_override = project_config["repo_path"]
630
- candidate = File.join(File.dirname(repo_path_for_override), "#{File.basename(repo_path_for_override)}--#{override_branch}")
631
- if File.directory?(candidate)
632
- worktree_override = { "branch" => override_branch, "worktree" => candidate }
633
- LOG.info "Worktree override requested: #{override_branch} -> #{candidate}"
634
- else
635
- LOG.warn "Worktree override branch '#{override_branch}' not found at #{candidate}, ignoring"
636
- end
637
- end
638
-
639
- card_tags = eventable.dig("card", "tags") || []
640
- model = detect_model(project_config, text: plain_text)
641
- effort = detect_effort(project_config, tags: card_tags, text: effort_text_for_detection)
642
- cli_provider_override = detect_cli_provider(text: plain_text, tags: card_tags)
643
-
644
- # Strip [cli:X] tag from prompt content
645
- plain_text = plain_text.sub(/\[cli:\w+\]/i, "").strip
646
-
647
- # Determine which agent should handle this comment.
648
- #
649
- # Only local agents (marked with "local": true in ~/.brainiac/agents.json or
650
- # discovered from ~/.kiro/agents/*.json configs) can be dispatched on this machine.
651
- # Non-local agents are filtered out earlier in the flow.
652
- #
653
- # - If @Galen is mentioned and Galen is local, dispatch Galen
654
- # - If no agent is mentioned but the card is in our card_map, the card's assigned agent handles it
655
- # - If the mentioned agent differs from the card's assigned agent, it's a cross-agent review
656
- card_assigned_agent = card_info&.dig("agent")
657
-
658
- # When card_info is nil (card not in map), try to resolve the assigned agent
659
- # from the webhook payload's card assignees. This handles reactivated cards
660
- # or cards that were cleared from the map.
661
- if card_assigned_agent.nil?
662
- card_assignees = eventable.dig("card", "assignees") || []
663
- webhook_agent = card_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }
664
-
665
- # Webhook payload often lacks assignees — query Fizzy API as fallback
666
- if webhook_agent.nil? && project_config
667
- api_card_number = card_info&.dig("number") || eventable.dig("card", "number")
668
- if api_card_number
669
- begin
670
- output = run_cmd("fizzy", "card", "show", api_card_number.to_s, chdir: project_config["repo_path"], env: default_fizzy_env)
671
- api_assignees = begin
672
- JSON.parse(output).dig("data", "assignees") || []
673
- rescue StandardError
674
- []
675
- end
676
- webhook_agent = api_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }
677
- LOG.info "Resolved assigned agent '#{webhook_agent}' via Fizzy API for card ##{api_card_number}" if webhook_agent
678
- rescue StandardError => e
679
- LOG.warn "Fizzy API fallback failed for card ##{api_card_number}: #{e.message}"
680
- end
681
- end
682
- end
683
-
684
- if webhook_agent
685
- card_assigned_agent = webhook_agent
686
- # Backfill the card map so subsequent comments work without this fallback
687
- map = load_card_map
688
- map[card_internal_id] ||= {}
689
- map[card_internal_id]["agent"] = webhook_agent
690
- save_card_map(map)
691
- LOG.info "Backfilled agent '#{webhook_agent}' into card map for #{card_internal_id}"
692
- end
693
- end
694
-
695
- if mentioned_agent
696
- agent_name = mentioned_agent
697
- # If the mentioned agent differs from the card's assigned agent, this is a
698
- # cross-agent mention (e.g. "@Galen what do you think?" on Kaylee's card).
699
- # The mentioned agent should review/discuss, not take over the card's worktree.
700
- is_cross_agent_mention = !card_assigned_agent || card_assigned_agent != mentioned_agent
701
- else
702
- # If no agent is assigned and none was mentioned, don't fall back to the
703
- # project default — that causes orphaned card map entries to dispatch the
704
- # wrong agent (e.g. Kaylee getting triggered on Sheogorath's card).
705
- unless card_assigned_agent
706
- LOG.info "Skipping card #{card_internal_id} — no assigned agent and no mention"
707
- return [200, { status: "ignored", reason: "no assigned agent" }.to_json]
708
- end
709
- agent_name = card_assigned_agent
710
- is_cross_agent_mention = false
711
- end
712
-
713
- # Per-card comment cooldown — suppress rapid-fire near-duplicate triggers.
714
- # Include agent name in the key so cross-agent mentions don't block each other.
715
- cooldown_key = "card-#{card_info ? (card_info["number"] || card_internal_id) : card_internal_id}-#{agent_name.downcase}"
716
- if on_comment_cooldown?(cooldown_key)
717
- LOG.info "Skipping comment on #{cooldown_key} — within #{COMMENT_COOLDOWN}s cooldown"
718
- return [200, { status: "ignored", reason: "comment cooldown" }.to_json]
719
- end
720
- touch_comment_cooldown(cooldown_key)
721
-
722
- # Common template vars for the triggering comment
723
- comment_vars = {
724
- "COMMENT_CREATOR" => creator_name || "Unknown",
725
- "COMMENT_ID" => comment_id.to_s,
726
- "COMMENT_BODY" => plain_text
727
- }
728
-
729
- # --- Cross-agent mention: an agent is tagged on a card owned by a different agent ---
730
- # e.g. Kaylee is working on card #42, Andy comments "@Galen what do you think?"
731
- # Galen reviews and responds without touching Kaylee's worktree.
732
- # Also handles: SecurityBot tagged on Galen's card to audit the code.
733
- if is_cross_agent_mention
734
- # Skip dispatch when the comment is a card creation/assignment announcement.
735
- # The Fizzy webhook handles card assignments — dispatching here too causes
736
- # the mentioned agent to respond on the *original* card instead of the new one.
737
- if creator_is_agent && (plain_text.match?(/created\s+card\s+#?\d+/i) || plain_text.match?(/assigned\s+.*card\s+#?\d+/i) || plain_text.match?(/card\s+#?\d+.*assigned/i))
738
- LOG.info "Ignoring cross-agent mention from #{creator_name} on card #{card_internal_id} — Fizzy card creation/assignment (handled by webhook)"
739
- return [200, { status: "ignored", reason: "card creation announcement" }.to_json]
740
- end
741
-
742
- card_number = card_info&.dig("number")
743
-
744
- # Resolve card_number if missing
745
- if card_number.nil?
746
- card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
747
- if card_number
748
- map = load_card_map
749
- map[card_internal_id] ||= {}
750
- map[card_internal_id]["number"] = card_number
751
- save_card_map(map)
752
- end
753
- end
754
-
755
- card_key = "card-#{card_number || card_internal_id}-#{agent_name.downcase}"
756
- if creator_is_agent && session_active?(card_key)
757
- unless wait_for_session?(card_key)
758
- LOG.info "Giving up on cross-agent dispatch for #{agent_name} on card #{card_number || card_internal_id} — session didn't finish in time"
759
- return [200, { status: "ignored", reason: "session wait timeout" }.to_json]
760
- end
761
- elsif session_active?(card_key)
762
- LOG.info "Skipping cross-agent mention for #{agent_name} on card #{card_number || card_internal_id} — session already active"
763
- return [200, { status: "ignored", reason: "session already active" }.to_json]
764
- end
765
-
766
- LOG.info "Cross-agent mention: #{agent_name} tagged on #{card_assigned_agent}'s card ##{card_number || card_internal_id} (project: #{project_key})"
767
-
768
- # Record this agent-to-agent dispatch for loop prevention
769
- record_agent_dispatch(card_internal_id) if creator_is_agent
770
-
771
- # React in background — don't block the dispatch path
772
- Thread.new do
773
- if card_number
774
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👀",
775
- chdir: project_config["repo_path"], env: fizzy_env_for(agent_name))
776
- LOG.info "Added 👀 reaction to comment ##{comment_id} for #{agent_name}"
777
- end
778
- rescue StandardError => e
779
- LOG.warn "Could not add reaction to comment: #{e.message}"
780
- end
781
-
782
- # Create a worktree for the cross-agent reviewer so they don't clobber the
783
- # main repo's working tree (or the assigned agent's worktree).
784
- repo_path = project_config["repo_path"]
785
- review_branch = "#{agent_name.downcase}/fizzy-#{card_number}-#{slugify(card_info&.dig("title") || eventable.dig("card", "title") || "review")}"
786
- review_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{review_branch.tr("/", "-")}")
787
-
788
- debounced_repo_fetch(repo_path)
789
-
790
- # Reuse existing worktree or create a new one
791
- if File.directory?(review_worktree_path)
792
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
793
- FileUtils.rm_rf(review_worktree_path) unless worktree_list.include?(review_worktree_path)
794
- end
795
-
796
- if File.directory?(review_worktree_path)
797
- LOG.info "Reusing existing cross-agent review worktree at #{review_worktree_path}"
798
- else
799
- # Branch from the card's branch if it exists, otherwise from origin default
800
- card_branch = card_info&.dig("branch")
801
- branch_exists = card_branch && system("git", "rev-parse", "--verify", card_branch, chdir: repo_path, out: File::NULL, err: File::NULL)
802
- base_ref = branch_exists ? card_branch : "origin/#{get_default_branch(repo_path)}"
803
-
804
- # Delete stale local branch if it exists (from a previous review)
805
- if system("git", "rev-parse", "--verify", review_branch, chdir: repo_path, out: File::NULL, err: File::NULL)
806
- run_cmd("git", "branch", "-D", review_branch, chdir: repo_path)
807
- end
808
-
809
- run_cmd("git", "worktree", "add", "-b", review_branch, review_worktree_path, base_ref, chdir: repo_path)
810
- trust_version_manager(review_worktree_path, chdir: review_worktree_path)
811
- apply_worktree_includes(repo_path, review_worktree_path)
812
- run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => review_worktree_path })
813
- LOG.info "Created cross-agent review worktree at #{review_worktree_path} (base: #{base_ref})"
814
- end
815
-
816
- card_context = prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name)
817
-
818
- prompt = render_prompt(PROMPT_CROSS_AGENT_REVIEW,
819
- comment_vars.merge(
820
- "CARD_NUMBER" => card_number || "N/A",
821
- "CARD_INTERNAL_ID" => card_internal_id,
822
- "CARD_ID" => card_number || card_internal_id,
823
- "CARD_AGENT" => card_assigned_agent,
824
- "WORKTREE_PATH" => review_worktree_path,
825
- "BRANCH" => review_branch
826
- ),
827
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
828
- source: :fizzy),
829
- card_context: card_context,
830
- agent_name: agent_name)
831
-
832
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: review_worktree_path,
833
- log_name: "review-#{agent_name.downcase}-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name,
834
- card_number: card_number, comment_id: comment_id,
835
- source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
836
- register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
837
-
838
- return [200, { status: "cross_agent_review", agent: agent_name, card_agent: card_assigned_agent,
839
- card: card_number, card_internal_id: card_internal_id, project: project_key, worktree: review_worktree_path }.to_json]
840
- end
841
-
842
- if card_info || worktree_override
843
- # Merge worktree override into card_info if provided
844
- effective_info = worktree_override ? (card_info || {}).merge(worktree_override) : card_info
845
- card_number = effective_info["number"]
846
- worktree = effective_info["worktree"]
847
-
848
- # Resolve card_number if missing from the map entry
849
- if card_number.nil?
850
- card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
851
- if card_number
852
- # Backfill into card map for next time
853
- map = load_card_map
854
- map[card_internal_id] ||= {}
855
- map[card_internal_id]["number"] = card_number
856
- save_card_map(map)
857
- LOG.info "Backfilled card number #{card_number} for #{card_internal_id}"
858
- end
859
- end
860
-
861
- # If worktree is missing or gone, try to find one by card number on disk
862
- if !(worktree && File.directory?(worktree)) && card_number
863
- repo_dir = File.dirname(project_config["repo_path"])
864
- repo_base = File.basename(project_config["repo_path"])
865
- candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
866
- if candidates.any?
867
- worktree = candidates.first
868
- branch_name = File.basename(worktree).sub("#{repo_base}--", "")
869
- # Backfill worktree + branch into card map
870
- map = load_card_map
871
- map[card_internal_id] ||= {}
872
- map[card_internal_id].merge!("worktree" => worktree, "branch" => branch_name)
873
- save_card_map(map)
874
- LOG.info "Found worktree by card number scan: #{worktree} (branch: #{branch_name})"
875
- end
876
- end
877
-
878
- work_dir = worktree && File.directory?(worktree) ? worktree : project_config["repo_path"]
879
- card_key = "card-#{card_number || card_internal_id}"
880
-
881
- # If an agent tagged this card's own agent back (e.g. GLaDOS tags @Galen on
882
- # Galen's card), the original agent may still be running. Wait for it to finish
883
- # rather than dropping the dispatch — the depth system already validated this.
884
- if creator_is_agent && session_active?(card_key)
885
- unless wait_for_session?(card_key)
886
- LOG.info "Giving up on agent-to-agent dispatch for card #{card_number || card_internal_id} — session didn't finish in time"
887
- return [200, { status: "ignored", reason: "session wait timeout" }.to_json]
888
- end
889
- elsif session_active?(card_key)
890
- # Supersede: if the human comments within 60s, kill the previous run and start fresh
891
- prev = find_supersedable_session(card_key)
892
- if prev
893
- LOG.info "Superseding session on card #{card_number || card_internal_id} (pid: #{prev[:pid]}) — human follow-up within #{SUPERSEDE_WINDOW}s"
894
- kill_session(prev[:session_key])
895
- # Fall through to dispatch fresh below
896
- else
897
- # After 60s: queue and wait for the active session to finish, then dispatch
898
- LOG.info "Queuing follow-up comment on card #{card_number || card_internal_id} — waiting for active session to finish"
899
-
900
- # React immediately so the human knows we saw it
901
- Thread.new do
902
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👍", chdir: work_dir,
903
- env: fizzy_env_for(agent_name))
904
- LOG.info "Added 👍 reaction to queued comment ##{comment_id} as #{agent_name}"
905
- rescue StandardError => e
906
- LOG.warn "Could not add reaction to queued comment: #{e.message}"
907
- end
908
-
909
- Thread.new do
910
- unless wait_for_session?(card_key)
911
- LOG.warn "Giving up on queued follow-up for card #{card_number || card_internal_id} — session didn't finish in time"
912
- next
913
- end
914
-
915
- LOG.info "Active session finished, dispatching queued follow-up for card #{card_number || card_internal_id}"
916
- dispatch_followup_comment(
917
- card_key: card_key, card_number: card_number, card_internal_id: card_internal_id,
918
- work_dir: work_dir, project_config: project_config, project_key: project_key,
919
- comment_vars: comment_vars, plain_text: plain_text, model: model,
920
- agent_name: agent_name, comment_id: comment_id, eventable: eventable,
921
- deploy_intent: deploy_intent, cli_provider: cli_provider_override
922
- )
923
- end
924
-
925
- return [200, { status: "queued", card: card_number, card_internal_id: card_internal_id, reason: "waiting for active session" }.to_json]
926
- end
927
- end
928
-
929
- LOG.info "Follow-up comment on card #{card_number || card_internal_id} (project: #{project_key}), worktree: #{work_dir}"
930
-
931
- # React in background — don't block the dispatch path
932
- Thread.new do
933
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👍", chdir: work_dir,
934
- env: fizzy_env_for(agent_name))
935
- LOG.info "Added 👍 reaction to comment ##{comment_id} as #{agent_name}"
936
- rescue StandardError => e
937
- LOG.warn "Could not add reaction to comment: #{e.message}"
938
- end
939
-
940
- result = dispatch_followup_comment(
941
- card_key: card_key, card_number: card_number, card_internal_id: card_internal_id,
942
- work_dir: work_dir, project_config: project_config, project_key: project_key,
943
- comment_vars: comment_vars, plain_text: plain_text, model: model,
944
- agent_name: agent_name, comment_id: comment_id, eventable: eventable,
945
- deploy_intent: deploy_intent, cli_provider: cli_provider_override
946
- )
947
- [200, result.to_json]
948
- else
949
- # Get card data to extract number and title
950
- card_data = eventable["card"] || {}
951
- card_number = card_data["number"]
952
- card_title = card_data["title"] || "exploration"
953
-
954
- # If card_number is missing from the webhook payload, resolve it via fizzy CLI,
955
- # falling back to the card map as a cheap cache.
956
- if card_number.nil?
957
- map_entry = load_card_map[card_internal_id]
958
- if map_entry && map_entry["number"]
959
- card_number = map_entry["number"]
960
- LOG.info "Resolved card number #{card_number} from card map for internal_id #{card_internal_id}"
961
- else
962
- card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
963
- end
964
- end
965
-
966
- LOG.info "#{agent_name} mentioned on card (internal_id: #{card_internal_id}, project: #{project_key}), creating exploration worktree"
967
-
968
- # Record agent-to-agent dispatch for loop prevention
969
- record_agent_dispatch(card_internal_id) if creator_is_agent
970
-
971
- card_key = "card-#{card_number || card_internal_id}"
972
- if session_active?(card_key)
973
- LOG.info "Skipping mention on card #{card_number || card_internal_id} — agent session already active"
974
- return [200, { status: "ignored", reason: "session already active" }.to_json]
975
- end
976
-
977
- # React in background — don't block the dispatch path
978
- Thread.new do
979
- if card_number
980
- run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👀",
981
- chdir: project_config["repo_path"], env: fizzy_env_for(agent_name))
982
- LOG.info "Added 👀 reaction to comment ##{comment_id} as #{agent_name}"
983
- else
984
- LOG.warn "Could not add reaction: card number not available in webhook payload or card map"
985
- end
986
- rescue StandardError => e
987
- LOG.warn "Could not add reaction to comment: #{e.message}"
988
- end
989
-
990
- # Create exploration branch and worktree
991
- repo_path = project_config["repo_path"]
992
-
993
- # Check if the card already has a branch/worktree in the map (e.g. registered
994
- # by a previous assign event). If so, reuse it rather than spinning up a new one.
995
- # Also check by card number in case the map entry predates project tracking.
996
- existing_map_entry = load_card_map[card_internal_id]
997
-
998
- # If the map entry has a valid worktree, use it directly
999
- if existing_map_entry && existing_map_entry["branch"] && existing_map_entry["worktree"] &&
1000
- File.directory?(existing_map_entry["worktree"])
1001
- branch = existing_map_entry["branch"]
1002
- worktree_path = existing_map_entry["worktree"]
1003
- LOG.info "Reusing existing worktree from card map: #{worktree_path} (branch: #{branch})"
1004
- elsif card_number
1005
- # Map entry missing or stale — scan for any worktree directory matching fizzy-NNN-*
1006
- repo_dir = File.dirname(repo_path)
1007
- repo_base = File.basename(repo_path)
1008
- pattern = File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")
1009
- candidates = Dir.glob(pattern).select { |d| File.directory?(d) }
1010
- if candidates.any?
1011
- worktree_path = candidates.first
1012
- branch = File.basename(worktree_path).sub("#{repo_base}--", "")
1013
- LOG.info "Found existing worktree by card number scan: #{worktree_path} (branch: #{branch})"
1014
- end
1015
- end
1016
-
1017
- if worktree_path && File.directory?(worktree_path)
1018
- LOG.info "Reusing worktree at #{worktree_path} (branch: #{branch})"
1019
-
1020
- map = load_card_map
1021
- map[card_internal_id] ||= {}
1022
- map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key,
1023
- "agent" => agent_name)
1024
- save_card_map(map)
1025
-
1026
- # Detect planning mode
1027
- card_tags = eventable.dig("card", "tags") || []
1028
-
1029
- # Check if we can resume the existing session (lean prompt) vs full prompt
1030
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name)
1031
- should_resume = resolved["resume_flag"]
1032
-
1033
- if should_resume
1034
- prompt = render_resume_prompt(
1035
- comment_body: plain_text,
1036
- comment_creator: comment_vars["COMMENT_CREATOR"],
1037
- comment_id: comment_id,
1038
- card_number: card_number,
1039
- agent_name: agent_name
1040
- )
1041
- LOG.info "[Resume] Using lean prompt for mention on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
1042
- else
1043
- planning_info = detect_planning_mode(
1044
- text: plain_text,
1045
- tags: card_tags,
1046
- card_internal_id: card_internal_id,
1047
- card_number: card_number
1048
- )
1049
-
1050
- prompt = if planning_info
1051
- # Planning mode
1052
- card_id = planning_info[:card_id]
1053
- LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}"
1054
-
1055
- render_planning_prompt(PROMPT_MENTION,
1056
- comment_vars.merge(
1057
- "CARD_INTERNAL_ID" => card_internal_id,
1058
- "CARD_ID" => card_id,
1059
- "CARD_NUMBER" => card_number || "N/A",
1060
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1061
- "BRANCH" => branch
1062
- ),
1063
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1064
- comment_body: plain_text, source: :fizzy),
1065
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1066
- agent_name: agent_name)
1067
- else
1068
- render_prompt(PROMPT_MENTION,
1069
- comment_vars.merge(
1070
- "CARD_INTERNAL_ID" => card_internal_id,
1071
- "CARD_ID" => card_number || card_internal_id,
1072
- "CARD_NUMBER" => card_number || "N/A",
1073
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1074
- "BRANCH" => branch
1075
- ),
1076
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1077
- comment_body: plain_text, source: :fizzy),
1078
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1079
- agent_name: agent_name)
1080
- end
1081
- end
1082
-
1083
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1084
- source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override, resume: true)
1085
- register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1086
- return [200,
1087
- { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
1088
- project: project_key }.to_json]
1089
- end
1090
-
1091
- branch = card_number ? "fizzy-#{card_number}-#{slugify(card_title)}" : "fizzy-explore-#{card_internal_id[0..7]}"
1092
-
1093
- # Fetch latest from origin (doesn't touch working tree)
1094
- debounced_repo_fetch(repo_path)
1095
-
1096
- # Create worktree (handle existing branch)
1097
- worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
1098
-
1099
- # Get current worktree list once
1100
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
1101
-
1102
- # Check if worktree directory exists but is orphaned (not tracked by git)
1103
- if File.directory?(worktree_path)
1104
- is_tracked = worktree_list.include?(worktree_path)
1105
-
1106
- if is_tracked
1107
- LOG.info "Worktree directory #{worktree_path} is tracked by git"
1108
- else
1109
- LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
1110
- begin
1111
- FileUtils.rm_rf(worktree_path)
1112
- LOG.info "Successfully removed orphaned directory"
1113
- rescue StandardError => e
1114
- LOG.error "Failed to remove orphaned directory: #{e.message}"
1115
- raise
1116
- end
1117
- end
1118
- end
1119
-
1120
- # Check if branch already exists
1121
- branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
1122
-
1123
- if branch_exists
1124
- LOG.info "Branch #{branch} already exists, checking for existing worktree"
1125
-
1126
- # Check if worktree already exists for this branch (refresh the list after potential cleanup)
1127
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
1128
-
1129
- # Parse worktree list - format is: worktree <path>\nHEAD <sha>\nbranch <ref>\n\n
1130
- has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
1131
-
1132
- if has_worktree && File.directory?(worktree_path)
1133
- LOG.info "Reusing existing worktree at #{worktree_path}"
1134
- else
1135
- # Branch exists but no worktree, create worktree from existing branch
1136
- LOG.info "Creating worktree from existing branch #{branch}"
1137
- run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
1138
- end
1139
- else
1140
- # Branch doesn't exist, create new branch and worktree from origin
1141
- LOG.info "Creating new exploration branch #{branch} and worktree"
1142
- default_branch = get_default_branch(repo_path)
1143
- run_cmd("git", "worktree", "add", "-b", branch, worktree_path, "origin/#{default_branch}", chdir: repo_path)
1144
- end
1145
-
1146
- # Trust version manager in the new worktree
1147
- trust_version_manager(worktree_path, chdir: worktree_path)
1148
-
1149
- # Copy gitignored files and symlink directories per .worktreeinclude / .worktreelink
1150
- apply_worktree_includes(repo_path, worktree_path)
1151
-
1152
- # Run project-level worktree-setup hook for anything .worktreeinclude/.worktreelink doesn't cover
1153
- run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
1154
-
1155
- map = load_card_map
1156
- map[card_internal_id] = {
1157
- "number" => card_number,
1158
- "branch" => branch,
1159
- "worktree" => worktree_path,
1160
- "project" => project_key,
1161
- "agent" => agent_name
1162
- }
1163
- save_card_map(map)
1164
-
1165
- # Detect planning mode
1166
- card_tags = eventable.dig("card", "tags") || []
1167
- planning_info = detect_planning_mode(
1168
- text: plain_text,
1169
- tags: card_tags,
1170
- card_internal_id: card_internal_id,
1171
- card_number: card_number
1172
- )
1173
-
1174
- prompt = if planning_info
1175
- # Planning mode
1176
- card_id = planning_info[:card_id]
1177
- LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}"
1178
-
1179
- render_planning_prompt(PROMPT_MENTION,
1180
- comment_vars.merge(
1181
- "CARD_INTERNAL_ID" => card_internal_id,
1182
- "CARD_ID" => card_id,
1183
- "CARD_NUMBER" => card_number || "N/A",
1184
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1185
- "BRANCH" => branch
1186
- ),
1187
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1188
- comment_body: plain_text, source: :fizzy),
1189
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1190
- agent_name: agent_name)
1191
- else
1192
- render_prompt(PROMPT_MENTION,
1193
- comment_vars.merge(
1194
- "CARD_INTERNAL_ID" => card_internal_id,
1195
- "CARD_ID" => card_number || card_internal_id,
1196
- "CARD_NUMBER" => card_number || "N/A",
1197
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1198
- "BRANCH" => branch
1199
- ),
1200
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1201
- comment_body: plain_text, source: :fizzy),
1202
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1203
- agent_name: agent_name)
1204
- end
1205
-
1206
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1207
- source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
1208
- register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1209
- [200,
1210
- { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
1211
- project: project_key }.to_json]
1212
- end
1213
- end
1214
-
1215
- # Dispatch a follow-up comment to the agent. Extracted so it can be called
1216
- # both inline (no active session) and from a queued background thread.
1217
- def dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:,
1218
- model:, agent_name:, comment_id:, eventable:, deploy_intent: nil, cli_provider: nil)
1219
- card_tags = eventable.dig("card", "tags") || []
1220
- effort = detect_effort(project_config, tags: card_tags, text: plain_text)
1221
-
1222
- # Determine if we should resume (worktree + provider supports it)
1223
- is_worktree = work_dir != project_config["repo_path"]
1224
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
1225
- should_resume = is_worktree && resolved["resume_flag"]
1226
-
1227
- if should_resume
1228
- # Lean prompt: only the new comment. The previous session
1229
- # already has role, persona, knowledge, core instructions, and card history.
1230
- prompt = render_resume_prompt(
1231
- comment_body: plain_text,
1232
- comment_creator: comment_vars["COMMENT_CREATOR"],
1233
- comment_id: comment_id,
1234
- card_number: card_number,
1235
- agent_name: agent_name
1236
- )
1237
- LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
1238
- else
1239
- # Full prompt: no session to resume, build everything from scratch
1240
- planning_info = detect_planning_mode(
1241
- text: plain_text,
1242
- tags: card_tags,
1243
- card_internal_id: card_internal_id,
1244
- card_number: card_number
1245
- )
1246
-
1247
- prompt = if planning_info
1248
- card_id = planning_info[:card_id]
1249
- LOG.info "[Planning] Planning mode active for card #{card_number || card_internal_id}"
1250
-
1251
- if work_dir == project_config["repo_path"]
1252
- render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1253
- comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id),
1254
- brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1255
- source: :fizzy),
1256
- card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"],
1257
- agent_name: agent_name),
1258
- agent_name: agent_name)
1259
- else
1260
- render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
1261
- comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
1262
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1263
- source: :fizzy),
1264
- card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1265
- agent_name: agent_name)
1266
- end
1267
- elsif work_dir != project_config["repo_path"]
1268
- render_prompt(PROMPT_FOLLOWUP_WORKTREE,
1269
- comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
1270
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1271
- source: :fizzy),
1272
- card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1273
- agent_name: agent_name)
1274
- else
1275
- render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1276
- comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id),
1277
- brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1278
- source: :fizzy),
1279
- card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name),
1280
- agent_name: agent_name)
1281
- end
1282
- end
1283
-
1284
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "followup-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1285
- source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent }, cli_provider: cli_provider, resume: is_worktree)
1286
- register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1287
-
1288
- # Move card to Right Now — agent is actively working again
1289
- Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: agent_name) }
1290
-
1291
- { status: "follow_up", card: card_number, card_internal_id: card_internal_id, worktree: work_dir, project: project_key }
1292
- end