zillacore 0.0.1
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared helpers: project identification, card map, run_cmd, run_agent, signatures, model detection.
|
|
4
|
+
|
|
5
|
+
require "English"
|
|
6
|
+
CLI_PROVIDERS_DIR = File.join(ZILLACORE_DIR, "cli-providers")
|
|
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
|
+
TRUSTED_TOOLS = "execute_bash,fs_write,fs_read,code,grep,glob,web_search,web_fetch,use_subagent,use_aws"
|
|
11
|
+
|
|
12
|
+
def add_trust_tools!(cmd, agent_cli_args)
|
|
13
|
+
return if agent_cli_args.include?("--trust-tools")
|
|
14
|
+
|
|
15
|
+
cmd.push("--trust-tools", TRUSTED_TOOLS)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Clean up all worktrees associated with a card: the primary worktree and any
|
|
19
|
+
# cross-agent review worktrees (e.g. glados-fizzy-123-*, threepio-fizzy-123-*).
|
|
20
|
+
# Safe: skips worktrees with uncommitted changes.
|
|
21
|
+
def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil)
|
|
22
|
+
return unless card_number
|
|
23
|
+
|
|
24
|
+
repo_dir = File.dirname(repo_path)
|
|
25
|
+
repo_base = File.basename(repo_path)
|
|
26
|
+
cleaned = 0
|
|
27
|
+
|
|
28
|
+
# Collect all worktree dirs for this card: primary + cross-agent review
|
|
29
|
+
candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) }
|
|
30
|
+
candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree)
|
|
31
|
+
|
|
32
|
+
candidates.uniq.each do |wt_path|
|
|
33
|
+
status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path)
|
|
34
|
+
if status_output.strip.empty?
|
|
35
|
+
branch_name = File.basename(wt_path).sub("#{repo_base}--", "")
|
|
36
|
+
begin
|
|
37
|
+
run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path)
|
|
38
|
+
run_cmd("git", "branch", "-D", branch_name, chdir: repo_path)
|
|
39
|
+
cleaned += 1
|
|
40
|
+
LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})"
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
LOG.warn "Failed to clean up worktree #{wt_path}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolve CLI config for a project by merging provider defaults with project overrides.
|
|
53
|
+
# Priority: project-level keys > provider file > DEFAULT_PROJECT
|
|
54
|
+
def resolve_project_cli_config(project_config)
|
|
55
|
+
provider_config = {}
|
|
56
|
+
if (provider_name = project_config["cli_provider"])
|
|
57
|
+
provider_file = File.join(CLI_PROVIDERS_DIR, "#{provider_name}.json")
|
|
58
|
+
if File.exist?(provider_file)
|
|
59
|
+
raw = JSON.parse(File.read(provider_file))
|
|
60
|
+
provider_config = {
|
|
61
|
+
"agent_cli" => raw["binary"],
|
|
62
|
+
"agent_cli_args" => raw["default_args"],
|
|
63
|
+
"agent_model_flag" => raw["model_flag"],
|
|
64
|
+
"allowed_models" => raw["models"]
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
DEFAULT_PROJECT.merge(provider_config).merge(project_config)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
|
|
73
|
+
# Symlink directories matching .worktreelink patterns instead of copying.
|
|
74
|
+
# Both files use .gitignore syntax. Only gitignored files/dirs are processed.
|
|
75
|
+
def apply_worktree_includes(repo_path, worktree_path)
|
|
76
|
+
copied = 0
|
|
77
|
+
linked = 0
|
|
78
|
+
|
|
79
|
+
[".worktreeinclude", ".worktreelink"].each do |filename|
|
|
80
|
+
config_file = File.join(repo_path, filename)
|
|
81
|
+
next unless File.exist?(config_file)
|
|
82
|
+
|
|
83
|
+
symlink_mode = filename == ".worktreelink"
|
|
84
|
+
patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
85
|
+
next if patterns.empty?
|
|
86
|
+
|
|
87
|
+
patterns.each do |pattern|
|
|
88
|
+
Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match|
|
|
89
|
+
src = File.join(repo_path, match)
|
|
90
|
+
dest = File.join(worktree_path, match)
|
|
91
|
+
next if File.exist?(dest) || File.symlink?(dest)
|
|
92
|
+
|
|
93
|
+
# Only process gitignored files/dirs
|
|
94
|
+
_, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path)
|
|
95
|
+
next unless st.success?
|
|
96
|
+
|
|
97
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
98
|
+
|
|
99
|
+
if symlink_mode && File.directory?(src)
|
|
100
|
+
FileUtils.ln_s(src, dest)
|
|
101
|
+
linked += 1
|
|
102
|
+
LOG.info "Symlinked #{match} from main repo"
|
|
103
|
+
elsif File.file?(src)
|
|
104
|
+
FileUtils.cp(src, dest)
|
|
105
|
+
copied += 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Run a project-level hook script from .zillacore/<hook_name> if it exists.
|
|
115
|
+
# Passes REPO_PATH (and optionally WORKTREE_PATH) as environment variables.
|
|
116
|
+
def run_project_hook(repo_path, hook_name, extra_env: {})
|
|
117
|
+
hook = File.join(repo_path, ".zillacore", hook_name)
|
|
118
|
+
return unless File.exist?(hook)
|
|
119
|
+
|
|
120
|
+
env = { "REPO_PATH" => repo_path }.merge(extra_env)
|
|
121
|
+
LOG.info "Running .zillacore/#{hook_name} hook for #{repo_path}"
|
|
122
|
+
output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
|
|
123
|
+
if status.success?
|
|
124
|
+
LOG.info ".zillacore/#{hook_name} completed successfully"
|
|
125
|
+
else
|
|
126
|
+
LOG.warn ".zillacore/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def default_project_key
|
|
131
|
+
# Find the project marked as default
|
|
132
|
+
default = PROJECTS.find { |_key, config| config["default"] == true }
|
|
133
|
+
default ? default[0] : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def identify_project_by_tags(tags)
|
|
137
|
+
return nil if PROJECTS.empty?
|
|
138
|
+
|
|
139
|
+
tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }
|
|
140
|
+
|
|
141
|
+
PROJECTS.each do |project_key, config|
|
|
142
|
+
project_tags = (config["fizzy_tags"] || []).map(&:downcase)
|
|
143
|
+
return [project_key, config] if tag_names.intersect?(project_tags)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Fall back to default project if configured
|
|
147
|
+
default_key = default_project_key
|
|
148
|
+
if default_key
|
|
149
|
+
LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'"
|
|
150
|
+
return [default_key, PROJECTS[default_key]]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def identify_project_by_repo(repo_full_name)
|
|
157
|
+
return nil if PROJECTS.empty?
|
|
158
|
+
|
|
159
|
+
PROJECTS.each do |project_key, config|
|
|
160
|
+
return [project_key, config] if config["github_repo"] == repo_full_name
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fall back to default project if configured
|
|
164
|
+
default_key = default_project_key
|
|
165
|
+
if default_key
|
|
166
|
+
LOG.info "No project matched GitHub repo '#{repo_full_name}', falling back to default project '#{default_key}'"
|
|
167
|
+
return [default_key, PROJECTS[default_key]]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resolve_card_number(internal_id, repo_path:)
|
|
174
|
+
env = default_fizzy_env
|
|
175
|
+
[nil, "--indexed-by closed"].each do |extra_flag|
|
|
176
|
+
cmd = ["fizzy", "card", "list", "--all"]
|
|
177
|
+
cmd << extra_flag if extra_flag
|
|
178
|
+
output, status = Open3.capture2(env, *cmd, chdir: repo_path)
|
|
179
|
+
next unless status.success?
|
|
180
|
+
|
|
181
|
+
data = JSON.parse(output)["data"] || []
|
|
182
|
+
match = data.find { |c| c["id"] == internal_id }
|
|
183
|
+
if match
|
|
184
|
+
LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}"
|
|
185
|
+
return match["number"]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
LOG.warn "Could not resolve card number for internal_id #{internal_id}"
|
|
190
|
+
nil
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
LOG.warn "resolve_card_number failed for #{internal_id}: #{e.message}"
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def load_card_map
|
|
197
|
+
return {} unless File.exist?(CARD_MAP_FILE)
|
|
198
|
+
|
|
199
|
+
JSON.parse(File.read(CARD_MAP_FILE))
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
{}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def save_card_map(map)
|
|
205
|
+
File.write(CARD_MAP_FILE, JSON.pretty_generate(map))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def slugify(title, max_length: 40)
|
|
209
|
+
title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def verify_signature!(request, payload_body, board_key: nil)
|
|
213
|
+
signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
|
|
214
|
+
halt 403, { error: "Missing signature" }.to_json unless signature
|
|
215
|
+
secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET
|
|
216
|
+
halt 403, { error: "No webhook secret configured" }.to_json unless secret
|
|
217
|
+
computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
|
|
218
|
+
halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def verify_github_signature!(request, payload_body)
|
|
222
|
+
signature = request.env["HTTP_X_HUB_SIGNATURE_256"]
|
|
223
|
+
halt 403, { error: "Missing GitHub signature" }.to_json unless signature
|
|
224
|
+
secret = github_webhook_secret
|
|
225
|
+
halt 500, { error: "GitHub webhook secret not configured" }.to_json unless secret
|
|
226
|
+
computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}"
|
|
227
|
+
halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def run_cmd(*cmd, chdir:, env: {})
|
|
231
|
+
LOG.info "Running: #{cmd.join(" ")} (in #{chdir})"
|
|
232
|
+
stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir)
|
|
233
|
+
raise "Command failed (#{cmd.first}): #{stderr}" unless status.success?
|
|
234
|
+
|
|
235
|
+
stdout
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Trust the version manager config in a directory (supports mise and asdf)
|
|
239
|
+
def trust_version_manager(path, chdir:)
|
|
240
|
+
if system("which mise >/dev/null 2>&1")
|
|
241
|
+
run_cmd("mise", "trust", path, chdir: chdir)
|
|
242
|
+
elsif system("which asdf >/dev/null 2>&1")
|
|
243
|
+
LOG.info "asdf detected — no explicit trust needed for #{path}"
|
|
244
|
+
else
|
|
245
|
+
LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}"
|
|
246
|
+
end
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
LOG.warn "Could not trust version manager in #{path}: #{e.message}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Cards that have been merged to main — skip Needs Review moves for these.
|
|
252
|
+
# Keyed by card number (string), value is Time. Entries expire after 10 minutes.
|
|
253
|
+
MERGED_CARDS = {}
|
|
254
|
+
MERGED_CARDS_MUTEX = Mutex.new
|
|
255
|
+
|
|
256
|
+
def mark_card_merged(card_number)
|
|
257
|
+
MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def card_merged?(card_number)
|
|
261
|
+
MERGED_CARDS_MUTEX.synchronize do
|
|
262
|
+
ts = MERGED_CARDS[card_number.to_s]
|
|
263
|
+
ts && (Time.now - ts < 600)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Pre-fetch a Fizzy card's body and comments so the agent doesn't have to.
|
|
268
|
+
# Returns a formatted string suitable for injection into the prompt, or ''
|
|
269
|
+
# if the fetch fails (agent can still fetch manually as a fallback).
|
|
270
|
+
PREFETCH_COMMENT_LIMIT = 15
|
|
271
|
+
COMMENT_BODY_TRUNCATE_LENGTH = 500
|
|
272
|
+
CARD_CONTEXT_CACHE = {}
|
|
273
|
+
CARD_CONTEXT_CACHE_TTL = 60 # seconds
|
|
274
|
+
|
|
275
|
+
def prefetch_card_context(card_number, repo_path:, agent_name: nil)
|
|
276
|
+
return "" unless card_number
|
|
277
|
+
|
|
278
|
+
# Return cached context if fresh enough
|
|
279
|
+
cache_key = "#{card_number}-#{agent_name}"
|
|
280
|
+
cached = CARD_CONTEXT_CACHE[cache_key]
|
|
281
|
+
if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL
|
|
282
|
+
LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)"
|
|
283
|
+
return cached[:context]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
env = fizzy_env_for(agent_name)
|
|
287
|
+
parts = []
|
|
288
|
+
|
|
289
|
+
card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env)
|
|
290
|
+
return "" if card_parts.nil?
|
|
291
|
+
|
|
292
|
+
parts.concat(card_parts)
|
|
293
|
+
parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env))
|
|
294
|
+
return "" if parts.empty?
|
|
295
|
+
|
|
296
|
+
context = parts.join("\n")
|
|
297
|
+
result = <<~CARD_CONTEXT
|
|
298
|
+
## Card Context (pre-fetched — do NOT re-fetch this)
|
|
299
|
+
#{context}
|
|
300
|
+
|
|
301
|
+
CARD_CONTEXT
|
|
302
|
+
|
|
303
|
+
CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now }
|
|
304
|
+
CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50
|
|
305
|
+
result
|
|
306
|
+
rescue StandardError => e
|
|
307
|
+
LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.message}"
|
|
308
|
+
""
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
|
|
312
|
+
def fetch_card_details(card_number, repo_path:, env:)
|
|
313
|
+
card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
|
|
314
|
+
card_data = begin
|
|
315
|
+
JSON.parse(card_output)["data"]
|
|
316
|
+
rescue StandardError
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
return [] unless card_data
|
|
320
|
+
|
|
321
|
+
parts = []
|
|
322
|
+
parts << "## Card ##{card_number}: #{card_data["title"]}"
|
|
323
|
+
parts << "Status: #{card_data["status"]}" if card_data["status"]
|
|
324
|
+
tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
|
|
325
|
+
parts << "Tags: #{tags.join(", ")}" unless tags.empty?
|
|
326
|
+
body = card_data.dig("body", "plain_text") || card_data["body"]
|
|
327
|
+
parts << "\n#{body}" if body && !body.to_s.strip.empty?
|
|
328
|
+
parts
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
LOG.warn "Could not pre-fetch card ##{card_number}: #{e.message}"
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Fetch recent comments for a card. Returns array of text parts.
|
|
335
|
+
def fetch_card_comments(card_number, repo_path:, env:)
|
|
336
|
+
comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
337
|
+
comments_data = JSON.parse(comments_output)["data"] || []
|
|
338
|
+
return [] if comments_data.empty?
|
|
339
|
+
|
|
340
|
+
parts = []
|
|
341
|
+
total = comments_data.size
|
|
342
|
+
comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT)
|
|
343
|
+
parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}"
|
|
344
|
+
comments_data.each do |c|
|
|
345
|
+
author = c.dig("creator", "name") || "Unknown"
|
|
346
|
+
body = c.dig("body", "plain_text") || ""
|
|
347
|
+
cid = c["id"]
|
|
348
|
+
next if body.strip.empty?
|
|
349
|
+
|
|
350
|
+
body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH
|
|
351
|
+
parts << "\n### #{author} (comment ID: #{cid})\n#{body}"
|
|
352
|
+
end
|
|
353
|
+
parts
|
|
354
|
+
rescue StandardError => e
|
|
355
|
+
LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
|
|
356
|
+
[]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def scrub_invalid_attachments!(dir)
|
|
360
|
+
attachments_dir = File.join(dir, ".fizzy-attachments")
|
|
361
|
+
return unless File.directory?(attachments_dir)
|
|
362
|
+
|
|
363
|
+
Dir.glob(File.join(attachments_dir, "*")).each do |file_path|
|
|
364
|
+
next unless File.file?(file_path)
|
|
365
|
+
|
|
366
|
+
file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path)
|
|
367
|
+
unless file_type.strip.start_with?("image/")
|
|
368
|
+
LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})"
|
|
369
|
+
FileUtils.rm_f(file_path)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
LOG.error "Error scrubbing attachments in #{dir}: #{e.message}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Extract the last N meaningful lines from an agent log for crash reporting.
|
|
377
|
+
def extract_crash_snippet(log_file, max_lines: 20)
|
|
378
|
+
return nil unless log_file && File.exist?(log_file)
|
|
379
|
+
|
|
380
|
+
lines = File.readlines(log_file).map { |l| l.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").rstrip }.reject(&:empty?).last(max_lines)
|
|
381
|
+
lines&.join("\n")
|
|
382
|
+
rescue StandardError => e
|
|
383
|
+
LOG.warn "[CrashNotify] Could not read log: #{e.message}"
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Notify the originating channel that an agent crashed.
|
|
388
|
+
# source: :fizzy, :github, :discord
|
|
389
|
+
# source_context: hash with channel-specific info needed to post the notification
|
|
390
|
+
def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:)
|
|
391
|
+
agent_display = agent_name || "Agent"
|
|
392
|
+
snippet = extract_crash_snippet(log_file)
|
|
393
|
+
snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
|
|
394
|
+
|
|
395
|
+
case source
|
|
396
|
+
when :fizzy
|
|
397
|
+
card_number = source_context[:card_number]
|
|
398
|
+
return unless card_number
|
|
399
|
+
|
|
400
|
+
repo_path = project_config&.dig("repo_path") || Dir.pwd
|
|
401
|
+
body = "<p>💥 <strong>#{agent_display} crashed</strong> (exit code #{exit_status})</p>" \
|
|
402
|
+
"<p>Log: <code>#{log_file}</code></p>"
|
|
403
|
+
if snippet
|
|
404
|
+
escaped = snippet[-1500..].gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
405
|
+
body += "<pre>#{escaped}</pre>"
|
|
406
|
+
end
|
|
407
|
+
begin
|
|
408
|
+
run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
|
|
409
|
+
chdir: repo_path, env: fizzy_env_for(agent_display))
|
|
410
|
+
LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}"
|
|
411
|
+
rescue StandardError => e
|
|
412
|
+
LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.message}"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
when :github
|
|
416
|
+
pr_number = source_context[:pr_number]
|
|
417
|
+
repo_name = source_context[:repo_name]
|
|
418
|
+
return unless pr_number && repo_name
|
|
419
|
+
|
|
420
|
+
work_dir = source_context[:work_dir] || Dir.pwd
|
|
421
|
+
comment_body = "💥 **#{agent_display} crashed** (exit code #{exit_status})\n\nLog: `#{log_file}`#{snippet_block}"
|
|
422
|
+
begin
|
|
423
|
+
run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir)
|
|
424
|
+
LOG.info "[CrashNotify] Posted crash comment on GitHub PR ##{pr_number}"
|
|
425
|
+
rescue StandardError => e
|
|
426
|
+
LOG.error "[CrashNotify] Failed to post GitHub crash comment: #{e.message}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
when :discord
|
|
430
|
+
channel_id = source_context[:channel_id]
|
|
431
|
+
message_id = source_context[:message_id]
|
|
432
|
+
bot_token = source_context[:bot_token]
|
|
433
|
+
return unless channel_id && bot_token
|
|
434
|
+
|
|
435
|
+
message = "💥 **#{agent_display} crashed** (exit code #{exit_status})\nLog: `#{log_file}`#{snippet_block}"
|
|
436
|
+
send_discord_message(channel_id, message, token: bot_token, reply_to: message_id)
|
|
437
|
+
LOG.info "[CrashNotify] Posted crash message to Discord channel #{channel_id}"
|
|
438
|
+
end
|
|
439
|
+
rescue StandardError => e
|
|
440
|
+
LOG.error "[CrashNotify] Unexpected error: #{e.message}"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Append an italic PR/branch footer to the agent's most recent Fizzy comment.
|
|
444
|
+
def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
|
|
445
|
+
repo_path = project_config["repo_path"]
|
|
446
|
+
project_config["github_repo"]
|
|
447
|
+
env = fizzy_env_for(agent_name)
|
|
448
|
+
|
|
449
|
+
# Find branch and tracked PRs from card_map
|
|
450
|
+
card_map = load_card_map
|
|
451
|
+
card_info = card_map.values.find { |v| v["number"] == card_number }
|
|
452
|
+
branch = card_info&.dig("branch")
|
|
453
|
+
return unless branch
|
|
454
|
+
|
|
455
|
+
prs = card_info&.dig("prs") || []
|
|
456
|
+
|
|
457
|
+
# Build footer parts
|
|
458
|
+
parts = []
|
|
459
|
+
parts << "Branch: <code>#{branch}</code>"
|
|
460
|
+
prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" }
|
|
461
|
+
return if parts.empty?
|
|
462
|
+
|
|
463
|
+
footer_html = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>"
|
|
464
|
+
|
|
465
|
+
# Find agent's most recent comment
|
|
466
|
+
begin
|
|
467
|
+
output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
|
|
468
|
+
comments = (JSON.parse(output)["data"] || []).reverse
|
|
469
|
+
agent_display = fizzy_display_name(agent_name)
|
|
470
|
+
comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") }
|
|
471
|
+
return unless comment
|
|
472
|
+
|
|
473
|
+
existing_html = comment.dig("body", "html") || ""
|
|
474
|
+
# Don't double-append if footer already present
|
|
475
|
+
return if existing_html.include?("Branch: <code>#{branch}</code>")
|
|
476
|
+
|
|
477
|
+
# Strip Fizzy's outer wrapper — it re-wraps on update
|
|
478
|
+
inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "")
|
|
479
|
+
updated_html = "#{inner}\n#{footer_html}"
|
|
480
|
+
run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s,
|
|
481
|
+
"--body", updated_html, chdir: repo_path, env: env)
|
|
482
|
+
LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}"
|
|
483
|
+
rescue StandardError => e
|
|
484
|
+
LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.message}"
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
|
|
489
|
+
return unless card_number
|
|
490
|
+
|
|
491
|
+
board_key = board_key_for_project(project_config)
|
|
492
|
+
column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name]
|
|
493
|
+
return unless column_id
|
|
494
|
+
|
|
495
|
+
repo_path = project_config["repo_path"]
|
|
496
|
+
env = fizzy_env_for(agent_name || AI_AGENT_NAME)
|
|
497
|
+
run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
|
|
498
|
+
record_self_move(card_number)
|
|
499
|
+
LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})"
|
|
500
|
+
rescue StandardError => e
|
|
501
|
+
LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.message}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
505
|
+
source: nil, source_context: {}, skip_column_move: false)
|
|
506
|
+
resolved = resolve_project_cli_config(project_config)
|
|
507
|
+
chdir ||= resolved["repo_path"]
|
|
508
|
+
model ||= resolved["agent_model"]
|
|
509
|
+
effort ||= resolved["agent_effort"]
|
|
510
|
+
agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
511
|
+
|
|
512
|
+
ensure_fizzy_yaml!(chdir, project_config)
|
|
513
|
+
Thread.new { scrub_invalid_attachments!(chdir) }
|
|
514
|
+
|
|
515
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
516
|
+
log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
|
|
517
|
+
FileUtils.mkdir_p(File.dirname(log_file))
|
|
518
|
+
|
|
519
|
+
prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
|
|
520
|
+
cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort)
|
|
521
|
+
spawn_env = agent_env_for(agent_name)
|
|
522
|
+
|
|
523
|
+
LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
|
|
524
|
+
LOG.info "Prompt written to #{prompt_file}"
|
|
525
|
+
LOG.info "Command: #{cmd.join(" ")}"
|
|
526
|
+
LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty?
|
|
527
|
+
|
|
528
|
+
head_before = nil
|
|
529
|
+
project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
530
|
+
if project_key_for_restart == "zillacore"
|
|
531
|
+
head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
|
|
532
|
+
head_before = head_before.strip
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
pid = spawn(spawn_env, *cmd,
|
|
536
|
+
chdir: chdir,
|
|
537
|
+
in: prompt_file,
|
|
538
|
+
out: [log_file, "w"],
|
|
539
|
+
err: %i[child out])
|
|
540
|
+
|
|
541
|
+
Thread.new do
|
|
542
|
+
Process.wait(pid)
|
|
543
|
+
handle_agent_completion(
|
|
544
|
+
pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
|
|
545
|
+
agent_name: agent_name, log_file: log_file, log_name: log_name,
|
|
546
|
+
prompt_file: prompt_file, chdir: chdir, source: source,
|
|
547
|
+
source_context: source_context, project_config: project_config,
|
|
548
|
+
card_number: card_number, skip_column_move: skip_column_move,
|
|
549
|
+
head_before: head_before, project_key_for_restart: project_key_for_restart
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
|
|
554
|
+
"model: #{model || "default"}), tail -f #{log_file}"
|
|
555
|
+
|
|
556
|
+
[pid, log_file]
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
|
|
560
|
+
def ensure_fizzy_yaml!(chdir, project_config)
|
|
561
|
+
fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
|
|
562
|
+
return if File.exist?(fizzy_yaml_dest)
|
|
563
|
+
|
|
564
|
+
fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
|
|
565
|
+
return unless File.exist?(fizzy_yaml_src)
|
|
566
|
+
|
|
567
|
+
FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
|
|
568
|
+
LOG.info "Copied .fizzy.yaml to #{chdir}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Write agent prompt to a temp file, return path.
|
|
572
|
+
def write_agent_prompt_file(prompt, log_name, timestamp)
|
|
573
|
+
prompt_dir = File.join(ZILLACORE_DIR, "tmp")
|
|
574
|
+
FileUtils.mkdir_p(prompt_dir)
|
|
575
|
+
prompt_file = File.join(prompt_dir, "prompt-#{log_name}-#{timestamp}.md")
|
|
576
|
+
File.write(prompt_file, prompt)
|
|
577
|
+
prompt_file
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Build the CLI command array for an agent invocation.
|
|
581
|
+
def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil)
|
|
582
|
+
cmd = [resolved["agent_cli"]]
|
|
583
|
+
cmd.push("--agent", agent_config_name) if agent_config_name
|
|
584
|
+
cmd.concat(resolved["agent_cli_args"].split)
|
|
585
|
+
add_trust_tools!(cmd, resolved["agent_cli_args"])
|
|
586
|
+
cmd.push(resolved["agent_model_flag"], model) if resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty? && model
|
|
587
|
+
cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort
|
|
588
|
+
cmd
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def handle_agent_completion(**ctx)
|
|
592
|
+
agent_exit_status = $CHILD_STATUS.exitstatus
|
|
593
|
+
agent_signaled = $CHILD_STATUS.signaled?
|
|
594
|
+
LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})"
|
|
595
|
+
|
|
596
|
+
if ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
|
|
597
|
+
notify_agent_crash(
|
|
598
|
+
exit_status: agent_exit_status, log_file: ctx[:log_file],
|
|
599
|
+
agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
|
|
600
|
+
project_config: ctx[:project_config]
|
|
601
|
+
)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
fizzy_card = ctx[:card_number] || ctx[:source_context][:card_number]
|
|
605
|
+
handle_fizzy_post_session(fizzy_card, agent_exit_status, agent_signaled, ctx[:agent_name], ctx[:chdir], ctx[:source], ctx[:source_context],
|
|
606
|
+
ctx[:project_config], ctx[:skip_column_move])
|
|
607
|
+
handle_plan_finalization(ctx[:prompt_file], ctx[:agent_name], ctx[:project_config])
|
|
608
|
+
|
|
609
|
+
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
610
|
+
if qmd_status.success?
|
|
611
|
+
LOG.info "[Brain] qmd update completed after #{ctx[:agent_config_name] || "agent"} session"
|
|
612
|
+
else
|
|
613
|
+
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
skill_candidate = detect_skill_candidate(ctx[:log_file])
|
|
617
|
+
if skill_candidate[:extract]
|
|
618
|
+
LOG.info "[Skills] Session qualifies for skill extraction " \
|
|
619
|
+
"(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
|
|
620
|
+
"— agent was nudged via reflection prompt"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
|
|
624
|
+
check_zillacore_restart(ctx[:head_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move)
|
|
628
|
+
return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled
|
|
629
|
+
|
|
630
|
+
unless skip_column_move || card_merged?(fizzy_card)
|
|
631
|
+
move_card_to_column(fizzy_card, "needs_review", project_config: project_config, agent_name: agent_name)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
append_fizzy_comment_footer(fizzy_card, project_config: project_config, agent_name: agent_name)
|
|
635
|
+
|
|
636
|
+
return unless source_context[:deploy_intent]
|
|
637
|
+
|
|
638
|
+
auto_deploy_after_session(
|
|
639
|
+
deploy_intent: source_context[:deploy_intent],
|
|
640
|
+
card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first,
|
|
641
|
+
card_number: fizzy_card,
|
|
642
|
+
worktree_path: chdir,
|
|
643
|
+
agent_name: agent_name
|
|
644
|
+
)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def handle_plan_finalization(prompt_file, agent_name, project_config)
|
|
648
|
+
return unless File.exist?(prompt_file)
|
|
649
|
+
|
|
650
|
+
prompt_content = File.read(prompt_file)
|
|
651
|
+
card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/)
|
|
652
|
+
return unless card_id_match
|
|
653
|
+
|
|
654
|
+
card_id = card_id_match[1]
|
|
655
|
+
plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md")
|
|
656
|
+
return unless File.exist?(plan_file)
|
|
657
|
+
|
|
658
|
+
LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..."
|
|
659
|
+
card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil
|
|
660
|
+
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
661
|
+
|
|
662
|
+
result = finalize_plan(
|
|
663
|
+
card_id: card_id, card_number: card_num,
|
|
664
|
+
agent_name: agent_name || AI_AGENT_NAME,
|
|
665
|
+
project_key: project_key, repo_path: project_config["repo_path"]
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if result[:success]
|
|
669
|
+
LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created"
|
|
670
|
+
else
|
|
671
|
+
LOG.error "[Planning] Failed to finalize plan: #{result[:error]}"
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def check_zillacore_restart(head_before, chdir, project_key_for_restart, agent_config_name)
|
|
676
|
+
return unless project_key_for_restart == "zillacore" && head_before
|
|
677
|
+
|
|
678
|
+
head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
|
|
679
|
+
git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
680
|
+
if head_after.strip != head_before || !git_status.strip.empty?
|
|
681
|
+
queue_zillacore_restart(agent_config_name || "agent")
|
|
682
|
+
else
|
|
683
|
+
LOG.info "[ZillaCore] #{agent_config_name || "agent"} session on zillacore had no changes — skipping restart"
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def authorized?(payload)
|
|
688
|
+
creator_id = payload.dig("creator", "id")
|
|
689
|
+
AUTHORIZED_USER_IDS.include?(creator_id)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def human_mentioned?(user_id)
|
|
693
|
+
return false unless FIZZY_CONFIG["authorized_users"]
|
|
694
|
+
|
|
695
|
+
user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id }
|
|
696
|
+
user && user["human"]
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def detect_model(project_config, tags: [], text: "")
|
|
700
|
+
resolved = resolve_project_cli_config(project_config)
|
|
701
|
+
allowed_models = resolved["allowed_models"] || {}
|
|
702
|
+
return resolved["agent_model"] if allowed_models.empty?
|
|
703
|
+
|
|
704
|
+
if (match = text.match(/\[(\w+)\]/))
|
|
705
|
+
key = match[1].downcase
|
|
706
|
+
return allowed_models[key] if allowed_models.key?(key)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
tags.each do |tag|
|
|
710
|
+
key = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
|
|
711
|
+
return allowed_models[key] if allowed_models.key?(key)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
resolved["agent_model"]
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Detect effort level from inline tags [effort:high] or Fizzy card tags (effort-high).
|
|
718
|
+
# Returns the effort level string (e.g. "high") or nil.
|
|
719
|
+
# If the requested level isn't supported by the current model, returns the closest
|
|
720
|
+
# lower level from allowed_efforts.
|
|
721
|
+
def detect_effort(project_config, tags: [], text: "")
|
|
722
|
+
resolved = resolve_project_cli_config(project_config)
|
|
723
|
+
allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
|
|
724
|
+
|
|
725
|
+
# Inline tag: [effort:high]
|
|
726
|
+
if (match = text.match(/\[effort:(\w+)\]/i))
|
|
727
|
+
level = match[1].downcase
|
|
728
|
+
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Fizzy card tags: effort-high, effort-max
|
|
732
|
+
tags.each do |tag|
|
|
733
|
+
name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
|
|
734
|
+
if name.start_with?("effort-")
|
|
735
|
+
level = name.sub("effort-", "")
|
|
736
|
+
return resolve_effort_level(level, allowed) if allowed.include?(level)
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
resolved["agent_effort"]
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# If a level isn't in allowed_efforts, return the closest lower level.
|
|
744
|
+
def resolve_effort_level(level, allowed)
|
|
745
|
+
all_levels = %w[low medium high xhigh max]
|
|
746
|
+
return level if allowed.include?(level)
|
|
747
|
+
|
|
748
|
+
idx = all_levels.index(level)
|
|
749
|
+
return nil unless idx
|
|
750
|
+
|
|
751
|
+
# Walk down to find closest supported lower level
|
|
752
|
+
idx.downto(0) { |i| return all_levels[i] if allowed.include?(all_levels[i]) }
|
|
753
|
+
nil
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def notify_unauthorized(action, creator_name, card_info)
|
|
757
|
+
msg = "Unauthorized: #{creator_name} triggered #{action} on #{card_info}"
|
|
758
|
+
LOG.warn msg
|
|
759
|
+
system("#{NOTIFICATION_COMMAND} '#{msg}'") if NOTIFICATION_COMMAND
|
|
760
|
+
end
|