Top Level Namespace

Defined Under Namespace

Modules: Brainiac Classes: CardIndex, CommentContext

Constant Summary collapse

PROMPT_CARD_ASSIGNED =

Top-level prompt constants — handler files reference these directly

Brainiac::Plugins::Fizzy::Prompts::CARD_ASSIGNED
PROMPT_FOLLOWUP_WORKTREE =
Brainiac::Plugins::Fizzy::Prompts::FOLLOWUP_WORKTREE
PROMPT_FOLLOWUP_NO_WORKTREE =
Brainiac::Plugins::Fizzy::Prompts::FOLLOWUP_NO_WORKTREE
PROMPT_MENTION =
Brainiac::Plugins::Fizzy::Prompts::MENTION
PROMPT_CROSS_AGENT_REVIEW =
Brainiac::Plugins::Fizzy::Prompts::CROSS_AGENT_REVIEW
FIZZY_CONFIG =

Config constants — handler files reference these as top-level constants. These are evaluated after Config.load! has been called (during plugin register). Use a delegating object so it always reflects current config state.

Class.new do
  def fetch(key, default = nil) = Brainiac::Plugins::Fizzy::Config.current.fetch(key, default)
  def [](key) = Brainiac::Plugins::Fizzy::Config.current[key]
  def dig(*keys) = Brainiac::Plugins::Fizzy::Config.current.dig(*keys)
end.new
AUTHORIZED_USER_IDS =
Class.new do
  def include?(id) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.include?(id)
  def map(&) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.map(&)
  def any?(&) = Brainiac::Plugins::Fizzy::Config.authorized_user_ids.any?(&)
end.new
CARD_INDEX =

--- Create singleton instance ---

CardIndex.new(
  index_file: File.join(BRAINIAC_DIR, "card_index.json"),
  titles_dir: File.join(BRAINIAC_DIR, "card_titles")
)
DEPLOYMENTS_CONFIG_FILE =

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

File.join(BRAINIAC_DIR, "deployments.json")
DEPLOYMENT_STATE_FILE =
File.join(BRAINIAC_DIR, "deployment_state.json")
DEPLOYMENTS_CONFIG =
load_deployments_config
DEPLOYMENT_STATE =
load_deployment_state
DEPLOY_LOGS_DIR =
File.join(BRAINIAC_DIR, "deploy_logs")

Instance Method Summary collapse

Instance Method Details



32
33
34
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 32

def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
  Brainiac::Plugins::Fizzy::Helpers.append_fizzy_comment_footer(card_number, project_config: project_config, agent_name: agent_name)
end

#authorize_human_comment(eventable, card_internal_id, creator_name, plain_text) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 142

def authorize_human_comment(eventable, card_internal_id, creator_name, plain_text)
  creator_id = eventable.dig("creator", "id")

  unless AUTHORIZED_USER_IDS.include?(creator_id)
    notify_unauthorized("comment_created", creator_name, "card #{card_internal_id}")
    return [200, { status: "ignored", reason: "unauthorized" }.to_json]
  end

  record_human_comment(card_internal_id)

  cancel_keywords = %w[cancel stop halt abort kill ]
  return handle_cancel_command(eventable, card_internal_id) if cancel_keywords.include?(plain_text.strip.downcase)

  nil
end

#authorized?(payload) ⇒ Boolean

Returns:

  • (Boolean)


83
84
85
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 83

def authorized?(payload)
  Brainiac::Plugins::Fizzy::Config.authorized?(payload)
end

#auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:) ⇒ Object

Auto-deploy after agent session when [deploy] tag was present. deploy_intent is either a specific env key (e.g. "dev04"), :auto (auto-detect), or nil (no deploy).



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

def auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:)
  state = load_deployment_state
  config = DEPLOYMENTS_CONFIG["environments"] || {}

  env_key = resolve_deploy_environment(deploy_intent, state, card_number)
  return unless env_key

  unless config.key?(env_key)
    LOG.warn "[Deploy] Auto-deploy skipped — unknown environment: #{env_key}"
    return
  end

  env_owner = config[env_key]["owner"]
  unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
    LOG.info "[Deploy] Auto-deploy skipped #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
    return
  end

  deploy_script = File.join(worktree_path, "scripts", "deploy.sh")
  unless File.exist?(deploy_script)
    LOG.warn "[Deploy] Auto-deploy skipped — no deploy script at #{deploy_script}"
    return
  end

  LOG.info "[Deploy] Auto-deploying card ##{card_number} to #{env_key} (triggered by [deploy] tag)"
  mark_deploying(env_key, worktree_path: worktree_path)

  deploy_env = {}
  aws_profile = config.dig(env_key, "aws_profile")
  deploy_env["AWS_PROFILE"] = aws_profile if aws_profile

  run_deploy(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name)
end

#available_environments(project: nil) ⇒ Object

Return environments with status "available", optionally filtered by project.



227
228
229
230
231
232
233
234
235
236
237
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 227

def available_environments(project: nil)
  config = DEPLOYMENTS_CONFIG["environments"] || {}
  state = load_deployment_state

  config.select do |env_key, env_config|
    next false if project && env_config["project"] != project

    info = state[env_key]
    info.nil? || info["status"] == "available"
  end.keys
end

#board_column_id(board_key, column_name) ⇒ Object



71
72
73
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 71

def board_column_id(board_key, column_name)
  Brainiac::Plugins::Fizzy::Config.board_column_id(board_key, column_name)
end

#board_config(board_key) ⇒ Object



63
64
65
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 63

def board_config(board_key)
  Brainiac::Plugins::Fizzy::Config.board_config(board_key)
end

#board_key_for_id(board_id) ⇒ Object



79
80
81
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 79

def board_key_for_id(board_id)
  Brainiac::Plugins::Fizzy::Config.board_key_for_id(board_id)
end

#board_key_for_project(project_config) ⇒ Object



75
76
77
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 75

def board_key_for_project(project_config)
  Brainiac::Plugins::Fizzy::Config.board_key_for_project(project_config)
end

#board_webhook_secret(board_key) ⇒ Object



67
68
69
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 67

def board_webhook_secret(board_key)
  Brainiac::Plugins::Fizzy::Config.board_webhook_secret(board_key)
end

#build_comment_context(eventable:, plain_text:, tags:, card_internal_id:, card_info:, comment_id:, creator_name:, creator_is_agent:, mentioned_agent:, agent_name:, is_cross_agent_mention:, project_config:, project_key:) ⇒ Object

--- Early-exit helpers ---



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 87

def build_comment_context(eventable:, plain_text:, tags:, card_internal_id:, card_info:, comment_id:, creator_name:,
                          creator_is_agent:, mentioned_agent:, agent_name:, is_cross_agent_mention:,
                          project_config:, project_key:)
  deploy_intent = tags[:deploy_intent]
  LOG.info "[Deploy] Detected [deploy#{":#{deploy_intent}" unless deploy_intent == :auto}] tag on card #{card_internal_id}" if deploy_intent

  card_tags = eventable.dig("card", "tags") || []
  clean_text = tags[:clean_text]

  CommentContext.new(
    eventable: eventable, plain_text: clean_text, card_internal_id: card_internal_id,
    card_info: card_info, comment_id: comment_id, creator_name: creator_name,
    creator_is_agent: creator_is_agent, mentioned_agent: mentioned_agent,
    agent_name: agent_name, is_cross_agent_mention: is_cross_agent_mention,
    project_config: project_config, project_key: project_key,
    model: detect_model(project_config, text: plain_text),
    effort: detect_effort(project_config, tags: card_tags, text: plain_text),
    deploy_intent: deploy_intent,
    cli_provider_override: detect_cli_provider(text: plain_text, tags: card_tags),
    card_tags: card_tags,
    worktree_override: resolve_worktree_override(tags, project_config),
    comment_vars: {
      "COMMENT_CREATOR" => creator_name || "Unknown",
      "COMMENT_ID" => comment_id.to_s,
      "COMMENT_BODY" => clean_text
    }
  )
end

#build_cross_agent_prompt(ctx, card_number, card_assigned_agent, worktree_path, branch) ⇒ Object



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 381

def build_cross_agent_prompt(ctx, card_number, card_assigned_agent, worktree_path, branch)
  card_context = prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"], agent_name: ctx.agent_name)

  render_prompt(PROMPT_CROSS_AGENT_REVIEW,
                ctx.comment_vars.merge(
                  "CARD_NUMBER" => card_number || "N/A",
                  "CARD_INTERNAL_ID" => ctx.card_internal_id,
                  "CARD_ID" => card_number || ctx.card_internal_id,
                  "CARD_AGENT" => card_assigned_agent,
                  "WORKTREE_PATH" => worktree_path,
                  "BRANCH" => branch
                ),
                brain_context: build_brain_context(
                  agent_name: ctx.agent_name, card_number: card_number,
                  project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
                ),
                card_context: card_context,
                agent_name: ctx.agent_name)
