brainiac-basecamp 0.0.16 → 0.0.18

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: 4d77f4d0787e023edeec79754c9958f4ee09f036250038ef7cc4ae1dc9961a20
4
- data.tar.gz: 74d8aba9539c7526eed62e209b8a84cabe9ecf26482f8873f8c1581a2a70a86a
3
+ metadata.gz: c082cb39df24b56239de1b12de0772b25088854641d633afa7352a035e63b721
4
+ data.tar.gz: 2139e2c80a63636e2f3e427b9b784a4237d5f5abf2e52e8018407081d3b6ad23
5
5
  SHA512:
6
- metadata.gz: 198ee07060302e70cf71612ede20b9232efda88f91090273dcee7606264364f381ad7646443a8965319a2437cb5a7f46d6e1e1b92e2683aa87fb176028fd97d2
7
- data.tar.gz: 9194582cc89b6a9cb05721c0369f80762b656776b29e5ad5f97662a6ae460364d486c7d0d15b20bd36e131b9e41c74b9e66d71910155e30b206d0865bd03c967
6
+ metadata.gz: 16b4848a37f1d4a0b0e0a93940540cace70f020dd11c4cbad84410310f442cb6377a1bf7a8de0204326bf36c894a345aee88278908fffc5c2cd38ba0456b81d9
7
+ data.tar.gz: 2f2caa6a328819e0c27eb5f3b1da73b26e411b5a38f40d1bea2078ad4c3c9dfa1f381d25f83838675941a2cc06b599f50df99f75aec30bc024f891741d892d35
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "open3"
4
5
 
5
6
  module Brainiac
6
7
  module Plugins
@@ -30,11 +31,19 @@ module Brainiac
30
31
  cmd_projects(args)
31
32
  when "set"
32
33
  cmd_set(args)
34
+ when "reset"
35
+ cmd_reset(args)
36
+ when "webhook", "webhooks"
37
+ cmd_webhook(args)
33
38
  else
34
39
  print_help
35
40
  end
36
41
  end
37
42
 
43
+ # The canonical set of webhook event types this plugin needs.
44
+ # Add new types here as features are added — webhook sync will pick them up.
45
+ REQUIRED_WEBHOOK_TYPES = %w[Todo Todolist Comment].freeze
46
+
38
47
  private
39
48
 
40
49
  def cmd_setup
@@ -86,7 +95,8 @@ module Brainiac
86
95
  puts " 2. Add bot accounts: brainiac basecamp bot add <name> <person-id> <agent>"
87
96
  puts " 3. Map projects: brainiac basecamp projects map <brainiac-key> <basecamp-id>"
88
97
  puts " 4. Set review gate: brainiac basecamp set review-gate <on_complete|on_pr_merge>"
89
- puts " 5. Set up webhooks: basecamp webhooks create \"https://your-ngrok/basecamp\" --types \"Todo,Todolist\" --in <project>"
98
+ puts " 5. Set up webhooks: basecamp webhooks create \"https://your-ngrok/basecamp\" " \
99
+ "--types \"Todo,Todolist,Comment\" --in <project>"
90
100
  puts " 6. Restart brainiac: brainiac restart"
91
101
  end
92
102
 
@@ -172,8 +182,23 @@ module Brainiac
172
182
 
173
183
  status_icon = epic["status"] == "active" ? "🚀" : "✅"
174
184
  puts "#{status_icon} #{epic['title']}"
175
- puts " Agent: #{epic['agent']} | Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
185
+ puts " Todolist: #{epic['basecamp_todolist_id']} | Agent: #{epic['agent']}"
186
+ puts " Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
176
187
  puts " Started: #{epic['started_at']}"
