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.
- checksums.yaml +7 -0
- data/README.md +322 -0
- data/lib/brainiac/plugins/basecamp/cli.rb +411 -0
- data/lib/brainiac/plugins/basecamp/client.rb +174 -0
- data/lib/brainiac/plugins/basecamp/config.rb +115 -0
- data/lib/brainiac/plugins/basecamp/epic.rb +200 -0
- data/lib/brainiac/plugins/basecamp/epic_branch.rb +271 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +820 -0
- data/lib/brainiac/plugins/basecamp/metadata.rb +20 -0
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +722 -0
- data/lib/brainiac/plugins/basecamp/prompts.rb +41 -0
- data/lib/brainiac/plugins/basecamp/review_gate.rb +383 -0
- data/lib/brainiac/plugins/basecamp/version.rb +9 -0
- data/lib/brainiac/plugins/basecamp/webhook.rb +189 -0
- data/lib/brainiac/plugins/basecamp.rb +314 -0
- data/lib/brainiac_basecamp.rb +4 -0
- metadata +126 -0
|
@@ -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
|