Top Level Namespace

Defined Under Namespace

Modules: Brainiac

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)

File.join(BRAINIAC_DIR, "users.json")
USER_REGISTRY =
load_user_registry
AGENT_REGISTRY =
load_agent_registry
BRAINIAC_VERSION =
Brainiac::VERSION
BRAINIAC_DIR =

--- Environment & paths ---

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")
WORK_ITEM_MAP_FILE =
File.join(BRAINIAC_DIR, "work_items.json")
AGENT_REGISTRY_FILE =
File.join(BRAINIAC_DIR, "agents.json")
BRAINIAC_CONFIG_FILE =

--- Master config (handler toggles, global settings) ---

File.join(BRAINIAC_DIR, "brainiac.json")
BRAINIAC_CONFIG =
load_brainiac_config
AI_AGENT_NAME =

--- Default agent name --- Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.

ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agent"] || begin
  raise <<~MSG
    No default agent configured. Set one of:
      1. Environment variable: export AI_AGENT_NAME="YourAgent"
      2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" }
  MSG
end
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")
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),
  "github_repo" => ENV.fetch("GITHUB_REPO", nil),
  # CLI defaults below are overridden by ~/.brainiac/cli-providers/*.json when a
  # cli_provider is configured on the project or agent. These only apply as a
  # last-resort fallback when no provider config exists.
  "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
DASHBOARD_TOKEN =

--- Dashboard auth ---

begin
  brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
  config = File.exist?(brainiac_config_file) ? JSON.parse(File.read(brainiac_config_file)) : {}
  config["dashboard_token"]
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")
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 =

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

Plugin system for Brainiac.

Plugins are distributed as gems named brainiac-<name> (e.g. brainiac-whatsapp). Each gem exposes a module at Brainiac::Plugins::<Name> that responds to .register(app) where app is the Sinatra application instance.

Installed plugins are tracked in ~/.brainiac/plugins.json so the receiver knows which gems to require at startup.

