brainiac 0.4.0 → 0.4.2

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: 1e2fc609dfdad411c1262f53783c3d6d88de4dbb75c83ccb9cedf6b935555a42
4
- data.tar.gz: 6c7f6aed1727d45ba3bd953554598a20222c3ed47a81879b66108fbe2e7abb98
3
+ metadata.gz: ba8a88daeee5e47d89614674cc1855e4a3fb50ca9b7cb8b5f063fa40ef544aeb
4
+ data.tar.gz: e0dcd3bc1916e066f1da0b0827cac3952978e44451df5262308eb9003400f268
5
5
  SHA512:
6
- metadata.gz: 68db7910a0cca76489408685a40f1fe549eaa6462ddefad4b532160b2ef8bd80c798a79b84c4daaeb538e52cc4df3412fca8f61862c39d94a49e9c080cb83e95
7
- data.tar.gz: ab36e6f636e4ac76b28bfa55a418d1c00f2251adc36663e9fe21786150d3a27aa7d870bf4c225ffc47303873af38c76ed7601166be9d9ce88a2f30e372770e3b
6
+ metadata.gz: e4a84e1cc4f055ad794ebde722e653af89376ab2f600cd7c35dee4cb5731f99d612b699e6aaf0b6f8e51857ae354ffd3b9627c234d0538c56e3400231b48f0a6
7
+ data.tar.gz: bb1dd4fe2cca8a4c84f73a042b0b7dc8d754d4718281dd849d6d3b966ee70c4a3bacddcabd21b6bfc111bd37c5c70a541142fd0443914f24181b75e3966d35e8
data/bin/brainiac CHANGED
@@ -310,6 +310,114 @@ def agent_persona_description(agent_key)
310
310
  body.empty? ? nil : body
311
311
  end
312
312
 
