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 +4 -4
- data/lib/brainiac/plugins/basecamp/cli.rb +534 -10
- data/lib/brainiac/plugins/basecamp/comment_responder.rb +380 -0
- data/lib/brainiac/plugins/basecamp/epic_memory.rb +10 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +94 -270
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +65 -187
- data/lib/brainiac/plugins/basecamp/review_gate.rb +165 -218
- data/lib/brainiac/plugins/basecamp/session_registry.rb +361 -0
- data/lib/brainiac/plugins/basecamp/task_state.rb +86 -0
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp/webhook.rb +4 -6
- data/lib/brainiac/plugins/basecamp.rb +128 -492
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c082cb39df24b56239de1b12de0772b25088854641d633afa7352a035e63b721
|
|
4
|
+
data.tar.gz: 2139e2c80a63636e2f3e427b9b784a4237d5f5abf2e52e8018407081d3b6ad23
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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\"
|
|
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 "
|
|
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
|
-
|
|
204
|
-
|
|
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 "
|
|
207
|
-
puts " brainiac basecamp bot add
|
|
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 "
|
|
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>
|
|
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>
|
|
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
|