brainiac-basecamp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,820 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # Registers lifecycle hooks with the core event system.
9
+ #
10
+ # Hooks:
11
+ # :agent_completed — advances epic or dispatches review gates
12
+ # :pr_merged — marks task as truly complete (on_pr_merge mode)
13
+ # :pr_review_received — tracks gate approvals / change requests
14
+ # :pr_synchronized — re-triggers gates after fixes are pushed
15
+ # :build_brain_context — injects epic context into agent prompts
16
+ # :resolve_base_branch — returns epic branch as worktree base
17
+ # :resolve_pr_target — returns epic branch as PR target
18
+ module Hooks
19
+ class << self
20
+ def register_all!
21
+ register_agent_completed
22
+ register_pr_merged
23
+ register_pr_review_received
24
+ register_pr_synchronized
25
+ register_build_brain_context
26
+ register_resolve_base_branch
27
+ register_resolve_pr_target
28
+ end
29
+
30
+ private
31
+
32
+ # When an implementation agent completes a Fizzy card task:
33
+ # - on_complete: advance immediately
34
+ # - on_pr_merge: wait for manual PR merge
35
+ # - epic_branch: dispatch review gates (parallel), then auto-merge when all approve
36
+ def register_agent_completed
37
+ Brainiac.on(:agent_completed) do |ctx|
38
+ next unless ctx[:source] == :fizzy
39
+ next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
40
+
41
+ card_number = ctx[:card_number]
42
+ next unless card_number
43
+
44
+ epic = Orchestrator.find_epic_for_card(card_number)
45
+ next unless epic
46
+
47
+ review_gate = epic["review_gate"] || Config.review_gate
48
+
49
+ case review_gate
50
+ when "on_complete"
51
+ Orchestrator.on_card_completed(card_number)
52
+ when "on_pr_merge"
53
+ mark_in_review(epic, card_number)
54
+ when "epic_branch"
55
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
56
+ next unless task
57
+
58
+ # Only dispatch gates for fresh tasks, not ones already in review/final_decision/complete
59
+ current_status = task["status"]
60
+ if %w[in_review final_decision complete].include?(current_status)
61
+ LOG.info "[Basecamp:Hooks] Skipping gate dispatch for card ##{card_number} — already #{current_status}" if defined?(LOG)
62
+ next
63
+ end
64
+
65
+ if ReviewGate.enabled?
66
+ # Dispatch review gates in parallel after a delay (wait for PR to be created)
67
+ task["status"] = "in_review"
68
+ epic["updated_at"] = Time.now.iso8601
69
+ save_epic_state(epic)
70
+
71
+ Thread.new do
72
+ sleep 20 # Wait for agent to push + open PR
73
+ dispatch_review_gates(epic, task, ctx)
74
+ rescue StandardError => e
75
+ LOG.error "[Basecamp:Hooks] Review gate dispatch failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
76
+ end
77
+ else
78
+ # No gates configured — auto-merge directly
79
+ Thread.new do
80
+ sleep 10
81
+ auto_merge_and_advance(epic, card_number, ctx)
82
+ rescue StandardError => e
83
+ LOG.error "[Basecamp:Hooks] Auto-merge failed: #{e.message}" if defined?(LOG)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # When a PR is merged.
91
+ # - For on_pr_merge mode: listens to :pr_merged (main branch only)
92
+ # - For epic_branch mode: listens to :pr_merged_to_branch (any branch)
93
+ def register_pr_merged
94
+ # Legacy hook for on_pr_merge mode (merged to main)
95
+ Brainiac.on(:pr_merged) do |ctx|
96
+ card_number = ctx[:card_number]
97
+ next unless card_number
98
+
99
+ epic = Orchestrator.find_epic_for_card(card_number)
100
+ next unless epic
101
+
102
+ review_gate = epic["review_gate"] || Config.review_gate
103
+ if review_gate == "on_pr_merge"
104
+ LOG.info "[Basecamp:Hooks] PR merged for card ##{card_number} — advancing epic" if defined?(LOG)
105
+ Orchestrator.on_card_completed(card_number)
106
+ end
107
+ end
108
+
109
+ # New hook for epic_branch mode (merged to any branch, including epic branches)
110
+ Brainiac.on(:pr_merged_to_branch) do |ctx|
111
+ card_number = ctx[:card_number]
112
+ next unless card_number
113
+
114
+ epic = Orchestrator.find_epic_for_card(card_number)
115
+ next unless epic
116
+
117
+ review_gate = epic["review_gate"] || Config.review_gate
118
+ next unless review_gate == "epic_branch"
119
+
120
+ base_branch = ctx[:base_branch]
121
+ epic_branches = epic["epic_branches"]&.values || []
122
+
123
+ if epic_branches.include?(base_branch)
124
+ LOG.info "[Basecamp:Hooks] PR merged to epic branch #{base_branch} for card ##{card_number} — advancing" if defined?(LOG)
125
+ Orchestrator.on_card_completed(card_number)
126
+ end
127
+ end
128
+ end
129
+
130
+ # When a PR review is submitted (from brainiac-github).
131
+ # Tracks gate approvals — when all gates approve, auto-merge and advance.
132
+ # For changes_requested, we batch/debounce to wait for all gates before dispatching fixes.
133
+ def register_pr_review_received
134
+ Brainiac.on(:pr_review_received) do |ctx|
135
+ card_number = ctx[:card_number]
136
+ next unless card_number
137
+
138
+ epic = Orchestrator.find_epic_for_card(card_number)
139
+ next unless epic
140
+ next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
141
+
142
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
143
+ # Accept reviews during in_review OR in_flight (when addressing changes)
144
+ next unless task && %w[in_review in_flight].include?(task["status"])
145
+
146
+ review_state = ctx[:review_state]
147
+ reviewer = ctx[:reviewer] || ctx[:agent_name]
148
+
149
+ LOG.info "[Basecamp:Hooks] PR review received: #{reviewer} (#{review_state}) on card ##{card_number}" if defined?(LOG)
150
+
151
+ # Match reviewer to a configured gate agent
152
+ # GitHub bot logins are like "threepio-brainiac" or "glados-brainiac[bot]"
153
+ gate = match_reviewer_to_gate(reviewer)
154
+
155
+ unless gate
156
+ # Not a gate agent — check if it's the implementation agent's final approval
157
+ impl_agent = epic["agent"]
158
+ is_impl_agent = match_reviewer_to_agent?(reviewer, impl_agent)
159
+
160
+ if is_impl_agent && task["awaiting_final_decision"] && review_state == "approved"
161
+ # Implementation agent approved after reviewing gate feedback — proceed to merge
162
+ LOG.info "[Basecamp:Hooks] Final decision: #{impl_agent} approved — merging card ##{card_number}" if defined?(LOG)
163
+ task.delete("awaiting_final_decision")
164
+ save_epic_state(epic)
165
+
166
+ Thread.new do
167
+ post_gate_summary(epic, task)
168
+ auto_merge_and_advance(epic, card_number, ctx)
169
+ rescue StandardError => e
170
+ LOG.error "[Basecamp:Hooks] Post-final-decision merge failed: #{e.message}" if defined?(LOG)
171
+ end
172
+ end
173
+
174
+ next # Not a gate agent and not a final decision — skip
175
+ end
176
+
177
+ agent_name = gate["agent"]
178
+ role = gate["role"] || "review"
179
+
180
+ # Only record approval for APPROVED reviews
181
+ if review_state == "approved"
182
+ LOG.info "[Basecamp:Hooks] Gate APPROVED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
183
+ ReviewGate.record_approval(task, agent: agent_name, role: role)
184
+ # Clear this gate from changes_requested if it was there
185
+ task["changes_requested_by"]&.delete(agent_name)
186
+ epic["updated_at"] = Time.now.iso8601
187
+
188
+ if ReviewGate.all_gates_passed?(task)
189
+ LOG.info "[Basecamp:Hooks] All review gates passed for card ##{card_number} — dispatching final decision" if defined?(LOG)
190
+ save_epic_state(epic)
191
+
192
+ Thread.new do
193
+ dispatch_final_decision(epic, task, ctx)
194
+ rescue StandardError => e
195
+ LOG.error "[Basecamp:Hooks] Final decision dispatch failed: #{e.message}" if defined?(LOG)
196
+ end
197
+ else
198
+ approvals = task["gate_approvals"] || []
199
+ remaining = ReviewGate.gates.size - approvals.size
200
+ LOG.info "[Basecamp:Hooks] #{approvals.size}/#{ReviewGate.gates.size} gates passed, #{remaining} remaining" if defined?(LOG)
201
+ save_epic_state(epic)
202
+ end
203
+ elsif review_state == "changes_requested"
204
+ LOG.info "[Basecamp:Hooks] Gate CHANGES_REQUESTED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
205
+
206
+ # Track which gates requested changes
207
+ task["changes_requested_by"] ||= []
208
+ task["changes_requested_by"] << agent_name unless task["changes_requested_by"].include?(agent_name)
209
+
210
+ # Check if all gates have now responded (either approved or requested changes)
211
+ all_responded = all_gates_responded?(task)
212
+
213
+ if all_responded
214
+ # All gates have reviewed — dispatch implementation agent to address ALL feedback
215
+ LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
216
+ task["status"] = "in_flight"
217
+ save_epic_state(epic)
218
+ # brainiac-github will dispatch the impl agent since this is changes_requested
219
+ else
220
+ # Wait for remaining gates to respond
221
+ responded = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
222
+ remaining = ReviewGate.gates.size - responded
223
+ LOG.info "[Basecamp:Hooks] #{responded}/#{ReviewGate.gates.size} gates responded, waiting for #{remaining} more" if defined?(LOG)
224
+ save_epic_state(epic)
225
+
226
+ # Start a debounce timer if this is the first changes_requested
227
+ # In case some gates never respond, we'll dispatch after 60 seconds
228
+ unless task["changes_debounce_started"]
229
+ task["changes_debounce_started"] = true
230
+ save_epic_state(epic)
231
+ Thread.new do
232
+ sleep 60
233
+ # Reload epic state using public API
234
+ epic_reloaded = Orchestrator.find_epic_for_card(card_number)
235
+ next unless epic_reloaded
236
+
237
+ task_reloaded = epic_reloaded["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
238
+ if task_reloaded && task_reloaded["status"] == "in_review" && task_reloaded["changes_requested_by"]&.any?
239
+ LOG.info "[Basecamp:Hooks] Debounce timeout for card ##{card_number} — forcing dispatch" if defined?(LOG)
240
+ task_reloaded["status"] = "in_flight"
241
+ task_reloaded.delete("changes_debounce_started")
242
+ save_epic_state(epic_reloaded)
243
+ # Re-assign card to trigger dispatch
244
+ fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, epic_reloaded["agent"])
245
+ Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id) if fizzy_user_id
246
+ end
247
+ rescue StandardError => e
248
+ LOG.error "[Basecamp:Hooks] Debounce dispatch failed: #{e.message}" if defined?(LOG)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ # Check if all configured gates have responded (either approved or requested changes)
257
+ def all_gates_responded?(task)
258
+ approvals = (task["gate_approvals"] || []).map { |a| a["agent"].downcase }
259
+ changes = (task["changes_requested_by"] || []).map(&:downcase)
260
+ responded = approvals + changes
261
+
262
+ required = ReviewGate.gates.map { |g| g["agent"].downcase }
263
+ required.all? { |agent| responded.include?(agent) }
264
+ end
265
+
266
+ # Match a GitHub reviewer login to a configured gate agent.
267
+ # Handles patterns like "threepio-brainiac", "glados-brainiac[bot]"
268
+ def match_reviewer_to_gate(reviewer)
269
+ return nil unless reviewer
270
+
271
+ normalized = reviewer.to_s.downcase.delete_suffix("[bot]")
272
+
273
+ ReviewGate.gates.find do |gate|
274
+ agent = gate["agent"].to_s.downcase
275
+ # Direct match
276
+ next true if normalized == agent
277
+ # GitHub bot pattern: "agent-brainiac"
278
+ next true if normalized == "#{agent}-brainiac"
279
+ # Check against display_name from registry
280
+ agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
281
+ if File.exist?(agents_file)
282
+ agents = JSON.parse(File.read(agents_file))
283
+ agent_entry = agents[agent]
284
+ display = agent_entry&.dig("display_name")&.to_s&.downcase
285
+ next true if display && (normalized == display || normalized == "#{display}-brainiac")
286
+ end
287
+ false
288
+ end
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ # Check if a reviewer matches a specific agent name
294
+ def match_reviewer_to_agent?(reviewer, agent_name)
295
+ return false unless reviewer && agent_name
296
+
297
+ normalized = reviewer.to_s.downcase.delete_suffix("[bot]")
298
+ agent = agent_name.to_s.downcase
299
+
300
+ return true if normalized == agent
301
+ return true if normalized == "#{agent}-brainiac"
302
+
303
+ # Check display_name
304
+ agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
305
+ if File.exist?(agents_file)
306
+ agents = JSON.parse(File.read(agents_file))
307
+ agent_entry = agents[agent]
308
+ display = agent_entry&.dig("display_name")&.to_s&.downcase
309
+ return true if display && (normalized == display || normalized == "#{display}-brainiac")
310
+ end
311
+
312
+ false
313
+ rescue StandardError
314
+ false
315
+ end
316
+
317
+ # When a PR is updated (new commits pushed) — re-trigger gates if in review.
318
+ # This handles the "Galen fixes → pushes → gates re-review" flow.
319
+ def register_pr_synchronized
320
+ Brainiac.on(:pr_synchronized) do |ctx|
321
+ card_number = ctx[:card_number]
322
+ next unless card_number
323
+
324
+ epic = Orchestrator.find_epic_for_card(card_number)
325
+ next unless epic
326
+ next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
327
+
328
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
329
+ # Only re-trigger if the task is in_flight (fixes pushed) and had prior reviews
330
+ next unless task && task["status"] == "in_flight" && task["gate_approvals"]&.any?
331
+
332
+ LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
333
+
334
+ # Reset approvals and re-dispatch gates
335
+ ReviewGate.reset_approvals(task)
336
+ task["status"] = "in_review"
337
+ epic["updated_at"] = Time.now.iso8601
338
+ save_epic_state(epic)
339
+
340
+ Thread.new do
341
+ dispatch_review_gates(epic, task, ctx)
342
+ rescue StandardError => e
343
+ LOG.error "[Basecamp:Hooks] Gate re-dispatch failed: #{e.message}" if defined?(LOG)
344
+ end
345
+ end
346
+ end
347
+
348
+ # Inject epic context into agent prompts.
349
+ def register_build_brain_context
350
+ Brainiac.on(:build_brain_context) do |ctx|
351
+ card_number = ctx[:card_number]
352
+ next unless card_number
353
+
354
+ epic = Orchestrator.find_epic_for_card(card_number)
355
+ next unless epic
356
+
357
+ tasks = epic["tasks"] || []
358
+ current_task = tasks.find { |t| t["fizzy_card"] == card_number.to_i }
359
+ complete_count = tasks.count { |t| t["status"] == "complete" }
360
+ agent_name = ctx[:agent_name] || epic["agent"]
361
+
362
+ context_lines = [
363
+ "## Epic Context",
364
+ "This card is part of epic: **#{epic['title']}**",
365
+ "Progress: #{complete_count}/#{tasks.size} tasks complete",
366
+ ""
367
+ ]
368
+
369
+ # List completed tasks with their memory files for reference
370
+ completed_tasks = tasks.select { |t| t["status"] == "complete" }
371
+ if completed_tasks.any?
372
+ context_lines << "### Completed tasks (you can reference their memory files):"
373
+ completed_tasks.each do |t|
374
+ memory_file = "~/.brainiac/brain/memory/#{agent_name&.downcase}/card-#{t['fizzy_card']}.md"
375
+ context_lines << " - ##{t['fizzy_card']}: #{t['title']} → `#{memory_file}`"
376
+ end
377
+ context_lines << ""
378
+ end
379
+
380
+ if current_task
381
+ deps = current_task["depends_on"] || []
382
+ context_lines << "Dependencies (all satisfied): #{deps.map { |d| "##{d}" }.join(', ')}" if deps.any?
383
+
384
+ review_gate = epic["review_gate"] || Config.review_gate
385
+ if review_gate == "epic_branch"
386
+ epic_branches = epic["epic_branches"] || {}
387
+ branch = epic_branches[current_task["project"]] || epic_branches.values.first
388
+ context_lines << "**Epic branch mode:** Your PR should target `#{branch}` (not main)." if branch
389
+
390
+ if ReviewGate.enabled?
391
+ gate_names = ReviewGate.gates.map { |g| "#{g['agent']} (#{g['role']})" }.join(", ")
392
+ context_lines << "**Review gates:** #{gate_names} will review your PR after you open it."
393
+ end
394
+
395
+ # Final decision mode — agent reads gate feedback and decides
396
+ if current_task["awaiting_final_decision"]
397
+ pr_number = current_task["pr_number"]
398
+ approvals = current_task["gate_approvals"] || []
399
+ gate_agents = approvals.map { |a| a["agent"] }.join(", ")
400
+
401
+ context_lines << ""
402
+ context_lines << "## ⚡ FINAL DECISION REQUIRED"
403
+ context_lines << ""
404
+ context_lines << "All review gates have approved (#{gate_agents}). Your job now:"
405
+ context_lines << ""
406
+ context_lines << "1. Read their feedback: `gh pr view #{pr_number} --comments`"
407
+ context_lines << "2. If fixes needed → make them, commit, push"
408
+ context_lines << "3. When ready → **merge the PR**:"
409
+ context_lines << " ```"
410
+ context_lines << " gh pr merge #{pr_number} --squash --delete-branch"
411
+ context_lines << " ```"
412
+ context_lines << ""
413
+ context_lines << "**Out-of-scope work:** If reviewers flagged issues better solved outside this epic,"
414
+ context_lines << "create a new Fizzy card for that work instead of expanding this PR's scope."
415
+ end
416
+ end
417
+
418
+ remaining = tasks.select { |t| t["status"] == "pending" }
419
+ if remaining.any?
420
+ context_lines << "" << "Upcoming tasks:"
421
+ remaining.first(3).each { |t| context_lines << " - #{t['title']} (Fizzy ##{t['fizzy_card']})" }
422
+ end
423
+ end
424
+
425
+ context_lines.join("\n")
426
+ end
427
+ end
428
+
429
+ # Return the epic branch as the worktree base for cards in an active epic.
430
+ def register_resolve_base_branch
431
+ Brainiac.on(:resolve_base_branch) do |ctx|
432
+ card_number = ctx[:card_number]
433
+ next unless card_number
434
+
435
+ branch = EpicBranch.epic_branch_for_card(card_number)
436
+ next unless branch
437
+
438
+ "origin/#{branch}"
439
+ end
440
+ end
441
+
442
+ # Return the epic branch as the PR target for cards in an active epic.
443
+ def register_resolve_pr_target
444
+ Brainiac.on(:resolve_pr_target) do |ctx|
445
+ card_number = ctx[:card_number]
446
+ next unless card_number
447
+
448
+ EpicBranch.epic_branch_for_card(card_number)
449
+ end
450
+ end
451
+
452
+ # --- Private helpers ---
453
+
454
+ # Dispatch all review gate agents in parallel for a task.
455
+ def dispatch_review_gates(epic, task, ctx)
456
+ card_number = task["fizzy_card"]
457
+ project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
458
+
459
+ # Resolve repo info
460
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
461
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
462
+ project_config = projects[project_key] || {}
463
+ repo_path = project_config["repo_path"]
464
+ github_repo = project_config["github_repo"]
465
+
466
+ unless repo_path && github_repo
467
+ LOG.warn "[Basecamp:Hooks] Cannot dispatch gates — missing repo_path or github_repo for #{project_key}" if defined?(LOG)
468
+ return
469
+ end
470
+
471
+ # Find the PR number for this card's branch
472
+ branch = ctx[:branch] || "fizzy-#{card_number}-*"
473
+ pr_number = find_pr_number(repo_path: repo_path, branch: branch)
474
+
475
+ unless pr_number
476
+ LOG.warn "[Basecamp:Hooks] No PR found for card ##{card_number} — gates cannot be dispatched" if defined?(LOG)
477
+ # Retry once after delay
478
+ sleep 30
479
+ pr_number = find_pr_number(repo_path: repo_path, branch: branch)
480
+ unless pr_number
481
+ LOG.error "[Basecamp:Hooks] Still no PR for card ##{card_number} after retry" if defined?(LOG)
482
+ return
483
+ end
484
+ end
485
+
486
+ task["pr_number"] = pr_number
487
+ task["pr_repo"] = github_repo
488
+
489
+ # Self-healing: sync gate approvals from GitHub before dispatching
490
+ # This handles the case where gates already reviewed but our state is stale
491
+ sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
492
+ if sync_result[:synced] && sync_result[:changes]
493
+ changes = sync_result[:changes]
494
+ if changes[:approvals_added]&.any?
495
+ LOG.info "[Basecamp:Hooks] Self-healed: recorded approvals from #{changes[:approvals_added].join(', ')}" if defined?(LOG)
496
+ end
497
+ end
498
+
499
+ # Check if all gates have already approved (self-healed state)
500
+ if ReviewGate.all_gates_passed?(task)
501
+ LOG.info "[Basecamp:Hooks] All gates already approved for card ##{card_number} — dispatching final decision" if defined?(LOG)
502
+ save_epic_state(epic)
503
+ dispatch_final_decision(epic, task, ctx)
504
+ return
505
+ end
506
+
507
+ ReviewGate.dispatch_gates(
508
+ epic: epic,
509
+ task: task,
510
+ pr_number: pr_number,
511
+ repo_name: github_repo,
512
+ repo_path: repo_path
513
+ )
514
+
515
+ save_epic_state(epic)
516
+ end
517
+
518
+ # Post a summary comment on the Fizzy card after all gates pass.
519
+ def post_gate_summary(epic, task)
520
+ card_number = task["fizzy_card"]
521
+ pr_number = task["pr_number"]
522
+ pr_repo = task["pr_repo"]
523
+ pr_url = "https://github.com/#{pr_repo}/pull/#{pr_number}" if pr_repo && pr_number
524
+
525
+ comment_html = ReviewGate.build_gate_summary_comment(task, pr_url: pr_url || "")
526
+
527
+ # Post via Fizzy CLI as the implementation agent
528
+ agent_name = epic["agent"]
529
+ fizzy_env = resolve_fizzy_env(agent_name)
530
+
531
+ Open3.capture3(
532
+ "fizzy", "comment", "create",
533
+ "--card", card_number.to_s,
534
+ "--body", comment_html,
535
+ **({ env: fizzy_env } if fizzy_env)
536
+ )
537
+ rescue StandardError => e
538
+ LOG.warn "[Basecamp:Hooks] Failed to post gate summary on card ##{card_number}: #{e.message}" if defined?(LOG)
539
+ end
540
+
541
+ def mark_in_review(epic, card_number)
542
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
543
+ return unless task
544
+
545
+ task["status"] = "in_review"
546
+ task["review_started_at"] = Time.now.iso8601
547
+ epic["updated_at"] = Time.now.iso8601
548
+ save_epic_state(epic)
549
+ end
550
+
551
+ # Dispatch the implementation agent to read gate feedback and make a final decision.
552
+ # The agent reads all review comments, then either:
553
+ # - Makes fixes and pushes (triggers re-review cycle)
554
+ # - Approves the PR (triggers the merge)
555
+ #
556
+ # This spawns the agent directly via run_agent (not Fizzy assignment) to avoid
557
+ # confusing unassign/reassign activity in the Fizzy feed.
558
+ def dispatch_final_decision(epic, task, _ctx)
559
+ card_number = task["fizzy_card"]
560
+ agent_name = epic["agent"]
561
+ pr_number = task["pr_number"]
562
+ project_key = task["project"]
563
+
564
+ LOG.info "[Basecamp:Hooks] Dispatching #{agent_name} for final decision on card ##{card_number}" if defined?(LOG)
565
+
566
+ # Mark task as awaiting final decision
567
+ task["awaiting_final_decision"] = true
568
+ task["status"] = "final_decision"
569
+ epic["updated_at"] = Time.now.iso8601
570
+ save_epic_state(epic)
571
+
572
+ # Reset the agent-to-agent dispatch depth for this card
573
+ card_internal_id = lookup_card_internal_id(card_number)
574
+ if card_internal_id
575
+ LOG.info "[Basecamp:Hooks] Resetting dispatch depth for card #{card_internal_id}" if defined?(LOG)
576
+ if defined?(record_human_comment)
577
+ record_human_comment(card_internal_id)
578
+ elsif Object.respond_to?(:record_human_comment, true)
579
+ Object.send(:record_human_comment, card_internal_id)
580
+ end
581
+ end
582
+
583
+ # Get project config and repo path
584
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
585
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
586
+ project_config = projects[project_key]
587
+ repo_path = project_config&.dig("repo_path")
588
+
589
+ unless repo_path
590
+ LOG.error "[Basecamp:Hooks] No repo_path for project #{project_key} — cannot dispatch final decision" if defined?(LOG)
591
+ return
592
+ end
593
+
594
+ # Find the worktree for this card
595
+ work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
596
+ worktree_path = repo_path # Default to main repo
597
+ if File.exist?(work_items_file)
598
+ work_items = JSON.parse(File.read(work_items_file))
599
+ work_item = work_items.values.find { |wi| wi.dig("sources", "fizzy", "card_number") == card_number }
600
+ worktree_path = work_item["worktree"] if work_item&.dig("worktree")
601
+ end
602
+
603
+ # Build prompt for final decision
604
+ gate_approvals = task["gate_approvals"] || []
605
+ gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
606
+
607
+ prompt = <<~PROMPT
608
+ ## Final Decision Required — Fizzy Card ##{card_number}
609
+
610
+ All review gates have approved (#{gate_agents}). Your job:
611
+
612
+ 1. Read their feedback: `gh pr view #{pr_number} --comments`
613
+ 2. If fixes needed → make them, commit, push
614
+ 3. When ready → **merge the PR directly**:
615
+ ```
616
+ gh pr merge #{pr_number} --squash --delete-branch
617
+ ```
618
+
619
+ Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
620
+
621
+ After merging, update the Fizzy card with a brief status comment.
622
+ PROMPT
623
+
624
+ # Resolve GitHub App token so agent's `gh` commands run as their bot identity
625
+ github_repo = project_config&.dig("github_repo")
626
+ agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
627
+
628
+ # Spawn the agent directly (like gate agents do)
629
+ pid = nil
630
+ log_file = nil
631
+ card_key = "final-decision-#{card_number}"
632
+
633
+ begin
634
+ pid, log_file = method(:run_agent).call(
635
+ prompt,
636
+ project_config: project_config,
637
+ chdir: worktree_path,
638
+ log_name: "final-decision-#{card_number}",
639
+ agent_name: agent_name,
640
+ source: :basecamp,
641
+ card_number: card_number,
642
+ env: agent_env
643
+ )
644
+ rescue NameError
645
+ if Object.respond_to?(:run_agent, true)
646
+ pid, log_file = Object.send(:run_agent,
647
+ prompt,
648
+ project_config: project_config,
649
+ chdir: worktree_path,
650
+ log_name: "final-decision-#{card_number}",
651
+ agent_name: agent_name,
652
+ source: :basecamp,
653
+ card_number: card_number,
654
+ env: agent_env)
655
+ else
656
+ LOG.warn "[Basecamp:Hooks] run_agent not available — final decision dispatch skipped" if defined?(LOG)
657
+ return
658
+ end
659
+ end
660
+
661
+ # Register session for waybar visibility
662
+ if pid
663
+ if defined?(register_session)
664
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
665
+ elsif Object.respond_to?(:register_session, true)
666
+ Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
667
+ end
668
+ LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
669
+ end
670
+ end
671
+
672
+ def mark_in_review(epic, card_number)
673
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
674
+ return unless task
675
+
676
+ task["status"] = "in_review"
677
+ task["review_started_at"] = Time.now.iso8601
678
+ epic["updated_at"] = Time.now.iso8601
679
+ save_epic_state(epic)
680
+ end
681
+
682
+ def auto_merge_and_advance(epic, card_number, ctx)
683
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
684
+ return unless task
685
+
686
+ project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
687
+ epic_branches = epic["epic_branches"] || {}
688
+ epic_branch = epic_branches[project_key] || epic_branches.values.first
689
+ return unless epic_branch
690
+
691
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
692
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
693
+ repo_path = projects.dig(project_key, "repo_path")
694
+ return unless repo_path
695
+
696
+ branch_name = ctx[:branch]
697
+
698
+ # If we don't have the exact branch name, try to find it
699
+ unless branch_name
700
+ stdout, _, status = Open3.capture3("gh", "pr", "list", "--head", "fizzy-#{card_number}",
701
+ "--json", "headRefName", "--jq", ".[0].headRefName",
702
+ chdir: repo_path)
703
+ branch_name = stdout.strip if status.success? && !stdout.strip.empty?
704
+ end
705
+
706
+ merged = EpicBranch.merge_task_into_epic(
707
+ repo_path: repo_path,
708
+ branch_name: branch_name || "fizzy-#{card_number}",
709
+ epic_branch: epic_branch
710
+ )
711
+
712
+ if merged
713
+ LOG.info "[Basecamp:Hooks] Merged card ##{card_number} into #{epic_branch} — advancing epic" if defined?(LOG)
714
+ Orchestrator.on_card_completed(card_number)
715
+ else
716
+ sleep 30
717
+ merged = EpicBranch.merge_task_into_epic(
718
+ repo_path: repo_path,
719
+ branch_name: branch_name || "fizzy-#{card_number}",
720
+ epic_branch: epic_branch
721
+ )
722
+ if merged
723
+ Orchestrator.on_card_completed(card_number)
724
+ else
725
+ LOG.warn "[Basecamp:Hooks] Could not merge card ##{card_number} — manual intervention needed" if defined?(LOG)
726
+ task["status"] = "merge_failed"
727
+ save_epic_state(epic)
728
+ end
729
+ end
730
+ end
731
+
732
+ # Find a PR number by branch name pattern.
733
+ def find_pr_number(repo_path:, branch:)
734
+ # Try exact match first
735
+ stdout, _, status = Open3.capture3(
736
+ "gh", "pr", "list", "--head", branch, "--json", "number", "--jq", ".[0].number",
737
+ chdir: repo_path
738
+ )
739
+ return stdout.strip.to_i if status.success? && !stdout.strip.empty?
740
+
741
+ # Try pattern match (fizzy-NNNN-*)
742
+ if branch.include?("*")
743
+ card_num = branch.match(/fizzy-(\d+)/)[1] rescue nil
744
+ if card_num
745
+ stdout, _, status = Open3.capture3(
746
+ "gh", "pr", "list", "--json", "number,headRefName",
747
+ "--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_num}\")) | .number",
748
+ chdir: repo_path
749
+ )
750
+ return stdout.strip.to_i if status.success? && !stdout.strip.empty?
751
+ end
752
+ end
753
+
754
+ nil
755
+ end
756
+
757
+ # Look up the Fizzy internal ID for a card number from work_items.
758
+ def lookup_card_internal_id(card_number)
759
+ work_items_file = File.join(
760
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
761
+ "work_items.json"
762
+ )
763
+ return nil unless File.exist?(work_items_file)
764
+
765
+ work_items = JSON.parse(File.read(work_items_file))
766
+ work_items.each do |_id, item|
767
+ fizzy_card = item.dig("sources", "fizzy", "card_number") || item["card_number"]
768
+ if fizzy_card.to_i == card_number.to_i
769
+ return item.dig("sources", "fizzy", "card_internal_id") || item["card_internal_id"]
770
+ end
771
+ end
772
+ nil
773
+ rescue StandardError
774
+ nil
775
+ end
776
+
777
+ # Resolve Fizzy env for an agent (FIZZY_TOKEN).
778
+ def resolve_fizzy_env(agent_name)
779
+ agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
780
+ return nil unless File.exist?(agents_file)
781
+
782
+ agents = JSON.parse(File.read(agents_file))
783
+ agent = agents[agent_name.downcase]
784
+ return nil unless agent
785
+
786
+ env = agent.dig("env") || {}
787
+ env.empty? ? nil : env
788
+ rescue StandardError
789
+ nil
790
+ end
791
+
792
+ def save_epic_state(epic)
793
+ epics_file = File.join(
794
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
795
+ "basecamp_epics.json"
796
+ )
797
+
798
+ all = if File.exist?(epics_file)
799
+ data = JSON.parse(File.read(epics_file))
800
+ data["epics"] || []
801
+ else
802
+ []
803
+ end
804
+
805
+ idx = all.index { |e| e["id"] == epic["id"] }
806
+ if idx
807
+ all[idx] = epic
808
+ else
809
+ all << epic
810
+ end
811
+
812
+ File.write(epics_file, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
813
+ rescue StandardError => e
814
+ LOG.error "[Basecamp:Hooks] Failed to save epic state: #{e.message}" if defined?(LOG)
815
+ end
816
+ end
817
+ end
818
+ end
819
+ end
820
+ end