brainiac 0.0.23 → 0.0.26

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bdf84241f89389aef9a1693abd00b1cfdf77d645c096081acb4ea0e8d1de8ed
4
- data.tar.gz: 9ed60b3c1ba3fdcdcae4e3b05cfb15fa2a1a60c6311cd8add2c7fa9a3e4e839a
3
+ metadata.gz: 963f3a3319ca2ccff44fc003072a0566d3b76d06a42ea645d81693bb48ea2ab3
4
+ data.tar.gz: 7abb9b38680549aaf3da6068b459f0faf474f5862f7f235f1dabddfcb38d5e55
5
5
  SHA512:
6
- metadata.gz: 7e5df006c01b492f2e1e9f9918a208b2bcdbdefcf0df032662b12d2f107872f1456ba37200ad70ebd19d3fa218da030a35681249ec4f232af56da4132bc7eef8
7
- data.tar.gz: 24cc2728b08071c0ecc4c52bbabedf568446749322e27aad386c5b23395affce9e6b1c16c06feace6ba8487d261dc136a64db6325de1d75f1efc0ca320bef5f4
6
+ metadata.gz: dae8e213cb21a26f97de1406a5fe0bc1ce7dc5806e3b67079e2c0183b94888439f288243d86dc52fd9831d1ee61294a2f28fe9fe4f88dc03819c1d720a95db9b
7
+ data.tar.gz: 2e7cf53a24fd776e2eb7960c379dc49309d932412f5fe39a8a56dd90a6162dcf3a9924396a7386b67a0288c0034381343effb529e53b8bcf4b4f43b7fc454708
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.23)
4
+ brainiac (0.0.26)
5
5
  puma (~> 7.2)
6
6
  rackup (~> 2.3)
7
7
  sinatra (~> 4.1)
@@ -84,7 +84,7 @@ DEPENDENCIES
84
84
  CHECKSUMS
85
85
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
86
86
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
87
- brainiac (0.0.23)
87
+ brainiac (0.0.26)
88
88
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
89
89
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/bin/brainiac CHANGED
@@ -208,14 +208,7 @@ end
208
208
  def stop_server
209
209
  pid = find_server_pid
210
210
 
211
- unless pid
212
- puts "No running Brainiac server found."
213
- return false
214
- end
215
-
216
- puts "Stopping Brainiac server (PID: #{pid})..."
217
-
218
- # Also stop the monitor daemon
211
+ # Stop the monitor daemon regardless of server PID
219
212
  daemon_pid_file = "/tmp/brainiac-daemon.pid"
220
213
  if File.exist?(daemon_pid_file)
221
214
  daemon_pid = File.read(daemon_pid_file).strip.to_i
@@ -228,40 +221,61 @@ def stop_server
228
221
  File.delete(daemon_pid_file)
229
222
  end
230
223
 
231
- begin
232
- Process.kill("TERM", pid)
233
-
234
- # Wait up to 5 seconds for graceful shutdown
235
- 5.times do
236
- sleep 2
237
- next if process_running?(pid)
224
+ # Kill the known server PID
225
+ if pid
226
+ puts "Stopping Brainiac server (PID: #{pid})..."
227
+ kill_pid(pid)
228
+ end
238
229
 
239
- puts "✓ Server stopped."
240
- FileUtils.rm_f(PID_FILE)
241
- return true
242
- end
230
+ # After killing the known PID, find anything still holding port 4567.
231
+ # This catches orphaned processes from self-restarts, stale PIDs, or child processes.
232
+ lingering = find_port_holders(4567) - [pid].compact
233
+ if lingering.any?
234
+ puts "Cleaning up #{lingering.size} orphaned process(es) on port 4567..."
235
+ lingering.each { |lpid| kill_pid(lpid, quiet: true) }
236
+ end
243
237
 
244
- # Force kill if still running
245
- puts "Server didn't stop gracefully, forcing..."
246
- Process.kill("KILL", pid)
247
- sleep 1
238
+ FileUtils.rm_f(PID_FILE)
248
239
 
249
- FileUtils.rm_f(PID_FILE)
250
- puts "✓ Server stopped (forced)."
240
+ if pid || lingering.any?
241
+ puts "✓ Server stopped."
251
242
  true
252
- rescue Errno::ESRCH
253
- puts " Server already stopped."
254
- FileUtils.rm_f(PID_FILE)
255
- true
256
- rescue Errno::EPERM
257
- puts "Error: Permission denied. Try running with sudo or check process ownership."
258
- false
259
- rescue StandardError => e
260
- puts "Error stopping server: #{e.message}"
243
+ else
244
+ puts "No running Brainiac server found."
261
245
  false
262
246
  end
263
247
  end
264
248
 
249
+ # Kill a single process: TERM first, KILL if it doesn't exit within 5s.
250
+ def kill_pid(pid, quiet: false)
251
+ Process.kill("TERM", pid)
252
+
253
+ 5.times do
254
+ sleep 1
255
+ return true unless process_running?(pid)
256
+ end
257
+
258
+ puts " PID #{pid} didn't stop gracefully, forcing..." unless quiet
259
+ Process.kill("KILL", pid)
260
+ sleep 1
261
+ true
262
+ rescue Errno::ESRCH
263
+ true # Already dead
264
+ rescue Errno::EPERM
265
+ puts "Error: Permission denied to stop PID #{pid}." unless quiet
266
+ false
267
+ end
268
+
269
+ # Find all PIDs listening on a given port via lsof.
270
+ def find_port_holders(port)
271
+ output, status = Open3.capture2("lsof", "-ti", ":#{port}")
272
+ return [] unless status.success?
273
+
274
+ output.strip.split("\n").map(&:to_i).select(&:positive?)
275
+ rescue StandardError
276
+ []
277
+ end
278
+
265
279
  def update_handler_config(name, enabled)
266
280
  brainiac_config_path = File.join(BRAINIAC_DIR, "brainiac.json")
267
281
  config = if File.exist?(brainiac_config_path)
@@ -527,6 +541,84 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
527
541
  SKILL
528
542
  end
529
543
 