313
+ # Markdown that already opens with YAML frontmatter is kept as written, so a
314
+ # hand-made style.md or role file isn't wrapped in a second frontmatter block.
315
+ def frontmatter?(text)
316
+ text.to_s.lstrip.start_with?("---\n")
317
+ end
318
+
319
+ def available_roles
320
+ roles_dir = File.join(BRAINIAC_DIR, "roles")
321
+ Dir.exist?(roles_dir) ? Dir.glob(File.join(roles_dir, "*.md")).map { |f| File.basename(f, ".md") }.sort : []
322
+ end
323
+
324
+ def role_name_arg(value)
325
+ value.to_s.downcase.gsub(/[^a-z0-9-]/, "-")
326
+ end
327
+
328
+ # Starting point for a new role, shaped like the built-in ones. HTML comment
329
+ # lines are instructions and get stripped by edit_description_in_editor.
330
+ def role_template(role_name, agent_name = nil)
331
+ title = role_name.split("-").map(&:capitalize).join(" ")
332
+ you = agent_name ? "You are #{agent_name}, the #{title.downcase}." : "You are the #{title.downcase}."
333
+ <<~MD
334
+ <!-- Role "#{role_name}": WHAT this agent does. HOW it talks belongs in its persona (style.md). -->
335
+ <!-- Fill in or delete sections. Lines like this one are removed. Save and quit when done; empty = cancel. -->
336
+ # Role: #{title}
337
+
338
+ #{you} Your primary responsibility is ...
339
+
340
+ ## Primary Responsibilities
341
+
342
+ - **...**: ...
343
+
344
+ ## Checklist
345
+
346
+ When taking on any task:
347
+
348
+ 1. ...
349
+
350
+ ## What You Don't Do
351
+
352
+ - ...
353
+
354
+ ## Looping In Other Agents (opt-in ONLY)
355
+
356
+ Do NOT tag, @mention, or "flag for" other agents by default. Every cross-agent
357
+ mention triggers an automated dispatch that spends the user's credits.
358
+ Only bring another agent in when the user explicitly asks for it. If you think
359
+ another agent should weigh in, say so in plain prose WITHOUT an @mention.
360
+ MD
361
+ end
362
+
363
+ # Write ~/.brainiac/roles/<name>.md, adding frontmatter unless the text has its own.
364
+ def write_role_file(role_name, text, agent_name = nil)
365
+ roles_dir = File.join(BRAINIAC_DIR, "roles")
366
+ FileUtils.mkdir_p(roles_dir)
367
+ file = File.join(roles_dir, "#{role_name}.md")
368
+ body = text.strip
369
+ unless frontmatter?(body)
370
+ description = agent_name ? "Role definition for #{agent_name}" : "Role definition: #{role_name}"
371
+ body = "---\nname: #{role_name}\ndescription: #{description}\ninclusion: manual\n---\n\n#{body}"
372
+ end
373
+ File.write(file, "#{body}\n")
374
+ file
375
+ end
376
+
377
+ # Add roles to a registry entry, keeping the string-or-array shape.
378
+ def assign_roles(entry, new_roles)
379
+ roles = (Array(entry["role"]) + new_roles).uniq
380
+ entry["role"] = roles.size == 1 ? roles.first : roles if roles.any?
381
+ roles
382
+ end
383
+
384
+ # Interactive role step for create/setup: pick existing roles by number or
385
+ # name, write a new one in $EDITOR, or skip. Returns the chosen role names.
386
+ def prompt_for_agent_role(agent_key, display_name)
387
+ existing = available_roles
388
+ puts ""
389
+ puts "Role for #{display_name} (what they do; the persona is how they talk):"
390
+ existing.each_with_index { |r, i| puts " #{i + 1}. #{r}" }
391
+ puts " n. New role (opens #{ENV["VISUAL"] || ENV["EDITOR"] || "vi"})"
392
+ print "Choose #{"numbers/names (comma-separated), " if existing.any?}n, or blank to skip: "
393
+ input = $stdin.gets.to_s.strip
394
+ return [] if input.empty?
395
+
396
+ if input.downcase.start_with?("n") && !existing.include?(input)
397
+ print "Role name [#{agent_key}]: "
398
+ name = role_name_arg($stdin.gets.to_s.strip)
399
+ name = agent_key if name.empty?
400
+ if existing.include?(name)
401
+ puts " Role '#{name}' already exists; assigning it."
402
+ return [name]
403
+ end
404
+ text = edit_description_in_editor(name, role_template(name, display_name))
405
+ unless text
406
+ puts " Empty role, skipped."
407
+ return []
408
+ end
409
+ puts " Created #{write_role_file(name, text, display_name)}"
410
+ return [name]
411
+ end
412
+
413
+ picked = input.split(",").map(&:strip).filter_map do |token|
414
+ token.match?(/\A\d+\z/) ? existing[token.to_i - 1] : token
415
+ end
416
+ unknown = picked - existing
417
+ puts " Unknown role(s) ignored: #{unknown.join(", ")}" if unknown.any?
418
+ picked & existing
419
+ end
420
+
313
421
  # Payload handed to plugins' on_agent_created: the registry entry plus the
314
422
  # setup inputs plugins need to provision accounts (avatar, description, email).
315
423
  # "open_browser" tells plugins whether they may launch a browser for their
@@ -668,6 +776,9 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
668
776
  | `:create_work_item` | Create card/issue/ticket |
669
777
  | `:detect_cli_provider` | Detect CLI provider |
670
778
  | `:detect_effort` | Detect effort level |
779
+ | `:agent_added` | New agent in agents.json (after reload) |
780
+ | `:agent_updated` | Existing agent's entry changed (after reload) |
781
+ | `:agent_removed` | Agent removed from agents.json (after reload) |
671
782
 
672
783
  ## Agent Onboarding (`on_agent_created`)
673
784
 
@@ -731,6 +842,13 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
731
842
  asked during create). Core filters on the plugin name before calling you, so
732
843
  plugins don't need to handle this.
733
844
 
845
+ **Going live without a restart.** `agent create` / `agent setup` tell the running
846
+ server to reload `agents.json`. The server may already have seen the agent before
847
+ your hook wrote its token, so a server-side plugin that starts something per agent
848
+ (a gateway connection, a poller) must handle `:agent_updated` as well as
849
+ `:agent_added`: start it when the credential appears or changes, stop it when it's
850
+ removed.
851
+
734
852
  ## Channel Prompts
735
853
 
