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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +136 -6
- data/bin/brainiac +385 -19
- data/bin/brainiac-completion.bash +1 -1
- data/lib/brainiac/agents.rb +15 -38
- data/lib/brainiac/config.rb +40 -11
- 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/fizzy/assignment.rb +125 -0
- data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
- data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
- data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
- data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
- data/lib/brainiac/handlers/fizzy.rb +12 -1290
- data/lib/brainiac/handlers/github.rb +149 -212
- data/lib/brainiac/handlers/shared/git.rb +203 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
- data/lib/brainiac/handlers/zoho.rb +79 -76
- data/lib/brainiac/helpers.rb +2 -121
- data/lib/brainiac/planning.rb +2 -2
- data/lib/brainiac/plugins.rb +129 -0
- data/lib/brainiac/restart.rb +94 -0
- data/lib/brainiac/routes/api.rb +427 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac/zoho_mail_api.rb +2 -1
- data/lib/brainiac.rb +7 -0
- data/monitor/daemon.rb +4 -27
- data/monitor/shared.rb +247 -0
- data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
- 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 +155 -0
- data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
- data/receiver.rb +91 -450
- data/templates/brainiac.json.example +9 -0
- data/templates/cli-providers/kiro.json.example +8 -2
- data/templates/plugins.json.example +3 -0
- metadata +30 -20
- 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/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
- /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
- /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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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(/ /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
|
-
|
|
139
|
-
|
|
140
|
-
if body_text.empty?
|
|
141
|
-
raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
|
|
142
|
-
body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/ /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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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)
|
|
@@ -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,
|
|
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
|
|