544
+ SCREENSHOT_CREDS_FILE = File.join(BRAINIAC_DIR, "tmp", "screenshot-creds.json")
545
+
546
+ # Resolve all op:// references in deployments.json and write to a secure cache file.
547
+ # This allows screenshot commands to work without re-authenticating with 1Password.
548
+ def resolve_screenshot_credentials(quiet: false)
549
+ deployments_file = File.join(BRAINIAC_DIR, "deployments.json")
550
+ unless File.exist?(deployments_file)
551
+ puts " ⚠ No deployments.json found, skipping credential resolution." unless quiet
552
+ return false
553
+ end
554
+
555
+ deployments = JSON.parse(File.read(deployments_file))
556
+ environments = deployments["environments"] || {}
557
+
558
+ # Collect all unique op:// references
559
+ op_refs = {}
560
+ environments.each_value do |env_config|
561
+ screenshot = env_config["screenshot"]
562
+ next unless screenshot
563
+
564
+ (screenshot["customers"] || {}).each_value { |ref| op_refs[ref] = nil }
565
+ (screenshot["employees"] || {}).each_value { |ref| op_refs[ref] = nil }
566
+ end
567
+
568
+ if op_refs.empty?
569
+ puts " ⚠ No screenshot credentials to resolve." unless quiet
570
+ return false
571
+ end
572
+
573
+ puts " Resolving #{op_refs.size} credential(s) from 1Password..." unless quiet
574
+
575
+ # Resolve each unique op:// reference
576
+ failed = []
577
+ op_refs.each_key do |ref|
578
+ password, status = Open3.capture2("op", "read", ref)
579
+ if status.success?
580
+ op_refs[ref] = password.chomp
581
+ else
582
+ failed << ref
583
+ end
584
+ end
585
+
586
+ if failed.any?
587
+ puts " ❌ Failed to resolve #{failed.size} credential(s):" unless quiet
588
+ failed.each { |ref| puts " #{ref}" } unless quiet
589
+ return false
590
+ end
591
+
592
+ # Write resolved credentials to cache file with restrictive permissions
593
+ cache_dir = File.join(BRAINIAC_DIR, "tmp")
594
+ FileUtils.mkdir_p(cache_dir)
595
+ File.write(SCREENSHOT_CREDS_FILE, JSON.pretty_generate(op_refs))
596
+ File.chmod(0o600, SCREENSHOT_CREDS_FILE)
597
+
598
+ puts " ✓ Screenshot credentials cached (#{op_refs.size} resolved)" unless quiet
599
+ true
600
+ rescue JSON::ParserError => e
601
+ puts " ❌ Failed to parse deployments.json: #{e.message}" unless quiet
602
+ false
603
+ end
604
+
605
+ # Read a resolved credential from the cache file, falling back to `op read`.
606
+ def read_screenshot_credential(op_ref)
607
+ # Try cache first
608
+ if File.exist?(SCREENSHOT_CREDS_FILE)
609
+ creds = JSON.parse(File.read(SCREENSHOT_CREDS_FILE))
610
+ return creds[op_ref] if creds[op_ref]
611
+ end
612
+
613
+ # Fallback: resolve directly via op read
614
+ password, status = Open3.capture2("op", "read", op_ref)
615
+ return password.chomp if status.success?
616
+
617
+ nil
618
+ rescue JSON::ParserError
619
+ nil
620
+ end
621
+
530
622
  def start_server(daemon: false)
531
623
  # Resolve the real path of the brainiac script (follows symlinks)
532
624
  receiver_path = File.join(BRAINIAC_ROOT, "receiver.rb")
@@ -544,6 +636,9 @@ def start_server(daemon: false)
544
636
  exit 1
545
637
  end
546
638
 
639
+ # Pre-resolve screenshot credentials from 1Password (so agents can screenshot without re-auth)
640
+ resolve_screenshot_credentials
641
+
547
642
  if daemon
548
643
  # Daemon mode: start in background (like the old behavior)
549
644
  log_dir = File.join(receiver_dir, "tmp")
@@ -1107,8 +1202,7 @@ when "unregister", "remove", "rm"
1107
1202
  when "list", "ls"
1108
1203
  list_projects
1109
1204
 
1110
- when "projects"
1111
- # Support "brainiac projects list" as an alias for "brainiac list"
1205
+ when "projects", "project"
1112
1206
  projects_cmd = ARGV.shift
1113
1207
  case projects_cmd
1114
1208
  when "list", "ls", nil
@@ -1140,9 +1234,140 @@ when "projects"
1140
1234
  save_projects(projects)
1141
1235
 
1142
1236
  puts "✓ Set '#{project_key}' as the default project"
