brainiac 0.0.5 → 0.0.7

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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +385 -19
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +15 -38
  7. data/lib/brainiac/config.rb +40 -11
  8. data/lib/brainiac/handlers/discord/api.rb +196 -0
  9. data/lib/brainiac/handlers/discord/config.rb +134 -0
  10. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  11. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  12. data/lib/brainiac/handlers/discord/message.rb +933 -0
  13. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  14. data/lib/brainiac/handlers/discord.rb +14 -1892
  15. data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
  16. data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
  17. data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
  18. data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
  19. data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
  20. data/lib/brainiac/handlers/fizzy.rb +12 -1290
  21. data/lib/brainiac/handlers/github.rb +149 -212
  22. data/lib/brainiac/handlers/shared/git.rb +203 -0
  23. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  24. data/lib/brainiac/handlers/zoho.rb +79 -76
  25. data/lib/brainiac/helpers.rb +2 -121
  26. data/lib/brainiac/planning.rb +2 -2
  27. data/lib/brainiac/plugins.rb +129 -0
  28. data/lib/brainiac/restart.rb +94 -0
  29. data/lib/brainiac/routes/api.rb +427 -0
  30. data/lib/brainiac/version.rb +1 -1
  31. data/lib/brainiac/zoho_mail_api.rb +2 -1
  32. data/lib/brainiac.rb +7 -0
  33. data/monitor/daemon.rb +4 -27
  34. data/monitor/shared.rb +247 -0
  35. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
  36. data/monitor/waybar/setup.rb +232 -0
  37. data/monitor/waybar/status.rb +51 -0
  38. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  39. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  40. data/monitor/xbar/plugin.rb +155 -0
  41. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  42. data/receiver.rb +91 -450
  43. data/templates/brainiac.json.example +9 -0
  44. data/templates/cli-providers/kiro.json.example +8 -2
  45. data/templates/plugins.json.example +3 -0
  46. metadata +30 -20
  47. data/lib/user_registry.rb +0 -159
  48. data/monitor/menubar.rb +0 -295
  49. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  50. data/monitor/setup-waybar-deployments.rb +0 -96
  51. data/monitor/setup-waybar-module.rb +0 -113
  52. data/monitor/setup-xbar-plugin.rb +0 -35
  53. data/monitor/view-logs.rb +0 -119
  54. data/monitor/waybar-config-updater.rb +0 -56
  55. data/monitor/waybar-deployments.rb +0 -239
  56. data/monitor/waybar.rb +0 -146
  57. data/monitor/xbar.3s.rb +0 -179
  58. /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
  59. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  60. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared git operations: worktree lifecycle, branching, fetching, cleanup.