File.join(BRAINIAC_DIR, "plugins.json")
PLUGINS_CONFIG =
load_plugins_config
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.

  **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 (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 "qmd" -c brainiac-knowledge`, `qmd search "gh" -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 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., "robin" 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 Discord, GitHub, or other channels.
  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_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
CHANNEL_PROMPTS =

Channel constant mapping for render_prompt NOTE: GitHub prompts have been extracted to brainiac-github plugin. The plugin registers via Brainiac.register_channel_prompt(:github, ...)

{}.freeze
BRAINIAC_RESTART_STATE =

Brainiac self-restart logic.

When an agent works on brainiac itself (modifies code), a restart is queued. A background thread checks every 30s and only restarts when no other agents are running, preventing mid-session kills.

{ queued: false, triggered_by: nil }
BRAINIAC_RESTART_MUTEX =
Mutex.new
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 (Channel 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
NOTIFICATIONS_CONFIG_KEY =

Generic notification system for Brainiac.

Provides a channel-agnostic way to send messages. Plugins register as notification providers for their channel. Core code emits notifications without knowing which plugin will deliver them.

Configuration in ~/.brainiac/brainiac.json:

"notifications": {
"deploy": { "channel": "discord", "target": "channel-id-123" },
"cron": { "channel": "discord", "target": "channel-id-456" },
"restart": { "channel": "discord", "target": "channel-id-789" }
}

Plugins register handlers:

Brainiac.on(:notify) do |ctx|
if ctx[:channel].to_s == "discord"
  send_to_discord(ctx[:target], ctx[:message], agent: ctx[:agent])
end
end
"notifications"
REPO_LAST_FETCH =

Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.

{}
REPO_FETCH_DEBOUNCE =

5 minutes

300

Instance Method Summary collapse

Instance Method Details

#add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, notify_channel: nil, notify_target: 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
238
239
240
# 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,
                 notify_channel: nil, notify_target: 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
  return { error: "notify_target requires notify_channel (e.g. -c discord)" } if notify_target && !notify_channel

  job = {
    id: id,
    schedule: schedule,
    parsed: parsed,
    agent: agent,
    project: project,
    model: model,
    effort: effort,
    prompt: prompt,
    script: script,
    enabled: enabled,
    notify_channel: notify_channel&.to_s,
    notify_target: notify_target,
    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

#agent_cli_provider_for(agent_name) ⇒ Object

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



63
64
65
66
67
68
69
70
71
# File 'lib/brainiac/helpers.rb', line 63

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)


266
267
268
269
270
271
272
# File 'lib/brainiac/sessions.rb', line 266

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_display_name(agent_name) ⇒ Object

Get the display name for an agent (from agents.json registry). This is the human-readable canonical name (e.g., "Robin" not "robin"). Core owns this — it's the identity used across all channels and plugins.



76
77
78
79
80
81
82
83
84
# File 'lib/brainiac/agents.rb', line 76

def agent_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["display_name"] || agent_name
end

#agent_env_for(agent_name) ⇒ Object

Get the env hash for an agent. Returns {} if none configured.



58
59
60
61
62
63
64
65
66
# File 'lib/brainiac/agents.rb', line 58

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.



69
70
71
# File 'lib/brainiac/agents.rb', line 69

def agent_env_var(agent_name, var_name)
  agent_env_for(agent_name)[var_name]
end

#agent_name_for(project_config) ⇒ Object



135
136
137
# File 'lib/brainiac/agents.rb', line 135

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



88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/brainiac/agents.rb', line 88

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



120
121
122
123
124
# File 'lib/brainiac/agents.rb', line 120

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

#ai_agentsObject

Get all AI agents



68
69
70
# File 'lib/brainiac/users.rb', line 68

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

#all_agent_namesObject



139
140
141
142
143
144
145
146
147
148
# File 'lib/brainiac/agents.rb', line 139

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
  AGENT_REGISTRY.each do |key, entry|
    names << (entry["display_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)


29
30
31
32
33
34
35
36
37
38
# File 'lib/brainiac/restart.rb', line 29

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

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



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
# File 'lib/brainiac/handlers/shared/git.rb', line 56

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)

        _, _, 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

#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

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



471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/brainiac/helpers.rb', line 471

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?

  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

  # Plugin hook: source-specific brain queries (e.g., plugins inject source-specific knowledge)
  plugin_queries = Brainiac.emit(:build_brain_context,
                                 source: source, card_title: card_title, comment_body: comment_body)
  plugin_queries.flatten.compact.each do |query|
    knowledge_threads << Thread.new { query_brain(query, scope: :knowledge, max_results: 2) }
    search_queries << query
  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.



591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/brainiac/cron.rb', line 591

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



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
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/brainiac/cron.rb', line 377

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

  if has_notify
    notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
    FileUtils.mkdir_p(notify_dir)
    draft_file = File.join(notify_dir, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
    meta_file = "#{draft_file}.meta.json"

    agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
    notify_channel = job[:notify_channel]&.to_s
    notify_target = job[:notify_target]

    meta = {
      notify_channel: notify_channel,
      notify_target: notify_target,
      channel_id: notify_target,
      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 (Notification Posting)
      This is a scheduled cron job. Output will be posted to #{notify_channel} (#{notify_target}).

      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.

      #{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



57
58
59
60
# File 'lib/brainiac/users.rb', line 57

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.



33
34
35
36
37
38
39
# File 'lib/brainiac/handlers/shared/git.rb', line 33

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

#check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name) ⇒ Object



538
539
540
541
542
543
544
545
546
547
# File 'lib/brainiac/helpers.rb', line 538

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 }



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/brainiac/config.rb', line 144

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_work_item_worktrees(card_number, repo_path:, primary_worktree: nil, primary_branch: nil) ⇒ Object

Clean up all worktrees associated with a work item (primary + cross-agent review). Safe: skips worktrees with uncommitted changes.



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
# File 'lib/brainiac/handlers/shared/git.rb', line 161

def cleanup_work_item_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

  # Find worktrees that contain the work item number in their name (any naming convention)
  candidates = Dir.glob(File.join(repo_dir, "#{repo_base}--*#{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

#comment_from_agent?(name) ⇒ Boolean

Returns:

  • (Boolean)


185
186
187
188
189
190
# File 'lib/brainiac/agents.rb', line 185

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_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil) ⇒ Object

Create or reuse a git worktree for a given branch. Returns the worktree path on success.



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
# File 'lib/brainiac/handlers/shared/git.rb', line 111

def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil)
  worktree_path ||= File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
  base_ref ||= "origin/#{get_default_branch(repo_path)}"

  worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)

  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

  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"
    worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
    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
      LOG.info "Creating worktree from existing branch #{branch}"
      run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
    end
  else
    LOG.info "Creating new branch #{branch} and worktree"
    run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, 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 })

  worktree_path
end

#cron_loopObject

Cron loop — runs every minute, checks all jobs



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

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



12
13
14
15
16
17
18
19
20
21
# File 'lib/brainiac/handlers/shared/git.rb', line 12

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_project_keyObject



89
90
91
92
93
# File 'lib/brainiac/helpers.rb', line 89

def default_project_key
  # Find the project marked as default
  default = PROJECTS.find { |_key, config| config["default"] == true }
  default ? default[0] : nil
end

#deliver_script_output(job, log_file, draft_file) ⇒ Object

Read script output and write to draft file or log.



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/brainiac/cron.rb', line 357

def deliver_script_output(job, log_file, draft_file)
  return unless File.exist?(log_file)

  output = File.read(log_file).strip
  has_notify = job[:notify_target]

  if has_notify && draft_file && !output.empty?
    LOG.warn "[Cron] Job #{job[:id]} has notify_target but no notify_channel — notification may not be delivered" unless job[:notify_channel]
    File.write(draft_file, output)
    # Deliver via notification system
    notify_cron_output(job, output, agent_name: job[:agent])
    LOG.info "[Cron] Script output delivered via notification (#{output.length} chars)"
  elsif !output.empty?
    LOG.info "[Cron] Script output: #{output[0..200]}..."
  else
    LOG.warn "[Cron] Script produced no output"
  end
end

#detect_cli_provider(text: "", tags: []) ⇒ Object

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



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/brainiac/helpers.rb', line 75

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

  # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
  results = Brainiac.emit(:detect_cli_provider, text: text, tags: tags)
  plugin_result = results.compact.first
  return plugin_result if plugin_result

  nil
end

#detect_effort(project_config, tags: [], text: "") ⇒ Object

Detect effort level from inline tags [effort:high] or 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.



571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# File 'lib/brainiac/helpers.rb', line 571

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] — works in any channel
  if (match = text.match(/\[effort:(\w+)\]/i))
    level = match[1].downcase
    return resolve_effort_level(level, allowed) if allowed.include?(level)
  end

  # Plugin hook: let plugins detect from their own metadata (e.g., card tags)
  results = Brainiac.emit(:detect_effort, tags: tags, allowed: allowed)
  plugin_result = results.compact.first
  return resolve_effort_level(plugin_result, allowed) if plugin_result

  resolved["agent_effort"]
end

#detect_mentioned_agent(text) ⇒ Object



170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/brainiac/agents.rb', line 170

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

    # Some systems render mentions using first name only (e.g. "@Robin" not "@Robin Hood").
    # 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_model(project_config, tags: [], text: "") ⇒ Object



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
# File 'lib/brainiac/helpers.rb', line 549

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_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

#discover_kiro_agentsObject



126
127
128
129
130
131
132
133
# File 'lib/brainiac/agents.rb', line 126

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

#execute_cron_job(job) ⇒ Object

