brainiac-basecamp 0.0.15 → 0.0.17

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: e8e71f8f043a52cb54d83e974031b0bb83339f02452e701bf381e4e87b32274d
4
- data.tar.gz: ac57b522a30cdb3cdf42c0aca069c52bb1ce27b2b93fdbdf4b63a2c9f57088b9
3
+ metadata.gz: 632f37b6a52ba851b1f16430cc9f2af1a34b0343adcdc486e950d78470d01eb8
4
+ data.tar.gz: 7af23b5ee546eacb7b5d51b0dab4879dc642d7f0e8548ba4d94451e2aadc6518
5
5
  SHA512:
6
- metadata.gz: 955cfcffe81b1856b0ee90e8c2124b48d13b3de28db1e258054069442b8a67096deeb95cda1b5ce0e21b20cd2b78e3bd7c73fd107c5753c3de82ef0ead916d01
7
- data.tar.gz: 7b6e92a54b136065013d178d3422bc7583958f83b161184c9905b2d950898907ee1eebdffa6d2cf07bc7394836b98aef52acd8bb50583231b2b6b8f2f9ca3247
6
+ metadata.gz: 4d69200fb71f7d04244f0bfcd3d1e8b9ecc97920cb9f1488a96a3fab6da51117af64dd4a47a0ba3a4f8857ce2c36fd5ec23efcf48e35efa5b34f29f86e3af72d
7
+ data.tar.gz: c2253c409cdc64c02ec853dfca0623cc483ddd527b6f8135d9af9eb7ee256745a86a5992f998f6fcc73d10175593150f2288beb6437a4fc25e3c2ce7c278d8e1
@@ -30,6 +30,8 @@ module Brainiac
30
30
  cmd_projects(args)
31
31
  when "set"
32
32
  cmd_set(args)
33
+ when "reset"
34
+ cmd_reset(args)
33
35
  else
34
36
  print_help
35
37
  end
@@ -172,8 +174,23 @@ module Brainiac
172
174
 
173
175
  status_icon = epic["status"] == "active" ? "🚀" : "✅"
174
176
  puts "#{status_icon} #{epic['title']}"
175
- puts " Agent: #{epic['agent']} | Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
177
+ puts " Todolist: #{epic['basecamp_todolist_id']} | Agent: #{epic['agent']}"
178
+ puts " Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
176
179
  puts " Started: #{epic['started_at']}"
180
+
181
+ # Show per-task detail if verbose or few tasks
182
+ if args.include?("--verbose") || args.include?("-v")
183
+ tasks.each do |t|
184
+ icon = case t["status"]
185
+ when "complete" then "✅"
186
+ when "in_flight" then "🚀"
187
+ when "in_review" then "👀"
188
+ when "final_decision" then "⚡"
189
+ else "⬜"
190
+ end
191
+ puts " #{icon} ##{t['fizzy_card']} — #{t['title']} [#{t['status']}]"
192
+ end
193
+ end
177
194
  puts ""
178
195
  end
179
196
  end
@@ -317,9 +334,12 @@ module Brainiac
317
334
  projects map <key> <basecamp-id> Map a Brainiac project to Basecamp
318
335
  projects list List project mappings
319
336
  projects unmap <key> Remove a project mapping
320
- set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
337
+ set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
321
338
  set review-gate <mode> Set review gate (on_complete or on_pr_merge)
322
339
  set epic-prefix <prefix> Set epic todolist prefix (default: "Epic:")
340
+ reset task <card-number> [--to <status>] Reset a task to a given status (default: pending)
341
+ reset epic <todolist-id> [--to <status>] Reset entire epic or all tasks within it
342
+ reset gates <card-number> Clear gate approvals and re-dispatch gates
323
343
 
324
344
  Config file: ~/.brainiac/basecamp.json
325
345
  Epics state: ~/.brainiac/basecamp_epics.json
@@ -327,6 +347,13 @@ module Brainiac
327
347
  Review gate modes:
328
348
  on_complete — Advance to next task as soon as agent finishes (default)
329
349
  on_pr_merge — Wait for PR to be merged before advancing (review gate)
350
+
351
+ Task statuses (for reset --to):
352
+ pending — Not yet started, waiting for dependencies
353
+ in_flight — Agent is actively working on it
354
+ in_review — PR open, gates reviewing
355
+ final_decision — Gates passed, awaiting implementation agent merge decision
356
+ complete — Done
330
357
  HELP
331
358
  end
332
359
 
@@ -385,6 +412,273 @@ module Brainiac
385
412
  end
386
413
  end
387
414
 