1237
+ when "update", "set"
1238
+ project_key = ARGV.shift
1239
+ field = ARGV.shift
1240
+ value = ARGV.join(" ") # Allow multi-word values (e.g. tags with spaces)
1241
+
1242
+ unless project_key && field
1243
+ puts "Usage: brainiac projects update <key> <field> <value>"
1244
+ puts ""
1245
+ puts "Fields:"
1246
+ puts " repo_path <path> Path to the project repository"
1247
+ puts " github_repo <owner/repo> GitHub repository (e.g. stowzilla/brainiac)"
1248
+ puts " fizzy_tags <tag1,tag2> Comma-separated Fizzy tags"
1249
+ puts " fizzy_board <board> Default Fizzy board key"
1250
+ puts " cli_provider <provider> CLI provider name (e.g. kiro, grok)"
1251
+ puts " agent_name <name> Default agent for this project"
1252
+ puts " agent_model <model> Default model (e.g. auto, opus, sonnet)"
1253
+ puts " default_branch <branch> Default git branch (e.g. main, master)"
1254
+ puts ""
1255
+ puts "Examples:"
1256
+ puts " brainiac projects update brainiac fizzy_board stowzilla"
1257
+ puts " brainiac projects update marketplace cli_provider grok"
1258
+ puts " brainiac projects update brainiac fizzy_tags brainiac,core"
1259
+ puts " brainiac projects update marketplace agent_model opus"
1260
+ exit 1
1261
+ end
1262
+
1263
+ projects = load_projects
1264
+ unless projects.key?(project_key)
1265
+ puts "Error: Project '#{project_key}' not found."
1266
+ puts "Available projects: #{projects.keys.join(", ")}"
1267
+ exit 1
1268
+ end
1269
+
1270
+ # Handle special field parsing
1271
+ case field
1272
+ when "fizzy_tags", "tags"
1273
+ field = "fizzy_tags"
1274
+ unless value && !value.empty?
1275
+ puts "Error: Value required. Provide comma-separated tags."
1276
+ exit 1
1277
+ end
1278
+ parsed_value = value.split(",").map(&:strip)
1279
+ when "repo_path"
1280
+ unless value && !value.empty?
1281
+ puts "Error: Value required."
1282
+ exit 1
1283
+ end
1284
+ parsed_value = File.expand_path(value)
1285
+ puts "Warning: Path does not exist: #{parsed_value}" unless Dir.exist?(parsed_value)
1286
+ else
1287
+ # Handles known fields (github_repo, fizzy_board, cli_provider, agent_name, agent_model,
1288
+ # default_branch) and arbitrary fields for forward-compatibility
1289
+ unless value && !value.empty?
1290
+ puts "Error: Value required."
1291
+ exit 1
1292
+ end
1293
+ parsed_value = value
1294
+ end
1295
+
1296
+ projects[project_key][field] = parsed_value
1297
+ save_projects(projects)
1298
+
1299
+ display_value = parsed_value.is_a?(Array) ? parsed_value.join(", ") : parsed_value
1300
+ puts "✓ Updated #{project_key}: #{field} = #{display_value}"
1301
+
1302
+ when "unset", "delete-field"
1303
+ project_key = ARGV.shift
1304
+ field = ARGV.shift
1305
+
1306
+ unless project_key && field
1307
+ puts "Usage: brainiac projects unset <key> <field>"
1308
+ puts ""
1309
+ puts "Removes a field from a project's configuration."
1310
+ puts ""
1311
+ puts "Examples:"
1312
+ puts " brainiac projects unset marketplace fizzy_board"
1313
+ puts " brainiac projects unset brainiac default_branch"
1314
+ exit 1
1315
+ end
1316
+
1317
+ projects = load_projects
1318
+ unless projects.key?(project_key)
1319
+ puts "Error: Project '#{project_key}' not found."
1320
+ exit 1
1321
+ end
1322
+
1323
+ unless projects[project_key].key?(field)
1324
+ puts "Error: Field '#{field}' not set on project '#{project_key}'."
1325
+ exit 1
1326
+ end
1327
+
1328
+ projects[project_key].delete(field)
1329
+ save_projects(projects)
1330
+ puts "✓ Removed #{field} from #{project_key}"
1331
+
1332
+ when "help", "--help", "-h"
1333
+ puts <<~HELP
1334
+ Usage: brainiac projects <command> [options]
1335
+
1336
+ Commands:
1337
+ list List all registered projects (default)
1338
+ show <key> Show detailed configuration for a project
1339
+ default <key> Set the default project (fallback when no tags match)
1340
+ update <key> <field> <value> Update a project config field
1341
+ unset <key> <field> Remove a field from project config
1342
+
1343
+ Aliases:
1344
+ brainiac project Works the same as 'brainiac projects'
1345
+ brainiac list Shortcut for 'brainiac projects list'
1346
+ brainiac show Shortcut for 'brainiac projects show'
1347
+
1348
+ Update fields:
1349
+ repo_path Path to the project repository
1350
+ github_repo GitHub repository (owner/repo)
1351
+ fizzy_tags Comma-separated Fizzy tags
1352
+ fizzy_board Default Fizzy board key
1353
+ cli_provider CLI provider name (kiro, grok, etc.)
1354
+ agent_name Default agent for this project
1355
+ agent_model Default model (auto, opus, sonnet, haiku, etc.)
1356
+ default_branch Default git branch
1357
+
1358
+ Examples:
1359
+ brainiac projects list
1360
+ brainiac project show marketplace
1361
+ brainiac projects default marketplace
1362
+ brainiac projects update brainiac fizzy_board stowzilla
1363
+ brainiac projects update marketplace agent_model opus
1364
+ brainiac projects update brainiac fizzy_tags brainiac,core
1365
+ brainiac projects unset marketplace fizzy_board
1366
+ HELP
1143
1367
  else
1144
1368
  puts "Unknown projects command: #{projects_cmd}"
1145
- puts "Available: list, show, default"
1369
+ puts "Available: list, show, default, update, unset, help"
1370
+ puts "Run 'brainiac projects help' for more details."
1146
1371
  exit 1
1147
1372
  end
1148
1373
 
@@ -3161,16 +3386,404 @@ when "plugin"
3161
3386
  puts " brainiac install #{plugin_name} --path #{output_dir}"
3162
3387
  puts " brainiac restart"
3163
3388
 