end

#build_followup_prompt(ctx, card_number, card_tags, work_dir) ⇒ Object



700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 700

def build_followup_prompt(ctx, card_number, card_tags, work_dir)
  planning_info = detect_planning_mode(text: ctx.plain_text, tags: card_tags,
                                       card_internal_id: ctx.card_internal_id, card_number: card_number)

  if planning_info
    build_planning_followup_prompt(ctx, card_number, planning_info[:card_id], work_dir)
  elsif work_dir != ctx.project_config["repo_path"]
    render_prompt(PROMPT_FOLLOWUP_WORKTREE,
                  ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
                  brain_context: build_brain_context(
                    agent_name: ctx.agent_name, card_number: card_number,
                    project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
                  ),
                  card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
                  agent_name: ctx.agent_name)
  else
    render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
                  ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => ctx.card_internal_id),
                  brain_context: build_brain_context(
                    agent_name: ctx.agent_name, project_key: ctx.project_key,
                    comment_body: ctx.plain_text, source: :fizzy
                  ),
                  card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
                                                                   agent_name: ctx.agent_name),
                  agent_name: ctx.agent_name)
  end
end

#build_mention_prompt(ctx, card_number, card_title, branch, worktree_path) ⇒ Object



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 664

def build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)
  planning_info = detect_planning_mode(text: ctx.plain_text, tags: ctx.card_tags,
                                       card_internal_id: ctx.card_internal_id, card_number: card_number)

  if planning_info
    render_planning_prompt(PROMPT_MENTION,
                           ctx.comment_vars.merge(
                             "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => planning_info[:card_id],
                             "CARD_NUMBER" => card_number || "N/A",
                             "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
                             "BRANCH" => branch
                           ),
                           brain_context: build_brain_context(
                             agent_name: ctx.agent_name, card_title: card_title, card_number: card_number,
                             project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
                           ),
                           card_context: prefetch_card_context(card_number, repo_path: worktree_path,
                                                                            agent_name: ctx.agent_name),
                           agent_name: ctx.agent_name)
  else
    card_id = card_number || ctx.card_internal_id
    render_prompt(PROMPT_MENTION,
                  ctx.comment_vars.merge(
                    "CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id,
                    "CARD_NUMBER" => card_number || "N/A", "CARD_NUMBER_TEXT" => card_number || ctx.card_internal_id
                  ),
                  brain_context: build_brain_context(
                    agent_name: ctx.agent_name, card_title: card_title, card_number: card_number,
                    project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
                  ),
                  card_context: prefetch_card_context(card_number, repo_path: worktree_path,
                                                                   agent_name: ctx.agent_name),
                  agent_name: ctx.agent_name)
  end
end

#build_planning_followup_prompt(ctx, card_number, card_id, work_dir) ⇒ Object



728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 728

def build_planning_followup_prompt(ctx, card_number, card_id, work_dir)
  if work_dir == ctx.project_config["repo_path"]
    render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
                           ctx.comment_vars.merge("CARD_INTERNAL_ID" => ctx.card_internal_id, "CARD_ID" => card_id),
                           brain_context: build_brain_context(
                             agent_name: ctx.agent_name, project_key: ctx.project_key,
                             comment_body: ctx.plain_text, source: :fizzy
                           ),
                           card_context: prefetch_card_context(card_number, repo_path: ctx.project_config["repo_path"],
                                                                            agent_name: ctx.agent_name),
                           agent_name: ctx.agent_name)
  else
    render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
                           ctx.comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
                           brain_context: build_brain_context(
                             agent_name: ctx.agent_name, card_number: card_number,
                             project_key: ctx.project_key, comment_body: ctx.plain_text, source: :fizzy
                           ),
                           card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: ctx.agent_name),
                           agent_name: ctx.agent_name)
  end
end

#card_announcement?(text) ⇒ Boolean

--- Shared helpers ---

Returns:

  • (Boolean)


526
527
528
529
530
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 526

def card_announcement?(text)
  text.match?(/created\s+card\s+#?\d+/i) ||
    text.match?(/assigned\s+.*card\s+#?\d+/i) ||
    text.match?(/card\s+#?\d+.*assigned/i)
end

#check_mention_gates(mentioned_agent, plain_text) ⇒ Object



116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 116

def check_mention_gates(mentioned_agent, plain_text)
  mentioned_user_ids = detect_mentioned_user_ids(plain_text)
  if mentioned_user_ids.any? { |id| human_mentioned?(id) }
    LOG.info "[Fizzy] Human @mentioned in comment, skipping agent dispatch"
    return [200, { status: "ignored", reason: "human mentioned" }.to_json]
  end

  if mentioned_agent && !local_agent_names.include?(mentioned_agent)
    LOG.info "[Fizzy] Ignoring mention of non-local agent #{mentioned_agent}"
    return [200, { status: "ignored", reason: "non-local agent mentioned" }.to_json]
  end

  nil
end

#clear_deployment_for_card(card_number) ⇒ Object

Clear all environments occupied by a given card number (called on PR merge).



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 206

def clear_deployment_for_card(card_number)
  state = load_deployment_state
  cleared = []

  state.each do |env_key, info|
    next unless info["card_number"] == card_number && info["status"] == "occupied"

    state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => card_number }
    cleared << env_key
  end

  if cleared.any?
    save_deployment_state(state)
    DEPLOYMENT_STATE.replace(state)
    LOG.info "[Deploy] Cleared #{cleared.join(", ")} — card ##{card_number} merged"
  end

  cleared
end

#clone_branch_for_deploy(eventable, card_internal_id, card_info) ⇒ Object

Clone a remote branch locally for deploy when the worktree doesn't exist on this machine. Returns { worktree:, card_number: } on success, nil on failure.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/brainiac/plugins/fizzy/handlers/deploy.rb', line 101

def clone_branch_for_deploy(eventable, card_internal_id, card_info)
  card_tags = eventable.dig("card", "tags") || []
  project_result = identify_project_by_tags(card_tags)
  unless project_result
    LOG.warn "[Deploy] Cannot identify project for card #{card_internal_id}"
    return nil
  end
  project_key, project_config = project_result
  repo_path = project_config["repo_path"]

  card_number = card_info&.dig("number")
  card_number ||= resolve_card_number(card_internal_id, repo_path: repo_path)
  unless card_number
    LOG.warn "[Deploy] Cannot resolve card number for #{card_internal_id}"
    return nil
  end

  debounced_repo_fetch(repo_path)
  branches = run_cmd("git", "branch", "-r", "--list", "origin/fizzy-#{card_number}-*", chdir: repo_path).strip
  branch = branches.lines.map(&:strip).first&.sub("origin/", "")
  unless branch
    LOG.warn "[Deploy] No remote branch matching fizzy-#{card_number}-* found"
    return nil
  end

  worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")

  unless File.directory?(worktree_path)
    branch_exists_locally = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
    if branch_exists_locally
      run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
    else
      run_cmd("git", "worktree", "add", "--track", "-b", branch, worktree_path, "origin/#{branch}", chdir: repo_path)
    end

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

  # Update card map
  map = load_card_map
  map[card_internal_id] ||= {}
  map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key)
  save_card_map(map)

  LOG.info "[Deploy] Cloned branch #{branch} into worktree #{worktree_path} for card ##{card_number}"
  { worktree: worktree_path, card_number: card_number }
rescue StandardError => e
  LOG.error "[Deploy] Failed to clone branch for card #{card_internal_id}: #{e.message}"
  nil
end

#create_review_worktree(repo_path, review_branch, review_worktree_path, card_info) ⇒ Object



624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 624

def create_review_worktree(repo_path, review_branch, review_worktree_path, card_info)
  card_branch = card_info&.dig("branch")
  branch_exists = card_branch && system("git", "rev-parse", "--verify", card_branch,
                                        chdir: repo_path, out: File::NULL, err: File::NULL)
  base_ref = branch_exists ? card_branch : "origin/#{get_default_branch(repo_path)}"

  if system("git", "rev-parse", "--verify", review_branch, chdir: repo_path, out: File::NULL, err: File::NULL)
    run_cmd("git", "branch", "-D", review_branch, chdir: repo_path)
  end

  run_cmd("git", "worktree", "add", "-b", review_branch, review_worktree_path, base_ref, chdir: repo_path)
  trust_version_manager(review_worktree_path, chdir: review_worktree_path)
  apply_worktree_includes(repo_path, review_worktree_path)
  run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => review_worktree_path })
  LOG.info "Created cross-agent review worktree at #{review_worktree_path} (base: #{base_ref})"
end