188
+
189
+ # Show per-task detail if verbose or few tasks
190
+ if args.include?("--verbose") || args.include?("-v")
191
+ tasks.each do |t|
192
+ icon = case t["status"]
193
+ when "complete" then "✅"
194
+ when "in_flight" then "🚀"
195
+ when "in_review" then "👀"
196
+ when "final_decision" then "⚡"
197
+ else "⬜"
198
+ end
199
+ puts " #{icon} ##{t['fizzy_card']} — #{t['title']} [#{t['status']}]"
200
+ end
201
+ end
177
202
  puts ""
178
203
  end
179
204
  end
@@ -200,11 +225,37 @@ module Brainiac
200
225
  person_id = args.shift
201
226
  agent = args.shift
202
227
 
203
- unless name && person_id && agent
204
- puts "Usage: brainiac basecamp bot add <name> <basecamp-person-id> <default-agent>"
228
+ # If person_id is omitted but agent is given, or only agent name given,
229
+ # try to auto-resolve from Basecamp
230
+ if name && !person_id
231
+ # Single arg: treat as agent name, auto-resolve
232
+ agent = name
233
+ person_id = auto_resolve_person_id(agent)
234
+ unless person_id
235
+ puts "❌ Could not find '#{agent}' in Basecamp people list."
236
+ puts " Try: basecamp people list --jq '.data[] | {id, name}'"
237
+ return
238
+ end
239
+ name = agent.downcase
240
+ elsif name && person_id && !agent
241
+ # Two args: name + agent, auto-resolve person_id
242
+ agent = person_id
243
+ person_id = auto_resolve_person_id(agent)
244
+ unless person_id
245
+ puts "❌ Could not find '#{agent}' in Basecamp people list."
246
+ puts " Provide the person_id manually:"
247
+ puts " brainiac basecamp bot add <name> <person-id> <agent>"
248
+ return
249
+ end
250
+ elsif !(name && person_id && agent)
251
+ puts "Usage: brainiac basecamp bot add <agent-name>"
252
+ puts " brainiac basecamp bot add <name> <agent-name>"
253
+ puts " brainiac basecamp bot add <name> <basecamp-person-id> <agent-name>"
205
254
  puts ""
206
- puts "Example:"
207
- puts " brainiac basecamp bot add andy-server 12345 Galen"
255
+ puts "Examples:"
256
+ puts " brainiac basecamp bot add Galen # auto-resolves person_id"
257
+ puts " brainiac basecamp bot add andy-server Galen # auto-resolves person_id"
258
+ puts " brainiac basecamp bot add andy-server 52992796 Galen"
208
259
  return
209
260
  end
210
261
 
@@ -217,7 +268,68 @@ module Brainiac
217
268
  save_config(config)
218
269
  puts "✓ Added bot account '#{name}' (person_id: #{person_id}, agent: #{agent})"
219
270
 
