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