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"
ROLES_DIR =

— Roles —

File.join(BRAINIAC_DIR, "roles")
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. This is now configured in the kiro provider’s default_args instead of being hardcoded here.

"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. 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. 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. Update your memory file at `{{MEMORY_DIR}}/card-{{CARD_ID}}.md`.
     Write what future-you needs to pick up where you left off. Use your judgement on what's important — status, decisions, open questions, file paths, PR URLs, timeline of sessions.

  ## 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 — only save significant architecture decisions,
  non-obvious gotchas, major workflow changes, or things the user explicitly asks you to remember.
  Routine card work and things already documented in the codebase don't need brain entries.

  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
  Post only **once per session** — combine all updates into a single message at the end of your work.
  Do not post incremental status updates. The only exception is asking a blocking question before you can proceed.

  Before posting:
  1. Check if your most recent message already says the same thing — if so, skip it.
  2. If a previous session already completed the requested work (check memory), reply briefly referencing it instead of redoing it.

  ## Clarifying Questions (MANDATORY when uncertain)

  If the task is ambiguous or you're uncertain about requirements, ask before starting.
  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-Session Reflection (after posting your response and updating memory)

  ### Step 1: Check persona relevance
  `qmd search "{{COMMENT_CREATOR}}" -c {{PERSONA_COLLECTION}}`
  If no results, this might be someone new worth noting.

  ### Step 2: Decide what to update
  Consider the interaction and ask:

  **Persona** — Did the user give feedback (explicit or implicit) on your tone or style?
  Is this someone new? Did they seem frustrated or pleased? Update persona files if so.
  Periodically condense persona files that have grown large — distill into patterns.

  **Knowledge** — High bar. Only save if:
  - User explicitly asked you to remember something
  - A significant architecture decision or convention was established
  - You discovered a non-obvious gotcha
  - A major workflow changed

  **Skills** — Did this session involve a multi-step procedure (5+ tool calls) that you or
  another agent might repeat? If so, save it at `{{KNOWLEDGE_DIR}}/skills/<name>/SKILL.md`
  with YAML frontmatter (name, description, tags).

  ### Step 3: Update the brain or move on
  Write/update relevant files if needed. If nothing warrants saving, move on.

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 a comment on the card with a concise summary, PR link, and branch name.

  **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 confirming it's done — do NOT redo it.
  Otherwise, make the requested changes, commit, push, and update the PR.
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 confirming it's done — 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
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
  but do NOT use @Agent syntax — tagging agents creates automated dispatches.
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. Reply on the PR summarizing what you changed

  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 a comment on the PR summarizing the changes

  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
DISCORD_THREAD_MAP_FILE =

Discord thread worktree map: tracks worktrees created for Discord thread conversations. Keyed by “agent_key:channel_id” → { worktree, branch, project, created_at } Persisted to disk so sessions survive restarts.

File.join(BRAINIAC_DIR, "discord_thread_map.json")
DISCORD_THREAD_MAP_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



307
308
309
310
# File 'lib/brainiac/handlers/discord.rb', line 307

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

#agent_cli_provider_for(agent_name) ⇒ Object

Get the cli_provider configured at the agent level in agents.json.



102
103
104
105
106
107
108
109
110
# File 'lib/brainiac/helpers.rb', line 102

def agent_cli_provider_for(agent_name)
  return nil unless agent_name

  key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
  entry = AGENT_REGISTRY[key]
  return nil unless entry.is_a?(Hash)

  entry["cli_provider"]
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



171
172
173
# File 'lib/brainiac/agents.rb', line 171

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

#agent_roles_for(agent_name) ⇒ Object

Get the role name(s) configured for an agent in agents.json. Returns an array of role names (may be empty).



124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/brainiac/agents.rb', line 124

def agent_roles_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)

  roles = entry["role"]
  case roles
  when Array then roles
  when String then [roles]
  else []
  end
end

#agent_rosterObject



156
157
158
159
160
# File 'lib/brainiac/agents.rb', line 156

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



175
176
177
178
179
180
181
182
183
184
# File 'lib/brainiac/agents.rb', line 175

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)