220
- when "list"
271
+ when "sync"
272
+ # Auto-discover agents from ~/.brainiac/agents.json and resolve their
273
+ # Basecamp person IDs. Creates bot_account entries for any that match.
274
+ config = load_config
275
+ config["bot_accounts"] ||= {}
276
+
277
+ agents = load_agents_registry
278
+ if agents.empty?
279
+ puts "No agents found in ~/.brainiac/agents.json"
280
+ return
281
+ end
282
+
283
+ puts "Syncing bot accounts from agents registry..."
284
+ puts "Found #{agents.size} agent(s): #{agents.map { |k, v| v['display_name'] || k }.join(', ')}"
285
+ puts ""
286
+
287
+ added = 0
288
+ updated = 0
289
+ not_found = 0
290
+
291
+ agents.each do |key, agent_config|
292
+ display_name = agent_config["display_name"] || key.capitalize
293
+ # Skip the meta "brainiac" agent
294
+ next if key == "brainiac"
295
+
296
+ resolved_id = auto_resolve_person_id(display_name)
297
+
298
+ if resolved_id
299
+ existing = config["bot_accounts"].find { |_k, v| v["default_agent"] == display_name }
300
+
301
+ if existing
302
+ _existing_key, existing_account = existing
303
+ if existing_account["person_id"].to_s == resolved_id.to_s
304
+ puts " \u00b7 #{display_name}: #{resolved_id} (unchanged)"
305
+ else
306
+ existing_account["person_id"] = resolved_id
307
+ updated += 1
308
+ puts " \u2191 #{display_name}: updated person_id \u2192 #{resolved_id}"
309
+ end
310
+ else
311
+ # Create new bot_account entry using agent key as the account name
312
+ config["bot_accounts"][key] = {
313
+ "person_id" => resolved_id,
314
+ "default_agent" => display_name
315
+ }
316
+ added += 1
317
+ puts " + #{display_name}: #{resolved_id} (new)"
318
+ end
319
+ else
320
+ puts " \u2717 #{display_name}: not found in Basecamp"
321
+ not_found += 1
322
+ end
323
+ end
324
+
325
+ if (added + updated).positive?
326
+ save_config(config)
327
+ puts ""
328
+ puts "\u2713 Added #{added}, updated #{updated} bot account(s)" if added.positive? || updated.positive?
329
+ else
330
+ puts "\nAll bot accounts already up to date."
331
+ end
332
+ puts " (#{not_found} agent(s) not found in Basecamp)" if not_found.positive?
221
333
  config = load_config
222
334
  bots = config["bot_accounts"] || {}
223
335
  if bots.empty?
@@ -301,6 +413,102 @@ module Brainiac
301
413
  end
302
414
  end
303
415
 
416
+ def cmd_webhook(args)
417
+ action = args.shift
418
+
419
+ case action
420
+ when "sync"
421
+ cmd_webhook_sync
422
+ else
423
+ puts "Usage: brainiac basecamp webhook <command>"
424
+ puts ""
425
+ puts "Commands:"
426
+ puts " sync Ensure all project webhooks have the required event types"
427
+ end
428
+ end
429
+
430
+ def cmd_webhook_sync
431
+ config = load_config
432
+ mappings = config["project_mappings"] || {}
433
+
434
+ if mappings.empty?
435
+ puts "No project mappings configured. Add one first:"
436
+ puts " brainiac basecamp projects map <key> <basecamp-project-id>"
437
+ return
438
+ end
439
+
440
+ updated = 0
441
+ skipped = 0
442
+ not_found = 0
443
+
444
+ mappings.each do |key, mapping|
445
+ project_id = mapping["basecamp_project_id"]
446
+ puts "Checking project '#{key}' (Basecamp #{project_id})..."
447
+
448
+ # List webhooks for this project
449
+ output, status = Open3.capture2("basecamp", "webhooks", "list", "--in", project_id.to_s, "--json")
450
+ unless status.success?
451
+ puts " ✗ Failed to list webhooks"
452
+ not_found += 1
453
+ next
454
+ end
455
+
456
+ data = JSON.parse(output)
457
+ webhooks = data["data"] || []
458
+
459
+ if webhooks.empty?
460
+ puts " ⚠ No webhooks found"
461
+ not_found += 1
462
+ next
463
+ end
464
+
465
+ # Find webhooks that point to a /basecamp endpoint (ours)
466
+ our_webhooks = webhooks.select { |wh| wh["payload_url"]&.include?("/basecamp") }
467
+
468
+ if our_webhooks.empty?
469
+ puts " ⚠ No webhooks with /basecamp endpoint found"
470
+ not_found += 1
471
+ next
472
+ end
473
+
474
+ our_webhooks.each do |wh|
475
+ current_types = wh["types"] || []
476
+ missing_types = REQUIRED_WEBHOOK_TYPES - current_types
477
+
478
+ if missing_types.empty?
479
+ puts " · Webhook #{wh['id']} (#{wh['payload_url']}): up to date"
480
+ skipped += 1
481
+ next
482
+ end
483
+
484
+ new_types = (current_types + missing_types).uniq
485
+ puts " ↑ Webhook #{wh['id']}: adding #{missing_types.join(', ')}"
486
+
487
+ _, stderr, update_status = Open3.capture3(
488
+ "basecamp", "webhooks", "update", wh["id"].to_s,
489
+ "--types", new_types.join(","),
490
+ "--in", project_id.to_s
491
+ )
492
+
493
+ if update_status.success?
494
+ puts " ✓ Updated: #{new_types.join(', ')}"
495
+ updated += 1
496
+ else
497
+ puts " ✗ Failed: #{stderr.strip}"
498
+ end
499
+ end
500
+ end
501
+
502
+ puts ""
503
+ if updated.positive?
504
+ puts "✓ Updated #{updated} webhook(s)"
505
+ elsif skipped.positive? && updated.zero?
506
+ puts "All webhooks already have required types: #{REQUIRED_WEBHOOK_TYPES.join(', ')}"
507
+ else
508
+ puts "No webhooks updated."
509
+ end
510
+ end
511
+
304
512
  def print_help
305
513
  puts <<~HELP
306
514
  Usage: brainiac basecamp <command>
@@ -311,15 +519,21 @@ module Brainiac
311
519
  status Check plugin status
312
520
  epics [--all] List active epics (--all for completed too)
313
521
  link <card> <url> Link a Fizzy card to a Basecamp todo
314
- bot add <name> <person-id> <agent> Add a bot account mapping
522
+ bot add <agent-name> Add bot (auto-resolves person_id from Basecamp)
523
+ bot add <name> <person-id> <agent> Add bot with explicit person_id
524
+ bot sync Re-resolve all bot person IDs from Basecamp
315
525
  bot list List bot accounts
316
526
  bot remove <name> Remove a bot account
317
527
  projects map <key> <basecamp-id> Map a Brainiac project to Basecamp
318
528
  projects list List project mappings
319
529
  projects unmap <key> Remove a project mapping
320
- set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
530
+ set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
321
531
  set review-gate <mode> Set review gate (on_complete or on_pr_merge)
322
532
  set epic-prefix <prefix> Set epic todolist prefix (default: "Epic:")
533
+ reset task <card-number> [--to <status>] Reset a task to a given status (default: pending)
534
+ reset epic <todolist-id> [--to <status>] Reset entire epic or all tasks within it
535
+ reset gates <card-number> Clear gate approvals and re-dispatch gates
536
+ webhook sync Ensure webhooks have all required event types
323
537
 
324
538
  Config file: ~/.brainiac/basecamp.json
325
539
  Epics state: ~/.brainiac/basecamp_epics.json
@@ -327,6 +541,13 @@ module Brainiac
327
541
  Review gate modes:
328
542
  on_complete — Advance to next task as soon as agent finishes (default)
329
543
  on_pr_merge — Wait for PR to be merged before advancing (review gate)
544
+
545
+ Task statuses (for reset --to):
546
+ pending — Not yet started, waiting for dependencies
547
+ in_flight — Agent is actively working on it
548
+ in_review — PR open, gates reviewing
549
+ final_decision — Gates passed, awaiting implementation agent merge decision
550
+ complete — Done
330
551
  HELP
331
552
  end
332
553
 
@@ -385,6 +606,309 @@ module Brainiac
385
606
  end
386
607
  end
387
608
 
