brainiac 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/Gemfile.lock +2 -2
- data/bin/brainiac +317 -0
- data/bin/brainiac-completion.bash +178 -0
- data/lib/brainiac/agents.rb +34 -0
- data/lib/brainiac/brain.rb +25 -0
- data/lib/brainiac/config.rb +4 -0
- data/lib/brainiac/cron.rb +9 -6
- data/lib/brainiac/handlers/discord.rb +233 -30
- data/lib/brainiac/handlers/fizzy.rb +129 -86
- data/lib/brainiac/helpers.rb +125 -41
- data/lib/brainiac/prompts.rb +97 -105
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +13 -0
- data/templates/cli-providers/grok.json.example +15 -0
- data/templates/cli-providers/kiro.json.example +15 -0
- data/templates/roles/code-reviewer.md.example +28 -0
- data/templates/roles/general-engineer.md.example +36 -0
- data/templates/roles/test-engineer.md.example +33 -0
- data.tar.gz.sig +0 -0
- metadata +7 -1
- metadata.gz.sig +0 -0
|
@@ -32,6 +32,24 @@ DISCORD_ALL_READY_LOGGED = { done: false }
|
|
|
32
32
|
DISCORD_SHARED_THREADS = {}
|
|
33
33
|
DISCORD_SHARED_THREADS_MUTEX = Mutex.new
|
|
34
34
|
|
|
35
|
+
# Discord thread worktree map: tracks worktrees created for Discord thread conversations.
|
|
36
|
+
# Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
|
|
37
|
+
# Persisted to disk so sessions survive restarts.
|
|
38
|
+
DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
|
|
39
|
+
DISCORD_THREAD_MAP_MUTEX = Mutex.new
|
|
40
|
+
|
|
41
|
+
def load_discord_thread_map
|
|
42
|
+
return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
|
|
43
|
+
|
|
44
|
+
JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def save_discord_thread_map(map)
|
|
50
|
+
File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
|
|
51
|
+
end
|
|
52
|
+
|
|
35
53
|
# Brainiac restart queue: when an agent works on brainiac itself, queue a restart
|
|
36
54
|
# instead of doing it immediately. A background thread checks every 30s and only
|
|
37
55
|
# restarts when no other agents are running, preventing mid-session kills.
|
|
@@ -721,6 +739,9 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
721
739
|
# Strip effort tag (e.g. [effort:high]) from prompt content
|
|
722
740
|
clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
|
|
723
741
|
|
|
742
|
+
# Strip CLI provider tag (e.g. [cli:grok]) from prompt content
|
|
743
|
+
clean_content_for_prompt = clean_content_for_prompt.sub(/\[cli:\w+\]/i, "").strip
|
|
744
|
+
|
|
724
745
|
# Find project: inline override > channel mapping > default_project
|
|
725
746
|
if inline_project_key && PROJECTS.key?(inline_project_key)
|
|
726
747
|
project_key = inline_project_key
|
|
@@ -816,14 +837,37 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
816
837
|
# Also captures root message content so the agent always has the original context.
|
|
817
838
|
root_message = find_root_message(message, channel_id, bot_token)
|
|
818
839
|
root_message_id = root_message[:id]
|
|
819
|
-
|
|
840
|
+
|
|
841
|
+
# Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
|
|
842
|
+
# so all messages in the thread share one memory file. The thread's channel_id IS the thread
|
|
843
|
+
# starter message ID, which is stable across all messages within it.
|
|
844
|
+
card_id = if is_thread
|
|
845
|
+
"discord-#{parent_channel_id}-#{channel_id}"
|
|
846
|
+
else
|
|
847
|
+
"discord-#{channel_id}-#{root_message_id}"
|
|
848
|
+
end
|
|
820
849
|
|
|
821
850
|
# Build thread root context — inject the original question/message that started
|
|
822
851
|
# this thread so the agent never loses sight of it, even in long conversations.
|
|
823
852
|
thread_root_context = ""
|
|
824
|
-
if is_thread
|
|
825
|
-
|
|
826
|
-
|
|
853
|
+
if is_thread
|
|
854
|
+
root_content = root_message[:content]
|
|
855
|
+
root_author = root_message[:author]
|
|
856
|
+
|
|
857
|
+
# Fallback: if find_root_message didn't capture content (no message_reference chain),
|
|
858
|
+
# fetch the parent message directly. In Discord, a thread's channel_id equals the
|
|
859
|
+
# message_id that the thread was created on in the parent channel.
|
|
860
|
+
if root_content.nil? || root_content.empty?
|
|
861
|
+
parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
|
|
862
|
+
if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
|
|
863
|
+
root_content = parent_msg["content"].strip
|
|
864
|
+
root_author = parent_msg.dig("author", "username") || "unknown"
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
if root_content && !root_content.empty?
|
|
869
|
+
thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
|
|
870
|
+
end
|
|
827
871
|
end
|
|
828
872
|
|
|
829
873
|
# Detect planning mode
|
|
@@ -836,7 +880,126 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
836
880
|
|
|
837
881
|
brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
|
|
838
882
|
|
|
839
|
-
|
|
883
|
+
# --- Worktree management & session resume ---
|
|
884
|
+
# Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
|
|
885
|
+
# For channel messages, create a Discord thread upfront so we have a stable ID
|
|
886
|
+
# for the worktree, then dispatch in it. This ensures grok sessions persist
|
|
887
|
+
# across the thread conversation and can be resumed with -c.
|
|
888
|
+
should_resume = false
|
|
889
|
+
thread_worktree_path = nil
|
|
890
|
+
thread_cli_provider = nil
|
|
891
|
+
thread_model = nil
|
|
892
|
+
thread_effort = nil
|
|
893
|
+
|
|
894
|
+
# For channel messages (not DMs, not already in a thread), create a thread immediately
|
|
895
|
+
# so we can use its ID for the worktree. This also means the response delivery
|
|
896
|
+
# will find the pre-existing thread via the API lookup.
|
|
897
|
+
pre_created_thread_id = nil
|
|
898
|
+
if !is_thread && !is_dm && project_config
|
|
899
|
+
display_name = fizzy_display_name(agent_key)
|
|
900
|
+
thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
|
|
901
|
+
if thread && thread["id"]
|
|
902
|
+
pre_created_thread_id = thread["id"]
|
|
903
|
+
DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }
|
|
904
|
+
|
|
905
|
+
# Propagate dispatch depth to the new thread
|
|
906
|
+
parent_depth_key = "discord-#{channel_id}"
|
|
907
|
+
thread_depth_key = "discord-#{pre_created_thread_id}"
|
|
908
|
+
parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
|
|
909
|
+
if parent_info
|
|
910
|
+
AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
|
|
911
|
+
else
|
|
912
|
+
record_human_comment(thread_depth_key)
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
|
|
916
|
+
else
|
|
917
|
+
LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# Determine the thread map key: use the thread ID (existing thread or pre-created one)
|
|
922
|
+
effective_thread_id = is_thread ? channel_id : pre_created_thread_id
|
|
923
|
+
thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
|
|
924
|
+
|
|
925
|
+
if project_config && thread_map_key
|
|
926
|
+
repo_path = project_config["repo_path"]
|
|
927
|
+
thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
|
|
928
|
+
existing = thread_map[thread_map_key]
|
|
929
|
+
|
|
930
|
+
if existing && existing["worktree"] && File.directory?(existing["worktree"])
|
|
931
|
+
# Existing worktree — resume session. Inherit tags from the thread's first dispatch.
|
|
932
|
+
thread_worktree_path = existing["worktree"]
|
|
933
|
+
thread_cli_provider = existing["cli_provider"]
|
|
934
|
+
thread_model = existing["model"]
|
|
935
|
+
thread_effort = existing["effort"]
|
|
936
|
+
effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
937
|
+
resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
|
|
938
|
+
should_resume = resolved_for_resume["resume_flag"] ? true : false
|
|
939
|
+
LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
|
|
940
|
+
else
|
|
941
|
+
# First worktree creation. Inherit tags from a seeded thread map entry if present.
|
|
942
|
+
seeded_cli_provider = existing&.dig("cli_provider")
|
|
943
|
+
seeded_model = existing&.dig("model")
|
|
944
|
+
seeded_effort = existing&.dig("effort")
|
|
945
|
+
|
|
946
|
+
thread_slug = effective_thread_id[-8..]
|
|
947
|
+
branch = "discord-#{agent_key}-#{thread_slug}"
|
|
948
|
+
thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
|
|
949
|
+
|
|
950
|
+
debounced_repo_fetch(repo_path)
|
|
951
|
+
default_branch = get_default_branch(repo_path)
|
|
952
|
+
|
|
953
|
+
branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
954
|
+
|
|
955
|
+
if branch_exists
|
|
956
|
+
worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
957
|
+
has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
|
|
958
|
+
if has_worktree && File.directory?(thread_worktree_path)
|
|
959
|
+
LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
|
|
960
|
+
else
|
|
961
|
+
run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
|
|
962
|
+
end
|
|
963
|
+
else
|
|
964
|
+
run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
|
|
968
|
+
apply_worktree_includes(repo_path, thread_worktree_path)
|
|
969
|
+
run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })
|
|
970
|
+
|
|
971
|
+
# Store tags: prefer current message tag, then seeded value from initial dispatch
|
|
972
|
+
first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
|
|
973
|
+
first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
|
|
974
|
+
first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
|
|
975
|
+
thread_cli_provider = first_cli_provider
|
|
976
|
+
thread_model = first_model
|
|
977
|
+
thread_effort = first_effort
|
|
978
|
+
|
|
979
|
+
DISCORD_THREAD_MAP_MUTEX.synchronize do
|
|
980
|
+
map = load_discord_thread_map
|
|
981
|
+
map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
|
|
982
|
+
"project" => PROJECTS.find { |_k, v| v == project_config }&.first,
|
|
983
|
+
"channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
|
|
984
|
+
"model" => first_model, "effort" => first_effort,
|
|
985
|
+
"created_at" => Time.now.iso8601 }
|
|
986
|
+
save_discord_thread_map(map)
|
|
987
|
+
end
|
|
988
|
+
LOG.info "[Discord:#{agent_name}] Created thread worktree at #{thread_worktree_path}#{" (cli: #{first_cli_provider})" if first_cli_provider}#{" (model: #{first_model})" if first_model}#{" (effort: #{first_effort})" if first_effort}"
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
if should_resume && thread_worktree_path
|
|
993
|
+
# Lean resume prompt — prior session has full context
|
|
994
|
+
prompt = render_discord_resume_prompt(
|
|
995
|
+
message_body: clean_content_for_prompt,
|
|
996
|
+
discord_user: discord_user,
|
|
997
|
+
response_file: response_file,
|
|
998
|
+
agent_name: agent_name,
|
|
999
|
+
card_id: card_id
|
|
1000
|
+
)
|
|
1001
|
+
LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
|
|
1002
|
+
elsif planning_info
|
|
840
1003
|
# Planning mode — use planning prompt
|
|
841
1004
|
planning_card_id = planning_info[:card_id]
|
|
842
1005
|
LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
|
|
@@ -875,11 +1038,46 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
875
1038
|
channel: :discord)
|
|
876
1039
|
end
|
|
877
1040
|
|
|
878
|
-
work_dir = project_config ? project_config["repo_path"] : Dir.pwd
|
|
1041
|
+
work_dir = thread_worktree_path || (project_config ? project_config["repo_path"] : Dir.pwd)
|
|
879
1042
|
|
|
880
1043
|
prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
|
|
881
1044
|
File.write(prompt_file, prompt)
|
|
882
1045
|
|
|
1046
|
+
# Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
|
|
1047
|
+
# Current message tags override thread defaults; thread defaults apply when no tag is present.
|
|
1048
|
+
cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider
|
|
1049
|
+
|
|
1050
|
+
# For model/effort: detect_model/detect_effort return the project default when no tag matches,
|
|
1051
|
+
# so we check for explicit tag presence to know whether the thread default should apply.
|
|
1052
|
+
has_explicit_model = false
|
|
1053
|
+
if project_config
|
|
1054
|
+
allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
|
|
1055
|
+
model_tag_match = clean_content.match(/\[(\w+)\]/i)
|
|
1056
|
+
has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
|
|
1057
|
+
end
|
|
1058
|
+
has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
|
|
1059
|
+
|
|
1060
|
+
model = if has_explicit_model
|
|
1061
|
+
detect_model(project_config, text: clean_content)
|
|
1062
|
+
elsif thread_model
|
|
1063
|
+
thread_model
|
|
1064
|
+
else
|
|
1065
|
+
project_config ? detect_model(project_config, text: clean_content) : nil
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
effort = if has_explicit_effort
|
|
1069
|
+
detect_effort(project_config, text: clean_content)
|
|
1070
|
+
elsif thread_effort
|
|
1071
|
+
thread_effort
|
|
1072
|
+
else
|
|
1073
|
+
project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# Determine the "explicit" values from this message only (not thread defaults)
|
|
1077
|
+
# for seeding into the thread map on initial dispatch.
|
|
1078
|
+
explicit_model = has_explicit_model ? model : nil
|
|
1079
|
+
explicit_effort = has_explicit_effort ? effort : nil
|
|
1080
|
+
|
|
883
1081
|
# Write delivery metadata sidecar so the poller can post this response
|
|
884
1082
|
# even if the monitoring thread dies (e.g. server restart).
|
|
885
1083
|
meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
|
|
@@ -891,32 +1089,35 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
891
1089
|
is_dm: is_dm,
|
|
892
1090
|
is_thread: is_thread,
|
|
893
1091
|
clean_content: clean_content[0..80],
|
|
1092
|
+
cli_provider: detect_cli_provider(text: clean_content),
|
|
1093
|
+
model: explicit_model,
|
|
1094
|
+
effort: explicit_effort,
|
|
894
1095
|
created_at: Time.now.iso8601
|
|
895
1096
|
}))
|
|
896
1097
|
|
|
897
|
-
# Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments
|
|
898
|
-
model = project_config ? detect_model(project_config, text: clean_content) : nil
|
|
899
|
-
|
|
900
|
-
# Detect effort override — [effort:high] syntax
|
|
901
|
-
effort = project_config ? detect_effort(project_config, text: clean_content) : nil
|
|
902
|
-
|
|
903
1098
|
agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
904
1099
|
log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
|
|
905
1100
|
|
|
906
|
-
resolved = project_config ? resolve_project_cli_config(project_config) : {}
|
|
1101
|
+
resolved = project_config ? resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
|
|
907
1102
|
agent_cli = resolved["agent_cli"] || "kiro-cli"
|
|
908
1103
|
agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
|
|
909
1104
|
agent_model_flag = resolved["agent_model_flag"] || "--model"
|
|
910
1105
|
agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
|
|
1106
|
+
agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
|
|
1107
|
+
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
911
1108
|
|
|
912
1109
|
cmd = [agent_cli]
|
|
913
|
-
cmd.push(
|
|
1110
|
+
cmd.push(agent_flag, agent_config_name) if agent_flag
|
|
914
1111
|
cmd.concat(agent_cli_args.split)
|
|
915
|
-
|
|
916
|
-
|
|
1112
|
+
if model && agent_model_flag && !agent_model_flag.empty?
|
|
1113
|
+
allowed = resolved["allowed_models"] || {}
|
|
1114
|
+
cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
|
|
1115
|
+
end
|
|
917
1116
|
cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
|
|
1117
|
+
cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
|
|
1118
|
+
cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
|
|
918
1119
|
|
|
919
|
-
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
|
|
1120
|
+
LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}, cli: #{agent_cli}#{", resuming" if should_resume}), tail -f #{log_file}"
|
|
920
1121
|
LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
|
|
921
1122
|
|
|
922
1123
|
spawn_env = {}
|
|
@@ -926,19 +1127,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
926
1127
|
LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
|
|
927
1128
|
end
|
|
928
1129
|
|
|
929
|
-
# Capture HEAD before spawning so we can detect if THIS session made
|
|
1130
|
+
# Capture HEAD and dirty state before spawning so we can detect if THIS session made changes
|
|
930
1131
|
head_before = nil
|
|
1132
|
+
status_before = nil
|
|
931
1133
|
if project_config
|
|
932
1134
|
pk = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
933
|
-
if pk == "brainiac"
|
|
934
|
-
head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir)
|
|
935
|
-
head_before = head_before.strip
|
|
936
|
-
end
|
|
1135
|
+
head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
|
|
937
1136
|
end
|
|
938
1137
|
|
|
939
1138
|
pid = spawn(spawn_env, *cmd,
|
|
940
1139
|
chdir: work_dir,
|
|
941
|
-
in: prompt_file,
|
|
1140
|
+
**(prompt_mode == "stdin" ? { in: prompt_file } : {}),
|
|
942
1141
|
out: [log_file, "w"],
|
|
943
1142
|
err: %i[child out])
|
|
944
1143
|
|
|
@@ -1043,14 +1242,12 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
|
|
|
1043
1242
|
brain_push(message: "#{agent_name}: discord-#{message_id}")
|
|
1044
1243
|
|
|
1045
1244
|
# Restart brainiac if THIS session actually changed code
|
|
1046
|
-
# Compare HEAD now vs before
|
|
1245
|
+
# Compare HEAD and dirty state now vs before — only restart if the agent made commits or new dirty files
|
|
1047
1246
|
if project_config && head_before
|
|
1048
1247
|
project_key = PROJECTS.find { |_k, v| v == project_config }&.first
|
|
1049
1248
|
if project_key == "brainiac"
|
|
1050
|
-
|
|
1051
|
-
head_after
|
|
1052
|
-
git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
|
|
1053
|
-
if head_after.strip != head_before || !git_status.strip.empty?
|
|
1249
|
+
head_after, status_after = capture_git_state(project_config["repo_path"])
|
|
1250
|
+
if head_after != head_before || status_after != status_before
|
|
1054
1251
|
queue_brainiac_restart(agent_name)
|
|
1055
1252
|
else
|
|
1056
1253
|
LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
|
|
@@ -1624,10 +1821,16 @@ def start_all_discord_gateways
|
|
|
1624
1821
|
end
|
|
1625
1822
|
|
|
1626
1823
|
LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
|
|
1627
|
-
|
|
1628
|
-
|
|
1824
|
+
|
|
1825
|
+
# Register all bots upfront so the "all ready" check sees the full set
|
|
1826
|
+
# before any individual READY event fires.
|
|
1827
|
+
DISCORD_BOTS_MUTEX.synchronize do
|
|
1828
|
+
tokens.each do |agent_key, token|
|
|
1629
1829
|
DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
|
|
1630
1830
|
end
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
tokens.each do |agent_key, token|
|
|
1631
1834
|
start_discord_gateway_for(agent_key, token)
|
|
1632
1835
|
sleep 1 # Stagger connections to avoid rate limits
|
|
1633
1836
|
end
|
|
@@ -143,6 +143,7 @@ def handle_card_assigned(payload)
|
|
|
143
143
|
branch = "fizzy-#{card_number}-#{slugify(title)}"
|
|
144
144
|
model = detect_model(project_config, tags: tags)
|
|
145
145
|
effort = detect_effort(project_config, tags: tags)
|
|
146
|
+
cli_provider_override = detect_cli_provider(tags: tags)
|
|
146
147
|
|
|
147
148
|
card_key = "card-#{card_number}"
|
|
148
149
|
if session_active?(card_key)
|
|
@@ -274,7 +275,7 @@ def handle_card_assigned(payload)
|
|
|
274
275
|
end
|
|
275
276
|
|
|
276
277
|
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,
|
|
277
|
-
card_number: card_number, source: :fizzy, source_context: { card_number: card_number })
|
|
278
|
+
card_number: card_number, source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
|
|
278
279
|
register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: assigned_agent)
|
|
279
280
|
|
|
280
281
|
# Move card to Right Now — agent is starting work
|
|
@@ -635,8 +636,13 @@ def handle_comment(payload)
|
|
|
635
636
|
end
|
|
636
637
|
end
|
|
637
638
|
|
|
639
|
+
card_tags = eventable.dig("card", "tags") || []
|
|
638
640
|
model = detect_model(project_config, text: plain_text)
|
|
639
|
-
effort = detect_effort(project_config, tags:
|
|
641
|
+
effort = detect_effort(project_config, tags: card_tags, text: effort_text_for_detection)
|
|
642
|
+
cli_provider_override = detect_cli_provider(text: plain_text, tags: card_tags)
|
|
643
|
+
|
|
644
|
+
# Strip [cli:X] tag from prompt content
|
|
645
|
+
plain_text = plain_text.sub(/\[cli:\w+\]/i, "").strip
|
|
640
646
|
|
|
641
647
|
# Determine which agent should handle this comment.
|
|
642
648
|
#
|
|
@@ -826,7 +832,7 @@ def handle_comment(payload)
|
|
|
826
832
|
pid, log_file = run_agent(prompt, project_config: project_config, chdir: review_worktree_path,
|
|
827
833
|
log_name: "review-#{agent_name.downcase}-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name,
|
|
828
834
|
card_number: card_number, comment_id: comment_id,
|
|
829
|
-
source: :fizzy, source_context: { card_number: card_number })
|
|
835
|
+
source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
|
|
830
836
|
register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
|
|
831
837
|
|
|
832
838
|
return [200, { status: "cross_agent_review", agent: agent_name, card_agent: card_assigned_agent,
|
|
@@ -912,7 +918,7 @@ def handle_comment(payload)
|
|
|
912
918
|
work_dir: work_dir, project_config: project_config, project_key: project_key,
|
|
913
919
|
comment_vars: comment_vars, plain_text: plain_text, model: model,
|
|
914
920
|
agent_name: agent_name, comment_id: comment_id, eventable: eventable,
|
|
915
|
-
deploy_intent: deploy_intent
|
|
921
|
+
deploy_intent: deploy_intent, cli_provider: cli_provider_override
|
|
916
922
|
)
|
|
917
923
|
end
|
|
918
924
|
|
|
@@ -936,7 +942,7 @@ def handle_comment(payload)
|
|
|
936
942
|
work_dir: work_dir, project_config: project_config, project_key: project_key,
|
|
937
943
|
comment_vars: comment_vars, plain_text: plain_text, model: model,
|
|
938
944
|
agent_name: agent_name, comment_id: comment_id, eventable: eventable,
|
|
939
|
-
deploy_intent: deploy_intent
|
|
945
|
+
deploy_intent: deploy_intent, cli_provider: cli_provider_override
|
|
940
946
|
)
|
|
941
947
|
[200, result.to_json]
|
|
942
948
|
else
|
|
@@ -1019,47 +1025,63 @@ def handle_comment(payload)
|
|
|
1019
1025
|
|
|
1020
1026
|
# Detect planning mode
|
|
1021
1027
|
card_tags = eventable.dig("card", "tags") || []
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1028
|
+
|
|
1029
|
+
# Check if we can resume the existing session (lean prompt) vs full prompt
|
|
1030
|
+
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name)
|
|
1031
|
+
should_resume = resolved["resume_flag"]
|
|
1032
|
+
|
|
1033
|
+
if should_resume
|
|
1034
|
+
prompt = render_resume_prompt(
|
|
1035
|
+
comment_body: plain_text,
|
|
1036
|
+
comment_creator: comment_vars["COMMENT_CREATOR"],
|
|
1037
|
+
comment_id: comment_id,
|
|
1038
|
+
card_number: card_number,
|
|
1039
|
+
agent_name: agent_name
|
|
1040
|
+
)
|
|
1041
|
+
LOG.info "[Resume] Using lean prompt for mention on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
|
|
1042
|
+
else
|
|
1043
|
+
planning_info = detect_planning_mode(
|
|
1044
|
+
text: plain_text,
|
|
1045
|
+
tags: card_tags,
|
|
1046
|
+
card_internal_id: card_internal_id,
|
|
1047
|
+
card_number: card_number
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
prompt = if planning_info
|
|
1051
|
+
# Planning mode
|
|
1052
|
+
card_id = planning_info[:card_id]
|
|
1053
|
+
LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}"
|
|
1054
|
+
|
|
1055
|
+
render_planning_prompt(PROMPT_MENTION,
|
|
1056
|
+
comment_vars.merge(
|
|
1057
|
+
"CARD_INTERNAL_ID" => card_internal_id,
|
|
1058
|
+
"CARD_ID" => card_id,
|
|
1059
|
+
"CARD_NUMBER" => card_number || "N/A",
|
|
1060
|
+
"CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
|
|
1061
|
+
"BRANCH" => branch
|
|
1062
|
+
),
|
|
1063
|
+
brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
|
|
1064
|
+
comment_body: plain_text, source: :fizzy),
|
|
1065
|
+
card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
|
|
1066
|
+
agent_name: agent_name)
|
|
1067
|
+
else
|
|
1068
|
+
render_prompt(PROMPT_MENTION,
|
|
1069
|
+
comment_vars.merge(
|
|
1070
|
+
"CARD_INTERNAL_ID" => card_internal_id,
|
|
1071
|
+
"CARD_ID" => card_number || card_internal_id,
|
|
1072
|
+
"CARD_NUMBER" => card_number || "N/A",
|
|
1073
|
+
"CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
|
|
1074
|
+
"BRANCH" => branch
|
|
1075
|
+
),
|
|
1076
|
+
brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
|
|
1077
|
+
comment_body: plain_text, source: :fizzy),
|
|
1078
|
+
card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
|
|
1079
|
+
agent_name: agent_name)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1060
1082
|
|
|
1061
1083
|
pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
|
|
1062
|
-
source: :fizzy, source_context: { card_number: card_number })
|
|
1084
|
+
source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override, resume: true)
|
|
1063
1085
|
register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
|
|
1064
1086
|
return [200,
|
|
1065
1087
|
{ status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
|
|
@@ -1182,7 +1204,7 @@ def handle_comment(payload)
|
|
|
1182
1204
|
end
|
|
1183
1205
|
|
|
1184
1206
|
pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
|
|
1185
|
-
source: :fizzy, source_context: { card_number: card_number })
|
|
1207
|
+
source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
|
|
1186
1208
|
register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
|
|
1187
1209
|
[200,
|
|
1188
1210
|
{ status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
|
|
@@ -1193,53 +1215,74 @@ end
|
|
|
1193
1215
|
# Dispatch a follow-up comment to the agent. Extracted so it can be called
|
|
1194
1216
|
# both inline (no active session) and from a queued background thread.
|
|
1195
1217
|
def dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:,
|
|
1196
|
-
model:, agent_name:, comment_id:, eventable:, deploy_intent: nil)
|
|
1218
|
+
model:, agent_name:, comment_id:, eventable:, deploy_intent: nil, cli_provider: nil)
|
|
1197
1219
|
card_tags = eventable.dig("card", "tags") || []
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1220
|
+
effort = detect_effort(project_config, tags: card_tags, text: plain_text)
|
|
1221
|
+
|
|
1222
|
+
# Determine if we should resume (worktree + provider supports it)
|
|
1223
|
+
is_worktree = work_dir != project_config["repo_path"]
|
|
1224
|
+
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
1225
|
+
should_resume = is_worktree && resolved["resume_flag"]
|
|
1226
|
+
|
|
1227
|
+
if should_resume
|
|
1228
|
+
# Lean prompt: only the new comment. The previous session
|
|
1229
|
+
# already has role, persona, knowledge, core instructions, and card history.
|
|
1230
|
+
prompt = render_resume_prompt(
|
|
1231
|
+
comment_body: plain_text,
|
|
1232
|
+
comment_creator: comment_vars["COMMENT_CREATOR"],
|
|
1233
|
+
comment_id: comment_id,
|
|
1234
|
+
card_number: card_number,
|
|
1235
|
+
agent_name: agent_name
|
|
1236
|
+
)
|
|
1237
|
+
LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
|
|
1238
|
+
else
|
|
1239
|
+
# Full prompt: no session to resume, build everything from scratch
|
|
1240
|
+
planning_info = detect_planning_mode(
|
|
1241
|
+
text: plain_text,
|
|
1242
|
+
tags: card_tags,
|
|
1243
|
+
card_internal_id: card_internal_id,
|
|
1244
|
+
card_number: card_number
|
|
1245
|
+
)
|
|
1204
1246
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1247
|
+
prompt = if planning_info
|
|
1248
|
+
card_id = planning_info[:card_id]
|
|
1249
|
+
LOG.info "[Planning] Planning mode active for card #{card_number || card_internal_id}"
|
|
1250
|
+
|
|
1251
|
+
if work_dir == project_config["repo_path"]
|
|
1252
|
+
render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
|
|
1253
|
+
comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id),
|
|
1254
|
+
brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
|
|
1255
|
+
source: :fizzy),
|
|
1256
|
+
card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"],
|
|
1257
|
+
agent_name: agent_name),
|
|
1258
|
+
agent_name: agent_name)
|
|
1259
|
+
else
|
|
1260
|
+
render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
|
|
1261
|
+
comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
|
|
1262
|
+
brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
|
|
1263
|
+
source: :fizzy),
|
|
1264
|
+
card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
|
|
1265
|
+
agent_name: agent_name)
|
|
1266
|
+
end
|
|
1267
|
+
elsif work_dir != project_config["repo_path"]
|
|
1268
|
+
render_prompt(PROMPT_FOLLOWUP_WORKTREE,
|
|
1269
|
+
comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
|
|
1270
|
+
brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
|
|
1271
|
+
source: :fizzy),
|
|
1272
|
+
card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
|
|
1273
|
+
agent_name: agent_name)
|
|
1217
1274
|
else
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1275
|
+
render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
|
|
1276
|
+
comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id),
|
|
1277
|
+
brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
|
|
1278
|
+
source: :fizzy),
|
|
1279
|
+
card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name),
|
|
1280
|
+
agent_name: agent_name)
|
|
1224
1281
|
end
|
|
1225
|
-
|
|
1226
|
-
render_prompt(PROMPT_FOLLOWUP_WORKTREE,
|
|
1227
|
-
comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
|
|
1228
|
-
brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
|
|
1229
|
-
source: :fizzy),
|
|
1230
|
-
card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
|
|
1231
|
-
agent_name: agent_name)
|
|
1232
|
-
else
|
|
1233
|
-
render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
|
|
1234
|
-
comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id),
|
|
1235
|
-
brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
|
|
1236
|
-
source: :fizzy),
|
|
1237
|
-
card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name),
|
|
1238
|
-
agent_name: agent_name)
|
|
1239
|
-
end
|
|
1282
|
+
end
|
|
1240
1283
|
|
|
1241
1284
|
pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "followup-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
|
|
1242
|
-
source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent })
|
|
1285
|
+
source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent }, cli_provider: cli_provider, resume: is_worktree)
|
|
1243
1286
|
register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
|
|
1244
1287
|
|
|
1245
1288
|
# Move card to Right Now — agent is actively working again
|