#cross_agent_announcement?(ctx) ⇒ Boolean

Returns:

  • (Boolean)


532
533
534
535
536
537
538
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 532

def cross_agent_announcement?(ctx)
  return false unless ctx.creator_is_agent && card_announcement?(ctx.plain_text)

  LOG.info "Ignoring cross-agent mention from #{ctx.comment_vars["COMMENT_CREATOR"]} " \
           "on card #{ctx.card_internal_id} — card creation/assignment (handled by webhook)"
  true
end

#default_fizzy_envObject



20
21
22
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 20

def default_fizzy_env
  Brainiac::Plugins::Fizzy::Helpers.default_fizzy_env
end

#deploy_to_environment(env_key, worktree_path:, deployed_by: nil) ⇒ Object

Mark an environment as occupied. Resolves card info from the card map using the worktree path.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 60

def deploy_to_environment(env_key, worktree_path:, deployed_by: nil)
  config = DEPLOYMENTS_CONFIG["environments"] || {}
  unless config.key?(env_key)
    LOG.warn "[Deploy] Unknown environment: #{env_key}"
    return { error: "Unknown environment: #{env_key}" }
  end

  state = load_deployment_state
  entry = { "status" => "occupied", "deployed_at" => Time.now.iso8601, "deployed_by" => deployed_by,
            "last_deploy_status" => "success", "last_deploy_at" => Time.now.iso8601 }

  # Resolve card info from card map by matching worktree path
  map = load_card_map
  card_entry = map.values.find { |info| info["worktree"] == worktree_path }
  if card_entry
    entry["card_number"] = card_entry["number"]
    entry["card_title"]  = card_entry["title"]
    entry["branch"]      = card_entry["branch"]
    pr = (card_entry["prs"] || []).last
    if pr
      entry["pr_number"] = pr["number"]
      entry["pr_url"]    = pr["url"]
    end
    # Store card tags for URL resolution (e.g. ops-web-app → ops URL)
    if defined?(CARD_INDEX)
      card_idx = CARD_INDEX[card_entry["number"].to_s]
      entry["card_tags"] = card_idx["tags"] if card_idx && card_idx["tags"]
    end
  else
    # No card map match — record branch from git
    branch = `git -C #{Shellwords.escape(worktree_path)} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
    entry["branch"] = branch unless branch.empty?
  end

  commit = `git -C #{Shellwords.escape(worktree_path)} rev-parse --short HEAD 2>/dev/null`.strip
  entry["commit"] = commit unless commit.empty?

  state[env_key] = entry
  save_deployment_state(state)
  DEPLOYMENT_STATE.replace(state)
  LOG.info "[Deploy] #{env_key} marked occupied — card ##{entry["card_number"] || "none"}, branch: #{entry["branch"]}"
  entry
end

#deployment_statusObject

Full deployment status for API / waybar.



240
241
242
243
244
245
246
247
248
249
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 240

def deployment_status
  config = DEPLOYMENTS_CONFIG["environments"] || {}
  state = load_deployment_state

  config.map do |env_key, env_config|
    info = state[env_key] || { "status" => "available" }
    url = resolve_deployment_url(env_config, info["card_tags"])
    { "env" => env_key, "label" => env_config["label"], "url" => url, "project" => env_config["project"] }.merge(info)
  end
end

#detect_mentioned_user_ids(text) ⇒ Object

Extracts user IDs from Fizzy mention markup in plain text. Fizzy represents mentions as @Display Name in plain text.



93
94
95
96
97
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 93

def detect_mentioned_user_ids(text)
  return [] unless text

  text.scan(/@\[[^\]]*\]\(([^)]+)\)/).flatten
end

#detect_planning_mode(text:, tags:, card_internal_id:, card_number:) ⇒ Object



44
45
46
47
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 44

def detect_planning_mode(text:, tags:, card_internal_id:, card_number:)
  Brainiac::Plugins::Fizzy::Helpers.detect_planning_mode(text: text, tags: tags,
                                                         card_internal_id: card_internal_id, card_number: card_number)
end

#dispatch_assigned_card(card_number:, card_internal_id:, title:, tags:, branch:, worktree_path:, project_config:, project_key:, agent_name:, model:, effort:, cli_provider_override:) ⇒ Object



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

def dispatch_assigned_card(card_number:, card_internal_id:, title:, tags:, branch:, worktree_path:,
                           project_config:, project_key:, agent_name:, model:, effort:, cli_provider_override:)
  card_context = prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name)
  planning_info = detect_planning_mode(text: title, tags: tags, card_internal_id: card_internal_id, card_number: card_number)

  template_vars = {
    "CARD_NUMBER" => card_number, "CARD_TITLE" => title,
    "BRANCH" => branch, "COMMENT_CREATOR" => agent_name
  }
  brain_ctx = build_brain_context(
    agent_name: agent_name, card_title: title,
    card_number: card_number, project_key: project_key, source: :fizzy
  )

  prompt = if planning_info
             LOG.info "[Planning] Planning mode active for card ##{card_number}"
             template_vars["CARD_ID"] = planning_info[:card_id]
             render_planning_prompt(PROMPT_CARD_ASSIGNED, template_vars,
                                    brain_context: brain_ctx, card_context: card_context, agent_name: agent_name)
           else
             template_vars["CARD_ID"] = card_number
             render_prompt(PROMPT_CARD_ASSIGNED, template_vars,
                           brain_context: brain_ctx, card_context: card_context, agent_name: agent_name)
           end

  card_key = "card-#{card_number}"
  pid, log_file = run_agent(prompt,
                            project_config: project_config, chdir: worktree_path,
                            log_name: "assigned-#{card_number}", model: model, effort: effort,
                            agent_name: agent_name, card_number: card_number, source: :fizzy,
                            source_context: { card_number: card_number },
                            cli_provider: cli_provider_override)
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)

  Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: agent_name) }

  [200, { status: "processed", card: card_number, branch: branch, project: project_key, agent: agent_name }.to_json]
end

#dispatch_cross_agent_review(ctx, card_key:, card_number:, card_assigned_agent:) ⇒ Object



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 363

def dispatch_cross_agent_review(ctx, card_key:, card_number:, card_assigned_agent:)
  review_worktree_path, review_branch = setup_cross_agent_worktree(ctx, card_number)
  prompt = build_cross_agent_prompt(ctx, card_number, card_assigned_agent, review_worktree_path, review_branch)

  pid, log_file = run_agent(prompt,
                            project_config: ctx.project_config, chdir: review_worktree_path,
                            log_name: "review-#{ctx.agent_name.downcase}-#{card_number || ctx.card_internal_id}",
                            model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
                            card_number: card_number, comment_id: ctx.comment_id,
                            source: :fizzy, source_context: { card_number: card_number },
                            cli_provider: ctx.cli_provider_override)
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)

  [200, { status: "cross_agent_review", agent: ctx.agent_name, card_agent: card_assigned_agent,
          card: card_number, card_internal_id: ctx.card_internal_id,
          project: ctx.project_key, worktree: review_worktree_path }.to_json]
end

#dispatch_followup_comment(ctx, card_key:, card_number:, work_dir:) ⇒ Object

Dispatch a follow-up comment to the agent.



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 486

def dispatch_followup_comment(ctx, card_key:, card_number:, work_dir:)
  card_tags = ctx.eventable.dig("card", "tags") || []
  effort = detect_effort(ctx.project_config, tags: card_tags, text: ctx.plain_text)

  is_worktree = work_dir != ctx.project_config["repo_path"]
  should_resume = is_worktree && resume_viable?(
    project_config: ctx.project_config, cli_provider: ctx.cli_provider_override,
    agent_name: ctx.agent_name, chdir: work_dir
  )

  prompt = if should_resume
             LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || ctx.card_internal_id}"
             render_resume_prompt(
               comment_body: ctx.plain_text, comment_creator: ctx.comment_vars["COMMENT_CREATOR"],
               comment_id: ctx.comment_id, card_number: card_number, agent_name: ctx.agent_name
             )
           else
             build_followup_prompt(ctx, card_number, card_tags, work_dir)
           end

  pid, log_file = run_agent(prompt,
                            project_config: ctx.project_config, chdir: work_dir,
                            log_name: "followup-#{card_number || ctx.card_internal_id}",
                            model: ctx.model, effort: effort, agent_name: ctx.agent_name,
                            card_number: card_number, comment_id: ctx.comment_id,
                            source: :fizzy, cli_provider: ctx.cli_provider_override, resume: should_resume,
                            source_context: {
                              card_number: card_number, card_internal_id: ctx.card_internal_id,
                              deploy_intent: ctx.deploy_intent
                            })
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)

  Thread.new { move_card_to_column(card_number, "right_now", project_config: ctx.project_config, agent_name: ctx.agent_name) }

  { status: "follow_up", card: card_number, card_internal_id: ctx.card_internal_id,
    worktree: work_dir, project: ctx.project_key }
