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,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Brainiac
7
+ module Plugins
8
+ module Basecamp
9
+ # Manages active epic execution state.
10
+ #
11
+ # An epic = a Basecamp todolist where each todo is linked to a Fizzy card.
12
+ # The orchestrator drives execution: reads the todolist, builds the dep graph,
13
+ # assigns unblocked Fizzy cards, and advances state as cards complete.
14
+ #
15
+ # State is persisted to ~/.brainiac/basecamp_epics.json.
16
+ module Orchestrator
17
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
18
+ EPICS_FILE = File.join(BRAINIAC_DIR, "basecamp_epics.json")
19
+
20
+ class << self
21
+ # Start orchestrating an epic from a todolist.
22
+ #
23
+ # @param todolist_id [String, Integer] Basecamp todolist ID
24
+ # @param project_id [String, Integer] Basecamp project/bucket ID
25
+ # @param agent [String] Agent name to orchestrate
26
+ # @param title [String] Epic/todolist title
27
+ # @return [Hash] The created epic run state
28
+ def start_epic(todolist_id:, project_id:, agent:, title:)
29
+ review_gate = Config.review_gate
30
+
31
+ epic = {
32
+ "id" => "epic-#{todolist_id}",
33
+ "basecamp_todolist_id" => todolist_id.to_s,
34
+ "basecamp_project_id" => project_id.to_s,
35
+ "agent" => agent,
36
+ "title" => title,
37
+ "status" => "active",
38
+ "review_gate" => review_gate,
39
+ "started_at" => Time.now.iso8601,
40
+ "updated_at" => Time.now.iso8601,
41
+ "tasks" => [],
42
+ "epic_branches" => {},
43
+ "history" => []
44
+ }
45
+
46
+ save_epic(epic)
47
+ log_event(epic, "started", "Epic orchestration started by #{agent} (review_gate: #{review_gate})")
48
+
49
+ LOG.info "[Basecamp:Orchestrator] Started epic '#{title}' (todolist #{todolist_id}) " \
50
+ "with agent #{agent}, review_gate: #{review_gate}" if defined?(LOG)
51
+
52
+ # FIRST: Populate tasks with project info from Fizzy card tags
53
+ # This must happen BEFORE creating epic branches so we know which repos are involved
54
+ populate_tasks(epic)
55
+
56
+ # THEN: Create epic branches for all projects (now that we know which projects are involved)
57
+ if review_gate == "epic_branch"
58
+ create_epic_branches_for(epic)
59
+ end
60
+
61
+ # CRITICAL: Save epic state BEFORE dispatching tasks.
62
+ # The resolve_pr_target hook reads from disk, so tasks and epic_branches
63
+ # must be persisted before the agent is dispatched.
64
+ save_epic(epic)
65
+
66
+ # Finally: Dispatch unblocked work
67
+ dispatch_unblocked_tasks(epic)
68
+
69
+ save_epic(epic)
70
+ epic
71
+ end
72
+
73
+ # Start an epic from a single "trigger" todo that contains the todolist context.
74
+ # This is the webhook entry point — a todo is assigned to the bot account,
75
+ # and its parent todolist becomes the epic.
76
+ #
77
+ # @param todo_id [String, Integer] The trigger todo ID
78
+ # @param todolist_id [String, Integer] Parent todolist ID
79
+ # @param project_id [String, Integer] Basecamp project/bucket ID
80
+ # @param agent [String] Agent name
81
+ # @param title [String] Todolist title
82
+ # @return [Hash] The created epic run state
83
+ def start_epic_from_todo(todo_id:, todolist_id:, project_id:, agent:, title:)
84
+ # Check if this epic is already running
85
+ existing = find_epic_by_todolist(todolist_id)
86
+ if existing && existing["status"] == "active"
87
+ LOG.info "[Basecamp:Orchestrator] Epic for todolist #{todolist_id} already active, skipping" if defined?(LOG)
88
+ return existing
89
+ end
90
+
91
+ start_epic(todolist_id: todolist_id, project_id: project_id, agent: agent, title: title)
92
+ end
93
+
94
+ # Called when an agent completes a Fizzy card session.
95
+ # Checks if the card is part of an active epic and advances the orchestration.
96
+ #
97
+ # @param card_number [Integer, String] Fizzy card number that was completed
98
+ # @return [Boolean] Whether this card was part of an epic
99
+ def on_card_completed(card_number)
100
+ card_number = card_number.to_i
101
+ epic = find_epic_for_card(card_number)
102
+ return false unless epic
103
+
104
+ # Idempotency check — don't process completion twice
105
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number }
106
+ if task && task["status"] == "complete"
107
+ LOG.info "[Basecamp:Orchestrator] Card ##{card_number} already complete, skipping duplicate completion" if defined?(LOG)
108
+ return true
109
+ end
110
+
111
+ LOG.info "[Basecamp:Orchestrator] Card ##{card_number} completed, advancing epic '#{epic['title']}'" if defined?(LOG)
112
+
113
+ # Mark the task as complete in our state
114
+ if task
115
+ task["status"] = "complete"
116
+ task["completed_at"] = Time.now.iso8601
117
+ epic["updated_at"] = Time.now.iso8601
118
+ log_event(epic, "task_completed", "Card ##{card_number} completed")
119
+ end
120
+
121
+ # Mark the corresponding Basecamp todo as complete
122
+ mark_todo_complete(epic, card_number)
123
+
124
+ # Post a status comment on the Basecamp todo
125
+ post_completion_comment(epic, card_number)
126
+
127
+ # Check if epic is fully done
128
+ if epic["tasks"].all? { |t| t["status"] == "complete" }
129
+ complete_epic(epic)
130
+ else
131
+ # Dispatch epic review agent before moving to next tasks
132
+ # This ensures the plan still makes sense after implementation decisions
133
+ dispatch_epic_review(epic, card_number) do
134
+ # After review completes, dispatch next unblocked tasks
135
+ resolve_and_dispatch(epic)
136
+ end
137
+ end
138
+
139
+ save_epic(epic)
140
+ true
141
+ end
142
+
143
+ # Find an active epic that contains a given Fizzy card.
144
+ #
145
+ # @param card_number [Integer] Fizzy card number
146
+ # @return [Hash, nil] Epic state or nil
147
+ def find_epic_for_card(card_number)
148
+ load_epics.find do |epic|
149
+ epic["status"] == "active" &&
150
+ epic["tasks"].any? { |t| t["fizzy_card"] == card_number.to_i }
151
+ end
152
+ end
153
+
154
+ # Find an epic by its todolist ID.
155
+ #
156
+ # @param todolist_id [String, Integer] Basecamp todolist ID
157
+ # @return [Hash, nil]
158
+ def find_epic_by_todolist(todolist_id)
159
+ load_epics.find { |e| e["basecamp_todolist_id"] == todolist_id.to_s }
160
+ end
161
+
162
+ # Get all active epics.
163
+ #
164
+ # @return [Array<Hash>]
165
+ def active_epics
166
+ load_epics.select { |e| e["status"] == "active" }
167
+ end
168
+
169
+ # Get all epics (active and completed).
170
+ #
171
+ # @return [Array<Hash>]
172
+ def all_epics
173
+ load_epics
174
+ end
175
+
176
+ # Get a specific epic by ID.
177
+ #
178
+ # @param epic_id [String] Epic ID
179
+ # @return [Hash, nil]
180
+ def find_epic(epic_id)
181
+ load_epics.find { |e| e["id"] == epic_id }
182
+ end
183
+
184
+ private
185
+
186
+ # Resolve current todolist state from Basecamp and dispatch unblocked cards.
187
+ def resolve_and_dispatch(epic)
188
+ populate_tasks(epic)
189
+ dispatch_unblocked_tasks(epic)
190
+ end
191
+
192
+ # Populate/refresh task list from Basecamp todolist.
193
+ # Resolves project key for each task from Fizzy card tags.
194
+ # IMPORTANT: Preserves completed tasks that Basecamp API no longer returns.
195
+ # Also preserves PR/gate state for tasks that are in progress.
196
+ def populate_tasks(epic)
197
+ todos = fetch_todos(epic)
198
+ return unless todos
199
+
200
+ # Parse new todos from Basecamp
201
+ new_tasks = Epic.parse_todos(todos)
202
+ existing_tasks = epic["tasks"] || []
203
+
204
+ # Build a map of existing tasks by fizzy_card for quick lookup
205
+ existing_by_card = existing_tasks.each_with_object({}) { |t, h| h[t["fizzy_card"]] = t }
206
+
207
+ # Start with tasks from the API (incomplete todos)
208
+ updated_tasks = new_tasks.map do |task|
209
+ existing = existing_by_card[task.fizzy_card]
210
+ status = if task.completed
211
+ "complete"
212
+ elsif existing&.dig("status")
213
+ # Preserve existing status (in_flight, in_review, final_decision, etc.)
214
+ existing["status"]
215
+ else
216
+ "pending"
217
+ end
218
+
219
+ project_key = existing&.dig("project") ||
220
+ resolve_project_from_fizzy_card(task.fizzy_card) ||
221
+ Config.brainiac_project_for(epic["basecamp_project_id"])
222
+
223
+ # Build task, preserving all existing state
224
+ new_task = {
225
+ "todo_id" => task.todo_id,
226
+ "fizzy_card" => task.fizzy_card,
227
+ "title" => task.title,
228
+ "depends_on" => task.depends_on,
229
+ "status" => status,
230
+ "project" => project_key,
231
+ "completed_at" => task.completed ? (existing&.dig("completed_at") || Time.now.iso8601) : nil,
232
+ "assignees" => task.assignees,
233
+ "due_on" => task.due_on
234
+ }
235
+
236
+ # Preserve PR and gate state from existing task
237
+ if existing
238
+ %w[dispatched_at pr_number pr_repo gates_dispatched_at gate_approvals
239
+ changes_requested_by awaiting_final_decision changes_debounce_started
240
+ fizzy_internal_id].each do |key|
241
+ new_task[key] = existing[key] if existing.key?(key)
242
+ end
243
+ end
244
+
245
+ new_task
246
+ end
247
+
248
+ # Preserve completed tasks that are no longer in the Basecamp API response
249
+ new_card_numbers = new_tasks.map(&:fizzy_card)
250
+ completed_tasks = existing_tasks.select do |t|
251
+ t["status"] == "complete" && !new_card_numbers.include?(t["fizzy_card"])
252
+ end
253
+
254
+ epic["tasks"] = completed_tasks + updated_tasks
255
+ epic["updated_at"] = Time.now.iso8601
256
+ end
257
+
258
+ # Dispatch unblocked tasks that aren't already in-flight or complete.
259
+ def dispatch_unblocked_tasks(epic)
260
+ tasks = epic["tasks"].map do |t|
261
+ Epic::Task.new(
262
+ todo_id: t["todo_id"],
263
+ fizzy_card: t["fizzy_card"],
264
+ title: t["title"],
265
+ depends_on: t["depends_on"] || [],
266
+ status: t["status"].to_sym,
267
+ completed: t["status"] == "complete"
268
+ )
269
+ end
270
+
271
+ # Find unblocked tasks that aren't already in-flight or complete
272
+ unblocked = Epic.unblocked_tasks(tasks)
273
+ in_flight_cards = epic["tasks"].select { |t| t["status"] == "in_flight" }.map { |t| t["fizzy_card"] }
274
+ complete_cards = epic["tasks"].select { |t| t["status"] == "complete" }.map { |t| t["fizzy_card"] }
275
+
276
+ ready_to_dispatch = unblocked.reject { |t| in_flight_cards.include?(t.fizzy_card) || complete_cards.include?(t.fizzy_card) }
277
+
278
+ ready_to_dispatch.each do |task|
279
+ dispatch_card(epic, task)
280
+ end
281
+
282
+ # Log summary
283
+ LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}': " \
284
+ "#{epic['tasks'].count { |t| t['status'] == 'complete' }}/#{epic['tasks'].size} complete, " \
285
+ "#{ready_to_dispatch.size} dispatched, " \
286
+ "#{epic['tasks'].count { |t| t['status'] == 'in_flight' }} in-flight" if defined?(LOG)
287
+ end
288
+
289
+ # Dispatch a Fizzy card to the appropriate agent.
290
+ def dispatch_card(epic, task)
291
+ card_number = task.fizzy_card
292
+ agent = epic["agent"]
293
+
294
+ LOG.info "[Basecamp:Orchestrator] Dispatching Fizzy card ##{card_number} to #{agent}" if defined?(LOG)
295
+
296
+ # Mark as in-flight in our state
297
+ epic_task = epic["tasks"].find { |t| t["fizzy_card"] == card_number }
298
+ if epic_task
299
+ epic_task["status"] = "in_flight"
300
+ epic_task["dispatched_at"] = Time.now.iso8601
301
+ end
302
+ epic["updated_at"] = Time.now.iso8601
303
+
304
+ log_event(epic, "dispatched", "Card ##{card_number} dispatched to #{agent}")
305
+
306
+ # Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
307
+ assign_fizzy_card(card_number, agent)
308
+
309
+ # Post a comment on the Basecamp todo
310
+ if epic_task && epic_task["todo_id"]
311
+ Client.run_safe(
312
+ "comments", "create", epic_task["todo_id"].to_s,
313
+ "🚀 Dispatched to **#{agent}** via Brainiac",
314
+ "--in", epic["basecamp_project_id"], "--json",
315
+ profile: agent.downcase
316
+ )
317
+ end
318
+ end
319
+
320
+ # Assign a Fizzy card to an agent via Fizzy CLI.
321
+ # If the agent is already assigned, skip — the webhook should have already fired.
322
+ def assign_fizzy_card(card_number, agent)
323
+ agent_config = load_agent_registry[agent.downcase]
324
+ fizzy_name = agent_config&.dig("fizzy_name") || agent
325
+
326
+ # Resolve the Fizzy user ID from the agent's display name
327
+ fizzy_user_id = resolve_fizzy_user_id(fizzy_name)
328
+ unless fizzy_user_id
329
+ LOG.error "[Basecamp:Orchestrator] Could not resolve Fizzy user ID for '#{fizzy_name}'" if defined?(LOG)
330
+ return
331
+ end
332
+
333
+ # Check if agent is already assigned — if so, skip (webhook should have fired)
334
+ stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
335
+ if status.success?
336
+ card_data = JSON.parse(stdout).dig("data") rescue nil
337
+ if card_data
338
+ current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
339
+ if current_assignees.include?(fizzy_user_id)
340
+ LOG.info "[Basecamp:Orchestrator] Agent already assigned to ##{card_number}, skipping (webhook should have fired)" if defined?(LOG)
341
+ return
342
+ end
343
+ end
344
+ end
345
+
346
+ stdout, stderr, status = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
347
+
348
+ if status.success?
349
+ LOG.info "[Basecamp:Orchestrator] Assigned Fizzy ##{card_number} to #{fizzy_name} (#{fizzy_user_id})" if defined?(LOG)
350
+ else
351
+ LOG.error "[Basecamp:Orchestrator] Failed to assign Fizzy ##{card_number}: #{stderr.strip}" if defined?(LOG)
352
+ end
353
+ rescue Errno::ENOENT => e
354
+ LOG.error "[Basecamp:Orchestrator] fizzy CLI not found: #{e.message}" if defined?(LOG)
355
+ end
356
+
357
+ # Resolve a Fizzy user ID from their display name.
358
+ # Reads from ~/.brainiac/fizzy.json authorized_users list.
359
+ def resolve_fizzy_user_id(name)
360
+ @fizzy_users ||= begin
361
+ fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
362
+ return {} unless File.exist?(fizzy_config_file)
363
+
364
+ config = JSON.parse(File.read(fizzy_config_file))
365
+ users = config["authorized_users"] || []
366
+ users.each_with_object({}) { |u, h| h[u["name"].downcase] = u["id"] }
367
+ rescue StandardError
368
+ {}
369
+ end
370
+
371
+ @fizzy_users[name.downcase]
372
+ end
373
+
374
+ # Resolve the brainiac project key for a Fizzy card by querying its tags.
375
+ # Uses the same logic as brainiac-fizzy: card tags are matched against
376
+ # each project's fizzy_tags configuration.
377
+ #
378
+ # @param card_number [Integer, String] Fizzy card number
379
+ # @return [String, nil] Brainiac project key or nil
380
+ def resolve_project_from_fizzy_card(card_number)
381
+ return nil unless card_number
382
+
383
+ # Query the Fizzy card for its tags
384
+ stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
385
+ return nil unless status.success?
386
+
387
+ card_data = JSON.parse(stdout)
388
+ # Handle both envelope format and direct data
389
+ card = card_data.is_a?(Hash) && card_data["data"] ? card_data["data"] : card_data
390
+ tags = card["tags"] || []
391
+
392
+ # Extract tag names (tags can be strings or hashes with "name" key)
393
+ tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
394
+ return nil if tag_names.empty?
395
+
396
+ # Load projects and match tags
397
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
398
+ return nil unless File.exist?(projects_file)
399
+
400
+ all_projects = JSON.parse(File.read(projects_file))
401
+
402
+ # Find the first project whose fizzy_tags intersect with the card's tags
403
+ all_projects.each do |key, config|
404
+ project_tags = (config["tags"] || config["fizzy_tags"] || []).map(&:downcase)
405
+ return key if tag_names.intersect?(project_tags)
406
+ end
407
+
408
+ nil
409
+ rescue StandardError => e
410
+ LOG.warn "[Basecamp:Orchestrator] Could not resolve project for Fizzy card ##{card_number}: #{e.message}" if defined?(LOG)
411
+ nil
412
+ end
413
+
414
+ # Dispatch an agent to review the epic state after a task completes.
415
+ # This ensures the remaining plan still makes sense given implementation decisions.
416
+ # The callback is called after the review completes.
417
+ def dispatch_epic_review(epic, completed_card_number, &callback)
418
+ agent_name = epic["agent"]
419
+ remaining_tasks = epic["tasks"].select { |t| t["status"] == "pending" }
420
+
421
+ # Skip review if no remaining tasks
422
+ if remaining_tasks.empty?
423
+ callback&.call
424
+ return
425
+ end
426
+
427
+ LOG.info "[Basecamp:Orchestrator] Dispatching epic review after card ##{completed_card_number}" if defined?(LOG)
428
+
429
+ # Build the review prompt
430
+ completed_tasks = epic["tasks"].select { |t| t["status"] == "complete" }
431
+ completed_summary = completed_tasks.map { |t| "- ##{t['fizzy_card']}: #{t['title']}" }.join("\n")
432
+ remaining_summary = remaining_tasks.map do |t|
433
+ deps = t["depends_on"] || []
434
+ dep_str = deps.any? ? " [depends: #{deps.map { |d| "##{d}" }.join(', ')}]" : ""
435
+ "- ##{t['fizzy_card']}: #{t['title']}#{dep_str}"
436
+ end.join("\n")
437
+
438
+ prompt = <<~PROMPT
439
+ ## Epic Review: #{epic['title']}
440
+
441
+ Card ##{completed_card_number} just completed. Before dispatching the next task(s), review the epic state.
442
+
443
+ ### Completed tasks:
444
+ #{completed_summary}
445
+
446
+ ### Remaining tasks (with current dependencies):
447
+ #{remaining_summary}
448
+
449
+ ### Your job:
450
+ 1. Read the memory files for completed tasks to understand what was implemented
451
+ 2. Check if remaining tasks still make sense given the implementation decisions
452
+ 3. **Update dependencies** if implementation created new relationships between tasks
453
+ - Add `[depends:NNNN]` to a Fizzy card title if it now depends on another card
454
+ - Remove dependencies that are no longer needed
455
+ 4. If a remaining task is now obsolete, update its Fizzy card with a comment explaining why
456
+ 5. If a remaining task needs different scope, update its Fizzy card description
457
+ 6. If new tasks are needed, create new Fizzy cards (tag with the project)
458
+
459
+ Memory files are at: `~/.brainiac/brain/memory/#{agent_name&.downcase}/card-<number>.md`
460
+
461
+ After reviewing, post a brief summary comment on the Basecamp todolist:
462
+ `basecamp comments create #{epic['basecamp_todolist_id']} "Epic review after ##{completed_card_number}: <your summary>" --in #{epic['basecamp_project_id']}`
463
+
464
+ Keep it concise — this is a checkpoint, not a full analysis.
465
+ PROMPT
466
+
467
+ # Get project config for the agent
468
+ task = epic["tasks"].find { |t| t["fizzy_card"] == completed_card_number.to_i }
469
+ project_key = task&.dig("project") || Config.brainiac_project_for(epic["basecamp_project_id"])
470
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
471
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
472
+ project_config = projects[project_key] || {}
473
+ repo_path = project_config["repo_path"] || Dir.pwd
474
+
475
+ # Spawn the review agent
476
+ Thread.new do
477
+ card_key = "epic-review-#{epic['basecamp_todolist_id']}"
478
+
479
+ begin
480
+ pid, log_file = if Object.respond_to?(:run_agent, true)
481
+ Object.send(:run_agent,
482
+ prompt,
483
+ project_config: project_config,
484
+ chdir: repo_path,
485
+ log_name: "epic-review-#{completed_card_number}",
486
+ agent_name: agent_name,
487
+ source: :basecamp,
488
+ card_number: completed_card_number)
489
+ end
490
+
491
+ # Register session for waybar
492
+ if pid && Object.respond_to?(:register_session, true)
493
+ Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
494
+ end
495
+
496
+ # Wait for the review to complete
497
+ Process.wait(pid) if pid
498
+
499
+ LOG.info "[Basecamp:Orchestrator] Epic review completed for card ##{completed_card_number}" if defined?(LOG)
500
+ rescue StandardError => e
501
+ LOG.error "[Basecamp:Orchestrator] Epic review failed: #{e.message}" if defined?(LOG)
502
+ ensure
503
+ # Call the callback to dispatch next tasks
504
+ callback&.call
505
+ end
506
+ end
507
+ end
508
+
509
+ # Mark a Basecamp todo as complete.
510
+ def mark_todo_complete(epic, card_number)
511
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
512
+ return unless task && task["todo_id"]
513
+
514
+ Client.run_safe("todos", "complete", task["todo_id"].to_s, "--json",
515
+ profile: epic["agent"]&.downcase)
516
+ end
517
+
518
+ # Post a completion comment on the Basecamp todo.
519
+ def post_completion_comment(epic, card_number)
520
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
521
+ return unless task && task["todo_id"]
522
+
523
+ Client.run_safe(
524
+ "comments", "create", task["todo_id"].to_s,
525
+ "✅ Fizzy card ##{card_number} completed",
526
+ "--in", epic["basecamp_project_id"], "--json",
527
+ profile: epic["agent"]&.downcase
528
+ )
529
+ end
530
+
531
+ # Complete the entire epic.
532
+ def complete_epic(epic)
533
+ epic["status"] = "complete"
534
+ epic["completed_at"] = Time.now.iso8601
535
+ epic["updated_at"] = Time.now.iso8601
536
+ log_event(epic, "completed", "All tasks complete — epic finished!")
537
+
538
+ LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}' completed!" if defined?(LOG)
539
+
540
+ # If epic_branch mode, open final PRs to main
541
+ if epic["review_gate"] == "epic_branch" && epic["epic_branches"]&.any?
542
+ open_final_prs(epic)
543
+ end
544
+
545
+ # Post a summary message in Basecamp
546
+ summary = build_completion_summary(epic)
547
+ Client.run_safe(
548
+ "messages", "create", "Epic Complete: #{epic['title']}", summary,
549
+ "--in", epic["basecamp_project_id"], "--json",
550
+ profile: epic["agent"]&.downcase
551
+ )
552
+
553
+ # Send notification (Discord or other configured channel)
554
+ send_notification(
555
+ event: :epic_completed,
556
+ message: "🎉 Epic completed: **#{epic['title']}** (#{epic['tasks'].size} tasks)",
557
+ agent: epic["agent"]
558
+ )
559
+ end
560
+
561
+ # Fetch todos from the epic's todolist.
562
+ def fetch_todos(epic)
563
+ result = Client.run_safe(
564
+ "todos", "list", "--in", epic["basecamp_project_id"],
565
+ "--list", epic["basecamp_todolist_id"], "--json"
566
+ )
567
+
568
+ return nil unless result
569
+
570
+ # Handle both envelope format and raw array
571
+ if result.is_a?(Hash)
572
+ result["data"] || []
573
+ elsif result.is_a?(Array)
574
+ result
575
+ else
576
+ nil
577
+ end
578
+ end
579
+
580
+ # Build a completion summary for the epic.
581
+ def build_completion_summary(epic)
582
+ tasks = epic["tasks"]
583
+ duration = if epic["started_at"] && epic["completed_at"]
584
+ started = Time.parse(epic["started_at"])
585
+ completed = Time.parse(epic["completed_at"])
586
+ hours = ((completed - started) / 3600).round(1)
587
+ hours > 24 ? "#{(hours / 24).round(1)} days" : "#{hours} hours"
588
+ end
589
+
590
+ lines = []
591
+ lines << "All #{tasks.size} tasks completed#{duration ? " in #{duration}" : ''}."
592
+ lines << ""
593
+ tasks.each do |task|
594
+ lines << "- ✅ #{task['title']} (Fizzy ##{task['fizzy_card']})"
595
+ end
596
+ lines << ""
597
+ lines << "Orchestrated by #{epic['agent']} via brainiac-basecamp."
598
+ lines.join("\n")
599
+ end
600
+
601
+ # Log an event to the epic's history.
602
+ def log_event(epic, event_type, message)
603
+ epic["history"] ||= []
604
+ epic["history"] << {
605
+ "event" => event_type,
606
+ "message" => message,
607
+ "at" => Time.now.iso8601
608
+ }
609
+ end
610
+
611
+ # Load all epics from disk.
612
+ def load_epics
613
+ return [] unless File.exist?(EPICS_FILE)
614
+
615
+ data = JSON.parse(File.read(EPICS_FILE))
616
+ data["epics"] || []
617
+ rescue JSON::ParserError
618
+ []
619
+ end
620
+
621
+ # Save an epic to disk (upsert by ID).
622
+ def save_epic(epic)
623
+ all = load_epics
624
+ idx = all.index { |e| e["id"] == epic["id"] }
625
+ if idx
626
+ all[idx] = epic
627
+ else
628
+ all << epic
629
+ end
630
+
631
+ File.write(EPICS_FILE, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
632
+ end
633
+
634
+ # Load agent registry from ~/.brainiac/agents.json.
635
+ def load_agent_registry
636
+ agents_file = File.join(BRAINIAC_DIR, "agents.json")
637
+ return {} unless File.exist?(agents_file)
638
+
639
+ JSON.parse(File.read(agents_file))
640
+ rescue JSON::ParserError
641
+ {}
642
+ end
643
+
644
+ # Create epic branches for all projects involved in this epic.
645
+ def create_epic_branches_for(epic)
646
+ project_repos = resolve_project_repos(epic)
647
+ return if project_repos.empty?
648
+
649
+ epic["epic_branches"] = EpicBranch.create_epic_branches(epic, project_repos)
650
+ log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
651
+ rescue StandardError => e
652
+ LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.message}" if defined?(LOG)
653
+ end
654
+
655
+ # Open final PRs from epic branches to main.
656
+ def open_final_prs(epic)
657
+ project_repos = resolve_project_repos(epic)
658
+ return if project_repos.empty?
659
+
660
+ prs = EpicBranch.open_final_prs(epic, project_repos, epic["epic_branches"])
661
+ epic["final_prs"] = prs
662
+ log_event(epic, "final_prs_opened", "Opened #{prs.size} final PR(s): #{prs.map { |p| p[:url] }.join(', ')}")
663
+
664
+ # Send notification about final PRs
665
+ if prs.any?
666
+ pr_list = prs.map { |p| p[:url] }.join("\n")
667
+ send_notification(
668
+ event: :epic_prs_ready,
669
+ message: "📋 Epic **#{epic['title']}** — final PR ready for review:\n#{pr_list}",
670
+ agent: epic["agent"]
671
+ )
672
+ end
673
+ rescue StandardError => e
674
+ LOG.error "[Basecamp:Orchestrator] Failed to open final PRs: #{e.message}" if defined?(LOG)
675
+ end
676
+
677
+ # Resolve project_key => repo_path for all projects in the epic's tasks.
678
+ def resolve_project_repos(epic)
679
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
680
+ return {} unless File.exist?(projects_file)
681
+
682
+ all_projects = JSON.parse(File.read(projects_file))
683
+ project_keys = (epic["tasks"] || []).map { |t| t["project"] }.compact.uniq
684
+
685
+ # If no per-task project is set, use the brainiac project mapped to this basecamp project
686
+ if project_keys.empty?
687
+ mapped = Config.brainiac_project_for(epic["basecamp_project_id"])
688
+ project_keys = [mapped] if mapped
689
+ end
690
+
691
+ project_keys.each_with_object({}) do |key, hash|
692
+ repo = all_projects.dig(key, "repo_path")
693
+ hash[key] = repo if repo
694
+ end
695
+ rescue JSON::ParserError
696
+ {}
697
+ end
698
+
699
+ # Send a notification via the configured channel (Discord, etc.).
700
+ # Reads discord_channel_id from basecamp.json notifications config.
701
+ def send_notification(event:, message:, agent: nil)
702
+ return unless defined?(Brainiac) && Brainiac.respond_to?(:emit)
703
+
704
+ # Check if this event type is enabled
705
+ notifications_config = Config.current.dig("notifications") || {}
706
+ return unless notifications_config[event.to_s] != false
707
+
708
+ # Get the target channel
709
+ discord_channel = notifications_config["discord_channel_id"]
710
+ return unless discord_channel
711
+
712
+ Brainiac.emit(:notify,
713
+ channel: :discord,
714
+ target: discord_channel,
715
+ message: message,
716
+ agent: agent)
717
+ end
718
+ end
719
+ end
720
+ end
721
+ end
722
+ end