4
+ #
5
+ # All git-related utilities live here. Handlers call these instead of
6
+ # reimplementing git worktree/branch logic.
7
+
8
+ # Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.
9
+ REPO_LAST_FETCH = {}
10
+ REPO_FETCH_DEBOUNCE = 300 # 5 minutes
11
+
12
+ def debounced_repo_fetch(repo_path)
13
+ last = REPO_LAST_FETCH[repo_path]
14
+ if last && (Time.now - last) < REPO_FETCH_DEBOUNCE
15
+ LOG.info "Skipping git fetch for #{repo_path} — fetched #{(Time.now - last).to_i}s ago"
16
+ return
17
+ end
18
+
19
+ run_cmd("git", "fetch", "origin", chdir: repo_path)
20
+ REPO_LAST_FETCH[repo_path] = Time.now
21
+ end
22
+
23
+ def get_default_branch(repo_path)
24
+ default_branch = run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: repo_path).strip
25
+ begin
26
+ run_cmd("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: repo_path).strip.sub("origin/", "")
27
+ rescue StandardError
28
+ default_branch
29
+ end
30
+ end
31
+
32
+ # Capture git HEAD and working tree status for a directory.
33
+ def capture_git_state(chdir)
34
+ head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
35
+ status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
36
+ [head.strip, status.strip]
37
+ rescue StandardError
38
+ [nil, nil]
39
+ end
40
+
41
+ # Trust the version manager config in a directory (supports mise and asdf)
42
+ def trust_version_manager(path, chdir:)
43
+ if system("which mise >/dev/null 2>&1")
44
+ run_cmd("mise", "trust", path, chdir: chdir)
45
+ elsif system("which asdf >/dev/null 2>&1")
46
+ LOG.info "asdf detected — no explicit trust needed for #{path}"
47
+ else
48
+ LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}"
49
+ end
50
+ rescue StandardError => e
51
+ LOG.warn "Could not trust version manager in #{path}: #{e.message}"
52
+ end
53
+
54
+ # Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
55
+ # Symlink directories matching .worktreelink patterns instead of copying.
56
+ def apply_worktree_includes(repo_path, worktree_path)
57
+ copied = 0
58
+ linked = 0
59
+
60
+ [".worktreeinclude", ".worktreelink"].each do |filename|
61
+ config_file = File.join(repo_path, filename)
62
+ next unless File.exist?(config_file)
63
+
64
+ symlink_mode = filename == ".worktreelink"
65
+ patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
66
+ next if patterns.empty?
67
+
68
+ patterns.each do |pattern|
69
+ Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match|
70
+ src = File.join(repo_path, match)
71
+ dest = File.join(worktree_path, match)
72
+ next if File.exist?(dest) || File.symlink?(dest)
73
+
74
+ _, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path)
75
+ next unless st.success?
76
+
77
+ FileUtils.mkdir_p(File.dirname(dest))
78
+
79
+ if symlink_mode && File.directory?(src)
80
+ FileUtils.ln_s(src, dest)
81
+ linked += 1
82
+ LOG.info "Symlinked #{match} from main repo"
83
+ elsif File.file?(src)
84
+ FileUtils.cp(src, dest)
85
+ copied += 1
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive?
92
+ end
93
+
94
+ # Run a project-level hook script from .brainiac/<hook_name> if it exists.
95
+ def run_project_hook(repo_path, hook_name, extra_env: {})
96
+ hook = File.join(repo_path, ".brainiac", hook_name)
97
+ return unless File.exist?(hook)
98
+
99
+ env = { "REPO_PATH" => repo_path }.merge(extra_env)
100
+ LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}"
101
+ output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
102
+ if status.success?
103
+ LOG.info ".brainiac/#{hook_name} completed successfully"
104
+ else
105
+ LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
106
+ end
107
+ end
108
+
109
+ # Create or reuse a git worktree for a given branch.
110
+ # Returns the worktree path on success.
111
+ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil)
112
+ worktree_path ||= File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
113
+ base_ref ||= "origin/#{get_default_branch(repo_path)}"
114
+
115
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
116
+
117
+ if File.directory?(worktree_path)
118
+ is_tracked = worktree_list.include?(worktree_path)
119
+
120
+ if is_tracked
121
+ LOG.info "Worktree directory #{worktree_path} is tracked by git"
122
+ else
123
+ LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
124
+ begin
125
+ FileUtils.rm_rf(worktree_path)
126
+ LOG.info "Successfully removed orphaned directory"
127
+ rescue StandardError => e
128
+ LOG.error "Failed to remove orphaned directory: #{e.message}"
129
+ raise
130
+ end
131
+ end
132
+ end
133
+
134
+ branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
135
+
136
+ if branch_exists
137
+ LOG.info "Branch #{branch} already exists, checking for existing worktree"
138
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
139
+ has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
140
+
141
+ if has_worktree && File.directory?(worktree_path)
142
+ LOG.info "Reusing existing worktree at #{worktree_path}"
143
+ else
144
+ LOG.info "Creating worktree from existing branch #{branch}"
145
+ run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
146
+ end
147
+ else
148
+ LOG.info "Creating new branch #{branch} and worktree"
149
+ run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
150
+ end
151
+
152
+ trust_version_manager(worktree_path, chdir: worktree_path)
153
+ apply_worktree_includes(repo_path, worktree_path)
154
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
155
+
156
+ worktree_path
157
+ end
158
+
159
+ # Find an existing worktree for a card by scanning the filesystem.
160
+ def find_worktree_for_card(card_number, repo_path:)
161
+ return nil unless card_number
162
+
163
+ repo_dir = File.dirname(repo_path)
164
+ repo_base = File.basename(repo_path)
165
+ candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
166
+ return nil if candidates.empty?
167
+
168
+ worktree = candidates.first
169
+ branch = File.basename(worktree).sub("#{repo_base}--", "")
170
+ { worktree: worktree, branch: branch }
171
+ end
172
+
173
+ # Clean up all worktrees associated with a card (primary + cross-agent review).
174
+ # Safe: skips worktrees with uncommitted changes.
175
+ def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
176
+ return unless card_number
177
+
178
+ repo_dir = File.dirname(repo_path)
179
+ repo_base = File.basename(repo_path)
180
+ cleaned = 0
181
+
182
+ candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
183
+ candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
184
+
185
+ candidates.uniq.each do |wt_path|
186
+ status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
187
+ if status_output.strip.empty?
188
+ branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
189
+ begin
190
+ run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
191
+ run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
192
+ cleaned += 1
193
+ LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
194
+ rescue StandardError => e
195
+ LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
196
+ end
197
+ else
198
+ LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
199
+ end
200
+ end
201
+
202
+ LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
203
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared inline tag parsing for handler messages.
4
+ #
5
+ # Both Discord and Fizzy messages can contain inline tags like:
6
+ # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
7
+ #
8
+ # This module provides a single parser that extracts all tags and returns
9
+ # a structured result with the cleaned text.
10
+
11
+ # Parse inline tags from message text.
12
+ # Returns a hash:
13
+ # {
14
+ # project: "my-project" or nil,
15
+ # model_tag: "opus" or nil (raw tag, not resolved model ID),
16
+ # effort: "high" or nil,
17
+ # cli_provider: "grok" or nil,
18
+ # chat_mode: true/false,
19
+ # planning: true/false,
20
+ # deploy_intent: "dev01" / :auto / nil,
21
+ # worktree_override: "branch-name" or nil,
22
+ # clean_text: "the message with all tags stripped"
23
+ # }
24
+ def parse_inline_tags(text)
25
+ result = {
26
+ project: nil,
27
+ model_tag: nil,
28
+ effort: nil,
29
+ cli_provider: nil,
30
+ chat_mode: false,
31
+ planning: false,
32
+ deploy_intent: nil,
33
+ worktree_override: nil,
34
+ clean_text: text.dup
35
+ }
36
+
37
+ # [project:my-project]
38
+ if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
39
+ result[:project] = match[1]
40
+ result[:clean_text].sub!(match[0], "")
41
+ end
42
+
43
+ # [effort:high]
44
+ if (match = result[:clean_text].match(/\[effort:(\w+)\]/i))
45
+ result[:effort] = match[1].downcase
46
+ result[:clean_text].sub!(match[0], "")
47
+ end
48
+
49
+ # [cli:grok]
50
+ if (match = result[:clean_text].match(/\[cli:(\w+)\]/i))
51
+ result[:cli_provider] = match[1].downcase
52
+ result[:clean_text].sub!(match[0], "")
53
+ end
54
+
55
+ # [chat], [question], [?]
56
+ if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
57
+ result[:chat_mode] = true
58
+ result[:clean_text].sub!(/\[(chat|question|\?)\]/i, "")
59
+ end
60
+
61
+ # [plan]
62
+ if result[:clean_text].match?(/\[plan\]/i)
63
+ result[:planning] = true
64
+ result[:clean_text].sub!(/\[plan\]/i, "")
65
+ end
66
+
67
+ # [deploy] or [deploy:dev01]
68
+ if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
69
+ result[:deploy_intent] = match[1]&.strip&.downcase || :auto
70
+ result[:clean_text].sub!(match[0], "")
71
+ end
72
+
73
+ # [worktree:branch-name]
74
+ if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
75
+ result[:worktree_override] = match[1].strip
76
+ result[:clean_text].sub!(match[0], "")
77
+ end
78
+
79
+ # Model tag: any remaining [word] that isn't a known tag — detected separately
80
+ # because it depends on the project's allowed_models config. We just capture
81
+ # the raw match here for the caller to resolve.
82
+ if (match = result[:clean_text].match(/\[(\w+)\]/))
83
+ result[:model_tag] = match[1].downcase
84
+ result[:clean_text].sub!(match[0], "")
85
+ end
86
+
87
+ result[:clean_text].strip!
88
+ result
89
+ end
@@ -86,28 +86,34 @@ end
86
86
 
