brainiac 0.0.11 → 0.0.13

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.
data/bin/brainiac CHANGED
@@ -912,46 +912,85 @@ when "setup"
912
912
  end
913
913
  end
914
914
 
915
- # Handler configuration
916
- brainiac_config_path = File.join(BRAINIAC_DIR, "brainiac.json")
915
+ # Agent setup — agents.json is the authority
916
+ agents_file = File.join(BRAINIAC_DIR, "agents.json")
917
+ existing_agents = if File.exist?(agents_file)
918
+ registry = JSON.parse(File.read(agents_file))
919
+ registry.keys
920
+ else
921
+ []
922
+ end
923
+
917
924
  puts ""
918
- if File.exist?(brainiac_config_path)
919
- puts " Handler config already exists at ~/.brainiac/brainiac.json"
925
+ if existing_agents.empty?
926
+ puts "No agents found in ~/.brainiac/agents.json"
927
+ print "Create your first agent? Name: "
928
+ agent_name = $stdin.gets&.strip
929
+ if agent_name && !agent_name.empty?
930
+ agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
931
+ registry = { agent_key => { "display_name" => agent_name, "local" => true, "env" => {} } }
932
+ File.write(agents_file, JSON.pretty_generate(registry))
933
+ puts "✓ Created #{agents_file} with agent '#{agent_name}'"
934
+ existing_agents = [agent_key]
935
+ else
936
+ agent_name = nil
937
+ puts " Skipped — add agents to ~/.brainiac/agents.json later"
938
+ end
920
939
  else
921
- puts "Which integrations do you want to enable?"
922
- puts "(You can change this later in ~/.brainiac/brainiac.json)"
923
- puts ""
924
-
925
- available_handlers = {
926
- "github" => "GitHub (PR reviews, CI failure handling, deploy tracking)",
927
- "zoho" => "Zoho Mail (email notifications and triage)"
928
- }
940
+ puts "Found #{existing_agents.size} agent(s): #{existing_agents.join(", ")}"
941
+ end
929
942
 
930
- enabled_handlers = {}
931
- available_handlers.each do |key, description|
932
- print " Enable #{description}? [Y/n] "
933
- answer = $stdin.gets&.strip&.downcase
934
- enabled_handlers[key] = answer != "n"
943
+ # Default agent selection
944
+ brainiac_config_path = File.join(BRAINIAC_DIR, "brainiac.json")
945
+ puts ""
946
+ if File.exist?(brainiac_config_path)
947
+ current = begin
948
+ JSON.parse(File.read(brainiac_config_path)).fetch("default_agent", nil)
949
+ rescue StandardError
950
+ nil
935
951
  end
936
-
937
- config = { "handlers" => enabled_handlers }
952
+ puts "✓ Config already exists at ~/.brainiac/brainiac.json (default_agent: #{current})"
953
+ else
954
+ default_agent = if existing_agents.size == 1
955
+ name = existing_agents.first
956
+ # Look up display name if available
957
+ display = begin
958
+ JSON.parse(File.read(agents_file)).dig(name, "display_name") || name.capitalize
959
+ rescue StandardError
960
+ name.capitalize
961
+ end
962
+ print "Set #{display} as default agent? [Y/n] "
963
+ answer = $stdin.gets&.strip&.downcase
964
+ answer == "n" ? nil : display
965
+ elsif existing_agents.size > 1
966
+ puts "Choose default agent:"
967
+ display_names = existing_agents.map do |key|
968
+ JSON.parse(File.read(agents_file)).dig(key, "display_name") || key.capitalize
969
+ rescue StandardError
970
+ key.capitalize
971
+ end
972
+ display_names.each_with_index { |a, i| puts " #{i + 1}) #{a}" }
973
+ print "Enter number [1]: "
974
+ choice = $stdin.gets&.strip
975
+ idx = choice.to_i - 1
976
+ idx = 0 if idx.negative? || idx >= display_names.size
977
+ display_names[idx]
978
+ else
979
+ agent_name
980
+ end
981
+
982
+ default_agent ||= existing_agents.first&.capitalize
983
+ config = { "default_agent" => default_agent }
938
984
  File.write(brainiac_config_path, JSON.pretty_generate(config))
