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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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