3389
+ when "list", "ls"
3390
+ # Delegate to the same logic as `brainiac plugins`
3391
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
3392
+ plugins_config = if File.exist?(plugins_file)
3393
+ JSON.parse(File.read(plugins_file))
3394
+ else
3395
+ { "plugins" => [] }
3396
+ end
3397
+
3398
+ plugins = plugins_config["plugins"] || []
3399
+ if plugins.empty?
3400
+ puts "No plugins installed."
3401
+ puts ""
3402
+ puts "Install plugins with: brainiac install <name>"
3403
+ puts " Example: brainiac install whatsapp"
3404
+ puts " Example: brainiac install fizzy --path ~/Code/brainiac-fizzy"
3405
+ else
3406
+ puts "Installed plugins:"
3407
+ plugins.each do |p|
3408
+ entry = p.is_a?(Hash) ? p : { "name" => p.to_s }
3409
+ name = entry["name"]
3410
+ gem_name = entry["gem"] || "brainiac-#{name}"
3411
+ local_path = entry["path"]
3412
+ installed_at = entry["installed_at"] ? " (#{entry["installed_at"][0..9]})" : ""
3413
+
3414
+ if local_path
3415
+ if Dir.exist?(local_path)
3416
+ puts " ✓ #{name} → #{local_path}#{installed_at}"
3417
+ else
3418
+ puts " ✗ #{name} → #{local_path} (path missing)#{installed_at}"
3419
+ end
3420
+ else
3421
+ loadable = begin
3422
+ spec = Gem::Specification.find_by_name(gem_name)
3423
+ "✓ #{name} (#{gem_name} #{spec.version})"
3424
+ rescue Gem::MissingSpecError
3425
+ "✗ #{name} (#{gem_name} — gem not found)"
3426
+ end
3427
+ puts " #{loadable}#{installed_at}"
3428
+ end
3429
+ end
3430
+ end
3431
+
3432
+ when "switch"
3433
+ plugin_name = ARGV.shift
3434
+ branch_name = ARGV.shift
3435
+
3436
+ unless plugin_name && branch_name
3437
+ puts "Usage: brainiac plugin switch <name> <branch>"
3438
+ puts ""
3439
+ puts "Switches a local plugin to a different branch (via git worktrees)."
3440
+ puts "Useful for testing plugin changes from a PR before releasing."
3441
+ puts ""
3442
+ puts "Examples:"
3443
+ puts " brainiac plugin switch fizzy fix-fallback-for-followups-20260813"
3444
+ puts " brainiac plugin switch fizzy main # switch back to main"
3445
+ puts ""
3446
+ puts "After switching, restart the server: brainiac restart"
3447
+ exit 1
3448
+ end
3449
+
3450
+ plugin_name = plugin_name.sub(/^brainiac-/, "")
3451
+
3452
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
3453
+ unless File.exist?(plugins_file)
3454
+ puts "Error: No plugins installed (plugins.json not found)."
3455
+ exit 1
3456
+ end
3457
+
3458
+ plugins_config = JSON.parse(File.read(plugins_file))
3459
+ plugin_idx = (plugins_config["plugins"] || []).index { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == plugin_name }
3460
+
3461
+ unless plugin_idx
3462
+ puts "Error: Plugin '#{plugin_name}' is not installed."
3463
+ exit 1
3464
+ end
3465
+
3466
+ entry = plugins_config["plugins"][plugin_idx]
3467
+ unless entry.is_a?(Hash) && entry["path"]
3468
+ puts "Error: Plugin '#{plugin_name}' is not a local-path plugin."
3469
+ puts " Only plugins installed with --path support branch switching."
3470
+ exit 1
3471
+ end
3472
+
3473
+ current_path = entry["path"]
3474
+ gem_name = "brainiac-#{plugin_name}"
3475
+
3476
+ # Determine the base repo path (strip worktree suffix like --branch-name)
3477
+ base_path = current_path.sub(/--[^\/]+$/, "")
3478
+ unless Dir.exist?(base_path)
3479
+ puts "Error: Base repo not found at #{base_path}"
3480
+ exit 1
3481
+ end
3482
+
3483
+ # Get the default branch of the repo
3484
+ default_branch, _, db_status = Open3.capture3("git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short", chdir: base_path)
3485
+ default_branch = default_branch.strip.sub("origin/", "") if db_status.success?
3486
+ default_branch = "main" unless db_status.success? && !default_branch.empty?
3487
+
3488
+ # If switching to the default branch, just point back to the base path
3489
+ if branch_name == default_branch
3490
+ if current_path == base_path
3491
+ puts "Plugin '#{plugin_name}' is already on #{default_branch}."
3492
+ exit 0
3493
+ end
3494
+
3495
+ plugins_config["plugins"][plugin_idx]["path"] = base_path
3496
+ File.write(plugins_file, JSON.pretty_generate(plugins_config))
3497
+ puts "✓ Switched plugin '#{plugin_name}' back to #{default_branch}"
3498
+ puts " Path: #{base_path}"
3499
+ puts " Restart the server to apply: brainiac restart"
3500
+ exit 0
3501
+ end
3502
+
3503
+ # Fetch latest to make sure the branch is available
3504
+ puts "Fetching latest from origin..."
3505
+ _out, _err, fetch_status = Open3.capture3("git", "fetch", "origin", chdir: base_path)
3506
+ unless fetch_status.success?
3507
+ puts "Warning: git fetch failed — continuing with local state."
3508
+ end
3509
+
3510
+ # Check if a worktree already exists for this branch
3511
+ worktree_list, _, wt_status = Open3.capture3("git", "worktree", "list", "--porcelain", chdir: base_path)
3512
+ target_worktree = nil
3513
+
3514
+ if wt_status.success?
3515
+ # Parse porcelain output to find worktrees on the target branch
3516
+ worktrees = worktree_list.split("\n\n").map do |block|
3517
+ wt = {}
3518
+ block.each_line do |line|
3519
+ case line
3520
+ when /^worktree (.+)/
3521
+ wt[:path] = Regexp.last_match(1).strip
3522
+ when /^branch refs\/heads\/(.+)/
3523
+ wt[:branch] = Regexp.last_match(1).strip
3524
+ end
3525
+ end
3526
+ wt
3527
+ end.select { |wt| wt[:path] && wt[:branch] }
3528
+
3529
+ target_worktree = worktrees.find { |wt| wt[:branch] == branch_name }
3530
+ end
3531
+
3532
+ if target_worktree
3533
+ # Worktree already exists — just update the path
3534
+ new_path = target_worktree[:path]
3535
+ if current_path == new_path
3536
+ puts "Plugin '#{plugin_name}' is already on branch '#{branch_name}'."
3537
+ exit 0
3538
+ end
3539
+
3540
+ plugins_config["plugins"][plugin_idx]["path"] = new_path
3541
+ File.write(plugins_file, JSON.pretty_generate(plugins_config))
3542
+ puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
3543
+ puts " Path: #{new_path}"
3544
+ puts " Restart the server to apply: brainiac restart"
3545
+ else
3546
+ # Check if branch exists remotely or locally
3547
+ branch_exists = false
3548
+ check_cmd, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
3549
+ branch_exists = check_status.success?
3550
+
3551
+ unless branch_exists
3552
+ # Try local branch
3553
+ _, _, local_status = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
3554
+ branch_exists = local_status.success?
3555
+ end
3556
+
3557
+ unless branch_exists
3558
+ puts "Error: Branch '#{branch_name}' not found (checked local and origin)."
3559
+ puts ""
3560
+ puts "Available remote branches:"
3561
+ branches_out, = Open3.capture3("git", "branch", "-r", "--list", "origin/*", chdir: base_path)
3562
+ branches_out.each_line do |line|
3563
+ branch = line.strip.sub("origin/", "")
3564
+ next if branch.include?("HEAD")
3565
+
3566
+ puts " #{branch}"
3567
+ end
3568
+ exit 1
3569
+ end
3570
+
3571
+ # Create a new worktree
3572
+ worktree_dir = "#{base_path}--#{branch_name}"
3573
+ puts "Creating worktree at #{worktree_dir}..."
3574
+
3575
+ # If the branch exists on origin but not locally, track it
3576
+ _, _, local_check = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
3577
+ if !local_check.success? && check_status.success?
3578
+ # Remote branch exists but not local — create tracking branch in worktree
3579
+ _, stderr, wt_status = Open3.capture3("git", "worktree", "add", "--track", "-b", branch_name, worktree_dir, "origin/#{branch_name}", chdir: base_path)
3580
+ else
3581
+ # Local branch exists — use it directly
3582
+ _, stderr, wt_status = Open3.capture3("git", "worktree", "add", worktree_dir, branch_name, chdir: base_path)
3583
+ end
3584
+
3585
+ unless wt_status.success?
3586
+ puts "Error: Failed to create worktree:"
3587
+ puts " #{stderr.strip}"
3588
+ exit 1
3589
+ end
3590
+
3591
+ plugins_config["plugins"][plugin_idx]["path"] = worktree_dir
3592
+ File.write(plugins_file, JSON.pretty_generate(plugins_config))
3593
+ puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
3594
+ puts " Worktree: #{worktree_dir}"
3595
+ puts " Restart the server to apply: brainiac restart"
3596
+ end
3597
+
3164
3598
  else