939
- puts ""
940
- puts "✓ Wrote handler config to ~/.brainiac/brainiac.json"
941
-
942
- enabled_list = enabled_handlers.select { |_, v| v }.keys
943
- disabled_list = enabled_handlers.reject { |_, v| v }.keys
944
- puts " Enabled: #{enabled_list.join(", ")}" if enabled_list.any?
945
- puts " Disabled: #{disabled_list.join(", ")}" if disabled_list.any?
985
+ puts "✓ Wrote config to ~/.brainiac/brainiac.json (default_agent: #{default_agent})"
946
986
  end
947
987
 
948
988
  puts ""
949
989
  puts "Next steps:"
950
- puts " 1. Edit config files in ~/.brainiac/ with your secrets and IDs"
951
- puts " 2. Create agent configs in ~/.kiro/agents/"
952
- puts " 3. Register projects: cd ~/Code/myproject && brainiac register"
953
- puts " 4. Initialize brain: brainiac brain init"
954
- puts " 5. Start server: brainiac server"
990
+ puts " 1. Register projects: cd ~/Code/myproject && brainiac register"
991
+ puts " 2. Initialize brain: brainiac brain init"
992
+ puts " 3. Install plugins: brainiac install discord / fizzy / github / zoho"
993
+ puts " 4. Start server: brainiac server"
955
994
 
956
995
  # Shell completion setup
957
996
  puts ""
@@ -1054,64 +1093,6 @@ when "show"
1054
1093
  end
1055
1094
  show_project(ARGV[0])
1056
1095
 
1057
- when "handler", "handlers"
1058
- handler_cmd = ARGV.shift
1059
- case handler_cmd
1060
- when "list", "ls", nil
1061
- brainiac_config_path = File.join(BRAINIAC_DIR, "brainiac.json")
1062
- config = if File.exist?(brainiac_config_path)
1063
- JSON.parse(File.read(brainiac_config_path))
1064
- else
1065
- {}
1066
- end
1067
- handlers = config["handlers"] || {}
1068
-
1069
- # Built-in handlers
1070
- builtins = %w[github zoho]
1071
- puts "Built-in handlers:"
1072
- builtins.each do |name|
1073
- enabled = handlers.fetch(name, true)
1074
- status = enabled ? "✓ enabled" : "✗ disabled"
1075
- puts " #{name.ljust(10)} #{status}"
1076
- end
1077
-
1078
- # Custom handlers
1079
- custom_dir = File.join(BRAINIAC_DIR, "handlers")
1080
- if Dir.exist?(custom_dir)
1081
- custom_files = Dir.glob(File.join(custom_dir, "*.rb"))
1082
- if custom_files.any?
1083
- puts ""
1084
- puts "Custom handlers (#{custom_dir}):"
1085
- custom_files.each do |f|
1086
- name = File.basename(f, ".rb")
1087
- enabled = handlers.fetch(name, true)
1088
- status = enabled ? "✓ enabled" : "✗ disabled"
1089
- puts " #{name.ljust(10)} #{status}"
1090
- end
1091
- end
1092
- end
1093
-
1094
- when "enable"
1095
- name = ARGV.shift
1096
- unless name
1097
- puts "Usage: brainiac handler enable <name>"
1098
- exit 1
1099
- end
1100
- update_handler_config(name, true)
1101
-
1102
- when "disable"
1103
- name = ARGV.shift
1104
- unless name
1105
- puts "Usage: brainiac handler disable <name>"
1106
- exit 1
1107
- end
1108
- update_handler_config(name, false)
1109
-
1110
- else
1111
- puts "Usage: brainiac handler <list|enable|disable> [name]"
1112
- exit 1
1113
- end
1114
-
1115
1096
  when "config"
1116
1097
  OptionParser.new do |opts|
1117
1098
  opts.banner = "Usage: brainiac config [options]"
@@ -1322,8 +1303,8 @@ when "cron"
1322
1303
  opts.on("-p", "--project KEY", "Project key") { |v| project = v }
1323
1304
  opts.on("-m", "--model MODEL", "Model to use (opus, sonnet, haiku, auto)") { |v| model = v }
