brainiac-basecamp 0.0.16 → 0.0.18

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.
@@ -16,7 +16,7 @@ module Brainiac
16
16
  # :resolve_base_branch — returns epic branch as worktree base
17
17
  # :resolve_pr_target — returns epic branch as PR target
18
18
  module Hooks
19
- class << self # rubocop:disable Metrics/ClassLength
19
+ class << self
20
20
  def register_all!
21
21
  register_agent_completed
22
22
  register_pr_merged
@@ -36,7 +36,6 @@ module Brainiac
36
36
  def register_agent_completed
37
37
  Brainiac.on(:agent_completed) do |ctx|
38
38
  next unless ctx[:source] == :fizzy
39
- next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
40
39
 
41
40
  card_number = ctx[:card_number]
42
41
  next unless card_number
@@ -44,6 +43,15 @@ module Brainiac
44
43
  epic = Orchestrator.find_epic_for_card(card_number)
45
44
  next unless epic
46
45
 
46
+ implementation_session = SessionRegistry.find_session(
47
+ SessionRegistry.implementation_session_id(card_number)
48
+ )
49
+ if implementation_session && implementation_session["agent_name"].to_s.casecmp?(ctx[:agent_name].to_s)
50
+ SessionRegistry.mark_dead(implementation_session["task_id"])
51
+ end
52
+
53
+ next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
54
+
47
55
  review_gate = epic["review_gate"] || Config.review_gate
48
56
 
49
57
  case review_gate
@@ -64,7 +72,7 @@ module Brainiac
64
72
 
65
73
  if ReviewGate.enabled?
66
74
  # Dispatch review gates in parallel after a delay (wait for PR to be created)
67
- task["status"] = "in_review"
75
+ TaskState.transition!(task, :submit_for_review, triggered_by: "agent_completed")
68
76
  epic["updated_at"] = Time.now.iso8601
69
77
  save_epic_state(epic)
70
78
 
@@ -123,18 +131,6 @@ module Brainiac
123
131
  if epic_branches.include?(base_branch)
124
132
  LOG.info "[Basecamp:Hooks] PR merged to epic branch #{base_branch} for card ##{card_number} — advancing" if defined?(LOG)
125
133
  Orchestrator.on_card_completed(card_number)
126
- elsif epic_branches.empty?
127
- # Fallback: epic_branches was never populated (branch creation failed).
128
- # If the task is in final_decision or in_review, the merge still represents
129
- # completion — advance the epic regardless of which branch was targeted.
130
- task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
131
- if task && %w[final_decision in_review in_flight].include?(task["status"])
132
- if defined?(LOG)
133
- LOG.warn "[Basecamp:Hooks] epic_branches is empty but PR merged to '#{base_branch}' " \
134
- "for card ##{card_number} (status: #{task['status']}) — advancing anyway (branch creation likely failed)"
135
- end
136
- Orchestrator.on_card_completed(card_number)
137
- end
138
134
  end
139
135
  end
140
136
  end
@@ -193,8 +189,6 @@ module Brainiac
193
189
  if review_state == "approved"
194
190
  LOG.info "[Basecamp:Hooks] Gate APPROVED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
195
191
  ReviewGate.record_approval(task, agent: agent_name, role: role)
196
- # Clear this gate from changes_requested if it was there
197
- task["changes_requested_by"]&.delete(agent_name)
198
192
  epic["updated_at"] = Time.now.iso8601
199
193
 
200
194
  if ReviewGate.all_gates_passed?(task)
@@ -207,17 +201,15 @@ module Brainiac
207
201
  LOG.error "[Basecamp:Hooks] Final decision dispatch failed: #{e.message}" if defined?(LOG)
208
202
  end
209
203
  else
210
- approvals = task["gate_approvals"] || []
211
- remaining = ReviewGate.gates.size - approvals.size
212
- LOG.info "[Basecamp:Hooks] #{approvals.size}/#{ReviewGate.gates.size} gates passed, #{remaining} remaining" if defined?(LOG)
204
+ approved = ReviewGate.gate_states(task).values.count { |state| state["status"] == "approved" }
205
+ remaining = ReviewGate.gates.size - approved
206
+ LOG.info "[Basecamp:Hooks] #{approved}/#{ReviewGate.gates.size} gates passed, #{remaining} remaining" if defined?(LOG)
213
207
  save_epic_state(epic)