736
854
  For communication channel plugins:
@@ -2430,13 +2548,16 @@ when "agent"
2430
2548
  puts " --email <address> Email for the agent's accounts (default: agent_email_template)"
2431
2549
  puts " --local Mark agent as local (dispatches on this machine)"
2432
2550
  puts " --role <role> Assign a role (repeatable)"
2551
+ puts " --role-file <path> Create a role from a markdown file and assign it"
2552
+ puts " (named after the agent, or after a new --role name)"
2433
2553
  puts " --cli <provider> Set CLI provider (default: kiro)"
2434
2554
  puts " --skip <plugins> Don't set up these plugins for this agent, e.g. --skip basecamp"
2435
2555
  puts " --only <plugins> Set up just these plugins, e.g. --only discord,fizzy"
2436
2556
  puts " --no-browser Print setup links instead of opening them"
2437
2557
  puts ""
2438
2558
  puts "With no flags and a terminal, interactive mode is used. If no description is"
2439
- puts "given on a terminal, $EDITOR opens so you can write a full persona."
2559
+ puts "given on a terminal, $EDITOR opens so you can write a full persona. Without"
2560
+ puts "--role/--role-file you're asked to pick an existing role or write a new one."
2440
2561
  puts "After the brainiac files are written, each installed plugin sets up its part"
2441
2562
  puts "(Discord bot, Fizzy user, Basecamp person), asking first unless --skip/--only"
2442
2563
  puts "is given. Re-run with: brainiac agent setup <name>"
@@ -2446,6 +2567,8 @@ when "agent"
2446
2567
  puts " brainiac agent create Quill --local --avatar quill.png --description @quill.md"
2447
2568
  puts " brainiac agent create MyAgent # interactive"
2448
2569
  puts " brainiac agent create SecurityBot --local --role code-reviewer"
2570
+ puts " brainiac agent create \"Judge Dredd\" --local --avatar dredd.png \\"
2571
+ puts " --description-file dredd-style.md --role-file dredd-role.md"
2449
2572
  exit 1
2450
2573
  end
2451
2574
 
@@ -2460,6 +2583,7 @@ when "agent"
2460
2583
 
2461
2584
  local = false
2462
2585
  roles = []
2586
+ role_file = nil
2463
2587
  cli_provider = nil
2464
2588
  persona_text = nil
2465
2589
  avatar = nil
@@ -2477,7 +2601,10 @@ when "agent"
2477
2601
  local = true
2478
2602
  when "--role"
2479
2603
  ARGV.shift
2480
- roles << ARGV.shift
2604
+ roles << role_name_arg(ARGV.shift)
2605
+ when "--role-file"
2606
+ ARGV.shift
2607
+ role_file = File.expand_path(ARGV.shift.to_s)
2481
2608
  when "--cli"
2482
2609
  ARGV.shift
2483
2610
  cli_provider = ARGV.shift
@@ -2523,14 +2650,6 @@ when "agent"
2523
2650
  input = $stdin.gets.chomp
2524
2651
  cli_provider = input.empty? ? nil : input
2525
2652
 
2526
- # Show available roles
2527
- roles_dir = File.join(BRAINIAC_DIR, "roles")
2528
- available_roles = Dir.exist?(roles_dir) ? Dir.glob(File.join(roles_dir, "*.md")).map { |f| File.basename(f, ".md") } : []
2529
- hint = available_roles.any? ? " (#{available_roles.join(", ")})" : ""
2530
- print "Roles#{hint} (comma-separated) []: "
2531
- input = $stdin.gets.chomp
2532
- roles = input.empty? ? [] : input.split(",").map(&:strip)
2533
-
2534
2653
  print "Avatar image path (or blank to skip) []: "
2535
2654
  input = $stdin.gets.chomp
2536
2655
  avatar = input.empty? ? nil : input
@@ -2547,6 +2666,30 @@ when "agent"
2547
2666
  persona_text = edit_description_in_editor(name) unless $stdin.gets.to_s.strip.downcase.start_with?("n")
2548
2667
  end
2549
2668
 