1324
1305
  opts.on("-e", "--effort LEVEL", "Effort level (low, medium, high, xhigh, max)") { |v| effort = v }
1325
- opts.on("-d", "--notify TARGET", "Notification target (channel ID, card number, etc.)") { |v| notify_target = v }
1326
- opts.on("-c", "--channel CHANNEL", "Notification channel (discord, fizzy — default: discord)") { |v| notify_channel = v }
1306
+ opts.on("-d", "--notify TARGET", "Notification target (channel ID, webhook URL, etc.)") { |v| notify_target = v }
1307
+ opts.on("-c", "--channel CHANNEL", "Notification channel name (provided by plugins)") { |v| notify_channel = v }
1327
1308
  opts.on("-t", "--title TITLE", "Forum post title (for forum channels)") { |v| forum_title = v }
1328
1309
  opts.on("-r", "--repeat COUNT", Integer, "Number of times to repeat (for recurring schedules)") { |v| repeat_count = v }
1329
1310
  opts.on("--script PATH", "Run script directly (no agent, output posted via notify)") { |v| script = v }
@@ -1366,7 +1347,6 @@ when "cron"
1366
1347
  payload[:script] = script if script
1367
1348
  payload[:model] = model if model
1368
1349
  payload[:effort] = effort if effort
1369
- payload[:discord_channel_id] = notify_target if notify_target && (!notify_channel || notify_channel == "discord")
1370
1350
  payload[:notify_channel] = notify_channel if notify_channel
1371
1351
  payload[:notify_target] = notify_target if notify_target
1372
1352
  payload[:forum_title] = forum_title if forum_title
@@ -1383,7 +1363,7 @@ when "cron"
1383
1363
  puts " Project: #{project}"
1384
1364
  puts " Model: #{model || "default"}" unless script
1385
1365
  puts " Effort: #{effort || "default"}" unless script
1386
- puts " Notify: #{notify_target ? "#{notify_channel || "discord"} → #{notify_target}" : "none"}"
1366
+ puts " Notify: #{notify_target ? "#{notify_channel || "default"} → #{notify_target}" : "none"}"
1387
1367
  puts " Forum title: #{forum_title || "auto"}" if notify_target && forum_title
1388
1368
  puts " Repeat: #{repeat_count ? "#{repeat_count} times" : "unlimited"}"
1389
1369
  puts script ? " Script: #{script}" : " Prompt: #{prompt}"
@@ -1428,9 +1408,9 @@ when "cron"
1428
1408
  puts " #{status} #{id}"
1429
1409
  puts " Schedule: #{schedule_display}"
1430
1410
  puts " Agent: #{job["script"] ? "none (script mode)" : job["agent"]} | Project: #{job["project"]}"
1431
- notify_ch = job["notify_channel"] || (job["discord_channel_id"] ? "discord" : nil)
1432
- notify_tgt = job["notify_target"] || job["discord_channel_id"]
1433
- puts " Notify: #{notify_ch} → #{notify_tgt}" if notify_tgt
1411
+ notify_ch = job["notify_channel"]
1412
+ notify_tgt = job["notify_target"]
1413
+ puts " Notify: #{notify_ch || "default"} → #{notify_tgt}" if notify_tgt
1434
1414
  puts job["script"] ? " Script: #{job["script"]}" : " Prompt: #{job["prompt"]}"
1435
1415
  puts " Last run: #{last_run}"
1436
1416
  puts
@@ -1527,14 +1507,14 @@ when "cron"
1527
1507
  opts.banner = "Usage: brainiac cron update <job-id> [options]"
1528
1508
  opts.on("-s", "--schedule EXPR", "New cron expression") { |v| schedule = v }
1529
1509
  opts.on("-d", "--notify TARGET", "New notification target") { |v| notify_target = v }
1530
- opts.on("-c", "--channel CHANNEL", "New notification channel (discord, fizzy)") { |v| notify_channel = v }
1510
+ opts.on("-c", "--channel CHANNEL", "New notification channel") { |v| notify_channel = v }
1531
1511
  opts.on("-t", "--title TITLE", "New forum post title") { |v| forum_title = v }
1532
1512
  end.parse!
1533
1513
 
1534
1514
  unless id && (schedule || notify_target || notify_channel || forum_title)