87
87
  # Match an email against configured rules. Returns the first matching rule or nil.
88
88
  # If no rules match, returns a fallback rule (if configured) so nothing is missed.
89
- def match_zoho_rule(email)
90
- rules = ZOHO_CONFIG["rules"] || []
91
- rules.each do |rule|
92
- next if rule["enabled"] == false
93
-
94
- matches = true
95
- if rule["from_contains"] && !rule["from_contains"].empty? && !email["fromAddress"].to_s.downcase.include?(rule["from_contains"].downcase)
96
- matches = false
97
- end
98
- matches = false if rule["to_contains"] && !rule["to_contains"].empty? && !email["toAddress"].to_s.downcase.include?(rule["to_contains"].downcase)
99
- if rule["subject_contains"] && !rule["subject_contains"].empty? && !email["subject"].to_s.downcase.include?(rule["subject_contains"].downcase)
100
- matches = false
101
- end
102
- if rule["body_contains"] && !rule["body_contains"].empty?
103
- body = email["summary"].to_s + email["html"].to_s
104
- matches = false unless body.downcase.include?(rule["body_contains"].downcase)
105
- end
106
- matches = false if matches && zoho_email_excluded?(email, rule["exclude_words"])
89
+ def zoho_rule_matches?(rule, email)
90
+ return false if rule["enabled"] == false
91
+
92
+ %w[from_contains to_contains subject_contains].each do |field|
93
+ pattern = rule[field]
94
+ next if pattern.nil? || pattern.empty?
95
+
96
+ email_field = case field
97
+ when "from_contains" then email["fromAddress"]
98
+ when "to_contains" then email["toAddress"]
99
+ when "subject_contains" then email["subject"]
100
+ end
101
+ return false unless email_field.to_s.downcase.include?(pattern.downcase)
102
+ end
107
103
 