86
87
88
89
90
91
92
93
94
95
# File 'lib/brainiac/handlers/discord.rb', line 86

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.



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
# File 'lib/brainiac/helpers.rb', line 501

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.



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
# File 'lib/brainiac/helpers.rb', line 132

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)


771
772
773
774
# File 'lib/brainiac/helpers.rb', line 771

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



104
105
106
107
# File 'lib/brainiac/config.rb', line 104

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



95
96
97
# File 'lib/brainiac/config.rb', line 95

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)



110
111
112
113
114
115
# File 'lib/brainiac/config.rb', line 110

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



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/brainiac/config.rb', line 118

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



99
100
101
102
# File 'lib/brainiac/config.rb', line 99

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, prompt_file: nil, resume: false) ⇒ Object

Build the CLI command array for an agent invocation. When prompt_file is provided and prompt_mode is “flag”, appends the prompt as a CLI argument. When resume is true and the provider has a resume_flag, adds it to continue the last session.



643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
# File 'lib/brainiac/helpers.rb', line 643

def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false)
  cmd = [resolved["agent_cli"]]
  # agent_flag controls how the agent identity is passed. Defaults to "--agent".
  # Provider configs can set it to a different flag or null to suppress entirely.
  agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
  cmd.push(agent_flag, agent_config_name) if agent_flag && agent_config_name
  cmd.concat(resolved["agent_cli_args"].split)
  # Only pass --model if the model is a valid ID for this provider.
  # "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
  if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
    allowed = resolved["allowed_models"] || {}
    # Pass the model if it's a mapped value (e.g. "claude-opus-4.6") or the key itself is mapped
    is_known = allowed.value?(model) || allowed.key?(model)
    cmd.push(resolved["agent_model_flag"], model) if is_known
  end
  cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort
  # Resume the most recent session in the working directory (for multi-turn CLIs like grok)
  cmd.push(resolved["resume_flag"]) if resume && resolved["resume_flag"]
  # prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
  cmd
end

#build_brain_context(agent_name: AI_AGENT_NAME, card_title: "", card_number: nil, project_key: nil, comment_body: "", source: nil) ⇒ Object



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
# File 'lib/brainiac/brain.rb', line 158

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 = []

  # Role: CLI-agnostic agent role definition from ~/.brainiac/roles/
  role_section = build_role_section(agent_name)
  sections << role_section if role_section

  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, prompt_file: nil) ⇒ Object

Build the CLI command array for a cron agent invocation.



578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/brainiac/cron.rb', line 578

def build_cron_agent_cmd(job, project, prompt_file: nil)
  agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-")
  resolved = resolve_project_cli_config(project, agent_name: job[:agent])
  agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
  cmd = [resolved["agent_cli"]]
  cmd.push(agent_flag, agent_config_name) if agent_flag
  cmd.concat(resolved["agent_cli_args"].split)
  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.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
  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_role_section(agent_name) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/brainiac/brain.rb', line 137

def build_role_section(agent_name)
  roles = agent_roles_for(agent_name)
  return nil if roles.empty?

  sections = roles.filter_map do |role_name|
    content = load_role(role_name)
    next unless content

    "### #{role_name}\n\n#{content}"
  end
  return nil if sections.empty?

  <<~ROLE
    ## Role#{"s" if roles.size > 1} (#{roles.join(", ")})
    The following defines your role and responsibilities for this session.
    Follow these instructions for how you approach work.

    #{sections.join("\n\n")}
  ROLE
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

#capture_git_state(chdir) ⇒ Object

Capture git HEAD and working tree status for a directory. Returns [head_sha, status_porcelain] or [nil, nil] on failure.



752
753
754
755
756
757
758
# File 'lib/brainiac/helpers.rb', line 752

def capture_git_state(chdir)
  head, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
  status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
  [head.strip, status.strip]
rescue StandardError
  [nil, nil]
end

#card_merged?(card_number) ⇒ Boolean

Returns:

  • (Boolean)


317
318
319
320
321
322
# File 'lib/brainiac/helpers.rb', line 317

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, status_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object



760
761
762
763
764
765
766
767
768
769
# File 'lib/brainiac/helpers.rb', line 760

