brainiac-basecamp 0.0.17 → 0.0.19
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 +4 -4
- data/lib/brainiac/plugins/basecamp/cli.rb +238 -8
- data/lib/brainiac/plugins/basecamp/comment_responder.rb +380 -0
- data/lib/brainiac/plugins/basecamp/epic_memory.rb +10 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +94 -270
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +115 -197
- data/lib/brainiac/plugins/basecamp/review_gate.rb +165 -218
- data/lib/brainiac/plugins/basecamp/session_registry.rb +361 -0
- data/lib/brainiac/plugins/basecamp/task_state.rb +86 -0
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp/webhook.rb +4 -6
- data/lib/brainiac/plugins/basecamp.rb +128 -492
- metadata +4 -1
|
@@ -49,9 +49,6 @@ module Brainiac
|
|
|
49
49
|
LOG.info "[Basecamp:Orchestrator] Started epic '#{title}' (todolist #{todolist_id}) " \
|
|
50
50
|
"with agent #{agent}, review_gate: #{review_gate}" if defined?(LOG)
|
|
51
51
|
|
|
52
|
-
# Initialize epic memory for cross-task shared knowledge
|
|
53
|
-
EpicMemory.initialize_for(epic)
|
|
54
|
-
|
|
55
52
|
# FIRST: Populate tasks with project info from Fizzy card tags
|
|
56
53
|
# This must happen BEFORE creating epic branches so we know which repos are involved
|
|
57
54
|
populate_tasks(epic)
|
|
@@ -123,7 +120,7 @@ module Brainiac
|
|
|
123
120
|
|
|
124
121
|
# Mark the task as complete in our state
|
|
125
122
|
if task
|
|
126
|
-
task
|
|
123
|
+
TaskState.transition!(task, :complete, triggered_by: "fizzy_card_completed")
|
|
127
124
|
task["completed_at"] = Time.now.iso8601
|
|
128
125
|
epic["updated_at"] = Time.now.iso8601
|
|
129
126
|
log_event(epic, "task_completed", "Card ##{card_number} completed")
|
|
@@ -192,8 +189,6 @@ module Brainiac
|
|
|
192
189
|
load_epics.find { |e| e["id"] == epic_id }
|
|
193
190
|
end
|
|
194
191
|
|
|
195
|
-
WEBHOOK_CHECK_COOLDOWN = 300 # 5 minutes
|
|
196
|
-
|
|
197
192
|
private
|
|
198
193
|
|
|
199
194
|
# Resolve current todolist state from Basecamp and dispatch unblocked cards.
|
|
@@ -221,7 +216,7 @@ module Brainiac
|
|
|
221
216
|
updated_tasks = new_tasks.map do |task|
|
|
222
217
|
existing = existing_by_card[task.fizzy_card]
|
|
223
218
|
status = if task.completed
|
|
224
|
-
"complete"
|
|
219
|
+
existing&.dig("status") || "complete"
|
|
225
220
|
elsif existing&.dig("status")
|
|
226
221
|
# Preserve existing status (in_flight, in_review, final_decision, etc.)
|
|
227
222
|
existing["status"]
|
|
@@ -249,12 +244,18 @@ module Brainiac
|
|
|
249
244
|
# Preserve PR and gate state from existing task
|
|
250
245
|
if existing
|
|
251
246
|
%w[dispatched_at pr_number pr_repo gates_dispatched_at gate_approvals
|
|
252
|
-
changes_requested_by
|
|
253
|
-
|
|
247
|
+
changes_requested_by gate_redispatch_counts gate_states
|
|
248
|
+
awaiting_final_decision changes_debounce_started
|
|
249
|
+
fizzy_internal_id transitions].each do |key|
|
|
254
250
|
new_task[key] = existing[key] if existing.key?(key)
|
|
255
251
|
end
|
|
256
252
|
end
|
|
257
253
|
|
|
254
|
+
TaskState.migrate!(new_task, triggered_by: "basecamp_sync")
|
|
255
|
+
if task.completed && !TaskState.in?(new_task, :complete)
|
|
256
|
+
TaskState.transition!(new_task, :complete, triggered_by: "basecamp_todo_completed")
|
|
257
|
+
end
|
|
258
|
+
|
|
258
259
|
new_task
|
|
259
260
|
end
|
|
260
261
|
|
|
@@ -268,28 +269,42 @@ module Brainiac
|
|
|
268
269
|
epic["updated_at"] = Time.now.iso8601
|
|
269
270
|
end
|
|
270
271
|
|
|
271
|
-
# Dispatch unblocked tasks that
|
|
272
|
-
# Ensures Fizzy webhooks are active before dispatching (prevents silent failures).
|
|
272
|
+
# Dispatch unblocked tasks that do not have a live implementation session.
|
|
273
273
|
def dispatch_unblocked_tasks(epic)
|
|
274
|
-
ensure_fizzy_webhooks_active
|
|
275
274
|
tasks = epic["tasks"].map do |t|
|
|
275
|
+
# A stale in_flight state is diagnostic state, not liveness evidence.
|
|
276
|
+
# It becomes dispatchable again only after the implementation session has
|
|
277
|
+
# died; in_review/final_decision remain intentionally non-dispatchable.
|
|
278
|
+
# Give a newly assigned implementation agent time to register its
|
|
279
|
+
# session. Without this grace period, a recovery pass that just
|
|
280
|
+
# re-assigned a stale card can immediately assign it a second time
|
|
281
|
+
# before Fizzy's normal spawn hook has run.
|
|
282
|
+
dispatch_status = if TaskState.in?(t, :in_flight) &&
|
|
283
|
+
!SessionRegistry.implementation_alive?(t["fizzy_card"]) &&
|
|
284
|
+
Basecamp.send(:stale_dispatch?, t)
|
|
285
|
+
:pending
|
|
286
|
+
else
|
|
287
|
+
t["status"].to_sym
|
|
288
|
+
end
|
|
276
289
|
Epic::Task.new(
|
|
277
290
|
todo_id: t["todo_id"],
|
|
278
291
|
fizzy_card: t["fizzy_card"],
|
|
279
292
|
title: t["title"],
|
|
280
293
|
depends_on: t["depends_on"] || [],
|
|
281
|
-
status:
|
|
294
|
+
status: dispatch_status,
|
|
282
295
|
completed: t["status"] == "complete",
|
|
283
296
|
project: t["project"]
|
|
284
297
|
)
|
|
285
298
|
end
|
|
286
299
|
|
|
287
|
-
#
|
|
300
|
+
# The registry, rather than Fizzy assignment or a persisted status field,
|
|
301
|
+
# is the authority for whether an implementation dispatch is already live.
|
|
288
302
|
unblocked = Epic.unblocked_tasks(tasks)
|
|
289
|
-
in_flight_cards = epic["tasks"].select { |t| t["status"] == "in_flight" }.map { |t| t["fizzy_card"] }
|
|
290
303
|
complete_cards = epic["tasks"].select { |t| t["status"] == "complete" }.map { |t| t["fizzy_card"] }
|
|
291
304
|
|
|
292
|
-
ready_to_dispatch = unblocked.reject
|
|
305
|
+
ready_to_dispatch = unblocked.reject do |task|
|
|
306
|
+
complete_cards.include?(task.fizzy_card) || SessionRegistry.implementation_alive?(task.fizzy_card)
|
|
307
|
+
end
|
|
293
308
|
|
|
294
309
|
ready_to_dispatch.each do |task|
|
|
295
310
|
dispatch_card(epic, task)
|
|
@@ -307,6 +322,11 @@ module Brainiac
|
|
|
307
322
|
def dispatch_card(epic, task)
|
|
308
323
|
card_number = task.fizzy_card
|
|
309
324
|
|
|
325
|
+
if SessionRegistry.implementation_alive?(card_number)
|
|
326
|
+
LOG.info "[Basecamp:Orchestrator] Skipping Fizzy card ##{card_number} — implementation session is still live" if defined?(LOG)
|
|
327
|
+
return false
|
|
328
|
+
end
|
|
329
|
+
|
|
310
330
|
# Resolve agent from project config — each project can have its own default agent
|
|
311
331
|
project_key = task.is_a?(Hash) ? task["project"] : task.project
|
|
312
332
|
agent = resolve_agent_for_project(project_key) || epic["agent"]
|
|
@@ -316,16 +336,14 @@ module Brainiac
|
|
|
316
336
|
# Mark as in-flight in our state
|
|
317
337
|
epic_task = epic["tasks"].find { |t| t["fizzy_card"] == card_number }
|
|
318
338
|
if epic_task
|
|
319
|
-
epic_task
|
|
339
|
+
TaskState.transition!(epic_task, :dispatch, triggered_by: "orchestrator_dispatch")
|
|
320
340
|
epic_task["dispatched_at"] = Time.now.iso8601
|
|
321
341
|
end
|
|
322
342
|
epic["updated_at"] = Time.now.iso8601
|
|
323
343
|
|
|
324
344
|
log_event(epic, "dispatched", "Card ##{card_number} dispatched to #{agent}")
|
|
325
345
|
|
|
326
|
-
# Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
|
|
327
|
-
# If the agent is already assigned (stale state from a disabled webhook),
|
|
328
|
-
# the method will cycle the assignment to re-trigger the webhook.
|
|
346
|
+
# Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
|
|
329
347
|
assign_fizzy_card(card_number, agent)
|
|
330
348
|
|
|
331
349
|
# Post a comment on the Basecamp todo
|
|
@@ -337,6 +355,8 @@ module Brainiac
|
|
|
337
355
|
profile: agent.downcase
|
|
338
356
|
)
|
|
339
357
|
end
|
|
358
|
+
|
|
359
|
+
true
|
|
340
360
|
end
|
|
341
361
|
|
|
342
362
|
# Sync PR state from work_items for cards that already have open PRs.
|
|
@@ -366,7 +386,7 @@ module Brainiac
|
|
|
366
386
|
|
|
367
387
|
task["pr_number"] = pr_number
|
|
368
388
|
task["pr_repo"] = work_item.dig("sources", "github", "repo")
|
|
369
|
-
task
|
|
389
|
+
TaskState.transition!(task, :submit_for_review, triggered_by: "existing_pr_sync")
|
|
370
390
|
end
|
|
371
391
|
rescue StandardError => e
|
|
372
392
|
LOG.warn "[Basecamp:Orchestrator] Error syncing PR state: #{e.message}" if defined?(LOG)
|
|
@@ -387,9 +407,6 @@ module Brainiac
|
|
|
387
407
|
pr_number = task["pr_number"]
|
|
388
408
|
project_key = task["project"]
|
|
389
409
|
|
|
390
|
-
# Skip if gates already dispatched
|
|
391
|
-
next if task["gates_dispatched_at"]
|
|
392
|
-
|
|
393
410
|
# Get repo info
|
|
394
411
|
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
395
412
|
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
@@ -405,7 +422,7 @@ module Brainiac
|
|
|
405
422
|
# Check if all gates already passed
|
|
406
423
|
if ReviewGate.all_gates_passed?(task)
|
|
407
424
|
LOG.info "[Basecamp:Orchestrator] Gates already passed for card ##{card_number} — dispatching final decision" if defined?(LOG)
|
|
408
|
-
task
|
|
425
|
+
TaskState.transition!(task, :approve, triggered_by: "existing_pr_gate_sync", guard: ReviewGate.all_gates_passed?(task))
|
|
409
426
|
task["awaiting_final_decision"] = true
|
|
410
427
|
# Final decision dispatch happens in resume logic
|
|
411
428
|
else
|
|
@@ -425,10 +442,7 @@ module Brainiac
|
|
|
425
442
|
end
|
|
426
443
|
|
|
427
444
|
# Assign a Fizzy card to an agent via Fizzy CLI.
|
|
428
|
-
# If the agent is already assigned,
|
|
429
|
-
# (Fizzy webhooks can silently become disabled, causing the original assignment to be lost).
|
|
430
|
-
#
|
|
431
|
-
# @return [Symbol] :assigned, :reassigned, or :failed
|
|
445
|
+
# If the agent is already assigned, skip — the webhook should have already fired.
|
|
432
446
|
def assign_fizzy_card(card_number, agent)
|
|
433
447
|
agent_config = load_agent_registry[agent.downcase]
|
|
434
448
|
fizzy_name = agent_config&.dig("fizzy_name") || agent
|
|
@@ -437,34 +451,18 @@ module Brainiac
|
|
|
437
451
|
fizzy_user_id = resolve_fizzy_user_id(fizzy_name)
|
|
438
452
|
unless fizzy_user_id
|
|
439
453
|
LOG.error "[Basecamp:Orchestrator] Could not resolve Fizzy user ID for '#{fizzy_name}'" if defined?(LOG)
|
|
440
|
-
return
|
|
454
|
+
return
|
|
441
455
|
end
|
|
442
456
|
|
|
443
|
-
# Check if agent is already assigned
|
|
457
|
+
# Check if agent is already assigned — if so, skip (webhook should have fired)
|
|
444
458
|
stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
|
|
445
459
|
if status.success?
|
|
446
460
|
card_data = JSON.parse(stdout).dig("data") rescue nil
|
|
447
461
|
if card_data
|
|
448
462
|
current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
|
|
449
463
|
if current_assignees.include?(fizzy_user_id)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
LOG.warn "[Basecamp:Orchestrator] Agent already assigned to ##{card_number} — " \
|
|
453
|
-
"cycling assignment to re-trigger webhook" if defined?(LOG)
|
|
454
|
-
|
|
455
|
-
# Fizzy assign TOGGLES, so calling it removes the assignment
|
|
456
|
-
Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
457
|
-
sleep 1 # Brief pause to ensure the unassign is processed
|
|
458
|
-
|
|
459
|
-
# Now reassign — this should trigger the webhook
|
|
460
|
-
stdout2, stderr2, status2 = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
461
|
-
if status2.success?
|
|
462
|
-
LOG.info "[Basecamp:Orchestrator] Reassigned Fizzy ##{card_number} to #{fizzy_name} (webhook re-triggered)" if defined?(LOG)
|
|
463
|
-
return :reassigned
|
|
464
|
-
else
|
|
465
|
-
LOG.error "[Basecamp:Orchestrator] Failed to reassign Fizzy ##{card_number}: #{stderr2.strip}" if defined?(LOG)
|
|
466
|
-
return :failed
|
|
467
|
-
end
|
|
464
|
+
LOG.info "[Basecamp:Orchestrator] Agent already assigned to ##{card_number}, skipping (webhook should have fired)" if defined?(LOG)
|
|
465
|
+
return
|
|
468
466
|
end
|
|
469
467
|
end
|
|
470
468
|
end
|
|
@@ -473,77 +471,11 @@ module Brainiac
|
|
|
473
471
|
|
|
474
472
|
if status.success?
|
|
475
473
|
LOG.info "[Basecamp:Orchestrator] Assigned Fizzy ##{card_number} to #{fizzy_name} (#{fizzy_user_id})" if defined?(LOG)
|
|
476
|
-
:assigned
|
|
477
474
|
else
|
|
478
475
|
LOG.error "[Basecamp:Orchestrator] Failed to assign Fizzy ##{card_number}: #{stderr.strip}" if defined?(LOG)
|
|
479
|
-
:failed
|
|
480
476
|
end
|
|
481
477
|
rescue Errno::ENOENT => e
|
|
482
478
|
LOG.error "[Basecamp:Orchestrator] fizzy CLI not found: #{e.message}" if defined?(LOG)
|
|
483
|
-
:failed
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
# Ensure Fizzy webhooks are active before dispatching cards.
|
|
487
|
-
# Fizzy webhooks can silently become disabled (e.g., after repeated delivery failures),
|
|
488
|
-
# causing assigned cards to never trigger agent work. This pre-flight check reactivates
|
|
489
|
-
# any disabled brainiac webhooks.
|
|
490
|
-
#
|
|
491
|
-
# Uses a cooldown to avoid hammering the API on every dispatch batch.
|
|
492
|
-
def ensure_fizzy_webhooks_active
|
|
493
|
-
now = Time.now
|
|
494
|
-
@last_webhook_check ||= Time.at(0)
|
|
495
|
-
return if (now - @last_webhook_check) < WEBHOOK_CHECK_COOLDOWN
|
|
496
|
-
|
|
497
|
-
@last_webhook_check = now
|
|
498
|
-
|
|
499
|
-
# Read fizzy config to get admin_token and board list
|
|
500
|
-
fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
|
|
501
|
-
return unless File.exist?(fizzy_config_file)
|
|
502
|
-
|
|
503
|
-
fizzy_config = JSON.parse(File.read(fizzy_config_file))
|
|
504
|
-
admin_token = fizzy_config["admin_token"]
|
|
505
|
-
boards = fizzy_config["boards"] || {}
|
|
506
|
-
|
|
507
|
-
unless admin_token
|
|
508
|
-
LOG.debug "[Basecamp:Orchestrator] Fizzy webhook check skipped (no admin_token in fizzy.json)" if defined?(LOG)
|
|
509
|
-
return
|
|
510
|
-
end
|
|
511
|
-
|
|
512
|
-
env = { "FIZZY_TOKEN" => admin_token }
|
|
513
|
-
reactivated = 0
|
|
514
|
-
|
|
515
|
-
boards.each do |board_key, board_config|
|
|
516
|
-
board_id = board_config["board_id"]
|
|
517
|
-
next unless board_id
|
|
518
|
-
|
|
519
|
-
stdout, _, status = Open3.capture3(env, "fizzy", "webhook", "list",
|
|
520
|
-
"--board", board_id, "--all", "--json",
|
|
521
|
-
chdir: Dir.home)
|
|
522
|
-
next unless status.success?
|
|
523
|
-
|
|
524
|
-
webhooks = JSON.parse(stdout)["data"] || []
|
|
525
|
-
webhooks.each do |webhook|
|
|
526
|
-
next unless webhook["name"]&.start_with?("brainiac")
|
|
527
|
-
next if webhook["active"]
|
|
528
|
-
|
|
529
|
-
wh_name = webhook["name"]
|
|
530
|
-
LOG.warn "[Basecamp:Orchestrator] Fizzy webhook '#{wh_name}' on board '#{board_key}' is INACTIVE — reactivating" if defined?(LOG)
|
|
531
|
-
_, _, reactivate_status = Open3.capture3(env, "fizzy", "webhook", "reactivate", webhook["id"],
|
|
532
|
-
"--board", board_id, chdir: Dir.home)
|
|
533
|
-
if reactivate_status.success?
|
|
534
|
-
reactivated += 1
|
|
535
|
-
LOG.info "[Basecamp:Orchestrator] Reactivated webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
|
|
536
|
-
else
|
|
537
|
-
LOG.error "[Basecamp:Orchestrator] Failed to reactivate webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
rescue StandardError => e
|
|
541
|
-
LOG.warn "[Basecamp:Orchestrator] Webhook check failed for board '#{board_key}': #{e.message}" if defined?(LOG)
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
LOG.info "[Basecamp:Orchestrator] Fizzy webhook health check: #{reactivated} reactivated" if reactivated.positive?
|
|
545
|
-
rescue StandardError => e
|
|
546
|
-
LOG.warn "[Basecamp:Orchestrator] Fizzy webhook health check failed: #{e.message}" if defined?(LOG)
|
|
547
479
|
end
|
|
548
480
|
|
|
549
481
|
# Resolve a Fizzy user ID from their display name.
|
|
@@ -590,7 +522,7 @@ module Brainiac
|
|
|
590
522
|
def resolve_project_from_fizzy_card(card_number)
|
|
591
523
|
return nil unless card_number
|
|
592
524
|
|
|
593
|
-
# Query the Fizzy card for its tags
|
|
525
|
+
# Query the Fizzy card for its tags and board
|
|
594
526
|
stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
|
|
595
527
|
return nil unless status.success?
|
|
596
528
|
|
|
@@ -599,20 +531,43 @@ module Brainiac
|
|
|
599
531
|
card = card_data.is_a?(Hash) && card_data["data"] ? card_data["data"] : card_data
|
|
600
532
|
tags = card["tags"] || []
|
|
601
533
|
|
|
602
|
-
#
|
|
603
|
-
tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
|
|
604
|
-
return nil if tag_names.empty?
|
|
605
|
-
|
|
606
|
-
# Load projects and match tags
|
|
534
|
+
# Load projects for matching
|
|
607
535
|
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
608
536
|
return nil unless File.exist?(projects_file)
|
|
609
537
|
|
|
610
538
|
all_projects = JSON.parse(File.read(projects_file))
|
|
611
539
|
|
|
612
|
-
#
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
540
|
+
# Priority 1: Match by card tags (same as fizzy handler)
|
|
541
|
+
tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
|
|
542
|
+
unless tag_names.empty?
|
|
543
|
+
all_projects.each do |key, config|
|
|
544
|
+
project_tags = (config["tags"] || config["fizzy_tags"] || []).map(&:downcase)
|
|
545
|
+
return key if tag_names.intersect?(project_tags)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Priority 2: Match by card's board → board_key → project with fizzy_board
|
|
550
|
+
# This mirrors the fizzy handler's fallback logic to prevent project mismatch
|
|
551
|
+
board_id = card.dig("board", "id")
|
|
552
|
+
if board_id
|
|
553
|
+
board_key = resolve_board_key_for_id(board_id)
|
|
554
|
+
if board_key
|
|
555
|
+
# Check for board's default_project in fizzy config
|
|
556
|
+
fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
|
|
557
|
+
if File.exist?(fizzy_config_file)
|
|
558
|
+
fizzy_config = JSON.parse(File.read(fizzy_config_file))
|
|
559
|
+
board_config = fizzy_config.dig("boards", board_key)
|
|
560
|
+
if board_config && board_config["default_project"]
|
|
561
|
+
project_key = board_config["default_project"]
|
|
562
|
+
return project_key if all_projects.key?(project_key)
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Fall back to any project whose fizzy_board matches this board_key
|
|
567
|
+
all_projects.each do |key, config|
|
|
568
|
+
return key if config["fizzy_board"] == board_key
|
|
569
|
+
end
|
|
570
|
+
end
|
|
616
571
|
end
|
|
617
572
|
|
|
618
573
|
nil
|
|
@@ -621,6 +576,23 @@ module Brainiac
|
|
|
621
576
|
nil
|
|
622
577
|
end
|
|
623
578
|
|
|
579
|
+
# Resolve a fizzy board_key from a board_id by checking fizzy.json boards config.
|
|
580
|
+
def resolve_board_key_for_id(board_id)
|
|
581
|
+
fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
|
|
582
|
+
return nil unless File.exist?(fizzy_config_file)
|
|
583
|
+
|
|
584
|
+
fizzy_config = JSON.parse(File.read(fizzy_config_file))
|
|
585
|
+
boards = fizzy_config["boards"] || {}
|
|
586
|
+
|
|
587
|
+
boards.each do |key, config|
|
|
588
|
+
return key if config["board_id"] == board_id
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
nil
|
|
592
|
+
rescue StandardError
|
|
593
|
+
nil
|
|
594
|
+
end
|
|
595
|
+
|
|
624
596
|
# Dispatch an agent to review the epic state after a task completes.
|
|
625
597
|
# This ensures the remaining plan still makes sense given implementation decisions.
|
|
626
598
|
# The callback is called after the review completes.
|
|
@@ -636,6 +608,9 @@ module Brainiac
|
|
|
636
608
|
|
|
637
609
|
LOG.info "[Basecamp:Orchestrator] Dispatching epic review after card ##{completed_card_number}" if defined?(LOG)
|
|
638
610
|
|
|
611
|
+
# Ensure epic memory exists (in case this epic started before the feature)
|
|
612
|
+
EpicMemory.ensure_exists_for(epic)
|
|
613
|
+
|
|
639
614
|
# Build the review prompt
|
|
640
615
|
completed_tasks = epic["tasks"].select { |t| t["status"] == "complete" }
|
|
641
616
|
completed_summary = completed_tasks.map { |t| "- ##{t['fizzy_card']}: #{t['title']}" }.join("\n")
|
|
@@ -645,18 +620,6 @@ module Brainiac
|
|
|
645
620
|
"- ##{t['fizzy_card']}: #{t['title']}#{dep_str}"
|
|
646
621
|
end.join("\n")
|
|
647
622
|
|
|
648
|
-
# Include existing epic memory if any
|
|
649
|
-
epic_memory_path = EpicMemory.path_for(epic["basecamp_todolist_id"])
|
|
650
|
-
epic_memory_section = if EpicMemory.exists?(epic["basecamp_todolist_id"])
|
|
651
|
-
<<~MEM
|
|
652
|
-
|
|
653
|
-
### Epic Memory (shared knowledge so far)
|
|
654
|
-
Read the epic memory file at `#{epic_memory_path}` for decisions and patterns established in previous tasks.
|
|
655
|
-
MEM
|
|
656
|
-
else
|
|
657
|
-
""
|
|
658
|
-
end
|
|
659
|
-
|
|
660
623
|
prompt = <<~PROMPT
|
|
661
624
|
## Epic Review: #{epic['title']}
|
|
662
625
|
|
|
@@ -667,7 +630,7 @@ module Brainiac
|
|
|
667
630
|
|
|
668
631
|
### Remaining tasks (with current dependencies):
|
|
669
632
|
#{remaining_summary}
|
|
670
|
-
|
|
633
|
+
|
|
671
634
|
### Your job:
|
|
672
635
|
1. Read the memory files for completed tasks to understand what was implemented
|
|
673
636
|
2. Check if remaining tasks still make sense given the implementation decisions
|
|
@@ -680,20 +643,10 @@ module Brainiac
|
|
|
680
643
|
|
|
681
644
|
Memory files are at: `~/.brainiac/brain/memory/#{agent_name&.downcase}/card-<number>.md`
|
|
682
645
|
|
|
683
|
-
|
|
684
|
-
After your review, update the epic memory file at `#{epic_memory_path}` with:
|
|
685
|
-
- **Architectural decisions** made during task ##{completed_card_number}
|
|
686
|
-
- **Patterns established** that should be followed in remaining tasks
|
|
687
|
-
- **Gotchas** discovered that future tasks should know about
|
|
688
|
-
- **Cross-task notes** about relationships or dependencies
|
|
689
|
-
|
|
690
|
-
This is shared knowledge — other agents will read it. Be concise but thorough.
|
|
691
|
-
Append a new section, don't replace the existing content.
|
|
692
|
-
|
|
693
|
-
Then post a brief summary comment on the Basecamp todolist:
|
|
646
|
+
After reviewing, post a brief summary comment on the Basecamp todolist:
|
|
694
647
|
`basecamp comments create #{epic['basecamp_todolist_id']} "Epic review after ##{completed_card_number}: <your summary>" --in #{epic['basecamp_project_id']}`
|
|
695
648
|
|
|
696
|
-
Keep
|
|
649
|
+
Keep it concise — this is a checkpoint, not a full analysis.
|
|
697
650
|
PROMPT
|
|
698
651
|
|
|
699
652
|
# Get project config for the agent
|
|
@@ -720,9 +673,15 @@ module Brainiac
|
|
|
720
673
|
card_number: completed_card_number)
|
|
721
674
|
end
|
|
722
675
|
|
|
723
|
-
# Register session for
|
|
724
|
-
if pid
|
|
725
|
-
|
|
676
|
+
# Register session in SessionRegistry for liveness tracking
|
|
677
|
+
if pid
|
|
678
|
+
SessionRegistry.register_session(
|
|
679
|
+
card_key, pid,
|
|
680
|
+
log_file: log_file,
|
|
681
|
+
agent_name: agent_name,
|
|
682
|
+
epic_id: epic["id"],
|
|
683
|
+
card_number: completed_card_number
|
|
684
|
+
)
|
|
726
685
|
end
|
|
727
686
|
|
|
728
687
|
# Wait for the review to complete
|
|
@@ -747,15 +706,6 @@ module Brainiac
|
|
|
747
706
|
profile: epic["agent"]&.downcase)
|
|
748
707
|
end
|
|
749
708
|
|
|
750
|
-
# Mark a Basecamp todo as incomplete (undo premature completion).
|
|
751
|
-
def uncheck_todo(epic, card_number)
|
|
752
|
-
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
753
|
-
return unless task && task["todo_id"]
|
|
754
|
-
|
|
755
|
-
Client.run_safe("todos", "uncomplete", task["todo_id"].to_s, "--json",
|
|
756
|
-
profile: epic["agent"]&.downcase)
|
|
757
|
-
end
|
|
758
|
-
|
|
759
709
|
# Post a completion comment on the Basecamp todo.
|
|
760
710
|
def post_completion_comment(epic, card_number)
|
|
761
711
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
@@ -784,9 +734,6 @@ module Brainiac
|
|
|
784
734
|
|
|
785
735
|
LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}' completed!" if defined?(LOG)
|
|
786
736
|
|
|
787
|
-
# Archive epic memory (preserves it for future reference)
|
|
788
|
-
EpicMemory.cleanup(epic["basecamp_todolist_id"], archive: true)
|
|
789
|
-
|
|
790
737
|
# If epic_branch mode, open final PRs to main
|
|
791
738
|
if epic["review_gate"] == "epic_branch" && epic["epic_branches"]&.any?
|
|
792
739
|
open_final_prs(epic)
|
|
@@ -892,43 +839,14 @@ module Brainiac
|
|
|
892
839
|
end
|
|
893
840
|
|
|
894
841
|
# Create epic branches for all projects involved in this epic.
|
|
895
|
-
# CRITICAL: If this fails, epic_branches will be empty and agents will
|
|
896
|
-
# open PRs against the default branch instead of the epic branch.
|
|
897
|
-
# We log loudly and retry resolution of project repos if initial attempt fails.
|
|
898
842
|
def create_epic_branches_for(epic)
|
|
899
843
|
project_repos = resolve_project_repos(epic)
|
|
900
|
-
|
|
901
|
-
if project_repos.empty?
|
|
902
|
-
# Fallback: try to resolve from the basecamp project mapping directly
|
|
903
|
-
mapped_project = Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
904
|
-
if mapped_project
|
|
905
|
-
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
906
|
-
all_projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
907
|
-
repo = all_projects.dig(mapped_project, "repo_path")
|
|
908
|
-
project_repos = { mapped_project => repo } if repo
|
|
909
|
-
end
|
|
910
|
-
end
|
|
911
|
-
|
|
912
|
-
if project_repos.empty?
|
|
913
|
-
LOG.error "[Basecamp:Orchestrator] Cannot create epic branches — no project repos resolved. " \
|
|
914
|
-
"Tasks have projects: #{(epic['tasks'] || []).map { |t| t['project'] }.compact.uniq.inspect}. " \
|
|
915
|
-
"Epic will proceed WITHOUT epic branches — PRs will target the default branch!" if defined?(LOG)
|
|
916
|
-
log_event(epic, "branches_failed", "No project repos resolved — epic branches not created")
|
|
917
|
-
return
|
|
918
|
-
end
|
|
844
|
+
return if project_repos.empty?
|
|
919
845
|
|
|
920
846
|
epic["epic_branches"] = EpicBranch.create_epic_branches(epic, project_repos)
|
|
921
|
-
|
|
922
|
-
if epic["epic_branches"].empty?
|
|
923
|
-
LOG.error "[Basecamp:Orchestrator] create_epic_branches returned empty — branch creation failed" if defined?(LOG)
|
|
924
|
-
log_event(epic, "branches_failed", "Branch creation returned empty for repos: #{project_repos.keys.join(', ')}")
|
|
925
|
-
else
|
|
926
|
-
log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
|
|
927
|
-
end
|
|
847
|
+
log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
|
|
928
848
|
rescue StandardError => e
|
|
929
|
-
LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.
|
|
930
|
-
"\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
931
|
-
log_event(epic, "branches_failed", "Exception: #{e.message}")
|
|
849
|
+
LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.message}" if defined?(LOG)
|
|
932
850
|
end
|
|
933
851
|
|
|
934
852
|
# Open final PRs from epic branches to main.
|