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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "basecamp/version"
4
+ require_relative "basecamp/metadata"
5
+ require_relative "basecamp/config"
6
+ require_relative "basecamp/client"
7
+ require_relative "basecamp/epic"
8
+ require_relative "basecamp/epic_branch"
9
+ require_relative "basecamp/review_gate"
10
+ require_relative "basecamp/orchestrator"
11
+ require_relative "basecamp/webhook"
12
+ require_relative "basecamp/hooks"
13
+ require_relative "basecamp/prompts"
14
+ require_relative "basecamp/cli"
15
+
16
+ module Brainiac
17
+ module Plugins
18
+ module Basecamp
19
+ class << self
20
+ # Called by Brainiac plugin system during server startup.
21
+ #
22
+ # @param app [Sinatra::Application] The running Brainiac server
23
+ def register(app)
24
+ Config.load!
25
+
26
+ # Register lifecycle hooks
27
+ Hooks.register_all!
28
+
29
+ # Register channel prompt (for when agents need Basecamp awareness)
30
+ Brainiac.register_channel_prompt(:basecamp, Prompts::CHANNEL)
31
+
32
+ # Set up routes
33
+ setup_routes(app)
34
+
35
+ # Log active epics on startup and resume them
36
+ active = Orchestrator.active_epics
37
+ if active.any?
38
+ LOG.info "[Basecamp] #{active.size} active epic(s) in progress"
39
+ active.each { |e| LOG.info "[Basecamp] - #{e['title']} (#{e['tasks']&.count { |t| t['status'] == 'complete' }}/#{e['tasks']&.size} complete)" }
40
+
41
+ # Resume active epics in background after server is ready
42
+ Thread.new do
43
+ sleep 10 # Wait for server to fully start
44
+ resume_active_epics(active)
45
+ rescue StandardError => e
46
+ LOG.error "[Basecamp] Error resuming epics: #{e.message}" if defined?(LOG)
47
+ end
48
+ end
49
+
50
+ LOG.info "[Basecamp] Plugin registered (webhook: /basecamp, review_gate: #{Config.review_gate})"
51
+ end
52
+
53
+ private
54
+
55
+ # Resume active epics on server startup.
56
+ # For each epic, checks task states and takes appropriate action.
57
+ def resume_active_epics(epics)
58
+ epics.each do |epic|
59
+ LOG.info "[Basecamp] Resuming epic: #{epic['title']}" if defined?(LOG)
60
+
61
+ # Check if all tasks are complete — finalize the epic
62
+ if epic["tasks"]&.all? { |t| t["status"] == "complete" }
63
+ LOG.info "[Basecamp] Resume: all tasks complete — finalizing epic" if defined?(LOG)
64
+
65
+ # Mark each Basecamp todo as complete (in case they weren't marked during normal flow)
66
+ epic["tasks"].each do |task|
67
+ Orchestrator.send(:mark_todo_complete, epic, task["fizzy_card"])
68
+ end
69
+
70
+ Orchestrator.send(:complete_epic, epic)
71
+ Orchestrator.send(:save_epic, epic)
72
+ next
73
+ end
74
+
75
+ epic["tasks"]&.each do |task|
76
+ card_number = task["fizzy_card"]
77
+ status = task["status"]
78
+ LOG.info "[Basecamp] Resume: task ##{card_number} status=#{status}" if defined?(LOG)
79
+
80
+ case status
81
+ when "in_review", "in_flight"
82
+ # Sync gate state from GitHub and decide next action
83
+ resume_in_review_task(epic, task)
84
+ when "final_decision"
85
+ # Final decision was pending — check if we can merge
86
+ resume_final_decision_task(epic, task)
87
+ when "pending"
88
+ # Check if dependencies are met and dispatch
89
+ # (This is handled by dispatch_unblocked_tasks normally)
90
+ next
91
+ end
92
+ end
93
+
94
+ # Dispatch any unblocked tasks
95
+ Orchestrator.send(:dispatch_unblocked_tasks, epic)
96
+ end
97
+ end
98
+
99
+ def resume_in_review_task(epic, task)
100
+ card_number = task["fizzy_card"]
101
+ pr_number = task["pr_number"]
102
+
103
+ unless pr_number
104
+ LOG.info "[Basecamp] Task ##{card_number} has no PR yet — skipping resume" if defined?(LOG)
105
+ return
106
+ end
107
+
108
+ # Get project config for repo path
109
+ project_key = task["project"]
110
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
111
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
112
+ repo_path = projects.dig(project_key, "repo_path")
113
+
114
+ unless repo_path
115
+ LOG.warn "[Basecamp] No repo_path for project #{project_key}" if defined?(LOG)
116
+ return
117
+ end
118
+
119
+ # Sync gate approvals from GitHub
120
+ sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
121
+ if sync_result[:synced]
122
+ changes = sync_result[:changes] || {}
123
+ if changes[:approvals_added]&.any?
124
+ LOG.info "[Basecamp] Resume: synced approvals for ##{card_number}: #{changes[:approvals_added].join(', ')}" if defined?(LOG)
125
+ end
126
+ end
127
+
128
+ # Check if all gates have approved
129
+ if ReviewGate.all_gates_passed?(task)
130
+ LOG.info "[Basecamp] Resume: all gates passed for ##{card_number} — dispatching final decision" if defined?(LOG)
131
+ task["status"] = "final_decision"
132
+ task["awaiting_final_decision"] = true
133
+ Hooks.send(:save_epic_state, epic)
134
+ Hooks.send(:dispatch_final_decision, epic, task, {})
135
+ else
136
+ # Gates haven't all approved — check if we need to dispatch fixes or wait
137
+ approvals = task["gate_approvals"]&.size || 0
138
+ changes = task["changes_requested_by"]&.size || 0
139
+ LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals, #{changes} changes_requested — waiting" if defined?(LOG)
140
+ end
141
+ end
142
+
143
+ def resume_final_decision_task(epic, task)
144
+ card_number = task["fizzy_card"]
145
+ LOG.info "[Basecamp] Resume: checking final_decision task ##{card_number}, awaiting=#{task['awaiting_final_decision']}" if defined?(LOG)
146
+
147
+ # If awaiting_final_decision is set, re-dispatch
148
+ # Also handle the case where it's nil but gates are all approved (stale state)
149
+ if task["awaiting_final_decision"] || ReviewGate.all_gates_passed?(task)
150
+ LOG.info "[Basecamp] Resume: dispatching final decision for ##{card_number}" if defined?(LOG)
151
+ task["awaiting_final_decision"] = true
152
+ Hooks.send(:save_epic_state, epic)
153
+ Hooks.send(:dispatch_final_decision, epic, task, {})
154
+ else
155
+ LOG.info "[Basecamp] Resume: final_decision task ##{card_number} not ready (awaiting=#{task['awaiting_final_decision']}, gates_passed=#{ReviewGate.all_gates_passed?(task)})" if defined?(LOG)
156
+ end
157
+ end
158
+
159
+ def setup_routes(app)
160
+ setup_webhook_route(app)
161
+ setup_api_routes(app)
162
+ end
163
+
164
+ def setup_webhook_route(app)
165
+ app.post "/basecamp" do
166
+ content_type :json
167
+ request.body.rewind
168
+ payload_body = request.body.read
169
+
170
+ begin
171
+ payload = JSON.parse(payload_body)
172
+ rescue JSON::ParserError => e
173
+ LOG.error "[Basecamp] Invalid JSON: #{e.message}"
174
+ halt 400, { error: "Invalid JSON" }.to_json
175
+ end
176
+
177
+ status_code, body = Webhook.handle(payload)
178
+ halt status_code, body
179
+ rescue StandardError => e
180
+ LOG.error "[Basecamp] Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
181
+ halt 500, { error: e.message }.to_json
182
+ end
183
+ end
184
+
185
+ def setup_api_routes(app)
186
+ # Status endpoint
187
+ app.get "/api/basecamp" do
188
+ content_type :json
189
+ config = Config.current
190
+ {
191
+ enabled: true,
192
+ review_gate: config["review_gate"],
193
+ bot_accounts: config["bot_accounts"].keys,
194
+ project_mappings: config["project_mappings"].keys,
195
+ active_epics: Orchestrator.active_epics.size,
196
+ total_epics: Orchestrator.all_epics.size
197
+ }.to_json
198
+ end
199
+
200
+ # List epics
201
+ app.get "/api/basecamp/epics" do
202
+ content_type :json
203
+ epics = params["status"] == "all" ? Orchestrator.all_epics : Orchestrator.active_epics
204
+ { epics: epics }.to_json
205
+ end
206
+
207
+ # Get specific epic with dependency graph
208
+ app.get "/api/basecamp/epics/:id" do
209
+ content_type :json
210
+ epic = Orchestrator.find_epic(params["id"])
211
+ halt 404, { error: "Epic not found" }.to_json unless epic
212
+
213
+ # Build dependency graph from current state
214
+ tasks = (epic["tasks"] || []).map do |t|
215
+ Epic::Task.new(
216
+ todo_id: t["todo_id"],
217
+ title: t["title"],
218
+ fizzy_card: t["fizzy_card"],
219
+ depends_on: t["depends_on"] || [],
220
+ status: t["status"]&.to_sym || :pending,
221
+ completed: t["status"] == "complete"
222
+ )
223
+ end
224
+
225
+ epic.merge("dependency_graph" => Epic.dependency_graph(tasks)).to_json
226
+ end
227
+
228
+ # Manually trigger an epic (for testing or when webhooks aren't set up)
229
+ app.post "/api/basecamp/epics" do
230
+ content_type :json
231
+ request.body.rewind
232
+
233
+ begin
234
+ payload = JSON.parse(request.body.read)
235
+ rescue JSON::ParserError
236
+ halt 400, { error: "Invalid JSON" }.to_json
237
+ end
238
+
239
+ todolist_id = payload["todolist_id"]
240
+ project_id = payload["project_id"]
241
+ agent = payload["agent"]
242
+ title = payload["title"]
243
+
244
+ halt 400, { error: "Missing required fields: todolist_id, project_id, agent, title" }.to_json unless todolist_id && project_id && agent && title
245
+
246
+ Thread.new do
247
+ Orchestrator.start_epic(
248
+ todolist_id: todolist_id,
249
+ project_id: project_id,
250
+ agent: agent,
251
+ title: title
252
+ )
253
+ rescue StandardError => e
254
+ LOG.error "[Basecamp:API] Epic start failed: #{e.message}" if defined?(LOG)
255
+ end
256
+
257
+ { status: "starting", todolist_id: todolist_id, agent: agent }.to_json
258
+ end
259
+
260
+ # Pause/resume an epic
261
+ app.post "/api/basecamp/epics/:id/pause" do
262
+ content_type :json
263
+ epic = Orchestrator.find_epic(params["id"])
264
+ halt 404, { error: "Epic not found" }.to_json unless epic
265
+
266
+ epic["status"] = "paused"
267
+ epic["paused_at"] = Time.now.iso8601
268
+ epic["updated_at"] = Time.now.iso8601
269
+
270
+ # Save via the epics file
271
+ save_epic_via_api(epic)
272
+ { status: "paused", epic_id: epic["id"] }.to_json
273
+ end
274
+
275
+ app.post "/api/basecamp/epics/:id/resume" do
276
+ content_type :json
277
+ epic = Orchestrator.find_epic(params["id"])
278
+ halt 404, { error: "Epic not found" }.to_json unless epic
279
+
280
+ epic["status"] = "active"
281
+ epic["resumed_at"] = Time.now.iso8601
282
+ epic["updated_at"] = Time.now.iso8601
283
+
284
+ save_epic_via_api(epic)
285
+
286
+ # Re-trigger dispatch
287
+ Thread.new { Orchestrator.send(:resolve_and_dispatch, epic) }
288
+
289
+ { status: "resumed", epic_id: epic["id"] }.to_json
290
+ end
291
+ end
292
+
293
+ def save_epic_via_api(epic)
294
+ epics_file = File.join(
295
+ ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
296
+ "basecamp_epics.json"
297
+ )
298
+
299
+ all = if File.exist?(epics_file)
300
+ data = JSON.parse(File.read(epics_file))
301
+ data["epics"] || []
302
+ else
303
+ []
304
+ end
305
+
306
+ idx = all.index { |e| e["id"] == epic["id"] }
307
+ all[idx] = epic if idx
308
+
309
+ File.write(epics_file, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Brainiac Basecamp Plugin — entry point loaded by RubyGems.
4
+ require_relative "brainiac/plugins/basecamp"
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brainiac-basecamp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andy Davis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: brainiac
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.23
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.23
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.75'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.75'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-performance
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.25'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.25'
82
+ description: Manages epics in Basecamp with autonomous agent orchestration. Tracks
83
+ dependencies between Fizzy cards, dispatches agents in sequence, and syncs completion
84
+ status bidirectionally.
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - README.md
90
+ - lib/brainiac/plugins/basecamp.rb
91
+ - lib/brainiac/plugins/basecamp/cli.rb
92
+ - lib/brainiac/plugins/basecamp/client.rb
93
+ - lib/brainiac/plugins/basecamp/config.rb
94
+ - lib/brainiac/plugins/basecamp/epic.rb
95
+ - lib/brainiac/plugins/basecamp/epic_branch.rb
96
+ - lib/brainiac/plugins/basecamp/hooks.rb
97
+ - lib/brainiac/plugins/basecamp/metadata.rb
98
+ - lib/brainiac/plugins/basecamp/orchestrator.rb
99
+ - lib/brainiac/plugins/basecamp/prompts.rb
100
+ - lib/brainiac/plugins/basecamp/review_gate.rb
101
+ - lib/brainiac/plugins/basecamp/version.rb
102
+ - lib/brainiac/plugins/basecamp/webhook.rb
103
+ - lib/brainiac_basecamp.rb
104
+ homepage: https://github.com/stowzilla/brainiac-basecamp
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ rubygems_mfa_required: 'true'
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '3.4'
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.6.9
124
+ specification_version: 4
125
+ summary: Basecamp epic orchestration plugin for Brainiac
126
+ test_files: []