def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
  return unless project_key_for_restart == "brainiac" && head_before

  head_after, status_after = capture_git_state(chdir)
  if head_after != head_before || status_after != (status_before || "")
    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 }



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/brainiac/config.rb', line 228

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.



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
# File 'lib/brainiac/helpers.rb', line 16

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.



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
440
# File 'lib/brainiac/handlers/fizzy.rb', line 385

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)


232
233
234
235
236
237
# File 'lib/brainiac/agents.rb', line 232

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



317
318
319
320
321
322
323
# File 'lib/brainiac/handlers/discord.rb', line 317

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



269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/brainiac/handlers/discord.rb', line 269

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



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/cron.rb', line 592

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



187
188
189
190
191
# File 'lib/brainiac/helpers.rb', line 187

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).



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
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
# File 'lib/brainiac/handlers/discord.rb', line 1486

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_cli_provider(text: "", tags: []) ⇒ Object

Detect CLI provider override from inline [cli:X] tag or Fizzy card tags. Returns the provider name (e.g. “grok”) or nil.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/brainiac/helpers.rb', line 114

def detect_cli_provider(text: "", tags: [])
  # Inline tag: [cli:grok]
  if (match = text.match(/\[cli:(\w+)\]/i))
    return match[1].downcase
  end

  # Fizzy card tags: cli-grok
  tags.each do |tag|
    name = (tag.is_a?(Hash) ? tag["name"] : tag).to_s.downcase
    return name.sub("cli-", "") if name.start_with?("cli-")
  end

  nil
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.



805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
# File 'lib/brainiac/helpers.rb', line 805

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



206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/brainiac/agents.rb', line 206

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



221
222
223
224
225
226
227
228
229
230
# File 'lib/brainiac/agents.rb', line 221

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



783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
# File 'lib/brainiac/helpers.rb', line 783

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 —



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

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…” }



155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/brainiac/handlers/discord.rb', line 155

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.



1840
1841
1842
1843
1844
1845
1846
# File 'lib/brainiac/handlers/discord.rb', line 1840

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)


414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/brainiac/handlers/discord.rb', line 414

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



162
163
164
165
166
167
168
169
# File 'lib/brainiac/agents.rb', line 162

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, cli_provider: 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.



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
1250
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
1284
1285
1286
1287
1288
1289
1290
1291
1292
# File 'lib/brainiac/handlers/fizzy.rb', line 1217

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, cli_provider: nil)
  card_tags = eventable.dig("card", "tags") || []
  effort = detect_effort(project_config, tags: card_tags, text: plain_text)

  # Determine if we should resume (worktree + provider supports it)
  is_worktree = work_dir != project_config["repo_path"]
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
  should_resume = is_worktree && resolved["resume_flag"]

  if should_resume
    # Lean prompt: only the new comment. The previous session
    # already has role, persona, knowledge, core instructions, and card history.
    prompt = render_resume_prompt(
      comment_body: plain_text,
      comment_creator: comment_vars["COMMENT_CREATOR"],
      comment_id: comment_id,
      card_number: card_number,
      agent_name: agent_name
    )
    LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
  else
    # Full prompt: no session to resume, build everything from scratch
    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
  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 }, cli_provider: cli_provider, resume: is_worktree)
  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).



620
621
622
623
624
625
626
627
628
629
# File 'lib/brainiac/helpers.rb', line 620

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
565
566
# 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)
  resolved = resolve_project_cli_config(project, agent_name: agent_name)
  cmd = build_cron_agent_cmd(job, project, prompt_file: prompt_file)
  prompt_mode = resolved["prompt_mode"] || "stdin"

  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"],
              **(prompt_mode == "stdin" ? { 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.



434
435
436
437
438
439
440
441
442
# File 'lib/brainiac/helpers.rb', line 434

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.



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/brainiac/helpers.rb', line 392

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.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/brainiac/helpers.rb', line 369

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



238
239
240
# File 'lib/brainiac/handlers/discord.rb', line 238

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



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

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



300
301
302
# File 'lib/brainiac/handlers/discord.rb', line 300

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



325
326
327
# File 'lib/brainiac/handlers/discord.rb', line 325

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)


156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/brainiac/config.rb', line 156

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



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/brainiac/handlers/discord.rb', line 247

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



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/brainiac/handlers/discord.rb', line 354

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.



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

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)