end

#dispatch_new_mention(ctx, card_key:, card_number:, card_title:, branch:, worktree_path:) ⇒ Object



469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 469

def dispatch_new_mention(ctx, card_key:, card_number:, card_title:, branch:, worktree_path:)
  prompt = build_mention_prompt(ctx, card_number, card_title, branch, worktree_path)

  pid, log_file = run_agent(prompt,
                            project_config: ctx.project_config, chdir: worktree_path,
                            log_name: "mention-#{card_number || ctx.card_internal_id}",
                            model: ctx.model, effort: ctx.effort, agent_name: ctx.agent_name,
                            card_number: card_number, comment_id: ctx.comment_id,
                            source: :fizzy, cli_provider: ctx.cli_provider_override,
                            source_context: { card_number: card_number })
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: ctx.agent_name)

  [200, { status: "responded", card_internal_id: ctx.card_internal_id, card_number: card_number,
          branch: branch, worktree: worktree_path, project: ctx.project_key }.to_json]
end

#dispatch_webhook_action(action, payload) ⇒ Object

Webhook dispatch — routes incoming actions to the appropriate handler. Called from within the Sinatra route block, so it must be a top-level method.



101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 101

def dispatch_webhook_action(action, payload)
  case action
  when "card_assigned"
    handle_card_assigned(payload)
  when "comment_created"
    handle_comment(payload)
  when "card_published", "card_triaged"
    Brainiac::Plugins::Fizzy.handle_publish_or_triage(action, payload)
  else
    LOG.info "[Fizzy] Ignoring unknown action: #{action}"
    [200, { status: "ignored", action: action }.to_json]
  end
end

#ensure_fizzy_yaml!(chdir, project_config) ⇒ Object



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

def ensure_fizzy_yaml!(chdir, project_config)
  Brainiac::Plugins::Fizzy::Helpers.ensure_fizzy_yaml!(chdir, project_config)
end

#extract_creator_info(payload, eventable) ⇒ Object



131
132
133
134
135
136
137
138
139
140
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 131

def extract_creator_info(payload, eventable)
  creator_name = eventable.dig("creator", "name")
  creator_is_agent = comment_from_agent?(creator_name)
  creator_is_agent ||= comment_from_agent?(payload.dig("creator", "name"))

  source = eventable["source"] || payload["source"]
  is_api_sourced = source && source != "web"

  [creator_name, creator_is_agent, is_api_sourced]
end

#find_and_save_worktree(card_internal_id, card_number, project_config) ⇒ Object



564
565
566
567
568
569
570
571
572
573
574
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 564

def find_and_save_worktree(card_internal_id, card_number, project_config)
  found = find_worktree_for_card(card_number, repo_path: project_config["repo_path"])
  return nil unless found

  map = load_work_item_map
  map[card_internal_id] ||= {}
  map[card_internal_id].merge!("worktree" => found[:worktree], "branch" => found[:branch])
  save_work_item_map(map)
  LOG.info "Found worktree by card number scan: #{found[:worktree]}"
  found[:worktree]
end

#fizzy_env_for(agent_name) ⇒ Object



16
17
18
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 16

def fizzy_env_for(agent_name)
  Brainiac::Plugins::Fizzy::Helpers.fizzy_env_for(agent_name)
end

#fizzy_token_for(agent_name) ⇒ Object

Top-level convenience methods that delegate to Fizzy plugin modules.

The handler files (assignment.rb, comments.rb, etc.) were originally top-level functions in brainiac core. They call helpers like fizzy_env_for, identify_project_by_tags, etc. as top-level methods.

These delegators make them available at top level so the handler files work without modification.



12
13
14
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 12

def fizzy_token_for(agent_name)
  Brainiac::Plugins::Fizzy::Helpers.fizzy_token_for(agent_name)
end

#handle_cancel_command(eventable, card_internal_id) ⇒ Object

--- Comment sub-handlers ---



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 208

def handle_cancel_command(eventable, card_internal_id)
  killed = 0
  card_number_for_cancel = load_work_item_map.dig(card_internal_id, "number")
  prefixes = ["card-#{card_internal_id}"]
  prefixes << "card-#{card_number_for_cancel}" if card_number_for_cancel

  ACTIVE_SESSIONS_MUTEX.synchronize do
    ACTIVE_SESSIONS.keys.select { |k| prefixes.any? { |p| k == p || k.start_with?("#{p}-") } }.each do |key|
      info = ACTIVE_SESSIONS[key]
      next unless info

      begin
        Process.kill("KILL", info[:pid])
        LOG.info "[Fizzy] Cancelled session #{key} (PID: #{info[:pid]})"
      rescue Errno::ESRCH, Errno::EPERM => e
        LOG.warn "[Fizzy] Could not kill #{key}: #{e.message}"
      end
      archive_session(key, info)
      ACTIVE_SESSIONS.delete(key)
      killed += 1
    end
  end

  comment_id_for_cancel = eventable["id"]
  card_info_for_cancel = load_work_item_map[card_internal_id]
  if card_info_for_cancel && card_number_for_cancel && comment_id_for_cancel
    repo = (card_info_for_cancel["project"] && PROJECTS.dig(card_info_for_cancel["project"], "repo_path")) ||
           DEFAULT_PROJECT["repo_path"]
    Thread.new do
      run_cmd("fizzy", "reaction", "create", "--card", card_number_for_cancel.to_s,
              "--comment", comment_id_for_cancel.to_s, "--content", "🛑",
              chdir: repo, env: default_fizzy_env)
    rescue StandardError => e
      LOG.warn "[Fizzy] Could not add 🛑 reaction: #{e.message}"
    end
  end

  LOG.info "[Fizzy] Cancel command received for card #{card_number_for_cancel || card_internal_id}: killed #{killed} session(s)"
  [200, { status: "cancelled", card: card_number_for_cancel || card_internal_id, sessions_killed: killed }.to_json]
end

#handle_card_assigned(payload) ⇒ Object

Fizzy card assignment handler.

When a card is assigned to a local agent, creates a worktree, builds the prompt, and dispatches the agent to begin work.



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/brainiac/plugins/fizzy/handlers/assignment.rb', line 8

def handle_card_assigned(payload)
  eventable = payload["eventable"] || {}
  assignees = eventable["assignees"] || []

  local_names = local_agent_names
  assigned_agent = assignees.map { |a| a["name"] }.find { |name| local_names.include?(name) }

  assignee_names = assignees.map { |a| a["name"] }.join(", ")
  LOG.info "[Fizzy] Card assigned to: [#{assignee_names}], local agents: [#{local_names.join(", ")}]"

  return ignore_assignment("wrong assignee", assignee_names, local_names) unless assigned_agent
  return ignore_unauthorized(payload, eventable) unless authorized?(payload)

  card_number = eventable["number"]
  card_internal_id = eventable["id"]
  title = eventable["title"] || "untitled"
  tags = eventable["tags"] || []

  project_result = identify_project_by_tags(tags)
  unless project_result
    tag_names = tags.map { |t| t.is_a?(Hash) ? t["name"] : t }.join(", ")
    LOG.warn "No project found for card ##{card_number} with tags: #{tag_names}"
    return [200, { status: "ignored", reason: "no matching project" }.to_json]
  end

  project_key, project_config = project_result
  repo_path = project_config["repo_path"]
  branch = "fizzy-#{card_number}-#{slugify(title)}"

  card_key = "card-#{card_number}"
  if session_active?(card_key)
    LOG.info "Skipping card ##{card_number} — agent session already active"
    return [200, { status: "ignored", reason: "session already active" }.to_json]
  end

  LOG.info "Card ##{card_number} assigned to #{assigned_agent} for project '#{project_key}', " \
           "creating worktree: #{branch} (model: #{detect_model(project_config, tags: tags) || "default"})"

  react_to_assignment(card_number, repo_path, assigned_agent)
  worktree_path = setup_assigned_worktree(repo_path, branch, card_internal_id, card_number, project_key, assigned_agent)

  dispatch_assigned_card(
    card_number: card_number, card_internal_id: card_internal_id, title: title, tags: tags,
    branch: branch, worktree_path: worktree_path, project_config: project_config, project_key: project_key,
    agent_name: assigned_agent, model: detect_model(project_config, tags: tags),
    effort: detect_effort(project_config, tags: tags), cli_provider_override: detect_cli_provider(tags: tags)
  )
end

#handle_card_published(payload) ⇒ Object

Card duplicate detection (card_published / card_triaged).

When a new card is created, checks for similar existing cards using trigram and semantic similarity. Posts a warning comment if duplicates found.



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/brainiac/plugins/fizzy/handlers/dedup.rb', line 8