Execute a cron job (dispatch agent)



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

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_restartObject



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
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/brainiac/restart.rb', line 40

def execute_restart
  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"})...")

  Thread.new do
    sleep 1
    source_dir = defined?(SERVER_ROOT) ? SERVER_ROOT : File.expand_path("../..", __dir__)
    receiver_path = File.join(source_dir, "receiver.rb")

    # Determine if we're running in foreground mode.
    # If stdin is a TTY or we weren't launched as a daemon, use exec to replace the process.
    # This keeps the server in the foreground terminal.
    foreground = $stdout.tty? || !File.exist?(File.join(BRAINIAC_DIR, "server.pid")) ||
                 File.read(File.join(BRAINIAC_DIR, "server.pid")).strip.to_i == Process.pid

    if foreground
      LOG.info "[Brainiac] Restarting in foreground mode (exec)..."
      # Write PID file for the new process (same PID after exec)
      File.write(File.join(BRAINIAC_DIR, "server.pid"), Process.pid.to_s)

      # exec replaces this process — the terminal stays attached
      Dir.chdir(source_dir)
      exec("ruby", receiver_path)
    else
      # Daemon mode — spawn a new background process
      log_file = File.join(source_dir, "tmp", "brainiac-server.log")
      FileUtils.mkdir_p(File.dirname(log_file))

      pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "ruby", receiver_path,
                  chdir: source_dir, out: [log_file, "a"], err: %i[child out])
      Process.detach(pid)

      File.write(File.join(BRAINIAC_DIR, "server.pid"), pid.to_s)

      LOG.info "[Brainiac] Stopping server, new instance started (PID: #{pid}) from #{source_dir}"
      sleep 0.5
      Sinatra::Application.quit!
      sleep 0.5
      exit!
    end
  end
end

#execute_script_job(job, project) ⇒ Object

Execute a script-based cron job (no agent, direct script execution)



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

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_notification_draft(job, timestamp) if job[:notify_target]

  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

#extract_crash_snippet(log_file, max_lines: 20) ⇒ Object

Extract the last N meaningful lines from an agent log for crash reporting.



319
320
321
322
323
324
325
326
327
# File 'lib/brainiac/helpers.rb', line 319

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



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

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_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

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

Returns:

  • (Boolean)


91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/brainiac/config.rb', line 91

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

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



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

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)



49
50
51
52
53
54
# File 'lib/brainiac/users.rb', line 49

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_canonical_name(identifier)
end

#find_user_by_canonical_name(name) ⇒ Object

Find user by canonical name



44
45
46
# File 'lib/brainiac/users.rb', line 44

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_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

#find_work_item_by_branch(branch) ⇒ Object

Find a work item by its branch name. Returns [work_item_id, info] or nil. This is the primary lookup method — branch is the universal join key.



182
183
184
185
186
187
188
189
190
191
192
# File 'lib/brainiac/helpers.rb', line 182

def find_work_item_by_branch(branch)
  return nil unless branch

  map = load_work_item_map
  map.each do |work_item_id, info|
    next unless info.is_a?(Hash) && info["branch"] == branch

    return [work_item_id, info]
  end
  nil
end

#find_work_item_by_card(card_internal_id) ⇒ Object

Find a work item by a Fizzy card internal ID (for backward compat with Fizzy plugin). Returns [work_item_id, info] or nil.



204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/brainiac/helpers.rb', line 204

def find_work_item_by_card(card_internal_id)
  return nil unless card_internal_id

  map = load_work_item_map
  map.each do |work_item_id, info|
    next unless info.is_a?(Hash)

    fizzy_source = info.dig("sources", "fizzy")
    next unless fizzy_source && fizzy_source["card_internal_id"] == card_internal_id

    return [work_item_id, info]
  end
  nil
end

#find_work_item_by_id(work_item_id) ⇒ Object

Find a work item by its ID. Returns the info hash or nil.



195
196
197
198
199
200
# File 'lib/brainiac/helpers.rb', line 195

def find_work_item_by_id(work_item_id)
  return nil unless work_item_id

  map = load_work_item_map
  map[work_item_id]
end

#format_active_sessionsObject



20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/brainiac/routes/api.rb', line 20

def format_active_sessions
  ACTIVE_SESSIONS.map do |card_key, info|
    agent_name = resolve_session_agent_name(card_key, info)
    {
      card_key: card_key, agent: agent_name, pid: info[:pid],
      started_at: info[:started_at].iso8601,
      elapsed_seconds: (Time.now - info[:started_at]).to_i,
      log_file: info[:log_file], alive: true,
      children: child_processes_for(info[:pid])
    }
  end
end

#format_recent_sessionsObject



39
40
41
42
43
44
45
46
47
# File 'lib/brainiac/routes/api.rb', line 39

def format_recent_sessions
  RECENT_SESSIONS.map do |s|
    {
      card_key: s[:card_key], agent: s[:agent_name] || "Unknown",
      log_file: s[:log_file], started_at: s[:started_at]&.iso8601,
      finished_at: s[:finished_at]&.iso8601
    }
  end
end

#generate_work_item_id(branch: nil, card_number: nil) ⇒ Object

Generate a deterministic work item ID from available identifiers. Priority: branch name (universal join key), then card number fallback.



170
171
172
173
174
175
176
177
178
# File 'lib/brainiac/helpers.rb', line 170

def generate_work_item_id(branch: nil, card_number: nil)
  if branch
    "wi-#{Digest::SHA256.hexdigest(branch)[0..7]}"
  elsif card_number
    "wi-card-#{card_number}"
  else
    "wi-#{SecureRandom.hex(4)}"
  end
end

#get_default_branch(repo_path) ⇒ Object



23
24
25
26
27
28
29
30
# File 'lib/brainiac/handlers/shared/git.rb', line 23

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