1535
1515
  puts "Error: Missing required arguments"
1536
1516
  puts "Usage: brainiac cron update <job-id> -s \"42 13 * * 1-5\""
1537
- puts " or: brainiac cron update <job-id> -d \"channel-id\" -c discord"
1517
+ puts " or: brainiac cron update <job-id> -d \"channel-id\" -c channel-name"
1538
1518
  puts " or: brainiac cron update <job-id> -t \"Daily Update\""
1539
1519
  exit 1
1540
1520
  end
@@ -1543,7 +1523,6 @@ when "cron"
1543
1523
  req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1544
1524
  payload = { id: id }
1545
1525
  payload[:schedule] = schedule if schedule
1546
- payload[:discord_channel_id] = notify_target if notify_target
1547
1526
  payload[:notify_channel] = notify_channel if notify_channel
1548
1527
  payload[:notify_target] = notify_target if notify_target
1549
1528
  payload[:forum_title] = forum_title if forum_title
@@ -1555,7 +1534,7 @@ when "cron"
1555
1534
  if data["success"]
1556
1535
  puts "✓ Updated cron job: #{id}"
1557
1536
  puts " New schedule: #{schedule}" if schedule
1558
- puts " New notify: #{notify_channel || "discord"} → #{notify_target}" if notify_target
1537
+ puts " New notify: #{notify_channel || "default"} → #{notify_target}" if notify_target
1559
1538
  puts " New forum title: #{forum_title}" if forum_title
1560
1539
  else
1561
1540
  puts "Error: #{data["error"]}"
@@ -1604,7 +1583,7 @@ when "cron"
1604
1583
  Examples:
1605
1584
  # Recurring
1606
1585
  brainiac cron add -s "0 9 * * *" -p marketplace "Summarize yesterday's work"
1607
- brainiac cron add -s "@daily" -p brainiac -a GLaDOS "Run security audit"
1586
+ brainiac cron add -s "@daily" -p brainiac -a Robin "Run security audit"
1608
1587
  #{" "}
1609
1588
  # One-time
1610
1589
  brainiac cron add -s "tomorrow at 9am" -p marketplace "Reminder about priorities"
@@ -1787,10 +1766,10 @@ when "agent"
1787
1766
  puts "If no options are given and stdin is a terminal, interactive mode is used."
1788
1767
  puts ""
1789
1768
  puts "Examples:"
1769
+ puts " brainiac agent create MyAgent # interactive"
1790
1770
  puts " brainiac agent create SecurityBot --local --role code-reviewer"
1791
1771
  puts " brainiac agent create Jane --role general-engineer --cli grok"
1792
- puts " brainiac agent create Avon --persona \"Dry wit, sardonic, brilliant\""
1793
- puts " brainiac agent create MyAgent # interactive"
1772
+ puts " brainiac agent create Merlin --persona \"Wise, enigmatic, helpful\""
1794
1773
  exit 1
1795
1774
  end
1796
1775
 
@@ -1941,16 +1920,16 @@ when "agent"
1941
1920
 
1942
1921
  Examples:
1943
1922
  brainiac agent list
1944
- brainiac agent default Galen
1923
+ brainiac agent default Sherlock
1945
1924
  brainiac agent create SecurityBot --local --role code-reviewer
1946
1925
  brainiac agent create Jane --cli grok
1947
1926
  brainiac agent create MyAgent # interactive mode
1948
1927
  brainiac agent remove SecurityBot
1949
- brainiac agent galen set local true
1950
- brainiac agent galen set persona "Dry, sardonic"
1951
- brainiac agent galen set role code-reviewer,general-engineer
1952
- brainiac agent galen env MY_TOKEN abc123
1953
- brainiac agent galen show
1928
+ brainiac agent sherlock set local true
1929
+ brainiac agent sherlock set persona "Dry, sardonic"
1930
+ brainiac agent sherlock set role code-reviewer,general-engineer
1931
+ brainiac agent sherlock env MY_TOKEN abc123
1932
+ brainiac agent sherlock show
1954
1933
  HELP
1955
1934
 
1956
1935
  else
@@ -2295,7 +2274,7 @@ when "update", "upgrade"
2295
2274
 
2296
2275
  plugin_entry = (plugins_config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name }
2297
2276
 
2298
- if plugin_entry && plugin_entry.is_a?(Hash) && plugin_entry["path"]
2277
+ if plugin_entry.is_a?(Hash) && plugin_entry["path"]
2299
2278
  puts "Plugin '#{name}' is installed from a local path: #{plugin_entry["path"]}"
2300
2279
  puts "Local plugins are updated via git pull in their source directory."
2301
2280
  exit 0
data/brainiac.gemspec CHANGED
@@ -3,9 +3,10 @@ require_relative "lib/brainiac/version"
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "brainiac"
5
5
  s.version = Brainiac::VERSION
6
- s.summary = "AI agent webhook receiver and dispatcher"
7
- s.description = "Webhook receiver that listens for GitHub and Zoho Mail events, then dispatches work to AI agent CLIs. " \
8
- "Additional channels (Discord, Fizzy) available via plugins."
6
+ s.summary = "Multi-agent orchestration layer for developer workflows"
7
+ s.description = "Core orchestration engine that manages AI agent identity, long-term memory (brain), " \
8
+ "prompt construction, and dispatch. Communication channels are provided by plugins: " \
9
+ "brainiac-discord, brainiac-fizzy, brainiac-github, and more."
9
10
  s.authors = ["Andy Davis"]
10
11
  s.homepage = "https://github.com/stowzilla/brainiac"
11
12
  s.license = "MIT"