415
+ def cmd_reset(args)
416
+ subcommand = args.shift
417
+
418
+ case subcommand
419
+ when "task"
420
+ cmd_reset_task(args)
421
+ when "epic"
422
+ cmd_reset_epic(args)
423
+ when "gates"
424
+ cmd_reset_gates(args)
425
+ else
426
+ puts <<~HELP
427
+ Usage: brainiac basecamp reset <subcommand>
428
+
429
+ Subcommands:
430
+ task <card-number> [--to <status>] Reset a task's status (default: pending)
431
+ epic <todolist-id> [--to <status>] Reset entire epic or all its tasks
432
+ gates <card-number> Clear gate approvals and re-dispatch
433
+
434
+ Task statuses:
435
+ pending, in_flight, in_review, final_decision, complete
436
+
437
+ Examples:
438
+ brainiac basecamp reset task 1234 # Reset card #1234 to pending
439
+ brainiac basecamp reset task 1234 --to in_flight # Reset to in_flight
440
+ brainiac basecamp reset gates 1234 # Clear gates, re-dispatch reviewers
441
+ brainiac basecamp reset epic 10233212224 # Reset all tasks to pending
442
+ brainiac basecamp reset epic 10233212224 --to pending # Same as above
443
+ HELP
444
+ end
445
+ end
446
+
447
+ def cmd_reset_task(args)
448
+ card_number = args.shift&.gsub(/^#/, "")
449
+ unless card_number
450
+ puts "Usage: brainiac basecamp reset task <card-number> [--to <status>]"
451
+ return
452
+ end
453
+
454
+ target_status = parse_to_flag(args) || "pending"
455
+ unless valid_task_status?(target_status)
456
+ puts "Error: Invalid status '#{target_status}'"
457
+ puts "Valid statuses: pending, in_flight, in_review, final_decision, complete"
458
+ return
459
+ end
460
+
461
+ epics = load_epics
462
+ epic = epics.find do |e|
463
+ e["status"] == "active" &&
464
+ e["tasks"]&.any? { |t| t["fizzy_card"] == card_number.to_i }
465
+ end
466
+
467
+ unless epic
468
+ puts "Error: Card ##{card_number} not found in any active epic"
469
+ return
470
+ end
471
+
472
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
473
+ old_status = task["status"]
474
+
475
+ # Reset the task
476
+ reset_task_to(task, target_status)
477
+
478
+ save_epics(epics)
479
+ puts "✓ Reset card ##{card_number} from '#{old_status}' → '#{target_status}'"
480
+ puts " Epic: #{epic['title']}"
481
+
482
+ return unless target_status == "pending"
483
+
484
+ puts " The task will be picked up on next dispatch cycle (restart brainiac or wait for next event)"
485
+ end
486
+
487
+ def cmd_reset_gates(args)
488
+ card_number = args.shift&.gsub(/^#/, "")
489
+ unless card_number
490
+ puts "Usage: brainiac basecamp reset gates <card-number>"
491
+ return
492
+ end
493
+
494
+ epics = load_epics
495
+ epic = epics.find do |e|
496
+ e["status"] == "active" &&
497
+ e["tasks"]&.any? { |t| t["fizzy_card"] == card_number.to_i }
498
+ end
499
+
500
+ unless epic
501
+ puts "Error: Card ##{card_number} not found in any active epic"
502
+ return
503
+ end
504
+
505
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
506
+
507
+ # Clear all gate-related state
508
+ old_approvals = task["gate_approvals"]&.size || 0
509
+ old_changes = task["changes_requested_by"]&.size || 0
510
+
511
+ task["gate_approvals"] = []
512
+ task["changes_requested_by"] = []
513
+ task["gates_dispatched_at"] = nil
514
+ task["awaiting_final_decision"] = nil
515
+ task["changes_debounce_started"] = nil
516
+ task["gate_redispatch_counts"] = {}
517
+ task["last_redispatch_at"] = nil
518
+ task["status"] = "in_review"
519
+ epic["updated_at"] = Time.now.iso8601
520
+
521
+ save_epics(epics)
522
+ puts "✓ Cleared gates for card ##{card_number}"
523
+ puts " Removed #{old_approvals} approval(s), #{old_changes} changes_requested"
524
+ puts " Status set to 'in_review' — gates will be re-dispatched on next health check"
525
+ puts ""
526
+ puts " To force immediate re-dispatch, restart brainiac:"
527
+ puts " brainiac restart"
528
+ end
529
+
530
+ def cmd_reset_epic(args)
531
+ todolist_id = args.shift
532
+ unless todolist_id
533
+ puts "Usage: brainiac basecamp reset epic <todolist-id> [--to <status>]"
534
+ puts ""
535
+ puts "Options:"
536
+ puts " --to <status> Reset all tasks to this status (default: pending)"
537
+ puts " --reactivate Reset a completed epic back to active"
538
+ puts ""
539
+ puts "Find your todolist ID with: brainiac basecamp epics --all"
540
+ return
541
+ end
542
+
543
+ target_status = parse_to_flag(args) || "pending"
544
+ reactivate = args.include?("--reactivate")
545
+
546
+ unless valid_task_status?(target_status)
547
+ puts "Error: Invalid status '#{target_status}'"
548
+ puts "Valid statuses: pending, in_flight, in_review, final_decision, complete"
549
+ return
550
+ end
551
+
552
+ epics = load_epics
553
+ epic = epics.find { |e| e["basecamp_todolist_id"] == todolist_id.to_s }
554
+
555
+ unless epic
556
+ puts "Error: No epic found with todolist ID #{todolist_id}"
557
+ puts ""
558
+ puts "Active epics:"
559
+ epics.select { |e| e["status"] == "active" }.each do |e|
560
+ puts " #{e['basecamp_todolist_id']} — #{e['title']}"
561
+ end
562
+ return
563
+ end
564
+
565
+ if epic["status"] != "active" && !reactivate
566
+ puts "Error: Epic '#{epic['title']}' is #{epic['status']} (not active)"
567
+ puts " Use --reactivate to set it back to active"
568
+ return
569
+ end
570
+
571
+ # Reactivate if needed
572
+ if epic["status"] != "active" && reactivate
573
+ epic["status"] = "active"
574
+ epic["completed_at"] = nil
575
+ puts "✓ Reactivated epic '#{epic['title']}'"
576
+ end
577
+
578
+ # Reset all non-complete tasks (unless resetting to pending, in which case reset all)
579
+ tasks_to_reset = if target_status == "pending"
580
+ epic["tasks"]
581
+ else
582
+ epic["tasks"].reject { |t| t["status"] == "complete" }
583
+ end
584
+
585
+ tasks_to_reset.each { |t| reset_task_to(t, target_status) }
586
+ epic["updated_at"] = Time.now.iso8601
587
+
588
+ save_epics(epics)
589
+ puts "✓ Reset #{tasks_to_reset.size} task(s) → '#{target_status}' in epic '#{epic['title']}'"
590
+ puts ""
591
+ epic["tasks"].each do |t|
592
+ icon = case t["status"]
593
+ when "complete" then "✅"
594
+ when "in_flight" then "🚀"
595
+ when "in_review" then "👀"
596
+ when "final_decision" then "⚡"
597
+ else "⬜"
598
+ end
599
+ puts " #{icon} ##{t['fizzy_card']} — #{t['title']} [#{t['status']}]"
600
+ end
601
+ end
602
+
603
+ # --- Reset helpers ---
604
+
605
+ def valid_task_status?(status)
606
+ %w[pending in_flight in_review final_decision complete].include?(status)
607
+ end
608
+
609
+ def parse_to_flag(args)
610
+ idx = args.index("--to")
611
+ return nil unless idx
612
+
613
+ args.delete_at(idx) # remove --to
614
+ args.delete_at(idx) # remove the value (now at same index)
615
+ end
616
+
617
+ def reset_task_to(task, status)
618
+ task["status"] = status
619
+
620
+ case status
621
+ when "pending"
622
+ # Clear all progress state
623
+ task["dispatched_at"] = nil
624
+ task["completed_at"] = nil
625
+ task["pr_number"] = nil
626
+ task["pr_repo"] = nil
627
+ task["gate_approvals"] = []
628
+ task["changes_requested_by"] = []
629
+ task["gates_dispatched_at"] = nil
630
+ task["awaiting_final_decision"] = nil
631
+ task["changes_debounce_started"] = nil
632
+ task["gate_redispatch_counts"] = {}
633
+ task["last_redispatch_at"] = nil
634
+ task["last_reviewed_sha"] = nil
635
+ when "in_flight"
636
+ # Keep PR info but clear gate state
637
+ task["completed_at"] = nil
638
+ task["gate_approvals"] = []
639
+ task["changes_requested_by"] = []
640
+ task["gates_dispatched_at"] = nil
641
+ task["awaiting_final_decision"] = nil
642
+ task["changes_debounce_started"] = nil
643
+ task["gate_redispatch_counts"] = {}
644
+ task["last_redispatch_at"] = nil
645
+ task["dispatched_at"] ||= Time.now.iso8601
646
+ when "in_review"
647
+ # Clear gate approvals but keep PR info
648
+ task["completed_at"] = nil
649
+ task["gate_approvals"] = []
650
+ task["changes_requested_by"] = []
651
+ task["gates_dispatched_at"] = nil
652
+ task["awaiting_final_decision"] = nil
653
+ task["changes_debounce_started"] = nil
654
+ task["gate_redispatch_counts"] = {}
655
+ task["last_redispatch_at"] = nil
656
+ when "final_decision"
657
+ task["completed_at"] = nil
658
+ task["awaiting_final_decision"] = true
659
+ when "complete"
660
+ task["completed_at"] ||= Time.now.iso8601
661
+ end
662
+ end
663
+
664
+ def load_epics
665
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
666
+ return [] unless File.exist?(epics_file)
667
+
668
+ data = JSON.parse(File.read(epics_file))
669
+ data["epics"] || []
670
+ rescue JSON::ParserError
671
+ []
672
+ end
673
+
674
+ def save_epics(epics)
675
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
676
+ File.write(epics_file, JSON.pretty_generate({
677
+ "epics" => epics,
678
+ "updated_at" => Time.now.iso8601
679
+ }))
680
+ end
681
+
388
682
  def load_config
389
683
  if File.exist?(CONFIG_FILE)
390
684
  JSON.parse(File.read(CONFIG_FILE))
@@ -404,7 +698,7 @@ module Brainiac
404
698
  end
405
699
 
406
700
  def self.completions
407
- %w[setup config status epics link bot projects set]
701
+ %w[setup config status epics link bot projects set reset]
408
702
  end
409
703
  end
410
704
  end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Basecamp
6
+ # Epic-level shared memory.
7
+ #
8
+ # Unlike per-card memory (agent-specific, gitignored), epic memory is shared
9
+ # across all agents working the epic AND persisted to git. It accumulates
10
+ # architectural decisions, patterns established, gotchas discovered, and
11
+ # cross-task learnings.
12
+ #
13
+ # The epic review agent writes to this file after each task completes.
14
+ # All task agents and gate agents read it as part of their context.
15
+ #
16
+ # Location: ~/.brainiac/brain/knowledge/epics/epic-<todolist-id>.md
17
+ #
18
+ # This is under knowledge/ (not memory/) because:
19
+ # - memory/ is gitignored — per-card, per-agent, ephemeral
20
+ # - knowledge/ is synced to git — shared, permanent, valuable
21
+ # - Epic learnings should persist and be searchable via qmd
22
+ module EpicMemory
23
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
24
+ EPIC_MEMORY_DIR = File.join(BRAINIAC_DIR, "brain", "knowledge", "epics")
25
+
26
+ class << self
27
+ # Path to the epic memory file for a given epic.
28
+ #
29
+ # @param todolist_id [String, Integer] Basecamp todolist ID
30
+ # @return [String] Absolute file path
31
+ def path_for(todolist_id)
32
+ File.join(EPIC_MEMORY_DIR, "epic-#{todolist_id}.md")
33
+ end
34
+
35
+ # Ensure the epic memory directory exists.
36
+ def ensure_directory!
37
+ FileUtils.mkdir_p(EPIC_MEMORY_DIR) unless File.directory?(EPIC_MEMORY_DIR)
38
+ end
39
+
40
+ # Read the epic memory content, if it exists.
41
+ #
42
+ # @param todolist_id [String, Integer] Basecamp todolist ID
43
+ # @return [String, nil] Content or nil if no memory exists
44
+ def read(todolist_id)
45
+ path = path_for(todolist_id)
46
+ return nil unless File.exist?(path)
47
+
48
+ content = File.read(path).strip
49
+ content.empty? ? nil : content
50
+ rescue StandardError => e
51
+ LOG.warn "[Basecamp:EpicMemory] Failed to read epic memory: #{e.message}" if defined?(LOG)
52
+ nil
53
+ end
54
+
55
+ # Check if epic memory exists.
56
+ #
57
+ # @param todolist_id [String, Integer] Basecamp todolist ID
58
+ # @return [Boolean]
59
+ def exists?(todolist_id)
60
+ File.exist?(path_for(todolist_id))
61
+ end
62
+
63
+ # Initialize epic memory with the epic title and initial context.
64
+ # Called when an epic starts.
65
+ #
66
+ # @param epic [Hash] Epic state
67
+ def initialize_for(epic)
68
+ ensure_directory!
69
+ path = path_for(epic["basecamp_todolist_id"])
70
+
71
+ # Don't overwrite existing memory (in case of resume)
72
+ return if File.exist?(path) && !File.read(path).strip.empty?
73
+
74
+ initial_content = <<~MARKDOWN
75
+ # Epic Memory: #{epic['title']}
76
+
77
+ Epic started: #{epic['started_at']}
78
+ Orchestrating agent: #{epic['agent']}
79
+
80
+ ---
81
+
82
+ ## Architectural Decisions
83
+
84
+ (No decisions recorded yet)
85
+
86
+ ## Patterns Established
87
+
88
+ (No patterns recorded yet)
89
+
90
+ ## Gotchas & Learnings
91
+
92
+ (No learnings recorded yet)
93
+
94
+ ## Cross-Task Notes
95
+
96
+ (No notes recorded yet)
97
+ MARKDOWN
98
+
99
+ File.write(path, initial_content)
100
+ LOG.info "[Basecamp:EpicMemory] Initialized memory for epic #{epic['id']}" if defined?(LOG)
101
+ rescue StandardError => e
102
+ LOG.error "[Basecamp:EpicMemory] Failed to initialize: #{e.message}" if defined?(LOG)
103
+ end
104
+
105
+ # Append a section to the epic memory.
106
+ # Used by the epic review agent after each task completes.
107
+ #
108
+ # @param todolist_id [String, Integer] Basecamp todolist ID
109
+ # @param section [String] Section title (e.g., "After Task #1234")
110
+ # @param content [String] Content to add
111
+ def append_section(todolist_id, section:, content:)
112
+ ensure_directory!
113
+ path = path_for(todolist_id)
114
+
115
+ existing = File.exist?(path) ? File.read(path) : ""
116
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
117
+
118
+ new_section = <<~MARKDOWN
119
+
120
+ ---
121
+
122
+ ## #{section}
123
+ _Updated: #{timestamp}_
124
+
125
+ #{content.strip}
126
+ MARKDOWN
127
+
128
+ File.write(path, existing + new_section)
129
+ LOG.info "[Basecamp:EpicMemory] Appended section '#{section}' to epic #{todolist_id}" if defined?(LOG)
130
+ rescue StandardError => e
131
+ LOG.error "[Basecamp:EpicMemory] Failed to append: #{e.message}" if defined?(LOG)
132
+ end
133
+
134
+ # Build context injection for an agent prompt.
135
+ # Returns a formatted string with the epic memory, or nil if none exists.
136
+ #
137
+ # @param todolist_id [String, Integer] Basecamp todolist ID
138
+ # @return [String, nil] Formatted context for prompt injection
139
+ def build_context(todolist_id)
140
+ content = read(todolist_id)
141
+ return nil unless content
142
+
143
+ <<~CONTEXT
144
+ ## Epic Memory (Shared Knowledge)
145
+
146
+ The following is shared knowledge accumulated across this epic.
147
+ Reference this when making implementation decisions.
148
+
149
+ #{content}
150
+ CONTEXT
151
+ end
152
+
153
+ # Clean up epic memory after epic completes.
154
+ # Optionally archives to a different location rather than deleting.
155
+ #
156
+ # @param todolist_id [String, Integer] Basecamp todolist ID
157
+ # @param archive [Boolean] Whether to archive rather than delete
158
+ def cleanup(todolist_id, archive: true)
159
+ path = path_for(todolist_id)
160
+ return unless File.exist?(path)
161
+
162
+ if archive
163
+ archive_dir = File.join(EPIC_MEMORY_DIR, "archived")
164
+ FileUtils.mkdir_p(archive_dir)
165
+ archive_path = File.join(archive_dir, "epic-#{todolist_id}-#{Time.now.strftime('%Y%m%d')}.md")
166
+ FileUtils.mv(path, archive_path)
167
+ LOG.info "[Basecamp:EpicMemory] Archived epic memory to #{archive_path}" if defined?(LOG)
168
+ else
169
+ FileUtils.rm(path)
170
+ LOG.info "[Basecamp:EpicMemory] Deleted epic memory for #{todolist_id}" if defined?(LOG)
171
+ end
172
+ rescue StandardError => e
173
+ LOG.warn "[Basecamp:EpicMemory] Cleanup failed: #{e.message}" if defined?(LOG)
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -446,6 +446,23 @@ module Brainiac
446
446
  ""
447
447
  ]
448
448
 
449
+ # Inject epic memory if it exists (shared knowledge across the epic)
450
+ epic_memory = EpicMemory.read(epic["basecamp_todolist_id"])
451
+ if epic_memory
452
+ context_lines << "### Epic Memory (Shared Knowledge)"
453
+ context_lines << ""
454
+ context_lines << "The following is shared knowledge accumulated from previous tasks in this epic."
455
+ context_lines << "Reference this when making implementation decisions."
456
+ context_lines << ""
457
+ context_lines << "```markdown"
458
+ context_lines << epic_memory.lines.first(50).join # Limit to first 50 lines to avoid context overflow
459
+ if epic_memory.lines.size > 50
460
+ context_lines << "... (truncated — full file at ~/.brainiac/brain/memory/epics/epic-#{epic['basecamp_todolist_id']}.md)"
461
+ end
462
+ context_lines << "```"
463
+ context_lines << ""
464
+ end
465
+
449
466
  # List completed tasks with their memory files for reference
450
467
  completed_tasks = tasks.select { |t| t["status"] == "complete" }
451
468
  if completed_tasks.any?
@@ -49,6 +49,9 @@ module Brainiac
49
49
  LOG.info "[Basecamp:Orchestrator] Started epic '#{title}' (todolist #{todolist_id}) " \
50
50
  "with agent #{agent}, review_gate: #{review_gate}" if defined?(LOG)
51
51
 
52
+ # Initialize epic memory for cross-task shared knowledge
53
+ EpicMemory.initialize_for(epic)
54
+
52
55
  # FIRST: Populate tasks with project info from Fizzy card tags
53
56
  # This must happen BEFORE creating epic branches so we know which repos are involved
54
57
  populate_tasks(epic)
@@ -642,6 +645,18 @@ module Brainiac
642
645
  "- ##{t['fizzy_card']}: #{t['title']}#{dep_str}"
643
646
  end.join("\n")
644
647
 
648
+ # Include existing epic memory if any
649
+ epic_memory_path = EpicMemory.path_for(epic["basecamp_todolist_id"])
650
+ epic_memory_section = if EpicMemory.exists?(epic["basecamp_todolist_id"])
651
+ <<~MEM
652
+
653
+ ### Epic Memory (shared knowledge so far)
654
+ Read the epic memory file at `#{epic_memory_path}` for decisions and patterns established in previous tasks.
655
+ MEM
656
+ else
657
+ ""
658
+ end
659
+
645
660
  prompt = <<~PROMPT
646
661
  ## Epic Review: #{epic['title']}
647
662
 
@@ -652,7 +667,7 @@ module Brainiac
652
667
 
653
668
  ### Remaining tasks (with current dependencies):
654
669
  #{remaining_summary}
655
-
670
+ #{epic_memory_section}
656
671
  ### Your job:
657
672
  1. Read the memory files for completed tasks to understand what was implemented
658
673
  2. Check if remaining tasks still make sense given the implementation decisions
@@ -665,10 +680,20 @@ module Brainiac
665
680
 
666
681
  Memory files are at: `~/.brainiac/brain/memory/#{agent_name&.downcase}/card-<number>.md`
667
682
 
668
- After reviewing, post a brief summary comment on the Basecamp todolist:
683
+ ### Update Epic Memory (IMPORTANT)
684
+ After your review, update the epic memory file at `#{epic_memory_path}` with:
685
+ - **Architectural decisions** made during task ##{completed_card_number}
686
+ - **Patterns established** that should be followed in remaining tasks
687
+ - **Gotchas** discovered that future tasks should know about
688
+ - **Cross-task notes** about relationships or dependencies
689
+
690
+ This is shared knowledge — other agents will read it. Be concise but thorough.
691
+ Append a new section, don't replace the existing content.
692
+
693
+ Then post a brief summary comment on the Basecamp todolist:
669
694
  `basecamp comments create #{epic['basecamp_todolist_id']} "Epic review after ##{completed_card_number}: <your summary>" --in #{epic['basecamp_project_id']}`
670
695
 
671
- Keep it concise — this is a checkpoint, not a full analysis.
696
+ Keep the basecamp comment concise — this is a checkpoint, not a full analysis.
672
697
  PROMPT
673
698
 
674
699
  # Get project config for the agent
@@ -759,6 +784,9 @@ module Brainiac
759
784
 
760
785
  LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}' completed!" if defined?(LOG)