108
- return rule if matches
104
+ if rule["body_contains"] && !rule["body_contains"].empty?
105
+ body = "#{email["summary"]}#{email["html"]}"
106
+ return false unless body.downcase.include?(rule["body_contains"].downcase)
109
107
  end
110
108
 
109
+ !zoho_email_excluded?(email, rule["exclude_words"])
110
+ end
111
+
112
+ def match_zoho_rule(email)
113
+ rules = ZOHO_CONFIG["rules"] || []
114
+ matched = rules.find { |rule| zoho_rule_matches?(rule, email) }
115
+ return matched if matched
116
+
111
117
  # Fallback: post unmatched emails so nothing slips through
112
118
  fallback = zoho_fallback_rule
113
119
  return nil if fallback && zoho_email_excluded?(email, ZOHO_CONFIG.dig("fallback", "exclude_words"))
@@ -126,6 +132,23 @@ def zoho_fallback_rule
126
132
  "notify_as" => fallback["notify_as"] }
127
133
  end
128
134
 
135
+ # Extract body text from email payload for notification display.
136
+ def extract_zoho_body_text(email, rule)
137
+ body_text = email["summary"].to_s.strip
138
+ if body_text.empty?
139
+ raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
140
+ body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/\s+/, " ").strip
141
+ end
142
+
143
+ body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
144
+
145
+ return nil if body_text.empty?
146
+
147
+ max_len = rule["show_body"] ? 1800 : 500
148
+ body_text = "#{body_text[0..max_len]}..." if body_text.length > max_len
149
+ body_text
150
+ end
151
+
129
152
  # Format a Discord notification for a matched email.
