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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -0,0 +1,190 @@
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
+ # Clean up all worktrees associated with a work item (primary + cross-agent review).
160
+ # Safe: skips worktrees with uncommitted changes.
161
+ def cleanup_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
162
+ return unless card_number
163
+
164
+ repo_dir = File.dirname(repo_path)
165
+ repo_base = File.basename(repo_path)
166
+ cleaned = 0
167
+
168
+ # Find worktrees that contain the work item number in their name (any naming convention)
169
+ candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{card_number}*")).select { |d| File.directory?(d) }
170
+ candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
171
+
172
+ candidates.uniq.each do |wt_path|
173
+ status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
174
+ if status_output.strip.empty?
175
+ branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
176
+ begin
177
+ run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
178
+ run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
179
+ cleaned += 1
180
+ LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
181
+ rescue StandardError => e
182
+ LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
183
+ end
184
+ else
185
+ LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
186
+ end
187
+ end
188
+
189
+ LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
190
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared inline tag parsing for handler messages.
4
+ #
5
+ # Messages from any channel 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
@@ -187,7 +195,7 @@ ZOHO_TRIAGE_DIR = File.join(BRAINIAC_DIR, "tmp", "zoho", "triage")
187
195
  FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
188
196
 
189
197
  ZOHO_TRIAGE_PROMPT = <<~PROMPT
190
- You are triaging a support email. Decide whether this email needs a Fizzy card or not.
198
+ You are triaging a support email. Decide whether this email needs a card or not.
191
199
 
192
200
  ## Email
193
201
  **From:** {{FROM}}
@@ -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
 
@@ -248,41 +256,22 @@ ZOHO_TRIAGE_PROMPT = <<~PROMPT
248
256
  PROMPT
249
257
 
250
258
  # Dispatch an agent to triage a support email. The agent decides whether to create
251
- # a Fizzy card or just notify Discord.
259
+ # a card or just notify Discord.
252
260
  def dispatch_zoho_triage(email, rule)
253
261
  agent_name = rule["dispatch_agent"]
254
262
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
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
@@ -384,7 +390,7 @@ def execute_zoho_triage_decision(decision, email, rule)
384
390
  end
385
391
  end
386
392
 
387
- # Create a Fizzy card from the triage decision
393
+ # Create a card from the triage decision
388
394
  def create_zoho_triage_card(decision, email, channel_id, token)
389
395
  board_id = ZOHO_CONFIG["triage_board_id"]
390
396
  unless board_id
@@ -398,90 +404,34 @@ def create_zoho_triage_card(decision, email, channel_id, token)
398
404
  tags = ["support"]
399
405
  tags << decision["project_tag"] if decision["project_tag"]
400
406
 
401
- # Resolve tag IDs
402
- tag_ids = resolve_zoho_triage_tags(tags)
403
-
404
- # Create the card
405
- cmd = ["fizzy", "card", "create", "--board", board_id, "--title", title, "--description", description]
406
- cmd.push("--tag-ids", tag_ids.join(",")) unless tag_ids.empty?
407
-
408
- # Use the triage agent's env for fizzy token
409
- agent_name = "Threepio"
410
- agent_env = agent_env_for(agent_name)
411
- spawn_env = agent_env.empty? ? {} : agent_env
412
-
413
- output, status = Open3.capture2e(spawn_env, *cmd)
414
- unless status.success?
415
- LOG.error "[Zoho:Triage] Failed to create card: #{output}"
416
- notify_zoho_match(email, { "label" => "Support Email (card creation failed)", "emoji" => "❌" }.merge(rule_defaults(nil)))
417
- return
418
- end
419
-
420
- # Parse card number from response
421
- card_data = JSON.parse(output)
422
- card_number = card_data.dig("data", "number")
423
- card_url = card_data.dig("data", "url")
424
- LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
425
-
426
- # Assign to the appropriate agent
427
- assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"]
428
-
429
- # Notify Discord
430
- if channel_id && token
431
- msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
432
- msg += "**Title:** #{title}\n"
433
- msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
434
- msg += "**Tags:** #{tags.join(", ")}\n"
435
- msg += "**From:** #{email["fromAddress"]}"
436
- send_discord_message(channel_id, msg, token: token)
407
+ # Emit hook — work item plugin handles card creation (card management plugins)
408
+ results = Brainiac.emit(:create_work_item,
409
+ board_id: board_id,
410
+ title: title,
411
+ description: description,
412
+ tags: tags,
413
+ assign_to: decision["assign_to"])
414
+
415
+ card_info = results.compact.first
416
+ if card_info
417
+ card_number = card_info[:number]
418
+ card_url = card_info[:url]
419
+ LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
420
+
421
+ # Notify Discord
422
+ if channel_id && token
423
+ msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
424
+ msg += "**Title:** #{title}\n"
425
+ msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
426
+ msg += "**Tags:** #{tags.join(", ")}\n"
427
+ msg += "**From:** #{email["fromAddress"]}"
428
+ send_discord_message(channel_id, msg, token: token)
429
+ end
430
+ else
431
+ LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
432
+ notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
437
433
  end
438
434
  rescue StandardError => e
439
435
  LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
440
436
  notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
441
437
  end
442
-
443
- # Resolve tag names to IDs by querying Fizzy
444
- def resolve_zoho_triage_tags(tag_names)
445
- agent_env = agent_env_for("Threepio")
446
- spawn_env = agent_env.empty? ? {} : agent_env
447
-
448
- output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all")
449
- return [] unless status.success?
450
-
451
- all_tags = JSON.parse(output)["data"] || []
452
- tag_names.filter_map do |name|
453
- tag = all_tags.find { |t| t["title"].downcase == name.downcase }
454
- tag&.dig("id")
455
- end
456
- rescue StandardError => e
457
- LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
458
- []
459
- end
460
-
461
- # Assign a card to the appropriate agent
462
- 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
-
470
- user_id = agent_user_ids[agent_name]
471
- unless user_id
472
- LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name}"
473
- return
474
- end
475
-
476
- output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id)
477
- if status.success?
478
- LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}"
479
- else
480
- LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}"
481
- end
482
- end
483
-
484
- def rule_defaults(rule)
485
- { "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"],
486
- "notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] }
487
- end