3165
3599
  puts "Usage: brainiac plugin <command>"
3166
3600
  puts ""
3167
3601
  puts "Commands:"
3602
+ puts " list List installed plugins"
3168
3603
  puts " new <name> Generate a new plugin gem skeleton"
3604
+ puts " switch <name> <branch> Switch a local plugin to a different branch"
3169
3605
  puts ""
3170
3606
  puts "Managing installed plugins:"
3171
3607
  puts " brainiac install <name> Install a plugin"
3172
3608
  puts " brainiac uninstall <name> Remove a plugin"
3173
- puts " brainiac plugins List installed plugins"
3609
+ puts " brainiac plugins List installed plugins (alias)"
3610
+ end
3611
+
3612
+ when "screenshot"
3613
+ # Resolve screenshot credentials from cached file and delegate to project's screenshot script.
3614
+ # Usage: brainiac screenshot [app-name] [page-path] [--env ENV] [extra args...]
3615
+ #
3616
+ # Credentials are resolved from 1Password at server start and cached in
3617
+ # ~/.brainiac/tmp/screenshot-creds.json. Falls back to `op read` if cache is missing.
3618
+ # Run `brainiac screenshot resolve` to manually refresh the cache.
3619
+
3620
+ deployments_file = File.join(BRAINIAC_DIR, "deployments.json")
3621
+ abort "Error: #{deployments_file} not found. Run 'brainiac setup' or create it manually." unless File.exist?(deployments_file)
3622
+
3623
+ deployments = JSON.parse(File.read(deployments_file))
3624
+ environments = deployments["environments"] || {}
3625
+
3626
+ sub = ARGV.shift
3627
+ case sub
3628
+ when "envs", "list"
3629
+ environments.each do |key, env|
3630
+ screenshot = env["screenshot"]
3631
+ if screenshot
3632
+ customers = (screenshot["customers"] || {}).keys
3633
+ employees = (screenshot["employees"] || {}).keys
3634
+ puts " #{key} (#{env["label"]})"
3635
+ puts " Customers: #{customers.join(", ")}" if customers.any?
3636
+ puts " Employees: #{employees.join(", ")}" if employees.any?
3637
+ else
3638
+ puts " #{key} (#{env["label"]}) — no screenshot config"
3639
+ end
3640
+ end
3641
+
3642
+ when "resolve"
3643
+ puts "Resolving screenshot credentials from 1Password..."
3644
+ if resolve_screenshot_credentials(quiet: false)
3645
+ puts "\nCredentials cached at #{SCREENSHOT_CREDS_FILE}"
3646
+ puts "Screenshots will use the cache — no 1Password re-auth needed."
3647
+ else
3648
+ abort "\nFailed to resolve credentials. Ensure `op` is signed in."
3649
+ end
3650
+
3651
+ when "help", "--help", "-h", nil
3652
+ puts <<~HELP
3653
+ brainiac screenshot — Take screenshots with credentials from deployments.json
3654
+
3655
+ Usage:
3656
+ brainiac screenshot <app-name> <page-path> [options]
3657
+
3658
+ Credentials are resolved from 1Password at server start and cached in
3659
+ #{SCREENSHOT_CREDS_FILE} (mode 0600). Use `brainiac screenshot resolve`
3660
+ to manually refresh the cache.
3661
+
3662
+ Options:
3663
+ --env ENV Environment to use (default: auto-detect from project owner)
3664
+ --customer EMAIL Use specific customer email (default: first in list)
3665
+ --employee EMAIL Use specific employee email (default: first in list)
3666
+ --project KEY Project key (default: current directory or default project)
3667
+ All other args are passed through to screenshot-page.sh
3668
+
3669
+ Commands:
3670
+ brainiac screenshot resolve Resolve and cache credentials from 1Password
3671
+ brainiac screenshot envs List environments with screenshot config
3672
+ brainiac screenshot help Show this help
3673
+
3674
+ Examples:
3675
+ brainiac screenshot ops-app /api-explorer
3676
+ brainiac screenshot ops-app /inventory --env dev04
3677
+ brainiac screenshot app /dashboard --env dev02
3678
+ HELP
3679
+
3680
+ else
3681
+ # Parse flags from remaining args
3682
+ app_name = sub
3683
+ page_path = ARGV.shift
3684
+ abort "Error: page path required. Usage: brainiac screenshot <app-name> <page-path> [options]" unless page_path
3685
+
3686
+ env_name = nil
3687
+ customer_email = nil
3688
+ employee_email = nil
3689
+ project_key = nil
3690
+ passthrough_args = []
3691
+
3692
+ while (arg = ARGV.shift)
3693
+ case arg
3694
+ when "--env"
3695
+ env_name = ARGV.shift
3696
+ when "--customer"
3697
+ customer_email = ARGV.shift
3698
+ when "--employee"
3699
+ employee_email = ARGV.shift
3700
+ when "--project"
3701
+ project_key = ARGV.shift
3702
+ else
3703
+ passthrough_args << arg
3704
+ # If the flag takes a value, pass it through too
3705
+ passthrough_args << ARGV.shift if arg.start_with?("--") && !arg.include?("=") && ARGV.first && !ARGV.first.start_with?("--")
3706
+ end
3707
+ end
3708
+
3709
+ # Determine environment (default: dev02 for Linux)
3710
+ env_name ||= case RUBY_PLATFORM
3711
+ when /darwin/ then "dev01"
3712
+ else "dev02"
3713
+ end
3714
+
3715
+ env_config = environments[env_name]
3716
+ abort "Error: Environment '#{env_name}' not found in deployments.json. Available: #{environments.keys.join(", ")}" unless env_config
3717
+
3718
+ screenshot_config = env_config["screenshot"]
3719
+ abort "Error: No 'screenshot' config for environment '#{env_name}' in deployments.json." unless screenshot_config
3720
+
3721
+ customers = screenshot_config["customers"] || {}
3722
+ employees = screenshot_config["employees"] || {}
3723
+
3724
+ # Resolve which email to use
3725
+ if app_name == "ops-app"
3726
+ email = employee_email || employees.keys.first
3727
+ op_ref = employees[email]
3728
+ abort "Error: Employee email '#{email}' not found in #{env_name} screenshot config. Available: #{employees.keys.join(", ")}" unless op_ref
3729
+ else
3730
+ email = customer_email || customers.keys.first
3731
+ op_ref = customers[email]
3732
+ abort "Error: Customer email '#{email}' not found in #{env_name} screenshot config. Available: #{customers.keys.join(", ")}" unless op_ref
3733
+ end
3734
+
3735
+ # Resolve password (from cache or 1Password directly)
3736
+ password = read_screenshot_credential(op_ref)
3737
+ unless password
3738
+ abort "Error: Failed to resolve password for #{op_ref}. Run 'brainiac screenshot resolve' or 'brainiac restart' to refresh the cache."
3739
+ end
3740
+
3741
+ # Determine project path
3742
+ projects = load_projects
3743
+ if project_key
3744
+ project = projects[project_key]
3745
+ abort "Error: Project '#{project_key}' not found." unless project
3746
+ else
3747
+ # Try to find project from current directory or env config
3748
+ project = projects.find { |_k, p| p["repo_path"] == Dir.pwd }&.last
3749
+ project ||= projects[env_config["project"]]
3750
+ end
3751
+
3752
+ abort "Error: Could not determine project. Use --project KEY." unless project
3753
+ repo_path = project["repo_path"]
3754
+ script_path = File.join(repo_path, "scripts", "screenshot-page.sh")
3755
+ abort "Error: Screenshot script not found at #{script_path}" unless File.exist?(script_path)
3756
+
3757
+ # Build environment
3758
+ screenshot_env = {
3759
+ "SCREENSHOT_CUSTOMER_EMAIL" => (app_name == "ops-app" ? (customers.keys.first || email) : email),
3760
+ "SCREENSHOT_CUSTOMER_PASSWORD" => password,
3761
+ "SCREENSHOT_OPS_EMAIL" => (app_name == "ops-app" ? email : (employees.keys.first || email)),
3762
+ "SCREENSHOT_OPS_PASSWORD" => password
3763
+ }
3764
+
3765
+ # Also set environment-specific email vars
3766
+ env_upper = env_name.upcase.tr("-", "_")
3767
+ screenshot_env["SCREENSHOT_#{env_upper}_CUSTOMER_EMAIL"] = customers.keys.first if customers.any?
3768
+
3769
+ # Run the screenshot script
3770
+ cmd = [script_path, app_name, page_path, "", ""] + passthrough_args
3771
+ puts "Taking screenshot: #{app_name} #{page_path} (env: #{env_name}, user: #{email})"
3772
+ puts "Running: #{cmd.join(" ")}"
3773
+
3774
+ pid = spawn(screenshot_env, *cmd, chdir: repo_path)
3775
+ Process.wait(pid)
3776
+
3777
+ if $CHILD_STATUS.success?
3778
+ # Find the most recent screenshot
3779
+ attachments_dir = File.join(repo_path, ".fizzy-attachments")
3780
+ if Dir.exist?(attachments_dir)
3781
+ latest = Dir.glob(File.join(attachments_dir, "*.png")).max_by { |f| File.mtime(f) }
3782
+ puts "\n✅ Screenshot saved: #{latest}" if latest
3783
+ end
3784
+ else
3785
+ abort "\n❌ Screenshot failed (exit #{$CHILD_STATUS.exitstatus})"
3786
+ end
3174
3787
  end