242
243
244
245
# File 'lib/brainiac/handlers/discord.rb', line 242

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



86
87
88
89
# File 'lib/brainiac/config.rb', line 86

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



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
# File 'lib/brainiac/helpers.rb', line 666

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[:status_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
285
# 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)
  cli_provider_override = detect_cli_provider(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 }, cli_provider: cli_provider_override)
  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



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
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
# File 'lib/brainiac/handlers/fizzy.rb', line 442

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

  card_tags = eventable.dig("card", "tags") || []
  model = detect_model(project_config, text: plain_text)
  effort = detect_effort(project_config, tags: card_tags, text: effort_text_for_detection)
  cli_provider_override = detect_cli_provider(text: plain_text, tags: card_tags)

  # Strip [cli:X] tag from prompt content
  plain_text = plain_text.sub(/\[cli:\w+\]/i, "").strip

  # 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 }, cli_provider: cli_provider_override)
    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, cli_provider: cli_provider_override
          )
        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, cli_provider: cli_provider_override
    )
    [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") || []

      # Check if we can resume the existing session (lean prompt) vs full prompt
      resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name)
      should_resume = resolved["resume_flag"]

      if should_resume
        prompt = render_resume_prompt(
          comment_body: plain_text,
          comment_creator: comment_vars["COMMENT_CREATOR"],
          comment_id: comment_id,
          card_number: card_number,
          agent_name: agent_name
        )
        LOG.info "[Resume] Using lean prompt for mention on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
      else
        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
      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 }, cli_provider: cli_provider_override, resume: true)
      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 }, cli_provider: cli_provider_override)
    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.



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
381
# File 'lib/brainiac/handlers/fizzy.rb', line 289

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



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
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
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
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
# File 'lib/brainiac/handlers/discord.rb', line 440

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

  # Strip CLI provider tag (e.g. [cli:grok]) from prompt content
  clean_content_for_prompt = clean_content_for_prompt.sub(/\[cli:\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]

  # Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
  # so all messages in the thread share one memory file. The thread's channel_id IS the thread
  # starter message ID, which is stable across all messages within it.
  card_id = if is_thread
              "discord-#{parent_channel_id}-#{channel_id}"
            else
              "discord-#{channel_id}-#{root_message_id}"
            end

  # 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_content = root_message[:content]
    root_author = root_message[:author]

    # Fallback: if find_root_message didn't capture content (no message_reference chain),
    # fetch the parent message directly. In Discord, a thread's channel_id equals the
    # message_id that the thread was created on in the parent channel.
    if root_content.nil? || root_content.empty?
      parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
      if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
        root_content = parent_msg["content"].strip
        root_author = parent_msg.dig("author", "username") || "unknown"
      end
    end

    if root_content && !root_content.empty?
      thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
    end
  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)

  # --- Worktree management & session resume ---
  # Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
  # For channel messages, create a Discord thread upfront so we have a stable ID
  # for the worktree, then dispatch in it. This ensures grok sessions persist
  # across the thread conversation and can be resumed with -c.
  should_resume = false
  thread_worktree_path = nil
  thread_cli_provider = nil
  thread_model = nil
  thread_effort = nil

  # For channel messages (not DMs, not already in a thread), create a thread immediately
  # so we can use its ID for the worktree. This also means the response delivery
  # will find the pre-existing thread via the API lookup.
  pre_created_thread_id = nil
  if !is_thread && !is_dm && project_config
    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"]
      pre_created_thread_id = thread["id"]
      DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }

      # Propagate dispatch depth to the new thread
      parent_depth_key = "discord-#{channel_id}"
      thread_depth_key = "discord-#{pre_created_thread_id}"
      parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
      if parent_info
        AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
      else
        record_human_comment(thread_depth_key)
      end

      LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
    else
      LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
    end
  end

  # Determine the thread map key: use the thread ID (existing thread or pre-created one)
  effective_thread_id = is_thread ? channel_id : pre_created_thread_id
  thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id

  if project_config && thread_map_key
    repo_path = project_config["repo_path"]
    thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
    existing = thread_map[thread_map_key]

    if existing && existing["worktree"] && File.directory?(existing["worktree"])
      # Existing worktree — resume session. Inherit tags from the thread's first dispatch.
      thread_worktree_path = existing["worktree"]
      thread_cli_provider = existing["cli_provider"]
      thread_model = existing["model"]
      thread_effort = existing["effort"]
      effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
      resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
      should_resume = resolved_for_resume["resume_flag"] ? true : false
      LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
    else
      # First worktree creation. Inherit tags from a seeded thread map entry if present.
      seeded_cli_provider = existing&.dig("cli_provider")
      seeded_model = existing&.dig("model")
      seeded_effort = existing&.dig("effort")

      thread_slug = effective_thread_id[-8..]
      branch = "discord-#{agent_key}-#{thread_slug}"
      thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")

      debounced_repo_fetch(repo_path)
      default_branch = get_default_branch(repo_path)

      branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)

      if branch_exists
        worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
        has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
        if has_worktree && File.directory?(thread_worktree_path)
          LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
        else
          run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
        end
      else
        run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
      end

      trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
      apply_worktree_includes(repo_path, thread_worktree_path)
      run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })

      # Store tags: prefer current message tag, then seeded value from initial dispatch
      first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
      first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
      first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
      thread_cli_provider = first_cli_provider
      thread_model = first_model
      thread_effort = first_effort

      DISCORD_THREAD_MAP_MUTEX.synchronize do
        map = load_discord_thread_map
        map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
                                "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
                                "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
                                "model" => first_model, "effort" => first_effort,
                                "created_at" => Time.now.iso8601 }
        save_discord_thread_map(map)
      end
      LOG.info "[Discord:#{agent_name}] Created thread worktree at #{thread_worktree_path}#{" (cli: #{first_cli_provider})" if first_cli_provider}#{" (model: #{first_model})" if first_model}#{" (effort: #{first_effort})" if first_effort}"
    end
  end

  if should_resume && thread_worktree_path
    # Lean resume prompt — prior session has full context
    prompt = render_discord_resume_prompt(
      message_body: clean_content_for_prompt,
      discord_user: discord_user,
      response_file: response_file,
      agent_name: agent_name,
      card_id: card_id
    )
    LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
  elsif 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 = thread_worktree_path || (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)

  # Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
  # Current message tags override thread defaults; thread defaults apply when no tag is present.
  cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider

  # For model/effort: detect_model/detect_effort return the project default when no tag matches,
  # so we check for explicit tag presence to know whether the thread default should apply.
  has_explicit_model = false
  if project_config
    allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
    model_tag_match = clean_content.match(/\[(\w+)\]/i)
    has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
  end
  has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)

  model = if has_explicit_model
            detect_model(project_config, text: clean_content)
          elsif thread_model
            thread_model
          else
            project_config ? detect_model(project_config, text: clean_content) : nil
          end

  effort = if has_explicit_effort
             detect_effort(project_config, text: clean_content)
           elsif thread_effort
             thread_effort
           else
             project_config ? detect_effort(project_config, text: clean_content) : nil
           end

  # Determine the "explicit" values from this message only (not thread defaults)
  # for seeding into the thread map on initial dispatch.
  explicit_model = has_explicit_model ? model : nil
  explicit_effort = has_explicit_effort ? effort : nil

  # 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],
                                               cli_provider: detect_cli_provider(text: clean_content),
                                               model: explicit_model,
                                               effort: explicit_effort,
                                               created_at: Time.now.iso8601
                                             }))

  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, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
  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"
  agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
  prompt_mode = resolved["prompt_mode"] || "stdin"

  cmd = [agent_cli]
  cmd.push(agent_flag, agent_config_name) if agent_flag
  cmd.concat(agent_cli_args.split)
  if model && agent_model_flag && !agent_model_flag.empty?
    allowed = resolved["allowed_models"] || {}
    cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
  end
  cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
  cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]

  LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}, cli: #{agent_cli}#{", resuming" if should_resume}), 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 and dirty state before spawning so we can detect if THIS session made changes
  head_before = nil
  status_before = nil
  if project_config
    pk = PROJECTS.find { |_k, v| v == project_config }&.first
    head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
  end

  pid = spawn(spawn_env, *cmd,
              chdir: work_dir,
              **(prompt_mode == "stdin" ? { 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 and dirty state now vs before — only restart if the agent made commits or new dirty files
    if project_config && head_before
      project_key = PROJECTS.find { |_k, v| v == project_config }&.first
      if project_key == "brainiac"
        head_after, status_after = capture_git_state(project_config["repo_path"])
        if head_after != head_before || status_after != status_before
          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



1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
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
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
# File 'lib/brainiac/handlers/discord.rb', line 1271

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



702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
# File 'lib/brainiac/helpers.rb', line 702

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



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
# File 'lib/brainiac/helpers.rb', line 722

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)


776
777
778
779
780
781
# File 'lib/brainiac/helpers.rb', line 776

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



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/brainiac/helpers.rb', line 213

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



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/brainiac/helpers.rb', line 193

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



253
254
255
256
257
258
259
# File 'lib/brainiac/helpers.rb', line 253

def load_card_map
  return {} unless File.exist?(CARD_MAP_FILE)

  JSON.parse(File.read(CARD_MAP_FILE))
rescue JSON::ParserError
  {}
end

#load_cli_provider(provider_name) ⇒ Object

Load a CLI provider config from ~/.brainiac/cli-providers/<name>.json. Returns a hash with normalized keys, or {} if not found.



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
# File 'lib/brainiac/helpers.rb', line 49

def load_cli_provider(provider_name)
  return {} unless provider_name

  provider_file = File.join(CLI_PROVIDERS_DIR, "#{provider_name}.json")
  return {} unless File.exist?(provider_file)

  raw = JSON.parse(File.read(provider_file))
  config = {
    "agent_cli" => raw["binary"],
    "agent_cli_args" => raw["default_args"],
    "agent_model_flag" => raw["model_flag"],
    "agent_effort_flag" => raw["effort_flag"],
    "allowed_models" => raw["models"],
    "allowed_efforts" => raw["efforts"]
  }
  # agent_flag: how the agent identity is passed (default: "--agent").
  # Set to null/false in provider JSON to suppress passing agent name entirely.
  # We must preserve the key even when nil so merges don't lose the "no agent flag" intent.
  config["agent_flag"] = raw.key?("agent_flag") ? raw["agent_flag"] : "--agent"
  # prompt_mode: "stdin" (default) or "flag" — how the prompt is delivered.
  config["prompt_mode"] = raw["prompt_mode"] || "stdin"
  config["prompt_flag"] = raw["prompt_flag"] if raw["prompt_flag"]
  # resume_flag: when set, follow-up dispatches use this flag to continue the
  # most recent session in the working directory (e.g. "-c" or "--continue").
  config["resume_flag"] = raw["resume_flag"] if raw["resume_flag"]
  # Compact nil values except agent_flag (which uses nil to mean "don't pass agent name")
  agent_flag_value = config["agent_flag"]
  config.compact!
  config["agent_flag"] = agent_flag_value if raw.key?("agent_flag")
  config
rescue JSON::ParserError => e
  LOG.warn "Failed to parse CLI provider '#{provider_name}': #{e.message}"
  {}
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



137
138
139
140
141
142
143
144
145
# File 'lib/brainiac/handlers/discord.rb', line 137

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_discord_thread_mapObject



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

def load_discord_thread_map
  return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)

  JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
rescue JSON::ParserError
  {}
end

#load_fizzy_configObject



60
61
62
63
64
65
66
67
# File 'lib/brainiac/config.rb', line 60

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



75
76
77
78
79
80
81
82
# File 'lib/brainiac/config.rb', line 75

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 —



142
143
144
145
146
147
148
149
150
151
# File 'lib/brainiac/config.rb', line 142

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_role(role_name) ⇒ Object

Load a role definition from ~/.brainiac/roles/<name>.md. Returns the file content (markdown) or nil if not found.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/brainiac/agents.rb', line 141

def load_role(role_name)
  return nil unless role_name

  role_file = File.join(ROLES_DIR, "#{role_name}.md")
  return nil unless File.exist?(role_file)

  content = File.read(role_file).strip
  # Strip YAML front matter if present
  content = content.sub(/\A---\n.*?\n---\n*/m, "").strip
  content.empty? ? nil : content
rescue StandardError => e
  LOG.warn "Failed to load role '#{role_name}': #{e.message}"
  nil
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.



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/brainiac/agents.rb', line 189

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.



1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
# File 'lib/brainiac/handlers/discord.rb', line 1448

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



313
314
315
# File 'lib/brainiac/helpers.rb', line 313

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



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
# File 'lib/brainiac/helpers.rb', line 545

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



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
# File 'lib/brainiac/helpers.rb', line 447

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



840
841
842
843
844
# File 'lib/brainiac/helpers.rb', line 840

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).