2669
+ new_role = nil
2670
+ if role_file && !File.file?(role_file)
2671
+ puts "❌ Role file not found: #{role_file}"
2672
+ exit 1
2673
+ end
2674
+ if role_file
2675
+ # --role-file names its role after a --role that doesn't exist yet, else the agent key.
2676
+ new_role = (roles - available_roles).first || agent_key
2677
+ if available_roles.include?(new_role)
2678
+ puts "❌ Role '#{new_role}' already exists. Pass --role <new-name> to name the role from #{role_file}."
2679
+ exit 1
2680
+ end
2681
+ roles |= [new_role]
2682
+ elsif roles.empty? && $stdin.tty?
2683
+ roles = prompt_for_agent_role(agent_key, name)
2684
+ end
2685
+
2686
+ missing_roles = roles - available_roles - [new_role]
2687
+ if missing_roles.any?
2688
+ puts "❌ Unknown role(s): #{missing_roles.join(", ")}. Available: #{available_roles.join(", ")}"
2689
+ puts " Create one with --role-file, or 'brainiac role create <name>'."
2690
+ exit 1
2691
+ end
2692
+
2550
2693
  email ||= default_agent_email(agent_key, name)
2551
2694
 
2552
2695
  entry = { "display_name" => name }
@@ -2565,6 +2708,8 @@ when "agent"
2565
2708
  end
2566
2709
  end
2567
2710
 
2711
+ puts "✓ Created role #{write_role_file(new_role, File.read(role_file), name)}" if new_role
2712
+
2568
2713
  registry[agent_key] = entry
2569
2714
  File.write(agent_registry_file, JSON.pretty_generate(registry))
2570
2715
  puts "✓ Created agent '#{name}' (#{agent_key})"
@@ -2579,7 +2724,12 @@ when "agent"
2579
2724
  persona_dir = File.join(BRAINIAC_DIR, "brain", "persona", agent_key)
2580
2725
  FileUtils.mkdir_p(persona_dir)
2581
2726
  persona_file = File.join(persona_dir, "style.md")
2582
- unless File.exist?(persona_file)
2727
+ if File.exist?(persona_file)
2728
+ # Already there (e.g. a re-create); leave it alone.
2729
+ elsif frontmatter?(persona_text)
2730
+ File.write(persona_file, "#{persona_text.strip}\n")
2731
+ puts " Persona: #{persona_file}"
2732
+ else
2583
2733
  File.write(persona_file, <<~MD)
2584
2734
  ---
2585
2735
  name: #{agent_key}-style
@@ -2606,8 +2756,12 @@ when "agent"
2606
2756
  notify_plugins_agent_created(agent_key, agent_hook_payload(agent_key, entry, open_browser: open_browser),
2607
2757
  skip: skip_plugins, only: only_plugins, confirm: confirm)
2608
2758
  if skip_plugins.any? && entry["skip_plugins"] != skip_plugins.uniq
2609
- entry["skip_plugins"] = skip_plugins.uniq
2610
- registry[agent_key] = entry
2759
+ # Re-read: the plugin hooks above wrote their tokens (DISCORD_BOT_TOKEN,
2760
+ # FIZZY_TOKEN, ...) into agents.json, and the copy we loaded at the start
2761
+ # would clobber them.
2762
+ registry = JSON.parse(File.read(agent_registry_file))
2763
+ registry[agent_key] ||= entry
2764
+ registry[agent_key]["skip_plugins"] = skip_plugins.uniq
2611
2765
  File.write(agent_registry_file, JSON.pretty_generate(registry))
2612
2766
  puts " Skipping #{skip_plugins.uniq.join(", ")} for #{name} (undo: brainiac agent setup #{agent_key} --only <plugin>)"
2613
2767
  end
@@ -2618,12 +2772,16 @@ when "agent"
2618
2772
  when "setup"
2619
2773
  name = ARGV.shift
2620
2774
  unless name
2621
- puts "Usage: brainiac agent setup <name> [--avatar <path>] [--email <address>] [--skip <plugins>] [--only <plugins>] [--no-browser]"
2775
+ puts "Usage: brainiac agent setup <name> [--avatar <path>] [--email <address>] [--role <role>] [--role-file <path>]"
2776
+ puts " [--skip <plugins>] [--only <plugins>] [--no-browser]"
2622
2777
  puts ""