3175
3788
 
3176
3789
  when "version", "--version", "-v"
@@ -3200,8 +3813,8 @@ when "help", "--help", "-h", nil
3200
3813
  brainiac register [options] Register current directory as a project
3201
3814
  brainiac unregister <key> Unregister a project
3202
3815
  brainiac list List all registered projects
3203
- brainiac projects list List all registered projects (alias)
3204
- brainiac projects default <key> Set the default project
3816
+ brainiac projects <command> Manage projects (list, show, default, help)
3817
+ brainiac project <command> Alias for 'brainiac projects'
3205
3818
  brainiac show <key> Show project configuration
3206
3819
  brainiac plugin new <name> Generate a new plugin gem skeleton
3207
3820
  brainiac update Update brainiac and all installed plugins
@@ -3214,6 +3827,7 @@ when "help", "--help", "-h", nil
3214
3827
  brainiac provider <command> Manage CLI providers
3215
3828
  brainiac role <command> Manage agent roles
3216
3829
  brainiac agent <command> Manage agent registry (env, list, show)
3830
+ brainiac screenshot <app> <path> Take screenshot with 1Password credentials
3217
3831
  brainiac config Configure Brainiac CLI
3218
3832
  brainiac path Show Brainiac config directory
3219
3833
  brainiac version Show version
@@ -3224,6 +3838,7 @@ when "help", "--help", "-h", nil
3224
3838
 
3225
3839
  Plugin Commands:
3226
3840
  plugin new <name> Generate a new plugin gem skeleton
3841
+ plugin switch <name> <branch> Switch a local plugin to a branch (for testing)
3227
3842
  install <name> Install a plugin (gem install brainiac-<name>)
3228
3843
  uninstall <name> Remove a plugin
3229
3844
  update [name] Update brainiac + all plugins, or a specific plugin
@@ -9,7 +9,7 @@ _brainiac() {
9
9
  local brainiac_dir="${BRAINIAC_DIR:-$HOME/.brainiac}"
10
10
 
11
11
  # Top-level commands (built-in + installed plugins)
12
- local commands="server start stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects card-map handler plugin install uninstall plugins"
12
+ local commands="server start stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects project card-map handler plugin install uninstall plugins"
13
13
 
14
14
  # Add installed plugin names as top-level commands
15
15
  if [[ -f "$brainiac_dir/plugins.json" ]]; then
@@ -63,7 +63,18 @@ _brainiac() {
63
63
  plugin)
64
64
  case $cword in
65
65
  2)
