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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Basecamp
6
+ # Prompt templates for Basecamp-aware agent sessions.
7
+ module Prompts
8
+ # Injected into agent prompts when working on an epic task.
9
+ # Placeholders use %{name} format (rendered by the orchestrator).
10
+ EPIC_CONTEXT = <<~'PROMPT'
11
+ ## Basecamp Epic Context
12
+
13
+ You are working on a task that is part of a larger epic managed in Basecamp.
14
+ The orchestrator will automatically advance to the next task when you complete this one.
15
+
16
+ **Important:**
17
+ - Focus only on this specific card's requirements
18
+ - When done, commit and push as normal — the orchestrator handles sequencing
19
+ - If you discover the task needs changes to the plan (new dependencies, scope change),
20
+ mention it in your Fizzy comment so the human can adjust the Basecamp epic
21
+
22
+ Epic: %{epic_title}
23
+ Progress: %{epic_progress}
24
+ Your task: %{task_title} (Fizzy #%{fizzy_card})
25
+ %{dependencies}
26
+ PROMPT
27
+
28
+ # Template for the Basecamp channel (if agents post directly to Basecamp).
29
+ # Currently unused — agents post to Fizzy/Discord, orchestrator posts to Basecamp.
30
+ CHANNEL = <<~'PROMPT'
31
+ ## Basecamp Communication
32
+
33
+ When referencing Basecamp items:
34
+ - Use the basecamp CLI for queries: `basecamp todos list --in <project> --json`
35
+ - Format card references as: Fizzy #NNNN
36
+ - Keep Basecamp comments brief — detailed discussion happens in Fizzy/Discord
37
+ PROMPT
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # Review gate system for epic tasks.
9
+ #
10
+ # After an implementation agent completes a task PR, gate agents are
11
+ # dispatched IN PARALLEL to review it. All gates must approve before
12
+ # the PR auto-merges into the epic branch.
13
+ #
14
+ # Gate agents are triggered by:
15
+ # - :agent_completed on the task (initial review)
16
+ # - :pr_synchronized (re-review after fixes)
17
+ #
18
+ # Gate agents do NOT get assigned the Fizzy card — they review the PR
19
+ # directly on GitHub using their bot app identities.
20
+ #
21
+ # Configuration in ~/.brainiac/basecamp.json:
22
+ # "review_gates": ["GLaDOS", "Threepio"]
23
+ #
24
+ # The agent's role is looked up from ~/.brainiac/agents.json and used
25
+ # to determine review focus (test-engineer -> testing, code-reviewer -> quality, etc.)
26
+ #
27
+ # All gates run in parallel by default.
28
+ #
29
+ # Gates can also be triggered by a Fizzy card tag "review-gates" for non-epic PRs.
30
+ module ReviewGate
31
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
32
+
33
+ class << self
34
+ # Get the configured review gates as normalized hashes.
35
+ # Supports both old format [{agent:, role:}] and new format ["AgentName"]
36
+ #
37
+ # @return [Array<Hash>] Gate configs [{agent:, role:}]
38
+ def gates
39
+ raw = Config.current["review_gates"] || []
40
+ raw.map do |entry|
41
+ if entry.is_a?(Hash)
42
+ # Old format: {"agent": "GLaDOS", "role": "testing"}
43
+ entry
44
+ else
45
+ # New format: just agent name string — look up role from registry
46
+ agent_name = entry.to_s
47
+ role = lookup_agent_role(agent_name)
48
+ { "agent" => agent_name, "role" => role }
49
+ end
50
+ end
51
+ end
52
+
53
+ # Check if review gates are configured.
54
+ #
55
+ # @return [Boolean]
56
+ def enabled?
57
+ gates.any?
58
+ end
59
+
60
+ # Check if all gates have approved for a task.
61
+ #
62
+ # @param task [Hash] Task state from epic
63
+ # @return [Boolean]
64
+ def all_gates_passed?(task)
65
+ return true unless enabled?
66
+
67
+ approvals = task["gate_approvals"] || []
68
+ required_agents = gates.map { |g| g["agent"].downcase }
69
+ approved_agents = approvals.map { |a| a["agent"].downcase }
70
+
71
+ required_agents.all? { |agent| approved_agents.include?(agent) }
72
+ end
73
+
74
+ # Sync gate approvals from GitHub PR reviews (self-healing).
75
+ # Queries actual PR review state and updates task accordingly.
76
+ #
77
+ # @param task [Hash] Task state (mutated in place)
78
+ # @param repo_path [String] Path to repo for gh CLI
79
+ # @return [Hash] Summary of changes made
80
+ def sync_from_github(task, repo_path:)
81
+ return { synced: false, reason: "no PR" } unless task["pr_number"]
82
+
83
+ pr_number = task["pr_number"]
84
+ stdout, _, status = Open3.capture3(
85
+ "gh", "pr", "view", pr_number.to_s,
86
+ "--json", "reviews",
87
+ "--jq", ".reviews[] | [.author.login, .state] | @tsv",
88
+ chdir: repo_path
89
+ )
90
+ return { synced: false, reason: "gh failed" } unless status.success?
91
+
92
+ # Parse reviews into {author => state}
93
+ reviews = {}
94
+ stdout.each_line do |line|
95
+ author, state = line.strip.split("\t")
96
+ reviews[author.downcase] = state.downcase if author && state
97
+ end
98
+
99
+ changes = { approvals_added: [], changes_cleared: [] }
100
+
101
+ # Check each gate agent's review state
102
+ gates.each do |gate|
103
+ agent = gate["agent"]
104
+ role = gate["role"] || "review"
105
+
106
+ # Match agent to GitHub login (agent-brainiac pattern)
107
+ github_login = "#{agent.downcase}-brainiac"
108
+ review_state = reviews[github_login]
109
+
110
+ next unless review_state
111
+
112
+ if review_state == "approved"
113
+ # Record approval if not already recorded
114
+ unless (task["gate_approvals"] || []).any? { |a| a["agent"].downcase == agent.downcase }
115
+ record_approval(task, agent: agent, role: role)
116
+ changes[:approvals_added] << agent
117
+ end
118
+ # Clear from changes_requested if present
119
+ if task["changes_requested_by"]&.include?(agent)
120
+ task["changes_requested_by"].delete(agent)
121
+ changes[:changes_cleared] << agent
122
+ end
123
+ elsif review_state == "changes_requested"
124
+ # Remove any stale approval
125
+ task["gate_approvals"]&.reject! { |a| a["agent"].downcase == agent.downcase }
126
+ # Track changes_requested
127
+ task["changes_requested_by"] ||= []
128
+ task["changes_requested_by"] << agent unless task["changes_requested_by"].include?(agent)
129
+ end
130
+ end
131
+
132
+ { synced: true, changes: changes }
133
+ rescue StandardError => e
134
+ { synced: false, reason: e.message }
135
+ end
136
+
137
+ # Record a gate approval.
138
+ #
139
+ # @param task [Hash] Task state (mutated in place)
140
+ # @param agent [String] Agent that approved
141
+ # @param role [String] Gate role
142
+ def record_approval(task, agent:, role:)
143
+ task["gate_approvals"] ||= []
144
+ # Don't duplicate
145
+ return if task["gate_approvals"].any? { |a| a["agent"].downcase == agent.downcase }
146
+
147
+ task["gate_approvals"] << {
148
+ "agent" => agent,
149
+ "role" => role,
150
+ "approved_at" => Time.now.iso8601
151
+ }
152
+ end
153
+
154
+ # Reset gate approvals (when changes are requested and code is updated).
155
+ #
156
+ # @param task [Hash] Task state (mutated in place)
157
+ def reset_approvals(task)
158
+ task["gate_approvals"] = []
159
+ end
160
+
161
+ # Dispatch all gate agents to review a PR in parallel.
162
+ # Uses brainiac-github's app client to post review requests as each bot.
163
+ #
164
+ # @param epic [Hash] Epic state
165
+ # @param task [Hash] Task state
166
+ # @param pr_number [Integer, String] PR number
167
+ # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
168
+ # @param repo_path [String] Local repo path
169
+ # @return [Array<String>] Agent names dispatched
170
+ def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
171
+ dispatched = []
172
+
173
+ gates.each do |gate|
174
+ agent_name = gate["agent"]
175
+ role = gate["role"] || "review"
176
+
177
+ LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}" if defined?(LOG)
178
+
179
+ # Dispatch the gate agent via brainiac-github's PR review mechanism.
180
+ # The agent gets the PR diff and reviews it using their bot identity.
181
+ Thread.new do
182
+ dispatch_agent_for_review(
183
+ agent_name: agent_name,
184
+ role: role,
185
+ pr_number: pr_number,
186
+ repo_name: repo_name,
187
+ repo_path: repo_path,
188
+ card_number: task["fizzy_card"],
189
+ epic: epic
190
+ )
191
+ rescue StandardError => e
192
+ LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
193
+ end
194
+
195
+ dispatched << agent_name
196
+ end
197
+
198
+ # Update task state
199
+ task["status"] = "in_review"
200
+ task["gates_dispatched_at"] = Time.now.iso8601
201
+ task["gate_approvals"] ||= []
202
+
203
+ dispatched
204
+ end
205
+
206
+ # Build the summary comment for Fizzy after all gates pass and merge completes.
207
+ #
208
+ # @param task [Hash] Task state
209
+ # @param pr_url [String] PR URL
210
+ # @return [String] HTML comment for Fizzy
211
+ def build_gate_summary_comment(task, pr_url:)
212
+ approvals = task["gate_approvals"] || []
213
+ lines = []
214
+ lines << "<p>✅ <strong>All review gates passed</strong> — merged into epic branch.</p>"
215
+ lines << "<p><a href=\"#{pr_url}\">PR Link</a></p>"
216
+ lines << "<ul>"
217
+ approvals.each do |approval|
218
+ lines << "<li>#{approval['agent']} (#{approval['role']}): approved</li>"
219
+ end
220
+ lines << "</ul>"
221
+ lines.join("\n")
222
+ end
223
+
224
+ # Check if a Fizzy card has the review-gates tag (for non-epic PRs).
225
+ #
226
+ # @param tags [Array] Fizzy card tags
227
+ # @return [Boolean]
228
+ def tag_triggered?(tags)
229
+ tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
230
+ tag_names.include?("review-gates") || tag_names.include?("qa")
231
+ end
232
+
233
+ private
234
+
235
+ # Dispatch a single gate agent to review a PR.
236
+ # This creates a review prompt and runs the agent in the repo directory.
237
+ def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:)
238
+ # Build a review-specific prompt for the gate agent
239
+ prompt = build_gate_review_prompt(
240
+ agent_name: agent_name,
241
+ role: role,
242
+ pr_number: pr_number,
243
+ repo_name: repo_name,
244
+ card_number: card_number,
245
+ epic_title: epic["title"]
246
+ )
247
+
248
+ # Resolve the agent's GitHub token for their bot identity
249
+ agent_env = resolve_agent_github_env(agent_name, repo_name)
250
+
251
+ # Run the agent via the top-level helper method
252
+ # The run_agent method is defined in lib/brainiac/helpers.rb and loaded into main
253
+ pid = nil
254
+ log_file = nil
255
+ card_key = "gate-#{agent_name.downcase}-#{card_number}"
256
+
257
+ begin
258
+ pid, log_file = method(:run_agent).call(
259
+ prompt,
260
+ project_config: resolve_project_config(repo_path),
261
+ chdir: repo_path,
262
+ log_name: "gate-#{role}-#{card_number}",
263
+ agent_name: agent_name,
264
+ source: :github,
265
+ card_number: card_number,
266
+ env: agent_env
267
+ )
268
+ rescue NameError
269
+ # run_agent not available in this context — try calling via Object
270
+ if Object.respond_to?(:run_agent, true)
271
+ pid, log_file = Object.send(:run_agent,
272
+ prompt,
273
+ project_config: resolve_project_config(repo_path),
274
+ chdir: repo_path,
275
+ log_name: "gate-#{role}-#{card_number}",
276
+ agent_name: agent_name,
277
+ source: :github,
278
+ card_number: card_number,
279
+ env: agent_env)
280
+ else
281
+ LOG.warn "[Basecamp:ReviewGate] run_agent not available — gate dispatch skipped" if defined?(LOG)
282
+ end
283
+ end
284
+
285
+ # Register session for waybar visibility
286
+ if pid && defined?(register_session)
287
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
288
+ elsif pid && Object.respond_to?(:register_session, true)
289
+ Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
290
+ end
291
+ end
292
+
293
+ # Build the prompt for a gate review agent.
294
+ def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:)
295
+ <<~PROMPT
296
+ You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
297
+ Your role: **#{role}**
298
+
299
+ This is a review gate — the epic cannot proceed until you approve.
300
+
301
+ Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
302
+
303
+ Based on your role (#{role}):
304
+ #{role_instructions(role)}
305
+
306
+ After your review:
307
+ - If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
308
+ - If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
309
+
310
+ Be thorough but pragmatic. This is Fizzy card ##{card_number}.
311
+
312
+ IMPORTANT RESTRICTIONS:
313
+ - Do NOT open new PRs or modify code — you are a reviewer only
314
+ - Do NOT comment on the Fizzy card — your review goes on GitHub only
315
+ - Do NOT use the fizzy CLI at all
316
+ PROMPT
317
+ end
318
+
319
+ # Role-specific review instructions.
320
+ # Maps agent roles from the registry to review focus areas.
321
+ def role_instructions(role)
322
+ case role.to_s.downcase.gsub(/[-_]/, "")
323
+ # From registry role names
324
+ when "testengineer", "testing", "tests", "qa"
325
+ "- Verify tests exist for new functionality\n- Check test coverage\n- Run the test suite if possible\n- Flag missing edge cases"
326
+ when "codereviewer", "codequality", "quality"
327
+ "- Check code style and conventions\n- Look for code smells, duplication, complexity\n- Verify naming and structure\n- Ensure documentation for public interfaces"
328
+ when "securityengineer", "security"
329
+ "- Check for security vulnerabilities\n- Verify input validation\n- Check for secrets/credentials in code\n- Review auth/authz changes"
330
+ when "architect", "architecture"
331
+ "- Verify design patterns are followed\n- Check for proper separation of concerns\n- Review API design\n- Flag any architectural concerns"
332
+ when "androidengineer", "android"
333
+ "- Check Android-specific patterns and conventions\n- Verify lifecycle handling\n- Review resource usage and memory management"
334
+ when "frontenduxengineer", "frontend", "ux"
335
+ "- Check UI/UX patterns and accessibility\n- Verify responsive design\n- Review user interaction flows"
336
+ else
337
+ "- Review the changes thoroughly\n- Check for correctness and best practices"
338
+ end
339
+ end
340
+
341
+ # Look up an agent's role from the registry.
342
+ #
343
+ # @param agent_name [String] Agent name
344
+ # @return [String] Role name or "reviewer" as default
345
+ def lookup_agent_role(agent_name)
346
+ agents_file = File.join(BRAINIAC_DIR, "agents.json")
347
+ return "reviewer" unless File.exist?(agents_file)
348
+
349
+ agents = JSON.parse(File.read(agents_file))
350
+ agent = agents[agent_name.downcase]
351
+ agent&.dig("role") || "reviewer"
352
+ rescue StandardError
353
+ "reviewer"
354
+ end
355
+
356
+ # Resolve a project config from repo path.
357
+ def resolve_project_config(repo_path)
358
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
359
+ return {} unless File.exist?(projects_file)
360
+
361
+ projects = JSON.parse(File.read(projects_file))
362
+ projects.find { |_key, config| config["repo_path"] == repo_path }&.last || {}
363
+ rescue StandardError
364
+ {}
365
+ end
366
+
367
+ # Resolve GitHub env for an agent (GH_TOKEN from their app identity).
368
+ def resolve_agent_github_env(agent_name, repo_name)
369
+ # Try to use brainiac-github's AppClient if available
370
+ if defined?(Brainiac::Plugins::Github::AppClient)
371
+ repo_owner = repo_name.split("/").first
372
+ token = Brainiac::Plugins::Github::AppClient.installation_token_for(agent_name, repo_owner: repo_owner)
373
+ return { "GH_TOKEN" => token } if token
374
+ end
375
+ {}
376
+ rescue StandardError
377
+ {}
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Basecamp
6
+ VERSION = "0.0.1"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # Handles inbound Basecamp webhooks.
9
+ #
10
+ # Webhook types we care about:
11
+ # - todo_assignment_changed: A todo was assigned/unassigned
12
+ # - todo_completed: A todo was marked complete
13
+ # - todolist_created: A new todolist appeared (potential epic)
14
+ #
15
+ # The trigger for epic orchestration:
16
+ # When a todo inside a todolist (with "Epic:" prefix) is assigned to a bot account,
17
+ # OR when any todo in an epic's todolist is assigned to the bot.
18
+ module Webhook
19
+ class << self
20
+ # Process a webhook payload from Basecamp.
21
+ #
22
+ # @param payload [Hash] Parsed JSON webhook payload
23
+ # @return [Array(Integer, String)] HTTP status code and response body
24
+ def handle(payload)
25
+ kind = payload["kind"]
26
+ recording = payload["recording"] || {}
27
+ details = payload["details"] || {}
28
+
29
+ LOG.info "[Basecamp:Webhook] Received event: #{kind}" if defined?(LOG)
30
+
31
+ case kind
32
+ when "todo_assignment_changed"
33
+ handle_todo_assignment(payload, recording, details)
34
+ when "todo_completed"
35
+ handle_todo_completed(payload, recording)
36
+ when "todolist_created"
37
+ handle_todolist_created(payload, recording)
38
+ when "comment_created"
39
+ handle_comment(payload, recording)
40
+ else
41
+ LOG.debug "[Basecamp:Webhook] Ignoring event kind: #{kind}" if defined?(LOG) && LOG.debug?
42
+ [200, { status: "ignored", kind: kind }.to_json]
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Handle todo assignment changes.
49
+ #
50
+ # Strategy:
51
+ # - When a todo is assigned to a bot account → start epic orchestration
52
+ # - When a todo is unassigned from a bot account → cancel/reset the epic
53
+ def handle_todo_assignment(payload, recording, details)
54
+ added_person_ids = details["added_person_ids"] || []
55
+ removed_person_ids = details["removed_person_ids"] || []
56
+ title = recording["title"] || ""
57
+ todo_id = recording["id"]
58
+ project_id = recording.dig("bucket", "id")
59
+ parent = recording["parent"] || {}
60
+ parent_type = parent["type"]
61
+ parent_title = parent["title"] || ""
62
+ parent_id = parent["id"]
63
+
64
+ # Handle UNASSIGNMENT — if bot is removed, cancel the epic
65
+ removed_person_ids.each do |person_id|
66
+ bot_account = Config.bot_account_for_person(person_id)
67
+ next unless bot_account
68
+
69
+ todolist_id = parent_type == "Todolist" ? parent_id : nil
70
+ next unless todolist_id
71
+
72
+ epic = Orchestrator.find_epic_by_todolist(todolist_id)
73
+ if epic && epic["status"] == "active"
74
+ epic["status"] = "cancelled"
75
+ epic["cancelled_at"] = Time.now.iso8601
76
+ epic["updated_at"] = Time.now.iso8601
77
+
78
+ # Save the cancellation
79
+ epics_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "basecamp_epics.json")
80
+ all = File.exist?(epics_file) ? (JSON.parse(File.read(epics_file))["epics"] || []) : []
81
+ idx = all.index { |e| e["id"] == epic["id"] }
82
+ all[idx] = epic if idx
83
+ File.write(epics_file, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
84
+
85
+ LOG.info "[Basecamp:Webhook] Bot unassigned — cancelled epic '#{epic['title']}'" if defined?(LOG)
86
+ return [200, { status: "epic_cancelled", epic_id: epic["id"] }.to_json]
87
+ end
88
+ end
89
+
90
+ # Handle ASSIGNMENT — if bot is added, start the epic
91
+ added_person_ids.each do |person_id|
92
+ bot_account = Config.bot_account_for_person(person_id)
93
+ next unless bot_account
94
+
95
+ agent = bot_account["default_agent"]
96
+ LOG.info "[Basecamp:Webhook] Todo '#{title}' assigned to bot (agent: #{agent})" if defined?(LOG)
97
+
98
+ # Check if the parent todolist is an epic
99
+ epic_title = nil
100
+ todolist_id = nil
101
+
102
+ if parent_type == "Todolist"
103
+ todolist_id = parent_id
104
+ epic_title = parent_title
105
+ end
106
+
107
+ # If the todo itself has the epic prefix, it might be a standalone trigger
108
+ # But for Option C, we expect the TODOLIST to have the prefix
109
+ if todolist_id && epic_title&.start_with?(Config.epic_prefix)
110
+ Thread.new do
111
+ Orchestrator.start_epic_from_todo(
112
+ todo_id: todo_id,
113
+ todolist_id: todolist_id,
114
+ project_id: project_id,
115
+ agent: agent,
116
+ title: epic_title
117
+ )
118
+ rescue StandardError => e
119
+ LOG.error "[Basecamp:Webhook] Epic start failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(LOG)
120
+ end
121
+
122
+ return [200, { status: "epic_started", todolist_id: todolist_id, agent: agent }.to_json]
123
+ end
124
+
125
+ # Also support: todo title itself starts with Epic: (single-todo trigger)
126
+ if title.start_with?(Config.epic_prefix)
127
+ LOG.info "[Basecamp:Webhook] Standalone epic todo detected — not a todolist. Ignoring." if defined?(LOG)
128
+ return [200, { status: "ignored", reason: "standalone_epic_todo_not_supported" }.to_json]
129
+ end
130
+
131
+ LOG.info "[Basecamp:Webhook] Todo assigned to bot but parent '#{parent_title}' is not an epic" if defined?(LOG)
132
+ return [200, { status: "ignored", reason: "parent_not_epic" }.to_json]
133
+ end
134
+
135
+ [200, { status: "ignored", reason: "no_bot_account_matched" }.to_json]
136
+ end
137
+
138
+ # Handle todo completed events.
139
+ # If the todo is part of an active epic but was completed externally (not by the orchestrator),
140
+ # we should still advance the epic state.
141
+ def handle_todo_completed(_payload, recording)
142
+ todo_id = recording["id"]
143
+ title = recording["title"] || ""
144
+
145
+ # Check if this todo is part of an active epic
146
+ active = Orchestrator.active_epics
147
+ active.each do |epic|
148
+ task = epic["tasks"].find { |t| t["todo_id"].to_s == todo_id.to_s }
149
+ next unless task
150
+ next if task["status"] == "complete" # Already handled
151
+
152
+ # Extract the fizzy card from the task
153
+ fizzy_card = task["fizzy_card"]
154
+ if fizzy_card
155
+ LOG.info "[Basecamp:Webhook] Todo '#{title}' completed externally, advancing epic" if defined?(LOG)
156
+ Thread.new { Orchestrator.on_card_completed(fizzy_card) }
157
+ end
158
+
159
+ return [200, { status: "epic_advanced", epic_id: epic["id"] }.to_json]
160
+ end
161
+
162
+ [200, { status: "noted" }.to_json]
163
+ end
164
+
165
+ # Handle new todolist creation.
166
+ # If it has the epic prefix, we could optionally auto-detect it.
167
+ # For now, just log it — orchestration starts on assignment.
168
+ def handle_todolist_created(_payload, recording)
169
+ title = recording["title"] || ""
170
+
171
+ if title.start_with?(Config.epic_prefix)
172
+ LOG.info "[Basecamp:Webhook] New epic todolist detected: '#{title}' — waiting for assignment to start" if defined?(LOG)
173
+ end
174
+
175
+ [200, { status: "noted" }.to_json]
176
+ end
177
+
178
+ # Handle comments on todos that are part of an epic.
179
+ # Could be used for @bot commands within Basecamp comments.
180
+ def handle_comment(_payload, recording)
181
+ # Future: detect @bot commands in comments
182
+ # e.g., "@Galen pause", "@Galen skip", "@Galen reassign to Sherlock"
183
+ [200, { status: "noted" }.to_json]
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end