2623
2778
  puts "Re-runs every plugin's agent setup (Discord bot, Fizzy user, Basecamp person)."
2624
2779
  puts "Steps that are already done are skipped, so it's safe to run after an"
2625
2780
  puts "interrupted 'agent create' or to set up a newly installed plugin."
2781
+ puts "An agent without a role is asked to pick or write one."
2626
2782
  puts ""
2783
+ puts " --role r Assign an existing role"
2784
+ puts " --role-file f Create a role from a markdown file (named after the agent) and assign it"
2627
2785
  puts " --skip a,b Never set up these plugins for this agent (saved)"
2628
2786
  puts " --only a,b Run just these plugins (also un-skips them)"
2629
2787
  puts " --no-browser Print links instead of opening them"
@@ -2640,8 +2798,18 @@ when "agent"
2640
2798
 
2641
2799
  only_plugins = nil
2642
2800
  open_browser = true
2801
+ new_roles = []
2802
+ role_file = nil
2643
2803
  while ARGV.any?
2644
2804
  case ARGV.shift
2805
+ when "--role"
2806
+ new_roles << role_name_arg(ARGV.shift)
2807
+ when "--role-file"
2808
+ role_file = File.expand_path(ARGV.shift.to_s)
2809
+ unless File.file?(role_file)
2810
+ puts "❌ Role file not found: #{role_file}"
2811
+ exit 1
2812
+ end
2645
2813
  when "--skip"
2646
2814
  entry["skip_plugins"] = ((entry["skip_plugins"] || []) + plugin_list_arg(ARGV.shift)).uniq
2647
2815
  when "--only"
@@ -2667,6 +2835,26 @@ when "agent"
2667
2835
  # Explicitly running a plugin with --only un-skips it for good.
2668
2836
  entry["skip_plugins"] -= only_plugins if only_plugins && entry["skip_plugins"]
2669
2837
  entry.delete("skip_plugins") if entry["skip_plugins"] == []
2838
+
2839
+ display = entry["display_name"] || name
2840
+ if role_file
2841
+ role_name = (new_roles - available_roles).first || agent_key
2842
+ if available_roles.include?(role_name)
2843
+ puts "❌ Role '#{role_name}' already exists. Pass --role <new-name> to name the role from #{role_file}."
2844
+ exit 1
2845
+ end
2846
+ puts "✓ Created role #{write_role_file(role_name, File.read(role_file), display)}"
2847
+ new_roles |= [role_name]
2848
+ elsif new_roles.empty? && entry["role"].nil? && $stdin.tty?
2849
+ new_roles = prompt_for_agent_role(agent_key, display)
2850
+ end
2851
+ unknown = new_roles - available_roles
2852
+ if unknown.any?
2853
+ puts "❌ Unknown role(s): #{unknown.join(", ")}. Available: #{available_roles.join(", ")}"
2854
+ exit 1
2855
+ end
2856
+ puts "✓ Role: #{assign_roles(entry, new_roles).join(", ")}" if new_roles.any?
2857
+
2670
2858
  registry[agent_key] = entry
2671
2859
  File.write(agent_registry_file, JSON.pretty_generate(registry))
2672
2860
 
@@ -2937,8 +3125,9 @@ when "role"
2937
3125
 
2938
3126
  files.each do |f|
2939
3127
  name = File.basename(f, ".md")
2940
- # Read first non-empty, non-heading line as description
2941
- desc = File.readlines(f).find { |l| !l.strip.empty? && !l.start_with?("#") && !l.start_with?("---") }&.strip || ""
3128
+ # First non-empty, non-heading line after any frontmatter is the description
3129
+ body = File.read(f).sub(/\A---\n.*?\n---\n/m, "")
3130
+ desc = body.lines.find { |l| !l.strip.empty? && !l.start_with?("#") }&.strip || ""
2942
3131
  agents = agent_roles[name]
2943
3132
  agent_str = agents ? " (#{agents.join(", ")})" : ""
2944
3133
  puts "#{name}#{agent_str} — #{desc[0..70]}"