609
+ def cmd_reset(args)
610
+ subcommand = args.shift
611
+
612
+ case subcommand
613
+ when "task"
614
+ cmd_reset_task(args)
615
+ when "epic"
616
+ cmd_reset_epic(args)
617
+ when "gates"
618
+ cmd_reset_gates(args)
619
+ else
620
+ puts <<~HELP
621
+ Usage: brainiac basecamp reset <subcommand>
622
+
623
+ Subcommands:
624
+ task <card-number> [--to <status>] Reset a task's status (default: pending)
625
+ epic <todolist-id> [--to <status>] Reset entire epic or all its tasks
626
+ gates <card-number> Clear gate approvals and re-dispatch
627
+
628
+ Task statuses:
629
+ pending, in_flight, in_review, final_decision, complete
630
+
631
+ Examples:
632
+ brainiac basecamp reset task 1234 # Reset card #1234 to pending
633
+ brainiac basecamp reset task 1234 --to in_flight # Reset to in_flight
634
+ brainiac basecamp reset gates 1234 # Clear gates, re-dispatch reviewers
635
+ brainiac basecamp reset epic 10233212224 # Reset all tasks to pending
636
+ brainiac basecamp reset epic 10233212224 --to pending # Same as above
637
+ HELP
638
+ end
639
+ end
640
+
641
+ def cmd_reset_task(args)
642
+ card_number = args.shift&.gsub(/^#/, "")
643
+ unless card_number
644
+ puts "Usage: brainiac basecamp reset task <card-number> [--to <status>]"
645
+ return
646
+ end
647
+
648
+ target_status = parse_to_flag(args) || "pending"
649
+ unless valid_task_status?(target_status)
650
+ puts "Error: Invalid status '#{target_status}'"
651
+ puts "Valid statuses: pending, in_flight, in_review, final_decision, complete"
652
+ return
653
+ end
654
+
655
+ epics = load_epics
656
+ epic = epics.find do |e|
657
+ e["status"] == "active" &&
658
+ e["tasks"]&.any? { |t| t["fizzy_card"] == card_number.to_i }
659
+ end
660
+
661
+ unless epic
662
+ puts "Error: Card ##{card_number} not found in any active epic"
663
+ return
664
+ end
665
+
666
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
667
+ old_status = task["status"]
668
+
669
+ # Reset the task
670
+ reset_task_to(task, target_status)
671
+
672
+ save_epics(epics)
673
+ puts "✓ Reset card ##{card_number} from '#{old_status}' → '#{target_status}'"
674
+ puts " Epic: #{epic['title']}"
675
+
676
+ return unless target_status == "pending"
677
+
678
+ puts " The task will be picked up on next dispatch cycle (restart brainiac or wait for next event)"
679
+ end
680
+
681
+ def cmd_reset_gates(args)
682
+ card_number = args.shift&.gsub(/^#/, "")
683
+ unless card_number
684
+ puts "Usage: brainiac basecamp reset gates <card-number>"
685
+ return
686
+ end
687
+
688
+ epics = load_epics
689
+ epic = epics.find do |e|
690
+ e["status"] == "active" &&
691
+ e["tasks"]&.any? { |t| t["fizzy_card"] == card_number.to_i }
692
+ end
693
+
694
+ unless epic
695
+ puts "Error: Card ##{card_number} not found in any active epic"
696
+ return
697
+ end
698
+
699
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
700
+
701
+ # Clear all gate-related state
702
+ old_approvals = task["gate_approvals"]&.size || 0
703
+ old_changes = task["changes_requested_by"]&.size || 0
704
+
705
+ task["gate_approvals"] = []
706
+ task["changes_requested_by"] = []
707
+ task["gates_dispatched_at"] = nil
708
+ task["awaiting_final_decision"] = nil
709
+ task["changes_debounce_started"] = nil
710
+ task["gate_redispatch_counts"] = {}
711
+ task["last_redispatch_at"] = nil
712
+ task["status"] = "in_review"
713
+ epic["updated_at"] = Time.now.iso8601
714
+
715
+ save_epics(epics)
716
+ puts "✓ Cleared gates for card ##{card_number}"
717
+ puts " Removed #{old_approvals} approval(s), #{old_changes} changes_requested"
718
+ puts " Status set to 'in_review' — gates will be re-dispatched on next health check"
719
+ puts ""
720
+ puts " To force immediate re-dispatch, restart brainiac:"
721
+ puts " brainiac restart"
722
+ end
723
+
724
+ def cmd_reset_epic(args)
725
+ todolist_id = args.shift
726
+ unless todolist_id
727
+ puts "Usage: brainiac basecamp reset epic <todolist-id> [--to <status>]"
728
+ puts ""
729
+ puts "Options:"
730
+ puts " --to <status> Reset all tasks to this status (default: pending)"
731
+ puts " --reactivate Reset a completed epic back to active"
732
+ puts ""
733
+ puts "Find your todolist ID with: brainiac basecamp epics --all"
734
+ return
735
+ end
736
+
737
+ target_status = parse_to_flag(args) || "pending"
738
+ reactivate = args.include?("--reactivate")
739
+
740
+ unless valid_task_status?(target_status)
741
+ puts "Error: Invalid status '#{target_status}'"
742
+ puts "Valid statuses: pending, in_flight, in_review, final_decision, complete"
743
+ return
744
+ end
745
+
746
+ epics = load_epics
747
+ epic = epics.find { |e| e["basecamp_todolist_id"] == todolist_id.to_s }
748
+
749
+ unless epic
750
+ puts "Error: No epic found with todolist ID #{todolist_id}"
751
+ puts ""
752
+ puts "Active epics:"
753
+ epics.select { |e| e["status"] == "active" }.each do |e|
754
+ puts " #{e['basecamp_todolist_id']} — #{e['title']}"
755
+ end
756
+ return
757
+ end
758
+
759
+ if epic["status"] != "active" && !reactivate
760
+ puts "Error: Epic '#{epic['title']}' is #{epic['status']} (not active)"
761
+ puts " Use --reactivate to set it back to active"
762
+ return
763
+ end
764
+
765
+ # Reactivate if needed
766
+ if epic["status"] != "active" && reactivate
767
+ epic["status"] = "active"
768
+ epic["completed_at"] = nil
769
+ puts "✓ Reactivated epic '#{epic['title']}'"
770
+ end
771
+
772
+ # Reset all non-complete tasks (unless resetting to pending, in which case reset all)
773
+ tasks_to_reset = if target_status == "pending"
774
+ epic["tasks"]
775
+ else
776
+ epic["tasks"].reject { |t| t["status"] == "complete" }
777
+ end
778
+
779
+ tasks_to_reset.each { |t| reset_task_to(t, target_status) }
780
+ epic["updated_at"] = Time.now.iso8601
781
+
782
+ save_epics(epics)
783
+ puts "✓ Reset #{tasks_to_reset.size} task(s) → '#{target_status}' in epic '#{epic['title']}'"
784
+ puts ""
785
+ epic["tasks"].each do |t|
786
+ icon = case t["status"]
787
+ when "complete" then "✅"
788
+ when "in_flight" then "🚀"
789
+ when "in_review" then "👀"
790
+ when "final_decision" then "⚡"
791
+ else "⬜"
792
+ end
793
+ puts " #{icon} ##{t['fizzy_card']} — #{t['title']} [#{t['status']}]"
794
+ end
795
+ end
796
+
797
+ # --- Reset helpers ---
798
+
799
+ def valid_task_status?(status)
800
+ %w[pending in_flight in_review final_decision complete].include?(status)
801
+ end
802
+
803
+ def parse_to_flag(args)
804
+ idx = args.index("--to")
805
+ return nil unless idx
806
+
807
+ args.delete_at(idx) # remove --to
808
+ args.delete_at(idx) # remove the value (now at same index)
809
+ end
810
+
811
+ def reset_task_to(task, status)
812
+ task["status"] = status
813
+
814
+ case status
815
+ when "pending"
816
+ # Clear all progress state
817
+ task["dispatched_at"] = nil
818
+ task["completed_at"] = nil
819
+ task["pr_number"] = nil
820
+ task["pr_repo"] = nil
821
+ task["gate_approvals"] = []
822
+ task["changes_requested_by"] = []
823
+ task["gates_dispatched_at"] = nil
824
+ task["awaiting_final_decision"] = nil
825
+ task["changes_debounce_started"] = nil
826
+ task["gate_redispatch_counts"] = {}
827
+ task["last_redispatch_at"] = nil
828
+ task["last_reviewed_sha"] = nil
829
+ when "in_flight"
830
+ # Keep PR info but clear gate state
831
+ task["completed_at"] = nil
832
+ task["gate_approvals"] = []
833
+ task["changes_requested_by"] = []
834
+ task["gates_dispatched_at"] = nil
835
+ task["awaiting_final_decision"] = nil
836
+ task["changes_debounce_started"] = nil
837
+ task["gate_redispatch_counts"] = {}
838
+ task["last_redispatch_at"] = nil
839
+ task["dispatched_at"] ||= Time.now.iso8601
840
+ when "in_review"
841
+ # Clear gate approvals but keep PR info
842
+ task["completed_at"] = nil
843
+ task["gate_approvals"] = []
844
+ task["changes_requested_by"] = []
845
+ task["gates_dispatched_at"] = nil
846
+ task["awaiting_final_decision"] = nil
847
+ task["changes_debounce_started"] = nil
848
+ task["gate_redispatch_counts"] = {}
849
+ task["last_redispatch_at"] = nil
850
+ when "final_decision"
851
+ task["completed_at"] = nil
852
+ task["awaiting_final_decision"] = true
853
+ when "complete"
854
+ task["completed_at"] ||= Time.now.iso8601
855
+ end
856
+ end
857
+
858
+ def load_epics
859
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
860
+ return [] unless File.exist?(epics_file)
861
+
862
+ data = JSON.parse(File.read(epics_file))
863
+ data["epics"] || []
864
+ rescue JSON::ParserError
865
+ []
866
+ end
867
+
868
+ def save_epics(epics)
869
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
870
+ File.write(epics_file, JSON.pretty_generate({
871
+ "epics" => epics,
872
+ "updated_at" => Time.now.iso8601
873
+ }))
874
+ end
875
+
876
+ def auto_resolve_person_id(agent_name)
877
+ output, status = Open3.capture2(
878
+ "basecamp", "people", "list", "--jq",
879
+ ".data[] | select(.name | ascii_downcase | contains(\"#{agent_name.downcase}\")) | {id, name}"
880
+ )
881
+ return nil unless status.success? && !output.strip.empty?
882
+
883
+ # Parse each JSON line — prefer exact match
884
+ candidates = []
885
+ output.each_line do |line|
886
+ person = JSON.parse(line.strip)
887
+ candidates << person
888
+ rescue JSON::ParserError
889
+ next
890
+ end
891
+
892
+ # Exact name match first
893
+ exact = candidates.find { |p| p["name"]&.downcase == agent_name.downcase }
894
+ return exact["id"].to_s if exact
895
+
896
+ # Otherwise first contains-match
897
+ candidates.first&.dig("id")&.to_s
898
+ end
899
+
900
+ # Load the agents registry from ~/.brainiac/agents.json
901
+ #
902
+ # @return [Hash] Agent key => config hash
903
+ def load_agents_registry
904
+ agents_file = File.join(BRAINIAC_DIR, "agents.json")
905
+ return {} unless File.exist?(agents_file)
906
+
907
+ JSON.parse(File.read(agents_file))
908
+ rescue JSON::ParserError
909
+ {}
910
+ end
911
+
388
912
  def load_config
389
913
  if File.exist?(CONFIG_FILE)
390
914
  JSON.parse(File.read(CONFIG_FILE))
@@ -404,7 +928,7 @@ module Brainiac
404
928
  end
405
929
 
406
930
  def self.completions
407
- %w[setup config status epics link bot projects set]
931
+ %w[setup config status epics link bot projects set reset webhook]
408
932
  end
409
933
  end
410
934
  end