761
786
 
787
+ # Archive epic memory (preserves it for future reference)
788
+ EpicMemory.cleanup(epic["basecamp_todolist_id"], archive: true)
789
+
762
790
  # If epic_branch mode, open final PRs to main
763
791
  if epic["review_gate"] == "epic_branch" && epic["epic_branches"]&.any?
764
792
  open_final_prs(epic)
@@ -166,15 +166,23 @@ module Brainiac
166
166
  # @param pr_number [Integer, String] PR number
167
167
  # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
168
168
  # @param repo_path [String] Local repo path
169
+ # @param is_rereview [Boolean] True if this is a re-review after changes
169
170
  # @return [Array<String>] Agent names dispatched
170
- def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
171
+ def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:, is_rereview: false)
171
172
  dispatched = []
172
173
 
174
+ # Get current HEAD SHA for tracking incremental reviews
175
+ current_sha = get_pr_head_sha(pr_number: pr_number, repo_path: repo_path)
176
+
177
+ # Determine if this is a re-review and what SHA to diff from
178
+ last_reviewed_sha = task["last_reviewed_sha"]
179
+ diff_from_sha = is_rereview && last_reviewed_sha ? last_reviewed_sha : nil
180
+
173
181
  gates.each do |gate|
174
182
  agent_name = gate["agent"]