66
- COMPREPLY=($(compgen -W "new" -- "$cur"))
66
+ COMPREPLY=($(compgen -W "new switch list" -- "$cur"))
67
+ ;;
68
+ 3)
69
+ # For "plugin switch", suggest installed plugin names
70
+ if [[ "${COMP_WORDS[2]}" == "switch" && -f "$brainiac_dir/plugins.json" ]]; then
71
+ local installed
72
+ installed=$(ruby -rjson -e '
73
+ config = JSON.parse(File.read(ARGV[0]))
74
+ (config["plugins"] || []).select { |p| p.is_a?(Hash) && p["path"] }.each { |p| puts p["name"] }
75
+ ' "$brainiac_dir/plugins.json" 2>/dev/null)
76
+ COMPREPLY=($(compgen -W "$installed" -- "$cur"))
77
+ fi
67
78
  ;;
68
79
  esac
69
80
  ;;
@@ -180,16 +191,40 @@ _brainiac() {
180
191
  esac
181
192
  ;;
182
193
 
183
- projects)
194
+ projects|project)
184
195
  case $cword in
185
196
  2)
186
- COMPREPLY=($(compgen -W "list default" -- "$cur"))
197
+ COMPREPLY=($(compgen -W "list show default update unset help" -- "$cur"))
187
198
  ;;
188
199
  3)
189
- if [[ "${words[2]}" == "default" ]]; then
200
+ local subcmd="${words[2]}"
201
+ if [[ "$subcmd" == "default" || "$subcmd" == "show" || "$subcmd" == "update" || "$subcmd" == "unset" ]]; then
190
202
  COMPREPLY=($(compgen -W "$(_brainiac_projects)" -- "$cur"))
191
203
  fi
192
204
  ;;
205
+ 4)
206
+ local subcmd="${words[2]}"
207
+ if [[ "$subcmd" == "update" || "$subcmd" == "unset" ]]; then
208
+ COMPREPLY=($(compgen -W "repo_path github_repo fizzy_tags fizzy_board cli_provider agent_name agent_model default_branch" -- "$cur"))
209
+ fi
210
+ ;;
211
+ 5)
212
+ local subcmd="${words[2]}"
213
+ local field="${words[4]}"
214
+ if [[ "$subcmd" == "update" ]]; then
215
+ case "$field" in
216
+ cli_provider)
217
+ COMPREPLY=($(compgen -W "$(_brainiac_providers)" -- "$cur"))
218
+ ;;
219
+ agent_model)
220
+ COMPREPLY=($(compgen -W "auto opus sonnet haiku deepseek minimax qwen" -- "$cur"))
221
+ ;;
222
+ agent_name)
223
+ COMPREPLY=($(compgen -W "$(_brainiac_agents)" -- "$cur"))
224
+ ;;
225
+ esac
226
+ fi
227
+ ;;
193
228
  esac
194
229
  ;;
195
230
 
@@ -157,7 +157,7 @@ DEFAULT_PROJECT = {
157
157
  "agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort",
158
158
  "agent_effort" => ENV.fetch("AGENT_EFFORT", nil),
159
159
  "allowed_models" => {
160
- "opus" => "claude-opus-4.6",
160
+ "opus" => "claude-opus-4.5",
161
161
  "sonnet" => "claude-sonnet-4.6",
162
162
  "haiku" => "claude-haiku-4.5",
163
163
  "deepseek" => "deepseek-3.2",
data/lib/brainiac/cron.rb CHANGED
@@ -466,7 +466,10 @@ def handle_cron_completion(job, project, agent_name, agent_config_name, log_file
466
466
  update_cron_job_state(job)
467
467
 
468
468
  if File.exist?(response_file)
469
- LOG.info "[Cron] Job #{job[:id]} completed. Response: #{File.read(response_file)[0..100]}..."
469
+ response_content = File.read(response_file).strip
470
+ LOG.info "[Cron] Job #{job[:id]} completed. Response: #{response_content[0..100]}..."
471
+
472
+ notify_cron_output(job, response_content, agent_name: agent_name) if job[:notify_target] && !response_content.empty?
470
473
  else
471
474
  LOG.warn "[Cron] Job #{job[:id]} produced no response"
472
475
  end
@@ -4,6 +4,31 @@
4
4
  #
5
5
  # All git-related utilities live here. Handlers call these instead of
6
6
  # reimplementing git worktree/branch logic.
7
+ #
8
+ # NOTE: Methods that need to be accessible from Sinatra route handlers
9
+ # are wrapped in GitHelpers module and registered with Sinatra.
10
+
11
+ # Module containing git helpers that need to be accessible from Sinatra routes.
12
+ # These are registered as Sinatra helpers in receiver.rb so plugins can call them.
13
+ module GitHelpers
14
+ def resolve_base_branch(repo_path:, card_number: nil, project_key: nil)
15
+ results = Brainiac.emit(:resolve_base_branch,
16
+ repo_path: repo_path, card_number: card_number, project_key: project_key)
17
+ custom = results.compact.first
18
+ if custom
19
+ LOG.info "Using custom base branch '#{custom}' (from plugin hook)"
20
+ custom
21
+ else
22
+ "origin/#{get_default_branch(repo_path)}"
23
+ end
24
+ end
25
+
26
+ def resolve_pr_target(repo_path:, card_number: nil, project_key: nil)
27
+ results = Brainiac.emit(:resolve_pr_target,
28
+ repo_path: repo_path, card_number: card_number, project_key: project_key)
29
+ results.compact.first
30
+ end
31
+ end
7
32
 
8
33
  # Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.
9
34
  REPO_LAST_FETCH = {}
@@ -106,6 +131,40 @@ def run_project_hook(repo_path, hook_name, extra_env: {})
106
131
  end
107
132
  end
108
133
 
134
+ # Resolve the base branch for a new worktree.
135
+ # Emits :resolve_base_branch hook — plugins can override the default (origin/main).
136
+ # Returns a ref string (e.g., "origin/main", "epic/my-feature").
137
+ #
138
+ # @param repo_path [String] Path to the git repo
139
+ # @param card_number [Integer, String, nil] Fizzy card number (if applicable)
140
+ # @param project_key [String, nil] Brainiac project key
141
+ # @return [String] Base ref for the worktree
142
+ def resolve_base_branch(repo_path:, card_number: nil, project_key: nil)
143
+ results = Brainiac.emit(:resolve_base_branch,
144
+ repo_path: repo_path, card_number: card_number, project_key: project_key)
145
+ # First non-nil result wins (plugins return a branch name or nil to skip)
146
+ custom = results.compact.first
147
+ if custom
148
+ LOG.info "Using custom base branch '#{custom}' (from plugin hook)"
149
+ custom
150
+ else
151
+ "origin/#{get_default_branch(repo_path)}"
152
+ end
153
+ end
154
+
155
+ # Resolve the PR target branch for a card.
156
+ # Emits :resolve_pr_target hook — plugins can override the default branch.
157
+ #
158
+ # @param repo_path [String] Path to the git repo
159
+ # @param card_number [Integer, String, nil] Fizzy card number
160
+ # @param project_key [String, nil] Brainiac project key
161
+ # @return [String, nil] Target branch name (without origin/ prefix), or nil for default
162
+ def resolve_pr_target(repo_path:, card_number: nil, project_key: nil)
163
+ results = Brainiac.emit(:resolve_pr_target,
164
+ repo_path: repo_path, card_number: card_number, project_key: project_key)
165
+ results.compact.first
166
+ end
167
+
109
168
  # Create or reuse a git worktree for a given branch.
110
169
  # Returns the worktree path on success.
111
170
  def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil)
@@ -492,7 +492,11 @@ end
492
492
  def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
493
493
  return false unless message && agent_name && intent_config["enabled"]
494
494
 
495
+ # Some channels always require a response (e.g. PR comments are inherently directed at the agent).
496
+ bypass_channels = intent_config["bypass_channels"] || %w[github]
495
497
  intent_channel = channel || source&.to_s || "conversation"
498
+ return false if bypass_channels.any? { |bc| intent_channel.to_s.downcase.include?(bc) }
499
+
496
500
  unless check_intent(message, agent_name: agent_name, channel: intent_channel, context: context)
497
501
  LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
498
502
  return true
@@ -503,7 +507,7 @@ end
503
507
 
504
508
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
505
509
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
506
- message: nil, channel: nil, context: nil)
510
+ message: nil, channel: nil, context: nil, env: {})
507
511
  # Intent gate: if a raw message is provided, check whether the agent should respond.