@@ -10,39 +10,19 @@ This file configures how agents appear in the status bar — waybar on Linux, xb
10
10
  {
11
11
  "agents": [
12
12
  {
13
- "name": "GLaDOS",
14
- "emoji": "🤖",
15
- "color": "blue"
16
- },
17
- {
18
- "name": "Galen",
19
- "emoji": "🛠️",
13
+ "name": "Sherlock",
14
+ "emoji": "🔍",
20
15
  "color": "green"
21
16
  },
22
17
  {
23
- "name": "Threepio",
24
- "emoji": "📝",
25
- "color": "yellow"
18
+ "name": "Robin",
19
+ "emoji": "🏹",
20
+ "color": "blue"
26
21
  },
27
22
  {
28
- "name": "Sheogorath",
29
- "emoji": "🎭",
23
+ "name": "Merlin",
24
+ "emoji": "🧙",
30
25
  "color": "purple"
31
- },
32
- {
33
- "name": "Kaylee",
34
- "emoji": "🔧",
35
- "color": "pink"
36
- },
37
- {
38
- "name": "Avon",
39
- "emoji": "🔐",
40
- "color": "red"
41
- },
42
- {
43
- "name": "Sleeper Service",
44
- "emoji": "💤",
45
- "color": "cyan"
46
26
  }
47
27
  ],
48
28
  "default_emoji": "❓",
@@ -6,12 +6,12 @@
6
6
  # environment variable can be set per-agent:
7
7
  #
8
8
  # {
9
- # "galen": {
10
- # "display_name": "Galen",
9
+ # "sherlock": {
10
+ # "display_name": "Sherlock",
11
11
  # "local": true,
12
12
  # "env": {
13
13
  # "SOME_TOKEN": "token_abc...",
14
- # "DISCORD_BOT_TOKEN": "Bot_abc..."
14
+ # "OTHER_TOKEN": "Bot_abc..."
15
15
  # }
16
16
  # }
17
17
  # }
@@ -71,7 +71,7 @@ def agent_env_var(agent_name, var_name)
71
71
  end
72
72
 
73
73
  # Get the display name for an agent (from agents.json registry).
74
- # This is the human-readable canonical name (e.g., "GLaDOS" not "glados").
74
+ # This is the human-readable canonical name (e.g., "Robin" not "robin").
75
75
  # Core owns this — it's the identity used across all channels and plugins.
76
76
  def agent_display_name(agent_name)
77
77
  return agent_name unless agent_name
@@ -173,7 +173,7 @@ def detect_mentioned_agent(text)
173
173
  all_agent_names.each do |name|
174
174
  return name if downcased.include?("@#{name.downcase}")
175
175
 
176
- # Some systems render mentions using first name only (e.g. "@Sleeper" not "@Sleeper Service").
176
+ # Some systems render mentions using first name only (e.g. "@Robin" not "@Robin Hood").
177
177
  # Fall back to matching the first word of multi-word agent names.
178
178
  first_word = name.split.first.downcase
179
179
  next if first_word == name.downcase # already checked above
@@ -5,6 +5,7 @@ require "openssl"
5
5
  require "open3"
6
6
  require "fileutils"
7
7
  require "logger"
8
+ require "securerandom"
8
9
  require "net/http"
9
10
  require "uri"
10
11
 
@@ -46,15 +47,6 @@ AI_AGENT_NAME = ENV.fetch("AI_AGENT_NAME", nil) || BRAINIAC_CONFIG["default_agen
46
47
  MSG
47
48
  end
48
49
 
49
- # Check whether a handler is enabled. Defaults to true when brainiac.json
50
- # doesn't exist or doesn't list the handler (backwards compatible — all on).
51
- def handler_enabled?(name)
52
- handlers = BRAINIAC_CONFIG["handlers"]
53
- return true unless handlers # No config = everything enabled
54
-
55
- handlers.fetch(name.to_s, true)
56
- end
57
-
58
50
  LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
59
51
  LOG = Logger.new($stdout)
60
52
  LOG.level = case LOG_LEVEL
@@ -78,26 +70,6 @@ KNOWLEDGE_COLLECTION = "brainiac-knowledge"
78
70
 
79
71
  ROLES_DIR = File.join(BRAINIAC_DIR, "roles")
80
72
 
81
- # --- GitHub auth ---
82
-
83
- GITHUB_CONFIG_FILE = File.join(BRAINIAC_DIR, "github.json")
84
-
85
- def load_github_config
86
- return {} unless File.exist?(GITHUB_CONFIG_FILE)
87
-
88
- JSON.parse(File.read(GITHUB_CONFIG_FILE))
89
- rescue JSON::ParserError => e
90
- LOG.error "Failed to parse GitHub config: #{e.message}"
91
- {}
92
- end
93
-
94
- GITHUB_CONFIG = load_github_config
95
-
96
- def github_webhook_secret
97
- # Fallback to env var
98
- GITHUB_CONFIG["webhook_secret"] || ENV.fetch("GITHUB_WEBHOOK_SECRET", nil)
99
- end
100
-
101
73
  NOTIFICATION_COMMAND = ENV.fetch("NOTIFICATION_COMMAND", nil)
102
74
 
103
75
  # --- Projects ---
@@ -137,13 +109,6 @@ def reload_projects!(force: false)
137
109
  LOG.info "Reloaded projects configuration: #{PROJECTS.keys.join(", ")}"
138
110
  end
139
111
 
140
- def reload_github_config!(force: false)
141
- return unless file_changed?(GITHUB_CONFIG_FILE, force: force)
142
-
143
- GITHUB_CONFIG.replace(load_github_config)
144
- LOG.info "Reloaded GitHub configuration"
145
- end
146
-
147
112
  PROJECTS = load_projects_config
148
113
 
149
114
  DEFAULT_PROJECT = {
data/lib/brainiac/cron.rb CHANGED
@@ -199,17 +199,14 @@ def reload_cron_jobs!(force: false)
199
199
  end
200
200
 
201
201
  # Add a new cron job
202
- def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil,
202
+ def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil,
203
203
  notify_channel: nil, notify_target: nil,
204
204
  forum_title: nil, forum_reply_to_latest: false, repeat_count: nil)
205
205
  parsed = parse_cron_expression(schedule)
206
206
  return { error: "Invalid cron expression" } unless parsed
207
207
  return { error: "Must provide either prompt or script, not both" } if prompt && script
208
208
  return { error: "Must provide either prompt or script" } unless prompt || script
209
-
210
- # Normalize: discord_channel_id is legacy shorthand for notify_channel: discord, notify_target: <id>
211
- notify_channel ||= :discord if discord_channel_id
212
- notify_target ||= discord_channel_id
209
+ return { error: "notify_target requires notify_channel (e.g. -c discord)" } if notify_target && !notify_channel
213
210
 
214
211
  job = {
215
212
  id: id,
@@ -224,7 +221,6 @@ def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, ena
224
221
  enabled: enabled,
225
222
  notify_channel: notify_channel&.to_s,
226
223
  notify_target: notify_target,
227
- discord_channel_id: discord_channel_id,
228
224
  forum_title: forum_title,
229
225
  forum_reply_to_latest: forum_reply_to_latest,
230
226
  repeat_count: repeat_count,
@@ -269,9 +265,9 @@ def toggle_cron_job(id, enabled)
269
265
  end
270
266
  end
271
267
 
272
- # Update a cron job's schedule, discord channel, and/or forum title
273
- def update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil)
274
- return { error: "No updates provided" } if schedule.nil? && discord_channel_id.nil? && forum_title.nil? && forum_reply_to_latest.nil?
268
+ # Update a cron job's schedule, notification target, and/or forum title
269
+ def update_cron_job(id, schedule: nil, notify_target: nil, notify_channel: nil, forum_title: nil, forum_reply_to_latest: nil)
270
+ return { error: "No updates provided" } if schedule.nil? && notify_target.nil? && forum_title.nil? && forum_reply_to_latest.nil?
275
271
 
276
272
  if schedule
277
273
  parsed = parse_cron_expression(schedule)
@@ -287,7 +283,8 @@ def update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil
287
283
  job[:schedule] = schedule
288
284
  job[:parsed] = parsed
289
285
  end
290
- job[:discord_channel_id] = discord_channel_id if discord_channel_id
286
+ job[:notify_target] = notify_target if notify_target
287
+ job[:notify_channel] = notify_channel if notify_channel
291
288
  job[:forum_title] = forum_title if forum_title
292
289
  job[:forum_reply_to_latest] = forum_reply_to_latest unless forum_reply_to_latest.nil?
293
290
  jobs[id.to_sym] = job
@@ -315,7 +312,7 @@ def execute_script_job(job, project)
315
312
  log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
316
313
  FileUtils.mkdir_p(File.dirname(log_file))
317
314
 
318
- draft_file = prepare_script_notification_draft(job, timestamp) if job[:notify_target] || job[:discord_channel_id]
315
+ draft_file = prepare_script_notification_draft(job, timestamp) if job[:notify_target]
319
316
 
320
317
  LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}"
321
318
 
@@ -343,8 +340,8 @@ def prepare_script_notification_draft(job, timestamp)
343
340
 
344
341
  script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
345
342
  meta = {
346
- notify_channel: (job[:notify_channel] || "discord").to_s,
347
- notify_target: job[:notify_target] || job[:discord_channel_id],
343
+ notify_channel: job[:notify_channel]&.to_s,
344
+ notify_target: job[:notify_target],
348
345
  agent_key: script_agent_key,
349
346
  agent_name: job[:agent] || "Script",
350
347
  cron_job_id: job[:id],
@@ -361,9 +358,10 @@ def deliver_script_output(job, log_file, draft_file)
361
358
  return unless File.exist?(log_file)
362
359
 
363
360
  output = File.read(log_file).strip
364
- has_notify = job[:notify_target] || job[:discord_channel_id]
361
+ has_notify = job[:notify_target]
365
362
 
366
363
  if has_notify && draft_file && !output.empty?
364
+ LOG.warn "[Cron] Job #{job[:id]} has notify_target but no notify_channel — notification may not be delivered" unless job[:notify_channel]
367
365
  File.write(draft_file, output)
368
366
  # Deliver via notification system
369
367
  notify_cron_output(job, output, agent_name: job[:agent])
@@ -380,7 +378,7 @@ def build_cron_prompt(job, project)
380
378
  prompt = job[:prompt]
381
379
  agent_name = job[:agent]
382
380
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
383
- has_notify = job[:notify_target] || job[:discord_channel_id]
381
+ has_notify = job[:notify_target]
384
382
 
385
383
  if has_notify
386
384
  notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
@@ -389,8 +387,8 @@ def build_cron_prompt(job, project)
389
387
  meta_file = "#{draft_file}.meta.json"
390
388
 
391
389
  agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
392
- notify_channel = (job[:notify_channel] || "discord").to_s
393
- notify_target = job[:notify_target] || job[:discord_channel_id]
390
+ notify_channel = job[:notify_channel]&.to_s
391
+ notify_target = job[:notify_target]
394
392
 
395
393
  meta = {
396
394
  notify_channel: notify_channel,
@@ -3,7 +3,8 @@
3
3
  # Shared inline tag parsing for handler messages.
4
4
  #
5
5
  # Messages from any channel can contain inline tags like:
6
- # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
6
+ # [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan],
7
+ # [branch:feature-xyz], [workitem:wi-abc123]
7
8
  #
8
9
  # This module provides a single parser that extracts all tags and returns
9
10
  # a structured result with the cleaned text.
@@ -31,9 +32,21 @@ def parse_inline_tags(text)
31
32
  planning: false,
32
33
  deploy_intent: nil,
33
34
  worktree_override: nil,
35
+ work_item: nil,
36
+ branch_override: nil,
34
37
  clean_text: text.dup
35
38
  }
36
39
 
40
+ parse_value_tags(result)
41
+ parse_flag_tags(result)
42
+ parse_work_item_tags(result)
43
+ parse_model_tag(result)
44
+
45
+ result[:clean_text].strip!
46
+ result
47
+ end
48
+
49
+ def parse_value_tags(result)
37
50
  # [project:my-project]
38
51
  if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
39
52
  result[:project] = match[1]
@@ -52,6 +65,14 @@ def parse_inline_tags(text)
52
65
  result[:clean_text].sub!(match[0], "")
53
66
  end
54
67
 
68
+ # [deploy] or [deploy:dev01]
69
+ if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
70
+ result[:deploy_intent] = match[1]&.strip&.downcase || :auto
71
+ result[:clean_text].sub!(match[0], "")
72
+ end
73
+ end
74
+
75
+ def parse_flag_tags(result)
55
76
  # [chat], [question], [?]
56
77
  if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
57
78
  result[:chat_mode] = true
@@ -59,23 +80,35 @@ def parse_inline_tags(text)
59
80
  end
60
81
 
61
82
  # [plan]
62
- if result[:clean_text].match?(/\[plan\]/i)
63
- result[:planning] = true
64
- result[:clean_text].sub!(/\[plan\]/i, "")
83
+ return unless result[:clean_text].match?(/\[plan\]/i)
84
+
85
+ result[:planning] = true
86
+ result[:clean_text].sub!(/\[plan\]/i, "")
87
+ end
88
+
89
+ def parse_work_item_tags(result)
90
+ # [worktree:branch-name] — legacy syntax, still supported
91
+ if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
92
+ result[:worktree_override] = match[1].strip
93
+ result[:clean_text].sub!(match[0], "")
65
94
  end
66
95
 
67
- # [deploy] or [deploy:dev01]
68
- if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
69
- result[:deploy_intent] = match[1]&.strip&.downcase || :auto
96
+ # [branch:branch-name] preferred syntax for targeting a branch/worktree
97
+ if (match = result[:clean_text].match(/\[branch:([^\]]+)\]/i))
98
+ result[:branch_override] = match[1].strip
99
+ # Also set worktree_override for backward compat with plugins that read it
100
+ result[:worktree_override] ||= result[:branch_override]
70
101
  result[:clean_text].sub!(match[0], "")
71
102
  end
72
103
 
73
- # [worktree:branch-name]
74
- if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
75
- result[:worktree_override] = match[1].strip
104
+ # [workitem:wi-abc123] — target a specific work item by ID
105
+ if (match = result[:clean_text].match(/\[workitem:([^\]]+)\]/i))
106
+ result[:work_item] = match[1].strip
76
107
  result[:clean_text].sub!(match[0], "")
77
108
  end
109
+ end
78
110
 
111
+ def parse_model_tag(result)
79
112
  # Model tag: any remaining [word] that isn't a known tag — detected separately
80
113
  # because it depends on the project's allowed_models config. We just capture
81
114
  # the raw match here for the caller to resolve.
@@ -83,7 +116,4 @@ def parse_inline_tags(text)
83
116
  result[:model_tag] = match[1].downcase
84
117
  result[:clean_text].sub!(match[0], "")
85
118
  end
86
-
87
- result[:clean_text].strip!
88
- result
89
119
  end