175
183
  role = gate["role"] || "review"
176
184
 
177
- LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}" if defined?(LOG)
185
+ LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}#{" (incremental from #{diff_from_sha[0..7]})" if diff_from_sha}" if defined?(LOG)
178
186
 
179
187
  # Dispatch the gate agent via brainiac-github's PR review mechanism.
180
188
  # The agent gets the PR diff and reviews it using their bot identity.
@@ -186,7 +194,8 @@ module Brainiac
186
194
  repo_name: repo_name,
187
195
  repo_path: repo_path,
188
196
  card_number: task["fizzy_card"],
189
- epic: epic
197
+ epic: epic,
198
+ diff_from_sha: diff_from_sha
190
199
  )
191
200
  rescue StandardError => e
192
201
  LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
@@ -200,6 +209,9 @@ module Brainiac
200
209
  task["gates_dispatched_at"] = Time.now.iso8601
201
210
  task["gate_approvals"] ||= []
202
211
 
212
+ # Track the SHA we're reviewing for incremental diff on next round
213
+ task["last_reviewed_sha"] = current_sha if current_sha
214
+
203
215
  dispatched
204
216
  end
205
217
 
@@ -218,8 +230,9 @@ module Brainiac
218
230
  # @param pr_number [Integer, String] PR number
219
231
  # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