#handle_agent_completion(**ctx) ⇒ Object



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

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

  # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
  Brainiac.emit(:agent_completed,
                card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
                exit_status: agent_exit_status,
                signaled: agent_signaled,
                agent_name: ctx[:agent_name],
                chdir: ctx[:chdir],
                source: ctx[:source],
                source_context: ctx[:source_context],
                project_config: ctx[:project_config],
                skip_column_move: ctx[:skip_column_move],
                prompt_file: ctx[:prompt_file])

  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_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



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

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[:notify_target]
    notify_agent_crash(
      exit_status: cron_exit_status, log_file: log_file,
      agent_name: agent_name, source: :cron,
      source_context: { job: job },
      project_config: project
    )
  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

#human_usersObject

Get all human users (exclude AI agents)



63
64
65
# File 'lib/brainiac/users.rb', line 63

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

#identify_project_by_repo(repo_full_name) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/brainiac/helpers.rb', line 95

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

#install_plugin(name, version: nil) ⇒ Object

Install a plugin gem and register it in plugins.json. rubocop:disable Naming/PredicateMethod



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

def install_plugin(name, version: nil)
  gem_name = "brainiac-#{name}"

  if installed_plugins.include?(name)
    puts "Plugin '#{name}' is already installed."
    return false
  end

  puts "Installing #{gem_name}..."
  install_cmd = ["gem", "install", gem_name]
  install_cmd.push("--version", version) if version

  stdout, stderr, status = Open3.capture3(*install_cmd)
  unless status.success?
    puts "Failed to install #{gem_name}:"
    puts stderr.empty? ? stdout : stderr
    return false
  end
  puts stdout unless stdout.strip.empty?

  config = load_plugins_config
  config["plugins"] ||= []
  entry = { "name" => name, "gem" => gem_name, "installed_at" => Time.now.iso8601 }
  entry["version"] = version if version
  config["plugins"] << entry
  save_plugins_config(config)

  puts "✓ Installed plugin '#{name}' (#{gem_name})"
  puts "  Restart the server to activate: brainiac restart"
  true
end

#installed_pluginsObject

Returns the list of installed plugin names (e.g. ["whatsapp", "slack"])



31
32
33
# File 'lib/brainiac/plugins.rb', line 31

