brainiac 0.0.6 → 0.0.8
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/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +472 -44
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +27 -74
- data/lib/brainiac/brain.rb +6 -6
- data/lib/brainiac/config.rb +40 -76
- data/lib/brainiac/handlers/discord/api.rb +196 -0
- data/lib/brainiac/handlers/discord/config.rb +134 -0
- data/lib/brainiac/handlers/discord/delivery.rb +196 -0
- data/lib/brainiac/handlers/discord/gateway.rb +212 -0
- data/lib/brainiac/handlers/discord/message.rb +933 -0
- data/lib/brainiac/handlers/discord/reactions.rb +215 -0
- data/lib/brainiac/handlers/discord.rb +14 -1892
- data/lib/brainiac/handlers/github.rb +134 -317
- data/lib/brainiac/handlers/shared/git.rb +190 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +103 -153
- data/lib/brainiac/helpers.rb +43 -455
- data/lib/brainiac/hooks.rb +86 -0
- data/lib/brainiac/plugins.rb +154 -0
- data/lib/brainiac/prompts.rb +34 -172
- data/lib/brainiac/restart.rb +112 -0
- data/lib/brainiac/routes/api.rb +411 -0
- data/lib/brainiac/users.rb +1 -7
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +8 -1
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
- data/monitor/waybar/setup.rb +232 -0
- data/monitor/waybar/status.rb +51 -0
- data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
- data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
- data/monitor/xbar/plugin.rb +149 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +44 -551
- data/templates/agents.json.example +1 -2
- data/templates/brainiac.json.example +8 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- data/templates/users.json.example +0 -3
- metadata +25 -23
- data/lib/brainiac/card_index.rb +0 -389
- data/lib/brainiac/deployments.rb +0 -258
- data/lib/brainiac/handlers/fizzy.rb +0 -1292
- data/lib/brainiac/planning.rb +0 -237
- data/lib/user_registry.rb +0 -159
- data/monitor/menubar.rb +0 -295
- data/monitor/setup-waybar-deploy-envs.rb +0 -121
- data/monitor/setup-waybar-deployments.rb +0 -96
- data/monitor/setup-waybar-module.rb +0 -113
- data/monitor/setup-xbar-plugin.rb +0 -35
- data/monitor/view-logs.rb +0 -119
- data/monitor/waybar-config-updater.rb +0 -56
- data/monitor/waybar-deployments.rb +0 -239
- data/monitor/waybar.rb +0 -146
- data/monitor/xbar.3s.rb +0 -179
- data/templates/fizzy.json.example +0 -24
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -5,45 +5,6 @@
|
|
|
5
5
|
require "English"
|
|
6
6
|
CLI_PROVIDERS_DIR = File.join(BRAINIAC_DIR, "cli-providers")
|
|
7
7
|
|
|
8
|
-
# --trust-all-tools alone doesn't bypass the non-interactive deny list in kiro-cli 1.29.8+.
|
|
9
|
-
# Adding --trust-tools with explicit tool names ensures write/exec tools are approved.
|
|
10
|
-
# This is now configured in the kiro provider's default_args instead of being hardcoded here.
|
|
11
|
-
TRUSTED_TOOLS = "execute_bash,fs_write,fs_read,code,grep,glob,web_search,web_fetch,use_subagent,use_aws"
|
|
12
|
-
|
|
13
|
-
# Clean up all worktrees associated with a card: the primary worktree and any
|
|
14
|
-
# cross-agent review worktrees (e.g. glados-fizzy-123-*, threepio-fizzy-123-*).
|
|
15
|
-
# Safe: skips worktrees with uncommitted changes.
|
|
16
|
-
def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
|
|
17
|
-
return unless card_number
|
|
18
|
-
|
|
19
|
-
repo_dir = File.dirname(repo_path)
|
|
20
|
-
repo_base = File.basename(repo_path)
|
|
21
|
-
cleaned = 0
|
|
22
|
-
|
|
23
|
-
# Collect all worktree dirs for this card: primary + cross-agent review
|
|
24
|
-
candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
|
|
25
|
-
candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
|
|
26
|
-
|
|
27
|
-
candidates.uniq.each do |wt_path|
|
|
28
|
-
status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
|
|
29
|
-
if status_output.strip.empty?
|
|
30
|
-
branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
|
|
31
|
-
begin
|
|
32
|
-
run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
|
|
33
|
-
run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
|
|
34
|
-
cleaned += 1
|
|
35
|
-
LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
|
|
36
|
-
rescue StandardError => e
|
|
37
|
-
LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
|
|
38
|
-
end
|
|
39
|
-
else
|
|
40
|
-
LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
|
|
45
|
-
end
|
|
46
|
-
|
|
47
8
|
# Load a CLI provider config from ~/.brainiac/cli-providers/<name>.json.
|
|
48
9
|
# Returns a hash with normalized keys, or {} if not found.
|
|
49
10
|
def load_cli_provider(provider_name)
|
|
@@ -109,107 +70,28 @@ def agent_cli_provider_for(agent_name)
|
|
|
109
70
|
entry["cli_provider"]
|
|
110
71
|
end
|
|
111
72
|
|
|
112
|
-
# Detect CLI provider override from inline [cli:X] tag or
|
|
73
|
+
# Detect CLI provider override from inline [cli:X] tag or card tags.
|
|
113
74
|
# Returns the provider name (e.g. "grok") or nil.
|
|
114
75
|
def detect_cli_provider(text: "", tags: [])
|
|
115
|
-
# Inline tag: [cli:grok]
|
|
76
|
+
# Inline tag: [cli:grok] — works in any channel
|
|
116
77
|
if (match = text.match(/\[cli:(\w+)\]/i))
|
|
117
78
|
return match[1].downcase
|
|
118
79
|
end
|
|
119
80
|
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
81
|
+
# Plugin hook: let plugins detect from their own metadata (e.g., card tags)
|
|
82
|
+
results = Brainiac.emit(:detect_cli_provider, text: text, tags: tags)
|
|
83
|
+
plugin_result = results.compact.first
|
|
84
|
+
return plugin_result if plugin_result
|
|
125
85
|
|
|
126
86
|
nil
|
|
127
87
|
end
|
|
128
88
|
|
|
129
|
-
# Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
|
|
130
|
-
# Symlink directories matching .worktreelink patterns instead of copying.
|
|
131
|
-
# Both files use .gitignore syntax. Only gitignored files/dirs are processed.
|
|
132
|
-
def apply_worktree_includes(repo_path, worktree_path)
|
|
133
|
-
copied = 0
|
|
134
|
-
linked = 0
|
|
135
|
-
|
|
136
|
-
[".worktreeinclude", ".worktreelink"].each do |filename|
|
|
137
|
-
config_file = File.join(repo_path, filename)
|
|
138
|
-
next unless File.exist?(config_file)
|
|
139
|
-
|
|
140
|
-
symlink_mode = filename == ".worktreelink"
|
|
141
|
-
patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
142
|
-
next if patterns.empty?
|
|
143
|
-
|
|
144
|
-
patterns.each do |pattern|
|
|
145
|
-
Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match|
|
|
146
|
-
src = File.join(repo_path, match)
|
|
147
|
-
dest = File.join(worktree_path, match)
|
|
148
|
-
next if File.exist?(dest) || File.symlink?(dest)
|
|
149
|
-
|
|
150
|
-
# Only process gitignored files/dirs
|
|
151
|
-
_, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path)
|
|
152
|
-
next unless st.success?
|
|
153
|
-
|
|
154
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
155
|
-
|
|
156
|
-
if symlink_mode && File.directory?(src)
|
|
157
|
-
FileUtils.ln_s(src, dest)
|
|
158
|
-
linked += 1
|
|
159
|
-
LOG.info "Symlinked #{match} from main repo"
|
|
160
|
-
elsif File.file?(src)
|
|
161
|
-
FileUtils.cp(src, dest)
|
|
162
|
-
copied += 1
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive?
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Run a project-level hook script from .brainiac/<hook_name> if it exists.
|
|
172
|
-
# Passes REPO_PATH (and optionally WORKTREE_PATH) as environment variables.
|
|
173
|
-
def run_project_hook(repo_path, hook_name, extra_env: {})
|
|
174
|
-
hook = File.join(repo_path, ".brainiac", hook_name)
|
|
175
|
-
return unless File.exist?(hook)
|
|
176
|
-
|
|
177
|
-
env = { "REPO_PATH" => repo_path }.merge(extra_env)
|
|
178
|
-
LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}"
|
|
179
|
-
output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
|
|
180
|
-
if status.success?
|
|
181
|
-
LOG.info ".brainiac/#{hook_name} completed successfully"
|
|
182
|
-
else
|
|
183
|
-
LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
89
|
def default_project_key
|
|
188
90
|
# Find the project marked as default
|
|
189
91
|
default = PROJECTS.find { |_key, config| config["default"] == true }
|
|
190
92
|
default ? default[0] : nil
|
|
191
93
|
end
|
|
192
94
|
|
|
193
|
-
def identify_project_by_tags(tags)
|
|
194
|
-
return nil if PROJECTS.empty?
|
|
195
|
-
|
|
196
|
-
tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
|
|
197
|
-
|
|
198
|
-
PROJECTS.each do |project_key, config|
|
|
199
|
-
project_tags = (config["fizzy_tags"] || []).map(&:downcase)
|
|
200
|
-
return [project_key, config] if tag_names.intersect?(project_tags)
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# Fall back to default project if configured
|
|
204
|
-
default_key = default_project_key
|
|
205
|
-
if default_key
|
|
206
|
-
LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'"
|
|
207
|
-
return [default_key, PROJECTS[default_key]]
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
nil
|
|
211
|
-
end
|
|
212
|
-
|
|
213
95
|
def identify_project_by_repo(repo_full_name)
|
|
214
96
|
return nil if PROJECTS.empty?
|
|
215
97
|
|
|
@@ -227,54 +109,22 @@ def identify_project_by_repo(repo_full_name)
|
|
|
227
109
|
nil
|
|
228
110
|
end
|
|
229
111
|
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
[nil, "--indexed-by closed"].each do |extra_flag|
|
|
233
|
-
cmd = ["fizzy", "card", "list", "--all"]
|
|
234
|
-
cmd << extra_flag if extra_flag
|
|
235
|
-
output, status = Open3.capture2(env, *cmd, chdir: repo_path)
|
|
236
|
-
next unless status.success?
|
|
237
|
-
|
|
238
|
-
data = JSON.parse(output)["data"] || []
|
|
239
|
-
match = data.find { |c| c["id"] == internal_id }
|
|
240
|
-
if match
|
|
241
|
-
LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}"
|
|
242
|
-
return match["number"]
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
LOG.warn "Could not resolve card number for internal_id #{internal_id}"
|
|
247
|
-
nil
|
|
248
|
-
rescue StandardError => e
|
|
249
|
-
LOG.warn "resolve_card_number failed for #{internal_id}: #{e.message}"
|
|
250
|
-
nil
|
|
251
|
-
end
|
|
112
|
+
def load_work_item_map
|
|
113
|
+
return {} unless File.exist?(WORK_ITEM_MAP_FILE)
|
|
252
114
|
|
|
253
|
-
|
|
254
|
-
return {} unless File.exist?(CARD_MAP_FILE)
|
|
255
|
-
|
|
256
|
-
JSON.parse(File.read(CARD_MAP_FILE))
|
|
115
|
+
JSON.parse(File.read(WORK_ITEM_MAP_FILE))
|
|
257
116
|
rescue JSON::ParserError
|
|
258
117
|
{}
|
|
259
118
|
end
|
|
260
119
|
|
|
261
|
-
def
|
|
262
|
-
File.write(
|
|
120
|
+
def save_work_item_map(map)
|
|
121
|
+
File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
|
|
263
122
|
end
|
|
264
123
|
|
|
265
124
|
def slugify(title, max_length: 40)
|
|
266
125
|
title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
|
|
267
126
|
end
|
|
268
127
|
|
|
269
|
-
def verify_signature!(request, payload_body, board_key: nil)
|
|
270
|
-
signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
|
|
271
|
-
halt 403, { error: "Missing signature" }.to_json unless signature
|
|
272
|
-
secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET
|
|
273
|
-
halt 403, { error: "No webhook secret configured" }.to_json unless secret
|
|
274
|
-
computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
|
|
275
|
-
halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
128
|
def verify_github_signature!(request, payload_body)
|
|
279
129
|
signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
|
|
280
130
|
halt 403, { error: "Missing GitHub signature" }.to_json unless signature
|
|
@@ -292,36 +142,22 @@ def run_cmd(*cmd, chdir:, env: {})
|
|
|
292
142
|
stdout
|
|
293
143
|
end
|
|
294
144
|
|
|
295
|
-
# Trust the version manager config in a directory (supports mise and asdf)
|
|
296
|
-
def trust_version_manager(path, chdir:)
|
|
297
|
-
if system("which mise >/dev/null 2>&1")
|
|
298
|
-
run_cmd("mise", "trust", path, chdir: chdir)
|
|
299
|
-
elsif system("which asdf >/dev/null 2>&1")
|
|
300
|
-
LOG.info "asdf detected — no explicit trust needed for #{path}"
|
|
301
|
-
else
|
|
302
|
-
LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}"
|
|
303
|
-
end
|
|
304
|
-
rescue StandardError => e
|
|
305
|
-
LOG.warn "Could not trust version manager in #{path}: #{e.message}"
|
|
306
|
-
end
|
|
307
|
-
|
|
308
145
|
# Cards that have been merged to main — skip Needs Review moves for these.
|
|
309
146
|
# Keyed by card number (string), value is Time. Entries expire after 10 minutes.
|
|
310
147
|
MERGED_CARDS = {}
|
|
311
148
|
MERGED_CARDS_MUTEX = Mutex.new
|
|
312
149
|
|
|
313
|
-
def
|
|
150
|
+
def mark_work_item_merged(card_number)
|
|
314
151
|
MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
|
|
315
152
|
end
|
|
316
153
|
|
|
317
|
-
def
|
|
154
|
+
def work_item_merged?(card_number)
|
|
318
155
|
MERGED_CARDS_MUTEX.synchronize do
|
|
319
156
|
ts = MERGED_CARDS[card_number.to_s]
|
|
320
157
|
ts && (Time.now - ts < 600)
|
|
321
158
|
end
|
|
322
159
|
end
|
|
323
160
|
|
|
324
|
-
# Pre-fetch a Fizzy card's body and comments so the agent doesn't have to.
|
|
325
161
|
# Returns a formatted string suitable for injection into the prompt, or ''
|
|
326
162
|
# if the fetch fails (agent can still fetch manually as a fallback).
|
|
327
163
|
PREFETCH_COMMENT_LIMIT = 15
|
|
@@ -329,106 +165,7 @@ COMMENT_BODY_TRUNCATE_LENGTH = 500
|
|
|
329
165
|
CARD_CONTEXT_CACHE = {}
|
|
330
166
|
CARD_CONTEXT_CACHE_TTL = 60 # seconds
|
|
331
167
|
|
|
332
|
-
def prefetch_card_context(card_number, repo_path:, agent_name: nil)
|
|
333
|
-
return "" unless card_number
|
|
334
|
-
|
|
335
|
-
# Return cached context if fresh enough
|
|
336
|
-
cache_key = "#{card_number}-#{agent_name}"
|
|
337
|
-
cached = CARD_CONTEXT_CACHE[cache_key]
|
|
338
|
-
if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL
|
|
339
|
-
LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)"
|
|
340
|
-
return cached[:context]
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
env = fizzy_env_for(agent_name)
|
|
344
|
-
parts = []
|
|
345
|
-
|
|
346
|
-
card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env)
|
|
347
|
-
return "" if card_parts.nil?
|
|
348
|
-
|
|
349
|
-
parts.concat(card_parts)
|
|
350
|
-
parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env))
|
|
351
|
-
return "" if parts.empty?
|
|
352
|
-
|
|
353
|
-
context = parts.join("\n")
|
|
354
|
-
result = <<~CARD_CONTEXT
|
|
355
|
-
## Card Context (pre-fetched — do NOT re-fetch this)
|
|
356
|
-
#{context}
|
|
357
|
-
|
|
358
|
-
CARD_CONTEXT
|
|
359
|
-
|
|
360
|
-
CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now }
|
|
361
|
-
CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50
|
|
362
|
-
result
|
|
363
|
-
rescue StandardError => e
|
|
364
|
-
LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.message}"
|
|
365
|
-
""
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
# Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
|
|
369
|
-
def fetch_card_details(card_number, repo_path:, env:)
|
|
370
|
-
card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
|
|
371
|
-
card_data = begin
|
|
372
|
-
JSON.parse(card_output)["data"]
|
|
373
|
-
rescue StandardError
|
|
374
|
-
nil
|
|
375
|
-
end
|
|
376
|
-
return [] unless card_data
|
|
377
|
-
|
|
378
|
-
parts = []
|
|
379
|
-
parts << "## Card ##{card_number}: #{card_data["title"]}"
|
|
380
|
-
parts << "Status: #{card_data["status"]}" if card_data["status"]
|
|
381
|
-
tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
|
|
382
|
-
parts << "Tags: #{tags.join(", ")}" unless tags.empty?
|
|
383
|
-
body = card_data.dig("body", "plain_text") || card_data["body"]
|
|
384
|
-
parts << "\n#{body}" if body && !body.to_s.strip.empty?
|
|
385
|
-
parts
|
|
386
|
-
rescue StandardError => e
|
|
387
|
-
LOG.warn "Could not pre-fetch card ##{card_number}: #{e.message}"
|
|
388
|
-
nil
|
|
389
|
-
end
|
|
390
|
-
|
|
391
168
|
# Fetch recent comments for a card. Returns array of text parts.
|
|
392
|
-
def fetch_card_comments(card_number, repo_path:, env:)
|
|
393
|
-
comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
394
|
-
comments_data = JSON.parse(comments_output)["data"] || []
|
|
395
|
-
return [] if comments_data.empty?
|
|
396
|
-
|
|
397
|
-
parts = []
|
|
398
|
-
total = comments_data.size
|
|
399
|
-
comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT)
|
|
400
|
-
parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}"
|
|
401
|
-
comments_data.each do |c|
|
|
402
|
-
author = c.dig("creator", "name") || "Unknown"
|
|
403
|
-
body = c.dig("body", "plain_text") || ""
|
|
404
|
-
cid = c["id"]
|
|
405
|
-
next if body.strip.empty?
|
|
406
|
-
|
|
407
|
-
body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH
|
|
408
|
-
parts << "\n### #{author} (comment ID: #{cid})\n#{body}"
|
|
409
|
-
end
|
|
410
|
-
parts
|
|
411
|
-
rescue StandardError => e
|
|
412
|
-
LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
|
|
413
|
-
[]
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
def scrub_invalid_attachments!(dir)
|
|
417
|
-
attachments_dir = File.join(dir, ".fizzy-attachments")
|
|
418
|
-
return unless File.directory?(attachments_dir)
|
|
419
|
-
|
|
420
|
-
Dir.glob(File.join(attachments_dir, "*")).each do |file_path|
|
|
421
|
-
next unless File.file?(file_path)
|
|
422
|
-
|
|
423
|
-
file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path)
|
|
424
|
-
unless file_type.strip.start_with?("image/")
|
|
425
|
-
LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})"
|
|
426
|
-
FileUtils.rm_f(file_path)
|
|
427
|
-
end
|
|
428
|
-
end
|
|
429
|
-
rescue StandardError => e
|
|
430
|
-
LOG.error "Error scrubbing attachments in #{dir}: #{e.message}"
|
|
431
|
-
end
|
|
432
169
|
|
|
433
170
|
# Extract the last N meaningful lines from an agent log for crash reporting.
|
|
434
171
|
def extract_crash_snippet(log_file, max_lines: 20)
|
|
@@ -442,33 +179,23 @@ rescue StandardError => e
|
|
|
442
179
|
end
|
|
443
180
|
|
|
444
181
|
# Notify the originating channel that an agent crashed.
|
|
445
|
-
# source: :
|
|
182
|
+
# source: :github, :discord, or plugin-registered sources
|
|
446
183
|
# source_context: hash with channel-specific info needed to post the notification
|
|
447
184
|
def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
|
|
448
185
|
agent_display = agent_name || "Agent"
|
|
449
186
|
snippet = extract_crash_snippet(log_file)
|
|
450
187
|
snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
|
|
451
188
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if snippet
|
|
461
|
-
escaped = snippet[-1500..].gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
462
|
-
body += "<pre>#{escaped}</pre>"
|
|
463
|
-
end
|
|
464
|
-
begin
|
|
465
|
-
run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
|
|
466
|
-
chdir: repo_path, env: fizzy_env_for(agent_display))
|
|
467
|
-
LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}"
|
|
468
|
-
rescue StandardError => e
|
|
469
|
-
LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.message}"
|
|
470
|
-
end
|
|
189
|
+
# Try plugin-registered crash handlers first
|
|
190
|
+
handled = Brainiac.emit(:agent_crashed,
|
|
191
|
+
exit_status: exit_status, log_file: log_file, agent_name: agent_display,
|
|
192
|
+
source: source, source_context: source_context, project_config: project_config,
|
|
193
|
+
snippet: snippet)
|
|
194
|
+
|
|
195
|
+
# If a plugin handled it for this source, we're done
|
|
196
|
+
return if handled.any?(source)
|
|
471
197
|
|
|
198
|
+
case source
|
|
472
199
|
when :github
|
|
473
200
|
pr_number = source_context[:pr_number]
|
|
474
201
|
repo_name = source_context[:repo_name]
|
|
@@ -497,67 +224,6 @@ rescue StandardError => e
|
|
|
497
224
|
LOG.error "[CrashNotify] Unexpected error: #{e.message}"
|
|
498
225
|
end
|
|
499
226
|
|
|
500
|
-
# Append an italic PR/branch footer to the agent's most recent Fizzy comment.
|
|
501
|
-
def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
|
|
502
|
-
repo_path = project_config["repo_path"]
|
|
503
|
-
project_config["github_repo"]
|
|
504
|
-
env = fizzy_env_for(agent_name)
|
|
505
|
-
|
|
506
|
-
# Find branch and tracked PRs from card_map
|
|
507
|
-
card_map = load_card_map
|
|
508
|
-
card_info = card_map.values.find { |v| v["number"] == card_number }
|
|
509
|
-
branch = card_info&.dig("branch")
|
|
510
|
-
return unless branch
|
|
511
|
-
|
|
512
|
-
prs = card_info&.dig("prs") || []
|
|
513
|
-
|
|
514
|
-
# Build footer parts
|
|
515
|
-
parts = []
|
|
516
|
-
parts << "Branch: <code>#{branch}</code>"
|
|
517
|
-
prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" }
|
|
518
|
-
return if parts.empty?
|
|
519
|
-
|
|
520
|
-
footer_html = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>"
|
|
521
|
-
|
|
522
|
-
# Find agent's most recent comment
|
|
523
|
-
begin
|
|
524
|
-
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
525
|
-
comments = (JSON.parse(output)["data"] || []).reverse
|
|
526
|
-
agent_display = fizzy_display_name(agent_name)
|
|
527
|
-
comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") }
|
|
528
|
-
return unless comment
|
|
529
|
-
|
|
530
|
-
existing_html = comment.dig("body", "html") || ""
|
|
531
|
-
# Don't double-append if footer already present
|
|
532
|
-
return if existing_html.include?("Branch: <code>#{branch}</code>")
|
|
533
|
-
|
|
534
|
-
# Strip Fizzy's outer wrapper — it re-wraps on update
|
|
535
|
-
inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "")
|
|
536
|
-
updated_html = "#{inner}\n#{footer_html}"
|
|
537
|
-
run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s,
|
|
538
|
-
"--body", updated_html, chdir: repo_path, env: env)
|
|
539
|
-
LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}"
|
|
540
|
-
rescue StandardError => e
|
|
541
|
-
LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.message}"
|
|
542
|
-
end
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
|
|
546
|
-
return unless card_number
|
|
547
|
-
|
|
548
|
-
board_key = board_key_for_project(project_config)
|
|
549
|
-
column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name]
|
|
550
|
-
return unless column_id
|
|
551
|
-
|
|
552
|
-
repo_path = project_config["repo_path"]
|
|
553
|
-
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
554
|
-
run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
|
|
555
|
-
record_self_move(card_number)
|
|
556
|
-
LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})"
|
|
557
|
-
rescue StandardError => e
|
|
558
|
-
LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.message}"
|
|
559
|
-
end
|
|
560
|
-
|
|
561
227
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
562
228
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false)
|
|
563
229
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
@@ -570,8 +236,8 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
570
236
|
# that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
|
|
571
237
|
should_resume = resume && resolved["resume_flag"]
|
|
572
238
|
|
|
573
|
-
|
|
574
|
-
|
|
239
|
+
# Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
|
|
240
|
+
Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
|
|
575
241
|
|
|
576
242
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
577
243
|
log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
|
|
@@ -616,18 +282,6 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
616
282
|
[pid, log_file]
|
|
617
283
|
end
|
|
618
284
|
|
|
619
|
-
# Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
|
|
620
|
-
def ensure_fizzy_yaml!(chdir, project_config)
|
|
621
|
-
fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
|
|
622
|
-
return if File.exist?(fizzy_yaml_dest)
|
|
623
|
-
|
|
624
|
-
fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
|
|
625
|
-
return unless File.exist?(fizzy_yaml_src)
|
|
626
|
-
|
|
627
|
-
FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
|
|
628
|
-
LOG.info "Copied .fizzy.yaml to #{chdir}"
|
|
629
|
-
end
|
|
630
|
-
|
|
631
285
|
# Write agent prompt to a temp file, return path.
|
|
632
286
|
def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
633
287
|
prompt_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
@@ -676,10 +330,18 @@ def handle_agent_completion(**ctx)
|
|
|
676
330
|
)
|
|
677
331
|
end
|
|
678
332
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
333
|
+
# Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
|
|
334
|
+
Brainiac.emit(:agent_completed,
|
|
335
|
+
card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
|
|
336
|
+
exit_status: agent_exit_status,
|
|
337
|
+
signaled: agent_signaled,
|
|
338
|
+
agent_name: ctx[:agent_name],
|
|
339
|
+
chdir: ctx[:chdir],
|
|
340
|
+
source: ctx[:source],
|
|
341
|
+
source_context: ctx[:source_context],
|
|
342
|
+
project_config: ctx[:project_config],
|
|
343
|
+
skip_column_move: ctx[:skip_column_move],
|
|
344
|
+
prompt_file: ctx[:prompt_file])
|
|
683
345
|
|
|
684
346
|
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
685
347
|
if qmd_status.success?
|
|
@@ -699,64 +361,6 @@ def handle_agent_completion(**ctx)
|
|
|
699
361
|
check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
700
362
|
end
|
|
701
363
|
|
|
702
|
-
def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move)
|
|
703
|
-
return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
|
|
704
|
-
|
|
705
|
-
unless skip_column_move || card_merged?(fizzy_card)
|
|
706
|
-
move_card_to_column(fizzy_card, "needs_review", project_config: project_config, agent_name: agent_name)
|
|
707
|
-
end
|
|
708
|
-
|
|
709
|
-
append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
|
|
710
|
-
|
|
711
|
-
return unless source_context[:deploy_intent]
|
|
712
|
-
|
|
713
|
-
auto_deploy_after_session(
|
|
714
|
-
deploy_intent: source_context[:deploy_intent],
|
|
715
|
-
card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first,
|
|
716
|
-
card_number: fizzy_card,
|
|
717
|
-
worktree_path: chdir,
|
|
718
|
-
agent_name: agent_name
|
|
719
|
-
)
|
|
720
|
-
end
|
|
721
|
-
|
|
722
|
-
def handle_plan_finalization(prompt_file, agent_name, project_config)
|
|
723
|
-
return unless File.exist?(prompt_file)
|
|
724
|
-
|
|
725
|
-
prompt_content = File.read(prompt_file)
|
|
726
|
-
card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/)
|
|
727
|
-
return unless card_id_match
|
|
728
|
-
|
|
729
|
-
card_id = card_id_match[1]
|
|
730
|
-
plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
|
|
731
|
-
return unless File.exist?(plan_file)
|
|
732
|
-
|
|
733
|
-
LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..."
|
|
734
|
-
card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil
|
|
735
|
-
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
736
|
-
|
|
737
|
-
result = finalize_plan(
|
|
738
|
-
card_id: card_id, card_number: card_num,
|
|
739
|
-
agent_name: agent_name || AI_AGENT_NAME,
|
|
740
|
-
project_key: project_key, repo_path: project_config["repo_path"]
|
|
741
|
-
)
|
|
742
|
-
|
|
743
|
-
if result[:success]
|
|
744
|
-
LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created"
|
|
745
|
-
else
|
|
746
|
-
LOG.error "[Planning] Failed to finalize plan: #{result[:error]}"
|
|
747
|
-
end
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
# Capture git HEAD and working tree status for a directory.
|
|
751
|
-
# Returns [head_sha, status_porcelain] or [nil, nil] on failure.
|
|
752
|
-
def capture_git_state(chdir)
|
|
753
|
-
head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
|
|
754
|
-
status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
755
|
-
[head.strip, status.strip]
|
|
756
|
-
rescue StandardError
|
|
757
|
-
[nil, nil]
|
|
758
|
-
end
|
|
759
|
-
|
|
760
364
|
def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
|
|
761
365
|
return unless project_key_for_restart == "brainiac" && head_before
|
|
762
366
|
|
|
@@ -768,18 +372,6 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
|
|
|
768
372
|
end
|
|
769
373
|
end
|
|
770
374
|
|
|
771
|
-
def authorized?(payload)
|
|
772
|
-
creator_id = payload.dig("creator", "id")
|
|
773
|
-
AUTHORIZED_USER_IDS.include?(creator_id)
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
def human_mentioned?(user_id)
|
|
777
|
-
return false unless FIZZY_CONFIG["authorized_users"]
|
|
778
|
-
|
|
779
|
-
user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id }
|
|
780
|
-
user && user["human"]
|
|
781
|
-
end
|
|
782
|
-
|
|
783
375
|
def detect_model(project_config, tags: [], text: "")
|
|
784
376
|
resolved = resolve_project_cli_config(project_config)
|
|
785
377
|
allowed_models = resolved["allowed_models"] || {}
|
|
@@ -798,7 +390,7 @@ def detect_model(project_config, tags: [], text: "")
|
|
|
798
390
|
resolved["agent_model"]
|
|
799
391
|
end
|
|
800
392
|
|
|
801
|
-
# Detect effort level from inline tags [effort:high] or
|
|
393
|
+
# Detect effort level from inline tags [effort:high] or card tags (effort-high).
|
|
802
394
|
# Returns the effort level string (e.g. "high") or nil.
|
|
803
395
|
# If the requested level isn't supported by the current model, returns the closest
|
|
804
396
|
# lower level from allowed_efforts.
|
|
@@ -806,20 +398,16 @@ def detect_effort(project_config, tags: [], text: "")
|
|
|
806
398
|
resolved = resolve_project_cli_config(project_config)
|
|
807
399
|
allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
|
|
808
400
|
|
|
809
|
-
# Inline tag: [effort:high]
|
|
401
|
+
# Inline tag: [effort:high] — works in any channel
|
|
810
402
|
if (match = text.match(/\[effort:(\w+)\]/i))
|
|
811
403
|
level = match[1].downcase
|
|
812
404
|
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
813
405
|
end
|
|
814
406
|
|
|
815
|
-
#
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
level = name.sub("effort-", "")
|
|
820
|
-
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
821
|
-
end
|
|
822
|
-
end
|
|
407
|
+
# Plugin hook: let plugins detect from their own metadata (e.g., card tags)
|
|
408
|
+
results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
|
|
409
|
+
plugin_result = results.compact.first
|
|
410
|
+
return resolve_effort_level(plugin_result, allowed) if plugin_result
|
|
823
411
|
|
|
824
412
|
resolved["agent_effort"]
|
|
825
413
|
end
|