@@ -3035,19 +3224,64 @@ when "role"
3035
3224
  puts "✓ Removed role '#{role_name}' from #{agent_name}"
3036
3225
 
3037
3226
  when "create", "add"
3038
- name = ARGV[0]
3039
- unless name
3040
- puts "Usage: brainiac role create <name>"
3227
+ name = ARGV.shift
3228
+ if name.nil? || name.start_with?("--")
3229
+ puts "Usage: brainiac role create <name> [--from <file>] [--assign <agent>]"
3230
+ puts ""
3231
+ puts "Without --from, $EDITOR opens on a role template (or the template is written"
3232
+ puts "as-is when there's no terminal)."
3041
3233
  exit 1
3042
3234
  end
3043
- FileUtils.mkdir_p(roles_dir)
3235
+ name = role_name_arg(name)
3236
+ from = nil
3237
+ assign_to = nil
3238
+ while ARGV.any?
3239
+ case ARGV.shift
3240
+ when "--from" then from = File.expand_path(ARGV.shift.to_s)
3241
+ when "--assign" then assign_to = ARGV.shift
3242
+ else
3243
+ puts "Unknown option. Run 'brainiac role create' for usage."
3244
+ exit 1
3245
+ end
3246
+ end
3247
+
3044
3248
  file = File.join(roles_dir, "#{name}.md")
3045
3249
  if File.exist?(file)
3046
3250
  puts "Role '#{name}' already exists. Edit #{file} directly."
3047
3251
  exit 1
3048
3252
  end
3049
- File.write(file, "# Role: #{name.split("-").map(&:capitalize).join(" ")}\n\nDescribe this role's responsibilities here.\n")
3050
- puts "Created #{file} — edit it to define the role."
3253
+ if from && !File.file?(from)
3254
+ puts "File not found: #{from}"
3255
+ exit 1
3256
+ end
3257
+
3258
+ agent_registry_file = File.join(BRAINIAC_DIR, "agents.json")
3259
+ registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
3260
+ agent_key = assign_to && role_name_arg(assign_to)
3261
+ if agent_key && !registry[agent_key]
3262
+ puts "Agent '#{assign_to}' not found."
3263
+ exit 1
3264
+ end
3265
+ agent_display = agent_key && (registry[agent_key]["display_name"] || assign_to)
3266
+
3267
+ text = if from
3268
+ File.read(from)
3269
+ elsif $stdin.tty?
3270
+ edit_description_in_editor(name, role_template(name, agent_display))
3271
+ else
3272
+ role_template(name, agent_display).gsub(/^\s*<!--.*?-->\s*\n?/, "")
3273
+ end
3274
+ unless text
3275
+ puts "Empty role, nothing created."
3276
+ exit 1
3277
+ end
3278
+ puts "✓ Created #{write_role_file(name, text, agent_display)}"
3279
+
3280
+ if agent_key
3281
+ assign_roles(registry[agent_key], [name])
3282
+ File.write(agent_registry_file, JSON.pretty_generate(registry))
3283
+ puts "✓ Assigned role '#{name}' to #{agent_display}"
3284
+ end
3051
3285
 
3052
3286
  else
3053
3287
  puts <<~HELP
@@ -3056,7 +3290,8 @@ when "role"
3056
3290
  Commands:
3057
3291
  list List configured roles
3058
3292
  show <name> Show role definition
3059
- create <name> Create a new role file
3293
+ create <name> [--from <file>] [--assign <agent>]
3294
+ Create a role (opens $EDITOR on a template)
3060
3295
  assign <agent> <role> Assign a role to an agent (additive)
3061
3296
  unassign <agent> <role> Remove a role from an agent
3062
3297
  HELP
@@ -4378,7 +4613,7 @@ when "help", "--help", "-h", nil
4378
4613
  Role Commands:
4379
4614
  role list List configured roles and assignments
4380
4615
  role show <name> Show role definition
4381
- role create <name> Create a new role file
4616
+ role create <name> [--from f] [--assign a] Create a role (template in $EDITOR)
4382
4617
  role assign <agent> <role> Assign a role to an agent (additive)
4383
4618
  role unassign <agent> <role> Remove a role from an agent