130
153
  def format_zoho_notification(email, rule)
131
154
  label = rule["label"] || "Zoho Mail"
@@ -135,23 +158,8 @@ def format_zoho_notification(email, rule)
135
158
  parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"]
136
159
  parts << "**To:** #{email["toAddress"]}" if email["toAddress"]
137
160
 
138
- # Include body content — try webhook payload first, then fetch via API
139
- body_text = email["summary"].to_s.strip
140
- if body_text.empty?
141
- raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
142
- body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/\s+/, " ").strip
143
- end
144
-
145
- # If still empty and show_body requested, fetch via Zoho Mail API
146
- body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
147
-
148
- if !body_text.empty? && rule["show_body"]
149
- body_text = "#{body_text[0..1800]}..." if body_text.length > 1800
150
- parts << "```\n#{body_text}\n```"
151
- elsif !body_text.empty?
152
- body_text = "#{body_text[0..500]}..." if body_text.length > 500
153
- parts << "```\n#{body_text}\n```"
154
- end
161
+ body_text = extract_zoho_body_text(email, rule)
162
+ parts << "```\n#{body_text}\n```" if body_text
155
163
 
156
164
  parts.join("\n")
157
165
  end
@@ -226,7 +234,7 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
226
234
  "title": "Brief descriptive title for the card",
227
235
  "description": "HTML description with relevant details from the email",
228
236
  "project_tag": "project-tag-name or null",
229
- "assign_to": "Galen|Avon|Sheogorath"
237
+ "assign_to": "agent-name from the assignment rules above"
230
238
  }