220
232
  # @param repo_path [String] Local repo path
233
+ # @param is_rereview [Boolean] True if this is a re-review after implementation fixes
221
234
  # @return [Array<String>] Agent names dispatched
222
- def dispatch_missing_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
235
+ def dispatch_missing_gates(epic:, task:, pr_number:, repo_name:, repo_path:, is_rereview: false)
223
236
  responded_agents = Set.new
224
237
  (task["gate_approvals"] || []).each { |a| responded_agents << a["agent"].downcase }
225
238
  (task["changes_requested_by"] || []).each { |a| responded_agents << a.downcase }
@@ -230,6 +243,10 @@ module Brainiac
230
243
  # Track per-gate re-dispatch counts to prevent infinite loops
231
244
  task["gate_redispatch_counts"] ||= {}
232
245
 
246
+ # For re-reviews, determine incremental diff range
247
+ diff_from_sha = is_rereview && task["last_reviewed_sha"] ? task["last_reviewed_sha"] : nil
248
+ current_sha = get_pr_head_sha(pr_number: pr_number, repo_path: repo_path)
249
+
233
250
  dispatched = []
234
251
 
235
252
  missing_gates.each do |gate|
@@ -243,7 +260,7 @@ module Brainiac
243
260
  next
244
261
  end
245
262
 
246
- LOG.info "[Basecamp:ReviewGate] Re-dispatching missing gate #{agent_name} (#{role}) to review PR ##{pr_number} (retry #{retries + 1}/#{MAX_GATE_REDISPATCH_RETRIES})" if defined?(LOG)
263
+ LOG.info "[Basecamp:ReviewGate] Re-dispatching missing gate #{agent_name} (#{role}) to review PR ##{pr_number} (retry #{retries + 1}/#{MAX_GATE_REDISPATCH_RETRIES})#{' (incremental)' if diff_from_sha}" if defined?(LOG)
247
264
 
248
265
  task["gate_redispatch_counts"][agent_name.downcase] = retries + 1
249
266
 
@@ -255,7 +272,8 @@ module Brainiac
255
272
  repo_name: repo_name,
256
273
  repo_path: repo_path,
257
274
  card_number: task["fizzy_card"],
258
- epic: epic
275
+ epic: epic,
276
+ diff_from_sha: diff_from_sha
259
277
  )
260
278
  rescue StandardError => e
261
279
  LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
@@ -268,6 +286,9 @@ module Brainiac
268
286
  # but do NOT overwrite gates_dispatched_at — that's the original timestamp
269
287
  task["last_redispatch_at"] = Time.now.iso8601
270
288
 
289
+ # Update last_reviewed_sha for next round
290
+ task["last_reviewed_sha"] = current_sha if current_sha
291
+
271
292
  dispatched
272
293
  end
273
294
 
@@ -300,9 +321,33 @@ module Brainiac
300
321
 
301
322
  private
302
323
 
324
+ # Get the HEAD SHA of a PR (for tracking incremental reviews).
325
+ #
326
+ # @param pr_number [Integer, String] PR number
327
+ # @param repo_path [String] Local repo path
328
+ # @return [String, nil] Commit SHA or nil if failed
329
+ def get_pr_head_sha(pr_number:, repo_path:)
330
+ stdout, _, status = Open3.capture3(
331
+ "gh", "pr", "view", pr_number.to_s,
332
+ "--json", "headRefOid",
333
+ "--jq", ".headRefOid",
334
+ chdir: repo_path
335
+ )
336
+ return nil unless status.success?
337
+
338
+ sha = stdout.strip
339
+ sha.empty? ? nil : sha
340
+ rescue StandardError => e
341
+ LOG.warn "[Basecamp:ReviewGate] Failed to get PR head SHA: #{e.message}" if defined?(LOG)
342
+ nil
343
+ end
344
+
303
345
  # Dispatch a single gate agent to review a PR.
304
346
  # This creates a review prompt and runs the agent in the repo directory.
305
- def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:)
347
+ #
348
+ # @param diff_from_sha [String, nil] If present, this is a re-review and agent should
349
+ # focus on changes since this SHA (incremental review)
350
+ def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:, diff_from_sha: nil)
306
351
  # Build a review-specific prompt for the gate agent
307
352
  prompt = build_gate_review_prompt(
308
353
  agent_name: agent_name,
@@ -310,7 +355,8 @@ module Brainiac
310
355
  pr_number: pr_number,
311
356
  repo_name: repo_name,
312
357
  card_number: card_number,
313
- epic_title: epic["title"]
358
+ epic_title: epic["title"],
359
+ diff_from_sha: diff_from_sha
314
360
  )
315
361
 
316
362
  # Resolve the agent's GitHub token for their bot identity
@@ -359,29 +405,80 @@ module Brainiac
359
405
  end
360
406
 
361
407
  # Build the prompt for a gate review agent.