251
252
253
254
255
256
257
258
# File 'lib/brainiac/config.rb', line 251

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



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
# File 'lib/brainiac/helpers.rb', line 332

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



60
61
62
63
64
65
66
67
68
# File 'lib/brainiac/handlers/discord.rb', line 60

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



149
150
151
# File 'lib/brainiac/handlers/discord.rb', line 149

def reload_discord_config!
  DISCORD_CONFIG.replace(load_discord_config)
end

#reload_github_config!(force: false) ⇒ Object



177
178
179
180
181
182
# File 'lib/brainiac/config.rb', line 177

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



170
171
172
173
174
175
# File 'lib/brainiac/config.rb', line 170

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



312
313
314
315
# File 'lib/brainiac/handlers/discord.rb', line 312

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_discord_resume_prompt(message_body:, discord_user:, response_file:, agent_name: AI_AGENT_NAME, card_id: nil) ⇒ Object

Lean resume prompt for Discord threads. The previous session has full context (role, persona, knowledge, instructions). We only send the new message + channel history.



589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/brainiac/prompts.rb', line 589

def render_discord_resume_prompt(message_body:, discord_user:, response_file:, agent_name: AI_AGENT_NAME, card_id: nil)
  memory_dir = memory_dir_for(agent_name)
  if card_id
    memory_file = File.join(memory_dir, "card-#{card_id}.md")
    FileUtils.mkdir_p(memory_dir)
    FileUtils.touch(memory_file)
  end

  lines = []
  lines << "## Resumed Session — New Discord Message"
  lines << ""
  lines << "This is a continuation of your previous session in this thread."
  lines << "All prior context, instructions, and your previous work are still in this conversation."
  lines << ""
  lines << "### New Message from #{discord_user}"
  lines << ""
  lines << message_body
  lines << ""
  lines << "---"
  lines << "**IMPORTANT: Write your response to `#{response_file}`. Do NOT reply via stdout.**"
  lines << "All your previous instructions still apply (memory, persona, one message per session, etc.)."

  lines.join("\n")
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



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
# File 'lib/brainiac/prompts.rb', line 516

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