231
239
  ```
232
240
 
@@ -255,34 +263,15 @@ def dispatch_zoho_triage(email, rule)
255
263
  response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.json")
256
264
  log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.log")
257
265
 
258
- body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
259
- body = body[0..2000] if body.length > 2000
260
-
261
- prompt = ZOHO_TRIAGE_PROMPT
262
- .gsub("{{FROM}}", email["fromAddress"].to_s)
263
- .gsub("{{TO}}", email["toAddress"].to_s)
264
- .gsub("{{SUBJECT}}", email["subject"].to_s)
265
- .gsub("{{BODY}}", body)
266
- .gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
267
- .gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
268
-
269
- prompt += "\n\nWrite your JSON response to: #{response_file}\n"
270
-
271
- prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
272
- File.write(prompt_file, prompt)
266
+ prompt_file = write_zoho_triage_prompt(email, response_file, timestamp)
273
267
 
274
268
  agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
275
269
  project_config = default_project_config
270
+ resolved = resolve_project_cli_config(project_config || DEFAULT_PROJECT)
276
271
 
277
- resolved = project_config ? resolve_project_cli_config(project_config) : {}
278
- agent_cli = resolved["agent_cli"] || "kiro-cli"
279
- agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
280
- resolved["agent_model_flag"] || "--model"
281
-
282
- cmd = [agent_cli]
272
+ cmd = [resolved["agent_cli"]]
283
273
  cmd.push("--agent", agent_key)
284
- cmd.concat(agent_cli_args.split)
285
- add_trust_tools!(cmd, agent_cli_args)
274
+ cmd.concat(resolved["agent_cli_args"].split)
286
275
 
287
276
  spawn_env = {}
288
277
  agent_env = agent_env_for(agent_name)
@@ -294,12 +283,32 @@ def dispatch_zoho_triage(email, rule)
294
283
  LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}"
295
284
 
296
285
  pid = spawn(spawn_env, *cmd,
297
- chdir: work_dir,
298
- in: prompt_file,
299
- out: [log_file, "w"],
300
- err: %i[child out])
286
+ chdir: work_dir, in: prompt_file,
287
+ out: [log_file, "w"], err: %i[child out])
301
288
 
302
- # Monitor in background process the triage decision when agent finishes
289
+ monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
290
+ pid
291
+ end
292
+
293
+ def write_zoho_triage_prompt(email, response_file, timestamp)
294
+ body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
295
+ body = body[0..2000] if body.length > 2000
296
+
297
+ prompt = ZOHO_TRIAGE_PROMPT
298
+ .gsub("{{FROM}}", email["fromAddress"].to_s)
299
+ .gsub("{{TO}}", email["toAddress"].to_s)
300
+ .gsub("{{SUBJECT}}", email["subject"].to_s)
301
+ .gsub("{{BODY}}", body)
302
+ .gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
303
+ .gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
304
+ prompt += "\n\nWrite your JSON response to: #{response_file}\n"
305
+
306
+ prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
307
+ File.write(prompt_file, prompt)
308
+ prompt_file
309
+ end
310
+
311
+ def monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
303
312
  Thread.new do
304
313
  Process.wait(pid)
305
314
  exit_status = $CHILD_STATUS
@@ -313,14 +322,11 @@ def dispatch_zoho_triage(email, rule)
313
322
  notify_zoho_match(email, rule)
314
323
  end
315
324
 
316
- # Cleanup prompt file after a delay
317
325
  Thread.new do
318
326
  sleep 300
319
327
  FileUtils.rm_f(prompt_file)
320
328
  end
321
329
  end
322
-
323
- pid
324
330
  end
325
331
 
326
332
  # Read the triage response — try the response file first, then extract from log
@@ -460,16 +466,13 @@ end
460
466
 
461
467
  # Assign a card to the appropriate agent
462
468
  def assign_zoho_triage_card(card_number, agent_name, spawn_env)
463
- # Map agent names to Fizzy user IDs
464
- agent_user_ids = {
465
- "Galen" => "03fja52opiykf0mua7aeqv8uk",
466
- "Avon" => "03fnwe6kl4g2t8xw0djbfkv96",
467
- "Sheogorath" => "03fnwjyt6gighy98ld46u2hni"
468
- }
469
+ # Resolve agent name to Fizzy user ID from fizzy.json authorized_users
470
+ users = FIZZY_CONFIG["authorized_users"] || []
471
+ user = users.find { |u| u["name"]&.downcase == agent_name.downcase }
472
+ user_id = user&.dig("id")
469
473
 
470
- user_id = agent_user_ids[agent_name]
471
474
  unless user_id
472
- LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name}"
475
+ LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name} (not found in fizzy.json authorized_users)"
473
476
  return
474
477
  end
475
478
 
@@ -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)
@@ -126,64 +87,6 @@ def detect_cli_provider(text: "", tags: [])
126
87
  nil
127
88
  end
128
89
 
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
90
  def default_project_key
188
91
  # Find the project marked as default
189
92
  default = PROJECTS.find { |_key, config| config["default"] == true }
@@ -292,19 +195,6 @@ def run_cmd(*cmd, chdir:, env: {})
292
195
  stdout
293
196
  end
294
197
 
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
198
  # Cards that have been merged to main — skip Needs Review moves for these.
309
199
  # Keyed by card number (string), value is Time. Entries expire after 10 minutes.
310
200
  MERGED_CARDS = {}
@@ -703,7 +593,8 @@ def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chd
703
593
  return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
704
594
 
705
595
  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)
596
+ move_card_to_column(fizzy_card, "needs_review", project_config: project_config,
597
+ agent_name: agent_name)
707
598
  end
708
599
 
709
600
  append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
@@ -747,16 +638,6 @@ def handle_plan_finalization(prompt_file, agent_name, project_config)
747
638
  end
748
639
  end
749
640
 
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
641
  def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
761
642
  return unless project_key_for_restart == "brainiac" && head_before
762
643