brainiac-basecamp 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brainiac/plugins/basecamp/cli.rb +534 -10
- data/lib/brainiac/plugins/basecamp/comment_responder.rb +380 -0
- data/lib/brainiac/plugins/basecamp/epic_memory.rb +10 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +94 -270
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +65 -187
- data/lib/brainiac/plugins/basecamp/review_gate.rb +165 -218
- data/lib/brainiac/plugins/basecamp/session_registry.rb +361 -0
- data/lib/brainiac/plugins/basecamp/task_state.rb +86 -0
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp/webhook.rb +4 -6
- data/lib/brainiac/plugins/basecamp.rb +128 -492
- metadata +4 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Handles inbound Basecamp comments on epic todolists/todos.
|
|
9
|
+
#
|
|
10
|
+
# Routing:
|
|
11
|
+
# 1. If a bot account is @mentioned in the comment → dispatch that agent
|
|
12
|
+
# 2. If no mention → dispatch the last agent who responded on this epic
|
|
13
|
+
# 3. If no prior responder → dispatch the epic's default agent
|
|
14
|
+
#
|
|
15
|
+
# The dispatched agent receives the comment content as a prompt with epic context,
|
|
16
|
+
# and posts its reply back via Client.add_comment.
|
|
17
|
+
module CommentResponder
|
|
18
|
+
MENTION_TAG_OPEN = "<bc-attachment"
|
|
19
|
+
MENTION_TAG_CLOSE = "</bc-attachment>"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Process a comment_created webhook and dispatch the appropriate agent.
|
|
23
|
+
#
|
|
24
|
+
# @param payload [Hash] Full webhook payload
|
|
25
|
+
# @param recording [Hash] The comment recording from the payload
|
|
26
|
+
# @return [Array(Integer, String)] HTTP status code and response body
|
|
27
|
+
def handle(payload, recording)
|
|
28
|
+
content = recording["content"] || ""
|
|
29
|
+
creator = payload["creator"] || {}
|
|
30
|
+
creator_id = creator["id"]&.to_s
|
|
31
|
+
parent = recording["parent"] || {}
|
|
32
|
+
parent_type = parent["type"]
|
|
33
|
+
parent_id = parent["id"]
|
|
34
|
+
parent_title = parent["title"] || ""
|
|
35
|
+
project_id = recording.dig("bucket", "id")&.to_s
|
|
36
|
+
|
|
37
|
+
# Ignore comments posted by our own bot accounts (prevent loops)
|
|
38
|
+
if Config.bot_account_for_person(creator_id)
|
|
39
|
+
LOG.debug "[Basecamp:Comment] Ignoring comment from our own bot (person #{creator_id})" if defined?(LOG)
|
|
40
|
+
return [200, { status: "ignored", reason: "self_comment" }.to_json]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Determine if this comment is on an epic todolist or a todo within one
|
|
44
|
+
epic = resolve_epic_for_comment(parent_type, parent_id, parent_title, project_id)
|
|
45
|
+
unless epic
|
|
46
|
+
LOG.debug "[Basecamp:Comment] Comment not on an epic recording — ignoring" if defined?(LOG)
|
|
47
|
+
return [200, { status: "ignored", reason: "not_epic" }.to_json]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Determine which agent to dispatch
|
|
51
|
+
agent_name = resolve_target_agent(content, epic)
|
|
52
|
+
|
|
53
|
+
LOG.info "[Basecamp:Comment] Dispatching #{agent_name} to respond to comment on '#{epic['title']}'" if defined?(LOG)
|
|
54
|
+
|
|
55
|
+
# Strip HTML tags for a clean text prompt, preserve @mentions as names
|
|
56
|
+
clean_content = strip_html_preserve_mentions(content)
|
|
57
|
+
commenter_name = creator["name"] || "Someone"
|
|
58
|
+
|
|
59
|
+
# Dispatch the agent in a background thread
|
|
60
|
+
Thread.new do
|
|
61
|
+
dispatch_comment_response(
|
|
62
|
+
epic: epic,
|
|
63
|
+
agent_name: agent_name,
|
|
64
|
+
comment_text: clean_content,
|
|
65
|
+
commenter_name: commenter_name,
|
|
66
|
+
recording_id: parent_id,
|
|
67
|
+
project_id: project_id
|
|
68
|
+
)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
LOG.error "[Basecamp:Comment] Dispatch failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Track last responding agent on the epic
|
|
74
|
+
epic["last_responding_agent"] = agent_name
|
|
75
|
+
epic["updated_at"] = Time.now.iso8601
|
|
76
|
+
Hooks.send(:save_epic_state, epic)
|
|
77
|
+
|
|
78
|
+
[200, { status: "dispatched", agent: agent_name, epic_id: epic["id"] }.to_json]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Resolve Basecamp person IDs for known agent names using the basecamp CLI.
|
|
82
|
+
# Used during setup to auto-map bot accounts.
|
|
83
|
+
#
|
|
84
|
+
# @param agent_names [Array<String>] Agent names to look up (e.g. ["Galen", "Kaylee"])
|
|
85
|
+
# @param project_id [String, nil] Optional project/bucket ID for scoping
|
|
86
|
+
# @return [Hash<String, String>] agent_name => person_id mapping
|
|
87
|
+
def resolve_person_ids(agent_names)
|
|
88
|
+
results = {}
|
|
89
|
+
|
|
90
|
+
agent_names.each do |name|
|
|
91
|
+
# Use jq to filter people by name
|
|
92
|
+
output, status = Open3.capture2(
|
|
93
|
+
"basecamp", "people", "list", "--jq",
|
|
94
|
+
".data[] | select(.name | ascii_downcase | contains(\"#{name.downcase}\")) | {id, name}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
next unless status.success?
|
|
98
|
+
|
|
99
|
+
# Parse each JSON line (could be multiple matches)
|
|
100
|
+
output.each_line do |line|
|
|
101
|
+
person = JSON.parse(line.strip)
|
|
102
|
+
# Exact match preferred, otherwise first contains-match
|
|
103
|
+
if person["name"]&.downcase == name.downcase
|
|
104
|
+
results[name] = person["id"].to_s
|
|
105
|
+
break
|
|
106
|
+
elsif !results.key?(name)
|
|
107
|
+
results[name] = person["id"].to_s
|
|
108
|
+
end
|
|
109
|
+
rescue JSON::ParserError
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
results
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Find the active epic that this comment belongs to.
|
|
120
|
+
#
|
|
121
|
+
# @param parent_type [String] "Todolist" or "Todo"
|
|
122
|
+
# @param parent_id [Integer, String] ID of the parent recording
|
|
123
|
+
# @param parent_title [String] Title of the parent
|
|
124
|
+
# @param project_id [String] Basecamp bucket/project ID
|
|
125
|
+
# @return [Hash, nil] Epic state or nil
|
|
126
|
+
def resolve_epic_for_comment(parent_type, parent_id, _parent_title, _project_id)
|
|
127
|
+
active_epics = Orchestrator.active_epics
|
|
128
|
+
|
|
129
|
+
case parent_type
|
|
130
|
+
when "Todolist"
|
|
131
|
+
# Comment directly on the epic todolist
|
|
132
|
+
active_epics.find { |e| e["todolist_id"].to_s == parent_id.to_s }
|
|
133
|
+
when "Todo"
|
|
134
|
+
# Comment on a specific todo within an epic
|
|
135
|
+
active_epics.find do |e|
|
|
136
|
+
e["tasks"]&.any? { |t| t["todo_id"].to_s == parent_id.to_s }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Determine which agent should respond to this comment.
|
|
142
|
+
#
|
|
143
|
+
# Priority:
|
|
144
|
+
# 1. Explicit @mention of a bot account in the comment HTML
|
|
145
|
+
# 2. Last agent who responded on this epic
|
|
146
|
+
# 3. Epic's default agent
|
|
147
|
+
#
|
|
148
|
+
# @param content [String] Comment HTML content
|
|
149
|
+
# @param epic [Hash] Epic state
|
|
150
|
+
# @return [String] Agent name
|
|
151
|
+
def resolve_target_agent(content, epic)
|
|
152
|
+
# 1. Check for @mentions of bot accounts
|
|
153
|
+
mentioned_agent = detect_mentioned_agent(content)
|
|
154
|
+
return mentioned_agent if mentioned_agent
|
|
155
|
+
|
|
156
|
+
# 2. Fall back to last responding agent
|
|
157
|
+
return epic["last_responding_agent"] if epic["last_responding_agent"]
|
|
158
|
+
|
|
159
|
+
# 3. Fall back to epic's default agent
|
|
160
|
+
epic["agent"] || "Galen"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Parse Basecamp rich text HTML to find @mentions of bot accounts.
|
|
164
|
+
#
|
|
165
|
+
# Basecamp mentions look like:
|
|
166
|
+
# <bc-attachment sgid="..." content-type="application/vnd.basecamp.mention">@Name</bc-attachment>
|
|
167
|
+
#
|
|
168
|
+
# We also check for plain-text @AgentName patterns as a fallback.
|
|
169
|
+
#
|
|
170
|
+
# @param content [String] HTML content of the comment
|
|
171
|
+
# @return [String, nil] Agent name if a bot was mentioned, nil otherwise
|
|
172
|
+
def detect_mentioned_agent(content)
|
|
173
|
+
bot_accounts = Config.current["bot_accounts"] || {}
|
|
174
|
+
|
|
175
|
+
# Strategy 1: Parse bc-attachment mentions (Basecamp's native format)
|
|
176
|
+
# The sgid encodes the person — but we can match by the visible name text
|
|
177
|
+
each_basecamp_mention(content) do |mention_content|
|
|
178
|
+
mention_name = strip_html_tags(mention_content).delete_prefix("@").strip
|
|
179
|
+
bot_accounts.each_value do |account|
|
|
180
|
+
agent = account["default_agent"]
|
|
181
|
+
# Match if the mention text contains the agent name (case insensitive)
|
|
182
|
+
return agent if mention_name.downcase.include?(agent.downcase)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Strategy 2: Plain text @AgentName pattern (fallback for simple comments)
|
|
187
|
+
bot_accounts.each_value do |account|
|
|
188
|
+
agent = account["default_agent"]
|
|
189
|
+
return agent if content.match?(/(?:^|\s)@#{Regexp.escape(agent)}\b/i)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Strip HTML tags but preserve mention names as readable text.
|
|
196
|
+
# Uses bounded atomic groups to prevent polynomial regex backtracking,
|
|
197
|
+
# and loops until stable to prevent incomplete sanitization (e.g. nested tags
|
|
198
|
+
# that reconstruct dangerous elements after a single pass).
|
|
199
|
+
#
|
|
200
|
+
# @param html [String] HTML content
|
|
201
|
+
# @return [String] Clean text
|
|
202
|
+
def strip_html_preserve_mentions(html)
|
|
203
|
+
text = +""
|
|
204
|
+
cursor = 0
|
|
205
|
+
|
|
206
|
+
while (tag_start = html.index("<", cursor))
|
|
207
|
+
text << html[cursor...tag_start]
|
|
208
|
+
tag_end = html.index(">", tag_start + 1)
|
|
209
|
+
break unless tag_end
|
|
210
|
+
|
|
211
|
+
tag = html[tag_start..tag_end]
|
|
212
|
+
if basecamp_mention_tag?(tag)
|
|
213
|
+
closing_start = html.index(MENTION_TAG_CLOSE, tag_end + 1)
|
|
214
|
+
break unless closing_start
|
|
215
|
+
|
|
216
|
+
mention_content = html[(tag_end + 1)...closing_start]
|
|
217
|
+
text << "@#{strip_html_tags(mention_content).delete_prefix('@').strip}"
|
|
218
|
+
cursor = closing_start + MENTION_TAG_CLOSE.size
|
|
219
|
+
next
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
cursor = tag_end + 1
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
text << html[cursor..] unless cursor >= html.length || tag_start
|
|
226
|
+
text.split.join(" ")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Iterate over the text from native Basecamp mention attachments. This uses
|
|
230
|
+
# bounded String operations rather than a backtracking regular expression,
|
|
231
|
+
# because comment content comes from an untrusted webhook payload.
|
|
232
|
+
def each_basecamp_mention(html)
|
|
233
|
+
cursor = 0
|
|
234
|
+
|
|
235
|
+
while (tag_start = html.index(MENTION_TAG_OPEN, cursor))
|
|
236
|
+
tag_end = html.index(">", tag_start + 1)
|
|
237
|
+
break unless tag_end
|
|
238
|
+
|
|
239
|
+
closing_start = html.index(MENTION_TAG_CLOSE, tag_end + 1)
|
|
240
|
+
break unless closing_start
|
|
241
|
+
|
|
242
|
+
tag = html[tag_start..tag_end]
|
|
243
|
+
yield html[(tag_end + 1)...closing_start] if basecamp_mention_tag?(tag)
|
|
244
|
+
cursor = closing_start + MENTION_TAG_CLOSE.size
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Return whether an attachment tag has a content-type value containing
|
|
249
|
+
# "mention". Basecamp uses application/vnd.basecamp.mention.
|
|
250
|
+
def basecamp_mention_tag?(tag)
|
|
251
|
+
marker = "content-type="
|
|
252
|
+
marker_start = tag.downcase.index(marker)
|
|
253
|
+
return false unless marker_start
|
|
254
|
+
|
|
255
|
+
value_start = marker_start + marker.length
|
|
256
|
+
quote = tag[value_start]
|
|
257
|
+
return false unless ['"', "'"].include?(quote)
|
|
258
|
+
|
|
259
|
+
value_end = tag.index(quote, value_start + 1)
|
|
260
|
+
return false unless value_end
|
|
261
|
+
|
|
262
|
+
tag[(value_start + 1)...value_end].downcase.include?("mention")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Remove markup without returning a dangling '<' sequence. An unclosed tag
|
|
266
|
+
# is discarded with the remainder of the input, keeping the result safe as
|
|
267
|
+
# plain text if it is ever rendered by a downstream consumer.
|
|
268
|
+
def strip_html_tags(html)
|
|
269
|
+
text = +""
|
|
270
|
+
cursor = 0
|
|
271
|
+
|
|
272
|
+
while (tag_start = html.index("<", cursor))
|
|
273
|
+
text << html[cursor...tag_start]
|
|
274
|
+
tag_end = html.index(">", tag_start + 1)
|
|
275
|
+
return text if tag_end.nil?
|
|
276
|
+
|
|
277
|
+
cursor = tag_end + 1
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
text << html[cursor..]
|
|
281
|
+
text
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Dispatch an agent to respond to the Basecamp comment.
|
|
285
|
+
#
|
|
286
|
+
# @param epic [Hash] Epic state
|
|
287
|
+
# @param agent_name [String] Agent to dispatch
|
|
288
|
+
# @param comment_text [String] Clean text of the comment
|
|
289
|
+
# @param commenter_name [String] Name of the person who commented
|
|
290
|
+
# @param recording_id [String, Integer] The recording to reply to
|
|
291
|
+
# @param project_id [String] Basecamp project/bucket ID
|
|
292
|
+
def dispatch_comment_response(epic:, agent_name:, comment_text:, commenter_name:, recording_id:, project_id:)
|
|
293
|
+
# Build context about the epic state
|
|
294
|
+
tasks_summary = (epic["tasks"] || []).map do |t|
|
|
295
|
+
status_icon = case t["status"]
|
|
296
|
+
when "complete" then "✅"
|
|
297
|
+
when "in_flight" then "🚀"
|
|
298
|
+
when "in_review" then "👀"
|
|
299
|
+
when "final_decision" then "⚖️"
|
|
300
|
+
else "⏳"
|
|
301
|
+
end
|
|
302
|
+
"#{status_icon} ##{t['fizzy_card']} — #{t['title'] || 'Untitled'} (#{t['status']})"
|
|
303
|
+
end.join("\n")
|
|
304
|
+
|
|
305
|
+
prompt = <<~PROMPT
|
|
306
|
+
## Basecamp Comment — Reply Required
|
|
307
|
+
|
|
308
|
+
**#{commenter_name}** commented on the epic "#{epic['title']}":
|
|
309
|
+
|
|
310
|
+
> #{comment_text}
|
|
311
|
+
|
|
312
|
+
### Epic Status
|
|
313
|
+
#{tasks_summary}
|
|
314
|
+
|
|
315
|
+
### Instructions
|
|
316
|
+
You're responding to a comment on a Basecamp epic todolist. Reply conversationally
|
|
317
|
+
and helpfully. If they're asking about status, give specifics from the task list.
|
|
318
|
+
If they're asking you to do something (pause, skip, adjust), explain what you can do.
|
|
319
|
+
|
|
320
|
+
**Reply format:** Write your response as plain text (Basecamp supports basic Markdown).
|
|
321
|
+
Keep it concise but informative.
|
|
322
|
+
|
|
323
|
+
When you're done composing your reply, post it using:
|
|
324
|
+
```
|
|
325
|
+
basecamp comments create #{recording_id} "<your reply>" --in #{project_id}
|
|
326
|
+
```
|
|
327
|
+
PROMPT
|
|
328
|
+
|
|
329
|
+
# Resolve project config for the agent
|
|
330
|
+
project_key = Config.brainiac_project_for(project_id)
|
|
331
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
332
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
333
|
+
project_config = projects[project_key]
|
|
334
|
+
|
|
335
|
+
repo_path = project_config&.dig("repo_path") || Dir.home
|
|
336
|
+
|
|
337
|
+
# Spawn the agent
|
|
338
|
+
pid = nil
|
|
339
|
+
log_file = nil
|
|
340
|
+
card_key = "basecamp-comment-#{epic['id']}"
|
|
341
|
+
|
|
342
|
+
begin
|
|
343
|
+
pid, log_file = Hooks.send(:run_agent,
|
|
344
|
+
prompt,
|
|
345
|
+
project_config: project_config,
|
|
346
|
+
chdir: repo_path,
|
|
347
|
+
log_name: "basecamp-comment-#{epic['id']}-#{Time.now.strftime('%Y%m%d-%H%M%S')}",
|
|
348
|
+
agent_name: agent_name,
|
|
349
|
+
source: :basecamp,
|
|
350
|
+
env: {})
|
|
351
|
+
rescue NameError
|
|
352
|
+
if Object.respond_to?(:run_agent, true)
|
|
353
|
+
pid, log_file = Object.send(:run_agent,
|
|
354
|
+
prompt,
|
|
355
|
+
project_config: project_config,
|
|
356
|
+
chdir: repo_path,
|
|
357
|
+
log_name: "basecamp-comment-#{epic['id']}-#{Time.now.strftime('%Y%m%d-%H%M%S')}",
|
|
358
|
+
agent_name: agent_name,
|
|
359
|
+
source: :basecamp,
|
|
360
|
+
env: {})
|
|
361
|
+
else
|
|
362
|
+
LOG.warn "[Basecamp:Comment] run_agent not available — comment response skipped" if defined?(LOG)
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
return unless pid
|
|
368
|
+
|
|
369
|
+
if defined?(register_session)
|
|
370
|
+
register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
371
|
+
elsif Object.respond_to?(:register_session, true)
|
|
372
|
+
Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
373
|
+
end
|
|
374
|
+
LOG.info "[Basecamp:Comment] Spawned #{agent_name} (pid #{pid}) to respond on epic '#{epic['title']}'" if defined?(LOG)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -60,6 +60,16 @@ module Brainiac
|
|
|
60
60
|
File.exist?(path_for(todolist_id))
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Ensure epic memory exists for an epic (creates if missing).
|
|
64
|
+
# Called on resume for epics that started before the feature existed.
|
|
65
|
+
#
|
|
66
|
+
# @param epic [Hash] Epic state
|
|
67
|
+
def ensure_exists_for(epic)
|
|
68
|
+
return if exists?(epic["basecamp_todolist_id"])
|
|
69
|
+
|
|
70
|
+
initialize_for(epic)
|
|
71
|
+
end
|
|
72
|
+
|
|
63
73
|
# Initialize epic memory with the epic title and initial context.
|
|
64
74
|
# Called when an epic starts.
|
|
65
75
|
#
|