#render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_number: nil, agent_name: AI_AGENT_NAME) ⇒ Object

Lean prompt for resumed sessions. The previous session already has the full context (role, persona, knowledge, core instructions, channel prompts). We only send the new comment and any fresh card context so the agent knows what changed.



563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
# File 'lib/brainiac/prompts.rb', line 563

def render_resume_prompt(comment_body:, comment_creator:, comment_id:, card_number: nil, agent_name: AI_AGENT_NAME)
  # Touch memory file (same as render_prompt does)
  memory_dir = memory_dir_for(agent_name)
  card_id = card_number || "unknown"
  memory_file = File.join(memory_dir, "card-#{card_id}.md")
  FileUtils.mkdir_p(memory_dir)
  FileUtils.touch(memory_file)

  lines = []
  lines << "## Resumed Session — New Follow-up Comment"
  lines << ""
  lines << "This is a continuation of your previous session on this card."
  lines << "All prior context, instructions, and your previous work are still in this conversation."
  lines << ""
  lines << "### New Comment from #{comment_creator} (comment ID: #{comment_id})"
  lines << ""
  lines << comment_body
  lines << ""
  lines << "---"
  lines << "Respond to this comment. All your previous instructions still apply."

  lines.join("\n")
end

#resolve_card_number(internal_id, repo_path:) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/brainiac/helpers.rb', line 230

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.



828
829
830
831
832
833
834
835
836
837
838
# File 'lib/brainiac/helpers.rb', line 828

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, cli_provider_override: nil, agent_name: nil) ⇒ Object

Resolve CLI config for a project by merging provider defaults with project overrides. Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider > DEFAULT_PROJECT



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/brainiac/helpers.rb', line 86

def resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil)
  # Determine which CLI provider to use (priority: override > agent > project)
  provider_name = cli_provider_override
  provider_name ||= agent_cli_provider_for(agent_name) if agent_name
  provider_name ||= project_config["cli_provider"]

  provider_config = load_cli_provider(provider_name)

  DEFAULT_PROJECT.merge(provider_config).merge(project_config).tap do |resolved|
    # If an override or agent-level provider was used, it should win over the
    # project-level cli_provider's config. Re-apply the override provider on top.
    resolved.merge!(provider_config) if provider_name && provider_name != project_config["cli_provider"]
  end
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, cli_provider: nil, resume: false) ⇒ Object



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
# File 'lib/brainiac/helpers.rb', line 561

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, cli_provider: nil, resume: false)
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
  chdir ||= resolved["repo_path"]
  model ||= resolved["agent_model"]
  effort ||= resolved["agent_effort"]
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")

  # Auto-resume: if the provider supports session resume and we're in a worktree
  # that has had a previous session, resume it. Only applies to follow-ups (not first dispatch).
  should_resume = resume && resolved["resume_flag"]

  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, prompt_file: prompt_file, resume: should_resume)
  prompt_mode = resolved["prompt_mode"] || "stdin"

  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(" ")}#{" (resuming session)" if should_resume}"
  LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty?

  project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
  head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"

  pid = spawn(spawn_env, *cmd,
              chdir: chdir,
              **(prompt_mode == "stdin" ? { 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, status_before: status_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



287
288
289
290
291
292
293
# File 'lib/brainiac/helpers.rb', line 287

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.



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/brainiac/helpers.rb', line 173

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



261
262
263
# File 'lib/brainiac/helpers.rb', line 261

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_discord_thread_map(map) ⇒ Object



49
50
51
# File 'lib/brainiac/handlers/discord.rb', line 49

def save_discord_thread_map(map)
  File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
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



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/brainiac/helpers.rb', line 416

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



284
285
286
287
288
289
290
291
292
293
294
# File 'lib/brainiac/handlers/discord.rb', line 284

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



296
297
298
# File 'lib/brainiac/handlers/discord.rb', line 296

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



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/brainiac/handlers/discord.rb', line 329

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.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/brainiac/handlers/discord.rb', line 71

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



265
266
267
# File 'lib/brainiac/helpers.rb', line 265

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.



1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
# File 'lib/brainiac/handlers/discord.rb', line 1816

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(", ")}"

  # Register all bots upfront so the "all ready" check sees the full set
  # before any individual READY event fires.
  DISCORD_BOTS_MUTEX.synchronize do
    tokens.each do |agent_key, token|
      DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
    end
  end

  tokens.each do |agent_key, token|
    start_discord_gateway_for(agent_key, token)
    sleep 1 # Stagger connections to avoid rate limits
  end
end

#start_brainiac_restart_monitorObject



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

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



623
624
625
626
627
628
629
630
631
632
# File 'lib/brainiac/cron.rb', line 623

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



1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
# File 'lib/brainiac/handlers/discord.rb', line 1636

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) —



1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
# File 'lib/brainiac/handlers/discord.rb', line 1672

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)



296
297
298
299
300
301
302
303
304
305
306
# File 'lib/brainiac/helpers.rb', line 296

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



278
279
280
281
282
283
284
285
# File 'lib/brainiac/helpers.rb', line 278

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



269
270
271
272
273
274
275
276
# File 'lib/brainiac/helpers.rb', line 269

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.



632
633
634
635
636
637
638
# File 'lib/brainiac/helpers.rb', line 632

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.



569
570
571
572
573
574
575
# File 'lib/brainiac/cron.rb', line 569

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