362
- def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:)
363
- <<~PROMPT
364
- You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
365
- Your role: **#{role}**
408
+ #
409
+ # @param diff_from_sha [String, nil] If present, this is an incremental re-review
410
+ def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:, diff_from_sha: nil)
411
+ if diff_from_sha
412
+ # Incremental re-review prompt — focus only on new changes
413
+ <<~PROMPT
414
+ You are RE-REVIEWING PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
415
+ Your role: **#{role}**
416
+
417
+ This is an INCREMENTAL REVIEW — you already reviewed this PR and requested changes.
418
+ The implementation agent has pushed fixes. Focus on what changed.
419
+
420
+ **Step 1: Read the context to understand WHY changes were made:**
421
+ ```
422
+ gh pr view #{pr_number}
423
+ gh pr view #{pr_number} --comments
424
+ git log #{diff_from_sha}..HEAD --oneline
425
+ ```
426
+
427
+ - `gh pr view` shows the PR description (implementation rationale)
428
+ - `--comments` shows discussion and review responses
429
+ - `git log` shows commit messages explaining each fix
430
+
431
+ Read these BEFORE looking at the code changes.
432
+
433
+ **Step 2: View the NEW changes since your last review:**
434
+ ```
435
+ git fetch origin && git diff #{diff_from_sha}..HEAD
436
+ ```
437
+
438
+ Or compare in GitHub: `#{repo_name}/compare/#{diff_from_sha[0..7]}...HEAD`
439
+
440
+ If you need the full context, you can still use `gh pr diff #{pr_number}`, but prioritize
441
+ reviewing the incremental changes first.
442
+
443
+ Based on your role (#{role}):
444
+ #{role_instructions(role)}
445
+
446
+ After your review:
447
+ - If the fixes address your concerns: `gh pr review #{pr_number} --approve --body "your summary"`
448
+ - If more changes are needed: `gh pr review #{pr_number} --request-changes --body "what still needs fixing"`
449
+
450
+ Be thorough but pragmatic. This is Fizzy card ##{card_number}.
451
+
452
+ IMPORTANT RESTRICTIONS:
453
+ - Do NOT open new PRs or modify code — you are a reviewer only
454
+ - Do NOT comment on the Fizzy card — your review goes on GitHub only
455
+ - Do NOT use the fizzy CLI at all
456
+ PROMPT
457
+ else
458
+ # Initial full review prompt
459
+ <<~PROMPT
460
+ You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
461
+ Your role: **#{role}**
366
462
 
367
- This is a review gate — the epic cannot proceed until you approve.
463
+ This is a review gate — the epic cannot proceed until you approve.
368
464
 
369
- Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
465
+ Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
370
466
 
371
- Based on your role (#{role}):
372
- #{role_instructions(role)}
467
+ Based on your role (#{role}):
468
+ #{role_instructions(role)}
373
469
 
374
- After your review:
375
- - If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
376
- - If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
470
+ After your review:
471
+ - If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
472
+ - If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
377
473
 
378
- Be thorough but pragmatic. This is Fizzy card ##{card_number}.
474
+ Be thorough but pragmatic. This is Fizzy card ##{card_number}.
379
475
 
380
- IMPORTANT RESTRICTIONS:
381
- - Do NOT open new PRs or modify code — you are a reviewer only
382
- - Do NOT comment on the Fizzy card — your review goes on GitHub only
383
- - Do NOT use the fizzy CLI at all
384
- PROMPT
476
+ IMPORTANT RESTRICTIONS:
477
+ - Do NOT open new PRs or modify code — you are a reviewer only
478
+ - Do NOT comment on the Fizzy card — your review goes on GitHub only
479
+ - Do NOT use the fizzy CLI at all
480
+ PROMPT
481
+ end
385
482
  end
386
483
 
387
484
  # Role-specific review instructions.
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.15"
6
+ VERSION = "0.0.17"
7
7
  end
8
8
  end
9
9
  end
@@ -7,6 +7,7 @@ require_relative "basecamp/config"
7
7
  require_relative "basecamp/client"
8
8
  require_relative "basecamp/epic"
9
9
  require_relative "basecamp/epic_branch"
10
+ require_relative "basecamp/epic_memory"
10
11
  require_relative "basecamp/review_gate"
11
12
  require_relative "basecamp/orchestrator"
12
13
  require_relative "basecamp/webhook"
@@ -206,7 +207,8 @@ module Brainiac
206
207
  task: task,
207
208
  pr_number: effective_pr,
208
209
  repo_name: github_repo,
209
- repo_path: repo_path
210
+ repo_path: repo_path,
211
+ is_rereview: true
210
212
  )
211
213
  rescue StandardError => e
212
214
  LOG.error "[Basecamp:HealthCheck] Gate re-dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-basecamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -93,6 +93,7 @@ files:
93
93
  - lib/brainiac/plugins/basecamp/config.rb
94
94
  - lib/brainiac/plugins/basecamp/epic.rb
95
95
  - lib/brainiac/plugins/basecamp/epic_branch.rb
96
+ - lib/brainiac/plugins/basecamp/epic_memory.rb
96
97
  - lib/brainiac/plugins/basecamp/hooks.rb
97
98
  - lib/brainiac/plugins/basecamp/metadata.rb
98
99
  - lib/brainiac/plugins/basecamp/orchestrator.rb