def handle_card_published(payload)
  eventable = payload["eventable"] || {}
  card_number = eventable["number"]
  title = eventable["title"] || ""
  creator_name = payload.dig("creator", "name")
  creator_id = payload.dig("creator", "id")
  tags = eventable["tags"] || []

  # Creator-based routing: only the machine whose local human created the card
  # handles dedup. Requires `"local": true` on the human in fizzy.json authorized_users.
  local_humans = FIZZY_CONFIG.fetch("authorized_users", []).select { |u| u["human"] && u["local"] }
  if local_humans.empty?
    LOG.info "[CardIndex] No local humans configured — skipping dedup, indexing only"
    return index_card_only(card_number, title, creator_name, creator_id, tags)
  end

  unless local_humans.any? { |u| u["id"] == creator_id }
    LOG.info "[CardIndex] Ignoring card ##{card_number} — creator '#{creator_name}' is not a local human"
    return index_card_only(card_number, title, creator_name, creator_id, tags)
  end

  # Check for duplicates before indexing
  similar = CARD_INDEX.find_similar_cards(title, exclude_number: card_number, tags: tags) if card_number
  index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: true)

  if similar&.any?
    post_duplicate_warning(card_number, title, tags, similar)
    [200, { status: "duplicate_detected", card: card_number,
            similar: similar.map { |s| { number: s[:number], score: s[:score].round(2) } } }.to_json]
  else
    LOG.info "[CardIndex] Card ##{card_number} '#{title}' indexed, no duplicates found"
    [200, { status: "indexed", card: card_number }.to_json]
  end
end

#handle_comment(payload) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 24

def handle_comment(payload)
  eventable = payload["eventable"] || {}
  plain_text = eventable.dig("body", "plain_text") || ""
  card_internal_id = eventable.dig("card", "id")

  return handle_deploy_comment(eventable, plain_text.strip.downcase, card_internal_id) if plain_text.strip.match?(/\Adev\d+\z/i)

  mentioned_agent = detect_mentioned_agent(plain_text)
  gate_result = check_mention_gates(mentioned_agent, plain_text)
  return gate_result if gate_result

  creator_name, creator_is_agent, is_api_sourced = extract_creator_info(payload, eventable)
  unless creator_is_agent || is_api_sourced
    auth_result = authorize_human_comment(eventable, card_internal_id, creator_name, plain_text)
    return auth_result if auth_result
  end

  agent_result = validate_agent_comment(creator_is_agent, is_api_sourced, creator_name, mentioned_agent, card_internal_id)
  return agent_result if agent_result

  card_info = load_work_item_map[card_internal_id]
  comment_id = eventable["id"]

  return [200, { status: "ignored", reason: "not relevant" }.to_json] unless mentioned_agent || card_info

  project_config, project_key = resolve_fizzy_project(card_info, card_internal_id, eventable)
  return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_config

  tags = parse_inline_tags(plain_text)

  agent_name, is_cross_agent_mention = resolve_comment_agent(
    mentioned_agent: mentioned_agent, card_info: card_info, card_internal_id: card_internal_id,
    eventable: eventable, project_config: project_config, creator_is_agent: creator_is_agent
  )
  return [200, { status: "ignored", reason: "no assigned agent" }.to_json] unless agent_name

  cooldown_key = "card-#{card_info ? (card_info["number"] || card_internal_id) : card_internal_id}-#{agent_name.downcase}"
  if on_comment_cooldown?(cooldown_key)
    LOG.info "Skipping comment on #{cooldown_key} — within #{COMMENT_COOLDOWN}s cooldown"
    return [200, { status: "ignored", reason: "comment cooldown" }.to_json]
  end
  touch_comment_cooldown(cooldown_key)

  ctx = build_comment_context(
    eventable: eventable, plain_text: plain_text, tags: tags, card_internal_id: card_internal_id,
    card_info: card_info, comment_id: comment_id, creator_name: creator_name,
    creator_is_agent: creator_is_agent, mentioned_agent: mentioned_agent,
    agent_name: agent_name, is_cross_agent_mention: is_cross_agent_mention,
    project_config: project_config, project_key: project_key
  )

  # --- Route to appropriate sub-handler ---
  if is_cross_agent_mention
    handle_cross_agent_mention(ctx)
  elsif card_info || ctx.worktree_override
    handle_existing_card_comment(ctx)
  else
    handle_new_mention(ctx)
  end
end

#handle_cross_agent_mention(ctx) ⇒ Object

Handle cross-agent mention (agent tagged on another agent's card)



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 340

def handle_cross_agent_mention(ctx)
  card_assigned_agent = ctx.card_info&.dig("agent")
  return [200, { status: "ignored", reason: "card creation announcement" }.to_json] if cross_agent_announcement?(ctx)

  card_number = ctx.card_info&.dig("number")
  card_number ||= resolve_card_number(ctx.card_internal_id, repo_path: ctx.project_config["repo_path"])
  card_key = "card-#{card_number || ctx.card_internal_id}-#{ctx.agent_name.downcase}"
  if ctx.creator_is_agent && session_active?(card_key)
    return [200, { status: "ignored", reason: "session wait timeout" }.to_json] unless wait_for_session?(card_key)
  elsif session_active?(card_key)
    return [200, { status: "ignored", reason: "session already active" }.to_json]
  end

  LOG.info "Cross-agent mention: #{ctx.agent_name} tagged on #{card_assigned_agent}'s card " \
           "##{card_number || ctx.card_internal_id} (project: #{ctx.project_key})"
  record_agent_dispatch(ctx.card_internal_id) if ctx.creator_is_agent

  react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")

  dispatch_cross_agent_review(ctx, card_key: card_key, card_number: card_number,
                                   card_assigned_agent: card_assigned_agent)
end

#handle_deploy_comment(eventable, env_key, card_internal_id) ⇒ Object

Fizzy deploy comment handler.

When a comment is just "dev02" (or any dev\d+), deploy the card's worktree to that environment. No agent dispatch — reactions only.



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/brainiac/plugins/fizzy/handlers/deploy.rb', line 8

def handle_deploy_comment(eventable, env_key, card_internal_id)
  comment_id = eventable["id"]
  card_info = load_card_map[card_internal_id]

  # Validate environment exists
  deploy_config = DEPLOYMENTS_CONFIG["environments"] || {}
  unless deploy_config.key?(env_key)
    LOG.warn "[Deploy] Unknown environment: #{env_key}"
    return [200, { status: "ignored", reason: "unknown environment" }.to_json]
  end

  # Check environment ownership
  env_owner = deploy_config[env_key]["owner"]
  unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
    LOG.info "[Deploy] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
    return [200, { status: "ignored", reason: env_owner ? "owned by #{env_owner}" : "no owner configured" }.to_json]
  end

  worktree = card_info&.dig("worktree")
  card_number = card_info&.dig("number")

  # If worktree doesn't exist locally, try to clone the branch from origin
  if worktree.nil? || !File.directory?(worktree)
    result = clone_branch_for_deploy(eventable, card_internal_id, card_info)
    unless result
      LOG.warn "[Deploy] Could not resolve or clone branch for card #{card_internal_id}"
      return [200, { status: "ignored", reason: "no worktree and could not clone branch" }.to_json]
    end
    worktree = result[:worktree]
    card_number = result[:card_number]
  end

  deploy_script = File.join(worktree, "scripts", "deploy.sh")
  unless File.exist?(deploy_script)
    LOG.warn "[Deploy] No deploy script at #{deploy_script}"
    return [200, { status: "ignored", reason: "no deploy script" }.to_json]
  end

  LOG.info "[Deploy] Deploying card ##{card_number} worktree to #{env_key}"
  mark_deploying(env_key, worktree_path: worktree)

  Thread.new do
    react_to_deploy(card_number, comment_id, worktree, "🚀")
    run_deploy(env_key, card_number, comment_id, worktree)
  rescue StandardError => e
    LOG.error "[Deploy] Error deploying card ##{card_number} to #{env_key}: #{e.message}"
    react_to_deploy(card_number, comment_id, worktree, "")
  end

  [200, { status: "deploying", card: card_number, env: env_key }.to_json]
end

#handle_existing_card_comment(ctx) ⇒ Object

Handle comment on a card that's already in the card map (or has a worktree override)



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 402

def handle_existing_card_comment(ctx)
  effective_info = ctx.worktree_override ? (ctx.card_info || {}).merge(ctx.worktree_override) : ctx.card_info
  card_number = effective_info["number"]
  worktree = effective_info["worktree"]

  card_number = resolve_and_save_card_number(ctx.card_internal_id, ctx.project_config) if card_number.nil?
  worktree = find_and_save_worktree(ctx.card_internal_id, card_number, ctx.project_config) if !(worktree && File.directory?(worktree)) && card_number

  work_dir = worktree && File.directory?(worktree) ? worktree : ctx.project_config["repo_path"]
  card_key = "card-#{card_number || ctx.card_internal_id}"

  # Session management (wait, supersede, or queue)
  queued = handle_session_conflict(ctx, card_key, card_number, work_dir)
  return queued if queued

  LOG.info "Follow-up comment on card #{card_number || ctx.card_internal_id} " \
           "(project: #{ctx.project_key}), worktree: #{work_dir}"

  react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)

  result = dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
  [200, result.to_json]
end

#handle_new_mention(ctx) ⇒ Object

Handle mention on a card with no existing card_info (exploration)



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

def handle_new_mention(ctx)
  card_data = ctx.eventable["card"] || {}
  card_number = card_data["number"]
  card_title = card_data["title"] || "exploration"

  if card_number.nil?
    map_entry = load_work_item_map[ctx.card_internal_id]
    card_number = if map_entry && map_entry["number"]
                    map_entry["number"]
                  else
                    resolve_card_number(ctx.card_internal_id, repo_path: ctx.project_config["repo_path"])
                  end
  end

  LOG.info "#{ctx.agent_name} mentioned on card (internal_id: #{ctx.card_internal_id}, " \
           "project: #{ctx.project_key}), creating exploration worktree"
  record_agent_dispatch(ctx.card_internal_id) if ctx.creator_is_agent

  card_key = "card-#{card_number || ctx.card_internal_id}"
  return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)

  react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")

  worktree_path, branch = setup_new_mention_worktree(ctx, card_number, card_title)
  dispatch_new_mention(ctx, card_key: card_key, card_number: card_number,
                            card_title: card_title, branch: branch, worktree_path: worktree_path)
end

#handle_session_conflict(ctx, card_key, card_number, work_dir) ⇒ Object



576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 576

def handle_session_conflict(ctx, card_key, card_number, work_dir)
  if ctx.creator_is_agent && session_active?(card_key)
    return [200, { status: "ignored", reason: "session wait timeout" }.to_json] unless wait_for_session?(card_key)
  elsif session_active?(card_key)
    prev = find_supersedable_session(card_key)
    return queue_followup(ctx, card_key, card_number, work_dir) unless prev

    LOG.info "Superseding session on card #{card_number || ctx.card_internal_id} " \
             "(pid: #{prev[:pid]}) — human follow-up within #{SUPERSEDE_WINDOW}s"
    kill_session(prev[:session_key])

  end
  nil
end

#human_mentioned?(user_id) ⇒ Boolean

Returns:

  • (Boolean)


87
88
89
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 87

def human_mentioned?(user_id)
  Brainiac::Plugins::Fizzy::Config.human_mentioned?(user_id)
end

#identify_project_by_tags(tags) ⇒ Object

Config delegators



59
60
61
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 59

def identify_project_by_tags(tags)
  Brainiac::Plugins::Fizzy::Config.identify_project_by_tags(tags)
end

#ignore_assignment(reason, assignee_names, local_names) ⇒ Object



57
58
59
60
# File 'lib/brainiac/plugins/fizzy/handlers/assignment.rb', line 57

def ignore_assignment(reason, assignee_names, local_names)
  LOG.info "[Fizzy] No local agent matched. Assignees: [#{assignee_names}], Local: [#{local_names.join(", ")}]"
  [200, { status: "ignored", reason: reason }.to_json]
end

#ignore_unauthorized(payload, eventable) ⇒ Object



62
63
64
65
66
# File 'lib/brainiac/plugins/fizzy/handlers/assignment.rb', line 62

def ignore_unauthorized(payload, eventable)
  creator_name = payload.dig("creator", "name") || "Unknown"
  notify_unauthorized("card_assigned", creator_name, "card ##{eventable["number"]}")
  [200, { status: "ignored", reason: "unauthorized" }.to_json]
end

#index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: false) ⇒ Object



43
44
45
46
47
48
# File 'lib/brainiac/plugins/fizzy/handlers/dedup.rb', line 43

def index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: false)
  CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
  CARD_INDEX.save
  CARD_INDEX.schedule_qmd_reindex
  [200, { status: "indexed", card: card_number }.to_json] unless skip_response
end

#load_deployment_stateObject



20
21
22
23
24
25
26
27
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 20

def load_deployment_state
  return {} unless File.exist?(DEPLOYMENT_STATE_FILE)

  JSON.parse(File.read(DEPLOYMENT_STATE_FILE))
rescue JSON::ParserError => e
  LOG.error "Failed to parse deployment state: #{e.message}"
  {}
end

#load_deployments_configObject



11
12
13
14
15
16
17
18
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 11

def load_deployments_config
  return {} unless File.exist?(DEPLOYMENTS_CONFIG_FILE)

  JSON.parse(File.read(DEPLOYMENTS_CONFIG_FILE))
rescue JSON::ParserError => e
  LOG.error "Failed to parse deployments config: #{e.message}"
  {}
end

#mark_deploying(env_key, worktree_path:) ⇒ Object

Mark an environment as actively deploying (in-progress state for waybar).



49
50
51
52
53
54
55
56
57
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 49

def mark_deploying(env_key, worktree_path:)
  state = load_deployment_state
  state[env_key] ||= {}
  state[env_key]["status"] = "occupied"
  state[env_key]["last_deploy_status"] = "deploying"
  state[env_key]["last_deploy_at"] = Time.now.iso8601
  save_deployment_state(state)
  DEPLOYMENT_STATE.replace(state)
end

#move_card_to_column(card_number, column_name, project_config:, agent_name: nil) ⇒ Object



28
29
30
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 28

def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
  Brainiac::Plugins::Fizzy::Helpers.move_card_to_column(card_number, column_name, project_config: project_config, agent_name: agent_name)
end

#post_duplicate_warning(card_number, title, tags, similar) ⇒ Object



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

def post_duplicate_warning(card_number, title, tags, similar)
  best = similar.first
  LOG.info "[CardIndex] Potential duplicate: ##{card_number} '#{title}' ≈ " \
           "##{best[:number]} '#{best[:title]}' (score: #{best[:score].round(2)})"

  project_result = identify_project_by_tags(tags)
  return unless project_result

  _project_key, project_config = project_result
  repo_path = project_config["repo_path"]

  Thread.new do
    method_label = { trigram: "📝", semantic: "🧠", both: "📝🧠" }
    dupes = similar.map do |s|
      icon = method_label[s[:method]] || "📝"
      "##{s[:number]} \"#{s[:title]}\" (#{(s[:score] * 100).round}% #{icon})"
    end.join("\n- ")
    body = "⚠️ **Possible duplicate detected:**\n- #{dupes}\n\n_📝 = text similarity, 🧠 = semantic similarity_"
    run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
            chdir: repo_path, env: default_fizzy_env)
    LOG.info "[CardIndex] Posted duplicate warning on card ##{card_number}"
  rescue StandardError => e
    LOG.warn "[CardIndex] Failed to post duplicate warning: #{e.message}"
  end
end

#prefetch_card_context(card_number, repo_path:, agent_name: nil) ⇒ Object



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

def prefetch_card_context(card_number, repo_path:, agent_name: nil)
  Brainiac::Plugins::Fizzy::Helpers.prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name)
end

#queue_followup(ctx, card_key, card_number, work_dir) ⇒ Object



591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 591

def queue_followup(ctx, card_key, card_number, work_dir)
  react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👍", chdir: work_dir)

  Thread.new do
    unless wait_for_session?(card_key)
      LOG.warn "Giving up on queued follow-up for card #{card_number || ctx.card_internal_id}"
      next
    end
    dispatch_followup_comment(ctx, card_key: card_key, card_number: card_number, work_dir: work_dir)
  end

  [200, { status: "queued", card: card_number, card_internal_id: ctx.card_internal_id,
          reason: "waiting for active session" }.to_json]
end

#react_to_assignment(card_number, repo_path, agent_name) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/brainiac/plugins/fizzy/handlers/assignment.rb', line 68

def react_to_assignment(card_number, repo_path, agent_name)
  Thread.new do
    env = fizzy_env_for(agent_name)

    # Best-effort cleanup of existing reactions from this agent
    begin
      result = run_cmd("fizzy", "reaction", "list", "--card", card_number.to_s,
                       chdir: repo_path, env: env)
      reactions = JSON.parse(result)["data"] || []

      identity_output = run_cmd("fizzy", "identity", "show", chdir: repo_path, env: env)
      current_user_id = JSON.parse(identity_output).dig("data", "accounts", 0, "user", "id")

      if current_user_id
        reactions.each do |reaction|
          if reaction.dig("reacter", "id") == current_user_id
            run_cmd("fizzy", "reaction", "delete", reaction["id"], "--card", card_number.to_s,
                    chdir: repo_path, env: env)
          end
        end
      end
    rescue StandardError => e
      LOG.warn "Could not clean up existing reactions on card ##{card_number}: #{e.message}"
    end

    # Always attempt to add the reaction even if cleanup failed
    run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
            "--content", "👀", chdir: repo_path, env: env)
  rescue StandardError => e
    LOG.warn "Could not add reaction to card ##{card_number}: #{e.message}"
  end
end

#react_to_comment(card_number, comment_id, project_config, agent_name, emoji, chdir: nil) ⇒ Object



540
541
542
543
544
545
546
547
548
549
550
551
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 540

def react_to_comment(card_number, comment_id, project_config, agent_name, emoji, chdir: nil)
  return unless card_number

  work_dir = chdir || project_config["repo_path"]
  Thread.new do
    run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
            "--comment", comment_id.to_s, "--content", emoji,
            chdir: work_dir, env: fizzy_env_for(agent_name))
  rescue StandardError => e
    LOG.warn "Could not add #{emoji} reaction to comment: #{e.message}"
  end
end

#react_to_deploy(card_number, comment_id, worktree, emoji) ⇒ Object



60
61
62
63
64
65
66
# File 'lib/brainiac/plugins/fizzy/handlers/deploy.rb', line 60

def react_to_deploy(card_number, comment_id, worktree, emoji)
  run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
          "--comment", comment_id.to_s, "--content", emoji,
          chdir: worktree, env: default_fizzy_env)
rescue StandardError => e
  LOG.warn "[Deploy] Could not add reaction #{emoji}: #{e.message}"
end

#record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "") ⇒ Object

Record a failed deploy — saves output to a log file and updates state.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 107

def record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "")
  FileUtils.mkdir_p(DEPLOY_LOGS_DIR)
  log_file = File.join(DEPLOY_LOGS_DIR, "#{env_key}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.log")
  File.write(log_file, "=== STDOUT ===\n#{stdout}\n\n=== STDERR ===\n#{stderr}")

  state = load_deployment_state
  state[env_key] ||= {}
  state[env_key]["last_deploy_status"] = "failed"
  state[env_key]["last_deploy_at"] = Time.now.iso8601
  state[env_key]["last_deploy_log"] = log_file
  save_deployment_state(state)
  DEPLOYMENT_STATE.replace(state)
  LOG.info "[Deploy] #{env_key} deploy failed — log at #{log_file}"
end

#reload_deployment_state!(force: false) ⇒ Object



42
43
44
45
46
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 42

def reload_deployment_state!(force: false)
  return unless file_changed?(DEPLOYMENT_STATE_FILE, force: force)

  DEPLOYMENT_STATE.replace(load_deployment_state)
end

#reload_deployments_config!(force: false) ⇒ Object



36
37
38
39
40
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 36

def reload_deployments_config!(force: false)
  return unless file_changed?(DEPLOYMENTS_CONFIG_FILE, force: force)

  DEPLOYMENTS_CONFIG.replace(load_deployments_config)
end

#resolve_agent_via_api(card_info, card_internal_id, project_config) ⇒ Object



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 320

def resolve_agent_via_api(card_info, card_internal_id, project_config)
  api_card_number = card_info&.dig("number") || card_internal_id
  return nil unless api_card_number

  output = run_cmd("fizzy", "card", "show", api_card_number.to_s,
                   chdir: project_config["repo_path"], env: default_fizzy_env)
  api_assignees = begin
    JSON.parse(output).dig("data", "assignees") || []
  rescue StandardError
    []
  end
  agent = api_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }
  LOG.info "Resolved assigned agent '#{agent}' via Fizzy API for card ##{api_card_number}" if agent
  agent
rescue StandardError => e
  LOG.warn "Fizzy API fallback failed for card ##{api_card_number}: #{e.message}"
  nil
end

#resolve_and_save_card_number(card_internal_id, project_config) ⇒ Object



553
554
555
556
557
558
559
560
561
562
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 553

def resolve_and_save_card_number(card_internal_id, project_config)
  card_number = resolve_card_number(card_internal_id, repo_path: project_config["repo_path"])
  if card_number
    map = load_work_item_map
    map[card_internal_id] ||= {}
    map[card_internal_id]["number"] = card_number
    save_work_item_map(map)
  end
  card_number
end

#resolve_assigned_agent(card_info, card_internal_id, eventable, project_config) ⇒ Object



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 304

def resolve_assigned_agent(card_info, card_internal_id, eventable, project_config)
  card_assignees = eventable.dig("card", "assignees") || []
  webhook_agent = card_assignees.map { |a| a["name"] }.find { |name| local_agent_names.include?(name) }

  webhook_agent = resolve_agent_via_api(card_info, card_internal_id, project_config) if webhook_agent.nil? && project_config

  return nil unless webhook_agent

  map = load_work_item_map
  map[card_internal_id] ||= {}
  map[card_internal_id]["agent"] = webhook_agent
  save_work_item_map(map)
  LOG.info "Backfilled agent '#{webhook_agent}' into card map for #{card_internal_id}"
  webhook_agent
end

#resolve_comment_agent(mentioned_agent:, card_info:, card_internal_id:, eventable:, project_config:, creator_is_agent:) ⇒ Object



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 283

def resolve_comment_agent(mentioned_agent:, card_info:, card_internal_id:, eventable:, project_config:, creator_is_agent:)
  card_assigned_agent = card_info&.dig("agent")

  # Resolve assigned agent from payload or API if missing
  card_assigned_agent = resolve_assigned_agent(card_info, card_internal_id, eventable, project_config) if card_assigned_agent.nil?

  if mentioned_agent
    agent_name = mentioned_agent
    is_cross_agent_mention = !card_assigned_agent || card_assigned_agent != mentioned_agent
  else
    unless card_assigned_agent
      LOG.info "Skipping card #{card_internal_id} — no assigned agent and no mention"
      return [nil, false]
    end
    agent_name = card_assigned_agent
    is_cross_agent_mention = false
  end

  [agent_name, is_cross_agent_mention]
end

#resolve_deploy_environment(deploy_intent, state, card_number) ⇒ Object

Resolve which environment to deploy to from the intent.



159
160
161
162
163
164
165
166
167
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 159

def resolve_deploy_environment(deploy_intent, state, card_number)
  if deploy_intent.is_a?(String) && !deploy_intent.empty?
    deploy_intent
  else
    existing = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
    LOG.info "[Deploy] Auto-deploy skipped — card ##{card_number} not currently deployed to any environment" unless existing
    existing
  end
end

#resolve_deployment_url(env_config, card_tags) ⇒ Object

Resolve the correct URL for an environment based on card tags. If the card has a tag matching a key in the environment's "urls" map, use that URL. Otherwise fall back to the default "url".



254
255
256
257
258
259
260
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 254

def resolve_deployment_url(env_config, card_tags)
  urls = env_config["urls"] || {}
  if card_tags && urls.any?
    card_tags.each { |tag| return urls[tag] if urls[tag] }
  end
  env_config["url"]
end

#resolve_fizzy_project(card_info, card_internal_id, eventable) ⇒ Object



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 249

def resolve_fizzy_project(card_info, card_internal_id, eventable)
  if card_info
    if card_info["project"]
      project_key = card_info["project"]
      project_config = PROJECTS[project_key] || DEFAULT_PROJECT
    else
      card_tags = eventable.dig("card", "tags") || []
      project_result = identify_project_by_tags(card_tags)
      if project_result
        project_key, project_config = project_result
        card_info["project"] = project_key
        map = load_work_item_map
        map[card_internal_id] = card_info
        save_work_item_map(map)
        LOG.info "Backfilled project '#{project_key}' for card #{card_internal_id} in card map"
      else
        LOG.warn "No project found for card #{card_internal_id}"
        return [nil, nil]
      end
    end
  else
    card_tags = eventable.dig("card", "tags") || []
    project_result = identify_project_by_tags(card_tags)
    if project_result
      project_key, project_config = project_result
    else
      LOG.warn "No project found for mentioned card #{card_internal_id}"
      return [nil, nil]
    end
  end

  [project_config, project_key]
end

#resolve_or_create_worktree(ctx, card_number, card_title, repo_path) ⇒ Object



641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 641