4384
4619
 
@@ -116,7 +116,7 @@ _brainiac() {
116
116
  case "$agent_name" in
117
117
  list|remove|delete|rm|setup) ;;
118
118
  create|add)
119
- COMPREPLY=($(compgen -W "--avatar --description --description-file --email --local --role --cli --skip --only --no-browser" -- "$cur"))
119
+ COMPREPLY=($(compgen -W "--avatar --description --description-file --email --local --role --role-file --cli --skip --only --no-browser" -- "$cur"))
120
120
  ;;
121
121
  *)
122
122
  COMPREPLY=($(compgen -W "show env" -- "$cur"))
@@ -50,6 +50,7 @@ AGENT_REGISTRY = load_agent_registry
50
50
  def reload_agent_registry!(force: false)
51
51
  return unless file_changed?(AGENT_REGISTRY_FILE, force: force)
52
52
 
53
+ old_registry = AGENT_REGISTRY.dup
53
54
  old_keys = AGENT_REGISTRY.keys.to_set
54
55
  AGENT_REGISTRY.replace(load_agent_registry)
55
56
  new_keys = AGENT_REGISTRY.keys.to_set
@@ -70,6 +71,17 @@ def reload_agent_registry!(force: false)
70
71
  LOG.info "Agent removed: #{key}"
71
72
  Brainiac.emit(:agent_removed, agent_key: key)
72
73
  end
74
+
75
+ # Agents that already existed but whose entry changed — e.g. `agent create`
76
+ # wrote the entry first and a plugin added its token a few seconds later.
77
+ (old_keys & new_keys).each do |key|
78
+ entry = AGENT_REGISTRY[key]
79
+ next if entry == old_registry[key]
80
+
81
+ display_name = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
82
+ Brainiac.emit(:agent_updated, agent_key: key, display_name: display_name, entry: entry,
83
+ previous_entry: old_registry[key])
84
+ end
73
85
  end
74
86
 
75
87
  # Default environment for all agent processes. Prevents git from hanging
@@ -11,6 +11,7 @@
11
11
  # :agent_completed — After an agent session finishes (success or failure)
12
12
  # :agent_added — When a new agent appears in the registry (after reload)
13
13
  # :agent_removed — When an agent is removed from the registry (after reload)
14
+ # :agent_updated — When an existing agent's entry changes (after reload; has :previous_entry)
14
15
  # :pr_merged — After a GitHub PR is merged
15
16
  # :pr_opened — After a GitHub PR is opened
16
17
  # :pr_reviewed — After a PR review is submitted
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.4.0"
5
+ VERSION = "0.4.2"
6
6
  end
@@ -109,6 +109,9 @@ Available hooks:
109
109
  | `:create_work_item` | Create a card/issue/ticket | title, body, project |
110
110
  | `:detect_cli_provider` | Detect CLI provider | metadata hash |
111
111
  | `:detect_effort` | Detect effort level | metadata hash |
112
+ | `:agent_added` | New key in agents.json (after reload) | agent_key, display_name, entry |
113
+ | `:agent_updated` | Existing agent's entry changed (after reload) | agent_key, display_name, entry, previous_entry |
114
+ | `:agent_removed` | Key gone from agents.json (after reload) | agent_key |
112
115
 
113
116
  ## Agent Onboarding (`on_agent_created`)
114
117
 
@@ -172,6 +175,13 @@ Users choose which plugins run with `--skip` / `--only` (or by answering "n" whe
172
175
  asked during create). Core filters on the plugin name before calling you, so
173
176
  plugins don't need to handle this.
174
177
 
178
+ **Going live without a restart.** `agent create` / `agent setup` tell the running
179
+ server to reload `agents.json`. The server may already have seen the agent before
180
+ your hook wrote its token, so a server-side plugin that starts something per agent
181
+ (a gateway connection, a poller) must handle `:agent_updated` as well as
182
+ `:agent_added`: start it when the credential appears or changes, stop it when it's
183
+ removed.
184
+
175
185
  ## Channel Prompts
176
186
 
177
187
  If your plugin is a communication channel (like Discord, Fizzy), register a prompt:
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.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis