Top Level Namespace

Defined Under Namespace

Modules: Brainiac Classes: CardIndex, UserRegistry

Constant Summary collapse

CRON_CONFIG_FILE =
File.join(BRAINIAC_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(BRAINIAC_DIR, "users.json")
USER_REGISTRY =
load_user_registry
AGENT_REGISTRY =
load_agent_registry
BRAINIAC_VERSION =
Brainiac::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
BRAINIAC_DIR =
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
PROJECTS_FILE =
File.join(BRAINIAC_DIR, "projects.json")
KIRO_AGENTS_DIR =
File.join(Dir.home, ".kiro", "agents")
CARD_MAP_FILE =
File.join(BRAINIAC_DIR, "card_map.json")
AGENT_TOKENS_FILE =
File.join(BRAINIAC_DIR, "agent_tokens.json")
AGENT_REGISTRY_FILE =
File.join(BRAINIAC_DIR, "agents.json")
LOG_LEVEL =
ENV.fetch("LOG_LEVEL", "info").downcase
LOG =
Logger.new($stdout)
BRAIN_BASE_DIR =

— Brain paths —

File.join(BRAINIAC_DIR, "brain")
KNOWLEDGE_DIR =
File.join(BRAIN_BASE_DIR, "knowledge")
PERSONA_BASE_DIR =
File.join(BRAIN_BASE_DIR, "persona")
MEMORY_BASE_DIR =
File.join(BRAINIAC_DIR, "brain", "memory")
MEMORY_FILE_TEMPLATE =
"card-{{CARD_ID}}.md"
KNOWLEDGE_COLLECTION =
"brainiac-knowledge"
FIZZY_CONFIG_FILE =

— Fizzy auth —

File.join(BRAINIAC_DIR, "fizzy.json")
FIZZY_CONFIG =
load_fizzy_config
GITHUB_CONFIG_FILE =

— GitHub auth —

File.join(BRAINIAC_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(BRAINIAC_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(BRAINIAC_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 brainiac-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 brainiac-knowledge
  ```
  Examples: `qmd search "fizzy" -c brainiac-knowledge`, `qmd search "qmd" -c brainiac-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: `brainiac 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
  Brainiac 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(BRAINIAC_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(BRAINIAC_DIR, "card_index.json"),
  titles_dir: File.join(BRAINIAC_DIR, "card_titles")
)
DEPLOYMENTS_CONFIG_FILE =

Deployment environment tracking. Tracks which dev environments have active card deploys and which are available.

File.join(BRAINIAC_DIR, "deployments.json")
DEPLOYMENT_STATE_FILE =
File.join(BRAINIAC_DIR, "deployment_state.json")
DEPLOYMENTS_CONFIG =
load_deployments_config
DEPLOYMENT_STATE =
load_deployment_state
DEPLOY_LOGS_DIR =
File.join(BRAINIAC_DIR, "deploy_logs")
ZOHO_CONFIG_FILE =
File.join(BRAINIAC_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(BRAINIAC_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(BRAINIAC_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(BRAINIAC_DIR, "tmp", "discord", "draft")
DISCORD_POSTED_DIR =
File.join(BRAINIAC_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
BRAINIAC_RESTART_STATE =

Brainiac restart queue: when an agent works on brainiac 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 }
BRAINIAC_RESTART_MUTEX =
Mutex.new
DISCORD_CONFIG =
load_discord_config
RESERVED_EMOJIS =

Emojis reserved for brainiac 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

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/brainiac/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/brainiac/handlers/discord.rb', line 289

def add_discord_reaction(channel_id, message_id, emoji, token:)
  encoded = URI.encode_www_form_component(emoji)
  discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
end

#add_trust_tools!(cmd, agent_cli_args) ⇒ Object



12
13
14
15
16
# File 'lib/brainiac/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

Returns:

  • (Boolean)


267
268
269
270
271
272
273
# File 'lib/brainiac/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/brainiac/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/brainiac/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/brainiac/agents.rb', line 137

def agent_name_for(project_config)
  project_config["agent_name"] || AI_AGENT_NAME
end

#agent_rosterObject



122
123
124
125
126
# File 'lib/brainiac/agents.rb', line 122

def agent_roster
  roster = {}
  all_agent_names.each { |name| roster[name.downcase] = fizzy_display_name(name) }
  roster
end

#ai_agentsObject

Get all AI agents



74
75
76
# File 'lib/brainiac/users.rb', line 74

def ai_agents
  USER_REGISTRY["users"].select { |u| u["notes"]&.include?("AI agent") }
end

#all_agent_namesObject



141
142
143
144
145
146
147
148
149
150
# File 'lib/brainiac/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

Returns:

  • (Boolean)


10
11
12
13
14
15
16
17
18
19
20
# File 'lib/brainiac/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

Returns:

  • (Boolean)


68
69
70
71
72
73
74
75
76
77
# File 'lib/brainiac/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 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/brainiac/helpers.rb', line 444

def append_fizzy_comment_footer(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?

  footer_html = "<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#{footer_html}"
    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.message}"
  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/brainiac/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/brainiac/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/brainiac/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.message}"
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/brainiac/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

Returns:

  • (Boolean)


687
688
689
690
# File 'lib/brainiac/helpers.rb', line 687

def authorized?(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/brainiac/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)"
  mark_deploying(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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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.message}"
  nil
end

#board_webhook_secret(board_key) ⇒ Object



95
96
97
98
# File 'lib/brainiac/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 —

Returns:

  • (Boolean)


22
23
24
# File 'lib/brainiac/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/brainiac/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.message}"
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/brainiac/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/brainiac/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", message, 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.message}"
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/brainiac/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/brainiac/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/brainiac/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/brainiac/cron.rb', line 368

def build_cron_prompt(job, project)
  prompt = job[:prompt]
  agent_name = job[:agent]
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")

  if job[:discord_channel_id]
    draft_file = File.join(DISCORD_DRAFT_DIR, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
    meta_file = "#{draft_file}.meta.json"
    FileUtils.mkdir_p(File.dirname(draft_file))

    agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
    meta = {
      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(meta_file, JSON.pretty_generate(meta))

    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: meta_file, full_prompt: full_prompt }
  else
    response_file = File.join(BRAINIAC_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_mapObject

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/brainiac/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_indexObject

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/brainiac/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/brainiac/users.rb', line 63

def canonical_name_for(identifier)
  user = find_user(identifier)
  user ? user["canonical_name"] : identifier
end

#card_merged?(card_number) ⇒ Boolean

Returns:

  • (Boolean)


260
261
262
263
264
265
# File 'lib/brainiac/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_brainiac_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/brainiac/helpers.rb', line 675

def check_brainiac_restart(head_before, chdir, project_key_for_restart, agent_config_name)
  return unless project_key_for_restart == "brainiac" && 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_brainiac_restart(agent_config_name || "agent")
  else
    LOG.info "[Brainiac] #{agent_config_name || "agent"} session on brainiac had no changes — skipping restart"
  end
end

#check_brainiac_versionObject

Check if local brainiac 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/brainiac/config.rb', line 224

def check_brainiac_version
  brainiac_dir = File.join(__dir__, "..", "..")

  # Fetch latest from origin (quiet, don't fail if offline)
  _, _, status = Open3.capture3("git", "fetch", "origin", "master", "--quiet", chdir: brainiac_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: brainiac_dir)
  remote_sha, = Open3.capture3("git", "rev-parse", "origin/master", chdir: brainiac_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: brainiac_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/brainiac/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.message}"
  []
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/brainiac/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.message}"
      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/brainiac/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/brainiac/handlers/fizzy.rb', line 384

def clone_branch_for_deploy(eventable, card_internal_id, card_info)
  # Resolve project from card tags
  card_tags = eventable.dig("card", "tags") || []
  project_result = identify_project_by_tags(card_tags)
  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.message}"
  nil
end

#comment_from_agent?(name) ⇒ Boolean

Returns:

  • (Boolean)


198
199
200
201
202
203
# File 'lib/brainiac/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/brainiac/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/brainiac/handlers/discord.rb', line 299

def create_discord_thread(channel_id, message_id, name:, token:)
  thread_name = name.length > 100 ? "#{name[0..96]}..." : name
  discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/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/brainiac/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/brainiac/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
  tags = ["support"]
  tags << decision["project_tag"] if decision["project_tag"]

  # Resolve tag IDs
  tag_ids = resolve_zoho_triage_tags(tags)

  # 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:** #{tags.join(", ")}\n"
    msg += "**From:** #{email["fromAddress"]}"
    send_discord_message(channel_id, msg, token: token)
  end
rescue StandardError => e
  LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
  notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
end

#cron_loopObject

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/brainiac/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.message}\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

Returns:

  • (Boolean)


119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/brainiac/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_skillsObject

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/brainiac/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.message}"
  { archived: 0, consolidation_candidates: [], error: e.message }
end

#debounced_repo_fetch(repo_path) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
# File 'lib/brainiac/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_envObject



108
109
110
# File 'lib/brainiac/agents.rb', line 108

def default_fizzy_env
  fizzy_env_for(AI_AGENT_NAME)
end

#default_project_configObject



35
36
37
38
# File 'lib/brainiac/handlers/zoho.rb', line 35

def default_project_config
  key = default_project_key
  key ? PROJECTS[key] : PROJECTS.values.first
end

#default_project_keyObject



130
131
132
133
134
# File 'lib/brainiac/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/brainiac/handlers/discord.rb', line 1289

def deliver_discord_draft(response_file, meta_file)
  return false unless File.exist?(meta_file)

  # Simple file-based lock to prevent the monitoring thread and poller
  # from delivering the same draft simultaneously.
  lock_file = "#{meta_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

  meta = JSON.parse(File.read(meta_file))
  channel_id = meta["channel_id"]
  message_id = meta["message_id"]
  agent_key = meta["agent_key"]
  agent_name = meta["agent_name"]
  is_dm = meta["is_dm"]
  is_thread = meta["is_thread"]
  clean_content = meta["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, message_id, "😶", token: bot_token) if message_id
      send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
    elsif is_dm || is_thread || message_id.nil?
      # DMs, threads, and cron jobs (no message_id) need special handling
      # Check if this is a forum channel
      if message_id.nil? && forum_channel?(channel_id, token: bot_token)
        title = meta["forum_title"] || "#{agent_name}#{Time.now.strftime("%b %d, %Y")}"
        if meta["forum_reply_to_latest"]
          latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
          if latest_thread
            send_long_discord_message(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
        send_long_discord_message(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[message_id]

        # 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/#{message_id}", token: bot_token)
          if original_msg&.dig("thread", "id")
            thread_id = original_msg["thread"]["id"]
            DISCORD_SHARED_THREADS[message_id] = thread_id
            LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} 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, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
          if thread && thread["id"]
            thread_id = thread["id"]
            DISCORD_SHARED_THREADS[message_id] = thread_id
            created_thread = true
            LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
          end
        end
      end

      if thread_id
        LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" 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)
        send_long_discord_message(thread_id, response, token: bot_token)
      else
        LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
        send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
      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)
  meta_basename = File.basename(meta_file)
  FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, basename)) if File.exist?(response_file)
  FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, meta_basename))
  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 #{meta_file}: #{e.message}"
  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/brainiac/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/brainiac/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_statusObject

Full deployment status for API / waybar.



238
239
240
241
242
243
244
245
246
247
# File 'lib/brainiac/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/brainiac/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
  tags.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/brainiac/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/brainiac/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/brainiac/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

  tags.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/brainiac/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) || tags.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/brainiac/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/brainiac/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.message}" if log_errors
  nil
end

#discord_bot_tokensObject

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/brainiac/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_statusObject

Summary of all bot statuses for the API endpoint.



1637
1638
1639
1640
1641
1642
1643
# File 'lib/brainiac/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_rosterObject

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/brainiac/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_agentsObject



128
129
130
131
132
133
134
135
# File 'lib/brainiac/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.message}"
  []
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/brainiac/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)
  card_tags = eventable.dig("card", "tags") || []
  planning_info = detect_planning_mode(
    text: plain_text,
    tags: card_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/brainiac/handlers/zoho.rb', line 252

def dispatch_zoho_triage(email, rule)
  agent_name = rule["dispatch_agent"]
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
  response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.json")
  log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.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}}", zoho_triage_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-#{timestamp}.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/brainiac/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/brainiac/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)
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")

  log_file = File.join(project["repo_path"], "tmp/agent-cron-#{job[:id]}-#{timestamp}.log")
  FileUtils.mkdir_p(File.dirname(log_file))

  prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], timestamp)
  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.message}\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/brainiac/cron.rb', line 294

def execute_script_job(job, project)
  script_path = File.expand_path(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

  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
  log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
  FileUtils.mkdir_p(File.dirname(log_file))

  draft_file = prepare_script_discord_draft(job, timestamp) 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.message}\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/brainiac/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"]}"
    send_discord_message(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?
    send_discord_message(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/brainiac/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.message}"
  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/brainiac/cron.rb', line 463

def extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_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(meta_file) if meta_file && File.exist?(meta_file)
    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/brainiac/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(/&nbsp;/i, " ").gsub(/&amp;/i, "&")
               .gsub(/&lt;/i, "<").gsub(/&gt;/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/brainiac/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/brainiac/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|
    author = 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### #{author} (comment ID: #{cid})\n#{body}"
  end
  parts
rescue StandardError => e
  LOG.warn "Could not pre-fetch comments for card ##{card_number}: #{e.message}"
  []
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/brainiac/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"]
  tags = (card_data["tags"] || []).map { |t| t.is_a?(Hash) ? t["name"] : t }
  parts << "Tags: #{tags.join(", ")}" unless tags.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.message}"
  nil
end

#fetch_channel_info(channel_id, token:) ⇒ Object



220
221
222
# File 'lib/brainiac/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/brainiac/handlers/discord.rb', line 185

def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
  messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)

  all_messages = messages.is_a?(Array) ? messages : []

  # 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 all_messages.any?
    oldest = all_messages.last # API returns newest-first
    if oldest && oldest["type"] == 21 && oldest["referenced_message"]
      # Prepend the actual starter message content
      all_messages << oldest["referenced_message"]
    end
  end

  return "" if all_messages.empty?

  # Messages come newest-first from the API, reverse for chronological order
  lines = all_messages.reverse.filter_map do |msg|
    author = msg.dig("author", "username") || "unknown"
    content = msg["content"]&.strip || ""
    next if content.empty?

    "#{author}: #{content}"
  end

  return "" if lines.empty?

  lines.join("\n")
rescue StandardError => e
  LOG.warn "Failed to fetch channel history: #{e.message}"
  ""
end

#fetch_discord_message(channel_id, message_id, token:, log_errors: true) ⇒ Object



282
283
284
# File 'lib/brainiac/handlers/discord.rb', line 282

def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
  discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
end

#fetch_guild_member(guild_id, user_id, token:) ⇒ Object



307
308
309
# File 'lib/brainiac/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/brainiac/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.message}"
  []
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/brainiac/zoho_mail_api.rb', line 60

def fetch_zoho_email_content(message_id)
  return nil unless zoho_api_configured?

  token = zoho_access_token
  return nil unless token

   = ZOHO_CONFIG.dig("api", "account_id")
  uri = URI("#{ZOHO_MAIL_API_BASE}/#{}/messages/#{message_id}/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 #{message_id} (#{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.message}"
  nil
end

#file_changed?(path, force: false) ⇒ Boolean

Returns:

  • (Boolean)


152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/brainiac/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/brainiac/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.message}"
    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/brainiac/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/brainiac/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/brainiac/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/brainiac/handlers/discord.rb', line 357

def find_root_message(message, channel_id, bot_token)
  current_msg = message
  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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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(/&nbsp;/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

Returns:

  • (Boolean)


224
225
226
227
# File 'lib/brainiac/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/brainiac/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_secretObject



82
83
84
85
# File 'lib/brainiac/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/brainiac/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_brainiac_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/brainiac/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 authorized?(payload)
    creator_name = payload.dig("creator", "name") || "Unknown"
    notify_unauthorized("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"
  tags = eventable["tags"] || []

  # Identify project by tags
  project_result = identify_project_by_tags(tags)
  unless project_result
    LOG.warn "No project found for card ##{card_number} with tags: #{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: tags)
  effort = detect_effort(project_config, tags: 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.message}"
  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.message}"
        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: 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/brainiac/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")
  tags = 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: 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: 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: 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: 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 = identify_project_by_tags(tags)
    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.message}"
      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/brainiac/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)
      notify_unauthorized("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.message}"
          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.message}"
        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
      card_tags = eventable.dig("card", "tags") || []
      project_result = identify_project_by_tags(card_tags)
      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
    card_tags = eventable.dig("card", "tags") || []
    project_result = identify_project_by_tags(card_tags)
    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: tags, text: effort_text_for_detection)

  # Determine which agent should handle this comment.
  #
  # Only local agents (marked with "local": true in ~/.brainiac/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.message}"
        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.message}"
    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.message}"
        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.message}"
    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.message}"
    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
      card_tags = eventable.dig("card", "tags") || []
      planning_info = detect_planning_mode(
        text: plain_text,
        tags: card_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.message}"
          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
    card_tags = eventable.dig("card", "tags") || []
    planning_info = detect_planning_mode(
      text: plain_text,
      tags: card_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/brainiac/cron.rb', line 427

def handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_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, meta_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/brainiac/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)
  mark_deploying(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.message}"
    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.message}"
    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/brainiac/handlers/discord.rb', line 422

def handle_discord_message(message, agent_key, bot_token, bot_user_id)
  channel_id = message["channel_id"]
  message_id = message["id"]
  author = message["author"]
  content = message["content"] || ""

  is_bot = !author["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 = author["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=#{author["username"]}, bot=#{author["bot"]}"
      return
    end
  end

  mentions = message["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
  referenced_message = nil
  if message["message_reference"]
    ref_msg_id = message.dig("message_reference", "message_id")
    ref_channel = message.dig("message_reference", "channel_id") || channel_id
    if ref_msg_id
      referenced_message = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
      is_reply_to_bot = !mentioned && referenced_message && referenced_message.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 = message["attachments"] || []
  attachment_paths = []
  agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
  attachments.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(BRAINIAC_DIR, "tmp", "discord", "attachments")
    FileUtils.mkdir_p(temp_dir)
    temp_path = File.join(temp_dir, "#{message_id}-#{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)
        attachment_paths << 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.message}"
    end
  end

  # Append attachment paths to the message content so kiro-cli can process them
  unless attachment_paths.empty?
    clean_content += "\n\n" unless clean_content.empty?
    clean_content += attachment_paths.join("\n")
  end

  return if clean_content.empty? && attachment_paths.empty?

  # Build reply context from the cached referenced message.
  reply_context = ""
  if referenced_message && referenced_message["content"]
    ref_author = referenced_message.dig("author", "username") || "unknown"
    ref_text = referenced_message["content"].strip
    reply_context = "**Replying to #{ref_author}:**\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 = author["username"]
  discord_user_id = author["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, message_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
  authorized_users = 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
  authorized_roles = 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)
  authorized_roles = authorized_roles.map(&:to_s)

  unless authorized_users.empty? && authorized_roles.empty?
    user_authorized = authorized_users.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 = message.dig("member", "roles") || []

    # If member roles aren't in the message and we have a guild_id, fetch them
    if member_roles.empty? && message["guild_id"]
      guild_member = fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
      member_roles = guild_member["roles"] || [] if guild_member
    end

    role_authorized = member_roles.intersect?(authorized_roles)

    unless user_authorized || role_authorized
      LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
      add_discord_reaction(channel_id, message_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, message_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}-#{message_id}"
  supersede_key = "discord-#{agent_key}-#{channel_id}"

  if session_active?(session_key)
    add_discord_reaction(channel_id, message_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, message_id, "🛑", token: bot_token)
    add_discord_reaction(channel_id, message_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(BRAINIAC_DIR, "tmp")
  FileUtils.mkdir_p(response_dir)
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
  response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
  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.
  root_message = find_root_message(message, channel_id, bot_token)
  root_message_id = root_message[:id]
  card_id = "discord-#{channel_id}-#{root_message_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 && root_message[:content] && !root_message[:content].empty?
    root_author = root_message[:author] || "unknown"
    thread_root_context = "### Original Message (thread starter)\n#{root_author}: #{root_message[: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-#{timestamp}-#{agent_key}-#{message_id}.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).
  meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
  File.write(meta_file, JSON.pretty_generate({
                                               channel_id: channel_id,
                                               message_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-#{timestamp}-#{agent_key}-#{message_id}.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 == "brainiac"
      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: message_id, channel_id: channel_id,
                                     supersede_key: supersede_key,
                                     draft_files: [response_file, meta_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 #{message_id}"
      # Clean up draft/meta files so the poller doesn't deliver a stale response
      [response_file, meta_file].each { |f| FileUtils.rm_f(f) }
      Thread.new do
        sleep 300
        [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
      end
      next
    end

    LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (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: 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 #{message_id}"
        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(meta_file)
      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, message_id, "👀", token: bot_token)
    sleep 0.5 # Breathing room to avoid Discord rate limits

    delivered = deliver_discord_draft(response_file, meta_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 #{message_id}"
        add_discord_reaction(channel_id, message_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-#{message_id}")

    # Restart brainiac 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 == "brainiac"
        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_brainiac_restart(agent_name)
        else
          LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
        end
      end
    end

    Thread.new do
      sleep 300
      [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
    end
  rescue StandardError => e
    LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}"
    add_discord_reaction(channel_id, message_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/brainiac/handlers/discord.rb', line 1074

def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
  channel_id = reaction_data["channel_id"]
  message_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}-#{message_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 #{message_id} 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}"
        send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
        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```"
      send_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
    end
    return
  end

  # Handle 🧠 reaction (stream full thinking to thread)
  if emoji_name == "🧠"
    session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"

    ACTIVE_SESSIONS_MUTEX.synchronize do
      session_info = ACTIVE_SESSIONS[session_key]

      unless session_info
        LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{message_id} 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}"
        send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
        return
      end

      LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}"

      # Create thread
      thread_response = create_discord_thread(channel_id, message_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|
        send_discord_message(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, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
    rescue StandardError => e
      LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.message}"
    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}-#{message_id}"

  ACTIVE_SESSIONS_MUTEX.synchronize do
    session_info = ACTIVE_SESSIONS[session_key]

    unless session_info
      LOG.info "[Discord:#{agent_name}] ❌ reaction on #{message_id} but no active session found"
      return
    end

    LOG.info "[Discord:#{agent_name}] Cancelling session for message #{message_id} (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, message_id, "👀", token: bot_token)
      add_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
    rescue StandardError => e
      LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.message}"
    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/brainiac/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

  append_fizzy_comment_footer(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/brainiac/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.message}"
  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.message}"
  [500, { error: e.message }.to_json]
end

#handle_github_issue_opened(payload) ⇒ Object



388
389
390
391
392
393
394
395
396
397
398
# File 'lib/brainiac/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/brainiac/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.message}"
  [500, { error: e.message }.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/brainiac/handlers/github.rb', line 400

def (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.message}"
  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.message}"
  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.message}"
  [500, { error: e.message }.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/brainiac/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)"
  mark_deploying(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.message}"
  [500, { error: e.message }.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/brainiac/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.message}"
  [500, { error: e.message }.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/brainiac/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

Returns:

  • (Boolean)


692
693
694
695
696
697
# File 'lib/brainiac/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_usersObject

Get all human users (exclude AI agents)



69
70
71
# File 'lib/brainiac/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/brainiac/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/brainiac/helpers.rb', line 136

def identify_project_by_tags(tags)
  return nil if PROJECTS.empty?

  tag_names = tags.map { |t| (t.is_a?(Hash) ? t["name"] : t).to_s.downcase }

  PROJECTS.each do |project_key, config|
    project_tags = (config["fizzy_tags"] || []).map(&:downcase)
    return [project_key, config] if tag_names.intersect?(project_tags)
  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/brainiac/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_registryObject

Agent registry, discovery, identity, mention detection, and env injection.

The registry at ~/.brainiac/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/brainiac/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.message}"
  {}
end

#load_card_mapObject



196
197
198
199
200
201
202
# File 'lib/brainiac/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_jobsObject

Load cron jobs from config



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/brainiac/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.message}"
  {}
end

#load_deployment_stateObject



20
21
22
23
24
25
26
27
# File 'lib/brainiac/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.message}"
  {}
end

#load_deployments_configObject



11
12
13
14
15
16
17
18
# File 'lib/brainiac/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.message}"
  {}
end

#load_discord_configObject



119
120
121
122
123
124
125
126
127
# File 'lib/brainiac/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.message}"
  default
end

#load_fizzy_configObject



56
57
58
59
60
61
62
63
# File 'lib/brainiac/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.message}"
  {}
end

#load_github_configObject



71
72
73
74
75
76
77
78
# File 'lib/brainiac/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.message}"
  {}
end

#load_projects_configObject

— Projects —



138
139
140
141
142
143
144
145
146
147
# File 'lib/brainiac/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.message}"
  {}
end

#load_user_registryObject



8
9
10
11
12
13
14
15
16
17
# File 'lib/brainiac/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.message}"
  { "users" => [] }
end

#load_zoho_configObject



17
18
19
20
21
22
23
24
# File 'lib/brainiac/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.message}"
  {}
end

#local_agent_namesObject

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/brainiac/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/brainiac/handlers/discord.rb', line 1251

def log_emoji_feedback(channel_id, message_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 = fetch_discord_message(channel_id, message_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")

  timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
  entry = "- #{timestamp} #{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 #{message_id}"
end

#mark_card_merged(card_number) ⇒ Object



256
257
258
# File 'lib/brainiac/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/brainiac/deployments.rb', line 49

def mark_deploying(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

Returns:

  • (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/brainiac/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/brainiac/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.message}"
  []
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/brainiac/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/brainiac/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/brainiac/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.message}"
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/brainiac/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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
      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.message}"
    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.message}"
    end

  when :discord
    channel_id = source_context[:channel_id]
    message_id = source_context[:message_id]
    bot_token = source_context[:bot_token]
    return unless channel_id && bot_token

    message = "💥 **#{agent_display} crashed** (exit code #{exit_status})\nLog: `#{log_file}`#{snippet_block}"
    send_discord_message(channel_id, message, token: bot_token, reply_to: message_id)
    LOG.info "[CrashNotify] Posted crash message to Discord channel #{channel_id}"
  end
rescue StandardError => e
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
end

#notify_unauthorized(action, creator_name, card_info) ⇒ Object



756
757
758
759
760
# File 'lib/brainiac/helpers.rb', line 756

def notify_unauthorized(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/brainiac/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

  message = 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})"
  send_discord_message(channel_id, message, token: token)
end

#on_comment_cooldown?(card_key) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
237
# File 'lib/brainiac/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

Returns:

  • (Boolean)


248
249
250
251
# File 'lib/brainiac/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_idObject

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/brainiac/config.rb', line 247

def owner_discord_id
  discord_file = File.join(BRAINIAC_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/brainiac/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
    timestamp = parse_natural_time(expr)
    return { one_time: true, timestamp: timestamp } if timestamp

    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/brainiac/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/brainiac/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.message}"
  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/brainiac/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/brainiac/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.message}"
  nil
end

#persona_collection_for(agent_name) ⇒ Object



16
17
18
# File 'lib/brainiac/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/brainiac/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:

  1. A plan file exists at PLANS_DIR/card-id-plan.md

  2. Memory file indicates planning_complete: true

Returns:

  • (Boolean)


31
32
33
34
35
36
37
# File 'lib/brainiac/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

Check if a PR link is already present in the card’s comments.

Returns:

  • (Boolean)


60
61
62
63
64
65
66
67
68
# File 'lib/brainiac/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.message}"
  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/brainiac/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.message}"
  ""
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/brainiac/cron.rb', line 331

def prepare_script_discord_draft(job, timestamp)
  draft_file = File.join(DISCORD_DRAFT_DIR, "cron-script-#{timestamp}-#{job[:id]}.md")
  meta_file = "#{draft_file}.meta.json"

  FileUtils.mkdir_p(File.dirname(draft_file))

  script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
  meta = {
    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(meta_file, JSON.pretty_generate(meta))
  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/brainiac/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.message}"
  ""
end

#queue_brainiac_restart(agent_name) ⇒ Object



42
43
44
45
46
47
48
49
50
# File 'lib/brainiac/handlers/discord.rb', line 42

def queue_brainiac_restart(agent_name)
  BRAINIAC_RESTART_MUTEX.synchronize do
    unless BRAINIAC_RESTART_STATE[:queued]
      BRAINIAC_RESTART_STATE[:queued] = true
      BRAINIAC_RESTART_STATE[:triggered_by] = agent_name
      LOG.info "[Brainiac] #{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/brainiac/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/brainiac/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/brainiac/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

Returns:

  • (Boolean)


55
56
57
58
59
60
61
# File 'lib/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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_viewsObject

Batch-record views for all skills in the index (called when prompt is built).



210
211
212
# File 'lib/brainiac/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/brainiac/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.message}"
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/brainiac/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: 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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/handlers/discord.rb', line 294

def remove_discord_reaction(channel_id, message_id, emoji, token:)
  encoded = URI.encode_www_form_component(emoji)
  discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/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/brainiac/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/brainiac/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/brainiac/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.message}"
  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/brainiac/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/brainiac/deployments.rb', line 252

def resolve_deployment_url(env_config, card_tags)
  urls = env_config["urls"] || {}
  if card_tags && urls.any?
    card_tags.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/brainiac/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/brainiac/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/brainiac/handlers/zoho.rb', line 444

def resolve_zoho_triage_tags(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?

  all_tags = JSON.parse(output)["data"] || []
  tag_names.filter_map do |name|
    tag = all_tags.find { |t| t["title"].downcase == name.downcase }
    tag&.dig("id")
  end
rescue StandardError => e
  LOG.warn "[Zoho:Triage] Failed to resolve tags: #{e.message}"
  []
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/brainiac/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/brainiac/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/brainiac/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 { scrub_invalid_attachments!(chdir) }

  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
  log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
  FileUtils.mkdir_p(File.dirname(log_file))

  prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
  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 == "brainiac"
    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/brainiac/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/brainiac/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 .brainiac/<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/brainiac/helpers.rb', line 116

def run_project_hook(repo_path, hook_name, extra_env: {})
  hook = File.join(repo_path, ".brainiac", hook_name)
  return unless File.exist?(hook)

  env = { "REPO_PATH" => repo_path }.merge(extra_env)
  LOG.info "Running .brainiac/#{hook_name} hook for #{repo_path}"
  output, status = Open3.capture2e(env, "bash", hook, chdir: repo_path)
  if status.success?
    LOG.info ".brainiac/#{hook_name} completed successfully"
  else
    LOG.warn ".brainiac/#{hook_name} failed (exit #{status.exitstatus}): #{output.strip}"
  end
end

#save_card_map(map) ⇒ Object



204
205
206
# File 'lib/brainiac/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/brainiac/cron.rb', line 185

def save_cron_jobs(jobs)
  FileUtils.mkdir_p(BRAINIAC_DIR)
  File.write(CRON_CONFIG_FILE, JSON.pretty_generate(jobs))
end

#save_deployment_state(state) ⇒ Object



29
30
31
# File 'lib/brainiac/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/brainiac/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/brainiac/helpers.rb', line 359

def scrub_invalid_attachments!(dir)
  attachments_dir = File.join(dir, ".fizzy-attachments")
  return unless File.directory?(attachments_dir)

  Dir.glob(File.join(attachments_dir, "*")).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.message}"
end

#self_move_recent?(card_number, window: 120) ⇒ Boolean

Returns:

  • (Boolean)


38
39
40
41
42
43
# File 'lib/brainiac/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/brainiac/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")
  message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"

  send_discord_message(channel_id, message, token: token)
rescue StandardError => e
  LOG.warn "Failed to send deploy notification: #{e.message}"
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/brainiac/handlers/discord.rb', line 266

def send_discord_message(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/brainiac/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/brainiac/handlers/discord.rb', line 311

def send_long_discord_message(channel_id, content, token:, reply_to: nil)
  if content.length <= 2000
    send_discord_message(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|
    send_discord_message(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 brainiac restart/startup using any available bot token.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/brainiac/handlers/discord.rb', line 53

def send_restart_notification(message)
  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 = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
  token = tokens[triggered_by&.downcase] || tokens.values.first
  return unless token

  send_discord_message(channel_id, message, token: token)
rescue StandardError => e
  LOG.warn "[Brainiac] Failed to send restart notification: #{e.message}"
end

#send_uat_deploy_notification(project_key) ⇒ Object



362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/brainiac/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

  message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
  send_discord_message(channel_id, message, token: token)
rescue StandardError => e
  LOG.warn "Failed to send UAT deploy notification: #{e.message}"
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/brainiac/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

  message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
  send_discord_message(channel_id, message, token: token)
rescue StandardError => e
  LOG.warn "Failed to send workflow failure notification: #{e.message}"
end

#session_active?(card_key) ⇒ Boolean

Returns:

  • (Boolean)


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/brainiac/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_promptObject

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/brainiac/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/brainiac/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/brainiac/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_gatewaysObject

Start all per-agent Discord bots.



1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
# File 'lib/brainiac/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_brainiac_restart_monitorObject



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/brainiac/handlers/discord.rb', line 79

def start_brainiac_restart_monitor
  Thread.new do
    LOG.info "[Brainiac] Restart monitor started, checking every 30s"
    loop do
      sleep 30
      restart_needed = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] }

      if restart_needed && !any_agents_running?
        triggered_by = BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:triggered_by] }
        LOG.info "[Brainiac] All agents finished, executing restart..."
        BRAINIAC_RESTART_MUTEX.synchronize { BRAINIAC_RESTART_STATE[:queued] = false }

        send_restart_notification("🔄 Restarting brainiac (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 brainiac 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 && brainiac server --daemon",
                      out: "/dev/null", err: "/dev/null")
          Process.detach(pid)

          sleep 1
          LOG.info "[Brainiac] 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 "[Brainiac] Restart queued but #{active_count} agent(s) still running, waiting..."
      end
    end
  end
end

#start_cron_threadObject

Start cron background thread



620
621
622
623
624
625
626
627
628
629
# File 'lib/brainiac/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_pollerObject

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/brainiac/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 |meta_file|
          # Don't race the monitoring thread — wait for the file to age
          next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE

          # Cron metas: foo.md.meta.json → foo.md
          # Discord metas: foo.meta.json → foo.md
          response_file = if meta_file.end_with?(".md.meta.json")
                            meta_file.sub(".md.meta.json", ".md")
                          else
                            meta_file.sub(".meta.json", ".md")
                          end
          next unless File.exist?(response_file)

          LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
          deliver_discord_draft(response_file, meta_file)
        end
      rescue StandardError => e
        LOG.error "[Discord] Draft poller error: #{e.message}"
      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/brainiac/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: "brainiac", device: "brainiac" }
            }
          }.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
              handle_discord_message(data, agent_key, bot_token, bot_user_id)
            rescue StandardError => e
              LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\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
                handle_discord_message(data, agent_key, bot_token, bot_user_id)
              rescue StandardError => e
                LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\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.message}\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: "brainiac", device: "brainiac" } } }.to_json)
        when 11 then nil # Heartbeat ACK
        end
      rescue StandardError => e
        LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
      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.message}"
      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.message}, reconnecting in 5s..."
      sleep 5
    end
  end
end

#terraform_lock_error?(stdout, stderr) ⇒ Boolean

Detect Terraform provider lock file checksum mismatch errors.

Returns:

  • (Boolean)


198
199
200
201
# File 'lib/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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.message}"
end

#uat_column_id(project_config) ⇒ Object



8
9
10
11
# File 'lib/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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/brainiac/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

Returns:

  • (Boolean)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/brainiac/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/brainiac/helpers.rb', line 572

def write_agent_prompt_file(prompt, log_name, timestamp)
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
  FileUtils.mkdir_p(prompt_dir)
  prompt_file = File.join(prompt_dir, "prompt-#{log_name}-#{timestamp}.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/brainiac/cron.rb', line 567

def write_cron_prompt_file(job, prompt_content, timestamp)
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
  FileUtils.mkdir_p(prompt_dir)
  prompt_file = File.join(prompt_dir, "prompt-cron-#{job[:id]}-#{timestamp}.md")
  File.write(prompt_file, prompt_content)
  prompt_file
end

#zoho_access_tokenObject



51
52
53
54
55
# File 'lib/brainiac/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

Returns:

  • (Boolean)


21
22
23
24
# File 'lib/brainiac/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).

Returns:

  • (Boolean)


80
81
82
83
84
85
# File 'lib/brainiac/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_ruleObject

Returns the fallback rule config, or nil if not configured.



119
120
121
122
123
124
125
126
127
# File 'lib/brainiac/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_secretObject

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/brainiac/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/brainiac/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.message}"
  nil
end

#zoho_triage_agent_assignmentObject



47
48
49
50
51
52
# File 'lib/brainiac/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_tagsObject



40
41
42
43
44
45
# File 'lib/brainiac/handlers/zoho.rb', line 40

def zoho_triage_project_tags
  tags = ZOHO_CONFIG["triage_project_tags"]
  return "Use your best judgement to identify the relevant project." unless tags&.any?

  tags.map { |t| "  - `#{t["tag"]}` — #{t["description"]}" }.join("\n")
end