def installed_plugins
  (PLUGINS_CONFIG["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
end

#kill_child_process(target_pid) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/brainiac/routes/api.rb', line 49

def kill_child_process(target_pid)
  Process.kill("TERM", target_pid)
  Thread.new do
    sleep 3
    begin
      Process.kill(0, target_pid)
      Process.kill("KILL", target_pid)
    rescue Errno::ESRCH, Errno::EPERM # rubocop:disable Lint/SuppressedException
    end
  end
  LOG.info "Killed child process #{target_pid} (SIGTERM)"
  { killed: target_pid }.to_json
rescue Errno::ESRCH
  halt 404, { error: "process not found" }.to_json
rescue Errno::EPERM
  halt 403, { error: "permission denied" }.to_json
end

#kill_session(session_key) ⇒ Object

Kill a session's process. Returns true if killed.



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/brainiac/sessions.rb', line 205

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:

{
"sherlock": {
  "display_name": "Sherlock",
  "local": true,
  "env": {
    "SOME_TOKEN": "token_abc...",
    "OTHER_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.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/brainiac/agents.rb', line 23

def load_agent_registry
  unless File.exist?(AGENT_REGISTRY_FILE)
    LOG.info "No agent registry found at #{AGENT_REGISTRY_FILE}"
    return {}
  end

  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

  registry
rescue JSON::ParserError => e
  LOG.error "Failed to parse agent registry: #{e.message}"
  {}
end

#load_brainiac_configObject



29
30
31
32
33
34
35
36
# File 'lib/brainiac/config.rb', line 29

def load_brainiac_config
  return {} unless File.exist?(BRAINIAC_CONFIG_FILE)

  JSON.parse(File.read(BRAINIAC_CONFIG_FILE))
rescue JSON::ParserError => e
  LOG.error "Failed to parse brainiac.json: #{e.message}" if defined?(LOG)
  {}
end

#load_cli_provider(provider_name) ⇒ Object

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



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

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_plugins!(app) ⇒ Object

Load all installed plugin gems and call their register hooks. Called once during server startup, after core handlers are loaded.



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

def load_plugins!(app)
  # rubocop:disable Metrics/BlockLength
  installed_plugins.each do |name|
    gem_name = "brainiac-#{name}"
    entry = plugin_entry(name)
    begin
      # If plugin has a local path, add its lib/ to load path before requiring
      if entry.is_a?(Hash) && entry["path"]
        lib_path = File.join(entry["path"], "lib")
        $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
        LOG.info "[Plugins] Loading #{gem_name} from local path: #{entry["path"]}"
      end

      # Try both naming conventions: brainiac-fizzy and brainiac_fizzy
      begin
        require gem_name
      rescue LoadError
        require gem_name.tr("-", "_")
      end

      plugin_module = resolve_plugin_module(name)
      if plugin_module.respond_to?(:register)
        plugin_module.register(app)
        LOG.info "[Plugins] Loaded #{gem_name}"
      else
        LOG.warn "[Plugins] #{gem_name} loaded but no register method found"
      end
    rescue LoadError => e
      LOG.error "[Plugins] Could not load #{gem_name}: #{e.message}"
      if entry.is_a?(Hash) && entry["path"]
        LOG.error "[Plugins]   Local path: #{entry["path"]}"
      else
        LOG.error "[Plugins]   Is the gem installed? Run: gem install #{gem_name}"
      end
    rescue StandardError => e
      LOG.error "[Plugins] Error registering #{gem_name}: #{e.message}"
      LOG.error "[Plugins]   #{e.backtrace.first(3).join("\n  ")}"
    end
  end
  # rubocop:enable Metrics/BlockLength
end

#load_plugins_configObject



14
15
16
17
18
19
20
21
# File 'lib/brainiac/plugins.rb', line 14

def load_plugins_config
  return { "plugins" => [] } unless File.exist?(PLUGINS_FILE)

  JSON.parse(File.read(PLUGINS_FILE))
rescue JSON::ParserError => e
  LOG.error "Failed to parse plugins.json: #{e.message}"
  { "plugins" => [] }
end

#load_projects_configObject

--- Projects ---



77
78
79
80
81
82
83
84
85
86
# File 'lib/brainiac/config.rb', line 77

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/.md. Returns the file content (markdown) or nil if not found.



105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/brainiac/agents.rb', line 105

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_work_item_mapObject



112
113
114
115
116
117
118
119
# File 'lib/brainiac/helpers.rb', line 112

def load_work_item_map
  return {} unless File.exist?(WORK_ITEM_MAP_FILE)

  raw = JSON.parse(File.read(WORK_ITEM_MAP_FILE))
  migrate_work_item_map(raw)
rescue JSON::ParserError
  {}
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.



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

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["display_name"] || key.capitalize)
  end
  names
end

#mark_work_item_merged(card_number) ⇒ Object



298
299
300
# File 'lib/brainiac/helpers.rb', line 298

def mark_work_item_merged(card_number)
  MERGED_CARDS_MUTEX.synchronize { MERGED_CARDS[card_number.to_s] = Time.now }
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

#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

#migrate_work_item_map(raw) ⇒ Object

Migrate old-format work item maps (keyed by Fizzy card internal ID with flat structure) to the new source-agnostic format (keyed by work item ID with sources hash). Old format: { "fizzy-uuid" => { "number" => 42, "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "..." } } New format: { "wi-abc123" => { "id" => "wi-...", "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "...", "sources" => { "fizzy" => { ... } } } }



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

def migrate_work_item_map(raw)
  return raw if raw.empty?

  # Detect: if any entry has a "sources" key, it's already new format (or mixed)
  # If none have "sources", it's entirely old format
  needs_migration = raw.values.any? { |v| v.is_a?(Hash) && !v.key?("sources") }
  return raw unless needs_migration

  migrated = {}
  raw.each do |key, entry|
    next unless entry.is_a?(Hash)

    if entry.key?("sources")
      # Already new format
      migrated[key] = entry
    else
      # Old format — migrate. Generate a work item ID from the branch or card number.
      work_item_id = generate_work_item_id(branch: entry["branch"], card_number: entry["number"])
      migrated[work_item_id] = {
        "id" => work_item_id,
        "branch" => entry["branch"],
        "worktree" => entry["worktree"],
        "project" => entry["project"],
        "agent" => entry["agent"],
        "sources" => {
          "fizzy" => {
            "card_internal_id" => key,
            "card_number" => entry["number"]
          }
        }
      }
      # Preserve PR tracking if it exists
      migrated[work_item_id]["sources"]["github"] = { "prs" => entry["prs"] } if entry["prs"]
    end
  end
  migrated
end

#notification_config_for(event) ⇒ Object

Get the notification config for a specific event type. Falls back to "default" config if no event-specific config exists.



65
66
67
68
69
70
71
72
73
74
75
# File 'lib/brainiac/notifications.rb', line 65

def notification_config_for(event)
  brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
  return {} unless File.exist?(brainiac_config_file)

  config = JSON.parse(File.read(brainiac_config_file))
  notifications = config[NOTIFICATIONS_CONFIG_KEY] || {}

  notifications[event.to_s] || notifications["default"] || {}
rescue JSON::ParserError
  {}
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: :github, :discord, or plugin-registered sources source_context: hash with channel-specific info needed to post the notification



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/brainiac/helpers.rb', line 332

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)

  # Emit to plugins — they handle their own channel-specific delivery
  handled = Brainiac.emit(:agent_crashed,
                          exit_status: exit_status, log_file: log_file, agent_name: agent_display,
                          source: source, source_context: source_context, project_config: project_config,
                          snippet: snippet)

  # If no plugin handled it, log a warning
  LOG.warn "[CrashNotify] Agent crashed but no plugin handled notification (source: #{source})" unless handled.any?
rescue StandardError => e
  LOG.error "[CrashNotify] Unexpected error: #{e.message}"
end

#notify_cron_output(job, message, agent_name: nil) ⇒ Object

Convenience: send a notification for cron job output.



78
79
80
81
82
83
84
85
86
# File 'lib/brainiac/notifications.rb', line 78

def notify_cron_output(job, message, agent_name: nil)
  send_notification(:cron, message,
                    target: job[:notify_target],
                    channel: job[:notify_channel],
                    agent: agent_name || job[:agent],
                    job_id: job[:id],
                    forum_title: job[:forum_title],
                    forum_reply_to_latest: job[:forum_reply_to_latest])
end

#notify_deploy(project_key, message) ⇒ Object

Convenience: send a deploy notification.



89
90
91
# File 'lib/brainiac/notifications.rb', line 89

def notify_deploy(project_key, message)
  send_notification(:deploy, message, metadata_project: project_key)
end

#notify_restart(message) ⇒ Object

Convenience: send a restart notification.



94
95
96
# File 'lib/brainiac/notifications.rb', line 94

def notify_restart(message)
  send_notification(:restart, message)
end

#notify_unauthorized(action, creator_name, card_info) ⇒ Object



602
603
604
605
606
# File 'lib/brainiac/helpers.rb', line 602

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

#on_comment_cooldown?(card_key) ⇒ Boolean

Returns:

  • (Boolean)


233
234
235
236
# File 'lib/brainiac/sessions.rb', line 233

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)


247
248
249
250
# File 'lib/brainiac/sessions.rb', line 247

def on_deploy_cooldown?(env_key)
  last = LAST_DEPLOY_TIMES[env_key]
  last && (Time.now - last) < DEPLOY_COOLDOWN
end

#owner_idObject

Owner identifier (for version-outdated notifications). Reads from brainiac.json.



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

def owner_id
  brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
  return nil unless File.exist?(brainiac_config_file)

  JSON.parse(File.read(brainiac_config_file))["owner_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_flag_tags(result) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 75

def parse_flag_tags(result)
  # [chat], [question], [?]
  if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
    result[:chat_mode] = true
    result[:clean_text].sub!(/\[(chat|question|\?)\]/i, "")
  end

  # [plan]
  return unless result[:clean_text].match?(/\[plan\]/i)

  result[:planning] = true
  result[:clean_text].sub!(/\[plan\]/i, "")
end

#parse_inline_tags(text) ⇒ Object

Parse inline tags from message text. Returns a hash:

{
project: "my-project" or nil,
model_tag: "opus" or nil (raw tag, not resolved model ID),
effort: "high" or nil,
cli_provider: "grok" or nil,
chat_mode: true/false,
planning: true/false,
deploy_intent: "dev01" / :auto / nil,
worktree_override: "branch-name" or nil,
clean_text: "the message with all tags stripped"
}


25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 25

def parse_inline_tags(text)
  result = {
    project: nil,
    model_tag: nil,
    effort: nil,
    cli_provider: nil,
    chat_mode: false,
    planning: false,
    deploy_intent: nil,
    worktree_override: nil,
    work_item: nil,
    branch_override: nil,
    clean_text: text.dup
  }

  parse_value_tags(result)
  parse_flag_tags(result)
  parse_work_item_tags(result)
  parse_model_tag(result)

  result[:clean_text].strip!
  result
end

#parse_model_tag(result) ⇒ Object



111
112
113
114
115
116
117
118
119
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 111

def parse_model_tag(result)
  # Model tag: any remaining [word] that isn't a known tag — detected separately
  # because it depends on the project's allowed_models config. We just capture
  # the raw match here for the caller to resolve.
  if (match = result[:clean_text].match(/\[(\w+)\]/))
    result[:model_tag] = match[1].downcase
    result[:clean_text].sub!(match[0], "")
  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_value_tags(result) ⇒ Object



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/shared/inline_tags.rb', line 49

def parse_value_tags(result)
  # [project:my-project]
  if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
    result[:project] = match[1]
    result[:clean_text].sub!(match[0], "")
  end

  # [effort:high]
  if (match = result[:clean_text].match(/\[effort:(\w+)\]/i))
    result[:effort] = match[1].downcase
    result[:clean_text].sub!(match[0], "")
  end

  # [cli:grok]
  if (match = result[:clean_text].match(/\[cli:(\w+)\]/i))
    result[:cli_provider] = match[1].downcase
    result[:clean_text].sub!(match[0], "")
  end

  # [deploy] or [deploy:dev01]
  if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
    result[:deploy_intent] = match[1]&.strip&.downcase || :auto
    result[:clean_text].sub!(match[0], "")
  end
end

#parse_work_item_tags(result) ⇒ Object



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/brainiac/handlers/shared/inline_tags.rb', line 89

def parse_work_item_tags(result)
  # [worktree:branch-name] — legacy syntax, still supported
  if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
    result[:worktree_override] = match[1].strip
    result[:clean_text].sub!(match[0], "")
  end

  # [branch:branch-name] — preferred syntax for targeting a branch/worktree
  if (match = result[:clean_text].match(/\[branch:([^\]]+)\]/i))
    result[:branch_override] = match[1].strip
    # Also set worktree_override for backward compat with plugins that read it
    result[:worktree_override] ||= result[:branch_override]
    result[:clean_text].sub!(match[0], "")
  end

  # [workitem:wi-abc123] — target a specific work item by ID
  if (match = result[:clean_text].match(/\[workitem:([^\]]+)\]/i))
    result[:work_item] = match[1].strip
    result[:clean_text].sub!(match[0], "")
  end
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

#plugin_entry(name) ⇒ Object

Returns the full plugin entry (Hash) for a given name, or nil.



36
37
38
# File 'lib/brainiac/plugins.rb', line 36

def plugin_entry(name)
  (PLUGINS_CONFIG["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name }
end

#prepare_script_notification_draft(job, timestamp) ⇒ Object

Prepare a notification draft file for a script job. Returns the draft file path.



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/brainiac/cron.rb', line 335

def prepare_script_notification_draft(job, timestamp)
  notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
  FileUtils.mkdir_p(notify_dir)
  draft_file = File.join(notify_dir, "cron-script-#{timestamp}-#{job[:id]}.md")
  meta_file = "#{draft_file}.meta.json"

  script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
  meta = {
    notify_channel: job[:notify_channel]&.to_s,
    notify_target: job[:notify_target],
    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

#prior_session_exists?(chdir, agent_cli) ⇒ Boolean

Check if a prior CLI session exists in the given directory for the specified CLI binary. This prevents resume attempts when the CLI provider changed (e.g., [cli:grok] in a thread started by kiro-cli) or when the session was started on a different machine.

Returns:

  • (Boolean)


351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/brainiac/helpers.rb', line 351

def prior_session_exists?(chdir, agent_cli)
  return false unless chdir && agent_cli

  cli_name = File.basename(agent_cli)

  # Check for CLI-specific session markers:
  # - grok uses .grok/ directory for session state
  # - kiro-cli uses .kiro-cli/ or similar
  # - Generic fallback: check tmp/ for agent logs from this CLI
  session_dir = File.join(chdir, ".#{cli_name}")
  return true if File.directory?(session_dir)

  # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
  # This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
  tmp_dir = File.join(chdir, "tmp")
  return false unless File.directory?(tmp_dir)

  Dir.glob(File.join(tmp_dir, "agent-*.log")).any? do |log|
    # Only count logs from the last 24 hours as valid "resumable" sessions
    File.mtime(log) > Time.now - 86_400
  end
rescue StandardError
  false
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



12
13
14
15
16
17
18
19
20
# File 'lib/brainiac/restart.rb', line 12

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

#reap_dead_sessionsObject

--- Session Helpers ---



10
11
12
13
14
15
16
17
18
# File 'lib/brainiac/routes/api.rb', line 10

def reap_dead_sessions
  ACTIVE_SESSIONS.delete_if do |card_key, info|
    Process.kill(0, info[:pid])
    false
  rescue Errno::ESRCH, Errno::EPERM
    archive_session(card_key, info)
    true
  end
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



274
275
276
277
278
279
280
281
# File 'lib/brainiac/sessions.rb', line 274

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_human_comment(card_internal_id) ⇒ Object



262
263
264
# File 'lib/brainiac/sessions.rb', line 262

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

#register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {}) ⇒ Object

Register a new work item or update an existing one. Returns the work item ID.



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

def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
  map = load_work_item_map

  # Check if a work item already exists for this branch
  existing_id = nil
  map.each do |wid, info|
    if info.is_a?(Hash) && info["branch"] == branch
      existing_id = wid
      break
    end
  end

  work_item_id = existing_id || generate_work_item_id(branch: branch)

  if existing_id
    # Update existing entry — merge in new source, update worktree/agent if provided
    map[work_item_id]["worktree"] = worktree if worktree
    map[work_item_id]["agent"] = agent if agent
    map[work_item_id]["sources"] ||= {}
    map[work_item_id]["sources"][source] = source_data if source
  else
    # Create new entry
    map[work_item_id] = {
      "id" => work_item_id,
      "branch" => branch,
      "worktree" => worktree,
      "project" => project,
      "agent" => agent,
      "sources" => source ? { source => source_data } : {}
    }
  end

  save_work_item_map(map)
  work_item_id
end

#register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) ⇒ Object

Add or update a source on an existing work item. Returns true if the work item was found and updated, false otherwise.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/brainiac/helpers.rb', line 259

def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
  map = load_work_item_map

  # Find by ID or branch
  target_id = work_item_id
  unless target_id
    map.each do |wid, info|
      if info.is_a?(Hash) && info["branch"] == branch
        target_id = wid
        break
      end
    end
  end

  return false unless target_id && map[target_id]

  map[target_id]["sources"] ||= {}
  map[target_id]["sources"][source] = source_data
  save_work_item_map(map)
  true
end

#reload_agent_registry!(force: false) ⇒ Object



50
51
52
53
54
55
# File 'lib/brainiac/agents.rb', line 50

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_projects!(force: false) ⇒ Object



105
106
107
108
109
110
# File 'lib/brainiac/config.rb', line 105

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

#remove_cron_job(id) ⇒ Object

Remove a cron job



243
244
245
246
247
248
249
250
251
# File 'lib/brainiac/cron.rb', line 243

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

#render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



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

def render_prompt(template, vars = {}, brain_context: "", card_context: "", agent_name: AI_AGENT_NAME, channel: nil, board_key: nil)
  result = ""
  result += "#{brain_context}\n" unless brain_context.empty?
  result += card_context unless card_context.empty?
  result += PROMPT_CORE

  # Channel prompt: check plugin-registered prompts first, then built-in
  plugin_prompt = Brainiac.channel_prompts[channel]
  result += plugin_prompt || CHANNEL_PROMPTS.fetch(channel, "")

  result += template

  # Pre-post comment check: plugin-registered
  plugin_pre_post = Brainiac.channel_pre_post_checks[channel]
  result += plugin_pre_post if plugin_pre_post

  # Reflection prompt — skip for now
  # 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
  if defined?(DEFAULT_COLUMN_IDS)
    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
  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.



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

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_effort_level(level, allowed) ⇒ Object

If a level isn't in allowed_efforts, return the closest lower level.



590
591
592
593
594
595
596
597
598
599
600
# File 'lib/brainiac/helpers.rb', line 590

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_plugin_module(name) ⇒ Object

Resolve the plugin module for a given name. Tries Brainiac::Plugins::Whatsapp, Brainiac::Plugins::WhatsApp, etc.



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

def resolve_plugin_module(name)
  return nil unless defined?(Brainiac::Plugins)

  # Try PascalCase (e.g. "whatsapp" -> "Whatsapp", "test-widget" -> "TestWidget")
  pascal = name.split(/[-_]/).map(&:capitalize).join
  return Brainiac::Plugins.const_get(pascal) if Brainiac::Plugins.const_defined?(pascal)

  # Try case-insensitive match (e.g. "WhatsApp" if the gem defines it that way)
  Brainiac::Plugins.constants.each do |const|
    return Brainiac::Plugins.const_get(const) if const.to_s.downcase == name.downcase
  end

  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



47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/brainiac/helpers.rb', line 47

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_resume(resume, resolved, chdir) ⇒ Object

Determine whether a session resume should actually happen. Returns truthy (the resume flag string) if viable, false otherwise. Logs a message when resume was requested but isn't possible.



394
395
396
397
398
399
400
# File 'lib/brainiac/helpers.rb', line 394

def resolve_resume(resume, resolved, chdir)
  return false unless resume && resolved["resume_flag"]
  return resolved["resume_flag"] if prior_session_exists?(chdir, resolved["agent_cli"])

  LOG.info "[Dispatch] Resume requested but not viable for #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
  false
end

#resolve_session_agent_name(_card_key, info) ⇒ Object



33
34
35
36
37
# File 'lib/brainiac/routes/api.rb', line 33

def resolve_session_agent_name(_card_key, info)
  return info[:agent_name] if info[:agent_name]

  agent_display_name("Unknown")
end

#resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil) ⇒ Boolean

Public helper: check if resume is viable for a given project + CLI provider combo. Plugins should call this BEFORE building the prompt to decide between render_resume_prompt (lean) and render_prompt (full context).

Returns true if the CLI supports resume AND a prior session exists in the working directory. When this returns false, plugins should use render_prompt with thread history as card_context so the agent gets full context even though this is a follow-up message.

Returns:

  • (Boolean)


383
384
385
386
387
388
389
# File 'lib/brainiac/helpers.rb', line 383

def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
  chdir ||= resolved["repo_path"]
  return false unless resolved["resume_flag"]

  prior_session_exists?(chdir, resolved["agent_cli"])
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



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

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: only if the provider supports it AND a prior session exists for this CLI here.
  should_resume = resolve_resume(resume, resolved, chdir)

  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)

  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



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

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_project_hook(repo_path, hook_name, extra_env: {}) ⇒ Object

Run a project-level hook script from .brainiac/<hook_name> if it exists.



95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/brainiac/handlers/shared/git.rb', line 95

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_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_plugins_config(config) ⇒ Object



23
24
25
26
# File 'lib/brainiac/plugins.rb', line 23

def save_plugins_config(config)
  FileUtils.mkdir_p(BRAINIAC_DIR)
  File.write(PLUGINS_FILE, JSON.pretty_generate(config))
end

#save_work_item_map(map) ⇒ Object



121
122
123
# File 'lib/brainiac/helpers.rb', line 121

def save_work_item_map(map)
  File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
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_notification(event, message, target: nil, channel: nil, agent: nil, **metadata) ⇒ Object

Send a notification via the configured channel.

Parameters:

  • event (Symbol, String)

    The notification event type (e.g. :deploy, :restart, :cron)

  • message (String)

    The message content

  • target (String, nil) (defaults to: nil)

    Override target (channel ID, card number, etc.)

  • channel (Symbol, String, nil) (defaults to: nil)

    Override channel (:discord, :fizzy, etc.)

  • agent (String, nil) (defaults to: nil)

    Which agent identity to send as

  • metadata (Hash)

    Extra context passed to the handler



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

def send_notification(event, message, target: nil, channel: nil, agent: nil, **)
  config = notification_config_for(event)

  # Explicit params override config
  channel = (channel || config["channel"])&.to_sym
  target ||= config["target"]

  unless channel && target
    LOG.debug "[Notify] No channel/target configured for '#{event}', skipping" if LOG.debug?
    return false
  end

  agent ||= config["agent"]

  results = Brainiac.emit(:notify,
                          event: event.to_sym, channel: channel, target: target,
                          message: message, agent: agent, **)

  if results.any?
    LOG.info "[Notify] #{event} delivered via #{channel} to #{target}"
    true
  else
    LOG.warn "[Notify] No handler responded for channel '#{channel}' (is the plugin installed?)"
    false
  end
rescue StandardError => e
  LOG.error "[Notify] Failed to send '#{event}': #{e.message}"
  false
end

#send_restart_notification(message) ⇒ Object

Send a notification about brainiac restart/startup.



23
24
25
26
27
# File 'lib/brainiac/restart.rb', line 23

def send_restart_notification(message)
  notify_restart(message)
rescue StandardError => e
  LOG.warn "[Brainiac] Failed to send restart 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



281
282
283
# File 'lib/brainiac/helpers.rb', line 281

def slugify(title, max_length: 40)
  title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
end

#start_brainiac_restart_monitorObject



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

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?
        execute_restart
      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



636
637
638
639
640
641
642
643
644
645
# File 'lib/brainiac/cron.rb', line 636

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

#toggle_cron_job(id, enabled) ⇒ Object

Enable/disable a cron job



254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/brainiac/cron.rb', line 254

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



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

def touch_comment_cooldown(card_key)
  LAST_COMMENT_TIMES[card_key] = Time.now
end

#touch_deploy_cooldown(env_key) ⇒ Object



252
253
254
# File 'lib/brainiac/sessions.rb', line 252

def touch_deploy_cooldown(env_key)
  LAST_DEPLOY_TIMES[env_key] = Time.now
end

#trust_version_manager(path, chdir:) ⇒ Object

Trust the version manager config in a directory (supports mise and asdf)



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

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

#uninstall_plugin(name) ⇒ Object

Uninstall a plugin gem and remove from plugins.json



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

def uninstall_plugin(name)
  gem_name = "brainiac-#{name}"

  unless installed_plugins.include?(name)
    puts "Plugin '#{name}' is not installed."
    return false
  end

  config = load_plugins_config
  config["plugins"].reject! { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name }
  save_plugins_config(config)

  puts "Removed plugin '#{name}' from Brainiac."
  puts "  The gem #{gem_name} is still installed system-wide."
  puts "  To fully remove: gem uninstall #{gem_name}"
  puts "  Restart the server to apply: brainiac restart"
  true
end

#update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil) ⇒ Object

Update a cron job's schedule, notification target, and/or forum title



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

def update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil)
  return { error: "No updates provided" } if schedule.nil? && notify_target.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[:notify_target] = notify_target if notify_target
    job[:notify_channel] = notify_channel if notify_channel
    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)



507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
# File 'lib/brainiac/cron.rb', line 507

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

#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

#work_item_merged?(card_number) ⇒ Boolean

Returns:

  • (Boolean)


302
303
304
305
306
307
# File 'lib/brainiac/helpers.rb', line 302

def work_item_merged?(card_number)
  MERGED_CARDS_MUTEX.synchronize do
    ts = MERGED_CARDS[card_number.to_s]
    ts && (Time.now - ts < 600)
  end
end

#write_agent_prompt_file(prompt, log_name, timestamp) ⇒ Object

Write agent prompt to a temp file, return path.



460
461
462
463
464
465
466
# File 'lib/brainiac/helpers.rb', line 460

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.



582
583
584
585
586
587
588
# File 'lib/brainiac/cron.rb', line 582

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