508
512
  return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
509
513
 
@@ -527,7 +531,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
527
531
  cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort, prompt_file: prompt_file, resume: should_resume)
528
532
  prompt_mode = resolved["prompt_mode"] || "stdin"
529
533
 
530
- spawn_env = agent_env_for(agent_name)
534
+ spawn_env = agent_env_for(agent_name).merge(env)
531
535
 
532
536
  LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
533
537
  LOG.info "Prompt written to #{prompt_file}"
@@ -585,7 +589,7 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
585
589
  # "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
586
590
  if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
587
591
  allowed = resolved["allowed_models"] || {}
588
- # Pass the model if it's a mapped value (e.g. "claude-opus-4.6") or the key itself is mapped
592
+ # Pass the model if it's a mapped value (e.g. "claude-opus-4.5") or the key itself is mapped
589
593
  is_known = allowed.value?(model) || allowed.key?(model)
590
594
  cmd.push(resolved["agent_model_flag"], model) if is_known
591
595
  end
@@ -17,6 +17,8 @@
17
17
  # :build_brain_context — When building brain context (plugins add source-specific queries)
18
18
  # :pre_dispatch — Before dispatching an agent (plugins can inject config)
19
19
  # :post_comment — After an agent posts a comment/response
20
+ # :resolve_base_branch — Resolve custom base branch for worktree creation
21
+ # :resolve_pr_target — Resolve custom PR target branch (instead of default branch)
20
22
  #
21
23
  # Usage (in plugin .register):
22
24
  # Brainiac.on(:agent_completed) do |ctx|
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.23"
5
+ VERSION = "0.0.26"
6
6
  end
data/monitor/shared.rb CHANGED
@@ -205,7 +205,17 @@ def deploy_bash_script(env_key, worktree:, aws_profile: nil)
205
205
  fi
206
206
  rm -f "$logfile"
207
207
  echo
208
- if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
208
+ if [ $status -eq 0 ]; then
209
+ echo "✅ Deploy complete"
210
+ curl -s -X POST http://localhost:4567/api/deployments/#{env_key.shellescape} \
211
+ -H "Content-Type: application/json" \
212
+ -d '{"worktree": #{worktree.to_json}, "deployed_by": "waybar"}' > /dev/null 2>&1
213
+ else
214
+ echo "❌ Deploy failed (exit $status)"
215
+ curl -s -X POST http://localhost:4567/api/deployments/#{env_key.shellescape}/failed \
216
+ -H "Content-Type: application/json" \
217
+ -d '{}' > /dev/null 2>&1
218
+ fi
209
219
  echo "Press Enter to close..."
210
220
  read
211
221
  BASH
@@ -111,7 +111,17 @@ deploy_script = <<~BASH
111
111
  fi
112
112
  rm -f "$logfile"
113
113
  echo
114
- if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
114
+ if [ $status -eq 0 ]; then
115
+ echo "✅ Deploy complete"
116
+ curl -s -X POST http://localhost:4567/api/deployments/#{Shellwords.escape(env_key)} \
117
+ -H "Content-Type: application/json" \
118
+ -d '{"worktree": #{worktree.to_json}, "deployed_by": "waybar"}' > /dev/null 2>&1
119
+ else
120
+ echo "❌ Deploy failed (exit $status)"
121
+ curl -s -X POST http://localhost:4567/api/deployments/#{Shellwords.escape(env_key)}/failed \
122
+ -H "Content-Type: application/json" \
123
+ -d '{}' > /dev/null 2>&1
124
+ fi
115
125
  echo
116
126
  echo "Press any key to close..."
117
127
  read -n 1
data/receiver.rb CHANGED
@@ -114,8 +114,13 @@ else
114
114
  LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
115
115
  end
116
116
 
117
- # --- Dashboard authentication ---
117
+ # --- Sinatra helpers ---
118
118
 
119
+ # Register GitHelpers so plugins can call resolve_pr_target, resolve_base_branch
120
+ # from within Sinatra route handlers
121
+ helpers GitHelpers
122
+
123
+ # Dashboard authentication helpers
119
124
  helpers do
120
125
  def authenticate_dashboard!
121
126
  return unless DASHBOARD_TOKEN # No token configured = no auth (local-only mode)
@@ -7,7 +7,7 @@
7
7
  "prompt_mode": "stdin",
8
8
  "resume_flag": "--resume",
9
9
  "models": {
10
- "opus": "claude-opus-4.6",
10
+ "opus": "claude-opus-4.5",
11
11
  "sonnet": "claude-sonnet-4.6",
12
12
  "haiku": "claude-haiku-4.5",
13
13
  "deepseek": "deepseek-3.2",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.23
4
+ version: 0.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis