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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- 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
|