def resolve_or_create_worktree(ctx, card_number, card_title, repo_path)
  # Check for existing worktree in card map or on disk
  existing_map_entry = load_work_item_map[ctx.card_internal_id]
  if existing_map_entry && existing_map_entry["branch"] && existing_map_entry["worktree"] &&
     File.directory?(existing_map_entry["worktree"])
    LOG.info "Reusing existing worktree from card map: #{existing_map_entry["worktree"]}"
    return [existing_map_entry["worktree"], existing_map_entry["branch"]]
  end

  if card_number
    found = find_worktree_for_card(card_number, repo_path: repo_path)
    if found
      LOG.info "Found existing worktree by card number scan: #{found[:worktree]}"
      return [found[:worktree], found[:branch]]
    end
  end

  branch = card_number ? "fizzy-#{card_number}-#{slugify(card_title)}" : "fizzy-explore-#{ctx.card_internal_id[0..7]}"
  debounced_repo_fetch(repo_path)
  worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch)
  [worktree_path, branch]
end

#resolve_worktree_override(tags, project_config) ⇒ Object



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

def resolve_worktree_override(tags, project_config)
  return nil unless tags[:worktree_override]

  override_branch = tags[:worktree_override]
  repo_path = project_config["repo_path"]
  candidate = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{override_branch}")

  if File.directory?(candidate)
    LOG.info "Worktree override requested: #{override_branch} -> #{candidate}"
    { "branch" => override_branch, "worktree" => candidate }
  else
    LOG.warn "Worktree override branch '#{override_branch}' not found at #{candidate}, ignoring"
    nil
  end
end

#retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object

Retry deploy after clearing terraform lock.



185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 185

def retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:)
  lock_file = File.join(worktree_path, "infrastructure/#{env_key}/.terraform.lock.hcl")
  FileUtils.rm_f(lock_file)
  Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree_path, "infrastructure/#{env_key}"))
  stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path)
  if status2.success?
    deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]")
    LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
  else
    record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout2, stderr: stderr2)
    LOG.error "[Deploy] Auto-deploy to #{env_key} failed (after retry) for card ##{card_number}"
  end
end

#retry_deploy_with_init(deploy_env, env_key, card_number, worktree) ⇒ Object



90
91
92
93
94
95
96
97
# File 'lib/brainiac/plugins/fizzy/handlers/deploy.rb', line 90

def retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
  LOG.info "[Deploy] Terraform lock file mismatch for card ##{card_number} — retrying with init -upgrade"
  infra_dir = File.join(worktree, "infrastructure", env_key)
  lock_file = File.join(infra_dir, ".terraform.lock.hcl")
  FileUtils.rm_f(lock_file)
  Open3.capture3(deploy_env, "terraform", "init", "-upgrade", chdir: infra_dir) if File.directory?(infra_dir)
  Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
end

#run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:) ⇒ Object

Execute deploy script with terraform lock retry logic.



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 170

def run_deploy(env_key, card_number, comment_id, worktree)
  deploy_env = {}
  aws_profile = DEPLOYMENTS_CONFIG.dig("environments", env_key, "aws_profile")
  deploy_env["AWS_PROFILE"] = aws_profile if aws_profile

  stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)

  if !status.success? && terraform_lock_error?(stdout, stderr)
    stdout, stderr, status = retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
  end

  if status.success?
    LOG.info "[Deploy] Successfully deployed card ##{card_number} to #{env_key}"
    react_to_deploy(card_number, comment_id, worktree, "")
    deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "fizzy-comment")
  else
    LOG.error "[Deploy] Failed deploying card ##{card_number} to #{env_key}: #{stderr}"
    react_to_deploy(card_number, comment_id, worktree, "")
    record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
  end
end

#save_deployment_state(state) ⇒ Object



29
30
31
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 29

def save_deployment_state(state)
  File.write(DEPLOYMENT_STATE_FILE, JSON.pretty_generate(state))
end

#scrub_invalid_attachments!(dir) ⇒ Object



40
41
42
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 40

def scrub_invalid_attachments!(dir)
  Brainiac::Plugins::Fizzy::Helpers.scrub_invalid_attachments!(dir)
end

#setup_assigned_worktree(repo_path, branch, card_internal_id, card_number, project_key, agent_name) ⇒ Object



101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/brainiac/plugins/fizzy/handlers/assignment.rb', line 101

def setup_assigned_worktree(repo_path, branch, card_internal_id, card_number, project_key, agent_name)
  debounced_repo_fetch(repo_path)
  worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
  worktree_path = create_or_reuse_worktree(repo_path: repo_path, branch: branch, worktree_path: worktree_path)

  map = load_work_item_map
  map[card_internal_id] = {
    "number" => card_number, "branch" => branch, "worktree" => worktree_path,
    "project" => project_key, "agent" => agent_name
  }
  save_work_item_map(map)
  worktree_path
end

#setup_cross_agent_worktree(ctx, card_number) ⇒ Object



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 606

def setup_cross_agent_worktree(ctx, card_number)
  repo_path = ctx.project_config["repo_path"]
  card_title = ctx.card_info&.dig("title") || ctx.eventable.dig("card", "title") || "review"
  review_branch = "#{ctx.agent_name.downcase}/fizzy-#{card_number}-#{slugify(card_title)}"
  review_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{review_branch.tr("/", "-")}")

  debounced_repo_fetch(repo_path)

  if File.directory?(review_worktree_path)
    worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
    FileUtils.rm_rf(review_worktree_path) unless worktree_list.include?(review_worktree_path)
  end

  create_review_worktree(repo_path, review_branch, review_worktree_path, ctx.card_info) unless File.directory?(review_worktree_path)

  [review_worktree_path, review_branch]
end

#setup_new_mention_worktree(ctx, card_number, card_title) ⇒ Object



455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 455

def setup_new_mention_worktree(ctx, card_number, card_title)
  repo_path = ctx.project_config["repo_path"]
  worktree_path, branch = resolve_or_create_worktree(ctx, card_number, card_title, repo_path)

  map = load_work_item_map
  map[ctx.card_internal_id] = {
    "number" => card_number, "branch" => branch, "worktree" => worktree_path,
    "project" => ctx.project_key, "agent" => ctx.agent_name
  }
  save_work_item_map(map)

  [worktree_path, branch]
end

#terraform_lock_error?(stdout, stderr) ⇒ Boolean

Detect Terraform provider lock file checksum mismatch errors.

Returns:

  • (Boolean)


200
201
202
203
# File 'lib/brainiac/plugins/fizzy/handlers/deployments.rb', line 200

def terraform_lock_error?(stdout, stderr)
  combined = "#{stdout}\n#{stderr}"
  combined.include?("checksums previously recorded in the dependency lock file")
end

#validate_agent_comment(creator_is_agent, is_api_sourced, creator_name, mentioned_agent, card_internal_id) ⇒ 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
# File 'lib/brainiac/plugins/fizzy/handlers/comments.rb', line 158

def validate_agent_comment(creator_is_agent, is_api_sourced, creator_name, mentioned_agent, card_internal_id)
  return nil unless creator_is_agent || is_api_sourced

  card_info = load_work_item_map[card_internal_id]
  card_assigned_agent = card_info&.dig("agent")

  agent_is_assigned = card_assigned_agent && card_assigned_agent.downcase == (creator_name || "").downcase
  agent_is_mentioned = mentioned_agent && mentioned_agent.downcase == (creator_name || "").downcase

  unless agent_is_assigned || agent_is_mentioned
    LOG.info "Blocking agent comment from #{creator_name} on card #{card_internal_id}: not assigned and not mentioned"
    return [200, { status: "ignored", reason: "agent not assigned or mentioned" }.to_json]
  end

  # Agent-to-agent loop prevention
  if mentioned_agent && mentioned_agent.downcase != (creator_name || "").downcase
    unless agent_dispatch_allowed?(card_internal_id)
      LOG.info "Blocking agent-to-agent dispatch on card #{card_internal_id}: " \
               "depth limit reached (#{creator_name} → @#{mentioned_agent})"
      return [200, { status: "ignored", reason: "agent-to-agent depth limit" }.to_json]
    end
    LOG.info "Allowing agent-to-agent dispatch on card #{card_internal_id}: #{creator_name} → @#{mentioned_agent}"
  elsif !mentioned_agent
    LOG.info "Ignoring self-comment from #{creator_name} on card #{card_internal_id}"
    return [200, { status: "ignored", reason: "self-comment" }.to_json]
  end

  nil
end

#verify_fizzy_signature!(request, payload_body, board_key: nil) ⇒ Object



49
50
51
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 49

def verify_fizzy_signature!(request, payload_body, board_key: nil)
  Brainiac::Plugins::Fizzy::Helpers.verify_signature!(request, payload_body, board_key: board_key)
end

#verify_signature!(request, payload_body, board_key: nil) ⇒ Object

Legacy alias used by handler files



54
55
56
# File 'lib/brainiac/plugins/fizzy/delegators.rb', line 54

def verify_signature!(request, payload_body, board_key: nil)
  Brainiac::Plugins::Fizzy::Helpers.verify_signature!(request, payload_body, board_key: board_key)
end