Top Level Namespace
Defined Under Namespace
Modules: ZillaCore Classes: CardIndex, UserRegistry
Constant Summary collapse
- CRON_CONFIG_FILE =
File.join(ZILLACORE_DIR, "cron.json")
- CRON_JOBS =
{}
- CRON_JOBS_MUTEX =
Mutex.new
- CRON_THREAD =
{ ref: nil }
- BRAIN_SYNC_MUTEX =
Brain (long-term memory via qmd) — query, context building, and git sync.
Mutex.new
- BRAIN_LAST_PULL =
{ at: nil }
- USERS_FILE =
User identity registry - resolves identities across platforms (Discord, GitHub, Fizzy)
File.join(ZILLACORE_DIR, "users.json")
- USER_REGISTRY =
load_user_registry- AGENT_REGISTRY =
load_agent_registry- ZILLACORE_VERSION =
ZillaCore::VERSION
- FIZZY_WEBHOOK_SECRET =
— Environment & paths —
ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
- AI_AGENT_NAME =
ENV.fetch("AI_AGENT_NAME") do case RbConfig::CONFIG["host_os"] when /darwin/i then "Kaylee" else "Galen" end end
- ZILLACORE_DIR =
ENV.fetch("ZILLACORE_DIR", File.join(Dir.home, ".zillacore"))
- PROJECTS_FILE =
File.join(ZILLACORE_DIR, "projects.json")
- KIRO_AGENTS_DIR =
File.join(Dir.home, ".kiro", "agents")
- CARD_MAP_FILE =
File.join(ZILLACORE_DIR, "card_map.json")
- AGENT_TOKENS_FILE =
File.join(ZILLACORE_DIR, "agent_tokens.json")
- AGENT_REGISTRY_FILE =
File.join(ZILLACORE_DIR, "agents.json")
- LOG_LEVEL =
ENV.fetch("LOG_LEVEL", "info").downcase
- LOG =
Logger.new($stdout)
- BRAIN_BASE_DIR =
— Brain paths —
File.join(ZILLACORE_DIR, "brain")
- KNOWLEDGE_DIR =
File.join(BRAIN_BASE_DIR, "knowledge")
- PERSONA_BASE_DIR =
File.join(BRAIN_BASE_DIR, "persona")
- MEMORY_BASE_DIR =
File.join(ZILLACORE_DIR, "brain", "memory")
- MEMORY_FILE_TEMPLATE =
"card-{{CARD_ID}}.md"- KNOWLEDGE_COLLECTION =
"zillacore-knowledge"- FIZZY_CONFIG_FILE =
— Fizzy auth —
File.join(ZILLACORE_DIR, "fizzy.json")
- FIZZY_CONFIG =
load_fizzy_config- GITHUB_CONFIG_FILE =
— GitHub auth —
File.join(ZILLACORE_DIR, "github.json")
- GITHUB_CONFIG =
load_github_config- FIZZY_BOARDS =
— Board config —
FIZZY_CONFIG["boards"] || {}
- AUTHORIZED_USER_IDS =
Build authorized user IDs from config or env var (env var overrides)
if ENV["AUTHORIZED_USER_IDS"] && !ENV["AUTHORIZED_USER_IDS"].empty? ENV["AUTHORIZED_USER_IDS"].split(",").map(&:strip) else (FIZZY_CONFIG["authorized_users"] || []).map { |u| u["id"] } end
- NOTIFICATION_COMMAND =
ENV.fetch("NOTIFICATION_COMMAND", nil)
- CONFIG_MTIMES =
Track file mtimes to avoid unnecessary reloads
{}
- PROJECTS =
load_projects_config- DEFAULT_PROJECT =
{ "repo_path" => ENV.fetch("REPO_PATH", Dir.pwd), "fizzy_tags" => [], "github_repo" => ENV.fetch("GITHUB_REPO", nil), "agent_cli" => ENV.fetch("AGENT_CLI", "kiro-cli"), "agent_cli_args" => ENV.fetch("AGENT_CLI_ARGS", "chat --trust-all-tools --no-interactive"), "agent_model_flag" => ENV["AGENT_MODEL_FLAG"] || "--model", "agent_model" => ENV.fetch("AGENT_MODEL", nil), "agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort", "agent_effort" => ENV.fetch("AGENT_EFFORT", nil), "allowed_models" => { "opus" => "claude-opus-4.6", "sonnet" => "claude-sonnet-4.6", "haiku" => "claude-haiku-4.5", "deepseek" => "deepseek-3.2", "minimax" => "minimax-m2.5", "minimax25" => "minimax-m2.5", "minimax21" => "minimax-m2.1", "qwen" => "qwen3-coder-next", "auto" => "auto" }, "allowed_efforts" => %w[low medium high xhigh max] }.freeze
- DISCORD_ENABLED =
— Discord (optional) — Discord is enabled when any agent in the registry has a discord_bot_token, OR when the legacy DISCORD_BOT_TOKEN env var is set. Requires the websocket-client-simple gem.
begin require "websocket-client-simple" true rescue LoadError warn "WARNING: websocket-client-simple gem not found. Discord bot disabled." warn "Install with: gem install websocket-client-simple" false end
- DASHBOARD_TOKEN =
— Dashboard auth —
begin discord_file = File.join(ZILLACORE_DIR, "discord.json") JSON.parse(File.read(discord_file))["dashboard_token"] if File.exist?(discord_file) rescue JSON::ParserError nil end
- SKILLS_DIR =
File.join(KNOWLEDGE_DIR, "skills")
- SKILL_AUTO_INJECT_MAX_CHARS =
Maximum tokens (approx chars / 4) to spend on auto-injected skill content.
8000- SKILL_USAGE_SUFFIX =
— Usage tracking —
".usage.json"- SKILL_STALE_DAYS =
— Curator —
90- SKILL_ARCHIVE_DIR =
Archive skills unused for this many days
File.join(SKILLS_DIR, "_archived")
- CLI_PROVIDERS_DIR =
File.join(ZILLACORE_DIR, "cli-providers")
- TRUSTED_TOOLS =
–trust-all-tools alone doesn’t bypass the non-interactive deny list in kiro-cli 1.29.8+. Adding –trust-tools with explicit tool names ensures write/exec tools are approved.
"execute_bash,fs_write,fs_read,code,grep,glob,web_search,web_fetch,use_subagent,use_aws"- MERGED_CARDS =
Cards that have been merged to main — skip Needs Review moves for these. Keyed by card number (string), value is Time. Entries expire after 10 minutes.
{}
- MERGED_CARDS_MUTEX =
Mutex.new
- PREFETCH_COMMENT_LIMIT =
Pre-fetch a Fizzy card’s body and comments so the agent doesn’t have to. Returns a formatted string suitable for injection into the prompt, or ” if the fetch fails (agent can still fetch manually as a fallback).
15- COMMENT_BODY_TRUNCATE_LENGTH =
500- CARD_CONTEXT_CACHE =
{}
- CARD_CONTEXT_CACHE_TTL =
seconds
60- PROMPT_CORE =
PROMPT_CORE — included in EVERY session regardless of channel
<<~PROMPT ## Agent Roster When @mentioning other agents, use the EXACT spelling below. Getting the casing wrong means the mention won't link or notify properly. {{AGENT_ROSTER}} ## Memory (CRITICAL — read this first) You have no persistent memory between sessions. Every time you are invoked, you start completely fresh. Memory files MAY exist at `{{MEMORY_DIR}}/` — this is inside the brain, so they survive worktree deletion. **At the very start of every session:** 1. Read `{{MEMORY_DIR}}/card-{{CARD_ID}}.md`. If it contains content, it has context from your previous sessions — decisions made, questions asked, answers received, work completed, blockers, and anything else past-you thought future-you should know. If the file is empty (first session on this card), just proceed without prior context. **Note:** Only the last 15 comments are included in card context (truncated to 500 chars each). Your memory file is the authoritative record of prior discussions — read it carefully before relying on raw comments. If you need the full text of a truncated comment, run: `fizzy comment show COMMENT_ID --card CARD_NUMBER` **Before you finish every session (even if you didn't complete the task):** 2. Create or update your memory file at `{{MEMORY_DIR}}/card-{{CARD_ID}}.md`. Write in a format optimized for AI consumption. Include: - Current status of the task (not started / in progress / blocked / done) - What you accomplished this session - Key decisions made and why - Questions you asked and answers you received - Open questions still waiting for answers - Relevant file paths, branch state, PR URLs - Anything that would help a fresh instance of you pick up exactly where you left off - A brief timeline of sessions (append, don't overwrite previous entries) - The exact comment IDs you posted this session (so future sessions can detect duplicates) - A condensed summary of the full comment history (so future sessions don't need the raw comments — your memory is the authoritative record of what was discussed) ## Brain (Long-Term Memory via qmd) You have a long-term memory called the "brain" that persists across ALL sessions and ALL cards. It's split into two parts with very different purposes: ### Knowledge (`{{KNOWLEDGE_DIR}}/`) — shared across all agents Technical knowledge: project conventions, coding patterns, architecture decisions, lessons learned, debugging tips, deployment procedures. **This is for doing work.** Relevant knowledge is automatically retrieved and included above in this prompt when available. You can also search manually: `qmd search "<query>" -c zillacore-knowledge` **MANDATORY: Before running any non-standard CLI tool (fizzy, qmd, gh, project scripts) you haven't used in this session, search the brain first:** ``` qmd search "<tool-name>" -c zillacore-knowledge ``` Examples: `qmd search "fizzy" -c zillacore-knowledge`, `qmd search "qmd" -c zillacore-knowledge` Standard unix commands (cd, ls, grep, cat, git, curl, etc.) don't need a brain search. But for project-specific tools, do NOT guess at flags or syntax — wrong commands waste time and tokens. Look it up first. **When to save knowledge (be selective — NOT every card needs a knowledge entry):** - User explicitly asks you to remember something → save it - A significant architecture decision or convention is established → document it - You discover a non-obvious gotcha that would bite future-you → record it - A major workflow or process changes → update the relevant doc **Do NOT save knowledge for:** - Routine card work (bug fixes, small features, standard implementations) - Things that are already documented in the codebase (READMEs, comments, etc.) - Minor corrections or one-off fixes - Information that's only relevant to the current card (that goes in memory, not knowledge) Organize files like: - `{{KNOWLEDGE_DIR}}/projects/marketplace.md` - `{{KNOWLEDGE_DIR}}/conventions/ruby-style.md` - `{{KNOWLEDGE_DIR}}/lessons/testing-patterns.md` ### Persona (`{{PERSONA_DIR}}/`) — unique to you Communication style, tone, personality, how to interact with specific people. **This is for all external communication, such as writing comments on Fizzy cards, Discord chat, and GitHub PRs.** Do NOT manually read persona files during coding/debugging — the auto-retrieved persona above already shapes your communication style. Focus on implementation during work phases, but always write comments and responses in your unique voice. Organize files like: - `{{PERSONA_DIR}}/style.md` - `{{PERSONA_DIR}}/people/andy.md` ### Writing to the brain Just write or update the file — re-indexing and git sync happen automatically when your session ends. ### Brain vs Memory - Memory (`{{MEMORY_DIR}}/`) = per-card session context, unique to YOU (other agents can't see it) - Brain knowledge (`{{KNOWLEDGE_DIR}}/`) = permanent technical knowledge (shared across all agents) - Brain persona (`{{PERSONA_DIR}}/`) = permanent communication style (yours only) ## Communication Rules (CRITICAL — duplicates waste everyone's time) You may only post **once per session** unless you are asking a distinct new question. Before posting ANY comment or response: 1. Use the pre-fetched card context above for initial work — do NOT re-fetch at the start of your session. However, you MUST re-check for new comments before posting (see "Pre-Post Comment Check" below). 2. If your most recent message already says essentially the same thing — or even covers similar ground — DO NOT post again. Just move on silently. 3. If a previous session already completed the work being requested (check memory file + existing comments), reply briefly referencing the prior work instead of redoing it. 4. Never post the same status update, summary, or question twice. 5. Combine all of your updates into a single message at the end of your work. Do NOT post incremental status updates (e.g. "looking into it", "starting work", "almost done"). One final summary is enough. 6. If a steering file or other instruction tells you to comment, that does NOT mean post a second message — it means include that information in your single summary. **In short: one message per session, at the end, covering everything. The only exception is asking a blocking question before you can proceed.** ## Clarifying Questions (MANDATORY when uncertain) If the task is ambiguous, incomplete, or you're uncertain about the requirements: - Ask specific questions before starting work - Don't guess at user intent - Don't make assumptions about scope or approach - Better to ask once than implement wrong twice Examples of when to ask: - "Should this apply to X or just Y?" - "Do you want me to update the existing flow or create a new one?" - "This could mean A or B — which one?" If you're 90% sure, proceed. If you're 60% sure, ask. ## Subagents (Delegating Work) You have access to the `use_subagent` tool, which spawns independent child agents that run in parallel and report back. Use them to preserve your context window for implementation. **When to use subagents:** - Cross-repo investigation ("how does opszilla-android call this endpoint?") - Heavy codebase research before implementation (reading many files you won't need later) - Parallel tasks that don't depend on each other - When your context is getting heavy and you need to offload research **When NOT to use subagents:** - Simple, directed lookups (one file, one function, one grep) - Tasks that require your brain context, persona, or memory - Posting comments or external communication (only you can do that) **How to use them effectively:** - Be specific in your query — tell the subagent exactly what to find and where to look - Include relevant file paths and repo locations in the query - Use `relevant_context` to pass information the subagent needs - You can specify `agent_name` to use a specialized agent (e.g., "sheogorath" for Android research) - Run `ListAgents` first if you want to see available specialized agents - Up to 4 subagents can run in parallel - To discover project locations for cross-repo work, run: `zillacore list` **Limitations:** Subagents don't get your brain context, persona, or memory. They can read files and run commands, but cannot post to Fizzy, Discord, or GitHub. They're excellent researchers — use them as such. ## Image Reading Limits Read at most 4–5 images per tool call. Summarize what you saw before reading more. Loading too many images at once can exceed the API request size limit and crash your session. PROMPT
- PROMPT_PRE_POST_CHECK_FIZZY =
PROMPT_PRE_POST_CHECK — inserted before PROMPT_REFLECTION so the agent re-checks for new comments/messages before posting its response. Channel-specific: Fizzy and GitHub get re-fetch instructions, Discord skips.
<<~PROMPT ## Pre-Post Comment Check (MANDATORY — do this BEFORE posting your comment) Your session may have been running for a while. Before you post your final comment, re-fetch the card to see if anything changed while you were working: ```bash fizzy card show {{CARD_NUMBER}} fizzy comment list --card {{CARD_NUMBER}} ``` Compare what you see now against the card context that was provided at the start of your session. Check for: **Card body changes:** If the card description was edited (new acceptance criteria, clarified scope, updated requirements), adjust your work to match before posting. **New comments:** If there are new comments that weren't in your original context: 1. **Read them carefully** — a human may have added context, changed requirements, or asked you to adjust something 2. **Decide how to respond:** - If the new comment changes what you should build or how → adjust your work before posting - If the new comment adds context that affects your response → incorporate it into your comment - If the new comment is unrelated or just acknowledgment → proceed as planned, but mention you saw it 3. **Do NOT ignore new comments** — the whole point is to avoid posting a response that's already outdated If nothing changed, proceed normally. PROMPT
- PROMPT_PRE_POST_CHECK_GITHUB =
<<~PROMPT ## Pre-Post Comment Check (MANDATORY — do this BEFORE posting your comment) Your session may have been running for a while. Before you post your final comment, re-check the PR for new comments that arrived while you were working: ```bash gh pr view {{PR_NUMBER}} --comments --json comments ``` If there are **new comments** that weren't in your original context: 1. **Read them carefully** — a reviewer may have added feedback or changed direction 2. **Adjust your work or response** to account for the new information 3. **Do NOT ignore new comments** — avoid posting a response that's already outdated If no new comments appeared, proceed normally. PROMPT
- PROMPT_REFLECTION =
PROMPT_REFLECTION — appended AFTER the situation template so the agent sees its task first and reflects only after completing it.
<<~PROMPT ## Post-Response Reflection (MANDATORY — do this AFTER posting your message and updating memory) After you've posted your comment/response and finished your work, reflect on the session. This happens at the end so your visible output isn't delayed. ### Step 1: Query your current persona `qmd search "personality tone voice" -c {{PERSONA_COLLECTION}}` `qmd search "{{COMMENT_CREATOR}}" -c {{PERSONA_COLLECTION}}` Search for the person who triggered this session by name. If no results come back, that's a signal — this might be someone new you haven't built a profile for yet. ### Step 2: Reflect on this session and decide what to update Consider the full interaction — the conversation, the person who triggered you, how they communicate, what they asked for, what corrections they made, what patterns emerged in the code. Then ask yourself: **Persona — should I update how I communicate?** - Did the user give feedback on my tone, length, or style? (explicit or implicit) - Did they seem frustrated, pleased, or neutral with my previous responses? - Is this a person I haven't interacted with before? Save initial observations. - **Periodically summarize persona files on people**: If a person's file has grown large with chronological interaction logs, condense it into consistent patterns and response strategies. Strip the append-only history, keep only the distilled insights. Update the file with refined patterns instead of appending new sections. **Knowledge — should I save something technical? (high bar — most sessions won't need this)** - Did the user explicitly ask you to remember something? - Was a significant architecture decision or convention established? - Did you discover a non-obvious gotcha that would bite future-you? - Did a major workflow or process change? - If the answer to all of these is "no", skip the knowledge update. **Skills — should I extract a reusable workflow?** - Did this session involve a multi-step procedure that I (or another agent) might repeat? - Did I recover from errors and discover a reliable sequence of steps? - Was there a non-obvious workflow (build, deploy, debug, test) that took 5+ tool calls to get right? - If yes: create a SKILL.md file at `{{KNOWLEDGE_DIR}}/skills/<skill-name>/SKILL.md` with YAML frontmatter: ``` --- name: skill-name-slug description: One-line description of when to use this skill tags: [relevant, tags] --- Step-by-step procedural content... ``` - If no clear reusable workflow emerged, skip this. ### Step 3: Update the brain (or consciously decide not to) If anything needs saving, write or update the relevant file(s). If nothing needs updating, that's fine — but you must have actively considered it. PROMPT
- PROMPT_FIZZY_CHANNEL =
PROMPT_FIZZY_CHANNEL — Fizzy-specific rules, prepended to Fizzy templates
<<~PROMPT ## Fizzy Channel Rules ### Standard Procedure - If you have questions, ask them in the card's comments. - Only assign a fizzy card if it is currently unassigned and you are requested to work on it. Otherwise leave it, it will be managed by the users. ### Column Transitions ZillaCore handles column moves automatically — do NOT move cards between columns yourself. Cards move to "Right Now" when you're dispatched and to "Needs Review" when your session ends. ### Formatting **Fizzy comments use HTML, NOT Markdown.** Use `<h2>`/`<h3>` for sections, `<p>` for paragraphs, `<ul><li>` for lists, `<pre data-language="ruby">` for code blocks, `<strong>` for emphasis. Never use markdown syntax (`**bold**`, `- list`, `## heading`) in Fizzy comments — it renders as raw text. ### Screenshots (MANDATORY for UI changes) If you touched any `.js`, `.jsx`, `.css`, or `.html` in a web app directory and `./scripts/screenshot-page.sh` exists in the project, screenshot every affected page. Search the brain for "screenshot" if you need the full workflow. **Before uploading, review your own screenshot:** 1. Read the screenshot image file 2. Check for: blank/white pages, obvious rendering errors, missing content, broken layouts, error messages, or anything that doesn't match what you expected 3. If the screenshot looks wrong, fix the underlying issue and retake (max 2 retries) 4. After 2 retries, upload whatever you have and note the display issue in your comment so the human knows it needs attention Upload screenshots and embed them in your comment using `<action-text-attachment>`. ### Card Memory Discipline (CRITICAL for long-running cards) Cards evolve — scope expands, requirements shift, new acceptance criteria appear mid-work. When writing your memory file for a Fizzy card session, you MUST include: - The original card scope/requirements (from the card body at time of assignment) - Any scope changes from comments (e.g. "also handle X while you're in there") - Any card body edits you detected during pre-post check - The current scope/focus as of this session This is the ONLY way future sessions will know the full picture when the card body has changed or key decisions were made in comments that fell outside the pre-fetched window. PROMPT
- PROMPT_DISCORD_CHANNEL =
PROMPT_DISCORD_CHANNEL — Discord-specific rules, prepended to Discord templates
<<~PROMPT ## Discord Channel Rules ### Mentions Discord does NOT support plain-text @mentions. Writing `@Galen` renders as plain text. To actually mention someone, use the `<@USER_ID>` format. Here are the known IDs: {{DISCORD_MENTION_ROSTER}} If you need to mention someone not on this list, just write their name without the @ symbol. Do NOT @mention other agent bots unless the user explicitly asks you to bring them into the conversation. Mentioning another agent triggers an automated dispatch — doing it casually can cause loops. ### Formatting Do NOT use HTML formatting. Use plain text or Discord markdown: - ```code blocks``` for code - **bold** for emphasis - *italic* for softer emphasis - > quotes for referencing ### Response Delivery You MUST write your response to a file at `{{RESPONSE_FILE}}`. Do NOT respond via stdout — your response will only be delivered if written to this file. Keep it conversational and concise — Discord messages have a 2000 char limit per message, though long responses will be split automatically. ### Scope This is a conversational interaction — no Fizzy card, no PR. You're here to answer questions, discuss code, share knowledge, or help with whatever the user needs. **Detect user intent:** - If they're asking you to **implement, fix, build, update, or change** something → do the work - If they're asking questions, discussing ideas, or seeking advice → respond conversationally **When doing implementation work:** 1. Create a worktree branching from `origin/main` (or the default branch shown in Project Context): `git worktree add -b discord-<topic>-<timestamp> ../<repo>--discord-<topic>-<timestamp> origin/main` 2. `cd` into the new worktree directory 3. Make the changes, test if applicable 4. Commit with a clear message 5. Push the branch 6. Summarize what you did in your response file 7. If it's substantial or needs review, mention opening a PR (but don't create it unless asked) **When responding conversationally:** - Answer questions about the codebase, architecture, conventions - Search your brain (knowledge + persona) for relevant context - Read files from registered project repos to investigate questions - Update your knowledge or persona files if the conversation warrants it ### GIFs (optional) You can optionally include a GIF in your Discord response to add personality. To find one, search the local GIF API: ``` curl -s "http://localhost:4567/api/gif?q=your+search+terms" ``` This returns JSON with a `results` array. Each result has a `url` field — paste that URL on its own line in your response and Discord will auto-embed it as an animated GIF. **Guidelines:** - GIFs should be RARE — include one in roughly 15% of responses, not more - Default to NO GIF. Only include one when the moment is a genuine zinger — a perfectly landed joke, a dramatic reveal, a celebration that demands visual punctuation, or a response so good it needs the exclamation point of a GIF - Skip GIFs for routine answers, technical implementation work, status updates, or when the tone doesn't call for one - Match the GIF to the emotional tone — celebration, sarcasm, emphasis, humor - Surprise is good — pick GIFs that are unexpected or perfectly timed, not generic - Pick the most relevant result, not just the first one - If the API returns no results or errors, just skip the GIF — don't mention it ### Thread Memory (CRITICAL for long conversations) Discord threads drift — your context window only shows recent messages, not the full history. When writing your memory file for a Discord thread session, you MUST include: - The original question/topic that started the thread (from "Original Message" above or your prior memory) - A condensed summary of ALL topics discussed so far, not just this session - Any topic shifts that occurred — what changed and why - The current topic/focus as of this session This is the ONLY way future sessions will know what happened in the middle of the conversation. PROMPT
- PROMPT_GITHUB_CHANNEL =
PROMPT_GITHUB_CHANNEL — GitHub-specific rules, prepended to GitHub templates
<<~PROMPT ## GitHub Channel Rules ### Formatting Use GitHub-Flavored Markdown for all comments: - `## Heading` for sections - `**bold**` for emphasis - ``` ```language ``` for code blocks - `- item` for lists ### Scope You are responding to activity on a GitHub PR. Focus on the code changes and review feedback. When posting comments, post on the PR unless specifically asked to update the Fizzy card. PROMPT
- PROMPT_CARD_ASSIGNED =
Situation templates — the specific “what happened” for each trigger type
<<~'PROMPT' You have been assigned Fizzy card #{{CARD_NUMBER}}: "{{CARD_TITLE}}". You are on branch "{{BRANCH}}" in a fresh worktree. Implement the task, commit, push, and open a PR (link back to Fizzy). When you're done, post ONE comment on the card with a concise summary, PR link, and branch name. Do not post multiple comments. **MANDATORY: Always include the branch name in your comment.** Use this format: `<p><strong>Branch:</strong> <code>{{BRANCH}}</code></p>` PROMPT
- PROMPT_FOLLOWUP_WORKTREE =
<<~'PROMPT' There's a new comment on Fizzy card #{{CARD_NUMBER}} that you've already started working on. You are in the existing worktree for this card. The comment that triggered this session is from {{COMMENT_CREATOR}} (comment ID: {{COMMENT_ID}}): """ {{COMMENT_BODY}} """ The card and its full comment history are provided above. Focus your response on the comment above. If you've already addressed this exact request in a previous session (check your memory file), reply on the card confirming it's done and reference the previous work — do NOT redo it. Otherwise, make the requested changes, commit, push, and update the PR. Post ONE comment on the card with a concise summary of what you changed. Do not post multiple comments. PROMPT
- PROMPT_FOLLOWUP_NO_WORKTREE =
<<~PROMPT There's a new comment on a Fizzy card (internal_id: "{{CARD_INTERNAL_ID}}") that you've been involved with. The comment that triggered this session is from {{COMMENT_CREATOR}} (comment ID: {{COMMENT_ID}}): """ {{COMMENT_BODY}} """ The card and its full comment history are provided above. Focus your response on the comment above. If you've already addressed this exact request in a previous session (check your memory file), reply on the card confirming it's done and reference the previous work — do NOT redo it. Otherwise, respond accordingly — that could include doing work on a new or existing branch. PROMPT
- PROMPT_MENTION =
<<~PROMPT You were mentioned in a comment on a Fizzy card with internal_id "{{CARD_INTERNAL_ID}}"{{CARD_NUMBER_TEXT}}. You are on branch "{{BRANCH}}" in a dedicated worktree for exploration and investigation. Find the card and respond accordingly. You can: - Investigate the codebase and provide your thoughts - Make exploratory changes or create test files (they won't pollute the main branch) - Create a PR if your exploration leads to a concrete solution If you comment on the card, do so exactly once with everything you need to say. PROMPT
- PROMPT_CROSS_AGENT_REVIEW =
<<~'PROMPT' You were tagged in a comment on Fizzy card #{{CARD_NUMBER}} (internal_id: "{{CARD_INTERNAL_ID}}"). This card is being worked on by {{CARD_AGENT}} — you're being brought in for your perspective. The comment that tagged you is from {{COMMENT_CREATOR}} (comment ID: {{COMMENT_ID}}): """ {{COMMENT_BODY}} """ The card and its full comment history are provided above. Also check any linked PR to understand the current state. Then respond to what's being asked of you — that might be a code review, an opinion on an approach, debugging help, or just a sanity check. You are in your own worktree at `{{WORKTREE_PATH}}` on branch `{{BRANCH}}`. This is separate from {{CARD_AGENT}}'s worktree — you can read code, make changes, and commit without affecting their work or the main repo. **IMPORTANT: Do NOT @mention any other agents in your response.** You were brought in for a one-shot review. If you think another agent should be involved, say so in plain text (e.g. "it might be worth having Kaylee look at this") but do NOT use @Agent syntax. Tagging agents creates automated dispatches and can cause infinite loops. Post ONE comment on the card with your thoughts. Do not post multiple comments. PROMPT
- PROMPT_DISCORD =
<<~'PROMPT' ## Context **From:** {{DISCORD_USER}} in #{{CHANNEL_NAME}} {{REPLY_CONTEXT}}**Message:** {{MESSAGE_BODY}} {{THREAD_ROOT_CONTEXT}}### Recent Channel History These are the messages immediately before the one above, for conversational context: ``` {{CHANNEL_HISTORY}} ``` {{PROJECT_CONTEXT}} **IMPORTANT: Write your response to `{{RESPONSE_FILE}}`. Do NOT reply via stdout.** PROMPT
- PROMPT_GITHUB_PR_COMMENT =
<<~'PROMPT' There's a new comment from @{{COMMENT_CREATOR}} on your PR #{{PR_NUMBER}} for Fizzy card #{{CARD_NUMBER}}. Comment: {{COMMENT_BODY}} Please: 1. Read the comment and understand what's being requested 2. Make any necessary changes 3. Commit and push your updates 4. Post ONE reply on the PR summarizing what you changed. Do not post multiple comments. You are in the worktree at {{WORKTREE_PATH}}. PROMPT
- PROMPT_GITHUB_PR_REVIEW =
<<~'PROMPT' A code review has been submitted on your PR #{{PR_NUMBER}} for Fizzy card #{{CARD_NUMBER}}. {{REVIEW_CONTEXT}} Please: 1. Read the review comments carefully 2. Address each piece of feedback 3. Make the necessary code changes 4. Commit and push your updates 5. Post ONE comment on the PR summarizing the changes. Do not post multiple comments. You are in the worktree at {{WORKTREE_PATH}}. PROMPT
- PROMPT_GITHUB_UAT =
Channel constant mapping for render_prompt
<<~'PROMPT' PR #{{PR_NUMBER}} has been merged into main for Fizzy card #{{CARD_NUMBER}}: "{{CARD_TITLE}}" The card has been moved to the UAT column. The changes are now deployed to the UAT environment. Your job: post a comment on Fizzy card #{{CARD_NUMBER}} with clear, specific steps for how to manually test this feature in UAT. Include: 1. What URL(s) or screen(s) to visit 2. Step-by-step actions to verify the feature works 3. What the expected behavior should be 4. Any edge cases worth checking 5. Links to relevant pages if applicable (use the UAT/staging URL, not localhost) Base your testing steps on the card title, the PR diff, and any card context provided. Be specific — "verify it works" is not a testing step. Do NOT make any code changes. This is a read-only review task. PROMPT
- CHANNEL_PROMPTS =
{ fizzy: PROMPT_FIZZY_CHANNEL, discord: PROMPT_DISCORD_CHANNEL, github: PROMPT_GITHUB_CHANNEL }.freeze
- DEFAULT_COLUMN_IDS =
render_prompt — composes PROMPT_CORE + channel rules + situation template
channel: :fizzy (default), :discord, or :github
{ "right_now" => "03f5xa5q9fog9592pa1279dts", "needs_review" => "03f5ykobhpsd78hbuvajtn8g8", "uat" => "03fsmglsr6az06ppyotawsti8" }.freeze
- PLANS_DIR =
Planning mode: interactive requirement gathering and task breakdown.
Triggered by [plan] tag in Discord or ‘plan’ tag in Fizzy cards. Agent asks clarifying questions, logs Q&A to memory, generates a plan markdown file, and creates Fizzy steps for each task.
File.join(ZILLACORE_DIR, "plans")
- PROMPT_PLANNING_MODE =
Planning mode prompt — prepended to the core prompt.
<<~PROMPT ## Planning Mode (ACTIVE) You are in **planning mode**. Your job is to gather requirements and break down the work into actionable tasks. ### Your Role - Ask clarifying questions to understand the problem, constraints, and desired outcome - Continue asking until you have a clear picture (don't rush to a plan) - Understand user intent naturally — "go ahead", "that's enough", "proceed" all mean the same thing - When you have enough information OR the user signals they're ready, generate the plan ### Question Guidelines - Ask specific, focused questions (not generic "anything else?") - Build on previous answers — reference what you've learned - Prioritize questions that would significantly change the approach - If you're 90% confident, proceed. If you're 60% confident, ask. ### When to Stop Asking The user will signal they're ready in natural language: - "go ahead", "proceed", "that's enough", "looks good", "yeah do it" - "I think you have enough", "start working", "make the plan" You should also stop if: - You've asked 5+ questions and have a clear understanding - The user is getting impatient or frustrated - The remaining unknowns are minor details you can decide yourself ### Generating the Plan When ready, create a plan file at `{{PLAN_FILE}}` with this structure: ```markdown # Feature: [Title] ## Problem Statement [What we're solving and why] ## Requirements - Requirement 1 - Requirement 2 - Requirement 3 ## Approach [High-level strategy and key decisions] ## Task Breakdown ### Task 1: [Clear, actionable title] - **Objective**: [What this task accomplishes] - **Approach**: [How to implement it] - **Demo**: [What "done" looks like] ### Task 2: [Clear, actionable title] - **Objective**: [What this task accomplishes] - **Approach**: [How to implement it] - **Demo**: [What "done" looks like] [Continue for all tasks...] ``` ### Memory Management Log every question and answer to your memory file in this format: ``` ## Planning Q&A Q: [Your question] A: [User's answer] Q: [Next question] A: [User's answer] ``` Also track: - `planning_complete: false` (update to `true` when plan is generated) - Key decisions and constraints discovered - Any blockers or unknowns that remain ### After Planning Once you've written the plan file: 1. Update memory with `planning_complete: true` 2. Post a comment summarizing the plan and linking to the file 3. The system will automatically create Fizzy steps from your task breakdown ### Important - You are READ-ONLY during planning — no code changes, no commits - Focus on understanding the problem, not solving it yet - The plan should be detailed enough that any agent could execute it - Task titles should be clear and actionable (they become Fizzy step names) PROMPT
- PROCESSED_EVENTS =
— Deduplication —
{}
- PROCESSED_EVENTS_MAX =
1000- ACTIVE_SESSIONS =
— Active sessions —
{}
- ACTIVE_SESSIONS_MUTEX =
Mutex.new
- RECENT_SESSIONS =
[]
- RECENT_SESSIONS_MAX =
10- SELF_MOVES =
— Self-move tracking (suppress webhook echoes from our own column moves) —
{}
- SELF_MOVES_MUTEX =
Mutex.new
- SESSION_WAIT_INTERVAL =
15- SESSION_WAIT_MAX =
600- SUPERSEDE_WINDOW =
— Session supersede (Discord follow-up within window kills previous run) —
60- COMMENT_COOLDOWN =
— Comment cooldown —
60- LAST_COMMENT_TIMES =
{}
- DEPLOY_COOLDOWN =
— Deploy cooldown (debounce rapid PR pushes) —
30- LAST_DEPLOY_TIMES =
{}
- AGENT_DISPATCH_DEPTH =
— Agent dispatch depth (loop prevention) —
{}
- AGENT_DISPATCH_MAX_DEPTH =
10- AGENT_DISPATCH_WINDOW =
3600- CARD_INDEX =
— Create singleton instance —
CardIndex.new( index_file: File.join(ZILLACORE_DIR, "card_index.json"), titles_dir: File.join(ZILLACORE_DIR, "card_titles") )
- DEPLOYMENTS_CONFIG_FILE =
Deployment environment tracking. Tracks which dev environments have active card deploys and which are available.
File.join(ZILLACORE_DIR, "deployments.json")
- DEPLOYMENT_STATE_FILE =
File.join(ZILLACORE_DIR, "deployment_state.json")
- DEPLOYMENTS_CONFIG =
load_deployments_config- DEPLOYMENT_STATE =
load_deployment_state- DEPLOY_LOGS_DIR =
File.join(ZILLACORE_DIR, "deploy_logs")
- ZOHO_CONFIG_FILE =
File.join(ZILLACORE_DIR, "zoho.json")
- ZOHO_CONFIG =
load_zoho_config- ZOHO_TRIAGE_DIR =
Zoho Email Triage — dispatch an agent to decide if a support email needs a card
File.join(ZILLACORE_DIR, "tmp", "zoho", "triage")
- ZOHO_TRIAGE_PROMPT =
<<~PROMPT You are triaging a support email. Decide whether this email needs a Fizzy card or not. ## Email **From:** {{FROM}} **To:** {{TO}} **Subject:** {{SUBJECT}} **Body:** ``` {{BODY}} ``` ## Decision Criteria **Needs a card** (something is broken, a bug report, a feature request, a workflow issue): - Create a card with a clear title summarizing the issue - Tag with `support` plus a project tag if you can identify the relevant project - Assign to the appropriate agent **Does NOT need a card** (account questions, password resets, general inquiries, spam, marketing): - Just explain why briefly **Borderline** (you're not sure): - Mark as borderline and explain why — a human will decide ## Project Tags (use the tag name, not the ID) {{PROJECT_TAGS}} ## Agent Assignment {{AGENT_ASSIGNMENT}} ## Response Format Write ONLY valid JSON to stdout (no markdown, no explanation outside the JSON): For "needs a card": ```json { "decision": "create_card", "title": "Brief descriptive title for the card", "description": "HTML description with relevant details from the email", "project_tag": "project-tag-name or null", "assign_to": "Galen|Avon|Sheogorath" } ``` For "does not need a card": ```json { "decision": "skip", "reason": "Brief explanation of why no card is needed" } ``` For "borderline": ```json { "decision": "borderline", "reason": "Why you're unsure — what makes this ambiguous" } ``` PROMPT
- ZOHO_TOKEN_URL =
Zoho Mail REST API client for fetching email content. Used when webhook payloads don’t include the body (which is most of the time).
Requires zoho.json to have:
"api": { "client_id": "...", "client_secret": "...", "refresh_token": "...", "account_id": "..." } "https://accounts.zoho.com/oauth/v2/token"- ZOHO_MAIL_API_BASE =
"https://mail.zoho.com/api/accounts"- REPO_LAST_FETCH =
— Debounced repo git fetch — Avoids fetching the same repo multiple times within a short window (e.g. rapid card assignments). Uses fetch instead of checkout+pull so the main repo’s working tree is never touched —worktrees branch from origin/<default> directly, avoiding conflicts with local changes.
{}
- REPO_FETCH_DEBOUNCE =
5 minutes
300- DEFAULT_UAT_COLUMN_ID =
Fallback column ID for backwards compatibility when no board config exists
"03fsmglsr6az06ppyotawsti8"- DISCORD_CONFIG_FILE =
File.join(ZILLACORE_DIR, "discord.json")
- DISCORD_API_BASE =
"https://discord.com/api/v10"- DISCORD_GATEWAY_URL =
"wss://gateway.discord.gg/?v=10&encoding=json"- DISCORD_DRAFT_DIR =
Draft/posted directories for resilient Discord response delivery. Response files land in draft/ with a .meta.json sidecar containing delivery info. After successful posting, both files move to posted/. A poller thread recovers orphaned drafts (e.g. after a server restart).
File.join(ZILLACORE_DIR, "tmp", "discord", "draft")
- DISCORD_POSTED_DIR =
File.join(ZILLACORE_DIR, "tmp", "discord", "posted")
- DISCORD_BOTS =
Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
{}
- DISCORD_BOTS_MUTEX =
Mutex.new
- DISCORD_ALL_READY_LOGGED =
{ done: false }
- DISCORD_SHARED_THREADS =
Shared thread map: when multiple agents are mentioned in the same message, the first to deliver creates the thread and stores its ID here so the rest post into the same thread instead of creating duplicates. Key: original message_id, Value: thread channel ID
{}
- DISCORD_SHARED_THREADS_MUTEX =
Mutex.new
- ZILLACORE_RESTART_STATE =
Zillacore restart queue: when an agent works on zillacore itself, queue a restart instead of doing it immediately. A background thread checks every 30s and only restarts when no other agents are running, preventing mid-session kills. Using a hash instead of a constant to allow mutation inside synchronize blocks
{ queued: false, triggered_by: nil }
- ZILLACORE_RESTART_MUTEX =
Mutex.new
- DISCORD_CONFIG =
load_discord_config- RESERVED_EMOJIS =
Emojis reserved for zillacore functionality — not treated as feedback
%w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
- DISCORD_DRAFT_POLLER_INTERVAL =
Poller thread: scans draft/ for orphaned response files and delivers them. Runs every 5 seconds. Only attempts delivery if the response file exists (meaning the agent finished) and the meta file is at least 30 seconds old (giving the monitoring thread a chance to handle it first).
5- DISCORD_DRAFT_MIN_AGE =
seconds
30
Instance Method Summary collapse
-
#add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) ⇒ Object
Add a new cron job.
- #add_discord_reaction(channel_id, message_id, emoji, token:) ⇒ Object
- #add_trust_tools!(cmd, agent_cli_args) ⇒ Object
- #agent_dispatch_allowed?(card_internal_id) ⇒ Boolean
-
#agent_env_for(agent_name) ⇒ Object
Get the env hash for an agent.
-
#agent_env_var(agent_name, var_name) ⇒ Object
Get a specific env var for an agent.
- #agent_name_for(project_config) ⇒ Object
- #agent_roster ⇒ Object
-
#ai_agents ⇒ Object
Get all AI agents.
- #all_agent_names ⇒ Object
- #already_processed?(event_id) ⇒ Boolean
- #any_agents_running? ⇒ Boolean
-
#append_fizzy_comment_footer(card_number, project_config:, agent_name: nil) ⇒ Object
Append an italic PR/branch footer to the agent’s most recent Fizzy comment.
-
#apply_worktree_includes(repo_path, worktree_path) ⇒ Object
Copy gitignored files matching .worktreeinclude patterns from repo to worktree.
-
#archive_session(card_key, info) ⇒ Object
Archive a completed session for menu bar display.
- #archive_skill(skill_path) ⇒ Object
-
#assign_zoho_triage_card(card_number, agent_name, spawn_env) ⇒ Object
Assign a card to the appropriate agent.
- #authorized?(payload) ⇒ Boolean
-
#auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:) ⇒ Object
Auto-deploy after agent session when [deploy] tag was present.
-
#auto_inject_skills(search_context) ⇒ Object
Semantically match skills against the current task context and auto-inject their full content.
-
#available_environments(project: nil) ⇒ Object
Return environments with status “available”, optionally filtered by project.
- #board_column_id(board_key, column_name) ⇒ Object
- #board_config(board_key) ⇒ Object
-
#board_key_for_id(board_id) ⇒ Object
Find board_key by board_id (from .fizzy.yaml or payload).
-
#board_key_for_project(project_config) ⇒ Object
Determine board_key for a project by reading its .fizzy.yaml.
- #board_webhook_secret(board_key) ⇒ Object
-
#brain_git_repo? ⇒ Boolean
— Brain git sync —.
-
#brain_pull(force: false) ⇒ Object
Pull latest brain changes.
-
#brain_pull_internal(force: false) ⇒ Object
Internal pull logic without mutex (for use inside synchronized blocks).
-
#brain_push(message: "brain update", retries: 3) ⇒ Object
Commit and push any brain changes.
-
#build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil) ⇒ Object
Build the CLI command array for an agent invocation.
- #build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) ⇒ Object
-
#build_cron_agent_cmd(job, project) ⇒ Object
Build the CLI command array for a cron agent invocation.
-
#build_cron_prompt(job, project) ⇒ Object
Build the prompt content and meta files for a cron job.
-
#build_proc_children_map ⇒ Object
Build a ppid→children map from /proc in one pass.
-
#build_skill_index ⇒ Object
Build a compact skill index for prompt injection.
-
#canonical_name_for(identifier) ⇒ Object
Get canonical name for a platform-specific identifier.
- #card_merged?(card_number) ⇒ Boolean
- #check_zillacore_restart(head_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object
-
#check_zillacore_version ⇒ Object
Check if local zillacore is behind origin/master.
-
#child_processes_for(pid) ⇒ Object
Recursively collect all descendant processes of a given PID via /proc.
-
#cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) ⇒ Object
Clean up all worktrees associated with a card: the primary worktree and any cross-agent review worktrees (e.g. glados-fizzy-123-*, threepio-fizzy-123-*).
-
#clear_deployment_for_card(card_number) ⇒ Object
Clear all environments occupied by a given card number (called on PR merge).
-
#clone_branch_for_deploy(eventable, card_internal_id, card_info) ⇒ Object
Clone a remote branch locally for deploy when the worktree doesn’t exist on this machine.
- #comment_from_agent?(name) ⇒ Boolean
-
#convert_meridiem_hour(hour, meridiem) ⇒ Object
Convert 12-hour format hour + meridiem to 24-hour format.
- #create_discord_thread(channel_id, message_id, name:, token:) ⇒ Object
- #create_forum_post(channel_id, title:, content:, token:) ⇒ Object
-
#create_zoho_triage_card(decision, email, channel_id, token) ⇒ Object
Create a Fizzy card from the triage decision.
-
#cron_loop ⇒ Object
Cron loop — runs every minute, checks all jobs.
-
#cron_matches?(cron_hash, time = Time.now) ⇒ Boolean
Check if current time matches cron expression.
-
#curate_skills ⇒ Object
Run the curator: archive stale skills, log consolidation candidates.
- #debounced_repo_fetch(repo_path) ⇒ Object
- #default_fizzy_env ⇒ Object
- #default_project_config ⇒ Object
- #default_project_key ⇒ Object
-
#deliver_discord_draft(response_file, meta_file) ⇒ Object
— Discord Draft Delivery — Shared logic for posting a draft response file to Discord and moving it to posted/.
-
#deliver_script_output(job, log_file, draft_file) ⇒ Object
Read script output and write to draft file or log.
-
#deploy_to_environment(env_key, worktree_path:, deployed_by: nil) ⇒ Object
Mark an environment as occupied.
-
#deployment_status ⇒ Object
Full deployment status for API / waybar.
-
#detect_effort(project_config, tags: [], text: "") ⇒ Object
Detect effort level from inline tags [effort:high] or Fizzy card tags (effort-high).
- #detect_mentioned_agent(text) ⇒ Object
- #detect_mentioned_user_ids(text) ⇒ Object
- #detect_model(project_config, tags: [], text: "") ⇒ Object
-
#detect_planning_mode(text:, tags: [], card_internal_id: nil, card_number: nil) ⇒ Object
Detect if a message/card should trigger planning mode.
-
#detect_skill_candidate(log_file) ⇒ Object
Analyze an agent session log to determine if a skill should be extracted.
-
#discord_api(method, path, token:, body: nil, log_errors: true) ⇒ Object
— Discord REST API —.
-
#discord_bot_tokens ⇒ Object
Collect all agent Discord bot tokens from the registry.
-
#discord_bots_status ⇒ Object
Summary of all bot statuses for the API endpoint.
-
#discord_mention_roster ⇒ Object
Build a Discord mention roster so the agent can @mention people and other bots.
- #discover_kiro_agents ⇒ Object
-
#dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:, model:, agent_name:, comment_id:, eventable:, deploy_intent: nil) ⇒ Object
Dispatch a follow-up comment to the agent.
-
#dispatch_zoho_triage(email, rule) ⇒ Object
Dispatch an agent to triage a support email.
-
#ensure_fizzy_yaml!(chdir, project_config) ⇒ Object
Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
-
#execute_cron_job(job) ⇒ Object
Execute a cron job (dispatch agent).
-
#execute_script_job(job, project) ⇒ Object
Execute a script-based cron job (no agent, direct script execution).
-
#execute_zoho_triage_decision(decision, email, rule) ⇒ Object
Act on the triage decision.
-
#extract_crash_snippet(log_file, max_lines: 20) ⇒ Object
Extract the last N meaningful lines from an agent log for crash reporting.
-
#extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Extract agent response from log if the response file wasn’t written directly.
-
#extract_text_from_mime(mime) ⇒ Object
Extract readable text from raw MIME content.
- #extract_topics(card_title, comment_body, project_key) ⇒ Object
-
#fetch_card_comments(card_number, repo_path:, env:) ⇒ Object
Fetch recent comments for a card.
-
#fetch_card_details(card_number, repo_path:, env:) ⇒ Object
Fetch card details from Fizzy.
- #fetch_channel_info(channel_id, token:) ⇒ Object
- #fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10) ⇒ Object
- #fetch_discord_message(channel_id, message_id, token:, log_errors: true) ⇒ Object
- #fetch_guild_member(guild_id, user_id, token:) ⇒ Object
-
#fetch_pr_review_comments(pr_number, repo) ⇒ Object
Fetch review comments from a PR using GitHub CLI.
-
#fetch_zoho_email_content(message_id) ⇒ Object
Fetch email content by messageId using the “original message” endpoint.
- #file_changed?(path, force: false) ⇒ Boolean
-
#finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:) ⇒ Object
Generate plan markdown from memory Q&A and create Fizzy steps.
-
#find_card_by_branch(branch) ⇒ Object
Find a Fizzy card by matching the PR’s head branch to a branch in the card map.
- #find_latest_forum_thread(channel_id, token:) ⇒ Object
- #find_project_for_discord_channel(channel_id) ⇒ Object
-
#find_root_message(message, channel_id, bot_token) ⇒ Object
Find the root message for a conversation thread.
-
#find_supersedable_session(supersede_key) ⇒ Object
Find an active session for the same supersede key (agent+channel) started within the window.
-
#find_user(identifier) ⇒ Object
Find user by any identifier (tries all platforms).
-
#find_user_by_canonical_name(name) ⇒ Object
Find user by canonical name.
-
#find_user_by_discord_id(user_id) ⇒ Object
Find user by Discord user ID.
-
#find_user_by_discord_username(username) ⇒ Object
Find user by Discord username.
-
#find_user_by_fizzy_username(username) ⇒ Object
Find user by Fizzy username.
-
#find_user_by_github_username(username) ⇒ Object
Find user by GitHub username.
- #fizzy_display_name(agent_name) ⇒ Object
-
#fizzy_env_for(agent_name) ⇒ Object
Convenience: build env hash for fizzy CLI calls (backward compat).
-
#fizzy_token_for(agent_name) ⇒ Object
Convenience: get the Fizzy token for an agent.
-
#format_zoho_notification(email, rule) ⇒ Object
Format a Discord notification for a matched email.
- #forum_channel?(channel_id, token:) ⇒ Boolean
- #get_default_branch(repo_path) ⇒ Object
- #github_webhook_secret ⇒ Object
- #handle_agent_completion(**ctx) ⇒ Object
- #handle_card_assigned(payload) ⇒ Object
-
#handle_card_published(payload) ⇒ Object
— Card duplicate detection (card_published / card_triaged) —.
- #handle_comment(payload) ⇒ Object
-
#handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Handle post-execution: extract response from log, update job state.
-
#handle_deploy_comment(eventable, env_key, card_internal_id) ⇒ Object
Deploy a card’s worktree to a dev environment via comment shortcut.
-
#handle_discord_message(message, agent_key, bot_token, bot_user_id) ⇒ Object
Handle an incoming Discord message for a specific agent bot.
-
#handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id) ⇒ Object
— Discord Reaction Handler — Handles MESSAGE_REACTION_ADD events.
- #handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move) ⇒ Object
- #handle_github_issue_comment(payload) ⇒ Object
- #handle_github_issue_opened(payload) ⇒ Object
- #handle_github_pr_merged(payload) ⇒ Object
- #handle_github_pr_review_submitted(payload) ⇒ Object
-
#handle_github_pr_synchronized(payload) ⇒ Object
Auto-deploy when a PR gets new commits (synchronize event) if the card is already on a dev env.
- #handle_github_workflow_run(payload) ⇒ Object
- #handle_plan_finalization(prompt_file, agent_name, project_config) ⇒ Object
- #human_mentioned?(user_id) ⇒ Boolean
-
#human_users ⇒ Object
Get all human users (exclude AI agents).
- #identify_project_by_repo(repo_full_name) ⇒ Object
- #identify_project_by_tags(tags) ⇒ Object
-
#kill_session(session_key) ⇒ Object
Kill a session’s process.
-
#load_agent_registry ⇒ Object
Agent registry, discovery, identity, mention detection, and env injection.
- #load_card_map ⇒ Object
-
#load_cron_jobs ⇒ Object
Load cron jobs from config.
- #load_deployment_state ⇒ Object
- #load_deployments_config ⇒ Object
- #load_discord_config ⇒ Object
- #load_fizzy_config ⇒ Object
- #load_github_config ⇒ Object
-
#load_projects_config ⇒ Object
— Projects —.
- #load_user_registry ⇒ Object
- #load_zoho_config ⇒ Object
-
#local_agent_names ⇒ Object
Agents marked “local”: true in the registry — only these should pick up card assignments on this machine.
-
#log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token) ⇒ Object
— Emoji Feedback Logging — Logs non-reserved emoji reactions on bot messages to the agent’s persona feedback file.
- #mark_card_merged(card_number) ⇒ Object
-
#mark_deploying(env_key, worktree_path:) ⇒ Object
Mark an environment as actively deploying (in-progress state for waybar).
- #match_field?(pattern, value) ⇒ Boolean
-
#match_skills_semantically(search_context, skills) ⇒ Object
Use qmd semantic search to find skills whose descriptions match the current context.
-
#match_zoho_rule(email) ⇒ Object
Match an email against configured rules.
- #memory_dir_for(agent_name) ⇒ Object
- #move_card_to_column(card_number, column_name, project_config:, agent_name: nil) ⇒ Object
-
#notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) ⇒ Object
Notify the originating channel that an agent crashed.
- #notify_unauthorized(action, creator_name, card_info) ⇒ Object
-
#notify_zoho_match(email, rule) ⇒ Object
Send the notification to the configured Discord channel.
- #on_comment_cooldown?(card_key) ⇒ Boolean
- #on_deploy_cooldown?(env_key) ⇒ Boolean
-
#owner_discord_id ⇒ Object
Discord user ID of the machine owner (for version-outdated notifications).
-
#parse_cron_expression(expr) ⇒ Object
Parse cron expression (simplified: supports minute, hour, day, month, weekday) Format: “minute hour day month weekday” (e.g., “0 9 * * 1-5” = 9am weekdays) Also supports special strings: @hourly, @daily, @weekly, @monthly Also supports one-time timestamps: ISO8601 format (e.g., “2026-02-27T09:00:00-05:00”) Also supports natural language: “tomorrow at 9am”, “in 2 hours”, “next monday at 3pm”.
-
#parse_natural_time(expr) ⇒ Object
Parse natural language time expressions into absolute timestamps.
-
#parse_skill_frontmatter(path) ⇒ Object
Parse YAML frontmatter from a SKILL.md file.
-
#parse_time_of_day(time_str, date) ⇒ Object
Parse time of day (e.g., “9am”, “3:30pm”, “14:00”) and combine with a date.
- #parse_triage_json(content) ⇒ Object
- #persona_collection_for(agent_name) ⇒ Object
- #persona_dir_for(agent_name) ⇒ Object
-
#planning_complete?(card_id, agent_name) ⇒ Boolean
Check if planning is complete for a given card.
-
#pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env) ⇒ Boolean
Check if a PR link is already present in the card’s comments.
- #prefetch_card_context(card_number, repo_path:, agent_name: nil) ⇒ Object
-
#prepare_script_discord_draft(job, timestamp) ⇒ Object
Prepare a Discord draft file and meta for a script job.
- #query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) ⇒ Object
- #queue_zillacore_restart(agent_name) ⇒ Object
-
#read_proc_cmdline(pid) ⇒ Object
Read command line for a given PID from /proc.
-
#read_proc_elapsed(pid) ⇒ Object
Calculate elapsed seconds since process start from /proc/stat.
-
#read_zoho_triage_response(response_file, log_file) ⇒ Object
Read the triage response — try the response file first, then extract from log.
- #recently_completed?(card_key, window: 120) ⇒ Boolean
- #record_agent_dispatch(card_internal_id) ⇒ Object
-
#record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "") ⇒ Object
Record a failed deploy — saves output to a log file and updates state.
- #record_human_comment(card_internal_id) ⇒ Object
- #record_self_move(card_number) ⇒ Object
-
#record_skill_index_views ⇒ Object
Batch-record views for all skills in the index (called when prompt is built).
-
#record_skill_usage(skill_path, type: :view) ⇒ Object
Record a view (skill index shown in prompt) or use (agent read the full skill).
- #register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ⇒ Object
- #reload_agent_registry!(force: false) ⇒ Object
-
#reload_cron_jobs!(force: false) ⇒ Object
Reload cron jobs from disk.
- #reload_deployment_state!(force: false) ⇒ Object
- #reload_deployments_config!(force: false) ⇒ Object
- #reload_discord_config! ⇒ Object
- #reload_github_config!(force: false) ⇒ Object
- #reload_projects!(force: false) ⇒ Object
- #reload_user_registry!(force: false) ⇒ Object
- #reload_zoho_config!(force: false) ⇒ Object
-
#remove_cron_job(id) ⇒ Object
Remove a cron job.
- #remove_discord_reaction(channel_id, message_id, emoji, token:) ⇒ Object
-
#render_planning_prompt(situation_template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) ⇒ Object
Render planning mode prompt with appropriate channel rules.
- #render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) ⇒ Object
- #resolve_card_number(internal_id, repo_path:) ⇒ Object
-
#resolve_deploy_environment(deploy_intent, state, card_number) ⇒ Object
Resolve which environment to deploy to from the intent.
-
#resolve_deployment_url(env_config, card_tags) ⇒ Object
Resolve the correct URL for an environment based on card tags.
-
#resolve_effort_level(level, allowed) ⇒ Object
If a level isn’t in allowed_efforts, return the closest lower level.
-
#resolve_project_cli_config(project_config) ⇒ Object
Resolve CLI config for a project by merging provider defaults with project overrides.
-
#resolve_zoho_triage_tags(tag_names) ⇒ Object
Resolve tag names to IDs by querying Fizzy.
-
#retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object
Retry deploy after clearing terraform lock.
- #rule_defaults(rule) ⇒ Object
- #run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil, source: nil, source_context: {}, skip_column_move: false) ⇒ Object
- #run_cmd(*cmd, chdir:, env: {}) ⇒ Object
-
#run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object
Execute deploy script with terraform lock retry logic.
-
#run_project_hook(repo_path, hook_name, extra_env: {}) ⇒ Object
Run a project-level hook script from .zillacore/<hook_name> if it exists.
- #save_card_map(map) ⇒ Object
-
#save_cron_jobs(jobs) ⇒ Object
Save cron jobs to config.
- #save_deployment_state(state) ⇒ Object
- #save_zoho_hook_secret(secret) ⇒ Object
- #scrub_invalid_attachments!(dir) ⇒ Object
- #self_move_recent?(card_number, window: 120) ⇒ Boolean
- #send_deploy_notification(project_key, closed_cards) ⇒ Object
- #send_discord_message(channel_id, content, token:, reply_to: nil) ⇒ Object
- #send_discord_typing(channel_id, token:) ⇒ Object
- #send_long_discord_message(channel_id, content, token:, reply_to: nil) ⇒ Object
-
#send_restart_notification(message) ⇒ Object
Send a Discord notification about zillacore restart/startup using any available bot token.
- #send_uat_deploy_notification(project_key) ⇒ Object
- #send_workflow_failure_notification(project_key, workflow_name, run_url) ⇒ Object
- #session_active?(card_key) ⇒ Boolean
-
#skill_index_for_prompt ⇒ Object
Build the skill index section for prompt injection.
- #skill_usage_path(skill_path) ⇒ Object
- #slugify(title, max_length: 40) ⇒ Object
-
#start_all_discord_gateways ⇒ Object
Start all per-agent Discord bots.
-
#start_cron_thread ⇒ Object
Start cron background thread.
-
#start_discord_draft_poller ⇒ Object
seconds — don’t race the monitoring thread.
-
#start_discord_gateway_for(agent_key, bot_token) ⇒ Object
— Discord Gateway (one per agent bot) —.
- #start_zillacore_restart_monitor ⇒ Object
-
#terraform_lock_error?(stdout, stderr) ⇒ Boolean
Detect Terraform provider lock file checksum mismatch errors.
-
#toggle_cron_job(id, enabled) ⇒ Object
Enable/disable a cron job.
- #touch_comment_cooldown(card_key) ⇒ Object
- #touch_deploy_cooldown(env_key) ⇒ Object
-
#track_pr_in_card_map(payload) ⇒ Object
Track a newly opened PR in the card map by matching its branch.
-
#trust_version_manager(path, chdir:) ⇒ Object
Trust the version manager config in a directory (supports mise and asdf).
- #uat_column_id(project_config) ⇒ Object
-
#update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil) ⇒ Object
Update a cron job’s schedule, discord channel, and/or forum title.
-
#update_cron_job_state(job) ⇒ Object
Update cron job state after execution (last_run, execution_count, auto-disable).
- #verify_github_signature!(request, payload_body) ⇒ Object
- #verify_signature!(request, payload_body, board_key: nil) ⇒ Object
-
#verify_zoho_signature!(request, payload_body) ⇒ Object
Verify the X-Hook-Signature header (base64 HMAC-SHA256 of the raw body).
- #wait_for_session?(card_key) ⇒ Boolean
-
#write_agent_prompt_file(prompt, log_name, timestamp) ⇒ Object
Write agent prompt to a temp file, return path.
-
#write_cron_prompt_file(job, prompt_content, timestamp) ⇒ Object
Write cron prompt to a temp file, return path.
- #zoho_access_token ⇒ Object
- #zoho_api_configured? ⇒ Boolean
-
#zoho_email_excluded?(email, exclude_words) ⇒ Boolean
Check if an email contains any of the exclude words (checked against subject, from, and body).
-
#zoho_fallback_rule ⇒ Object
Returns the fallback rule config, or nil if not configured.
-
#zoho_hook_secret ⇒ Object
Zoho sends the signing secret in the X-Hook-Secret header on the very first request.
- #zoho_refresh_access_token! ⇒ Object
- #zoho_triage_agent_assignment ⇒ Object
- #zoho_triage_project_tags ⇒ Object
Instance Method Details
#add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) ⇒ Object
Add a new cron job
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/zillacore/cron.rb', line 202 def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: false, repeat_count: nil) parsed = parse_cron_expression(schedule) return { error: "Invalid cron expression" } unless parsed return { error: "Must provide either prompt or script, not both" } if prompt && script return { error: "Must provide either prompt or script" } unless prompt || script job = { id: id, schedule: schedule, parsed: parsed, agent: agent, project: project, model: model, effort: effort, prompt: prompt, script: script, enabled: enabled, discord_channel_id: discord_channel_id, forum_title: forum_title, forum_reply_to_latest: forum_reply_to_latest, repeat_count: repeat_count, execution_count: 0, created_at: Time.now.iso8601, last_run: nil } CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job end { success: true, job: job } end |
#add_discord_reaction(channel_id, message_id, emoji, token:) ⇒ Object
289 290 291 292 |
# File 'lib/zillacore/handlers/discord.rb', line 289 def add_discord_reaction(channel_id, , emoji, token:) encoded = URI.encode_www_form_component(emoji) discord_api(:put, "/channels/#{channel_id}/messages/#{}/reactions/#{encoded}/@me", token: token) end |
#add_trust_tools!(cmd, agent_cli_args) ⇒ Object
12 13 14 15 16 |
# File 'lib/zillacore/helpers.rb', line 12 def add_trust_tools!(cmd, agent_cli_args) return if agent_cli_args.include?("--trust-tools") cmd.push("--trust-tools", TRUSTED_TOOLS) end |
#agent_dispatch_allowed?(card_internal_id) ⇒ Boolean
267 268 269 270 271 272 273 |
# File 'lib/zillacore/sessions.rb', line 267 def agent_dispatch_allowed?(card_internal_id) info = AGENT_DISPATCH_DEPTH[card_internal_id] return false unless info return false if (Time.now - info[:last_human_at]) > AGENT_DISPATCH_WINDOW info[:count] < AGENT_DISPATCH_MAX_DEPTH end |
#agent_env_for(agent_name) ⇒ Object
Get the env hash for an agent. Returns {} if none configured.
81 82 83 84 85 86 87 88 89 |
# File 'lib/zillacore/agents.rb', line 81 def agent_env_for(agent_name) return {} unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return {} unless entry.is_a?(Hash) entry["env"] || {} end |
#agent_env_var(agent_name, var_name) ⇒ Object
Get a specific env var for an agent. Returns nil if not set.
92 93 94 |
# File 'lib/zillacore/agents.rb', line 92 def agent_env_var(agent_name, var_name) agent_env_for(agent_name)[var_name] end |
#agent_name_for(project_config) ⇒ Object
137 138 139 |
# File 'lib/zillacore/agents.rb', line 137 def agent_name_for(project_config) project_config["agent_name"] || AI_AGENT_NAME end |
#agent_roster ⇒ Object
122 123 124 125 126 |
# File 'lib/zillacore/agents.rb', line 122 def agent_roster roster = {} all_agent_names.each { |name| roster[name.downcase] = fizzy_display_name(name) } roster end |
#ai_agents ⇒ Object
Get all AI agents
74 75 76 |
# File 'lib/zillacore/users.rb', line 74 def ai_agents USER_REGISTRY["users"].select { |u| u["notes"]&.include?("AI agent") } end |
#all_agent_names ⇒ Object
141 142 143 144 145 146 147 148 149 150 |
# File 'lib/zillacore/agents.rb', line 141 def all_agent_names names = Set.new([AI_AGENT_NAME]) PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] } discover_kiro_agents.each { |name| names << name.capitalize } # Include agents from the registry (with their fizzy_name if specified) AGENT_REGISTRY.each do |key, entry| names << (entry["fizzy_name"] || key.capitalize) end names end |
#already_processed?(event_id) ⇒ Boolean
10 11 12 13 14 15 16 17 18 19 20 |
# File 'lib/zillacore/sessions.rb', line 10 def already_processed?(event_id) return false unless event_id return true if PROCESSED_EVENTS[event_id] PROCESSED_EVENTS[event_id] = Time.now if PROCESSED_EVENTS.size > PROCESSED_EVENTS_MAX oldest = PROCESSED_EVENTS.keys.first(PROCESSED_EVENTS.size - PROCESSED_EVENTS_MAX) oldest.each { |k| PROCESSED_EVENTS.delete(k) } end false end |
#any_agents_running? ⇒ Boolean
68 69 70 71 72 73 74 75 76 77 |
# File 'lib/zillacore/handlers/discord.rb', line 68 def any_agents_running? ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS.any? do |_key, info| Process.kill(0, info[:pid]) true rescue Errno::ESRCH, Errno::EPERM false end end end |
#append_fizzy_comment_footer(card_number, project_config:, agent_name: nil) ⇒ Object
Append an italic PR/branch footer to the agent’s most recent Fizzy comment.
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 |
# File 'lib/zillacore/helpers.rb', line 444 def (card_number, project_config:, agent_name: nil) repo_path = project_config["repo_path"] project_config["github_repo"] env = fizzy_env_for(agent_name) # Find branch and tracked PRs from card_map card_map = load_card_map card_info = card_map.values.find { |v| v["number"] == card_number } branch = card_info&.dig("branch") return unless branch prs = card_info&.dig("prs") || [] # Build footer parts parts = [] parts << "Branch: <code>#{branch}</code>" prs.each { |pr| parts << "PR: <a href=\"#{pr["url"]}\">##{pr["number"]}</a>" } return if parts.empty? = "<p style=\"margin-top:12px;font-size:0.85em;color:#888;\"><em>#{parts.join(" · ")}</em></p>" # Find agent's most recent comment begin output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env) comments = (JSON.parse(output)["data"] || []).reverse agent_display = fizzy_display_name(agent_name) comment = comments.find { |c| c.dig("creator", "name") == agent_display && c.dig("body", "html")&.include?("<") } return unless comment existing_html = comment.dig("body", "html") || "" # Don't double-append if footer already present return if existing_html.include?("Branch: <code>#{branch}</code>") # Strip Fizzy's outer wrapper — it re-wraps on update inner = existing_html.sub(/\A\s*<div class="action-text-content">\s*/m, "").sub(%r{\s*</div>\s*\z}m, "") updated_html = "#{inner}\n#{}" run_cmd("fizzy", "comment", "update", comment["id"], "--card", card_number.to_s, "--body", updated_html, chdir: repo_path, env: env) LOG.info "[Footer] Appended PR/branch footer to comment #{comment["id"]} on card ##{card_number}" rescue StandardError => e LOG.warn "[Footer] Could not append footer to card ##{card_number}: #{e.}" end end |
#apply_worktree_includes(repo_path, worktree_path) ⇒ Object
Copy gitignored files matching .worktreeinclude patterns from repo to worktree. Symlink directories matching .worktreelink patterns instead of copying. Both files use .gitignore syntax. Only gitignored files/dirs are processed.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/zillacore/helpers.rb', line 75 def apply_worktree_includes(repo_path, worktree_path) copied = 0 linked = 0 [".worktreeinclude", ".worktreelink"].each do |filename| config_file = File.join(repo_path, filename) next unless File.exist?(config_file) symlink_mode = filename == ".worktreelink" patterns = File.readlines(config_file).map(&:strip).reject { |l| l.empty? || l.start_with?("#") } next if patterns.empty? patterns.each do |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: repo_path).each do |match| src = File.join(repo_path, match) dest = File.join(worktree_path, match) next if File.exist?(dest) || File.symlink?(dest) # Only process gitignored files/dirs _, _, st = Open3.capture3("git", "check-ignore", "-q", match, chdir: repo_path) next unless st.success? FileUtils.mkdir_p(File.dirname(dest)) if symlink_mode && File.directory?(src) FileUtils.ln_s(src, dest) linked += 1 LOG.info "Symlinked #{match} from main repo" elsif File.file?(src) FileUtils.cp(src, dest) copied += 1 end end end end LOG.info "Worktree include: copied #{copied} file(s), symlinked #{linked} dir(s) for #{worktree_path}" if copied.positive? || linked.positive? end |
#archive_session(card_key, info) ⇒ Object
Archive a completed session for menu bar display. Call inside ACTIVE_SESSIONS_MUTEX.
46 47 48 49 50 51 52 53 |
# File 'lib/zillacore/sessions.rb', line 46 def archive_session(card_key, info) RECENT_SESSIONS.unshift({ card_key: card_key, agent_name: info[:agent_name], log_file: info[:log_file], started_at: info[:started_at], finished_at: Time.now }) RECENT_SESSIONS.pop while RECENT_SESSIONS.size > RECENT_SESSIONS_MAX end |
#archive_skill(skill_path) ⇒ Object
264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/zillacore/skills.rb', line 264 def archive_skill(skill_path) skill_dir = File.dirname(skill_path) skill_name = File.basename(skill_dir) archive_dest = File.join(SKILL_ARCHIVE_DIR, skill_name) FileUtils.mkdir_p(archive_dest) FileUtils.mv(Dir.glob(File.join(skill_dir, "*")), archive_dest) FileUtils.rmdir(skill_dir) if Dir.empty?(skill_dir) LOG.info "[Curator] Archived skill: #{skill_name}" rescue StandardError => e LOG.warn "[Curator] Failed to archive #{skill_path}: #{e.}" end |
#assign_zoho_triage_card(card_number, agent_name, spawn_env) ⇒ Object
Assign a card to the appropriate agent
462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 |
# File 'lib/zillacore/handlers/zoho.rb', line 462 def assign_zoho_triage_card(card_number, agent_name, spawn_env) # Map agent names to Fizzy user IDs agent_user_ids = { "Galen" => "03fja52opiykf0mua7aeqv8uk", "Avon" => "03fnwe6kl4g2t8xw0djbfkv96", "Sheogorath" => "03fnwjyt6gighy98ld46u2hni" } user_id = agent_user_ids[agent_name] unless user_id LOG.warn "[Zoho:Triage] Unknown agent for assignment: #{agent_name}" return end output, status = Open3.capture2e(spawn_env, "fizzy", "card", "assign", card_number.to_s, "--user", user_id) if status.success? LOG.info "[Zoho:Triage] Assigned card ##{card_number} to #{agent_name}" else LOG.warn "[Zoho:Triage] Failed to assign card ##{card_number}: #{output}" end end |
#authorized?(payload) ⇒ Boolean
687 688 689 690 |
# File 'lib/zillacore/helpers.rb', line 687 def (payload) creator_id = payload.dig("creator", "id") AUTHORIZED_USER_IDS.include?(creator_id) end |
#auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:) ⇒ Object
Auto-deploy after agent session when [deploy] tag was present. deploy_intent is either a specific env key (e.g. “dev04”), :auto (auto-detect), or nil (no deploy).
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/zillacore/deployments.rb', line 122 def auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:) state = load_deployment_state config = DEPLOYMENTS_CONFIG["environments"] || {} env_key = resolve_deploy_environment(deploy_intent, state, card_number) return unless env_key unless config.key?(env_key) LOG.warn "[Deploy] Auto-deploy skipped — unknown environment: #{env_key}" return end env_owner = config[env_key]["owner"] unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase LOG.info "[Deploy] Auto-deploy skipped #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}" return end deploy_script = File.join(worktree_path, "scripts", "deploy.sh") unless File.exist?(deploy_script) LOG.warn "[Deploy] Auto-deploy skipped — no deploy script at #{deploy_script}" return end LOG.info "[Deploy] Auto-deploying card ##{card_number} to #{env_key} (triggered by [deploy] tag)" (env_key, worktree_path: worktree_path) deploy_env = {} aws_profile = config.dig(env_key, "aws_profile") deploy_env["AWS_PROFILE"] = aws_profile if aws_profile run_deploy(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name) end |
#auto_inject_skills(search_context) ⇒ Object
Semantically match skills against the current task context and auto-inject their full content. This is the skill:// equivalent — skills are loaded automatically when relevant, not manually. Returns a prompt section with full skill content for top matches, plus an index of remaining skills.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/zillacore/skills.rb', line 103 def auto_inject_skills(search_context) skills = build_skill_index return "" if skills.empty? return "" unless system("which qmd > /dev/null 2>&1") # Semantic search against skill descriptions to find relevant ones matched_paths = match_skills_semantically(search_context, skills) # Split into auto-injected (matched) and index-only (rest) injected = [] chars_used = 0 matched_paths.each do |path| content = File.read(path) break if chars_used + content.size > SKILL_AUTO_INJECT_MAX_CHARS skill = skills.find { |s| s[:path] == path } next unless skill injected << { name: skill[:name], description: skill[:description], content: content, path: path } chars_used += content.size end remaining = skills.reject { |s| injected.any? { |i| i[:path] == s[:path] } } sections = [] unless injected.empty? sections << "## Auto-Loaded Skills (matched to your current task)" sections << "These skills were automatically loaded because they're relevant to what you're working on.\n" injected.each do |skill| sections << "### Skill: #{skill[:name]}" sections << skill[:content] sections << "" record_skill_usage(skill[:path], type: :use) end end unless remaining.empty? sections << "## Other Available Skills" sections << "Additional skills not auto-loaded. Read the file if needed.\n" remaining.each { |s| sections << "- **#{s[:name]}**: #{s[:description]} (`#{s[:path]}`)" } sections << "" end sections.join("\n") end |
#available_environments(project: nil) ⇒ Object
Return environments with status “available”, optionally filtered by project.
225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/zillacore/deployments.rb', line 225 def available_environments(project: nil) config = DEPLOYMENTS_CONFIG["environments"] || {} state = load_deployment_state config.select do |env_key, env_config| next false if project && env_config["project"] != project info = state[env_key] info.nil? || info["status"] == "available" end.keys end |
#board_column_id(board_key, column_name) ⇒ Object
100 101 102 103 |
# File 'lib/zillacore/config.rb', line 100 def board_column_id(board_key, column_name) config = board_config(board_key) config&.dig("columns", column_name.to_s) end |
#board_config(board_key) ⇒ Object
91 92 93 |
# File 'lib/zillacore/config.rb', line 91 def board_config(board_key) FIZZY_BOARDS[board_key.to_s] end |
#board_key_for_id(board_id) ⇒ Object
Find board_key by board_id (from .fizzy.yaml or payload)
106 107 108 109 110 111 |
# File 'lib/zillacore/config.rb', line 106 def board_key_for_id(board_id) FIZZY_BOARDS.each do |key, config| return key if config["board_id"] == board_id end nil end |
#board_key_for_project(project_config) ⇒ Object
Determine board_key for a project by reading its .fizzy.yaml
114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/zillacore/config.rb', line 114 def board_key_for_project(project_config) fizzy_yaml = File.join(project_config["repo_path"], ".fizzy.yaml") return nil unless File.exist?(fizzy_yaml) require "yaml" data = YAML.safe_load_file(fizzy_yaml) board_id = data["board"] board_key_for_id(board_id) rescue StandardError => e LOG.warn "Could not read .fizzy.yaml for board lookup: #{e.}" nil end |
#board_webhook_secret(board_key) ⇒ Object
95 96 97 98 |
# File 'lib/zillacore/config.rb', line 95 def board_webhook_secret(board_key) config = board_config(board_key) config&.dig("webhook_secret") || FIZZY_WEBHOOK_SECRET end |
#brain_git_repo? ⇒ Boolean
— Brain git sync —
22 23 24 |
# File 'lib/zillacore/brain.rb', line 22 def brain_git_repo? File.directory?(File.join(BRAIN_BASE_DIR, ".git")) end |
#brain_pull(force: false) ⇒ Object
Pull latest brain changes. Safe to call frequently — skips if pulled recently. Uses rebase to keep history clean and auto-resolves conflicts by keeping both sides.
61 62 63 64 65 66 67 68 69 |
# File 'lib/zillacore/brain.rb', line 61 def brain_pull(force: false) return unless brain_git_repo? BRAIN_SYNC_MUTEX.synchronize do brain_pull_internal(force: force) end rescue StandardError => e LOG.warn "[Brain] Pull error: #{e.}" end |
#brain_pull_internal(force: false) ⇒ Object
Internal pull logic without mutex (for use inside synchronized blocks)
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/zillacore/brain.rb', line 27 def brain_pull_internal(force: false) return unless brain_git_repo? # Skip if we pulled within the last 30 seconds (avoid hammering on rapid-fire sessions) unless force last = BRAIN_LAST_PULL[:at] return if last && (Time.now - last) < 30 end # Stash any uncommitted changes, pull, then pop status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR) has_changes = !status.strip.empty? if has_changes Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR) Open3.capture2("git", "stash", chdir: BRAIN_BASE_DIR) end output, pull_status = Open3.capture2e("git", "pull", "--rebase", "--autostash", chdir: BRAIN_BASE_DIR) if pull_status.success? LOG.info "[Brain] Pulled latest changes" else LOG.warn "[Brain] Pull failed: #{output.strip}" # Abort rebase if it got stuck Open3.capture2("git", "rebase", "--abort", chdir: BRAIN_BASE_DIR) end Open3.capture2("git", "stash", "pop", chdir: BRAIN_BASE_DIR) if has_changes BRAIN_LAST_PULL[:at] = Time.now end |
#brain_push(message: "brain update", retries: 3) ⇒ Object
Commit and push any brain changes. Called after agent sessions complete.
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/zillacore/brain.rb', line 72 def brain_push(message: "brain update", retries: 3) return unless brain_git_repo? BRAIN_SYNC_MUTEX.synchronize do # Check for changes status, = Open3.capture2("git", "status", "--porcelain", chdir: BRAIN_BASE_DIR) return if status.strip.empty? Open3.capture2("git", "add", "-A", chdir: BRAIN_BASE_DIR) Open3.capture2("git", "commit", "-m", , chdir: BRAIN_BASE_DIR) retries.times do |attempt| brain_pull_internal(force: true) if attempt.positive? _, push_status = Open3.capture2e("git", "push", chdir: BRAIN_BASE_DIR) if push_status.success? LOG.info "[Brain] Pushed changes#{" (retry #{attempt})" if attempt.positive?}" break end sleep(2**attempt) if attempt < retries - 1 end LOG.warn "[Brain] Push failed after #{retries} attempts" end rescue StandardError => e LOG.warn "[Brain] Push error: #{e.}" end |
#build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil) ⇒ Object
Build the CLI command array for an agent invocation.
581 582 583 584 585 586 587 588 589 |
# File 'lib/zillacore/helpers.rb', line 581 def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil) cmd = [resolved["agent_cli"]] cmd.push("--agent", agent_config_name) if agent_config_name cmd.concat(resolved["agent_cli_args"].split) add_trust_tools!(cmd, resolved["agent_cli_args"]) cmd.push(resolved["agent_model_flag"], model) if resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty? && model cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort cmd end |
#build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/zillacore/brain.rb', line 137 def build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) Thread.new { brain_pull } topics = extract_topics(card_title, comment_body, project_key) primary_query = topics.first(5).join(" ") primary_query = "project conventions" if primary_query.empty? fizzy_mentioned = [card_title, comment_body].any? { |s| s&.match?(/fizzy/i) } fizzy_originated = source == :fizzy search_queries = [primary_query] knowledge_threads = [ Thread.new { query_brain(primary_query, scope: :knowledge, max_results: 3) }, Thread.new { query_brain(agent_name, scope: :knowledge, max_results: 2) } ] search_queries << agent_name if fizzy_mentioned || fizzy_originated knowledge_threads << Thread.new { query_brain("fizzy CLI commands", scope: :knowledge, max_results: 2) } search_queries << "fizzy CLI commands" end persona_thread = Thread.new { query_brain("personality tone voice communication style", agent_name: agent_name, scope: :persona, max_results: 5) } all_knowledge = knowledge_threads.map(&:value).reject(&:empty?) persona_result = persona_thread.value sections = [] unless persona_result.empty? sections << <<~PERSONA ## Brain — Persona (auto-retrieved, CRITICAL) The following is YOUR personality, communication style, and voice. You MUST use this to shape every response you write — tone, word choice, humor, attitude. This is who you ARE. Do not respond in a generic or neutral voice. #{persona_result} PERSONA end unless all_knowledge.empty? knowledge_text = all_knowledge.join("\n\n") sections << <<~BRAIN ## Brain — Knowledge (auto-retrieved for: #{search_queries.map { |q| %("#{q}") }.join(", ")}) The following is relevant technical knowledge from your long-term memory. These are project conventions, coding patterns, lessons learned, and decisions that past-you saved for exactly this kind of work. Use it to inform your implementation. If these results don't look relevant to your current task, search manually with better terms. #{knowledge_text} BRAIN end # Auto-inject skills: semantically match skills against current task context skill_search_context = [card_title, comment_body, primary_query].compact.reject(&:empty?).join(" ") skill_section = auto_inject_skills(skill_search_context) sections << skill_section unless skill_section.empty? sections.join("\n") end |
#build_cron_agent_cmd(job, project) ⇒ Object
Build the CLI command array for a cron agent invocation.
576 577 578 579 580 581 582 583 584 585 586 |
# File 'lib/zillacore/cron.rb', line 576 def build_cron_agent_cmd(job, project) agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-") resolved = resolve_project_cli_config(project) cmd = [resolved["agent_cli"]] cmd.push("--agent", agent_config_name) cmd.concat(resolved["agent_cli_args"].split) add_trust_tools!(cmd, resolved["agent_cli_args"]) cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model] cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort] cmd end |
#build_cron_prompt(job, project) ⇒ Object
Build the prompt content and meta files for a cron job
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 |
# File 'lib/zillacore/cron.rb', line 368 def build_cron_prompt(job, project) prompt = job[:prompt] agent_name = job[:agent] = Time.now.strftime("%Y%m%d-%H%M%S") if job[:discord_channel_id] draft_file = File.join(DISCORD_DRAFT_DIR, "cron-#{}-#{agent_name}-#{job[:id]}.md") = "#{draft_file}.meta.json" FileUtils.mkdir_p(File.dirname(draft_file)) agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") = { channel_id: job[:discord_channel_id], agent_key: agent_key, agent_name: agent_name, cron_job_id: job[:id], forum_title: job[:forum_title], forum_reply_to_latest: job[:forum_reply_to_latest], created_at: Time.now.iso8601 } File.write(, JSON.pretty_generate()) full_prompt = <<~PROMPT ## Scheduled Task (Discord Posting) This is a scheduled cron job that will post to Discord channel #{job[:discord_channel_id]}. You were asked to: "#{prompt}" Project: #{job[:project]} Source directory: #{project["repo_path"]} **IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.** Your response will be automatically posted to Discord. #{prompt} PROMPT { response_file: draft_file, meta_file: , full_prompt: full_prompt } else response_file = File.join(ZILLACORE_DIR, "tmp", "cron", "cron-#{job[:id]}-#{Time.now.to_i}.md") FileUtils.mkdir_p(File.dirname(response_file)) full_prompt = <<~PROMPT ## Scheduled Task This is a scheduled cron job. You were asked to: "#{prompt}" Project: #{job[:project]} Source directory: #{project["repo_path"]} Write your response to: #{response_file} #{prompt} PROMPT { response_file: response_file, meta_file: nil, full_prompt: full_prompt } end end |
#build_proc_children_map ⇒ Object
Build a ppid→children map from /proc in one pass.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/zillacore/sessions.rb', line 124 def build_proc_children_map children_map = Hash.new { |h, k| h[k] = [] } Dir.glob("/proc/[0-9]*/stat").each do |stat_path| content = begin File.read(stat_path) rescue StandardError next end close_paren = content.rindex(")") next unless close_paren prefix_pid = content[0...content.index("(")].strip.to_i fields_after = content[(close_paren + 2)..].split ppid = fields_after[1].to_i children_map[ppid] << prefix_pid end children_map end |
#build_skill_index ⇒ Object
Build a compact skill index for prompt injection. Returns array of { name:, description:, path: } from all SKILL.md files.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/zillacore/skills.rb', line 51 def build_skill_index skills = [] Dir.glob(File.join(SKILLS_DIR, "**", "SKILL.md")).each do |path| frontmatter = parse_skill_frontmatter(path) next unless frontmatter skills << { name: frontmatter["name"], description: frontmatter["description"], tags: frontmatter["tags"] || [], path: path } end skills end |
#canonical_name_for(identifier) ⇒ Object
Get canonical name for a platform-specific identifier
63 64 65 66 |
# File 'lib/zillacore/users.rb', line 63 def canonical_name_for(identifier) user = find_user(identifier) user ? user["canonical_name"] : identifier end |
#card_merged?(card_number) ⇒ Boolean
260 261 262 263 264 265 |
# File 'lib/zillacore/helpers.rb', line 260 def card_merged?(card_number) MERGED_CARDS_MUTEX.synchronize do ts = MERGED_CARDS[card_number.to_s] ts && (Time.now - ts < 600) end end |
#check_zillacore_restart(head_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object
675 676 677 678 679 680 681 682 683 684 685 |
# File 'lib/zillacore/helpers.rb', line 675 def check_zillacore_restart(head_before, chdir, project_key_for_restart, agent_config_name) return unless project_key_for_restart == "zillacore" && head_before head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir) git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir) if head_after.strip != head_before || !git_status.strip.empty? queue_zillacore_restart(agent_config_name || "agent") else LOG.info "[ZillaCore] #{agent_config_name || "agent"} session on zillacore had no changes — skipping restart" end end |
#check_zillacore_version ⇒ Object
Check if local zillacore is behind origin/master. Returns { behind: true, local_sha:, remote_sha:, commits_behind: } or { behind: false }
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/zillacore/config.rb', line 224 def check_zillacore_version zillacore_dir = File.join(__dir__, "..", "..") # Fetch latest from origin (quiet, don't fail if offline) _, _, status = Open3.capture3("git", "fetch", "origin", "master", "--quiet", chdir: zillacore_dir) unless status.success? LOG.warn "[Version] Could not fetch origin/master — skipping version check" return { behind: false } end local_sha, = Open3.capture3("git", "rev-parse", "HEAD", chdir: zillacore_dir) remote_sha, = Open3.capture3("git", "rev-parse", "origin/master", chdir: zillacore_dir) local_sha = local_sha.strip remote_sha = remote_sha.strip return { behind: false } if local_sha == remote_sha count, = Open3.capture3("git", "rev-list", "--count", "HEAD..origin/master", chdir: zillacore_dir) { behind: true, local_sha: local_sha[0..6], remote_sha: remote_sha[0..6], commits_behind: count.strip.to_i } end |
#child_processes_for(pid) ⇒ Object
Recursively collect all descendant processes of a given PID via /proc. Returns array of hashes: { pid:, ppid:, cmd:, elapsed_seconds: }
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/zillacore/sessions.rb', line 104 def child_processes_for(pid) children_map = build_proc_children_map descendants = [] queue = [pid] while (current = queue.shift) (children_map[current] || []).each do |child_pid| queue << child_pid cmdline = read_proc_cmdline(child_pid) elapsed = read_proc_elapsed(child_pid) descendants << { pid: child_pid, ppid: current, cmd: cmdline, elapsed_seconds: elapsed } end end descendants rescue StandardError => e LOG.warn "Failed to enumerate child processes for PID #{pid}: #{e.}" [] end |
#cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) ⇒ Object
Clean up all worktrees associated with a card: the primary worktree and any cross-agent review worktrees (e.g. glados-fizzy-123-*, threepio-fizzy-123-*). Safe: skips worktrees with uncommitted changes.
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/zillacore/helpers.rb', line 21 def cleanup_card_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) return unless card_number repo_dir = File.dirname(repo_path) repo_base = File.basename(repo_path) cleaned = 0 # Collect all worktree dirs for this card: primary + cross-agent review candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*fizzy-#{card_number}-*")).select { |d| File.directory?(d) } candidates << primary_worktree if primary_worktree && File.directory?(primary_worktree) && !candidates.include?(primary_worktree) candidates.uniq.each do |wt_path| status_output, = Open3.capture3("git", "status", "--porcelain", chdir: wt_path) if status_output.strip.empty? branch_name = File.basename(wt_path).sub("#{repo_base}--", "") begin run_cmd("git", "worktree", "remove", wt_path, "--force", chdir: repo_path) run_cmd("git", "branch", "-D", branch_name, chdir: repo_path) cleaned += 1 LOG.info "Cleaned up worktree #{wt_path} (branch: #{branch_name})" rescue StandardError => e LOG.warn "Failed to clean up worktree #{wt_path}: #{e.}" end else LOG.warn "Worktree #{wt_path} has uncommitted changes — skipping cleanup" end end LOG.info "Card ##{card_number}: cleaned up #{cleaned} worktree(s)" if cleaned.positive? end |
#clear_deployment_for_card(card_number) ⇒ Object
Clear all environments occupied by a given card number (called on PR merge).
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/zillacore/deployments.rb', line 204 def clear_deployment_for_card(card_number) state = load_deployment_state cleared = [] state.each do |env_key, info| next unless info["card_number"] == card_number && info["status"] == "occupied" state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => card_number } cleared << env_key end if cleared.any? save_deployment_state(state) DEPLOYMENT_STATE.replace(state) LOG.info "[Deploy] Cleared #{cleared.join(", ")} — card ##{card_number} merged" end cleared end |
#clone_branch_for_deploy(eventable, card_internal_id, card_info) ⇒ Object
Clone a remote branch locally for deploy when the worktree doesn’t exist on this machine. Returns { worktree:, card_number: } on success, nil on failure.
384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 |
# File 'lib/zillacore/handlers/fizzy.rb', line 384 def clone_branch_for_deploy(eventable, card_internal_id, card_info) # Resolve project from card tags = eventable.dig("card", "tags") || [] project_result = () unless project_result LOG.warn "[Deploy] Cannot identify project for card #{card_internal_id}" return nil end project_key, project_config = project_result repo_path = project_config["repo_path"] # Resolve card number card_number = card_info&.dig("number") card_number ||= resolve_card_number(card_internal_id, repo_path: repo_path) unless card_number LOG.warn "[Deploy] Cannot resolve card number for #{card_internal_id}" return nil end # Fetch latest and find the branch on origin matching fizzy-<card_number>-* debounced_repo_fetch(repo_path) branches = run_cmd("git", "branch", "-r", "--list", "origin/fizzy-#{card_number}-*", chdir: repo_path).strip branch = branches.lines.map(&:strip).first&.sub("origin/", "") unless branch LOG.warn "[Deploy] No remote branch matching fizzy-#{card_number}-* found" return nil end # Create worktree from the remote branch worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}") unless File.directory?(worktree_path) branch_exists_locally = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL) if branch_exists_locally run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path) else run_cmd("git", "worktree", "add", "--track", "-b", branch, worktree_path, "origin/#{branch}", chdir: repo_path) end trust_version_manager(worktree_path, chdir: worktree_path) apply_worktree_includes(repo_path, worktree_path) run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path }) end # Update card map map = load_card_map map[card_internal_id] ||= {} map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key) save_card_map(map) LOG.info "[Deploy] Cloned branch #{branch} into worktree #{worktree_path} for card ##{card_number}" { worktree: worktree_path, card_number: card_number } rescue StandardError => e LOG.error "[Deploy] Failed to clone branch for card #{card_internal_id}: #{e.}" nil end |
#comment_from_agent?(name) ⇒ Boolean
198 199 200 201 202 203 |
# File 'lib/zillacore/agents.rb', line 198 def comment_from_agent?(name) return false unless name downcased = name.downcase all_agent_names.any? { |agent| agent.downcase == downcased } end |
#convert_meridiem_hour(hour, meridiem) ⇒ Object
Convert 12-hour format hour + meridiem to 24-hour format.
112 113 114 115 116 |
# File 'lib/zillacore/cron.rb', line 112 def convert_meridiem_hour(hour, meridiem) hour = 0 if hour == 12 && meridiem == "am" hour += 12 if meridiem == "pm" && hour < 12 hour end |
#create_discord_thread(channel_id, message_id, name:, token:) ⇒ Object
299 300 301 302 303 304 305 |
# File 'lib/zillacore/handlers/discord.rb', line 299 def create_discord_thread(channel_id, , name:, token:) thread_name = name.length > 100 ? "#{name[0..96]}..." : name discord_api(:post, "/channels/#{channel_id}/messages/#{}/threads", token: token, body: { name: thread_name, auto_archive_duration: 1440 }) end |
#create_forum_post(channel_id, title:, content:, token:) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/zillacore/handlers/discord.rb', line 251 def create_forum_post(channel_id, title:, content:, token:) thread_name = title.length > 100 ? "#{title[0..96]}..." : title result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: { name: thread_name, message: { content: content }, auto_archive_duration: 1440 }) if result && result["id"] LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}" else LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}" end result end |
#create_zoho_triage_card(decision, email, channel_id, token) ⇒ Object
Create a Fizzy card from the triage decision
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 |
# File 'lib/zillacore/handlers/zoho.rb', line 388 def create_zoho_triage_card(decision, email, channel_id, token) board_id = ZOHO_CONFIG["triage_board_id"] unless board_id LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json" return end title = decision["title"] || email["subject"] description = decision["description"] || "<p>Support email from #{email["fromAddress"]}: #{email["subject"]}</p>" # Build tag list — always include 'support', plus project tag if identified = ["support"] << decision["project_tag"] if decision["project_tag"] # Resolve tag IDs tag_ids = () # Create the card cmd = ["fizzy", "card", "create", "--board", board_id, "--title", title, "--description", description] cmd.push("--tag-ids", tag_ids.join(",")) unless tag_ids.empty? # Use the triage agent's env for fizzy token agent_name = "Threepio" agent_env = agent_env_for(agent_name) spawn_env = agent_env.empty? ? {} : agent_env output, status = Open3.capture2e(spawn_env, *cmd) unless status.success? LOG.error "[Zoho:Triage] Failed to create card: #{output}" notify_zoho_match(email, { "label" => "Support Email (card creation failed)", "emoji" => "❌" }.merge(rule_defaults(nil))) return end # Parse card number from response card_data = JSON.parse(output) card_number = card_data.dig("data", "number") card_url = card_data.dig("data", "url") LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}" # Assign to the appropriate agent assign_zoho_triage_card(card_number, decision["assign_to"], spawn_env) if card_number && decision["assign_to"] # Notify Discord if channel_id && token msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n" msg += "**Title:** #{title}\n" msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n" msg += "**Tags:** #{.join(", ")}\n" msg += "**From:** #{email["fromAddress"]}" (channel_id, msg, token: token) end rescue StandardError => e LOG.error "[Zoho:Triage] Error creating card: #{e.}\n#{e.backtrace.first(3).join("\n")}" notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil))) end |
#cron_loop ⇒ Object
Cron loop — runs every minute, checks all jobs
589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 |
# File 'lib/zillacore/cron.rb', line 589 def cron_loop loop do now = Time.now # Calculate sleep time to wake up at the start of the next minute seconds_until_next_minute = 60 - now.sec sleep seconds_until_next_minute now = Time.now CRON_JOBS_MUTEX.synchronize do CRON_JOBS.each_value do |job| next unless job[:enabled] next unless cron_matches?(job[:parsed], now) # Prevent duplicate runs within the same minute if job[:last_run] last_run_time = Time.parse(job[:last_run]) next if (now - last_run_time) < 60 end execute_cron_job(job) end end rescue StandardError => e LOG.error "[Cron] Loop error: #{e.}\n#{e.backtrace.first(3).join("\n")}" sleep 60 end end |
#cron_matches?(cron_hash, time = Time.now) ⇒ Boolean
Check if current time matches cron expression
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/zillacore/cron.rb', line 119 def cron_matches?(cron_hash, time = Time.now) return false unless cron_hash # Handle one-time scheduled tasks if cron_hash[:one_time] target = cron_hash[:timestamp] # Match if we're within the same minute as the target time return time.year == target.year && time.month == target.month && time.day == target.day && time.hour == target.hour && time.min == target.min end minute_match = match_field?(cron_hash[:minute], time.min) hour_match = match_field?(cron_hash[:hour], time.hour) day_match = match_field?(cron_hash[:day], time.day) month_match = match_field?(cron_hash[:month], time.month) weekday_match = match_field?(cron_hash[:weekday], time.wday) minute_match && hour_match && day_match && month_match && weekday_match end |
#curate_skills ⇒ Object
Run the curator: archive stale skills, log consolidation candidates. Never auto-deletes — only moves to _archived/.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/zillacore/skills.rb', line 221 def curate_skills FileUtils.mkdir_p(SKILL_ARCHIVE_DIR) now = Time.now archived = 0 consolidation_candidates = [] skills = build_skill_index skills_by_tag = Hash.new { |h, k| h[k] = [] } skills.each do |skill| usage_file = skill_usage_path(skill[:path]) usage = if File.exist?(usage_file) JSON.parse(File.read(usage_file)) else { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil } end # Check staleness last_activity = [usage["last_viewed"], usage["last_used"]].compact.max if last_activity.nil? || (now - Time.parse(last_activity)) > (SKILL_STALE_DAYS * 86_400) archive_skill(skill[:path]) archived += 1 next end # Track tags for consolidation detection (skill[:tags] || []).each { |tag| skills_by_tag[tag] << skill } end # Detect consolidation candidates: 3+ skills sharing the same tag skills_by_tag.each do |tag, tag_skills| consolidation_candidates << { tag: tag, skills: tag_skills.map { |s| s[:name] } } if tag_skills.size >= 3 end LOG.info "[Curator] Archived #{archived} stale skill(s)" if archived.positive? LOG.info "[Curator] Consolidation candidates: #{consolidation_candidates.map { |c| c[:tag] }.join(", ")}" unless consolidation_candidates.empty? { archived: archived, consolidation_candidates: consolidation_candidates } rescue StandardError => e LOG.warn "[Curator] Error during curation: #{e.}" { archived: 0, consolidation_candidates: [], error: e. } end |
#debounced_repo_fetch(repo_path) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/zillacore/handlers/fizzy.rb', line 92 def debounced_repo_fetch(repo_path) last = REPO_LAST_FETCH[repo_path] if last && (Time.now - last) < REPO_FETCH_DEBOUNCE LOG.info "Skipping git fetch for #{repo_path} — fetched #{(Time.now - last).to_i}s ago" return end run_cmd("git", "fetch", "origin", chdir: repo_path) REPO_LAST_FETCH[repo_path] = Time.now end |
#default_fizzy_env ⇒ Object
108 109 110 |
# File 'lib/zillacore/agents.rb', line 108 def default_fizzy_env fizzy_env_for(AI_AGENT_NAME) end |
#default_project_config ⇒ Object
35 36 37 38 |
# File 'lib/zillacore/handlers/zoho.rb', line 35 def default_project_config key = default_project_key key ? PROJECTS[key] : PROJECTS.values.first end |
#default_project_key ⇒ Object
130 131 132 133 134 |
# File 'lib/zillacore/helpers.rb', line 130 def default_project_key # Find the project marked as default default = PROJECTS.find { |_key, config| config["default"] == true } default ? default[0] : nil end |
#deliver_discord_draft(response_file, meta_file) ⇒ Object
— Discord Draft Delivery — Shared logic for posting a draft response file to Discord and moving it to posted/. Used by both the monitoring thread (happy path) and the poller (recovery path).
1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 |
# File 'lib/zillacore/handlers/discord.rb', line 1289 def deliver_discord_draft(response_file, ) return false unless File.exist?() # Simple file-based lock to prevent the monitoring thread and poller # from delivering the same draft simultaneously. lock_file = "#{}.lock" begin File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # atomic create-or-fail rescue Errno::EEXIST return false # Another thread is already delivering this draft end = JSON.parse(File.read()) channel_id = ["channel_id"] = ["message_id"] agent_key = ["agent_key"] agent_name = ["agent_name"] is_dm = ["is_dm"] is_thread = ["is_thread"] clean_content = ["clean_content"] || "" # Look up the bot token from the current registry bot_token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) } bot_token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"] unless bot_token LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft" FileUtils.rm_f(lock_file) return false end if File.exist?(response_file) response = File.read(response_file).strip if response.empty? add_discord_reaction(channel_id, , "😶", token: bot_token) if (channel_id, "_#{agent_name} had nothing to say._", token: bot_token) elsif is_dm || is_thread || .nil? # DMs, threads, and cron jobs (no message_id) need special handling # Check if this is a forum channel if .nil? && forum_channel?(channel_id, token: bot_token) title = ["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}" if ["forum_reply_to_latest"] latest_thread = find_latest_forum_thread(channel_id, token: bot_token) if latest_thread (latest_thread["id"], response, token: bot_token) else LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post" create_forum_post(channel_id, title: title, content: response, token: bot_token) end else create_forum_post(channel_id, title: title, content: response, token: bot_token) end else # Regular DM, thread, or text channel (channel_id, response, token: bot_token) end else # Check if another agent (local OR remote) already created a thread # for this message. Three-tier lookup: # 1. Local in-memory cache (DISCORD_SHARED_THREADS) — fast, same-machine # 2. Discord API — fetch the original message and check its `thread` field # This is the cross-machine fix: if machine B's agent finishes after # machine A already created a thread, the API will reveal it. # 3. Create a new thread if neither found one. # The mutex still covers the local check + create to prevent same-machine races. thread_id = nil created_thread = false DISCORD_SHARED_THREADS_MUTEX.synchronize do thread_id = DISCORD_SHARED_THREADS[] # Tier 2: Ask Discord if a thread already exists on this message. # This catches threads created by agents on other machines. unless thread_id original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{}", token: bot_token) if original_msg&.dig("thread", "id") thread_id = original_msg["thread"]["id"] DISCORD_SHARED_THREADS[] = thread_id LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{} via API" end end # Tier 3: No thread exists anywhere — create one. unless thread_id display_name = fizzy_display_name(agent_key) thread = create_discord_thread(channel_id, , name: "#{display_name}: #{clean_content[0..80]}", token: bot_token) if thread && thread["id"] thread_id = thread["id"] DISCORD_SHARED_THREADS[] = thread_id created_thread = true LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{}" end end end if thread_id LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{}" unless created_thread # Propagate the parent channel's dispatch depth to the thread so # cross-agent mentions inside the thread aren't blocked immediately. # The human's message was in the parent channel, but agent responses # land in this thread (different channel_id). Without this, the # thread's depth key has no entry and agent_dispatch_allowed? returns false. parent_depth_key = "discord-#{channel_id}" thread_depth_key = "discord-#{thread_id}" parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key] unless AGENT_DISPATCH_DEPTH[thread_depth_key] if parent_info AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] } LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}" else # No parent depth entry (edge case) — initialize with current time record_human_comment(thread_depth_key) end end send_discord_typing(thread_id, token: bot_token) (thread_id, response, token: bot_token) else LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply" (channel_id, response, token: bot_token, reply_to: ) end end else # Response file doesn't exist yet — agent may still be running FileUtils.rm_f(lock_file) return false end # Move both files to posted/ basename = File.basename(response_file) = File.basename() FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, basename)) if File.exist?(response_file) FileUtils.mv(, File.join(DISCORD_POSTED_DIR, )) FileUtils.rm_f(lock_file) LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/" true rescue StandardError => e LOG.error "[Discord] Failed to deliver draft #{}: #{e.}" File.delete(lock_file) if lock_file && File.exist?(lock_file) false end |
#deliver_script_output(job, log_file, draft_file) ⇒ Object
Read script output and write to draft file or log.
352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/zillacore/cron.rb', line 352 def deliver_script_output(job, log_file, draft_file) return unless File.exist?(log_file) output = File.read(log_file).strip if job[:discord_channel_id] && draft_file && !output.empty? File.write(draft_file, output) LOG.info "[Cron] Script output written to #{draft_file} (#{output.length} chars)" elsif !output.empty? LOG.info "[Cron] Script output: #{output[0..200]}..." else LOG.warn "[Cron] Script produced no output" end end |
#deploy_to_environment(env_key, worktree_path:, deployed_by: nil) ⇒ Object
Mark an environment as occupied. Resolves card info from the card map using the worktree path.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/zillacore/deployments.rb', line 60 def deploy_to_environment(env_key, worktree_path:, deployed_by: nil) config = DEPLOYMENTS_CONFIG["environments"] || {} unless config.key?(env_key) LOG.warn "[Deploy] Unknown environment: #{env_key}" return { error: "Unknown environment: #{env_key}" } end state = load_deployment_state entry = { "status" => "occupied", "deployed_at" => Time.now.iso8601, "deployed_by" => deployed_by, "last_deploy_status" => "success", "last_deploy_at" => Time.now.iso8601 } # Resolve card info from card map by matching worktree path map = load_card_map card_entry = map.values.find { |info| info["worktree"] == worktree_path } if card_entry entry["card_number"] = card_entry["number"] entry["card_title"] = card_entry["title"] entry["branch"] = card_entry["branch"] pr = (card_entry["prs"] || []).last if pr entry["pr_number"] = pr["number"] entry["pr_url"] = pr["url"] end # Store card tags for URL resolution (e.g. ops-web-app → ops URL) card_idx = CARD_INDEX[card_entry["number"].to_s] entry["card_tags"] = card_idx["tags"] if card_idx && card_idx["tags"] else # No card map match — record branch from git branch = `git -C #{Shellwords.escape(worktree_path)} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip entry["branch"] = branch unless branch.empty? end commit = `git -C #{Shellwords.escape(worktree_path)} rev-parse --short HEAD 2>/dev/null`.strip entry["commit"] = commit unless commit.empty? state[env_key] = entry save_deployment_state(state) DEPLOYMENT_STATE.replace(state) LOG.info "[Deploy] #{env_key} marked occupied — card ##{entry["card_number"] || "none"}, branch: #{entry["branch"]}" entry end |
#deployment_status ⇒ Object
Full deployment status for API / waybar.
238 239 240 241 242 243 244 245 246 247 |
# File 'lib/zillacore/deployments.rb', line 238 def deployment_status config = DEPLOYMENTS_CONFIG["environments"] || {} state = load_deployment_state config.map do |env_key, env_config| info = state[env_key] || { "status" => "available" } url = resolve_deployment_url(env_config, info["card_tags"]) { "env" => env_key, "label" => env_config["label"], "url" => url, "project" => env_config["project"] }.merge(info) end end |
#detect_effort(project_config, tags: [], text: "") ⇒ Object
Detect effort level from inline tags [effort:high] or Fizzy card tags (effort-high). Returns the effort level string (e.g. “high”) or nil. If the requested level isn’t supported by the current model, returns the closest lower level from allowed_efforts.
721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 |
# File 'lib/zillacore/helpers.rb', line 721 def detect_effort(project_config, tags: [], text: "") resolved = resolve_project_cli_config(project_config) allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max] # Inline tag: [effort:high] if (match = text.match(/\[effort:(\w+)\]/i)) level = match[1].downcase return resolve_effort_level(level, allowed) if allowed.include?(level) end # Fizzy card tags: effort-high, effort-max .each do |tag| name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase if name.start_with?("effort-") level = name.sub("effort-", "") return resolve_effort_level(level, allowed) if allowed.include?(level) end end resolved["agent_effort"] end |
#detect_mentioned_agent(text) ⇒ Object
172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/zillacore/agents.rb', line 172 def detect_mentioned_agent(text) downcased = text.downcase # Exact full-name match first (highest priority) all_agent_names.each do |name| return name if downcased.include?("@#{name.downcase}") # Fizzy renders mentions using first name only (e.g. "@Sleeper" not "@Sleeper Service"). # Fall back to matching the first word of multi-word agent names. first_word = name.split.first.downcase next if first_word == name.downcase # already checked above return name if downcased.include?("@#{first_word}") end nil end |
#detect_mentioned_user_ids(text) ⇒ Object
187 188 189 190 191 192 193 194 195 196 |
# File 'lib/zillacore/agents.rb', line 187 def detect_mentioned_user_ids(text) return [] unless FIZZY_CONFIG["authorized_users"] mentioned_ids = [] FIZZY_CONFIG["authorized_users"].each do |user| name = user["name"] mentioned_ids << user["id"] if text.downcase.include?("@#{name.downcase}") end mentioned_ids end |
#detect_model(project_config, tags: [], text: "") ⇒ Object
699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/zillacore/helpers.rb', line 699 def detect_model(project_config, tags: [], text: "") resolved = resolve_project_cli_config(project_config) allowed_models = resolved["allowed_models"] || {} return resolved["agent_model"] if allowed_models.empty? if (match = text.match(/\[(\w+)\]/)) key = match[1].downcase return allowed_models[key] if allowed_models.key?(key) end .each do |tag| key = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase return allowed_models[key] if allowed_models.key?(key) end resolved["agent_model"] end |
#detect_planning_mode(text:, tags: [], card_internal_id: nil, card_number: nil) ⇒ Object
Detect if a message/card should trigger planning mode. Returns: { mode: :planning, card_id: ‘…’, card_number: 123 } or nil
14 15 16 17 18 19 20 21 22 23 24 25 |
# File 'lib/zillacore/planning.rb', line 14 def detect_planning_mode(text:, tags: [], card_internal_id: nil, card_number: nil) # Discord: [plan] anywhere in message # Fizzy: 'plan' tag on card has_plan_tag = text.match?(/\[plan\]/i) || .any? { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase == "plan" } return nil unless has_plan_tag { mode: :planning, card_id: card_internal_id || "discord-#{Time.now.to_i}", card_number: card_number } end |
#detect_skill_candidate(log_file) ⇒ Object
Analyze an agent session log to determine if a skill should be extracted. Triggers when: 5+ tool calls AND at least one error-recovery pattern detected. Returns: { extract: true, topic: ‘…’, summary: ‘…’ } or { extract: false }
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/zillacore/skills.rb', line 28 def detect_skill_candidate(log_file) return { extract: false } unless File.exist?(log_file) content = File.read(log_file, encoding: "utf-8", invalid: :replace) # Count tool invocations (kiro-cli logs tool calls as "Tool:" or "antml:invoke") tool_calls = content.scan(/(?:^Tool:|<invoke|execute_bash|fs_write|fs_read|code.*operation)/).size return { extract: false } if tool_calls < 5 # Detect error-recovery patterns: retry, fix, error followed by success error_patterns = content.scan(/(?:error|failed|fix|retry|correcting|let me try)/i).size recovery_patterns = content.scan(/(?:that worked|fixed|resolved|now passing|success)/i).size has_recovery = error_patterns >= 1 && recovery_patterns >= 1 return { extract: false } unless has_recovery { extract: true, tool_calls: tool_calls, error_patterns: error_patterns } end |
#discord_api(method, path, token:, body: nil, log_errors: true) ⇒ Object
— Discord REST API —
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/zillacore/handlers/discord.rb', line 152 def discord_api(method, path, token:, body: nil, log_errors: true) uri = URI("#{DISCORD_API_BASE}#{path}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true req = case method when :get then Net::HTTP::Get.new(uri) when :post then Net::HTTP::Post.new(uri) when :put then Net::HTTP::Put.new(uri) when :delete then Net::HTTP::Delete.new(uri) end req["Authorization"] = "Bot #{token}" req["Content-Type"] = "application/json" req.body = body.to_json if body response = http.request(req) if response.code.to_i == 429 retry_after = JSON.parse(response.body)["retry_after"] || 1 LOG.warn "Discord rate limited, waiting #{retry_after}s" sleep retry_after return discord_api(method, path, token: token, body: body, log_errors: log_errors) end LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors JSON.parse(response.body) unless response.body.nil? || response.body.empty? rescue StandardError => e LOG.error "Discord API error (#{method} #{path}): #{e.}" if log_errors nil end |
#discord_bot_tokens ⇒ Object
Collect all agent Discord bot tokens from the registry. Returns { “galen” => “token…”, “glados” => “token…” }
137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/zillacore/handlers/discord.rb', line 137 def discord_bot_tokens tokens = {} AGENT_REGISTRY.each do |key, entry| next unless entry.is_a?(Hash) token = (entry["env"] || {})["DISCORD_BOT_TOKEN"] next unless token tokens[key] = token end tokens end |
#discord_bots_status ⇒ Object
Summary of all bot statuses for the API endpoint.
1637 1638 1639 1640 1641 1642 1643 |
# File 'lib/zillacore/handlers/discord.rb', line 1637 def discord_bots_status DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS.transform_values do |info| { status: info[:status], user_id: info[:user_id] } end end end |
#discord_mention_roster ⇒ Object
Build a Discord mention roster so the agent can @mention people and other bots. Discord requires ‘<@USER_ID>` syntax — plain text “@Name” doesn’t work. Sources:
- Other agent bots: DISCORD_BOTS (populated at gateway READY)
- Human users: discord.json "user_mappings" (manually maintained)
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 |
# File 'lib/zillacore/handlers/discord.rb', line 396 def discord_mention_roster lines = [] # Agent bots DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS.each do |agent_key, info| next unless info[:user_id] display = fizzy_display_name(agent_key) || agent_key.capitalize lines << " - #{display}: `<@#{info[:user_id]}>`" end end # Human users from config user_mappings = DISCORD_CONFIG["user_mappings"] || {} user_mappings.each do |name, discord_id| lines << " - #{name}: `<@#{discord_id}>`" end lines.join("\n") end |
#discover_kiro_agents ⇒ Object
128 129 130 131 132 133 134 135 |
# File 'lib/zillacore/agents.rb', line 128 def discover_kiro_agents return [] unless File.directory?(KIRO_AGENTS_DIR) Dir.glob(File.join(KIRO_AGENTS_DIR, "*.json")).map { |path| File.basename(path, ".json") } rescue StandardError => e LOG.error "Failed to scan kiro agents directory: #{e.}" [] end |
#dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:, model:, agent_name:, comment_id:, eventable:, deploy_intent: nil) ⇒ Object
Dispatch a follow-up comment to the agent. Extracted so it can be called both inline (no active session) and from a queued background thread.
1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 |
# File 'lib/zillacore/handlers/fizzy.rb', line 1195 def dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:, model:, agent_name:, comment_id:, eventable:, deploy_intent: nil) = eventable.dig("card", "tags") || [] planning_info = detect_planning_mode( text: plain_text, tags: , card_internal_id: card_internal_id, card_number: card_number ) prompt = if planning_info card_id = planning_info[:card_id] LOG.info "[Planning] Planning mode active for card #{card_number || card_internal_id}" if work_dir == project_config["repo_path"] render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE, comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id), brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name), agent_name: agent_name) else render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE, comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id), brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name), agent_name: agent_name) end elsif work_dir != project_config["repo_path"] render_prompt(PROMPT_FOLLOWUP_WORKTREE, comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number), brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name), agent_name: agent_name) else render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE, comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id), brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name), agent_name: agent_name) end pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "followup-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id, source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent }) register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name) # Move card to Right Now — agent is actively working again Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: agent_name) } { status: "follow_up", card: card_number, card_internal_id: card_internal_id, worktree: work_dir, project: project_key } end |
#dispatch_zoho_triage(email, rule) ⇒ Object
Dispatch an agent to triage a support email. The agent decides whether to create a Fizzy card or just notify Discord.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/zillacore/handlers/zoho.rb', line 252 def dispatch_zoho_triage(email, rule) agent_name = rule["dispatch_agent"] = Time.now.strftime("%Y%m%d-%H%M%S") response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{}.json") log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{}.log") body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip body = body[0..2000] if body.length > 2000 prompt = ZOHO_TRIAGE_PROMPT .gsub("{{FROM}}", email["fromAddress"].to_s) .gsub("{{TO}}", email["toAddress"].to_s) .gsub("{{SUBJECT}}", email["subject"].to_s) .gsub("{{BODY}}", body) .gsub("{{PROJECT_TAGS}}", ) .gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment) prompt += "\n\nWrite your JSON response to: #{response_file}\n" prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{}.md") File.write(prompt_file, prompt) agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") project_config = default_project_config resolved = project_config ? resolve_project_cli_config(project_config) : {} agent_cli = resolved["agent_cli"] || "kiro-cli" agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive" resolved["agent_model_flag"] || "--model" cmd = [agent_cli] cmd.push("--agent", agent_key) cmd.concat(agent_cli_args.split) add_trust_tools!(cmd, agent_cli_args) spawn_env = {} agent_env = agent_env_for(agent_name) spawn_env.merge!(agent_env) unless agent_env.empty? work_dir = project_config ? project_config["repo_path"] : Dir.pwd LOG.info "[Zoho:Triage] Dispatching #{agent_name} for: #{email["subject"]}" LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}" pid = spawn(spawn_env, *cmd, chdir: work_dir, in: prompt_file, out: [log_file, "w"], err: %i[child out]) # Monitor in background — process the triage decision when agent finishes Thread.new do Process.wait(pid) exit_status = $CHILD_STATUS LOG.info "[Zoho:Triage] Agent finished (exit: #{exit_status.exitstatus})" decision = read_zoho_triage_response(response_file, log_file) if decision execute_zoho_triage_decision(decision, email, rule) else LOG.warn "[Zoho:Triage] No valid decision from agent — falling back to Discord notification" notify_zoho_match(email, rule) end # Cleanup prompt file after a delay Thread.new do sleep 300 FileUtils.rm_f(prompt_file) end end pid end |
#ensure_fizzy_yaml!(chdir, project_config) ⇒ Object
Ensure .fizzy.yaml is present in the working directory (worktrees need a copy).
560 561 562 563 564 565 566 567 568 569 |
# File 'lib/zillacore/helpers.rb', line 560 def ensure_fizzy_yaml!(chdir, project_config) fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml") return if File.exist?(fizzy_yaml_dest) fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml") return unless File.exist?(fizzy_yaml_src) FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest) LOG.info "Copied .fizzy.yaml to #{chdir}" end |
#execute_cron_job(job) ⇒ Object
Execute a cron job (dispatch agent)
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 |
# File 'lib/zillacore/cron.rb', line 520 def execute_cron_job(job) return unless job[:enabled] LOG.info "[Cron] Executing job #{job[:id]}: #{job[:prompt] || job[:script]}..." project = PROJECTS[job[:project]] unless project LOG.error "[Cron] Project #{job[:project]} not found for job #{job[:id]}" return end if job[:script] execute_script_job(job, project) return end agent_name = job[:agent] agent_config_name = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") prompt_data = build_cron_prompt(job, project) = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(project["repo_path"], "tmp/agent-cron-#{job[:id]}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], ) cmd = build_cron_agent_cmd(job, project) LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}" spawn_env = agent_env_for(agent_name) LOG.info "[Cron] Injecting #{spawn_env.size} env var(s) for agent #{agent_name}" unless spawn_env.empty? pid = spawn(spawn_env, *cmd, chdir: project["repo_path"], in: prompt_file, out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) handle_cron_completion(job, project, agent_name, agent_config_name, log_file, prompt_data[:response_file], prompt_data[:meta_file]) rescue StandardError => e LOG.error "[Cron] Job #{job[:id]} failed: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end |
#execute_script_job(job, project) ⇒ Object
Execute a script-based cron job (no agent, direct script execution)
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 |
# File 'lib/zillacore/cron.rb', line 294 def execute_script_job(job, project) script_path = File.(job[:script]) unless File.exist?(script_path) LOG.error "[Cron] Script not found: #{script_path}" return end unless File.executable?(script_path) LOG.error "[Cron] Script not executable: #{script_path}" return end = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) draft_file = prepare_script_discord_draft(job, ) if job[:discord_channel_id] LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}" pid = spawn(script_path, chdir: project["repo_path"], out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) LOG.info "[Cron] Script job #{job[:id]} finished (exit: #{$CHILD_STATUS.exitstatus})" deliver_script_output(job, log_file, draft_file) update_cron_job_state(job) rescue StandardError => e LOG.error "[Cron] Script job #{job[:id]} failed: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end |
#execute_zoho_triage_decision(decision, email, rule) ⇒ Object
Act on the triage decision
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
# File 'lib/zillacore/handlers/zoho.rb', line 356 def execute_zoho_triage_decision(decision, email, rule) channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"] tokens = discord_bot_tokens bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first token = tokens[bot_name&.downcase] || tokens.values.first case decision["decision"] when "create_card" create_zoho_triage_card(decision, email, channel_id, token) when "skip" msg = "📧 **Support Email — No Card Needed**\n" msg += "**Subject:** #{email["subject"]}\n" msg += "**From:** #{email["fromAddress"]}\n" msg += "**Reason:** #{decision["reason"]}" (channel_id, msg, token: token) if channel_id && token LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}" when "borderline" msg = "⚠️ **Support Email — Needs Human Decision**\n" msg += "**Subject:** #{email["subject"]}\n" msg += "**From:** #{email["fromAddress"]}\n" msg += "**Why borderline:** #{decision["reason"]}\n" summary = (email["summary"] || "").to_s[0..300] msg += "```\n#{summary}\n```" unless summary.empty? (channel_id, msg, token: token) if channel_id && token LOG.info "[Zoho:Triage] Borderline — posted to Discord for human decision" else LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}" notify_zoho_match(email, rule) end end |
#extract_crash_snippet(log_file, max_lines: 20) ⇒ Object
Extract the last N meaningful lines from an agent log for crash reporting.
377 378 379 380 381 382 383 384 385 |
# File 'lib/zillacore/helpers.rb', line 377 def extract_crash_snippet(log_file, max_lines: 20) return nil unless log_file && File.exist?(log_file) lines = File.readlines(log_file).map { |l| l.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").rstrip }.reject(&:empty?).last(max_lines) lines&.join("\n") rescue StandardError => e LOG.warn "[CrashNotify] Could not read log: #{e.}" nil end |
#extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Extract agent response from log if the response file wasn’t written directly
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 |
# File 'lib/zillacore/cron.rb', line 463 def extract_cron_response_from_log(job, agent_config_name, log_file, response_file, ) return if File.exist?(response_file) return unless File.exist?(log_file) log_content = File.read(log_file) if log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/) LOG.error "[Cron] Auth failure detected for job #{job[:id]} — " \ "re-authenticate with: kiro-cli --agent #{agent_config_name} chat" File.delete() if && File.exist?() return end clean_output = log_content .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") .gsub(/\e\][^\a]*\a/, "") .delete("\r") .gsub(/^.*?(using tool:.*?)$/m, "") .gsub(/^.*?✓.*?$/m, "") .gsub(/^.*?▸.*?$/m, "") .gsub(/^.*?Loading\.\.\..*?$/m, "") .gsub(/^.*?Completed in.*?$/m, "") .strip return unless !clean_output.empty? && clean_output.length > 20 File.write(response_file, clean_output) LOG.info "[Cron] Extracted response from log (#{clean_output.length} chars)" end |
#extract_text_from_mime(mime) ⇒ Object
Extract readable text from raw MIME content. Prefers text/plain part; falls back to stripping HTML from text/html part.
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/zillacore/zoho_mail_api.rb', line 94 def extract_text_from_mime(mime) # Try to find text/plain part if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi return Regexp.last_match(1).gsub("\r\n", "\n").strip end # Fallback: extract text/html part and strip tags if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi html = Regexp.last_match(1).gsub("\r\n", "\n") return html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/&/i, "&") .gsub(/</i, "<").gsub(/>/i, ">").gsub(/\s+/, " ").strip end # Last resort: strip all HTML-ish content from the whole thing mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip end |
#extract_topics(card_title, comment_body, project_key) ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/zillacore/brain.rb', line 118 def extract_topics(card_title, comment_body, project_key) text = [card_title, comment_body].compact.join(" ") # Strip common noise words, extract meaningful terms stopwords = %w[the a an is are was were be been being have has had do does did will would shall should may might can could this that these those it its i me my we our you your he she they them to of in for on with at by from as into through during before after above below between and or but not no nor so yet both either neither each every all any few more most other some such only own same than too very just don doesn didn won wasn weren isn aren hasn haven hadn couldn shouldn wouldn about also back even still already again further then once here there when where why how what which who whom whose if because since while please thanks thank need want like make sure get got going go let know think see look work try use find give tell ask seem feel become leave call keep put run move live update fix add create new change set up check out] words = text.downcase.gsub(/[^a-z0-9\s_-]/, " ").split.uniq - stopwords topics = words.select { |w| w.length > 2 }.first(8) topics << project_key if project_key && !project_key.empty? topics.compact.uniq end |
#fetch_card_comments(card_number, repo_path:, env:) ⇒ Object
Fetch recent comments for a card. Returns array of text parts.
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 |
# File 'lib/zillacore/helpers.rb', line 335 def fetch_card_comments(card_number, repo_path:, env:) comments_output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env) comments_data = JSON.parse(comments_output)["data"] || [] return [] if comments_data.empty? parts = [] total = comments_data.size comments_data = comments_data.last(PREFETCH_COMMENT_LIMIT) parts << "\n## Comments#{" (last #{PREFETCH_COMMENT_LIMIT} of #{total})" if total > PREFETCH_COMMENT_LIMIT}" comments_data.each do |c| = c.dig("creator", "name") || "Unknown" body = c.dig("body", "plain_text") || "" cid = c["id"] next if body.strip.empty? body = "#{body[0...COMMENT_BODY_TRUNCATE_LENGTH]}… [truncated]" if body.length > COMMENT_BODY_TRUNCATE_LENGTH parts << "\n### #{} (comment ID: #{cid})\n#{body}" end parts rescue StandardError => e LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.}" [] end |
#fetch_card_details(card_number, repo_path:, env:) ⇒ Object
Fetch card details from Fizzy. Returns array of text parts, or nil on failure.
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/zillacore/helpers.rb', line 312 def fetch_card_details(card_number, repo_path:, env:) card_output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env) card_data = begin JSON.parse(card_output)["data"] rescue StandardError nil end return [] unless card_data parts = [] parts << "## Card ##{card_number}: #{card_data["title"]}" parts << "Status: #{card_data["status"]}" if card_data["status"] = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t } parts << "Tags: #{.join(", ")}" unless .empty? body = card_data.dig("body", "plain_text") || card_data["body"] parts << "\n#{body}" if body && !body.to_s.strip.empty? parts rescue StandardError => e LOG.warn "Could not pre-fetch card ##{card_number}: #{e.}" nil end |
#fetch_channel_info(channel_id, token:) ⇒ Object
220 221 222 |
# File 'lib/zillacore/handlers/discord.rb', line 220 def fetch_channel_info(channel_id, token:) discord_api(:get, "/channels/#{channel_id}", token: token) end |
#fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10) ⇒ Object
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/zillacore/handlers/discord.rb', line 185 def fetch_discord_channel_history(channel_id, , token:, limit: 10) = discord_api(:get, "/channels/#{channel_id}/messages?before=#{}&limit=#{limit}", token: token) = .is_a?(Array) ? : [] # If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21). # These messages have no content but point to the original message via referenced_message. # We need to include that original message for full context. if .any? oldest = .last # API returns newest-first if oldest && oldest["type"] == 21 && oldest["referenced_message"] # Prepend the actual starter message content << oldest["referenced_message"] end end return "" if .empty? # Messages come newest-first from the API, reverse for chronological order lines = .reverse.filter_map do |msg| = msg.dig("author", "username") || "unknown" content = msg["content"]&.strip || "" next if content.empty? "#{}: #{content}" end return "" if lines.empty? lines.join("\n") rescue StandardError => e LOG.warn "Failed to fetch channel history: #{e.}" "" end |
#fetch_discord_message(channel_id, message_id, token:, log_errors: true) ⇒ Object
282 283 284 |
# File 'lib/zillacore/handlers/discord.rb', line 282 def (channel_id, , token:, log_errors: true) discord_api(:get, "/channels/#{channel_id}/messages/#{}", token: token, log_errors: log_errors) end |
#fetch_guild_member(guild_id, user_id, token:) ⇒ Object
307 308 309 |
# File 'lib/zillacore/handlers/discord.rb', line 307 def fetch_guild_member(guild_id, user_id, token:) discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token) end |
#fetch_pr_review_comments(pr_number, repo) ⇒ Object
Fetch review comments from a PR using GitHub CLI
51 52 53 54 55 56 57 |
# File 'lib/zillacore/handlers/github.rb', line 51 def fetch_pr_review_comments(pr_number, repo) output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments", "--jq", ".[] | {path, line, body, user: .user.login}") output.lines.map { |line| JSON.parse(line) } rescue StandardError => e LOG.warn "Could not fetch PR review comments: #{e.}" [] end |
#fetch_zoho_email_content(message_id) ⇒ Object
Fetch email content by messageId using the “original message” endpoint. This endpoint doesn’t require a folder ID — just accountId + messageId. Returns plain-text body extracted from the MIME content, or nil.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/zillacore/zoho_mail_api.rb', line 60 def fetch_zoho_email_content() return nil unless zoho_api_configured? token = zoho_access_token return nil unless token account_id = ZOHO_CONFIG.dig("api", "account_id") uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{}/originalmessage") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true req = Net::HTTP::Get.new(uri) req["Authorization"] = "Zoho-oauthtoken #{token}" req["Accept"] = "application/json" res = http.request(req) data = JSON.parse(res.body) if data.dig("status", "code") == 200 raw_mime = data.dig("data", "content").to_s text = extract_text_from_mime(raw_mime) LOG.info "[Zoho:API] Fetched content for message #{} (#{text.length} chars)" text else LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}" nil end rescue StandardError => e LOG.error "[Zoho:API] Error fetching content: #{e.}" nil end |
#file_changed?(path, force: false) ⇒ Boolean
152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/zillacore/config.rb', line 152 def file_changed?(path, force: false) return true if force return true unless File.exist?(path) current_mtime = File.mtime(path) last_mtime = CONFIG_MTIMES[path] if last_mtime == current_mtime false else CONFIG_MTIMES[path] = current_mtime true end end |
#finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:) ⇒ Object
Generate plan markdown from memory Q&A and create Fizzy steps. This is called when the agent determines it has enough information.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/zillacore/planning.rb', line 41 def finalize_plan(card_id:, card_number:, agent_name:, project_key:, repo_path:) memory_file = File.join(memory_dir_for(agent_name), "card-#{card_id}.md") unless File.exist?(memory_file) LOG.error "[Planning] Cannot finalize plan — no memory file found for card #{card_id}" return { success: false, error: "No memory file found" } end memory_content = File.read(memory_file) # Extract Q&A from memory (agents should format it consistently) # Expected format in memory: # ## Planning Q&A # Q: What's the goal? # A: Build a feature that does X # Q: Should it support Y? # A: Yes, and also Z plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md") # The agent should have already written the plan to the file during its session. # This function just validates it exists and creates Fizzy steps. unless File.exist?(plan_file) LOG.error "[Planning] Plan file not found at #{plan_file}" return { success: false, error: "Plan file not generated by agent" } end plan_content = File.read(plan_file) # Parse tasks from plan markdown. # Expected format: # ## Task Breakdown # ### Task 1: Title # ### Task 2: Title tasks = [] plan_content.scan(/^###\s+Task\s+\d+:\s+(.+)$/i) do |match| tasks << match[0].strip end if tasks.empty? LOG.warn "[Planning] No tasks found in plan file #{plan_file}" return { success: false, error: "No tasks found in plan" } end # Create Fizzy steps for each task if card_number LOG.info "[Planning] Creating #{tasks.size} Fizzy steps for card ##{card_number}" tasks.each do |task_title| run_cmd("fizzy", "step", "create", "--card", card_number.to_s, "--content", task_title, chdir: repo_path, env: fizzy_env_for(agent_name)) LOG.info "[Planning] Created step: #{task_title}" rescue StandardError => e LOG.error "[Planning] Failed to create step '#{task_title}': #{e.}" end end # Mark planning as complete in memory updated_memory = memory_content.sub(/planning_complete:\s*false/i, "planning_complete: true") updated_memory += "\n\nplanning_complete: true\n" unless updated_memory.include?("planning_complete: true") File.write(memory_file, updated_memory) LOG.info "[Planning] Plan finalized for card #{card_id}: #{plan_file}" { success: true, plan_file: plan_file, tasks: tasks } end |
#find_card_by_branch(branch) ⇒ Object
Find a Fizzy card by matching the PR’s head branch to a branch in the card map.
14 15 16 17 18 19 20 21 22 |
# File 'lib/zillacore/handlers/github.rb', line 14 def find_card_by_branch(branch) map = load_card_map map.each do |internal_id, info| next unless info["branch"] == branch return [internal_id, info] end nil end |
#find_latest_forum_thread(channel_id, token:) ⇒ Object
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/zillacore/handlers/discord.rb', line 229 def find_latest_forum_thread(channel_id, token:) # Get the guild ID from the channel info, then list active threads channel_info = fetch_channel_info(channel_id, token: token) return nil unless channel_info && channel_info["guild_id"] guild_id = channel_info["guild_id"] result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token) return nil unless result && result["threads"] # Filter to threads in this forum channel, sort by creation (newest first) forum_threads = result["threads"] .select { |t| t["parent_id"] == channel_id } .sort_by { |t| t["id"].to_i } .reverse return nil if forum_threads.empty? latest = forum_threads.first LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}" latest end |
#find_project_for_discord_channel(channel_id) ⇒ Object
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/zillacore/handlers/discord.rb', line 336 def find_project_for_discord_channel(channel_id) mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id) unless mapping default_project = DISCORD_CONFIG["default_project"] mapping = { "project" => default_project } if default_project end return nil unless mapping project_key = mapping["project"] project_config = PROJECTS[project_key] return nil unless project_config [project_key, project_config, mapping] end |
#find_root_message(message, channel_id, bot_token) ⇒ Object
Find the root message for a conversation thread. Walks back through message_reference chain to find the original message. Returns { id: root_message_id, content: root_message_text, author: username } or { id: current_message_id, content: nil, author: nil } if already the root.
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/zillacore/handlers/discord.rb', line 357 def (, channel_id, bot_token) current_msg = visited = Set.new max_depth = 20 # Prevent infinite loops walked = false max_depth.times do msg_id = current_msg["id"] return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id) # Loop detected visited << msg_id ref = current_msg["message_reference"] break unless ref ref_msg_id = ref["message_id"] ref_channel = ref["channel_id"] || channel_id break unless ref_msg_id # Fetch the referenced message referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token) break unless referenced current_msg = referenced walked = true end { id: current_msg["id"], content: walked ? current_msg["content"]&.strip : nil, author: walked ? current_msg.dig("author", "username") : nil } end |
#find_supersedable_session(supersede_key) ⇒ Object
Find an active session for the same supersede key (agent+channel) started within the window. Returns the session info hash (with :session_key added) or nil.
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/zillacore/sessions.rb', line 188 def find_supersedable_session(supersede_key) ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS.each do |key, info| next unless info[:supersede_key] == supersede_key next if (Time.now - info[:started_at]) > SUPERSEDE_WINDOW begin Process.kill(0, info[:pid]) return info.merge(session_key: key) rescue Errno::ESRCH, Errno::EPERM next end end end nil end |
#find_user(identifier) ⇒ Object
Find user by any identifier (tries all platforms)
54 55 56 57 58 59 60 |
# File 'lib/zillacore/users.rb', line 54 def find_user(identifier) find_user_by_discord_id(identifier) || find_user_by_discord_username(identifier) || find_user_by_github_username(identifier) || find_user_by_fizzy_username(identifier) || find_user_by_canonical_name(identifier) end |
#find_user_by_canonical_name(name) ⇒ Object
Find user by canonical name
49 50 51 |
# File 'lib/zillacore/users.rb', line 49 def find_user_by_canonical_name(name) USER_REGISTRY["users"].find { |u| u["canonical_name"].downcase == name.downcase } end |
#find_user_by_discord_id(user_id) ⇒ Object
Find user by Discord user ID
29 30 31 |
# File 'lib/zillacore/users.rb', line 29 def find_user_by_discord_id(user_id) USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s } end |
#find_user_by_discord_username(username) ⇒ Object
Find user by Discord username
34 35 36 |
# File 'lib/zillacore/users.rb', line 34 def find_user_by_discord_username(username) USER_REGISTRY["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s } end |
#find_user_by_fizzy_username(username) ⇒ Object
Find user by Fizzy username
44 45 46 |
# File 'lib/zillacore/users.rb', line 44 def find_user_by_fizzy_username(username) USER_REGISTRY["users"].find { |u| u.dig("identities", "fizzy", "username") == username.to_s } end |
#find_user_by_github_username(username) ⇒ Object
Find user by GitHub username
39 40 41 |
# File 'lib/zillacore/users.rb', line 39 def find_user_by_github_username(username) USER_REGISTRY["users"].find { |u| u.dig("identities", "github", "username") == username.to_s } end |
#fizzy_display_name(agent_name) ⇒ Object
112 113 114 115 116 117 118 119 120 |
# File 'lib/zillacore/agents.rb', line 112 def fizzy_display_name(agent_name) return agent_name unless agent_name key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-") entry = AGENT_REGISTRY[key] return agent_name unless entry.is_a?(Hash) entry["fizzy_name"] || agent_name end |
#fizzy_env_for(agent_name) ⇒ Object
Convenience: build env hash for fizzy CLI calls (backward compat). Falls back to default agent token when the given agent has no token.
103 104 105 106 |
# File 'lib/zillacore/agents.rb', line 103 def fizzy_env_for(agent_name) token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME) token ? { "FIZZY_TOKEN" => token } : {} end |
#fizzy_token_for(agent_name) ⇒ Object
Convenience: get the Fizzy token for an agent.
97 98 99 |
# File 'lib/zillacore/agents.rb', line 97 def fizzy_token_for(agent_name) agent_env_var(agent_name, "FIZZY_TOKEN") end |
#format_zoho_notification(email, rule) ⇒ Object
Format a Discord notification for a matched email.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/zillacore/handlers/zoho.rb', line 130 def format_zoho_notification(email, rule) label = rule["label"] || "Zoho Mail" emoji = rule["emoji"] || "📧" parts = ["#{emoji} **#{label}**"] parts << "**Subject:** #{email["subject"]}" if email["subject"] parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"] parts << "**To:** #{email["toAddress"]}" if email["toAddress"] # Include body content — try webhook payload first, then fetch via API body_text = email["summary"].to_s.strip if body_text.empty? raw_html = (email["html"] || email["content"] || email["body"] || "").to_s body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/\s+/, " ").strip end # If still empty and show_body requested, fetch via Zoho Mail API body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"] if !body_text.empty? && rule["show_body"] body_text = "#{body_text[0..1800]}..." if body_text.length > 1800 parts << "```\n#{body_text}\n```" elsif !body_text.empty? body_text = "#{body_text[0..500]}..." if body_text.length > 500 parts << "```\n#{body_text}\n```" end parts.join("\n") end |
#forum_channel?(channel_id, token:) ⇒ Boolean
224 225 226 227 |
# File 'lib/zillacore/handlers/discord.rb', line 224 def forum_channel?(channel_id, token:) info = fetch_channel_info(channel_id, token: token) info && info["type"] == 15 end |
#get_default_branch(repo_path) ⇒ Object
75 76 77 78 79 80 81 82 83 |
# File 'lib/zillacore/handlers/fizzy.rb', line 75 def get_default_branch(repo_path) default_branch = run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: repo_path).strip begin run_cmd("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: repo_path).strip.sub("origin/", "") rescue StandardError default_branch end end |
#github_webhook_secret ⇒ Object
82 83 84 85 |
# File 'lib/zillacore/config.rb', line 82 def github_webhook_secret # Fallback to env var for backwards compatibility GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil) end |
#handle_agent_completion(**ctx) ⇒ Object
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 |
# File 'lib/zillacore/helpers.rb', line 591 def handle_agent_completion(**ctx) agent_exit_status = $CHILD_STATUS.exitstatus agent_signaled = $CHILD_STATUS.signaled? LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})" if ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled notify_agent_crash( exit_status: agent_exit_status, log_file: ctx[:log_file], agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config] ) end fizzy_card = ctx[:card_number] || ctx[:source_context][:card_number] handle_fizzy_post_session(fizzy_card, agent_exit_status, agent_signaled, ctx[:agent_name], ctx[:chdir], ctx[:source], ctx[:source_context], ctx[:project_config], ctx[:skip_column_move]) handle_plan_finalization(ctx[:prompt_file], ctx[:agent_name], ctx[:project_config]) qmd_out, qmd_status = Open3.capture2e("qmd", "update") if qmd_status.success? LOG.info "[Brain] qmd update completed after #{ctx[:agent_config_name] || "agent"} session" else LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}" end skill_candidate = detect_skill_candidate(ctx[:log_file]) if skill_candidate[:extract] LOG.info "[Skills] Session qualifies for skill extraction " \ "(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \ "— agent was nudged via reflection prompt" end brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}") check_zillacore_restart(ctx[:head_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name]) end |
#handle_card_assigned(payload) ⇒ Object
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/zillacore/handlers/fizzy.rb', line 104 def handle_card_assigned(payload) eventable = payload["eventable"] || {} assignees = eventable["assignees"] || [] # Check if any LOCAL agent was assigned. Only agents marked "local" in the # registry (or discovered from kiro-cli configs) should pick up assignments. # This prevents multiple machines from dispatching the same card. local_names = local_agent_names assigned_agent = assignees.map { |a| a["name"] }.find { |name| local_names.include?(name) } assignee_names = assignees.map { |a| a["name"] }.join(", ") LOG.info "[Fizzy] Card assigned to: [#{assignee_names}], local agents: [#{local_names.join(", ")}]" unless assigned_agent LOG.info "[Fizzy] No local agent matched. Assignees: [#{assignee_names}], Local: [#{local_names.join(", ")}]" return [200, { status: "ignored", reason: "wrong assignee" }.to_json] end unless (payload) creator_name = payload.dig("creator", "name") || "Unknown" ("card_assigned", creator_name, "card ##{eventable["number"]}") return [200, { status: "ignored", reason: "unauthorized" }.to_json] end card_number = eventable["number"] card_internal_id = eventable["id"] title = eventable["title"] || "untitled" = eventable["tags"] || [] # Identify project by tags project_result = () unless project_result LOG.warn "No project found for card ##{card_number} with tags: #{.map { |t| t.is_a?(Hash) ? t["name"] : t }.join(", ")}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end project_key, project_config = project_result repo_path = project_config["repo_path"] branch = "fizzy-#{card_number}-#{slugify(title)}" model = detect_model(project_config, tags: ) effort = detect_effort(project_config, tags: ) card_key = "card-#{card_number}" if session_active?(card_key) LOG.info "Skipping card ##{card_number} — agent session already active" return [200, { status: "ignored", reason: "session already active" }.to_json] end LOG.info "Card ##{card_number} assigned to #{assigned_agent} for project '#{project_key}', creating worktree: #{branch} (model: #{model || "default"})" # React in background — don't block the dispatch path Thread.new do emoji = "👍" run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--content", emoji, chdir: repo_path, env: fizzy_env_for(assigned_agent)) LOG.info "Added #{emoji} reaction to card ##{card_number} as #{assigned_agent}" rescue StandardError => e LOG.warn "Could not add reaction to card: #{e.}" end # Fetch latest from origin before creating worktree (doesn't touch working tree) debounced_repo_fetch(repo_path) # Create worktree (handle existing branch) worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}") # Get current worktree list once worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) # Check if worktree directory exists but is orphaned (not tracked by git) if File.directory?(worktree_path) is_tracked = worktree_list.include?(worktree_path) if is_tracked LOG.info "Worktree directory #{worktree_path} is tracked by git" else LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it" begin FileUtils.rm_rf(worktree_path) LOG.info "Successfully removed orphaned directory" rescue StandardError => e LOG.error "Failed to remove orphaned directory: #{e.}" raise end end end # Check if branch already exists branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL) if branch_exists LOG.info "Branch #{branch} already exists, checking for existing worktree" # Check if worktree already exists for this branch (refresh the list after potential cleanup) worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) # Parse worktree list - format is: worktree <path>\nHEAD <sha>\nbranch <ref>\n\n has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" } if has_worktree && File.directory?(worktree_path) LOG.info "Reusing existing worktree at #{worktree_path}" else # Branch exists but no worktree, create worktree from existing branch LOG.info "Creating worktree from existing branch #{branch}" run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path) end else # Branch doesn't exist, create new branch and worktree from origin LOG.info "Creating new branch #{branch} and worktree" default_branch = get_default_branch(repo_path) run_cmd("git", "worktree", "add", "-b", branch, worktree_path, "origin/#{default_branch}", chdir: repo_path) end # Trust version manager in the new worktree trust_version_manager(worktree_path, chdir: worktree_path) # Copy gitignored files and symlink directories per .worktreeinclude / .worktreelink apply_worktree_includes(repo_path, worktree_path) # Run project-level worktree-setup hook for anything .worktreeinclude/.worktreelink doesn't cover run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path }) map = load_card_map map[card_internal_id] = { "number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key, "agent" => assigned_agent } save_card_map(map) agent_name = assigned_agent card_context = prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name) # Detect planning mode planning_info = detect_planning_mode( text: title, tags: , card_internal_id: card_internal_id, card_number: card_number ) prompt = if planning_info # Planning mode card_id = planning_info[:card_id] LOG.info "[Planning] Planning mode active for card ##{card_number}" render_planning_prompt(PROMPT_CARD_ASSIGNED, { "CARD_NUMBER" => card_number, "CARD_TITLE" => title, "BRANCH" => branch, "CARD_ID" => card_id, "COMMENT_CREATOR" => assigned_agent }, brain_context: build_brain_context(agent_name: agent_name, card_title: title, card_number: card_number, project_key: project_key, source: :fizzy), card_context: card_context, agent_name: agent_name) else render_prompt(PROMPT_CARD_ASSIGNED, { "CARD_NUMBER" => card_number, "CARD_TITLE" => title, "BRANCH" => branch, "CARD_ID" => card_number, "COMMENT_CREATOR" => assigned_agent }, brain_context: build_brain_context(agent_name: agent_name, card_title: title, card_number: card_number, project_key: project_key, source: :fizzy), card_context: card_context, agent_name: agent_name) end pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "assigned-#{card_number}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, source: :fizzy, source_context: { card_number: card_number }) register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: assigned_agent) # Move card to Right Now — agent is starting work Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: assigned_agent) } [200, { status: "processed", card: card_number, branch: branch, project: project_key, agent: assigned_agent }.to_json] end |
#handle_card_published(payload) ⇒ Object
— Card duplicate detection (card_published / card_triaged) —
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/zillacore/handlers/fizzy.rb', line 5 def handle_card_published(payload) eventable = payload["eventable"] || {} card_number = eventable["number"] title = eventable["title"] || "" creator_name = payload.dig("creator", "name") creator_id = payload.dig("creator", "id") = eventable["tags"] || [] # Creator-based routing: only the machine whose local human created the card # handles dedup. Requires `"local": true` on the human in fizzy.json authorized_users. # If no local humans are configured, skip dedup entirely to avoid duplicate warnings # from multiple machines. local_humans = FIZZY_CONFIG.fetch("authorized_users", []).select { |u| u["human"] && u["local"] } if local_humans.empty? LOG.info "[CardIndex] No local humans configured — skipping dedup, indexing only" CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: ) if card_number CARD_INDEX.save CARD_INDEX.schedule_qmd_reindex return [200, { status: "indexed", card: card_number }.to_json] end is_local_creator = local_humans.any? { |u| u["id"] == creator_id } unless is_local_creator LOG.info "[CardIndex] Ignoring card ##{card_number} — creator '#{creator_name}' is not a local human" # Still index it so we can compare against it later CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: ) if card_number CARD_INDEX.save CARD_INDEX.schedule_qmd_reindex return [200, { status: "indexed", card: card_number }.to_json] end # Check for duplicates before indexing similar = CARD_INDEX.find_similar_cards(title, exclude_number: card_number, tags: ) if card_number # Index the new card CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: ) if card_number CARD_INDEX.save CARD_INDEX.schedule_qmd_reindex if similar&.any? best = similar.first LOG.info "[CardIndex] Potential duplicate: ##{card_number} '#{title}' ≈ ##{best[:number]} '#{best[:title]}' (score: #{best[:score].round(2)})" # Post a comment on the new card warning about the potential duplicate project_result = () if project_result _project_key, project_config = project_result repo_path = project_config["repo_path"] Thread.new do method_label = { trigram: "📝", semantic: "🧠", both: "📝🧠" } dupes = similar.map do |s| icon = method_label[s[:method]] || "📝" "##{s[:number]} \"#{s[:title]}\" (#{(s[:score] * 100).round}% #{icon})" end.join("\n- ") body = "⚠️ **Possible duplicate detected:**\n- #{dupes}\n\n_📝 = text similarity, 🧠 = semantic similarity_" run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body, chdir: repo_path, env: default_fizzy_env) LOG.info "[CardIndex] Posted duplicate warning on card ##{card_number}" rescue StandardError => e LOG.warn "[CardIndex] Failed to post duplicate warning: #{e.}" end end [200, { status: "duplicate_detected", card: card_number, similar: similar.map { |s| { number: s[:number], score: s[:score].round(2) } } }.to_json] else LOG.info "[CardIndex] Card ##{card_number} '#{title}' indexed, no duplicates found" [200, { status: "indexed", card: card_number }.to_json] end end |
#handle_comment(payload) ⇒ Object
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 |
# File 'lib/zillacore/handlers/fizzy.rb', line 441 def handle_comment(payload) eventable = payload["eventable"] || {} plain_text = eventable.dig("body", "plain_text") || "" card_internal_id = eventable.dig("card", "id") # --- Deploy shortcut: comment is just "dev02" (or any dev\d+) --- return handle_deploy_comment(eventable, plain_text.strip.downcase, card_internal_id) if plain_text.strip.match?(/\Adev\d+\z/i) # Detect which agent (if any) is @mentioned in the comment mentioned_agent = detect_mentioned_agent(plain_text) # Check if any humans are @mentioned — if so, skip agent dispatch mentioned_user_ids = detect_mentioned_user_ids(plain_text) if mentioned_user_ids.any? { |id| human_mentioned?(id) } LOG.info "[Fizzy] Human @mentioned in comment, skipping agent dispatch" return [200, { status: "ignored", reason: "human mentioned" }.to_json] end # If an agent is mentioned but not local to this machine, ignore the comment. # This prevents multiple machines from dispatching the same agent mention. if mentioned_agent && !local_agent_names.include?(mentioned_agent) LOG.info "[Fizzy] Ignoring mention of non-local agent #{mentioned_agent}" return [200, { status: "ignored", reason: "non-local agent mentioned" }.to_json] end mentioned = !mentioned_agent.nil? creator_name = eventable.dig("creator", "name") creator_id = eventable.dig("creator", "id") creator_is_agent = comment_from_agent?(creator_name) # Also check the top-level event creator in case the payload structure differs event_creator_name = payload.dig("creator", "name") creator_is_agent ||= comment_from_agent?(event_creator_name) # Ignore comments created via API (likely by us via fizzy CLI) source = eventable["source"] || payload["source"] is_api_sourced = source && source != "web" # --- Authorization check (must happen before agent logic) --- # Human comments must be from authorized users unless creator_is_agent || is_api_sourced unless AUTHORIZED_USER_IDS.include?(creator_id) ("comment_created", creator_name, "card #{card_internal_id}") return [200, { status: "ignored", reason: "unauthorized" }.to_json] end # Human comment — reset the dispatch depth counter for this card record_human_comment(card_internal_id) # --- Cancel detection (human-only, before any dispatch logic) --- cancel_keywords = %w[cancel stop halt abort kill ❌] if cancel_keywords.include?(plain_text.strip.downcase) killed = 0 card_number_for_cancel = load_card_map.dig(card_internal_id, "number") prefixes = ["card-#{card_internal_id}"] prefixes << "card-#{card_number_for_cancel}" if card_number_for_cancel ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS.keys.select { |k| prefixes.any? { |p| k == p || k.start_with?("#{p}-") } }.each do |key| info = ACTIVE_SESSIONS[key] next unless info begin Process.kill("KILL", info[:pid]) LOG.info "[Fizzy] Cancelled session #{key} (PID: #{info[:pid]})" rescue Errno::ESRCH, Errno::EPERM => e LOG.warn "[Fizzy] Could not kill #{key}: #{e.}" end archive_session(key, info) ACTIVE_SESSIONS.delete(key) killed += 1 end end # Add 🛑 reaction to the cancel comment comment_id_for_cancel = eventable["id"] card_info_for_cancel = load_card_map[card_internal_id] if card_info_for_cancel && card_number_for_cancel && comment_id_for_cancel repo = (card_info_for_cancel["project"] && PROJECTS.dig(card_info_for_cancel["project"], "repo_path")) || DEFAULT_PROJECT["repo_path"] Thread.new do run_cmd("fizzy", "reaction", "create", "--card", card_number_for_cancel.to_s, "--comment", comment_id_for_cancel.to_s, "--content", "🛑", chdir: repo, env: default_fizzy_env) rescue StandardError => e LOG.warn "[Fizzy] Could not add 🛑 reaction: #{e.}" end end LOG.info "[Fizzy] Cancel command received for card #{card_number_for_cancel || card_internal_id}: killed #{killed} session(s)" return [200, { status: "cancelled", card: card_number_for_cancel || card_internal_id, sessions_killed: killed }.to_json] end end # --- Agent comment validation --- # Agents can only act on cards where they're assigned or explicitly @mentioned. # This prevents agents from hijacking unrelated cards. if creator_is_agent || is_api_sourced card_info = load_card_map[card_internal_id] card_assigned_agent = card_info&.dig("agent") # Agent is allowed if: # 1. They're assigned to this card, OR # 2. They're explicitly @mentioned in this comment agent_is_assigned = card_assigned_agent && card_assigned_agent.downcase == (creator_name || "").downcase agent_is_mentioned = mentioned_agent && mentioned_agent.downcase == (creator_name || "").downcase unless agent_is_assigned || agent_is_mentioned LOG.info "Blocking agent comment from #{creator_name} on card #{card_internal_id}: not assigned and not mentioned" return [200, { status: "ignored", reason: "agent not assigned or mentioned" }.to_json] end # --- Agent-to-agent loop prevention --- # If the agent is @mentioning a *different* agent, check dispatch depth if mentioned_agent && mentioned_agent.downcase != (creator_name || "").downcase unless agent_dispatch_allowed?(card_internal_id) LOG.info "Blocking agent-to-agent dispatch on card #{card_internal_id}: depth limit reached (#{creator_name} → @#{mentioned_agent})" return [200, { status: "ignored", reason: "agent-to-agent depth limit" }.to_json] end LOG.info "Allowing agent-to-agent dispatch on card #{card_internal_id}: #{creator_name} → @#{mentioned_agent}" # Fall through — this agent mention will be processed below elsif !mentioned_agent # Agent comment with no @mention — this is a self-comment, ignore it LOG.info "Ignoring self-comment from #{creator_name} on card #{card_internal_id}" return [200, { status: "ignored", reason: "self-comment" }.to_json] end # If mentioned_agent == creator_name, that's the agent mentioning themselves, # which is weird but harmless — let it through (will be handled as self-comment below) end comment_id = eventable["id"] card_info = load_card_map[card_internal_id] return [200, { status: "ignored", reason: "not relevant" }.to_json] unless mentioned || card_info # Get project config from card_info or detect from tags project_config = nil project_key = nil if card_info if card_info["project"] project_key = card_info["project"] project_config = PROJECTS[project_key] || DEFAULT_PROJECT else # card_info exists but was registered before project tracking — resolve from tags = eventable.dig("card", "tags") || [] project_result = () if project_result project_key, project_config = project_result # Backfill the project key into the card map card_info["project"] = project_key map = load_card_map map[card_internal_id] = card_info save_card_map(map) LOG.info "Backfilled project '#{project_key}' for card #{card_internal_id} in card map" else LOG.warn "No project found for card #{card_internal_id}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end end elsif mentioned # Try to detect project from card tags = eventable.dig("card", "tags") || [] project_result = () if project_result project_key, project_config = project_result else LOG.warn "No project found for mentioned card #{card_internal_id}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end end # Check for [deploy] or [deploy:envN] tag — triggers auto-deploy after agent session deploy_intent = nil if (deploy_match = plain_text.match(/\[deploy(?::([^\]]+))?\]/i)) deploy_intent = deploy_match[1]&.strip&.downcase || :auto # :auto means "auto-detect env" plain_text = plain_text.sub(deploy_match[0], "").strip LOG.info "[Deploy] Detected [deploy#{":#{deploy_intent}" unless deploy_intent == :auto}] tag on card #{card_internal_id}" end # Strip [effort:X] tag from prompt content (detect_effort reads from original text via tags + inline) effort_text_for_detection = plain_text plain_text = plain_text.sub(/\[effort:\w+\]/i, "").strip # Check for [worktree:branch-name] override in comment text — lets you direct # Galen to a specific branch/worktree instead of the one in the card map. worktree_override = nil if (wt_match = plain_text.match(/\[worktree:([^\]]+)\]/)) override_branch = wt_match[1].strip repo_path_for_override = project_config["repo_path"] candidate = File.join(File.dirname(repo_path_for_override), "#{File.basename(repo_path_for_override)}--#{override_branch}") if File.directory?(candidate) worktree_override = { "branch" => override_branch, "worktree" => candidate } LOG.info "Worktree override requested: #{override_branch} -> #{candidate}" else LOG.warn "Worktree override branch '#{override_branch}' not found at #{candidate}, ignoring" end end model = detect_model(project_config, text: plain_text) effort = detect_effort(project_config, tags: , text: effort_text_for_detection) # Determine which agent should handle this comment. # # Only local agents (marked with "local": true in ~/.zillacore/agents.json or # discovered from ~/.kiro/agents/*.json configs) can be dispatched on this machine. # Non-local agents are filtered out earlier in the flow. # # - If @Galen is mentioned and Galen is local, dispatch Galen # - If no agent is mentioned but the card is in our card_map, the card's assigned agent handles it # - If the mentioned agent differs from the card's assigned agent, it's a cross-agent review card_assigned_agent = card_info&.dig("agent") # When card_info is nil (card not in map), try to resolve the assigned agent # from the webhook payload's card assignees. This handles reactivated cards # or cards that were cleared from the map. if card_assigned_agent.nil? card_assignees = eventable.dig("card", "assignees") || [] webhook_agent = card_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) } # Webhook payload often lacks assignees — query Fizzy API as fallback if webhook_agent.nil? && project_config api_card_number = card_info&.dig("number") || eventable.dig("card", "number") if api_card_number begin output = run_cmd("fizzy", "card", "show", api_card_number.to_s, chdir: project_config["repo_path"], env: default_fizzy_env) api_assignees = begin JSON.parse(output).dig("data", "assignees") || [] rescue StandardError [] end webhook_agent = api_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) } LOG.info "Resolved assigned agent '#{webhook_agent}' via Fizzy API for card ##{api_card_number}" if webhook_agent rescue StandardError => e LOG.warn "Fizzy API fallback failed for card ##{api_card_number}: #{e.}" end end end if webhook_agent card_assigned_agent = webhook_agent # Backfill the card map so subsequent comments work without this fallback map = load_card_map map[card_internal_id] ||= {} map[card_internal_id]["agent"] = webhook_agent save_card_map(map) LOG.info "Backfilled agent '#{webhook_agent}' into card map for #{card_internal_id}" end end if mentioned_agent agent_name = mentioned_agent # If the mentioned agent differs from the card's assigned agent, this is a # cross-agent mention (e.g. "@Galen what do you think?" on Kaylee's card). # The mentioned agent should review/discuss, not take over the card's worktree. is_cross_agent_mention = !card_assigned_agent || card_assigned_agent != mentioned_agent else # If no agent is assigned and none was mentioned, don't fall back to the # project default — that causes orphaned card map entries to dispatch the # wrong agent (e.g. Kaylee getting triggered on Sheogorath's card). unless card_assigned_agent LOG.info "Skipping card #{card_internal_id} — no assigned agent and no mention" return [200, { status: "ignored", reason: "no assigned agent" }.to_json] end agent_name = card_assigned_agent is_cross_agent_mention = false end # Per-card comment cooldown — suppress rapid-fire near-duplicate triggers. # Include agent name in the key so cross-agent mentions don't block each other. cooldown_key = "card-#{card_info ? (card_info["number"] || card_internal_id) : card_internal_id}-#{agent_name.downcase}" if on_comment_cooldown?(cooldown_key) LOG.info "Skipping comment on #{cooldown_key} — within #{COMMENT_COOLDOWN}s cooldown" return [200, { status: "ignored", reason: "comment cooldown" }.to_json] end touch_comment_cooldown(cooldown_key) # Common template vars for the triggering comment comment_vars = { "COMMENT_CREATOR" => creator_name || "Unknown", "COMMENT_ID" => comment_id.to_s, "COMMENT_BODY" => plain_text } # --- Cross-agent mention: an agent is tagged on a card owned by a different agent --- # e.g. Kaylee is working on card #42, Andy comments "@Galen what do you think?" # Galen reviews and responds without touching Kaylee's worktree. # Also handles: SecurityBot tagged on Galen's card to audit the code. if is_cross_agent_mention # Skip dispatch when the comment is a card creation/assignment announcement. # The Fizzy webhook handles card assignments — dispatching here too causes # the mentioned agent to respond on the *original* card instead of the new one. if creator_is_agent && (plain_text.match?(/created\s+card\s+#?\d+/i) || plain_text.match?(/assigned\s+.*card\s+#?\d+/i) || plain_text.match?(/card\s+#?\d+.*assigned/i)) LOG.info "Ignoring cross-agent mention from #{creator_name} on card #{card_internal_id} — Fizzy card creation/assignment (handled by webhook)" return [200, { status: "ignored", reason: "card creation announcement" }.to_json] end card_number = card_info&.dig("number") # Resolve card_number if missing if card_number.nil? card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"]) if card_number map = load_card_map map[card_internal_id] ||= {} map[card_internal_id]["number"] = card_number save_card_map(map) end end card_key = "card-#{card_number || card_internal_id}-#{agent_name.downcase}" if creator_is_agent && session_active?(card_key) unless wait_for_session?(card_key) LOG.info "Giving up on cross-agent dispatch for #{agent_name} on card #{card_number || card_internal_id} — session didn't finish in time" return [200, { status: "ignored", reason: "session wait timeout" }.to_json] end elsif session_active?(card_key) LOG.info "Skipping cross-agent mention for #{agent_name} on card #{card_number || card_internal_id} — session already active" return [200, { status: "ignored", reason: "session already active" }.to_json] end LOG.info "Cross-agent mention: #{agent_name} tagged on #{card_assigned_agent}'s card ##{card_number || card_internal_id} (project: #{project_key})" # Record this agent-to-agent dispatch for loop prevention record_agent_dispatch(card_internal_id) if creator_is_agent # React in background — don't block the dispatch path Thread.new do if card_number run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👀", chdir: project_config["repo_path"], env: fizzy_env_for(agent_name)) LOG.info "Added 👀 reaction to comment ##{comment_id} for #{agent_name}" end rescue StandardError => e LOG.warn "Could not add reaction to comment: #{e.}" end # Create a worktree for the cross-agent reviewer so they don't clobber the # main repo's working tree (or the assigned agent's worktree). repo_path = project_config["repo_path"] review_branch = "#{agent_name.downcase}/fizzy-#{card_number}-#{slugify(card_info&.dig("title") || eventable.dig("card", "title") || "review")}" review_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{review_branch.tr("/", "-")}") debounced_repo_fetch(repo_path) # Reuse existing worktree or create a new one if File.directory?(review_worktree_path) worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) FileUtils.rm_rf(review_worktree_path) unless worktree_list.include?(review_worktree_path) end if File.directory?(review_worktree_path) LOG.info "Reusing existing cross-agent review worktree at #{review_worktree_path}" else # Branch from the card's branch if it exists, otherwise from origin default card_branch = card_info&.dig("branch") branch_exists = card_branch && system("git", "rev-parse", "--verify", card_branch, chdir: repo_path, out: File::NULL, err: File::NULL) base_ref = branch_exists ? card_branch : "origin/#{get_default_branch(repo_path)}" # Delete stale local branch if it exists (from a previous review) if system("git", "rev-parse", "--verify", review_branch, chdir: repo_path, out: File::NULL, err: File::NULL) run_cmd("git", "branch", "-D", review_branch, chdir: repo_path) end run_cmd("git", "worktree", "add", "-b", review_branch, review_worktree_path, base_ref, chdir: repo_path) trust_version_manager(review_worktree_path, chdir: review_worktree_path) apply_worktree_includes(repo_path, review_worktree_path) run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => review_worktree_path }) LOG.info "Created cross-agent review worktree at #{review_worktree_path} (base: #{base_ref})" end card_context = prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name) prompt = render_prompt(PROMPT_CROSS_AGENT_REVIEW, comment_vars.merge( "CARD_NUMBER" => card_number || "N/A", "CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_number || card_internal_id, "CARD_AGENT" => card_assigned_agent, "WORKTREE_PATH" => review_worktree_path, "BRANCH" => review_branch ), brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: card_context, agent_name: agent_name) pid, log_file = run_agent(prompt, project_config: project_config, chdir: review_worktree_path, log_name: "review-#{agent_name.downcase}-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id, source: :fizzy, source_context: { card_number: card_number }) register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name) return [200, { status: "cross_agent_review", agent: agent_name, card_agent: card_assigned_agent, card: card_number, card_internal_id: card_internal_id, project: project_key, worktree: review_worktree_path }.to_json] end if card_info || worktree_override # Merge worktree override into card_info if provided effective_info = worktree_override ? (card_info || {}).merge(worktree_override) : card_info card_number = effective_info["number"] worktree = effective_info["worktree"] # Resolve card_number if missing from the map entry if card_number.nil? card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"]) if card_number # Backfill into card map for next time map = load_card_map map[card_internal_id] ||= {} map[card_internal_id]["number"] = card_number save_card_map(map) LOG.info "Backfilled card number #{card_number} for #{card_internal_id}" end end # If worktree is missing or gone, try to find one by card number on disk if !(worktree && File.directory?(worktree)) && card_number repo_dir = File.dirname(project_config["repo_path"]) repo_base = File.basename(project_config["repo_path"]) candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*")).select { |d| File.directory?(d) } if candidates.any? worktree = candidates.first branch_name = File.basename(worktree).sub("#{repo_base}--", "") # Backfill worktree + branch into card map map = load_card_map map[card_internal_id] ||= {} map[card_internal_id].merge!("worktree" => worktree, "branch" => branch_name) save_card_map(map) LOG.info "Found worktree by card number scan: #{worktree} (branch: #{branch_name})" end end work_dir = worktree && File.directory?(worktree) ? worktree : project_config["repo_path"] card_key = "card-#{card_number || card_internal_id}" # If an agent tagged this card's own agent back (e.g. GLaDOS tags @Galen on # Galen's card), the original agent may still be running. Wait for it to finish # rather than dropping the dispatch — the depth system already validated this. if creator_is_agent && session_active?(card_key) unless wait_for_session?(card_key) LOG.info "Giving up on agent-to-agent dispatch for card #{card_number || card_internal_id} — session didn't finish in time" return [200, { status: "ignored", reason: "session wait timeout" }.to_json] end elsif session_active?(card_key) # Supersede: if the human comments within 60s, kill the previous run and start fresh prev = find_supersedable_session(card_key) if prev LOG.info "Superseding session on card #{card_number || card_internal_id} (pid: #{prev[:pid]}) — human follow-up within #{SUPERSEDE_WINDOW}s" kill_session(prev[:session_key]) # Fall through to dispatch fresh below else # After 60s: queue and wait for the active session to finish, then dispatch LOG.info "Queuing follow-up comment on card #{card_number || card_internal_id} — waiting for active session to finish" # React immediately so the human knows we saw it Thread.new do run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👍", chdir: work_dir, env: fizzy_env_for(agent_name)) LOG.info "Added 👍 reaction to queued comment ##{comment_id} as #{agent_name}" rescue StandardError => e LOG.warn "Could not add reaction to queued comment: #{e.}" end Thread.new do unless wait_for_session?(card_key) LOG.warn "Giving up on queued follow-up for card #{card_number || card_internal_id} — session didn't finish in time" next end LOG.info "Active session finished, dispatching queued follow-up for card #{card_number || card_internal_id}" dispatch_followup_comment( card_key: card_key, card_number: card_number, card_internal_id: card_internal_id, work_dir: work_dir, project_config: project_config, project_key: project_key, comment_vars: comment_vars, plain_text: plain_text, model: model, agent_name: agent_name, comment_id: comment_id, eventable: eventable, deploy_intent: deploy_intent ) end return [200, { status: "queued", card: card_number, card_internal_id: card_internal_id, reason: "waiting for active session" }.to_json] end end LOG.info "Follow-up comment on card #{card_number || card_internal_id} (project: #{project_key}), worktree: #{work_dir}" # React in background — don't block the dispatch path Thread.new do run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👍", chdir: work_dir, env: fizzy_env_for(agent_name)) LOG.info "Added 👍 reaction to comment ##{comment_id} as #{agent_name}" rescue StandardError => e LOG.warn "Could not add reaction to comment: #{e.}" end result = dispatch_followup_comment( card_key: card_key, card_number: card_number, card_internal_id: card_internal_id, work_dir: work_dir, project_config: project_config, project_key: project_key, comment_vars: comment_vars, plain_text: plain_text, model: model, agent_name: agent_name, comment_id: comment_id, eventable: eventable, deploy_intent: deploy_intent ) [200, result.to_json] else # Get card data to extract number and title card_data = eventable["card"] || {} card_number = card_data["number"] card_title = card_data["title"] || "exploration" # If card_number is missing from the webhook payload, resolve it via fizzy CLI, # falling back to the card map as a cheap cache. if card_number.nil? map_entry = load_card_map[card_internal_id] if map_entry && map_entry["number"] card_number = map_entry["number"] LOG.info "Resolved card number #{card_number} from card map for internal_id #{card_internal_id}" else card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"]) end end LOG.info "#{agent_name} mentioned on card (internal_id: #{card_internal_id}, project: #{project_key}), creating exploration worktree" # Record agent-to-agent dispatch for loop prevention record_agent_dispatch(card_internal_id) if creator_is_agent card_key = "card-#{card_number || card_internal_id}" if session_active?(card_key) LOG.info "Skipping mention on card #{card_number || card_internal_id} — agent session already active" return [200, { status: "ignored", reason: "session already active" }.to_json] end # React in background — don't block the dispatch path Thread.new do if card_number run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "👀", chdir: project_config["repo_path"], env: fizzy_env_for(agent_name)) LOG.info "Added 👀 reaction to comment ##{comment_id} as #{agent_name}" else LOG.warn "Could not add reaction: card number not available in webhook payload or card map" end rescue StandardError => e LOG.warn "Could not add reaction to comment: #{e.}" end # Create exploration branch and worktree repo_path = project_config["repo_path"] # Check if the card already has a branch/worktree in the map (e.g. registered # by a previous assign event). If so, reuse it rather than spinning up a new one. # Also check by card number in case the map entry predates project tracking. existing_map_entry = load_card_map[card_internal_id] # If the map entry has a valid worktree, use it directly if existing_map_entry && existing_map_entry["branch"] && existing_map_entry["worktree"] && File.directory?(existing_map_entry["worktree"]) branch = existing_map_entry["branch"] worktree_path = existing_map_entry["worktree"] LOG.info "Reusing existing worktree from card map: #{worktree_path} (branch: #{branch})" elsif card_number # Map entry missing or stale — scan for any worktree directory matching fizzy-NNN-* repo_dir = File.dirname(repo_path) repo_base = File.basename(repo_path) pattern = File.join(repo_dir, "#{repo_base}--fizzy-#{card_number}-*") candidates = Dir.glob(pattern).select { |d| File.directory?(d) } if candidates.any? worktree_path = candidates.first branch = File.basename(worktree_path).sub("#{repo_base}--", "") LOG.info "Found existing worktree by card number scan: #{worktree_path} (branch: #{branch})" end end if worktree_path && File.directory?(worktree_path) LOG.info "Reusing worktree at #{worktree_path} (branch: #{branch})" map = load_card_map map[card_internal_id] ||= {} map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key, "agent" => agent_name) save_card_map(map) # Detect planning mode = eventable.dig("card", "tags") || [] planning_info = detect_planning_mode( text: plain_text, tags: , card_internal_id: card_internal_id, card_number: card_number ) prompt = if planning_info # Planning mode card_id = planning_info[:card_id] LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}" render_planning_prompt(PROMPT_MENTION, comment_vars.merge( "CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id, "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "", "BRANCH" => branch ), brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name), agent_name: agent_name) else render_prompt(PROMPT_MENTION, comment_vars.merge( "CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_number || card_internal_id, "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "", "BRANCH" => branch ), brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name), agent_name: agent_name) end pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id, source: :fizzy, source_context: { card_number: card_number }) register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name) return [200, { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path, project: project_key }.to_json] end branch = card_number ? "fizzy-#{card_number}-#{slugify(card_title)}" : "fizzy-explore-#{card_internal_id[0..7]}" # Fetch latest from origin (doesn't touch working tree) debounced_repo_fetch(repo_path) # Create worktree (handle existing branch) worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}") # Get current worktree list once worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) # Check if worktree directory exists but is orphaned (not tracked by git) if File.directory?(worktree_path) is_tracked = worktree_list.include?(worktree_path) if is_tracked LOG.info "Worktree directory #{worktree_path} is tracked by git" else LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it" begin FileUtils.rm_rf(worktree_path) LOG.info "Successfully removed orphaned directory" rescue StandardError => e LOG.error "Failed to remove orphaned directory: #{e.}" raise end end end # Check if branch already exists branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL) if branch_exists LOG.info "Branch #{branch} already exists, checking for existing worktree" # Check if worktree already exists for this branch (refresh the list after potential cleanup) worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path) # Parse worktree list - format is: worktree <path>\nHEAD <sha>\nbranch <ref>\n\n has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" } if has_worktree && File.directory?(worktree_path) LOG.info "Reusing existing worktree at #{worktree_path}" else # Branch exists but no worktree, create worktree from existing branch LOG.info "Creating worktree from existing branch #{branch}" run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path) end else # Branch doesn't exist, create new branch and worktree from origin LOG.info "Creating new exploration branch #{branch} and worktree" default_branch = get_default_branch(repo_path) run_cmd("git", "worktree", "add", "-b", branch, worktree_path, "origin/#{default_branch}", chdir: repo_path) end # Trust version manager in the new worktree trust_version_manager(worktree_path, chdir: worktree_path) # Copy gitignored files and symlink directories per .worktreeinclude / .worktreelink apply_worktree_includes(repo_path, worktree_path) # Run project-level worktree-setup hook for anything .worktreeinclude/.worktreelink doesn't cover run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path }) map = load_card_map map[card_internal_id] = { "number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key, "agent" => agent_name } save_card_map(map) # Detect planning mode = eventable.dig("card", "tags") || [] planning_info = detect_planning_mode( text: plain_text, tags: , card_internal_id: card_internal_id, card_number: card_number ) prompt = if planning_info # Planning mode card_id = planning_info[:card_id] LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}" render_planning_prompt(PROMPT_MENTION, comment_vars.merge( "CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id, "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "", "BRANCH" => branch ), brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name), agent_name: agent_name) else render_prompt(PROMPT_MENTION, comment_vars.merge( "CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_number || card_internal_id, "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "", "BRANCH" => branch ), brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key, comment_body: plain_text, source: :fizzy), card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name), agent_name: agent_name) end pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id, source: :fizzy, source_context: { card_number: card_number }) register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name) [200, { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path, project: project_key }.to_json] end end |
#handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file) ⇒ Object
Handle post-execution: extract response from log, update job state
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 |
# File 'lib/zillacore/cron.rb', line 427 def handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, ) cron_exit_status = $CHILD_STATUS.exitstatus LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})" if cron_exit_status && cron_exit_status != 0 && job[:discord_channel_id] bot_token = discord_bot_tokens[agent_config_name] || discord_bot_tokens.values.first if bot_token notify_agent_crash( exit_status: cron_exit_status, log_file: log_file, agent_name: agent_name, source: :discord, source_context: { channel_id: job[:discord_channel_id], bot_token: bot_token }, project_config: project ) end end extract_cron_response_from_log(job, agent_config_name, log_file, response_file, ) qmd_out, qmd_status = Open3.capture2e("qmd", "update") if qmd_status.success? LOG.info "[Brain] qmd update completed after cron job #{job[:id]}" else LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}" end brain_push(message: "#{agent_config_name}: cron-#{job[:id]}") update_cron_job_state(job) if File.exist?(response_file) LOG.info "[Cron] Job #{job[:id]} completed. Response: #{File.read(response_file)[0..100]}..." else LOG.warn "[Cron] Job #{job[:id]} produced no response" end end |
#handle_deploy_comment(eventable, env_key, card_internal_id) ⇒ Object
Deploy a card’s worktree to a dev environment via comment shortcut. Comment is just “dev02” etc. — no agent dispatch, reactions only.
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/zillacore/handlers/fizzy.rb', line 288 def handle_deploy_comment(eventable, env_key, card_internal_id) comment_id = eventable["id"] card_info = load_card_map[card_internal_id] # Validate environment exists in deployments config (check early, before any worktree work) deploy_config = DEPLOYMENTS_CONFIG["environments"] || {} unless deploy_config.key?(env_key) LOG.warn "[Deploy] Unknown environment: #{env_key}" return [200, { status: "ignored", reason: "unknown environment" }.to_json] end # Check environment ownership — only deploy if this machine owns the env env_owner = deploy_config[env_key]["owner"] unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase LOG.info "[Deploy] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}" return [200, { status: "ignored", reason: env_owner ? "owned by #{env_owner}" : "no owner configured" }.to_json] end worktree = card_info&.dig("worktree") card_number = card_info&.dig("number") # If worktree doesn't exist locally, try to clone the branch from origin if worktree.nil? || !File.directory?(worktree) result = clone_branch_for_deploy(eventable, card_internal_id, card_info) unless result LOG.warn "[Deploy] Could not resolve or clone branch for card #{card_internal_id}" return [200, { status: "ignored", reason: "no worktree and could not clone branch" }.to_json] end worktree = result[:worktree] card_number = result[:card_number] end deploy_script = File.join(worktree, "scripts", "deploy.sh") unless File.exist?(deploy_script) LOG.warn "[Deploy] No deploy script at #{deploy_script}" return [200, { status: "ignored", reason: "no deploy script" }.to_json] end LOG.info "[Deploy] Deploying card ##{card_number} worktree to #{env_key}" # Mark environment as deploying (for waybar yellow/orange border) (env_key, worktree_path: worktree) # React with 🚀 (deploying) and run deploy in background Thread.new do # Add pending reaction run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "🚀", chdir: worktree, env: default_fizzy_env) # Build deploy environment (inject AWS_PROFILE if configured) deploy_env = {} aws_profile = DEPLOYMENTS_CONFIG.dig("environments", env_key, "aws_profile") deploy_env["AWS_PROFILE"] = aws_profile if aws_profile # Run deploy (with terraform lock file retry) stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree) if !status.success? && terraform_lock_error?(stdout, stderr) LOG.info "[Deploy] Terraform lock file mismatch for card ##{card_number} — retrying with init -upgrade" infra_dir = File.join(worktree, "infrastructure", env_key) lock_file = File.join(infra_dir, ".terraform.lock.hcl") FileUtils.rm_f(lock_file) Open3.capture3(deploy_env, "terraform", "init", "-upgrade", chdir: infra_dir) if File.directory?(infra_dir) stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree) end if status.success? LOG.info "[Deploy] Successfully deployed card ##{card_number} to #{env_key}" run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "✅", chdir: worktree, env: default_fizzy_env) deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "fizzy-comment") else LOG.error "[Deploy] Failed deploying card ##{card_number} to #{env_key}: #{stderr}" run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "❌", chdir: worktree, env: default_fizzy_env) record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr) end rescue StandardError => e LOG.error "[Deploy] Error deploying card ##{card_number} to #{env_key}: #{e.}" begin run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s, "--comment", comment_id.to_s, "--content", "❌", chdir: worktree, env: default_fizzy_env) rescue StandardError => inner LOG.warn "[Deploy] Could not add failure reaction: #{inner.}" end end [200, { status: "deploying", card: card_number, env: env_key }.to_json] end |
#handle_discord_message(message, agent_key, bot_token, bot_user_id) ⇒ Object
Handle an incoming Discord message for a specific agent bot. agent_key: the lowercase agent key (e.g. “galen”) bot_token: the Discord bot token for this agent bot_user_id: the Discord user ID of this bot
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 |
# File 'lib/zillacore/handlers/discord.rb', line 422 def (, agent_key, bot_token, bot_user_id) channel_id = ["channel_id"] = ["id"] = ["author"] content = ["content"] || "" is_bot = !["bot"].nil? # Identify if the author is a known agent bot (local or remote). # Local agents are in DISCORD_BOTS; remote agents (running on other machines) # are recognized via discord.json "user_mappings". sender_agent_key = nil if is_bot sender_id = ["id"] # Check local bots first DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS.each do |key, info| if info[:user_id] == sender_id && key != agent_key sender_agent_key = key break end end end # Check user_mappings for remote agents unless sender_agent_key user_mappings = DISCORD_CONFIG["user_mappings"] || {} user_mappings.each do |name, discord_id| if discord_id == sender_id sender_agent_key = name.downcase break end end end # Unknown bot or self — ignore entirely unless sender_agent_key LOG.info "[Discord:#{agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{["username"]}, bot=#{["bot"]}" return end end mentions = ["mentions"] || [] mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s } # Discord doesn't always populate the mentions array for bot-to-bot mentions. # Check the raw content for mention patterns as a fallback. mentioned ||= content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/) # Check for @everyone mention (DISABLED — agents need to cool it) # unless mentioned # mentioned = message['mention_everyone'] == true # end # Cross-agent bot mention: only proceed if this bot is explicitly @mentioned # and the dispatch depth hasn't been exceeded (prevents infinite loops). if sender_agent_key unless mentioned fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize agent_display = fizzy_display_name(agent_key) || agent_key.capitalize # LOG.info "[Discord:#{agent_display}] Ignoring cross-agent message from #{sender_display} — not mentioned (content: #{content[0..100]})" return end # Skip dispatch when the message is a Fizzy card creation/assignment # announcement. The Fizzy webhook handles card assignments — dispatching # here too causes the mentioned agent to respond in Discord instead of # (or in addition to) the new card. if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i) sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize agent_display = fizzy_display_name(agent_key) || agent_key.capitalize LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — Fizzy card creation/assignment (handled by webhook)" return end depth_key = "discord-#{channel_id}" unless agent_dispatch_allowed?(depth_key) sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize agent_display = fizzy_display_name(agent_key) || agent_key.capitalize LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached" return end record_agent_dispatch(depth_key) end # Detect if the message is a reply to one of this bot's own messages. # Discord replies include a `message_reference` but don't automatically add # the referenced author to the `mentions` array, so we check explicitly. # We also cache the referenced message for later use as reply context. is_reply_to_bot = false = nil if ["message_reference"] ref_msg_id = .dig("message_reference", "message_id") ref_channel = .dig("message_reference", "channel_id") || channel_id if ref_msg_id = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token) is_reply_to_bot = !mentioned && && .dig("author", "id") == bot_user_id end end # Detect if inside a thread (follow-up conversation) or a DM. # Only call the API if the message doesn't already have an explicit @mention, # to avoid unnecessary API calls on every message. channel_info = nil is_thread = false is_dm = false in_own_thread = false if !mentioned && !is_reply_to_bot channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token) is_thread = channel_info && [11, 12].include?(channel_info["type"]) is_dm = channel_info && channel_info["type"] == 1 in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id end # If we'd respond only because we own the thread (not explicitly mentioned, # not a reply to us), check whether the human is explicitly talking to a # DIFFERENT agent. If so, stand down — they're directing the conversation # elsewhere and we shouldn't butt in. if in_own_thread && !mentioned && !is_reply_to_bot && !is_bot other_bot_mentioned = false DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS.each do |key, info| next if key == agent_key # skip self next unless info[:user_id] next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } || content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/) other_bot_mentioned = true break end end # Also check user_mappings for remote agent bots unless other_bot_mentioned user_mappings = DISCORD_CONFIG["user_mappings"] || {} user_mappings.each_value do |discord_id| next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } || content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/) other_bot_mentioned = true break end end if other_bot_mentioned agent_display = fizzy_display_name(agent_key) || agent_key.capitalize LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent" return end end # In DMs, threads the bot created, and replies to the bot's own messages, # respond without requiring an explicit @mention. # In guild channels, require an explicit @mention. return unless mentioned || in_own_thread || is_dm || is_reply_to_bot # Human message resets the cross-agent dispatch depth for this channel/thread record_human_comment("discord-#{channel_id}") unless is_bot clean_content = content.gsub(/<@!?#{bot_user_id}>/, "").strip # Handle attachments (images, gifs, etc.) = ["attachments"] || [] = [] agent_display = fizzy_display_name(agent_key) || agent_key.capitalize .each do |att| url = att["url"] filename = att["filename"] content_type = att["content_type"] || "" # Only process image attachments next unless content_type.start_with?("image/") # Download to temp directory temp_dir = File.join(ZILLACORE_DIR, "tmp", "discord", "attachments") FileUtils.mkdir_p(temp_dir) temp_path = File.join(temp_dir, "#{}-#{filename}") begin uri = URI(url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true response = http.get(uri.path + (uri.query ? "?#{uri.query}" : "")) if response.code.to_i == 200 File.binwrite(temp_path, response.body) << temp_path LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})" else LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}" end rescue StandardError => e LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.}" end end # Append attachment paths to the message content so kiro-cli can process them unless .empty? clean_content += "\n\n" unless clean_content.empty? clean_content += .join("\n") end return if clean_content.empty? && .empty? # Build reply context from the cached referenced message. reply_context = "" if && ["content"] = .dig("author", "username") || "unknown" ref_text = ["content"].strip reply_context = "**Replying to #{}:**\n> #{ref_text}\n\n" unless ref_text.empty? end # Fetch recent channel history so the agent has conversational context. # (Moved after is_thread detection below — needs thread status for limit) discord_user = ["username"] discord_user_id = ["id"] # The agent name comes directly from the bot identity — no detection needed agent_name = fizzy_display_name(agent_key) || agent_key.capitalize # Fetch channel_info if we haven't already (mentioned path skipped it) unless channel_info channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token) is_thread = channel_info && [11, 12].include?(channel_info["type"]) is_dm = channel_info && channel_info["type"] == 1 end parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id # Fetch recent channel history — threads get a larger window since they're bounded conversations. history_limit = is_thread ? 25 : 10 channel_history = fetch_discord_channel_history(channel_id, , token: bot_token, limit: history_limit) LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{if is_dm "DM" else is_thread ? "thread" : "channel" end} #{channel_id}: #{clean_content[0..100]}" reload_projects! reload_agent_registry! reload_discord_config! # Authorization = DISCORD_CONFIG["authorized_user_ids"] || [] # Support both role_mappings (hash) and authorized_role_ids (array or hash) # If authorized_role_ids is a hash, treat it like role_mappings = if DISCORD_CONFIG["role_mappings"] DISCORD_CONFIG["role_mappings"].values elsif DISCORD_CONFIG["authorized_role_ids"].is_a?(Hash) DISCORD_CONFIG["authorized_role_ids"].values else DISCORD_CONFIG["authorized_role_ids"] || [] end # Ensure all role IDs are strings (Discord API returns strings) = .map(&:to_s) unless .empty? && .empty? = .include?(discord_user_id) # Fetch member roles — message.member is not always populated, so we need to # fetch guild member info separately if we have a guild_id member_roles = .dig("member", "roles") || [] # If member roles aren't in the message and we have a guild_id, fetch them if member_roles.empty? && ["guild_id"] guild_member = fetch_guild_member(["guild_id"], discord_user_id, token: bot_token) member_roles = guild_member["roles"] || [] if guild_member end = member_roles.intersect?() unless || LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}" add_discord_reaction(channel_id, , "🚫", token: bot_token) return end end # Inline tags: [project:my-project] and [model] anywhere in the message. # Both are parsed for routing/config and stripped from the prompt content. inline_project_key = nil if (proj_match = clean_content.match(/\[project:(\S+)\]/i)) inline_project_key = proj_match[1] clean_content = clean_content.sub(proj_match[0], "").strip LOG.info "[Discord:#{agent_name}] Detected inline project tag: #{inline_project_key}" end # Strip model tag (e.g. [opus], [sonnet]) from prompt content — detect_model # reads the original clean_content later, but we save the tag-free version # for the actual prompt so the bracket noise doesn't leak through. inline_model_tag = clean_content.match(/\[\w+\]/) clean_content_for_prompt = inline_model_tag ? clean_content.sub(inline_model_tag[0], "").strip : clean_content # Strip effort tag (e.g. [effort:high]) from prompt content clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip # Find project: inline override > channel mapping > default_project if inline_project_key && PROJECTS.key?(inline_project_key) project_key = inline_project_key project_config = PROJECTS[inline_project_key] LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})" else if inline_project_key && !PROJECTS.key?(inline_project_key) LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}" Thread.new { add_discord_reaction(channel_id, , "⚠️", token: bot_token) } end project_key, project_config, _mapping = find_project_for_discord_channel(parent_channel_id) if project_key LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}" else LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)" end end session_key = "discord-#{agent_key}-#{channel_id}-#{}" supersede_key = "discord-#{agent_key}-#{channel_id}" if session_active?(session_key) add_discord_reaction(channel_id, , "⏳", token: bot_token) return end # Supersede: if a human sends a follow-up within 60s, kill the previous agent run if !is_bot && (prev = find_supersedable_session(supersede_key)) LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}" kill_session(prev[:session_key]) # React on the OLD message to show it was cancelled if prev[:message_id] && prev[:channel_id] Thread.new do remove_discord_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token) add_discord_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token) end end # Clean up draft files from the superseded session so the poller doesn't deliver stale responses (prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) } end # React in background — don't block the dispatch path # Remove 🛑 if it exists (user may have cancelled and is now retrying via edit) Thread.new do remove_discord_reaction(channel_id, , "🛑", token: bot_token) add_discord_reaction(channel_id, , "👀", token: bot_token) end # Build project context if project_config repo_path = project_config["repo_path"] # Fetch latest from origin so worktrees branch from up-to-date main debounced_repo_fetch(repo_path) default_branch = get_default_branch(repo_path) lines = ["## Project Context"] lines << "Project: #{project_key}" lines << "Source directory: `#{repo_path}`" lines << "Default branch: `#{default_branch}`" lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"] lines << "" lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, go directly to `#{repo_path}` — do NOT search for it." lines << "" lines << "### All registered projects" PROJECTS.each do |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" end context = lines.join("\n") LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})" else lines = ["## Project Context"] lines << "No specific project mapped to this channel." lines << "" lines << "### Registered projects (use `[project:name]` to target one)" PROJECTS.each do |key, cfg| lines << "- **#{key}**: `#{cfg["repo_path"]}`" end context = lines.join("\n") LOG.info "[Discord:#{agent_name}] No project context - showing available projects" end project_context = context # Prepare files — response goes to draft/ so the poller can recover it after restarts response_dir = File.join(ZILLACORE_DIR, "tmp") FileUtils.mkdir_p(response_dir) = Time.now.strftime("%Y%m%d-%H%M%S") response_basename = "discord-response-#{}-#{agent_key}-#{}" response_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.md") channel_name = channel_info&.dig("name") || channel_id # Find the root message for this conversation thread. # All messages in a thread should share the same memory file. # Also captures root message content so the agent always has the original context. = (, channel_id, bot_token) = [:id] card_id = "discord-#{channel_id}-#{}" # Build thread root context — inject the original question/message that started # this thread so the agent never loses sight of it, even in long conversations. thread_root_context = "" if is_thread && [:content] && ![:content].empty? = [:author] || "unknown" thread_root_context = "### Original Message (thread starter)\n#{}: #{[:content]}\n\n" end # Detect planning mode planning_info = detect_planning_mode( text: clean_content, tags: [], card_internal_id: card_id, card_number: nil ) brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content) if planning_info # Planning mode — use planning prompt planning_card_id = planning_info[:card_id] LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}" prompt = render_planning_prompt(PROMPT_DISCORD, { "DISCORD_USER" => discord_user, "CHANNEL_NAME" => channel_name, "MESSAGE_BODY" => clean_content_for_prompt.sub(/\[plan\]/i, "").strip, "REPLY_CONTEXT" => reply_context, "CHANNEL_HISTORY" => channel_history, "THREAD_ROOT_CONTEXT" => thread_root_context, "PROJECT_CONTEXT" => project_context, "RESPONSE_FILE" => response_file, "CARD_ID" => planning_card_id, "COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => discord_mention_roster }, brain_context: brain_context, agent_name: agent_name, channel: :discord) else # Normal mode prompt = render_prompt(PROMPT_DISCORD, { "DISCORD_USER" => discord_user, "CHANNEL_NAME" => channel_name, "MESSAGE_BODY" => clean_content_for_prompt, "REPLY_CONTEXT" => reply_context, "CHANNEL_HISTORY" => channel_history, "THREAD_ROOT_CONTEXT" => thread_root_context, "PROJECT_CONTEXT" => project_context, "RESPONSE_FILE" => response_file, "CARD_ID" => card_id, "COMMENT_CREATOR" => discord_user, "DISCORD_MENTION_ROSTER" => discord_mention_roster }, brain_context: brain_context, agent_name: agent_name, channel: :discord) end work_dir = project_config ? project_config["repo_path"] : Dir.pwd prompt_file = File.join(response_dir, "discord-prompt-#{}-#{agent_key}-#{}.md") File.write(prompt_file, prompt) # Write delivery metadata sidecar so the poller can post this response # even if the monitoring thread dies (e.g. server restart). = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json") File.write(, JSON.pretty_generate({ channel_id: channel_id, message_id: , agent_key: agent_key, agent_name: agent_name, is_dm: is_dm, is_thread: is_thread, clean_content: clean_content[0..80], created_at: Time.now.iso8601 })) # Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments model = project_config ? detect_model(project_config, text: clean_content) : nil # Detect effort override — [effort:high] syntax effort = project_config ? detect_effort(project_config, text: clean_content) : nil agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-") log_file = File.join(response_dir, "discord-agent-#{}-#{agent_key}-#{}.log") resolved = project_config ? resolve_project_cli_config(project_config) : {} agent_cli = resolved["agent_cli"] || "kiro-cli" agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive" agent_model_flag = resolved["agent_model_flag"] || "--model" agent_effort_flag = resolved["agent_effort_flag"] || "--effort" cmd = [agent_cli] cmd.push("--agent", agent_config_name) cmd.concat(agent_cli_args.split) add_trust_tools!(cmd, agent_cli_args) cmd.push(agent_model_flag, model) if agent_model_flag && !agent_model_flag.empty? && model cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}" LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}" spawn_env = {} agent_env = agent_env_for(agent_name) unless agent_env.empty? spawn_env.merge!(agent_env) LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" end # Capture HEAD before spawning so we can detect if THIS session made commits head_before = nil if project_config pk = PROJECTS.find { |_k, v| v == project_config }&.first if pk == "zillacore" head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir) head_before = head_before.strip end end pid = spawn(spawn_env, *cmd, chdir: work_dir, in: prompt_file, out: [log_file, "w"], err: %i[child out]) register_session(session_key, pid, log_file: log_file, message_id: , channel_id: channel_id, supersede_key: supersede_key, draft_files: [response_file, ], agent_name: agent_name) Thread.new do Process.wait(pid) exit_status = $CHILD_STATUS # Check if session was cancelled (removed from ACTIVE_SESSIONS by reaction handler) session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) } # If the process was killed by a signal (superseded or cancelled), skip response delivery if exit_status.signaled? || session_cancelled reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})" LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{}" # Clean up draft/meta files so the poller doesn't deliver a stale response [response_file, ].each { |f| FileUtils.rm_f(f) } Thread.new do sleep 300 [prompt_file, *].each { |f| FileUtils.rm_f(f) } end next end LOG.info "[Discord:#{agent_name}] Agent finished for message #{} (exit: #{exit_status.exitstatus})" # Notify if the agent crashed (non-zero exit) if exit_status.exitstatus && exit_status.exitstatus != 0 notify_agent_crash( exit_status: exit_status.exitstatus, log_file: log_file, agent_name: agent_name, source: :discord, source_context: { channel_id: channel_id, message_id: , bot_token: bot_token }, project_config: project_config ) end # If the agent didn't write to the response file, extract it from the log. # Agents should write to the file directly, but this is a fallback for when # they respond via stdout instead. if !File.exist?(response_file) && File.exist?(log_file) log_content = File.read(log_file) # Detect known fatal error patterns from kiro-cli and write a clean # user-facing message instead of leaking raw internal errors to Discord. if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i) LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{}" File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._") elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/) LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat" FileUtils.rm_f() else # Strip ANSI codes and kiro-cli UI noise clean_output = log_content .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") # ANSI escape codes (including cursor visibility) .gsub(/\e\][^\a]*\a/, "") # OSC sequences .delete("\r") # Carriage returns .gsub(/^.*?(using tool:.*?)$/m, "") # Tool usage lines .gsub(/^.*?✓.*?$/m, "") # Success checkmarks .gsub(/^.*?▸.*?$/m, "") # Timing lines .gsub(/^.*?Loading\.\.\..*?$/m, "") # Loading indicators .gsub(/^.*?Completed in.*?$/m, "") # Completion messages .strip # Only write if there's actual content if !clean_output.empty? && clean_output.length > 20 File.write(response_file, clean_output) LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)" end end end # Deliver Discord response FIRST for faster human feedback remove_discord_reaction(channel_id, , "👀", token: bot_token) sleep 0.5 # Breathing room to avoid Discord rate limits delivered = deliver_discord_draft(response_file, ) # If deliver returned false, check whether the poller already handled it # (files moved to posted/) or the response genuinely doesn't exist. unless delivered response_basename = File.basename(response_file) already_posted = File.exist?(File.join(DISCORD_POSTED_DIR, response_basename)) unless already_posted LOG.warn "[Discord:#{agent_name}] No response produced for message #{}" add_discord_reaction(channel_id, , "😶", token: bot_token) end end # Re-index brain AFTER response delivery (Discord bypasses run_agent, so we handle it here) qmd_out, qmd_status = Open3.capture2e("qmd", "update") if qmd_status.success? LOG.info "[Brain] qmd update completed after #{agent_name} Discord session" else LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}" end brain_push(message: "#{agent_name}: discord-#{}") # Restart zillacore if THIS session actually changed code # Compare HEAD now vs before the agent ran — only restart if commits were made or files are dirty if project_config && head_before project_key = PROJECTS.find { |_k, v| v == project_config }&.first if project_key == "zillacore" chdir = project_config["repo_path"] head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir) git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir) if head_after.strip != head_before || !git_status.strip.empty? queue_zillacore_restart(agent_name) else LOG.info "[ZillaCore] #{agent_name} Discord session on zillacore had no changes — skipping restart" end end end Thread.new do sleep 300 [prompt_file, *].each { |f| FileUtils.rm_f(f) } end rescue StandardError => e LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.}" add_discord_reaction(channel_id, , "❌", token: bot_token) end end |
#handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id) ⇒ Object
— Discord Reaction Handler — Handles MESSAGE_REACTION_ADD events. Currently supports:
-
❌ reaction to cancel an active agent session
1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 |
# File 'lib/zillacore/handlers/discord.rb', line 1074 def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id) channel_id = reaction_data["channel_id"] = reaction_data["message_id"] user_id = reaction_data["user_id"] emoji = reaction_data["emoji"] emoji_name = emoji["name"] agent_name = fizzy_display_name(agent_key) || agent_key.capitalize # Ignore reactions from bots (including self) return if user_id == bot_user_id # Handle ❔ or ❓ reactions (thinking file inspection) if ["❔", "❓"].include?(emoji_name) session_key = "discord-#{agent_key}-#{channel_id}-#{}" line_count = emoji_name == "❔" ? 10 : 20 ACTIVE_SESSIONS_MUTEX.synchronize do session_info = ACTIVE_SESSIONS[session_key] unless session_info LOG.info "[Discord:#{agent_name}] #{emoji_name} reaction on #{} but no active session found" return end log_file = session_info[:log_file] unless log_file && File.exist?(log_file) LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}" (channel_id, "No thinking file found for this session.", token: bot_token, reply_to: ) return end LOG.info "[Discord:#{agent_name}] Reading last #{line_count} lines from #{log_file}" # Read last N lines from the log file lines = File.readlines(log_file).last(line_count) thinking_output = lines.join # Strip all ANSI escape codes and non-ASCII characters thinking_output = thinking_output.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") # All CSI sequences .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "") # Alternative CSI notation .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "") # OSC sequences .gsub(/\e[=>]/, "") # Other escape sequences .gsub(/\[\?[0-9]+[lh]/, "") # Cursor visibility .gsub("[K", "") # Clear line .encode("ASCII", invalid: :replace, undef: :replace, replace: "") # Strip non-ASCII .strip # Post to Discord as a code block response = "**Last #{line_count} lines:**\n```\n#{thinking_output}\n```" (channel_id, response, token: bot_token, reply_to: ) end return end # Handle 🧠 reaction (stream full thinking to thread) if emoji_name == "🧠" session_key = "discord-#{agent_key}-#{channel_id}-#{}" ACTIVE_SESSIONS_MUTEX.synchronize do session_info = ACTIVE_SESSIONS[session_key] unless session_info LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{} but no active session found" return end log_file = session_info[:log_file] unless log_file && File.exist?(log_file) LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}" (channel_id, "No thinking file found for this session.", token: bot_token, reply_to: ) return end LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}" # Create thread thread_response = create_discord_thread(channel_id, , name: "🧠 Thinking Stream", token: bot_token) unless thread_response && thread_response["id"] LOG.error "[Discord:#{agent_name}] Failed to create thread, response: #{thread_response.inspect}" return end thread_id = thread_response["id"] LOG.info "[Discord:#{agent_name}] Thread created: #{thread_id}" # Read and clean full thinking file thinking_content = File.read(log_file) thinking_content = thinking_content.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "") .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "") .gsub(/\e[=>]/, "") .gsub(/\[\?[0-9]+[lh]/, "") .gsub("[K", "") .encode("ASCII", invalid: :replace, undef: :replace, replace: "") .strip # Split into 1900-char chunks (leave room for code blocks) chunks = [] current_chunk = "" thinking_content.lines.each do |line| if current_chunk.length + line.length > 1900 chunks << current_chunk current_chunk = line else current_chunk += line end end chunks << current_chunk unless current_chunk.empty? # Post chunks to thread chunks.each do |chunk| (thread_id, "```\n#{chunk}\n```", token: bot_token) sleep 0.5 # Rate limit protection end end return end # --- Feedback logging for non-reserved emojis --- unless RESERVED_EMOJIS.include?(emoji_name) Thread.new do log_emoji_feedback(channel_id, , user_id, emoji_name, agent_key, agent_name, bot_token) rescue StandardError => e LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.}" end return end # Only handle ❌ reactions beyond this point return unless emoji_name == "❌" # Check if there's an active session for this message session_key = "discord-#{agent_key}-#{channel_id}-#{}" ACTIVE_SESSIONS_MUTEX.synchronize do session_info = ACTIVE_SESSIONS[session_key] unless session_info LOG.info "[Discord:#{agent_name}] ❌ reaction on #{} but no active session found" return end LOG.info "[Discord:#{agent_name}] Cancelling session for message #{} (PID: #{session_info[:pid]})" # Kill the agent process begin Process.kill("KILL", session_info[:pid]) LOG.info "[Discord:#{agent_name}] Killed agent process #{session_info[:pid]}" rescue Errno::ESRCH LOG.warn "[Discord:#{agent_name}] Process #{session_info[:pid]} already exited" rescue Errno::EPERM LOG.error "[Discord:#{agent_name}] Permission denied killing process #{session_info[:pid]}" end # Remove from active sessions ACTIVE_SESSIONS.delete(session_key) # Update reactions: remove 👀, add 🛑 begin remove_discord_reaction(channel_id, , "👀", token: bot_token) add_discord_reaction(channel_id, , "🛑", token: bot_token) rescue StandardError => e LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.}" end # Clean up draft files if they exist session_info[:draft_files]&.each do |file| FileUtils.rm_f(file) end end end |
#handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move) ⇒ Object
627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 |
# File 'lib/zillacore/helpers.rb', line 627 def handle_fizzy_post_session(fizzy_card, exit_status, signaled, agent_name, chdir, source, source_context, project_config, skip_column_move) return unless source == :fizzy && fizzy_card && exit_status&.zero? && !signaled unless skip_column_move || card_merged?(fizzy_card) move_card_to_column(fizzy_card, "needs_review", project_config: project_config, agent_name: agent_name) end (fizzy_card, project_config: project_config, agent_name: agent_name) return unless source_context[:deploy_intent] auto_deploy_after_session( deploy_intent: source_context[:deploy_intent], card_internal_id: source_context[:card_internal_id] || load_card_map.find { |_, v| v["number"] == fizzy_card }&.first, card_number: fizzy_card, worktree_path: chdir, agent_name: agent_name ) end |
#handle_github_issue_comment(payload) ⇒ Object
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/zillacore/handlers/github.rb', line 161 def handle_github_issue_comment(payload) comment = payload["comment"] issue = payload["issue"] comment_body = comment["body"] || "" comment_id = comment["id"] comment_user = comment.dig("user", "login") repo_name = payload.dig("repository", "full_name") # Only process if this is a PR (issues have pull_request key when they're PRs) unless issue["pull_request"] LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring" return [200, { status: "ignored", reason: "not a PR comment" }.to_json] end # Identify project by GitHub repo project_result = identify_project_by_repo(repo_name) unless project_result LOG.info "No project found for GitHub repo #{repo_name}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end project_key, project_config = project_result pr_number = issue["number"] issue["html_url"] # Find the card by PR branch pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}", chdir: project_config["repo_path"]) branch = JSON.parse(pr_data)["branch"] result = find_card_by_branch(branch) unless result LOG.info "No Fizzy card found for PR ##{pr_number} (branch: #{branch})" return [200, { status: "ignored", reason: "no matching card" }.to_json] end _, card_info = result card_number = card_info["number"] worktree = card_info["worktree"] # Only process if worktree exists unless worktree && File.directory?(worktree) LOG.info "No active worktree for PR ##{pr_number}, ignoring comment" return [200, { status: "ignored", reason: "no active worktree" }.to_json] end LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})" card_key = "card-#{card_number}" if session_active?(card_key) LOG.info "Skipping PR comment on card ##{card_number} — agent session already active" return [200, { status: "ignored", reason: "session already active" }.to_json] end # React in background — don't block the dispatch path Thread.new do run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions", "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree) LOG.info "Added 👀 reaction to comment ##{comment_id}" rescue StandardError => e LOG.warn "Could not add reaction to comment: #{e.}" end # Detect model override model = detect_model(project_config, text: comment_body) effort = detect_effort(project_config, text: comment_body) agent_name = agent_name_for(project_config) prompt = render_prompt(PROMPT_GITHUB_PR_COMMENT, { "CARD_NUMBER" => card_number, "CARD_ID" => card_number, "COMMENT_CREATOR" => comment_user, "COMMENT_BODY" => comment_body, "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => worktree }, brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: comment_body), agent_name: agent_name, channel: :github) pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree, log_name: "pr-comment-#{pr_number}", model: model, effort: effort, agent_name: agent_name, source: :github, source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree }) register_session(card_key, pid, log_file: log_file, agent_name: agent_name) [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json] rescue StandardError => e LOG.error "Error handling PR comment: #{e.}" [500, { error: e. }.to_json] end |
#handle_github_issue_opened(payload) ⇒ Object
388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/zillacore/handlers/github.rb', line 388 def handle_github_issue_opened(payload) issue = payload["issue"] issue_url = issue["html_url"] issue_title = issue["title"] issue_number = issue["number"] repo_name = payload.dig("repository", "full_name") LOG.info "New GitHub issue ##{issue_number} on #{repo_name}: #{issue_title} (#{issue_url})" [200, { status: "logged", issue: issue_number, title: issue_title, url: issue_url }.to_json] end |
#handle_github_pr_merged(payload) ⇒ Object
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/zillacore/handlers/github.rb', line 70 def handle_github_pr_merged(payload) pr = payload["pull_request"] branch = pr.dig("head", "ref") base = pr.dig("base", "ref") pr_url = pr["html_url"] pr_title = pr["title"] repo_full_name = payload.dig("repository", "full_name") # Only act on merges into the repo's default branch default_branch = payload.dig("repository", "default_branch") || "main" unless base == default_branch LOG.info "PR merged into #{base}, not #{default_branch} — ignoring" return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json] end # Identify project by GitHub repo project_result = identify_project_by_repo(repo_full_name) unless project_result LOG.info "No project found for GitHub repo #{repo_full_name}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end project_key, project_config = project_result repo_path = project_config["repo_path"] result = find_card_by_branch(branch) unless result LOG.info "No Fizzy card found for branch #{branch}" return [200, { status: "ignored", reason: "no matching card" }.to_json] end internal_id, card_info = result card_number = card_info["number"] unless card_number LOG.warn "Card #{internal_id} has no number — can't comment or move" return [200, { status: "ignored", reason: "card has no number" }.to_json] end LOG.info "PR merged into main for card ##{card_number} (project: #{project_key}): #{pr_url}" # Use the card's assigned agent identity for fizzy interactions card_agent = card_info["agent"] card_fizzy_env = fizzy_env_for(card_agent) # Comment with the PR link if not already there if pr_link_already_commented?(card_number, pr_url, chdir: repo_path, env: card_fizzy_env) LOG.info "PR link already on card ##{card_number}, skipping comment" else comment_body = "<p>PR merged into main: <a href=\"#{pr_url}\">#{pr_title}</a></p><p>Branch: <code>#{branch}</code></p>" run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", comment_body, chdir: repo_path, env: card_fizzy_env) LOG.info "Commented PR link on card ##{card_number} as #{card_agent || "default"}" end # Move card to UAT column — merged into main means it's deployed to UAT mark_card_merged(card_number) run_cmd("fizzy", "card", "column", card_number.to_s, "--column", uat_column_id(project_config), chdir: repo_path, env: card_fizzy_env) record_self_move(card_number) LOG.info "Moved card ##{card_number} to UAT column as #{card_agent || "default"}" # Clean up the primary worktree and any cross-agent review worktrees cleanup_card_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch) # Clear any deployment environments occupied by this card clear_deployment_for_card(card_number) # Dispatch agent to comment UAT testing steps on the Fizzy card agent_name = card_agent || agent_name_for(project_config) card_title = card_info["title"] || pr_title prompt = render_prompt(PROMPT_GITHUB_UAT, { "CARD_NUMBER" => card_number, "CARD_TITLE" => card_title, "PR_NUMBER" => pr["number"].to_s }, brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, card_title: card_title, project_key: project_key), agent_name: agent_name, channel: :fizzy, board_key: board_key_for_project(project_config)) pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path, log_name: "uat-#{card_number}", agent_name: agent_name, source: :fizzy, source_context: { card_number: card_number }, skip_column_move: true) register_session("card-#{card_number}", pid, log_file: log_file, agent_name: agent_name) LOG.info "Dispatched #{agent_name} for UAT testing steps on card ##{card_number}" [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json] rescue StandardError => e LOG.error "Error handling merged PR: #{e.}" [500, { error: e. }.to_json] end |
#handle_github_pr_review_submitted(payload) ⇒ Object
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 |
# File 'lib/zillacore/handlers/github.rb', line 400 def handle_github_pr_review_submitted(payload) pr = payload["pull_request"] review = payload["review"] branch = pr.dig("head", "ref") pr_number = pr["number"] pr["html_url"] repo_name = payload.dig("repository", "full_name") review_state = review["state"] reviewer = review.dig("user", "login") # Only act on submitted reviews (not just comments) unless %w[changes_requested commented].include?(review_state) LOG.info "PR review state is '#{review_state}', ignoring" return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json] end # Identify project by GitHub repo project_result = identify_project_by_repo(repo_name) unless project_result LOG.info "No project found for GitHub repo #{repo_name}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end project_key, project_config = project_result repo_path = project_config["repo_path"] result = find_card_by_branch(branch) unless result LOG.info "No Fizzy card found for PR branch #{branch}" return [200, { status: "ignored", reason: "no matching card" }.to_json] end internal_id, card_info = result card_number = card_info["number"] worktree = card_info["worktree"] unless card_number LOG.warn "Card #{internal_id} has no number — can't comment" return [200, { status: "ignored", reason: "card has no number" }.to_json] end LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})" card_key = "card-#{card_number}" if session_active?(card_key) LOG.info "Skipping PR review on card ##{card_number} — agent session already active" return [200, { status: "ignored", reason: "session already active" }.to_json] end # React to the review with 👀 to show we're starting review_id = review["id"] # React in background — don't block the dispatch path Thread.new do run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions", "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path) LOG.info "Added 👀 reaction to review ##{review_id}" rescue StandardError => e LOG.warn "Could not add reaction to review: #{e.}" end # Post status to Fizzy in background — don't block the dispatch path agent_name = agent_name_for(project_config) Thread.new do status_comment = "<p>🔄 Code review received from @#{reviewer}. Updates in progress...</p>" run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", status_comment, chdir: repo_path, env: fizzy_env_for(agent_name)) LOG.info "Posted status update to card ##{card_number} as #{agent_name}" rescue StandardError => e LOG.warn "Could not post status update to card ##{card_number}: #{e.}" end # Fetch all review comments (line-specific comments) review_comments = fetch_pr_review_comments(pr_number, repo_name) # Build context for the agent review_context = "GitHub PR Review from @#{reviewer}:\n\n" review_context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty? if review_comments.any? review_context += "Line-specific comments:\n" review_comments.each do |comment| review_context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n" end end # Determine working directory work_dir = worktree && File.directory?(worktree) ? worktree : repo_path prompt = render_prompt(PROMPT_GITHUB_PR_REVIEW, { "CARD_NUMBER" => card_number, "CARD_ID" => card_number, "COMMENT_CREATOR" => reviewer, "REVIEW_CONTEXT" => review_context, "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => work_dir }, brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key), agent_name: agent_name, channel: :github) pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "review-#{card_number}", agent_name: agent_name, source: :github, source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir }) register_session(card_key, pid, log_file: log_file, agent_name: agent_name) [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json] rescue StandardError => e LOG.error "Error handling PR review: #{e.}" [500, { error: e. }.to_json] end |
#handle_github_pr_synchronized(payload) ⇒ Object
Auto-deploy when a PR gets new commits (synchronize event) if the card is already on a dev env.
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 |
# File 'lib/zillacore/handlers/github.rb', line 509 def handle_github_pr_synchronized(payload) pr = payload["pull_request"] branch = pr.dig("head", "ref") payload.dig("repository", "full_name") result = find_card_by_branch(branch) unless result LOG.info "[PR Sync] No card found for branch #{branch}" return [200, { status: "ignored", reason: "no matching card" }.to_json] end _internal_id, card_info = result card_number = card_info["number"] worktree = card_info["worktree"] unless worktree && File.directory?(worktree) LOG.info "[PR Sync] No worktree for card ##{card_number}" return [200, { status: "ignored", reason: "no worktree" }.to_json] end # Check if this card is deployed to any environment state = load_deployment_state config = DEPLOYMENTS_CONFIG["environments"] || {} env_key = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first unless env_key LOG.info "[PR Sync] Card ##{card_number} not deployed to any environment — skipping" return [200, { status: "ignored", reason: "card not deployed" }.to_json] end # Only deploy if this machine owns the environment env_owner = config.dig(env_key, "owner") unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase LOG.info "[PR Sync] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}" return [200, { status: "ignored", reason: "not env owner" }.to_json] end # Cooldown — debounce rapid pushes if on_deploy_cooldown?(env_key) LOG.info "[PR Sync] Skipping deploy to #{env_key} — within #{DEPLOY_COOLDOWN}s cooldown" return [200, { status: "ignored", reason: "deploy cooldown" }.to_json] end touch_deploy_cooldown(env_key) # Pull latest commits into the worktree system("git", "pull", "--ff-only", chdir: worktree) deploy_script = File.join(worktree, "scripts", "deploy.sh") unless File.exist?(deploy_script) LOG.warn "[PR Sync] No deploy script at #{deploy_script}" return [200, { status: "ignored", reason: "no deploy script" }.to_json] end LOG.info "[PR Sync] Auto-deploying card ##{card_number} to #{env_key} (PR updated)" (env_key, worktree_path: worktree) Thread.new do deploy_env = {} aws_profile = config.dig(env_key, "aws_profile") deploy_env["AWS_PROFILE"] = aws_profile if aws_profile stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree) if status.success? deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync") LOG.info "[PR Sync] Deploy to #{env_key} succeeded for card ##{card_number}" elsif terraform_lock_error?(stdout, stderr) lock_file = File.join(worktree, "infrastructure/#{env_key}/.terraform.lock.hcl") FileUtils.rm_f(lock_file) Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree, "infrastructure/#{env_key}")) stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree) if status2.success? deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync") LOG.info "[PR Sync] Deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}" else record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout2, stderr: stderr2) LOG.error "[PR Sync] Deploy to #{env_key} failed (after retry) for card ##{card_number}" end else record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr) LOG.error "[PR Sync] Deploy to #{env_key} failed for card ##{card_number}" end end [200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json] rescue StandardError => e LOG.error "[PR Sync] Error: #{e.}" [500, { error: e. }.to_json] end |
#handle_github_workflow_run(payload) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 |
# File 'lib/zillacore/handlers/github.rb', line 251 def handle_github_workflow_run(payload) workflow = payload["workflow_run"] workflow_name = workflow["name"] conclusion = workflow["conclusion"] repo_full_name = payload.dig("repository", "full_name") run_url = workflow["html_url"] # Handle Deploy to Production failure if workflow_name == "Deploy to Production" && conclusion == "failure" project_result = identify_project_by_repo(repo_full_name) project_key = project_result ? project_result[0] : repo_full_name send_workflow_failure_notification(project_key, workflow_name, run_url) LOG.info "Deploy to Production failed for #{project_key} — notified Discord" return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json] end # Handle Deploy to UAT success if workflow_name == "Deploy to UAT" && conclusion == "success" project_result = identify_project_by_repo(repo_full_name) project_key = project_result ? project_result[0] : repo_full_name send_uat_deploy_notification(project_key) LOG.info "Deploy to UAT succeeded for #{project_key} — notified Discord" return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json] end unless conclusion == "success" LOG.info "Workflow '#{workflow_name}' concluded with '#{conclusion}' — ignoring" return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json] end unless workflow_name == "Deploy to Production" LOG.info "Workflow '#{workflow_name}' is not a prod deploy — ignoring" return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json] end project_result = identify_project_by_repo(repo_full_name) unless project_result LOG.info "No project found for GitHub repo #{repo_full_name}" return [200, { status: "ignored", reason: "no matching project" }.to_json] end project_key, project_config = project_result repo_path = project_config["repo_path"] # List all cards in the UAT column output = run_cmd("fizzy", "card", "list", "--column", uat_column_id(project_config), "--all", chdir: repo_path, env: default_fizzy_env) cards = JSON.parse(output) card_list = cards["data"] || [] if card_list.empty? LOG.info "No cards in UAT column — nothing to close" return [200, { status: "processed", action: "no_uat_cards", project: project_key }.to_json] end closed_cards = [] map = load_card_map card_list.each do |card| card_number = card["number"] next unless card_number # Try to find the card in our map to get the assigned agent map_entry = map.values.find { |info| info["number"] == card_number } agent_name = map_entry["agent"] if map_entry env = agent_name ? fizzy_env_for(agent_name) : default_fizzy_env comment_body = "<p>✅ Deployed to production. Closing card.</p>" run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", comment_body, chdir: repo_path, env: env) run_cmd("fizzy", "card", "close", card_number.to_s, chdir: repo_path, env: env) # Clean up worktrees (primary + cross-agent review) primary_worktree = map_entry&.dig("worktree") primary_branch = map_entry&.dig("branch") cleanup_card_worktrees(card_number, repo_path: repo_path, primary_worktree: primary_worktree, primary_branch: primary_branch) # Clean up card map entry if map_entry internal_id = map.key(map_entry) map.delete(internal_id) end closed_cards << { number: card_number, url: card["url"], title: card["title"] } LOG.info "Closed UAT card ##{card_number} after prod deploy (agent: #{agent_name || "default"})" end save_card_map(map) if closed_cards.any? send_deploy_notification(project_key, closed_cards) if closed_cards.any? LOG.info "Prod deploy complete — closed #{closed_cards.size} UAT cards: #{closed_cards.map { |c| c[:number] }.join(", ")}" [200, { status: "processed", action: "prod_deploy_closed_uat", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json] rescue StandardError => e LOG.error "Error handling workflow run: #{e.}" [500, { error: e. }.to_json] end |
#handle_plan_finalization(prompt_file, agent_name, project_config) ⇒ Object
647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 |
# File 'lib/zillacore/helpers.rb', line 647 def handle_plan_finalization(prompt_file, agent_name, project_config) return unless File.exist?(prompt_file) prompt_content = File.read(prompt_file) card_id_match = prompt_content.match(/CARD_ID.*?(\d+|discord-[\w-]+)/) return unless card_id_match card_id = card_id_match[1] plan_file = File.join(PLANS_DIR, "card-#{card_id}-plan.md") return unless File.exist?(plan_file) LOG.info "[Planning] Plan file detected for card #{card_id}, finalizing..." card_num = card_id.match?(/^\d+$/) ? card_id.to_i : nil project_key = PROJECTS.find { |_k, v| v == project_config }&.first result = finalize_plan( card_id: card_id, card_number: card_num, agent_name: agent_name || AI_AGENT_NAME, project_key: project_key, repo_path: project_config["repo_path"] ) if result[:success] LOG.info "[Planning] Plan finalized: #{result[:tasks].size} tasks created" else LOG.error "[Planning] Failed to finalize plan: #{result[:error]}" end end |
#human_mentioned?(user_id) ⇒ Boolean
692 693 694 695 696 697 |
# File 'lib/zillacore/helpers.rb', line 692 def human_mentioned?(user_id) return false unless FIZZY_CONFIG["authorized_users"] user = FIZZY_CONFIG["authorized_users"].find { |u| u["id"] == user_id } user && user["human"] end |
#human_users ⇒ Object
Get all human users (exclude AI agents)
69 70 71 |
# File 'lib/zillacore/users.rb', line 69 def human_users USER_REGISTRY["users"].reject { |u| u["notes"]&.include?("AI agent") } end |
#identify_project_by_repo(repo_full_name) ⇒ Object
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/zillacore/helpers.rb', line 156 def identify_project_by_repo(repo_full_name) return nil if PROJECTS.empty? PROJECTS.each do |project_key, config| return [project_key, config] if config["github_repo"] == repo_full_name end # Fall back to default project if configured default_key = default_project_key if default_key LOG.info "No project matched GitHub repo '#{repo_full_name}', falling back to default project '#{default_key}'" return [default_key, PROJECTS[default_key]] end nil end |
#identify_project_by_tags(tags) ⇒ Object
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/zillacore/helpers.rb', line 136 def () return nil if PROJECTS.empty? tag_names = .map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase } PROJECTS.each do |project_key, config| = (config["fizzy_tags"] || []).map(&:downcase) return [project_key, config] if tag_names.intersect?() end # Fall back to default project if configured default_key = default_project_key if default_key LOG.info "No project matched tags [#{tag_names.join(", ")}], falling back to default project '#{default_key}'" return [default_key, PROJECTS[default_key]] end nil end |
#kill_session(session_key) ⇒ Object
Kill a session’s process. Returns true if killed.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/zillacore/sessions.rb', line 206 def kill_session(session_key) ACTIVE_SESSIONS_MUTEX.synchronize do info = ACTIVE_SESSIONS[session_key] return false unless info # Kill child processes first (bottom-up), then the parent children = child_processes_for(info[:pid]) children.reverse_each do |child| Process.kill("KILL", child[:pid]) rescue StandardError nil end begin Process.kill("KILL", info[:pid]) rescue Errno::ESRCH, Errno::EPERM # already gone end archive_session(session_key, info) ACTIVE_SESSIONS.delete(session_key) true end end |
#load_agent_registry ⇒ Object
Agent registry, discovery, identity, mention detection, and env injection.
The registry at ~/.zillacore/agents.json uses a generic ‘env` hash so any environment variable can be set per-agent:
{
"galen": {
"fizzy_name": "Galen",
"local": true,
"env": {
"FIZZY_TOKEN": "fizzy_abc...",
"DISCORD_BOT_TOKEN": "Bot_abc..."
}
}
}
The “local” flag marks agents that this machine should dispatch work for (card assignments). Agents without “local”: true are still known for mention detection, display names, tokens, and cross-agent interactions —they just won’t pick up card assignments on this machine.
Legacy format with top-level ‘fizzy_token` / `discord_bot_token` keys is auto-migrated into the `env` hash at load time.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/zillacore/agents.rb', line 27 def load_agent_registry if File.exist?(AGENT_REGISTRY_FILE) raw_registry = JSON.parse(File.read(AGENT_REGISTRY_FILE)) LOG.info "Loaded agent registry (#{raw_registry.size} agents) from #{AGENT_REGISTRY_FILE}" # Normalize keys: convert to lowercase, replace non-alphanumeric with hyphens registry = {} raw_registry.each do |key, entry| normalized_key = key.downcase.gsub(/[^a-z0-9-]/, "-") if registry.key?(normalized_key) && registry[normalized_key] != entry LOG.warn "Duplicate agent key after normalization: '#{key}' → '#{normalized_key}' (already exists)" end registry[normalized_key] = entry end # Migrate legacy keys into env hash registry.each_value do |entry| next unless entry.is_a?(Hash) entry["env"] ||= {} # Migrate fizzy_token → FIZZY_TOKEN if (ft = entry.delete("fizzy_token")) entry["env"]["FIZZY_TOKEN"] ||= ft end # Migrate discord_bot_token → DISCORD_BOT_TOKEN if (dt = entry.delete("discord_bot_token")) entry["env"]["DISCORD_BOT_TOKEN"] ||= dt end end return registry end if File.exist?(AGENT_TOKENS_FILE) tokens = JSON.parse(File.read(AGENT_TOKENS_FILE)) LOG.info "Loaded legacy agent tokens (#{tokens.size} agents) from #{AGENT_TOKENS_FILE}" return tokens.transform_values { |token| { "env" => { "FIZZY_TOKEN" => token } } } end {} rescue JSON::ParserError => e LOG.error "Failed to parse agent registry: #{e.}" {} end |
#load_card_map ⇒ Object
196 197 198 199 200 201 202 |
# File 'lib/zillacore/helpers.rb', line 196 def load_card_map return {} unless File.exist?(CARD_MAP_FILE) JSON.parse(File.read(CARD_MAP_FILE)) rescue JSON::ParserError {} end |
#load_cron_jobs ⇒ Object
Load cron jobs from config
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/zillacore/cron.rb', line 166 def load_cron_jobs return {} unless File.exist?(CRON_CONFIG_FILE) jobs = JSON.parse(File.read(CRON_CONFIG_FILE), symbolize_names: true) # Deserialize timestamp strings back to Time objects for one-time jobs jobs.each_value do |job| next unless job[:parsed] job[:parsed][:timestamp] = Time.parse(job[:parsed][:timestamp]) if job[:parsed][:one_time] && job[:parsed][:timestamp].is_a?(String) end jobs rescue JSON::ParserError => e LOG.error "[Cron] Failed to parse cron config: #{e.}" {} end |
#load_deployment_state ⇒ Object
20 21 22 23 24 25 26 27 |
# File 'lib/zillacore/deployments.rb', line 20 def load_deployment_state return {} unless File.exist?(DEPLOYMENT_STATE_FILE) JSON.parse(File.read(DEPLOYMENT_STATE_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse deployment state: #{e.}" {} end |
#load_deployments_config ⇒ Object
11 12 13 14 15 16 17 18 |
# File 'lib/zillacore/deployments.rb', line 11 def load_deployments_config return {} unless File.exist?(DEPLOYMENTS_CONFIG_FILE) JSON.parse(File.read(DEPLOYMENTS_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse deployments config: #{e.}" {} end |
#load_discord_config ⇒ Object
119 120 121 122 123 124 125 126 127 |
# File 'lib/zillacore/handlers/discord.rb', line 119 def load_discord_config default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] } return default unless File.exist?(DISCORD_CONFIG_FILE) JSON.parse(File.read(DISCORD_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse discord config: #{e.}" default end |
#load_fizzy_config ⇒ Object
56 57 58 59 60 61 62 63 |
# File 'lib/zillacore/config.rb', line 56 def load_fizzy_config return {} unless File.exist?(FIZZY_CONFIG_FILE) JSON.parse(File.read(FIZZY_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse Fizzy config: #{e.}" {} end |
#load_github_config ⇒ Object
71 72 73 74 75 76 77 78 |
# File 'lib/zillacore/config.rb', line 71 def load_github_config return {} unless File.exist?(GITHUB_CONFIG_FILE) JSON.parse(File.read(GITHUB_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "Failed to parse GitHub config: #{e.}" {} end |
#load_projects_config ⇒ Object
— Projects —
138 139 140 141 142 143 144 145 146 147 |
# File 'lib/zillacore/config.rb', line 138 def load_projects_config return {} unless File.exist?(PROJECTS_FILE) projects = JSON.parse(File.read(PROJECTS_FILE)) LOG.info "Loaded #{projects.size} project(s) from #{PROJECTS_FILE}" projects rescue JSON::ParserError => e LOG.error "Failed to parse projects config: #{e.}" {} end |
#load_user_registry ⇒ Object
8 9 10 11 12 13 14 15 16 17 |
# File 'lib/zillacore/users.rb', line 8 def load_user_registry return { "users" => [] } unless File.exist?(USERS_FILE) data = JSON.parse(File.read(USERS_FILE)) LOG.info "Loaded #{data["users"].size} user(s) from #{USERS_FILE}" data rescue JSON::ParserError => e LOG.error "Failed to parse user registry: #{e.}" { "users" => [] } end |
#load_zoho_config ⇒ Object
17 18 19 20 21 22 23 24 |
# File 'lib/zillacore/handlers/zoho.rb', line 17 def load_zoho_config return {} unless File.exist?(ZOHO_CONFIG_FILE) JSON.parse(File.read(ZOHO_CONFIG_FILE)) rescue JSON::ParserError => e LOG.error "[Zoho] Failed to parse config: #{e.}" {} end |
#local_agent_names ⇒ Object
Agents marked “local”: true in the registry — only these should pick up card assignments on this machine. All other agents are still “known” for mention detection, tokens, and display names.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/zillacore/agents.rb', line 155 def local_agent_names names = Set.new # The default AI_AGENT_NAME is always local (it's this machine's primary agent) names << AI_AGENT_NAME # Project-configured agents are local by definition PROJECTS.each_value { |config| names << config["agent_name"] if config["agent_name"] } # kiro-cli agent configs on disk are local discover_kiro_agents.each { |name| names << name.capitalize } # Registry agents only if explicitly marked local AGENT_REGISTRY.each do |key, entry| next unless entry.is_a?(Hash) && entry["local"] names << (entry["fizzy_name"] || key.capitalize) end names end |
#log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token) ⇒ Object
— Emoji Feedback Logging — Logs non-reserved emoji reactions on bot messages to the agent’s persona feedback file. No LLM call, no dispatch — just a file append.
1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 |
# File 'lib/zillacore/handlers/discord.rb', line 1251 def log_emoji_feedback(channel_id, , user_id, emoji_name, agent_key, agent_name, bot_token) # Verify the message was posted by this bot (quiet — bots get reactions from channels they can't access) msg = (channel_id, , token: bot_token, log_errors: false) return unless msg&.dig("author", "bot") bot_user_id = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :user_id) } return unless bot_user_id && msg.dig("author", "id") == bot_user_id # Resolve reactor to canonical name reactor = find_user_by_discord_id(user_id) reactor_name = reactor ? reactor["canonical_name"] : user_id # Build a brief context snippet from the message snippet = (msg["content"] || "")[0, 80].tr("\n", " ").strip snippet = "#{snippet}..." if (msg["content"] || "").length > 80 # Append to persona feedback file feedback_dir = File.join(persona_dir_for(agent_name), "people") FileUtils.mkdir_p(feedback_dir) feedback_file = File.join(feedback_dir, "#{reactor_name.downcase.gsub(/[^a-z0-9]/, "-")}-feedback.md") = Time.now.strftime("%Y-%m-%d %H:%M") entry = "- #{} #{emoji_name} on: \"#{snippet}\" (channel: #{channel_id})\n" # Create file with header if new if File.exist?(feedback_file) File.open(feedback_file, "a") { |f| f.write(entry) } else File.write(feedback_file, "# Feedback from #{reactor_name}\n\n## Reaction Log\n#{entry}") end LOG.info "[Discord:#{agent_name}] Logged #{emoji_name} feedback from #{reactor_name} on message #{}" end |
#mark_card_merged(card_number) ⇒ Object
256 257 258 |
# File 'lib/zillacore/helpers.rb', line 256 def mark_card_merged(card_number) MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now } end |
#mark_deploying(env_key, worktree_path:) ⇒ Object
Mark an environment as actively deploying (in-progress state for waybar).
49 50 51 52 53 54 55 56 57 |
# File 'lib/zillacore/deployments.rb', line 49 def (env_key, worktree_path:) state = load_deployment_state state[env_key] ||= {} state[env_key]["status"] = "occupied" state[env_key]["last_deploy_status"] = "deploying" state[env_key]["last_deploy_at"] = Time.now.iso8601 save_deployment_state(state) DEPLOYMENT_STATE.replace(state) end |
#match_field?(pattern, value) ⇒ Boolean
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/zillacore/cron.rb', line 142 def match_field?(pattern, value) return true if pattern == "*" # Handle ranges (e.g., "1-5") if pattern.include?("-") range_start, range_end = pattern.split("-").map(&:to_i) return value.between?(range_start, range_end) end # Handle lists (e.g., "1,3,5") return pattern.split(",").map(&:to_i).include?(value) if pattern.include?(",") # Handle step values (e.g., "*/5") if pattern.include?("/") base, step = pattern.split("/") step = step.to_i return (value % step).zero? if base == "*" end # Exact match pattern.to_i == value end |
#match_skills_semantically(search_context, skills) ⇒ Object
Use qmd semantic search to find skills whose descriptions match the current context. Returns an ordered array of SKILL.md paths (most relevant first).
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/zillacore/skills.rb', line 153 def match_skills_semantically(search_context, skills) return [] if search_context.strip.empty? # Search the knowledge collection — skills are indexed there since they're in knowledge/skills/ output, status = Open3.capture2("qmd", "search", search_context, "-c", KNOWLEDGE_COLLECTION, "-n", "10", "--md") return [] unless status.success? && !output.strip.empty? # Extract paths from qmd results that point to SKILL.md files skill_paths = skills.map { |s| s[:path] } matched = [] # qmd --md output includes file paths in results — match against known skill paths skill_paths.each do |path| # Check if the skill's directory name or file appears in search results skill_dir_name = File.basename(File.dirname(path)) matched << path if output.include?(skill_dir_name) || output.include?(path) end matched rescue StandardError => e LOG.warn "[Skills] Semantic matching failed: #{e.}" [] end |
#match_zoho_rule(email) ⇒ Object
Match an email against configured rules. Returns the first matching rule or nil. If no rules match, returns a fallback rule (if configured) so nothing is missed.
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/zillacore/handlers/zoho.rb', line 89 def match_zoho_rule(email) rules = ZOHO_CONFIG["rules"] || [] rules.each do |rule| next if rule["enabled"] == false matches = true if rule["from_contains"] && !rule["from_contains"].empty? && !email["fromAddress"].to_s.downcase.include?(rule["from_contains"].downcase) matches = false end matches = false if rule["to_contains"] && !rule["to_contains"].empty? && !email["toAddress"].to_s.downcase.include?(rule["to_contains"].downcase) if rule["subject_contains"] && !rule["subject_contains"].empty? && !email["subject"].to_s.downcase.include?(rule["subject_contains"].downcase) matches = false end if rule["body_contains"] && !rule["body_contains"].empty? body = email["summary"].to_s + email["html"].to_s matches = false unless body.downcase.include?(rule["body_contains"].downcase) end matches = false if matches && zoho_email_excluded?(email, rule["exclude_words"]) return rule if matches end # Fallback: post unmatched emails so nothing slips through fallback = zoho_fallback_rule return nil if fallback && zoho_email_excluded?(email, ZOHO_CONFIG.dig("fallback", "exclude_words")) fallback end |
#memory_dir_for(agent_name) ⇒ Object
8 9 10 |
# File 'lib/zillacore/brain.rb', line 8 def memory_dir_for(agent_name) File.join(MEMORY_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-")) end |
#move_card_to_column(card_number, column_name, project_config:, agent_name: nil) ⇒ Object
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 |
# File 'lib/zillacore/helpers.rb', line 488 def move_card_to_column(card_number, column_name, project_config:, agent_name: nil) return unless card_number board_key = board_key_for_project(project_config) column_id = (board_key && board_column_id(board_key, column_name)) || DEFAULT_COLUMN_IDS[column_name] return unless column_id repo_path = project_config["repo_path"] env = fizzy_env_for(agent_name || AI_AGENT_NAME) run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env) record_self_move(card_number) LOG.info "[Column] Moved card ##{card_number} to #{column_name} (#{column_id})" rescue StandardError => e LOG.warn "[Column] Failed to move card ##{card_number} to #{column_name}: #{e.}" end |
#notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) ⇒ Object
Notify the originating channel that an agent crashed. source: :fizzy, :github, :discord source_context: hash with channel-specific info needed to post the notification
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 |
# File 'lib/zillacore/helpers.rb', line 390 def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_context:, project_config:) agent_display = agent_name || "Agent" snippet = extract_crash_snippet(log_file) snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : "" case source when :fizzy card_number = source_context[:card_number] return unless card_number repo_path = project_config&.dig("repo_path") || Dir.pwd body = "<p>💥 <strong>#{agent_display} crashed</strong> (exit code #{exit_status})</p>" \ "<p>Log: <code>#{log_file}</code></p>" if snippet escaped = snippet[-1500..].gsub("&", "&").gsub("<", "<").gsub(">", ">") body += "<pre>#{escaped}</pre>" end begin run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body, chdir: repo_path, env: fizzy_env_for(agent_display)) LOG.info "[CrashNotify] Posted crash comment on Fizzy card ##{card_number}" rescue StandardError => e LOG.error "[CrashNotify] Failed to post Fizzy crash comment: #{e.}" end when :github pr_number = source_context[:pr_number] repo_name = source_context[:repo_name] return unless pr_number && repo_name work_dir = source_context[:work_dir] || Dir.pwd comment_body = "💥 **#{agent_display} crashed** (exit code #{exit_status})\n\nLog: `#{log_file}`#{snippet_block}" begin run_cmd("gh", "pr", "comment", pr_number.to_s, "--repo", repo_name, "--body", comment_body, chdir: work_dir) LOG.info "[CrashNotify] Posted crash comment on GitHub PR ##{pr_number}" rescue StandardError => e LOG.error "[CrashNotify] Failed to post GitHub crash comment: #{e.}" end when :discord channel_id = source_context[:channel_id] = source_context[:message_id] bot_token = source_context[:bot_token] return unless channel_id && bot_token = "💥 **#{agent_display} crashed** (exit code #{exit_status})\nLog: `#{log_file}`#{snippet_block}" (channel_id, , token: bot_token, reply_to: ) LOG.info "[CrashNotify] Posted crash message to Discord channel #{channel_id}" end rescue StandardError => e LOG.error "[CrashNotify] Unexpected error: #{e.}" end |
#notify_unauthorized(action, creator_name, card_info) ⇒ Object
756 757 758 759 760 |
# File 'lib/zillacore/helpers.rb', line 756 def (action, creator_name, card_info) msg = "Unauthorized: #{creator_name} triggered #{action} on #{card_info}" LOG.warn msg system("#{NOTIFICATION_COMMAND} '#{msg}'") if NOTIFICATION_COMMAND end |
#notify_zoho_match(email, rule) ⇒ Object
Send the notification to the configured Discord channel.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/zillacore/handlers/zoho.rb', line 160 def notify_zoho_match(email, rule) channel_id = rule["discord_channel_id"] || ZOHO_CONFIG["default_discord_channel_id"] unless channel_id LOG.warn "[Zoho] No discord_channel_id configured for rule '#{rule["label"]}' and no default set" return end = format_zoho_notification(email, rule) tokens = discord_bot_tokens bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"] || tokens.keys.first token = tokens[bot_name&.downcase] || tokens.values.first unless token LOG.warn "[Zoho] No Discord bot token available to send notification" return end LOG.info "[Zoho] Sending notification to channel #{channel_id} (bot: #{bot_name})" (channel_id, , token: token) end |
#on_comment_cooldown?(card_key) ⇒ Boolean
234 235 236 237 |
# File 'lib/zillacore/sessions.rb', line 234 def on_comment_cooldown?(card_key) last = LAST_COMMENT_TIMES[card_key] last && (Time.now - last) < COMMENT_COOLDOWN end |
#on_deploy_cooldown?(env_key) ⇒ Boolean
248 249 250 251 |
# File 'lib/zillacore/sessions.rb', line 248 def on_deploy_cooldown?(env_key) last = LAST_DEPLOY_TIMES[env_key] last && (Time.now - last) < DEPLOY_COOLDOWN end |
#owner_discord_id ⇒ Object
Discord user ID of the machine owner (for version-outdated notifications). Reads from discord.json (Discord-scoped config).
247 248 249 250 251 252 253 254 |
# File 'lib/zillacore/config.rb', line 247 def owner_discord_id discord_file = File.join(ZILLACORE_DIR, "discord.json") return nil unless File.exist?(discord_file) JSON.parse(File.read(discord_file))["owner_discord_id"] rescue JSON::ParserError nil end |
#parse_cron_expression(expr) ⇒ Object
Parse cron expression (simplified: supports minute, hour, day, month, weekday) Format: “minute hour day month weekday” (e.g., “0 9 * * 1-5” = 9am weekdays) Also supports special strings: @hourly, @daily, @weekly, @monthly Also supports one-time timestamps: ISO8601 format (e.g., “2026-02-27T09:00:00-05:00”) Also supports natural language: “tomorrow at 9am”, “in 2 hours”, “next monday at 3pm”
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/zillacore/cron.rb', line 22 def parse_cron_expression(expr) case expr when "@hourly" then { minute: 0, hour: "*", day: "*", month: "*", weekday: "*" } when "@daily" then { minute: 0, hour: 0, day: "*", month: "*", weekday: "*" } when "@weekly" then { minute: 0, hour: 0, day: "*", month: "*", weekday: 0 } when "@monthly" then { minute: 0, hour: 0, day: 1, month: "*", weekday: "*" } else # Try parsing as natural language or ISO8601 timestamp for one-time execution = parse_natural_time(expr) return { one_time: true, timestamp: } if parts = expr.split return nil unless parts.size == 5 { minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] } end end |
#parse_natural_time(expr) ⇒ Object
Parse natural language time expressions into absolute timestamps
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/zillacore/cron.rb', line 41 def parse_natural_time(expr) now = Time.now # Try ISO8601 first begin return Time.parse(expr) rescue ArgumentError # Not ISO8601, try natural language end # "tomorrow at HH:MM" or "tomorrow at HHam/pm" if expr =~ /^tomorrow\s+at\s+(.+)$/i time_str = Regexp.last_match(1) tomorrow = now + 86_400 parsed_time = parse_time_of_day(time_str, tomorrow) return parsed_time if parsed_time end # "in X hours/minutes/days" if expr =~ /^in\s+(\d+)\s+(hour|minute|day)s?$/i amount = Regexp.last_match(1).to_i unit = Regexp.last_match(2).downcase case unit when "minute" then return now + (amount * 60) when "hour" then return now + (amount * 3600) when "day" then return now + (amount * 86_400) end end # "next monday/tuesday/etc at HH:MM" weekdays = { "sunday" => 0, "monday" => 1, "tuesday" => 2, "wednesday" => 3, "thursday" => 4, "friday" => 5, "saturday" => 6 } if expr =~ /^next\s+(#{weekdays.keys.join("|")})\s+at\s+(.+)$/i target_wday = weekdays[Regexp.last_match(1).downcase] time_str = Regexp.last_match(2) days_ahead = (target_wday - now.wday + 7) % 7 days_ahead = 7 if days_ahead.zero? # "next monday" means next week if today is monday target_date = now + (days_ahead * 86_400) parsed_time = parse_time_of_day(time_str, target_date) return parsed_time if parsed_time end nil end |
#parse_skill_frontmatter(path) ⇒ Object
Parse YAML frontmatter from a SKILL.md file.
68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/zillacore/skills.rb', line 68 def parse_skill_frontmatter(path) content = File.read(path) return nil unless content.start_with?("---") parts = content.split("---", 3) return nil if parts.size < 3 YAML.safe_load(parts[1]) rescue StandardError => e LOG.warn "[Skills] Failed to parse frontmatter in #{path}: #{e.}" nil end |
#parse_time_of_day(time_str, date) ⇒ Object
Parse time of day (e.g., “9am”, “3:30pm”, “14:00”) and combine with a date
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/zillacore/cron.rb', line 87 def parse_time_of_day(time_str, date) # "9am" or "3pm" if time_str =~ /^(\d+)(am|pm)$/i hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(2).downcase) return Time.new(date.year, date.month, date.day, hour, 0, 0, date.utc_offset) end # "9:30am" or "3:45pm" if time_str =~ /^(\d+):(\d+)(am|pm)$/i hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(3).downcase) minute = Regexp.last_match(2).to_i return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset) end # "14:00" (24-hour format) if time_str =~ /^(\d+):(\d+)$/ hour = Regexp.last_match(1).to_i minute = Regexp.last_match(2).to_i return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset) end nil end |
#parse_triage_json(content) ⇒ Object
346 347 348 349 350 351 352 353 |
# File 'lib/zillacore/handlers/zoho.rb', line 346 def parse_triage_json(content) # Strip markdown code fences if present content = content.gsub(/```json\s*/, "").gsub(/```\s*/, "").strip JSON.parse(content) rescue JSON::ParserError => e LOG.warn "[Zoho:Triage] Failed to parse response JSON: #{e.}" nil end |
#persona_collection_for(agent_name) ⇒ Object
16 17 18 |
# File 'lib/zillacore/brain.rb', line 16 def persona_collection_for(agent_name) "#{agent_name.downcase.gsub(/[^a-z0-9-]/, "-")}-persona" end |
#persona_dir_for(agent_name) ⇒ Object
12 13 14 |
# File 'lib/zillacore/brain.rb', line 12 def persona_dir_for(agent_name) File.join(PERSONA_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-")) end |
#planning_complete?(card_id, agent_name) ⇒ Boolean
Check if planning is complete for a given card. Planning is complete when:
-
A plan file exists at PLANS_DIR/card-id-plan.md
-
Memory file indicates planning_complete: true
31 32 33 34 35 36 37 |
# File 'lib/zillacore/planning.rb', line 31 def planning_complete?(card_id, agent_name) memory_file = File.join(memory_dir_for(agent_name), "card-#{card_id}.md") return false unless File.exist?(memory_file) memory_content = File.read(memory_file) memory_content.match?(/planning_complete:\s*true/i) end |
#pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env) ⇒ Boolean
Check if a PR link is already present in the card’s comments.
60 61 62 63 64 65 66 67 68 |
# File 'lib/zillacore/handlers/github.rb', line 60 def pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env) output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: chdir, env: env) data = JSON.parse(output) comments = data["data"] || [] comments.any? { |c| (c.dig("body", "plain_text") || "").include?(pr_url) } rescue StandardError => e LOG.warn "Could not check existing comments for card ##{card_number}: #{e.}" false end |
#prefetch_card_context(card_number, repo_path:, agent_name: nil) ⇒ Object
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'lib/zillacore/helpers.rb', line 275 def prefetch_card_context(card_number, repo_path:, agent_name: nil) return "" unless card_number # Return cached context if fresh enough cache_key = "#{card_number}-#{agent_name}" cached = CARD_CONTEXT_CACHE[cache_key] if cached && (Time.now - cached[:at]) < CARD_CONTEXT_CACHE_TTL LOG.info "Using cached card context for ##{card_number} (#{(Time.now - cached[:at]).to_i}s old)" return cached[:context] end env = fizzy_env_for(agent_name) parts = [] card_parts = fetch_card_details(card_number, repo_path: repo_path, env: env) return "" if card_parts.nil? parts.concat(card_parts) parts.concat(fetch_card_comments(card_number, repo_path: repo_path, env: env)) return "" if parts.empty? context = parts.join("\n") result = <<~CARD_CONTEXT ## Card Context (pre-fetched — do NOT re-fetch this) #{context} CARD_CONTEXT CARD_CONTEXT_CACHE[cache_key] = { context: result, at: Time.now } CARD_CONTEXT_CACHE.delete_if { |_, v| (Time.now - v[:at]) > CARD_CONTEXT_CACHE_TTL * 5 } if CARD_CONTEXT_CACHE.size > 50 result rescue StandardError => e LOG.warn "prefetch_card_context failed for card ##{card_number}: #{e.}" "" end |
#prepare_script_discord_draft(job, timestamp) ⇒ Object
Prepare a Discord draft file and meta for a script job. Returns the draft file path.
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 |
# File 'lib/zillacore/cron.rb', line 331 def prepare_script_discord_draft(job, ) draft_file = File.join(DISCORD_DRAFT_DIR, "cron-script-#{}-#{job[:id]}.md") = "#{draft_file}.meta.json" FileUtils.mkdir_p(File.dirname(draft_file)) script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-") = { channel_id: job[:discord_channel_id], agent_key: script_agent_key, agent_name: job[:agent] || "Script", cron_job_id: job[:id], forum_title: job[:forum_title], forum_reply_to_latest: job[:forum_reply_to_latest], created_at: Time.now.iso8601 } File.write(, JSON.pretty_generate()) draft_file end |
#query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) ⇒ Object
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/zillacore/brain.rb', line 101 def query_brain(search_terms, agent_name: AI_AGENT_NAME, scope: :knowledge, max_results: 5) return "" unless system("which qmd > /dev/null 2>&1") collection = case scope when :persona then persona_collection_for(agent_name) else KNOWLEDGE_COLLECTION end output, status = Open3.capture2("qmd", "search", search_terms, "-c", collection, "-n", max_results.to_s, "--md") return "" unless status.success? && !output.strip.empty? output.strip rescue StandardError => e LOG.warn "Brain query failed (#{scope}, #{agent_name}): #{e.}" "" end |
#queue_zillacore_restart(agent_name) ⇒ Object
42 43 44 45 46 47 48 49 50 |
# File 'lib/zillacore/handlers/discord.rb', line 42 def queue_zillacore_restart(agent_name) ZILLACORE_RESTART_MUTEX.synchronize do unless ZILLACORE_RESTART_STATE[:queued] ZILLACORE_RESTART_STATE[:queued] = true ZILLACORE_RESTART_STATE[:triggered_by] = agent_name LOG.info "[ZillaCore] #{agent_name} queued a restart — will execute when all agents finish" end end end |
#read_proc_cmdline(pid) ⇒ Object
Read command line for a given PID from /proc.
144 145 146 147 148 |
# File 'lib/zillacore/sessions.rb', line 144 def read_proc_cmdline(pid) File.read("/proc/#{pid}/cmdline").tr("\0", " ").strip rescue StandardError "(unknown)" end |
#read_proc_elapsed(pid) ⇒ Object
Calculate elapsed seconds since process start from /proc/stat.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/zillacore/sessions.rb', line 151 def read_proc_elapsed(pid) stat_content = File.read("/proc/#{pid}/stat") rescue StandardError 0 else cp = stat_content.rindex(")") starttime_ticks = begin stat_content[(cp + 2)..].split[19].to_i rescue StandardError 0 end clk_tck = 100 uptime = begin File.read("/proc/uptime").split[0].to_f rescue StandardError 0 end start_seconds = starttime_ticks.to_f / clk_tck (uptime - start_seconds).to_i.clamp(0, Float::INFINITY).to_i end |
#read_zoho_triage_response(response_file, log_file) ⇒ Object
Read the triage response — try the response file first, then extract from log
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/zillacore/handlers/zoho.rb', line 327 def read_zoho_triage_response(response_file, log_file) # Try response file if File.exist?(response_file) && !File.empty?(response_file) content = File.read(response_file).strip return parse_triage_json(content) end # Fallback: extract JSON from log output if File.exist?(log_file) log_content = File.read(log_file) # Look for JSON block in the log if (match = log_content.match(/\{[^{}]*"decision"\s*:\s*"[^"]+?"[^{}]*\}/m)) return parse_triage_json(match[0]) end end nil end |
#recently_completed?(card_key, window: 120) ⇒ Boolean
55 56 57 58 59 60 61 |
# File 'lib/zillacore/sessions.rb', line 55 def recently_completed?(card_key, window: 120) ACTIVE_SESSIONS_MUTEX.synchronize do RECENT_SESSIONS.any? do |s| s[:card_key] == card_key && (Time.now - s[:finished_at]) < window end end end |
#record_agent_dispatch(card_internal_id) ⇒ Object
275 276 277 278 279 280 281 282 |
# File 'lib/zillacore/sessions.rb', line 275 def record_agent_dispatch(card_internal_id) info = AGENT_DISPATCH_DEPTH[card_internal_id] if info info[:count] += 1 else AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 1, last_human_at: Time.now } end end |
#record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "") ⇒ Object
Record a failed deploy — saves output to a log file and updates state.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/zillacore/deployments.rb', line 105 def record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "") FileUtils.mkdir_p(DEPLOY_LOGS_DIR) log_file = File.join(DEPLOY_LOGS_DIR, "#{env_key}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.log") File.write(log_file, "=== STDOUT ===\n#{stdout}\n\n=== STDERR ===\n#{stderr}") state = load_deployment_state state[env_key] ||= {} state[env_key]["last_deploy_status"] = "failed" state[env_key]["last_deploy_at"] = Time.now.iso8601 state[env_key]["last_deploy_log"] = log_file save_deployment_state(state) DEPLOYMENT_STATE.replace(state) LOG.info "[Deploy] #{env_key} deploy failed — log at #{log_file}" end |
#record_human_comment(card_internal_id) ⇒ Object
263 264 265 |
# File 'lib/zillacore/sessions.rb', line 263 def record_human_comment(card_internal_id) AGENT_DISPATCH_DEPTH[card_internal_id] = { count: 0, last_human_at: Time.now } end |
#record_self_move(card_number) ⇒ Object
34 35 36 |
# File 'lib/zillacore/sessions.rb', line 34 def record_self_move(card_number) SELF_MOVES_MUTEX.synchronize { SELF_MOVES[card_number.to_s] = Time.now } end |
#record_skill_index_views ⇒ Object
Batch-record views for all skills in the index (called when prompt is built).
210 211 212 |
# File 'lib/zillacore/skills.rb', line 210 def record_skill_index_views build_skill_index.each { |s| record_skill_usage(s[:path], type: :view) } end |
#record_skill_usage(skill_path, type: :view) ⇒ Object
Record a view (skill index shown in prompt) or use (agent read the full skill).
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/zillacore/skills.rb', line 186 def record_skill_usage(skill_path, type: :view) usage_file = skill_usage_path(skill_path) data = if File.exist?(usage_file) JSON.parse(File.read(usage_file)) else { "views" => 0, "uses" => 0, "last_viewed" => nil, "last_used" => nil, "created_at" => Time.now.iso8601 } end now = Time.now.iso8601 case type when :view data["views"] = (data["views"] || 0) + 1 data["last_viewed"] = now when :use data["uses"] = (data["uses"] || 0) + 1 data["last_used"] = now end File.write(usage_file, JSON.pretty_generate(data)) rescue StandardError => e LOG.warn "[Skills] Failed to record usage for #{skill_path}: #{e.}" end |
#register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ⇒ Object
172 173 174 175 176 177 178 179 180 |
# File 'lib/zillacore/sessions.rb', line 172 def register_session(card_key, pid, log_file: nil, message_id: nil, channel_id: nil, supersede_key: nil, draft_files: nil, agent_name: nil) ACTIVE_SESSIONS_MUTEX.synchronize do ACTIVE_SESSIONS[card_key] = { pid: pid, started_at: Time.now, log_file: log_file, message_id: , channel_id: channel_id, supersede_key: supersede_key, draft_files: draft_files, agent_name: agent_name } end end |
#reload_agent_registry!(force: false) ⇒ Object
73 74 75 76 77 78 |
# File 'lib/zillacore/agents.rb', line 73 def reload_agent_registry!(force: false) return unless file_changed?(AGENT_REGISTRY_FILE, force: force) AGENT_REGISTRY.replace(load_agent_registry) LOG.info "Reloaded agent registry: #{AGENT_REGISTRY.keys.join(", ")}" end |
#reload_cron_jobs!(force: false) ⇒ Object
Reload cron jobs from disk
191 192 193 194 195 196 197 198 199 |
# File 'lib/zillacore/cron.rb', line 191 def reload_cron_jobs!(force: false) return unless file_changed?(CRON_CONFIG_FILE, force: force) CRON_JOBS_MUTEX.synchronize do CRON_JOBS.clear CRON_JOBS.merge!(load_cron_jobs) end LOG.info "[Cron] Reloaded #{CRON_JOBS.size} cron jobs" end |
#reload_deployment_state!(force: false) ⇒ Object
42 43 44 45 46 |
# File 'lib/zillacore/deployments.rb', line 42 def reload_deployment_state!(force: false) return unless file_changed?(DEPLOYMENT_STATE_FILE, force: force) DEPLOYMENT_STATE.replace(load_deployment_state) end |
#reload_deployments_config!(force: false) ⇒ Object
36 37 38 39 40 |
# File 'lib/zillacore/deployments.rb', line 36 def reload_deployments_config!(force: false) return unless file_changed?(DEPLOYMENTS_CONFIG_FILE, force: force) DEPLOYMENTS_CONFIG.replace(load_deployments_config) end |
#reload_discord_config! ⇒ Object
131 132 133 |
# File 'lib/zillacore/handlers/discord.rb', line 131 def reload_discord_config! DISCORD_CONFIG.replace(load_discord_config) end |
#reload_github_config!(force: false) ⇒ Object
173 174 175 176 177 178 |
# File 'lib/zillacore/config.rb', line 173 def reload_github_config!(force: false) return unless file_changed?(GITHUB_CONFIG_FILE, force: force) GITHUB_CONFIG.replace(load_github_config) LOG.info "Reloaded GitHub configuration" end |
#reload_projects!(force: false) ⇒ Object
166 167 168 169 170 171 |
# File 'lib/zillacore/config.rb', line 166 def reload_projects!(force: false) return unless file_changed?(PROJECTS_FILE, force: force) PROJECTS.replace(load_projects_config) LOG.info "Reloaded projects configuration: #{PROJECTS.keys.join(", ")}" end |
#reload_user_registry!(force: false) ⇒ Object
19 20 21 22 23 24 |
# File 'lib/zillacore/users.rb', line 19 def reload_user_registry!(force: false) return unless file_changed?(USERS_FILE, force: force) USER_REGISTRY.replace(load_user_registry) LOG.info "Reloaded user registry: #{USER_REGISTRY["users"].size} users" end |
#reload_zoho_config!(force: false) ⇒ Object
28 29 30 31 32 33 |
# File 'lib/zillacore/handlers/zoho.rb', line 28 def reload_zoho_config!(force: false) return unless file_changed?(ZOHO_CONFIG_FILE, force: force) ZOHO_CONFIG.replace(load_zoho_config) LOG.info "[Zoho] Reloaded configuration" end |
#remove_cron_job(id) ⇒ Object
Remove a cron job
240 241 242 243 244 245 246 247 248 |
# File 'lib/zillacore/cron.rb', line 240 def remove_cron_job(id) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs removed = jobs.delete(id.to_sym) save_cron_jobs(jobs) CRON_JOBS.delete(id.to_sym) removed ? { success: true } : { error: "Job not found" } end end |
#remove_discord_reaction(channel_id, message_id, emoji, token:) ⇒ Object
294 295 296 297 |
# File 'lib/zillacore/handlers/discord.rb', line 294 def remove_discord_reaction(channel_id, , emoji, token:) encoded = URI.encode_www_form_component(emoji) discord_api(:delete, "/channels/#{channel_id}/messages/#{}/reactions/#{encoded}/@me", token: token) end |
#render_planning_prompt(situation_template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) ⇒ Object
Render planning mode prompt with appropriate channel rules.
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/zillacore/planning.rb', line 196 def render_planning_prompt(situation_template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) result = "" result += "#{brain_context}\n" unless brain_context.empty? result += card_context unless card_context.empty? result += PROMPT_CORE # Add planning mode instructions BEFORE channel rules plan_file = File.join(PLANS_DIR, "card-#{vars["CARD_ID"]}-plan.md") planning_vars = vars.merge("PLAN_FILE" => plan_file) result += PROMPT_PLANNING_MODE.gsub("{{PLAN_FILE}}", plan_file) result += CHANNEL_PROMPTS.fetch(channel, PROMPT_FIZZY_CHANNEL) result += situation_template result += PROMPT_REFLECTION planning_vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR planning_vars["MEMORY_DIR"] ||= memory_dir_for(agent_name) planning_vars["PERSONA_DIR"] ||= persona_dir_for(agent_name) planning_vars["PERSONA_COLLECTION"] ||= persona_collection_for(agent_name) planning_vars["AGENT_NAME"] ||= agent_name # Populate column IDs from board config, falling back to defaults DEFAULT_COLUMN_IDS.each do |col_name, default_id| var_name = "#{col_name.upcase}_COLUMN_ID" planning_vars[var_name] ||= (board_key && board_column_id(board_key, col_name)) || default_id end # Touch memory file if CARD_ID is present — ensures file exists before agent tries to read it if vars["CARD_ID"] memory_file = File.join(planning_vars["MEMORY_DIR"], "card-#{vars["CARD_ID"]}.md") FileUtils.mkdir_p(planning_vars["MEMORY_DIR"]) FileUtils.touch(memory_file) end roster = agent_roster roster_lines = roster.map { |_key, display| " - @#{display}" }.join("\n") planning_vars["AGENT_ROSTER"] ||= roster_lines planning_vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) } result end |
#render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) ⇒ Object
578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 |
# File 'lib/zillacore/prompts.rb', line 578 def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: :fizzy, board_key: nil) result = "" result += "#{brain_context}\n" unless brain_context.empty? result += card_context unless card_context.empty? result += PROMPT_CORE result += CHANNEL_PROMPTS.fetch(channel, PROMPT_FIZZY_CHANNEL) result += template # Pre-post comment check: tell the agent to re-fetch comments before posting. # Discord skips this — its supersede mechanism handles mid-session updates differently. case channel when :fizzy then result += PROMPT_PRE_POST_CHECK_FIZZY when :github then result += PROMPT_PRE_POST_CHECK_GITHUB end result += PROMPT_REFLECTION vars["KNOWLEDGE_DIR"] ||= KNOWLEDGE_DIR vars["MEMORY_DIR"] ||= memory_dir_for(agent_name) vars["PERSONA_DIR"] ||= persona_dir_for(agent_name) vars["PERSONA_COLLECTION"] ||= persona_collection_for(agent_name) vars["AGENT_NAME"] ||= agent_name # Populate column IDs from board config, falling back to defaults DEFAULT_COLUMN_IDS.each do |col_name, default_id| var_name = "#{col_name.upcase}_COLUMN_ID" vars[var_name] ||= (board_key && board_column_id(board_key, col_name)) || default_id end # Touch memory file if CARD_ID is present — ensures file exists before agent tries to read it if vars["CARD_ID"] memory_file = File.join(vars["MEMORY_DIR"], "card-#{vars["CARD_ID"]}.md") FileUtils.mkdir_p(vars["MEMORY_DIR"]) FileUtils.touch(memory_file) end roster = agent_roster roster_lines = roster.map { |_key, display| " - @#{display}" }.join("\n") vars["AGENT_ROSTER"] ||= roster_lines vars.each { |key, val| result.gsub!("{{#{key}}}", val.to_s) } result end |
#resolve_card_number(internal_id, repo_path:) ⇒ Object
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/zillacore/helpers.rb', line 173 def resolve_card_number(internal_id, repo_path:) env = default_fizzy_env [nil, "--indexed-by closed"].each do |extra_flag| cmd = ["fizzy", "card", "list", "--all"] cmd << extra_flag if extra_flag output, status = Open3.capture2(env, *cmd, chdir: repo_path) next unless status.success? data = JSON.parse(output)["data"] || [] match = data.find { |c| c["id"] == internal_id } if match LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}" return match["number"] end end LOG.warn "Could not resolve card number for internal_id #{internal_id}" nil rescue StandardError => e LOG.warn "resolve_card_number failed for #{internal_id}: #{e.}" nil end |
#resolve_deploy_environment(deploy_intent, state, card_number) ⇒ Object
Resolve which environment to deploy to from the intent.
157 158 159 160 161 162 163 164 165 |
# File 'lib/zillacore/deployments.rb', line 157 def resolve_deploy_environment(deploy_intent, state, card_number) if deploy_intent.is_a?(String) && !deploy_intent.empty? deploy_intent else existing = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first LOG.info "[Deploy] Auto-deploy skipped — card ##{card_number} not currently deployed to any environment" unless existing existing end end |
#resolve_deployment_url(env_config, card_tags) ⇒ Object
Resolve the correct URL for an environment based on card tags. If the card has a tag matching a key in the environment’s “urls” map, use that URL. Otherwise fall back to the default “url”.
252 253 254 255 256 257 258 |
# File 'lib/zillacore/deployments.rb', line 252 def resolve_deployment_url(env_config, ) urls = env_config["urls"] || {} if && urls.any? .each { |tag| return urls[tag] if urls[tag] } end env_config["url"] end |
#resolve_effort_level(level, allowed) ⇒ Object
If a level isn’t in allowed_efforts, return the closest lower level.
744 745 746 747 748 749 750 751 752 753 754 |
# File 'lib/zillacore/helpers.rb', line 744 def resolve_effort_level(level, allowed) all_levels = %w[low medium high xhigh max] return level if allowed.include?(level) idx = all_levels.index(level) return nil unless idx # Walk down to find closest supported lower level idx.downto(0) { |i| return all_levels[i] if allowed.include?(all_levels[i]) } nil end |
#resolve_project_cli_config(project_config) ⇒ Object
Resolve CLI config for a project by merging provider defaults with project overrides. Priority: project-level keys > provider file > DEFAULT_PROJECT
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/zillacore/helpers.rb', line 54 def resolve_project_cli_config(project_config) provider_config = {} if (provider_name = project_config["cli_provider"]) provider_file = File.join(CLI_PROVIDERS_DIR, "#{provider_name}.json") if File.exist?(provider_file) raw = JSON.parse(File.read(provider_file)) provider_config = { "agent_cli" => raw["binary"], "agent_cli_args" => raw["default_args"], "agent_model_flag" => raw["model_flag"], "allowed_models" => raw["models"] } end end DEFAULT_PROJECT.merge(provider_config).merge(project_config) end |
#resolve_zoho_triage_tags(tag_names) ⇒ Object
Resolve tag names to IDs by querying Fizzy
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/zillacore/handlers/zoho.rb', line 444 def (tag_names) agent_env = agent_env_for("Threepio") spawn_env = agent_env.empty? ? {} : agent_env output, status = Open3.capture2e(spawn_env, "fizzy", "tag", "list", "--all") return [] unless status.success? = JSON.parse(output)["data"] || [] tag_names.filter_map do |name| tag = .find { |t| t["title"].downcase == name.downcase } tag&.dig("id") end rescue StandardError => e LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.}" [] end |
#retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object
Retry deploy after clearing terraform lock.
183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/zillacore/deployments.rb', line 183 def retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) lock_file = File.join(worktree_path, "infrastructure/#{env_key}/.terraform.lock.hcl") FileUtils.rm_f(lock_file) Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree_path, "infrastructure/#{env_key}")) stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path) if status2.success? deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]") LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}" else record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout2, stderr: stderr2) LOG.error "[Deploy] Auto-deploy to #{env_key} failed (after retry) for card ##{card_number}" end end |
#rule_defaults(rule) ⇒ Object
484 485 486 487 |
# File 'lib/zillacore/handlers/zoho.rb', line 484 def rule_defaults(rule) { "discord_channel_id" => rule&.dig("discord_channel_id") || ZOHO_CONFIG["default_discord_channel_id"], "notify_as" => rule&.dig("notify_as") || ZOHO_CONFIG["notify_as"] } end |
#run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil, source: nil, source_context: {}, skip_column_move: false) ⇒ Object
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 |
# File 'lib/zillacore/helpers.rb', line 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, source: nil, source_context: {}, skip_column_move: false) resolved = resolve_project_cli_config(project_config) chdir ||= resolved["repo_path"] model ||= resolved["agent_model"] effort ||= resolved["agent_effort"] agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-") ensure_fizzy_yaml!(chdir, project_config) Thread.new { (chdir) } = Time.now.strftime("%Y%m%d-%H%M%S") log_file = File.join(chdir, "tmp/agent-#{log_name}-#{}.log") FileUtils.mkdir_p(File.dirname(log_file)) prompt_file = write_agent_prompt_file(prompt, log_name, ) cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort) spawn_env = agent_env_for(agent_name) LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}" LOG.info "Prompt written to #{prompt_file}" LOG.info "Command: #{cmd.join(" ")}" LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty? head_before = nil project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first if project_key_for_restart == "zillacore" head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir) head_before = head_before.strip end pid = spawn(spawn_env, *cmd, chdir: chdir, in: prompt_file, out: [log_file, "w"], err: %i[child out]) Thread.new do Process.wait(pid) handle_agent_completion( pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name, agent_name: agent_name, log_file: log_file, log_name: log_name, prompt_file: prompt_file, chdir: chdir, source: source, source_context: source_context, project_config: project_config, card_number: card_number, skip_column_move: skip_column_move, head_before: head_before, project_key_for_restart: project_key_for_restart ) end LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \ "model: #{model || "default"}), tail -f #{log_file}" [pid, log_file] end |
#run_cmd(*cmd, chdir:, env: {}) ⇒ Object
230 231 232 233 234 235 236 |
# File 'lib/zillacore/helpers.rb', line 230 def run_cmd(*cmd, chdir:, env: {}) LOG.info "Running: #{cmd.join(" ")} (in #{chdir})" stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir) raise "Command failed (#{cmd.first}): #{stderr}" unless status.success? stdout end |
#run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object
Execute deploy script with terraform lock retry logic.
168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/zillacore/deployments.rb', line 168 def run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path) if status.success? deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]") LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded for card ##{card_number}" elsif terraform_lock_error?(stdout, stderr) retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name) else record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout, stderr: stderr) LOG.error "[Deploy] Auto-deploy to #{env_key} failed for card ##{card_number}" end end |
#run_project_hook(repo_path, hook_name, extra_env: {}) ⇒ Object
Run a project-level hook script from .zillacore/<hook_name> if it exists. Passes REPO_PATH (and optionally WORKTREE_PATH) as environment variables.
116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/zillacore/helpers.rb', line 116 def run_project_hook(repo_path, hook_name, extra_env: {}) hook = File.join(repo_path, ".zillacore", hook_name) return unless File.exist?(hook) env = { "REPO_PATH" => repo_path }.merge(extra_env) LOG.info "Running .zillacore/#{hook_name} hook for #{repo_path}" output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path) if status.success? LOG.info ".zillacore/#{hook_name} completed successfully" else LOG.warn ".zillacore/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}" end end |
#save_card_map(map) ⇒ Object
204 205 206 |
# File 'lib/zillacore/helpers.rb', line 204 def save_card_map(map) File.write(CARD_MAP_FILE, JSON.pretty_generate(map)) end |
#save_cron_jobs(jobs) ⇒ Object
Save cron jobs to config
185 186 187 188 |
# File 'lib/zillacore/cron.rb', line 185 def save_cron_jobs(jobs) FileUtils.mkdir_p(ZILLACORE_DIR) File.write(CRON_CONFIG_FILE, JSON.pretty_generate(jobs)) end |
#save_deployment_state(state) ⇒ Object
29 30 31 |
# File 'lib/zillacore/deployments.rb', line 29 def save_deployment_state(state) File.write(DEPLOYMENT_STATE_FILE, JSON.pretty_generate(state)) end |
#save_zoho_hook_secret(secret) ⇒ Object
61 62 63 64 65 |
# File 'lib/zillacore/handlers/zoho.rb', line 61 def save_zoho_hook_secret(secret) ZOHO_CONFIG["hook_secret"] = secret File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG)) LOG.info "[Zoho] Saved hook_secret to #{ZOHO_CONFIG_FILE}" end |
#scrub_invalid_attachments!(dir) ⇒ Object
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/zillacore/helpers.rb', line 359 def (dir) = File.join(dir, ".fizzy-attachments") return unless File.directory?() Dir.glob(File.join(, "*")).each do |file_path| next unless File.file?(file_path) file_type, _status = Open3.capture2("file", "--brief", "--mime-type", file_path) unless file_type.strip.start_with?("image/") LOG.warn "Removing invalid attachment #{file_path} (detected as: #{file_type.strip})" FileUtils.rm_f(file_path) end end rescue StandardError => e LOG.error "Error scrubbing attachments in #{dir}: #{e.}" end |
#self_move_recent?(card_number, window: 120) ⇒ Boolean
38 39 40 41 42 43 |
# File 'lib/zillacore/sessions.rb', line 38 def self_move_recent?(card_number, window: 120) SELF_MOVES_MUTEX.synchronize do t = SELF_MOVES[card_number.to_s] t && (Time.now - t) < window end end |
#send_deploy_notification(project_key, closed_cards) ⇒ Object
347 348 349 350 351 352 353 354 355 356 357 358 359 360 |
# File 'lib/zillacore/handlers/github.rb', line 347 def send_deploy_notification(project_key, closed_cards) channel_id = DISCORD_CONFIG["deploy_notification_channel_id"] return unless channel_id token = discord_bot_tokens.values.first return unless token card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n") = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}" (channel_id, , token: token) rescue StandardError => e LOG.warn "Failed to send deploy notification: #{e.}" end |
#send_discord_message(channel_id, content, token:, reply_to: nil) ⇒ Object
266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/zillacore/handlers/discord.rb', line 266 def (channel_id, content, token:, reply_to: nil) body = { content: content } body[:message_reference] = { message_id: reply_to } if reply_to result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body) if result && result["id"] LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}" else LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}" end result end |
#send_discord_typing(channel_id, token:) ⇒ Object
278 279 280 |
# File 'lib/zillacore/handlers/discord.rb', line 278 def send_discord_typing(channel_id, token:) discord_api(:post, "/channels/#{channel_id}/typing", token: token) end |
#send_long_discord_message(channel_id, content, token:, reply_to: nil) ⇒ Object
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/zillacore/handlers/discord.rb', line 311 def (channel_id, content, token:, reply_to: nil) if content.length <= 2000 (channel_id, content, token: token, reply_to: reply_to) return end chunks = [] remaining = content while remaining.length.positive? if remaining.length <= 2000 chunks << remaining remaining = "" else split_at = remaining.rindex("\n", 1990) || 1990 chunks << remaining[0...split_at] remaining = remaining[split_at..].lstrip end end chunks.each_with_index do |chunk, i| (channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil) sleep 0.5 end end |
#send_restart_notification(message) ⇒ Object
Send a Discord notification about zillacore restart/startup using any available bot token.
53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/zillacore/handlers/discord.rb', line 53 def send_restart_notification() channel_id = DISCORD_CONFIG["notification_channel_id"] return unless channel_id tokens = discord_bot_tokens # Prefer the triggering agent's token, fall back to first available triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] } token = tokens[triggered_by&.downcase] || tokens.values.first return unless token (channel_id, , token: token) rescue StandardError => e LOG.warn "[ZillaCore] Failed to send restart notification: #{e.}" end |
#send_uat_deploy_notification(project_key) ⇒ Object
362 363 364 365 366 367 368 369 370 371 372 373 |
# File 'lib/zillacore/handlers/github.rb', line 362 def send_uat_deploy_notification(project_key) channel_id = DISCORD_CONFIG["deploy_notification_channel_id"] return unless channel_id token = discord_bot_tokens.values.first return unless token = "✅ **#{project_key.capitalize}** deployed to UAT successfully" (channel_id, , token: token) rescue StandardError => e LOG.warn "Failed to send UAT deploy notification: #{e.}" end |
#send_workflow_failure_notification(project_key, workflow_name, run_url) ⇒ Object
375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/zillacore/handlers/github.rb', line 375 def send_workflow_failure_notification(project_key, workflow_name, run_url) channel_id = DISCORD_CONFIG["deploy_notification_channel_id"] return unless channel_id token = discord_bot_tokens.values.first return unless token = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})" (channel_id, , token: token) rescue StandardError => e LOG.warn "Failed to send workflow failure notification: #{e.}" end |
#session_active?(card_key) ⇒ Boolean
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/zillacore/sessions.rb', line 63 def session_active?(card_key) ACTIVE_SESSIONS_MUTEX.synchronize do info = ACTIVE_SESSIONS[card_key] return false unless info begin Process.kill(0, info[:pid]) true rescue Errno::ESRCH, Errno::EPERM archive_session(card_key, info) ACTIVE_SESSIONS.delete(card_key) false end end end |
#skill_index_for_prompt ⇒ Object
Build the skill index section for prompt injection. Only includes name + description (not full content) to keep tokens bounded.
88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/zillacore/skills.rb', line 88 def skill_index_for_prompt skills = build_skill_index return "" if skills.empty? lines = skills.map { |s| "- **#{s[:name]}**: #{s[:description]}" } <<~SECTION ## Available Skills The following procedural skills are available. To use one, read the full file at its path. #{lines.join("\n")} SECTION end |
#skill_usage_path(skill_path) ⇒ Object
181 182 183 |
# File 'lib/zillacore/skills.rb', line 181 def skill_usage_path(skill_path) skill_path.sub(/SKILL\.md$/, "SKILL.usage.json") end |
#slugify(title, max_length: 40) ⇒ Object
208 209 210 |
# File 'lib/zillacore/helpers.rb', line 208 def slugify(title, max_length: 40) title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-") end |
#start_all_discord_gateways ⇒ Object
Start all per-agent Discord bots.
1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 |
# File 'lib/zillacore/handlers/discord.rb', line 1619 def start_all_discord_gateways tokens = discord_bot_tokens if tokens.empty? LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled" return end LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}" tokens.each do |agent_key, token| DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil } end start_discord_gateway_for(agent_key, token) sleep 1 # Stagger connections to avoid rate limits end end |
#start_cron_thread ⇒ Object
Start cron background thread
620 621 622 623 624 625 626 627 628 629 |
# File 'lib/zillacore/cron.rb', line 620 def start_cron_thread return if CRON_THREAD[:ref]&.alive? reload_cron_jobs! CRON_THREAD[:ref] = Thread.new do LOG.info "[Cron] Starting cron thread..." cron_loop end end |
#start_discord_draft_poller ⇒ Object
seconds — don’t race the monitoring thread
1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 |
# File 'lib/zillacore/handlers/discord.rb', line 1439 def start_discord_draft_poller Thread.new do LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s" loop do sleep DISCORD_DRAFT_POLLER_INTERVAL begin # Clean up stale lock files (older than 60s) left by crashed deliveries Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file| File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60 end Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do || # Don't race the monitoring thread — wait for the file to age next if (Time.now - File.mtime()) < DISCORD_DRAFT_MIN_AGE # Cron metas: foo.md.meta.json → foo.md # Discord metas: foo.meta.json → foo.md response_file = if .end_with?(".md.meta.json") .sub(".md.meta.json", ".md") else .sub(".meta.json", ".md") end next unless File.exist?(response_file) LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename()}" deliver_discord_draft(response_file, ) end rescue StandardError => e LOG.error "[Discord] Draft poller error: #{e.}" end end end end |
#start_discord_gateway_for(agent_key, bot_token) ⇒ Object
— Discord Gateway (one per agent bot) —
1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 |
# File 'lib/zillacore/handlers/discord.rb', line 1475 def start_discord_gateway_for(agent_key, bot_token) Thread.new do agent_display = fizzy_display_name(agent_key) || agent_key.capitalize bot_user_id = nil loop do DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS[agent_key] ||= {} DISCORD_BOTS[agent_key][:status] = "connecting" DISCORD_BOTS[agent_key][:token] = bot_token end LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." heartbeat_thread = nil last_sequence = nil ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL) ws.on :message do |msg| next if msg.data.nil? || msg.data.empty? payload = JSON.parse(msg.data) op = payload["op"] data = payload["d"] last_sequence = payload["s"] if payload["s"] case op when 10 # Hello heartbeat_interval = data["heartbeat_interval"] LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{heartbeat_interval}ms" heartbeat_thread&.kill heartbeat_thread = Thread.new do loop do sleep(heartbeat_interval / 1000.0) ws.send({ op: 1, d: last_sequence }.to_json) end end ws.send({ op: 2, d: { token: bot_token, intents: 46_593, properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" } } }.to_json) when 0 # Dispatch case payload["t"] when "READY" bot_user_id = data.dig("user", "id") DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS[agent_key][:user_id] = bot_user_id DISCORD_BOTS[agent_key][:status] = "ready" end guild_count = data["guilds"]&.size || 0 LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})" LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}" # Check if all bots are now ready (log once) DISCORD_BOTS_MUTEX.synchronize do if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" } DISCORD_ALL_READY_LOGGED[:done] = true LOG.info "[Discord] All bots connected." end end when "MESSAGE_CREATE" Thread.new do (data, agent_key, bot_token, bot_user_id) rescue StandardError => e LOG.error "[Discord:#{agent_display}] Error handling message: #{e.}\n#{e.backtrace.first(3).join("\n")}" end when "MESSAGE_UPDATE" # Discord sends MESSAGE_UPDATE for embed/link preview resolution, # not just human edits. Only dispatch if edited_timestamp is set # (real edit) — otherwise it's just Discord enriching the message. if data["edited_timestamp"] Thread.new do (data, agent_key, bot_token, bot_user_id) rescue StandardError => e LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end when "MESSAGE_REACTION_ADD" Thread.new do handle_discord_reaction(data, agent_key, bot_token, bot_user_id) rescue StandardError => e LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.}\n#{e.backtrace.first(3).join("\n")}" end end when 1 then ws.send({ op: 1, d: last_sequence }.to_json) when 7 then LOG.info "[Discord:#{agent_display}] Reconnect requested" ws.close when 9 then LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s" sleep 5 ws.send({ op: 2, d: { token: bot_token, intents: 46_593, properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" } } }.to_json) when 11 then nil # Heartbeat ACK end rescue StandardError => e LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.}" end ws.on :open do LOG.debug "[Discord:#{agent_display}] WebSocket connected" end ws.on :close do |e| DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key] end LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}" heartbeat_thread&.kill end ws.on :error do |e| LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.}" end loop do sleep 1 next if ws.open? LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..." sleep 5 break end rescue StandardError => e DISCORD_BOTS_MUTEX.synchronize do DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key] end LOG.error "[Discord:#{agent_display}] Gateway error: #{e.}, reconnecting in 5s..." sleep 5 end end end |
#start_zillacore_restart_monitor ⇒ Object
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/zillacore/handlers/discord.rb', line 79 def start_zillacore_restart_monitor Thread.new do LOG.info "[ZillaCore] Restart monitor started, checking every 30s" loop do sleep 30 restart_needed = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] } if restart_needed && !any_agents_running? triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] } LOG.info "[ZillaCore] All agents finished, executing restart..." ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] = false } send_restart_notification("🔄 Restarting zillacore (triggered by #{triggered_by || "unknown"})...") # Schedule restart: stop now, start in 3 seconds # This ensures the current process fully exits before the new one starts Thread.new do sleep 1 # Give time for log to flush # Spawn a delayed restart command that will execute after we exit # Inherit current PATH so zillacore binary can be found regardless of install location # Process.detach ensures the spawned process survives when parent exits pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "sh", "-c", "sleep 3 && zillacore server --daemon", out: "/dev/null", err: "/dev/null") Process.detach(pid) sleep 1 LOG.info "[ZillaCore] Stopping server, new instance will start in 3 seconds..." Sinatra::Application.quit! sleep 0.5 # Give Sinatra a moment to shut down gracefully exit! # Force exit to kill all threads immediately end elsif restart_needed active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size } LOG.info "[ZillaCore] Restart queued but #{active_count} agent(s) still running, waiting..." end end end end |
#terraform_lock_error?(stdout, stderr) ⇒ Boolean
Detect Terraform provider lock file checksum mismatch errors.
198 199 200 201 |
# File 'lib/zillacore/deployments.rb', line 198 def terraform_lock_error?(stdout, stderr) combined = "#{stdout}\n#{stderr}" combined.include?("checksums previously recorded in the dependency lock file") end |
#toggle_cron_job(id, enabled) ⇒ Object
Enable/disable a cron job
251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/zillacore/cron.rb', line 251 def toggle_cron_job(id, enabled) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job = jobs[id.to_sym] return { error: "Job not found" } unless job job[:enabled] = enabled jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job { success: true, job: job } end end |
#touch_comment_cooldown(card_key) ⇒ Object
239 240 241 |
# File 'lib/zillacore/sessions.rb', line 239 def touch_comment_cooldown(card_key) LAST_COMMENT_TIMES[card_key] = Time.now end |
#touch_deploy_cooldown(env_key) ⇒ Object
253 254 255 |
# File 'lib/zillacore/sessions.rb', line 253 def touch_deploy_cooldown(env_key) LAST_DEPLOY_TIMES[env_key] = Time.now end |
#track_pr_in_card_map(payload) ⇒ Object
Track a newly opened PR in the card map by matching its branch.
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/zillacore/handlers/github.rb', line 25 def track_pr_in_card_map(payload) pr = payload["pull_request"] branch = pr.dig("head", "ref") pr_number = pr["number"] pr_url = pr["html_url"] result = find_card_by_branch(branch) unless result LOG.info "[PR Track] No card found for branch #{branch}" return end internal_id, card_info = result prs = card_info["prs"] || [] return if prs.any? { |p| p["number"] == pr_number } prs << { "number" => pr_number, "url" => pr_url } card_info["prs"] = prs map = load_card_map map[internal_id] = card_info save_card_map(map) LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})" end |
#trust_version_manager(path, chdir:) ⇒ Object
Trust the version manager config in a directory (supports mise and asdf)
239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/zillacore/helpers.rb', line 239 def trust_version_manager(path, chdir:) if system("which mise >/dev/null 2>&1") run_cmd("mise", "trust", path, chdir: chdir) elsif system("which asdf >/dev/null 2>&1") LOG.info "asdf detected — no explicit trust needed for #{path}" else LOG.info "No version manager (mise/asdf) found — skipping trust for #{path}" end rescue StandardError => e LOG.warn "Could not trust version manager in #{path}: #{e.}" end |
#uat_column_id(project_config) ⇒ Object
8 9 10 11 |
# File 'lib/zillacore/handlers/github.rb', line 8 def uat_column_id(project_config) bk = board_key_for_project(project_config) (bk && board_column_id(bk, "uat")) || DEFAULT_UAT_COLUMN_ID end |
#update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil) ⇒ Object
Update a cron job’s schedule, discord channel, and/or forum title
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/zillacore/cron.rb', line 266 def update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil) return { error: "No updates provided" } if schedule.nil? && discord_channel_id.nil? && forum_title.nil? && forum_reply_to_latest.nil? if schedule parsed = parse_cron_expression(schedule) return { error: "Invalid cron expression" } unless parsed end CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job = jobs[id.to_sym] return { error: "Job not found" } unless job if schedule job[:schedule] = schedule job[:parsed] = parsed end job[:discord_channel_id] = discord_channel_id if discord_channel_id job[:forum_title] = forum_title if forum_title job[:forum_reply_to_latest] = forum_reply_to_latest unless forum_reply_to_latest.nil? jobs[id.to_sym] = job save_cron_jobs(jobs) CRON_JOBS[id.to_sym] = job { success: true, job: job } end end |
#update_cron_job_state(job) ⇒ Object
Update cron job state after execution (last_run, execution_count, auto-disable)
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 |
# File 'lib/zillacore/cron.rb', line 494 def update_cron_job_state(job) CRON_JOBS_MUTEX.synchronize do jobs = load_cron_jobs job_data = jobs[job[:id].to_sym] return unless job_data job_data[:last_run] = Time.now.iso8601 job_data[:execution_count] = (job_data[:execution_count] || 0) + 1 if job[:parsed][:one_time] job_data[:enabled] = false CRON_JOBS[job[:id].to_sym][:enabled] = false LOG.info "[Cron] Auto-disabled one-time job: #{job[:id]}" elsif job[:repeat_count] && job_data[:execution_count] >= job[:repeat_count] job_data[:enabled] = false CRON_JOBS[job[:id].to_sym][:enabled] = false LOG.info "[Cron] Auto-disabled job #{job[:id]} after #{job[:repeat_count]} executions" end save_cron_jobs(jobs) CRON_JOBS[job[:id].to_sym][:last_run] = Time.now.iso8601 CRON_JOBS[job[:id].to_sym][:execution_count] = job_data[:execution_count] end end |
#verify_github_signature!(request, payload_body) ⇒ Object
221 222 223 224 225 226 227 228 |
# File 'lib/zillacore/helpers.rb', line 221 def verify_github_signature!(request, payload_body) signature = request.env["HTTP_X_HUB_SIGNATURE_256"] halt 403, { error: "Missing GitHub signature" }.to_json unless signature secret = github_webhook_secret halt 500, { error: "GitHub webhook secret not configured" }.to_json unless secret computed = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)}" halt 403, { error: "Invalid GitHub signature" }.to_json unless Rack::Utils.secure_compare(signature, computed) end |
#verify_signature!(request, payload_body, board_key: nil) ⇒ Object
212 213 214 215 216 217 218 219 |
# File 'lib/zillacore/helpers.rb', line 212 def verify_signature!(request, payload_body, board_key: nil) signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"] halt 403, { error: "Missing signature" }.to_json unless signature secret = board_key ? board_webhook_secret(board_key) : FIZZY_WEBHOOK_SECRET halt 403, { error: "No webhook secret configured" }.to_json unless secret computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body) halt 403, { error: "Invalid signature" }.to_json unless Rack::Utils.secure_compare(signature, computed) end |
#verify_zoho_signature!(request, payload_body) ⇒ Object
Verify the X-Hook-Signature header (base64 HMAC-SHA256 of the raw body).
68 69 70 71 72 73 74 75 76 77 |
# File 'lib/zillacore/handlers/zoho.rb', line 68 def verify_zoho_signature!(request, payload_body) signature = request.env["HTTP_X_HOOK_SIGNATURE"] return unless signature # First request won't have a signature, just the secret secret = zoho_hook_secret halt 500, { error: "No hook_secret configured — waiting for initial Zoho handshake" }.to_json unless secret computed = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret, payload_body)) halt 403, { error: "Invalid Zoho signature" }.to_json unless Rack::Utils.secure_compare(signature, computed) end |
#wait_for_session?(card_key) ⇒ Boolean
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/zillacore/sessions.rb', line 82 def wait_for_session?(card_key) return true unless session_active?(card_key) LOG.info "Waiting for active session on #{card_key} to finish..." elapsed = 0 while session_active?(card_key) && elapsed < SESSION_WAIT_MAX sleep SESSION_WAIT_INTERVAL elapsed += SESSION_WAIT_INTERVAL LOG.info "Still waiting on #{card_key} (#{elapsed}s elapsed)" end if session_active?(card_key) LOG.warn "Timed out waiting for session on #{card_key} after #{SESSION_WAIT_MAX}s" false else LOG.info "Session on #{card_key} finished after ~#{elapsed}s, proceeding" true end end |
#write_agent_prompt_file(prompt, log_name, timestamp) ⇒ Object
Write agent prompt to a temp file, return path.
572 573 574 575 576 577 578 |
# File 'lib/zillacore/helpers.rb', line 572 def write_agent_prompt_file(prompt, log_name, ) prompt_dir = File.join(ZILLACORE_DIR, "tmp") FileUtils.mkdir_p(prompt_dir) prompt_file = File.join(prompt_dir, "prompt-#{log_name}-#{}.md") File.write(prompt_file, prompt) prompt_file end |
#write_cron_prompt_file(job, prompt_content, timestamp) ⇒ Object
Write cron prompt to a temp file, return path.
567 568 569 570 571 572 573 |
# File 'lib/zillacore/cron.rb', line 567 def write_cron_prompt_file(job, prompt_content, ) prompt_dir = File.join(ZILLACORE_DIR, "tmp") FileUtils.mkdir_p(prompt_dir) prompt_file = File.join(prompt_dir, "prompt-cron-#{job[:id]}-#{}.md") File.write(prompt_file, prompt_content) prompt_file end |
#zoho_access_token ⇒ Object
51 52 53 54 55 |
# File 'lib/zillacore/zoho_mail_api.rb', line 51 def zoho_access_token return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at zoho_refresh_access_token! end |
#zoho_api_configured? ⇒ Boolean
21 22 23 24 |
# File 'lib/zillacore/zoho_mail_api.rb', line 21 def zoho_api_configured? api = ZOHO_CONFIG["api"] api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"] end |
#zoho_email_excluded?(email, exclude_words) ⇒ Boolean
Check if an email contains any of the exclude words (checked against subject, from, and body).
80 81 82 83 84 85 |
# File 'lib/zillacore/handlers/zoho.rb', line 80 def zoho_email_excluded?(email, exclude_words) return false if exclude_words.nil? || exclude_words.empty? searchable = [email["subject"], email["fromAddress"], email["toAddress"], email["summary"], email["html"]].join(" ").downcase Array(exclude_words).any? { |word| searchable.include?(word.downcase) } end |
#zoho_fallback_rule ⇒ Object
Returns the fallback rule config, or nil if not configured.
119 120 121 122 123 124 125 126 127 |
# File 'lib/zillacore/handlers/zoho.rb', line 119 def zoho_fallback_rule fallback = ZOHO_CONFIG["fallback"] return nil unless fallback && fallback["enabled"] != false { "label" => fallback["label"] || "Unmatched Email", "emoji" => fallback["emoji"] || "📬", "discord_channel_id" => fallback["discord_channel_id"], "notify_as" => fallback["notify_as"] } end |
#zoho_hook_secret ⇒ Object
Zoho sends the signing secret in the X-Hook-Secret header on the very first request. We store it in the config file so subsequent requests can be verified. If the secret is already in the config, we use that.
57 58 59 |
# File 'lib/zillacore/handlers/zoho.rb', line 57 def zoho_hook_secret ZOHO_CONFIG["hook_secret"] end |
#zoho_refresh_access_token! ⇒ Object
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/zillacore/zoho_mail_api.rb', line 26 def zoho_refresh_access_token! api = ZOHO_CONFIG["api"] uri = URI(ZOHO_TOKEN_URL) res = Net::HTTP.post_form(uri, { "grant_type" => "refresh_token", "client_id" => api["client_id"], "client_secret" => api["client_secret"], "refresh_token" => api["refresh_token"] }) data = JSON.parse(res.body) if data["access_token"] @zoho_access_token = data["access_token"] @zoho_token_expires_at = Time.now + 3300 # ~55 min LOG.info "[Zoho:API] Refreshed access token" @zoho_access_token else LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}" nil end rescue StandardError => e LOG.error "[Zoho:API] Token refresh error: #{e.}" nil end |
#zoho_triage_agent_assignment ⇒ Object
47 48 49 50 51 52 |
# File 'lib/zillacore/handlers/zoho.rb', line 47 def zoho_triage_agent_assignment rules = ZOHO_CONFIG["triage_agent_assignment"] return "Assign to the default agent." unless rules&.any? rules.map { |r| " - #{r}" }.join("\n") end |
#zoho_triage_project_tags ⇒ Object
40 41 42 43 44 45 |
# File 'lib/zillacore/handlers/zoho.rb', line 40 def = ZOHO_CONFIG["triage_project_tags"] return "Use your best judgement to identify the relevant project." unless &.any? .map { |t| " - `#{t["tag"]}` — #{t["description"]}" }.join("\n") end |