214
208
  end
215
209
  elsif review_state == "changes_requested"
216
210
  LOG.info "[Basecamp:Hooks] Gate CHANGES_REQUESTED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
217
211
 
218
- # Track which gates requested changes
219
- task["changes_requested_by"] ||= []
220
- task["changes_requested_by"] << agent_name unless task["changes_requested_by"].include?(agent_name)
212
+ ReviewGate.record_changes_requested(task, agent: agent_name, role: role)
221
213
 
222
214
  # Check if all gates have now responded (either approved or requested changes)
223
215
  all_responded = all_gates_responded?(task)
@@ -225,16 +217,15 @@ module Brainiac
225
217
  if all_responded
226
218
  # All gates have reviewed — dispatch implementation agent to address ALL feedback
227
219
  LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
228
- task["status"] = "in_flight"
229
- task["dispatched_at"] = Time.now.iso8601
220
+ TaskState.transition!(task, :request_changes, triggered_by: "review_changes_requested")
230
221
  task.delete("changes_debounce_started")
231
222
  save_epic_state(epic)
232
223
 
233
- # Spawn agent directly — safe_assign_card won't work if already assigned
224
+ LOG.info "[Basecamp:Hooks] Direct-dispatching implementation for fixes on ##{card_number}" if defined?(LOG)
234
225
  dispatch_impl_directly(epic, task)
235
226
  else
236
227
  # Wait for remaining gates to respond
237
- responded = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
228
+ responded = ReviewGate.responded_count(task)
238
229
  remaining = ReviewGate.gates.size - responded
239
230
  LOG.info "[Basecamp:Hooks] #{responded}/#{ReviewGate.gates.size} gates responded, waiting for #{remaining} more" if defined?(LOG)
240
231
  save_epic_state(epic)
@@ -251,13 +242,12 @@ module Brainiac
251
242
  next unless epic_reloaded
252
243
 
253
244
  task_reloaded = epic_reloaded["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
254
- if task_reloaded && task_reloaded["status"] == "in_review" && task_reloaded["changes_requested_by"]&.any?
245
+ if task_reloaded && task_reloaded["status"] == "in_review" && ReviewGate.changes_requested?(task_reloaded)
255
246
  LOG.info "[Basecamp:Hooks] Debounce timeout for card ##{card_number} — forcing dispatch" if defined?(LOG)
256
- task_reloaded["status"] = "in_flight"
257
- task_reloaded["dispatched_at"] = Time.now.iso8601
247
+ TaskState.transition!(task_reloaded, :request_changes, triggered_by: "review_changes_timeout")
258
248
  task_reloaded.delete("changes_debounce_started")
259
249
  save_epic_state(epic_reloaded)
260
- # Spawn agent directly — safe_assign_card won't work if already assigned
250
+ # Re-assign card to trigger dispatch
261
251
  dispatch_impl_directly(epic_reloaded, task_reloaded)
262
252
  end
263
253
  rescue StandardError => e
@@ -271,12 +261,7 @@ module Brainiac
271
261
 
272
262
  # Check if all configured gates have responded (either approved or requested changes)
273
263
  def all_gates_responded?(task)
274
- approvals = (task["gate_approvals"] || []).map { |a| a["agent"].downcase }
275
- changes = (task["changes_requested_by"] || []).map(&:downcase)
276
- responded = approvals + changes
277
-
278
- required = ReviewGate.gates.map { |g| g["agent"].downcase }
279
- required.all? { |agent| responded.include?(agent) }
264
+ ReviewGate.all_gates_responded?(task)
280
265
  end
281
266
 
282
267
  # Match a GitHub reviewer login to a configured gate agent.
@@ -380,40 +365,15 @@ module Brainiac
380
365
  next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
381
366
 
382
367
  task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
383
- next unless task && task["status"] == "in_flight"
384
-
385
- # Ensure pr_number is set — it may be missing if initial gate dispatch
386
- # failed to persist it or if state was lost during serialization.
387
- unless task["pr_number"]
388
- pr_number = ctx.dig(:pull_request, "number") || ctx[:pr_number]
389
- if pr_number
390
- task["pr_number"] = pr_number.to_i
391
- LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number}" if defined?(LOG)
392
- else
393
- # Try to find it from the branch
394
- project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
395
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
396
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
397
- repo_path = projects.dig(project_key, "repo_path")
398
- if repo_path
399
- branch = ctx[:branch] || "fizzy-#{card_number}-*"
400
- found_pr = find_pr_number(repo_path: repo_path, branch: branch)
401
- if found_pr
402
- task["pr_number"] = found_pr
403
- LOG.info "[Basecamp:Hooks] Resolved pr_number=#{found_pr} for card ##{card_number} via branch lookup" if defined?(LOG)
404
- end
405
- end
406
- end
407
- end
408
-
409
- next unless task["pr_number"]
368
+ # Re-trigger gates if task is in_flight (agent working/pushed fixes)
369
+ # No need to check for prior reviews — if task is in_flight with a PR, gates should review
370
+ next unless task && task["status"] == "in_flight" && task["pr_number"]
410
371
 
411
372
  LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
412
373
 
413
374
  # Reset approvals and re-dispatch gates
414
375
  ReviewGate.reset_approvals(task)
415
- task["changes_requested_by"] = []
416
- task["status"] = "in_review"
376
+ TaskState.transition!(task, :submit_for_review, triggered_by: "pr_synchronized")
417
377
  epic["updated_at"] = Time.now.iso8601
418
378
  save_epic_state(epic)
419
379
 
@@ -446,23 +406,6 @@ module Brainiac
446
406
  ""
447
407
  ]
448
408
 
449
- # Inject epic memory if it exists (shared knowledge across the epic)
450
- epic_memory = EpicMemory.read(epic["basecamp_todolist_id"])
451
- if epic_memory
452
- context_lines << "### Epic Memory (Shared Knowledge)"
453
- context_lines << ""
454
- context_lines << "The following is shared knowledge accumulated from previous tasks in this epic."
455
- context_lines << "Reference this when making implementation decisions."
456
- context_lines << ""
457
- context_lines << "```markdown"
458
- context_lines << epic_memory.lines.first(50).join # Limit to first 50 lines to avoid context overflow
459
- if epic_memory.lines.size > 50
460
- context_lines << "... (truncated — full file at ~/.brainiac/brain/memory/epics/epic-#{epic['basecamp_todolist_id']}.md)"
461
- end
462
- context_lines << "```"
463
- context_lines << ""
464
- end
465
-
466
409
  # List completed tasks with their memory files for reference
467
410
  completed_tasks = tasks.select { |t| t["status"] == "complete" }
468
411
  if completed_tasks.any?
@@ -492,8 +435,9 @@ module Brainiac
492
435
  # Final decision mode — agent reads gate feedback and decides
493
436
  if current_task["awaiting_final_decision"]
494
437
  pr_number = current_task["pr_number"]
495
- approvals = current_task["gate_approvals"] || []
496
- gate_agents = approvals.map { |a| a["agent"] }.join(", ")
438
+ gate_agents = ReviewGate.gate_states(current_task).values
439
+ .select { |state| state["status"] == "approved" }
440
+ .map { |state| state["agent"] }.join(", ")
497
441
 
498
442
  context_lines << ""
499
443
  context_lines << "## ⚡ FINAL DECISION REQUIRED"
@@ -524,17 +468,12 @@ module Brainiac
524
468
  end
525
469
 
526
470
  # Return the epic branch as the worktree base for cards in an active epic.
527
- # If the epic branch doesn't exist for this task's project yet, create it lazily.
528
471
  def register_resolve_base_branch
529
472
  Brainiac.on(:resolve_base_branch) do |ctx|
530
473
  card_number = ctx[:card_number]
531
474
  next unless card_number
532
475
 
533
476
  branch = EpicBranch.epic_branch_for_card(card_number)
534
-
535
- # Lazy creation: if no branch exists for this task's project, create one now
536
- branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
537
-
538
477
  next unless branch
539
478
 
540
479
  "origin/#{branch}"
@@ -542,15 +481,12 @@ module Brainiac
542
481
  end
543
482
 
544
483
  # Return the epic branch as the PR target for cards in an active epic.
545
- # Same lazy-creation logic as resolve_base_branch.
546
484
  def register_resolve_pr_target
547
485
  Brainiac.on(:resolve_pr_target) do |ctx|
548
486
  card_number = ctx[:card_number]
549
487
  next unless card_number
550
488
 
551
- branch = EpicBranch.epic_branch_for_card(card_number)
552
- branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
553
- branch
489
+ EpicBranch.epic_branch_for_card(card_number)
554
490
  end
555
491
  end
556
492
 
@@ -647,7 +583,7 @@ module Brainiac
647
583
  task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
648
584
  return unless task
649
585
 
650
- task["status"] = "in_review"
586
+ TaskState.transition!(task, :submit_for_review, triggered_by: "pr_merge_wait")
651
587
  task["review_started_at"] = Time.now.iso8601
652
588
  epic["updated_at"] = Time.now.iso8601
653
589
  save_epic_state(epic)
@@ -675,37 +611,18 @@ module Brainiac
675
611
  github_repo = project_config&.dig("github_repo")
676
612
 
677
613
  # Check if PR is already merged (handles manual merges, webhook misses, etc.)
678
- if github_repo && pr_number&.to_i&.positive?
614
+ if github_repo && pr_number
679
615
  pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
680
616
  if pr_state.strip == "MERGED"
681
617
  LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
682
618
  Orchestrator.on_card_completed(card_number)
683
619
  return
684
620
  end
685
-
686
- # SAFEGUARD: Verify PR targets the epic branch (not main/master).
687
- # If epic_branches is populated and the PR targets the wrong base, retarget it.
688
- epic_branches = epic["epic_branches"] || {}
689
- expected_base = epic_branches[project_key] || epic_branches.values.first
690
- if expected_base
691
- actual_base, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "baseRefName", "-q",
692
- ".baseRefName")
693
- actual_base = actual_base.strip
694
- if !actual_base.empty? && actual_base != expected_base
695
- LOG.warn "[Basecamp:Hooks] PR ##{pr_number} targets '#{actual_base}' but expected '#{expected_base}' — retargeting" if defined?(LOG)
696
- _, stderr, status = Open3.capture3("gh", "pr", "edit", pr_number.to_s, "--repo", github_repo, "--base", expected_base)
697
- if status.success?
698
- LOG.info "[Basecamp:Hooks] Retargeted PR ##{pr_number} to '#{expected_base}'" if defined?(LOG)
699
- elsif defined?(LOG)
700
- LOG.error "[Basecamp:Hooks] Failed to retarget PR ##{pr_number}: #{stderr.strip}"
701
- end
702
- end
703
- end
704
621
  end
705
622
 
706
623
  # Mark task as awaiting final decision
707
624
  task["awaiting_final_decision"] = true
708
- task["status"] = "final_decision"
625
+ TaskState.transition!(task, :approve, triggered_by: "all_gates_approved", guard: ReviewGate.all_gates_passed?(task))
709
626
  epic["updated_at"] = Time.now.iso8601
710
627
  save_epic_state(epic)
711
628
 
@@ -737,19 +654,9 @@ module Brainiac
737
654
  end
738
655
 
739
656
  # Build prompt for final decision
740
- gate_approvals = task["gate_approvals"] || []
741
- gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
742
-
743
- # Determine expected target branch for the prompt
744
- epic_branches = epic["epic_branches"] || {}
745
- expected_base = epic_branches[project_key] || epic_branches.values.first
746
- target_note = if expected_base
747
- "\n**IMPORTANT:** This PR should target `#{expected_base}` (the epic branch). " \
748
- "If `gh pr view #{pr_number} --json baseRefName` shows a different base, " \
749
- "do NOT merge — report the mismatch instead.\n"
750
- else
751
- ""
752
- end
657
+ gate_agents = ReviewGate.gate_states(task).values
658
+ .select { |state| state["status"] == "approved" }
659
+ .map { |state| state["agent"] }.join(", ")
753
660
 
754
661
  prompt = <<~PROMPT
755
662
  ## Final Decision Required — Fizzy Card ##{card_number}
@@ -762,7 +669,7 @@ module Brainiac
762
669
  ```
763
670
  gh pr merge #{pr_number} --squash --delete-branch
764
671
  ```
765
- #{target_note}
672
+
766
673
  Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
767
674
 
768
675
  After merging, update the Fizzy card with a brief status comment.
@@ -805,148 +712,16 @@ module Brainiac
805
712
  end
806
713
  end
807
714
 
808
- # Register session for waybar visibility
715
+ # Register session in SessionRegistry for liveness tracking
809
716
  if pid
810
- if defined?(register_session)
811
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
812
- elsif Object.respond_to?(:register_session, true)
813
- Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
814
- end
815
- LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
816
- end
817
- end
818
-
819
- # Dispatch the implementation agent directly (spawns via run_agent) to address
820
- # changes_requested feedback. Used by resume and health-check when the agent is
821
- # no longer running but the Fizzy card is still assigned (so safe_assign_card
822
- # would bail out with "already assigned, skipping").
823
- #
824
- # @param epic [Hash] Epic state
825
- # @param task [Hash] Task within the epic
826
- # @return [void]
827
- def dispatch_impl_directly(epic, task)
828
- card_number = task["fizzy_card"]
829
- agent_name = epic["agent"]
830
- pr_number = task["pr_number"]
831
- project_key = task["project"]
832
- card_key = "changes-requested-#{card_number}"
833
-
834
- # Guard: don't spawn a duplicate if a session is already running for this task
835
- session_alive = if defined?(session_active?)
836
- session_active?(card_key)
837
- elsif Object.respond_to?(:session_active?, true)
838
- Object.send(:session_active?, card_key)
839
- else
840
- false
841
- end
842
-
843
- if session_alive
844
- LOG.info "[Basecamp:Hooks] Session already active for #{card_key} — skipping dispatch" if defined?(LOG)
845
- return
846
- end
847
-
848
- LOG.info "[Basecamp:Hooks] Direct-dispatching #{agent_name} for changes_requested on card ##{card_number}" if defined?(LOG)
849
-
850
- # Get project config
851
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
852
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
853
- project_config = projects[project_key]
854
-
855
- unless project_config
856
- LOG.error "[Basecamp:Hooks] No project config for '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
857
- return
858
- end
859
-
860
- repo_path = project_config["repo_path"]
861
- unless repo_path
862
- LOG.error "[Basecamp:Hooks] No repo_path for project '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
863
- return
864
- end
865
-
866
- # Ensure pr_number is set — look it up if missing.
867
- # This is critical for the pr_synchronized hook to re-trigger gates after fixes.
868
- unless pr_number
869
- found_pr = find_pr_number(repo_path: repo_path, branch: "fizzy-#{card_number}-*")
870
- if found_pr
871
- pr_number = found_pr
872
- task["pr_number"] = pr_number
873
- task["pr_repo"] = project_config["github_repo"]
874
- save_epic_state(epic)
875
- LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number} during direct dispatch" if defined?(LOG)
876
- elsif defined?(LOG)
877
- LOG.warn "[Basecamp:Hooks] Could not find PR for card ##{card_number} — gates won't re-trigger after fixes"
878
- end
879
- end
880
-
881
- # Find the worktree for this card
882
- work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
883
- worktree_path = repo_path # Default to main repo
884
- if File.exist?(work_items_file)
885
- work_items = JSON.parse(File.read(work_items_file))
886
- work_item = work_items.values.find { |wi| wi.dig("sources", "fizzy", "card_number") == card_number }
887
- worktree_path = work_item["worktree"] if work_item&.dig("worktree")
888
- end
889
-
890
- # Build prompt
891
- changers = (task["changes_requested_by"] || []).join(", ")
892
- prompt = <<~PROMPT
893
- ## Changes Requested — Fizzy Card ##{card_number}
894
-
895
- Review gates have requested changes on your PR#{" ##{pr_number}" if pr_number}.
896
- Reviewers who requested changes: #{changers}
897
-
898
- Your job:
899
- 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : 'check the Fizzy card comments'}
900
- 2. Address all requested changes
901
- 3. Commit and push your fixes
902
-
903
- After pushing, update the Fizzy card with a brief status comment noting what you fixed.
904
- PROMPT
905
-
906
- # Resolve GitHub App token so agent's `gh` commands run as their bot identity
907
- github_repo = project_config&.dig("github_repo")
908
- agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
909
-
910
- # Spawn the agent directly
911
- pid = nil
912
- log_file = nil
913
-
914
- begin
915
- pid, log_file = method(:run_agent).call(
916
- prompt,
917
- project_config: project_config,
918
- chdir: worktree_path,
919
- log_name: "changes-requested-#{card_number}",
717
+ SessionRegistry.register_session(
718
+ card_key, pid,
719
+ log_file: log_file,
920
720
  agent_name: agent_name,
921
- source: :basecamp,
922
- card_number: card_number,
923
- env: agent_env
721
+ epic_id: epic["id"],
722
+ card_number: card_number
924
723
  )
925
- rescue NameError
926
- if Object.respond_to?(:run_agent, true)
927
- pid, log_file = Object.send(:run_agent,
928
- prompt,
929
- project_config: project_config,
930
- chdir: worktree_path,
931
- log_name: "changes-requested-#{card_number}",
932
- agent_name: agent_name,
933
- source: :basecamp,
934
- card_number: card_number,
935
- env: agent_env)
936
- else
937
- LOG.warn "[Basecamp:Hooks] run_agent not available — direct impl dispatch skipped" if defined?(LOG)
938
- return
939
- end
940
- end
941
-
942
- # Register session for waybar visibility
943
- if pid
944
- if defined?(register_session)
945
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
946
- elsif Object.respond_to?(:register_session, true)
947
- Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
948
- end
949
- LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for changes_requested on card ##{card_number}" if defined?(LOG)
724
+ LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
950
725
  end
951
726
  end
952
727
 
@@ -994,12 +769,61 @@ module Brainiac
994
769
  Orchestrator.on_card_completed(card_number)
995
770
  else
996
771
  LOG.warn "[Basecamp:Hooks] Could not merge card ##{card_number} — manual intervention needed" if defined?(LOG)
997
- task["status"] = "merge_failed"
772
+ TaskState.transition!(task, :merge_failed, triggered_by: "epic_branch_merge_failed")
998
773
  save_epic_state(epic)
999
774
  end
1000
775
  end
1001
776
  end
1002
777
 
778
+ # Start an implementation agent even when Fizzy still considers the
779
+ # card assigned. Re-assigning a card is intentionally a no-op in that
780
+ # situation, which used to leave recovery loops unable to make progress.
781
+ def dispatch_impl_directly(epic, task)
782
+ card_number = task["fizzy_card"]
783
+ session_id = SessionRegistry.implementation_session_id(card_number)
784
+ return if SessionRegistry.alive?(session_id)
785
+
786
+ project_key = task["project"]
787
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
788
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
789
+ project_config = projects[project_key]
790
+ return unless project_config && project_config["repo_path"]
791
+
792
+ agent_name = Orchestrator.send(:resolve_agent_for_project, project_key) || epic["agent"]
793
+ prompt = <<~PROMPT
794
+ ## Changes Requested — Fizzy Card ##{card_number}
795
+
796
+ Review feedback needs attention on PR ##{task['pr_number']}. Read the review,
797
+ make the requested fixes, commit and push them, then update the Fizzy card.
798
+ PROMPT
799
+ github_repo = project_config["github_repo"]
800
+ agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
801
+ pid, log_file = method(:run_agent).call(
802
+ prompt,
803
+ project_config: project_config,
804
+ chdir: project_config["repo_path"],
805
+ log_name: session_id,
806
+ agent_name: agent_name,
807
+ source: :basecamp,
808
+ card_number: card_number,
809
+ env: agent_env
810
+ )
811
+ return unless pid
812
+
813
+ SessionRegistry.register_session(
814
+ session_id,
815
+ pid,
816
+ log_file: log_file,
817
+ agent_name: agent_name,
818
+ epic_id: epic["id"],
819
+ card_number: card_number
820
+ )
821
+ rescue NameError
822
+ LOG.warn "[Basecamp:Hooks] run_agent unavailable — implementation recovery skipped" if defined?(LOG)
823
+ rescue StandardError => e
824
+ LOG.error "[Basecamp:Hooks] Direct implementation dispatch failed: #{e.message}" if defined?(LOG)
825
+ end
826
+
1003
827
  # Find a PR number by branch name pattern.
1004
828
  def find_pr_number(repo_path:, branch:)
1005
829
  # Try exact match first