borisxdave 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.
boris.py CHANGED
@@ -28,6 +28,123 @@ DEFAULT_MAX_ITERATIONS = 15
28
28
  MAX_CORRECTIONS = 2
29
29
  MAX_RETRIES = 1
30
30
 
31
+ # Swarm mode configuration presets
32
+ SWARM_PRESETS = {
33
+ "conservative": {
34
+ "swarm_budget": 3,
35
+ "swarm_depth": 1,
36
+ "isolation": "worktree",
37
+ "no_converge": False,
38
+ },
39
+ "balanced": {
40
+ "swarm_budget": 5,
41
+ "swarm_depth": 1,
42
+ "isolation": "worktree",
43
+ "no_converge": False,
44
+ },
45
+ "aggressive": {
46
+ "swarm_budget": 10,
47
+ "swarm_depth": 2,
48
+ "isolation": "worktree",
49
+ "no_converge": False,
50
+ },
51
+ "yolo": { # Closest to the original emergent behavior
52
+ "swarm_budget": 999,
53
+ "swarm_depth": 2,
54
+ "isolation": "none",
55
+ "no_converge": True,
56
+ },
57
+ }
58
+
59
+
60
+ class TokenEstimator:
61
+ """Estimates token usage for Boris orchestration.
62
+
63
+ Claude Code runs on a token-based subscription model, not pay-per-API-call.
64
+ This estimator helps users understand token consumption before committing to
65
+ a task, and allows setting token budgets to limit usage.
66
+
67
+ Token estimates are based on realistic DaveLoop iteration profiles:
68
+ - Base iteration: ~4000 input tokens (prompt) + ~2000 output tokens (response)
69
+ - Context growth: each iteration adds ~1500 tokens of accumulated context
70
+ - Sub-agent spawn: ~3000 tokens each
71
+ - Convergence run: ~5000 tokens (reads multiple files + analysis)
72
+ """
73
+
74
+ BASE_INPUT_TOKENS = 4000 # Base input tokens per DaveLoop iteration
75
+ BASE_OUTPUT_TOKENS = 2000 # Base output tokens per DaveLoop iteration
76
+ CONTEXT_GROWTH_PER_ITER = 1500 # Additional context tokens accumulated per iteration
77
+ TOKENS_PER_SUB_AGENT = 3000 # Tokens per sub-agent spawn
78
+ TOKENS_PER_CONVERGENCE = 5000 # Tokens per convergence run
79
+
80
+ def __init__(self, max_tokens: int = None):
81
+ self.max_tokens = max_tokens
82
+ self.estimated_tokens = 0
83
+
84
+ def _tokens_per_iteration(self, iteration_num: int) -> int:
85
+ """Estimate tokens for a single DaveLoop iteration, accounting for context growth."""
86
+ input_tokens = self.BASE_INPUT_TOKENS + (iteration_num * self.CONTEXT_GROWTH_PER_ITER)
87
+ return input_tokens + self.BASE_OUTPUT_TOKENS
88
+
89
+ def record_tokens(self, amount: int):
90
+ """Record estimated token usage."""
91
+ self.estimated_tokens += amount
92
+
93
+ def estimate_batch(self, num_workers: int, avg_iterations: int,
94
+ avg_sub_agents: int) -> int:
95
+ """Estimate tokens for a batch of parallel workers."""
96
+ worker_tokens = 0
97
+ for w in range(num_workers):
98
+ for i in range(avg_iterations):
99
+ worker_tokens += self._tokens_per_iteration(i)
100
+ sub_agent_tokens = num_workers * avg_sub_agents * self.TOKENS_PER_SUB_AGENT
101
+ convergence_tokens = self.TOKENS_PER_CONVERGENCE
102
+ return worker_tokens + sub_agent_tokens + convergence_tokens
103
+
104
+ def estimate_task(self, plan) -> dict:
105
+ """Estimate total tokens for a Boris plan.
106
+
107
+ Returns a dict with breakdown: per-milestone estimates, sub-agent tokens,
108
+ convergence tokens, and total.
109
+ """
110
+ milestones = plan.milestones
111
+ num_milestones = len(milestones)
112
+ avg_iterations = 8 # Typical DaveLoop iterations per milestone
113
+
114
+ milestone_tokens = 0
115
+ for i in range(avg_iterations):
116
+ milestone_tokens += self._tokens_per_iteration(i)
117
+ total_milestone_tokens = milestone_tokens * num_milestones
118
+
119
+ # Estimate convergence runs (one per parallel batch, assume ~ceil(milestones/3) batches)
120
+ estimated_batches = max(1, (num_milestones + 2) // 3)
121
+ convergence_tokens = estimated_batches * self.TOKENS_PER_CONVERGENCE
122
+
123
+ total = total_milestone_tokens + convergence_tokens
124
+
125
+ return {
126
+ "num_milestones": num_milestones,
127
+ "avg_iterations_per_milestone": avg_iterations,
128
+ "tokens_per_milestone": milestone_tokens,
129
+ "total_milestone_tokens": total_milestone_tokens,
130
+ "convergence_tokens": convergence_tokens,
131
+ "total_tokens": total,
132
+ }
133
+
134
+ def check_budget(self, estimated_additional: int) -> bool:
135
+ """Check if estimated additional tokens fit within budget."""
136
+ if self.max_tokens is None:
137
+ return True
138
+ return (self.estimated_tokens + estimated_additional) <= self.max_tokens
139
+
140
+ def summary(self) -> dict:
141
+ """Return token tracking summary."""
142
+ return {
143
+ "estimated_tokens": self.estimated_tokens,
144
+ "max_tokens": self.max_tokens,
145
+ "remaining": (self.max_tokens - self.estimated_tokens) if self.max_tokens else None,
146
+ }
147
+
31
148
 
32
149
  def setup_logging() -> logging.Logger:
33
150
  """Set up logging to both console and file."""
@@ -82,6 +199,61 @@ def print_plan_summary(plan: state_module.Plan):
82
199
  print(flush=True)
83
200
 
84
201
 
202
+ def prompt_skip_milestones(plan: state_module.Plan, logger: logging.Logger) -> set:
203
+ """Prompt the user to optionally skip milestones before execution.
204
+
205
+ Returns a set of milestone IDs that were marked as skipped.
206
+ """
207
+ valid_ids = {m.id for m in plan.milestones}
208
+
209
+ try:
210
+ user_input = input("Enter milestone IDs to skip (comma-separated), or press Enter to run all: ").strip()
211
+ except EOFError:
212
+ # Non-interactive environment, skip nothing
213
+ return set()
214
+
215
+ if not user_input:
216
+ return set()
217
+
218
+ # Parse comma-separated milestone IDs
219
+ requested_ids = {s.strip().upper() for s in user_input.split(",") if s.strip()}
220
+ skip_ids = requested_ids & valid_ids
221
+ invalid_ids = requested_ids - valid_ids
222
+
223
+ if invalid_ids:
224
+ print(f"[Boris] WARNING: Unknown milestone IDs ignored: {', '.join(sorted(invalid_ids))}", flush=True)
225
+ logger.warning("Unknown milestone IDs in skip request: %s", invalid_ids)
226
+
227
+ if not skip_ids:
228
+ return set()
229
+
230
+ # Check for dependency warnings: if a skipped milestone is depended on by a non-skipped one
231
+ skipped_set = skip_ids
232
+ for m in plan.milestones:
233
+ if m.id in skipped_set:
234
+ continue
235
+ depends_on_skipped = [dep for dep in m.depends_on if dep in skipped_set]
236
+ if depends_on_skipped:
237
+ print(
238
+ f"[Boris] WARNING: {m.id} ({m.title}) depends on skipped milestone(s): "
239
+ f"{', '.join(depends_on_skipped)}",
240
+ flush=True,
241
+ )
242
+ logger.warning(
243
+ "Milestone %s depends on skipped milestone(s): %s",
244
+ m.id, depends_on_skipped,
245
+ )
246
+
247
+ # Mark milestones as skipped
248
+ for m in plan.milestones:
249
+ if m.id in skip_ids:
250
+ m.status = "skipped"
251
+ print(f"[Boris] Skipping milestone {m.id}: {m.title}", flush=True)
252
+ logger.info("User skipped milestone %s: %s", m.id, m.title)
253
+
254
+ return skip_ids
255
+
256
+
85
257
  def generate_summary(plan: state_module.Plan, project_dir: str, start_time: datetime) -> str:
86
258
  """Generate a summary markdown file and return its path."""
87
259
  os.makedirs(PLANS_DIR, exist_ok=True)
@@ -202,15 +374,23 @@ def parse_args() -> argparse.Namespace:
202
374
  description="Boris - Autonomous Project Orchestrator. Breaks tasks into milestones and executes via Claude CLI.",
203
375
  )
204
376
  parser.add_argument("task", nargs="?", help="The task description string")
377
+ parser.add_argument(
378
+ "-f", "--file", help="Read task description from a file"
379
+ )
205
380
  parser.add_argument(
206
381
  "-d", "--dir", default=os.getcwd(), help="Working directory for the project (default: current dir)"
207
382
  )
208
383
  parser.add_argument(
209
384
  "-r", "--resume", action="store_true", help="Resume from last saved state"
210
385
  )
211
- parser.add_argument(
386
+ plan_group = parser.add_mutually_exclusive_group()
387
+ plan_group.add_argument(
212
388
  "--plan-only", action="store_true", help="Generate plan but don't execute"
213
389
  )
390
+ plan_group.add_argument(
391
+ "--exec-only", metavar="PLAN_FILE",
392
+ help="Skip planning and execute an existing plan file directly"
393
+ )
214
394
  parser.add_argument("--remote", help="Git remote URL to set up")
215
395
  parser.add_argument(
216
396
  "--no-git", action="store_true", help="Disable git management"
@@ -224,6 +404,53 @@ def parse_args() -> argparse.Namespace:
224
404
  parser.add_argument(
225
405
  "--skip-ui", action="store_true", help="Skip UI testing phase after structural milestones"
226
406
  )
407
+ parser.add_argument(
408
+ "--incremental", action="store_true",
409
+ help="Execute one milestone at a time, then pause. Use with -r to continue to the next milestone."
410
+ )
411
+ parser.add_argument(
412
+ "--turbo", action="store_true",
413
+ help="Enable parallel DaveLoop execution for independent milestones"
414
+ )
415
+ parser.add_argument(
416
+ "--stop-at", metavar="MILESTONE_ID", dest="stop_at",
417
+ help="Stop execution after completing this milestone ID (e.g. M4). Implies --incremental."
418
+ )
419
+
420
+ # Swarm mode flags
421
+ parser.add_argument(
422
+ "--swarm", action="store_true",
423
+ help="Enable swarm mode: DaveLoops can spawn sub-agents via Task tool"
424
+ )
425
+ parser.add_argument(
426
+ "--swarm-budget", type=int, default=5,
427
+ help="Max sub-agents per DaveLoop worker in swarm mode (default: 5)"
428
+ )
429
+ parser.add_argument(
430
+ "--swarm-depth", type=int, default=1, choices=[1, 2],
431
+ help="Max sub-agent depth in swarm mode (default: 1, no recursive spawning)"
432
+ )
433
+ parser.add_argument(
434
+ "--preset", choices=["conservative", "balanced", "aggressive", "yolo"],
435
+ help="Apply a swarm configuration preset (implies --swarm)"
436
+ )
437
+ parser.add_argument(
438
+ "--isolation", choices=["none", "worktree"], default="none",
439
+ help="Isolation strategy for parallel workers (default: none)"
440
+ )
441
+ parser.add_argument(
442
+ "--no-converge", action="store_true", dest="no_converge",
443
+ help="Skip convergence phase after each swarm batch"
444
+ )
445
+ parser.add_argument(
446
+ "--max-tokens", type=int, default=None, dest="max_tokens",
447
+ help="Maximum estimated token budget for swarm/turbo mode execution (e.g. 500000)"
448
+ )
449
+ parser.add_argument(
450
+ "--estimate", metavar="TASK", nargs="?", const="__FROM_TASK__", dest="estimate",
451
+ help="Generate plan and print token estimate WITHOUT executing. "
452
+ "Use alone (boris --estimate 'task') or with positional task."
453
+ )
227
454
 
228
455
  return parser.parse_args()
229
456
 
@@ -400,11 +627,273 @@ def _run_ui_milestones(st, plan, project_dir, args, logger):
400
627
  logger.info("UI testing complete: %d/%d", ui_completed, ui_total)
401
628
 
402
629
 
630
+ def get_ready_milestones(plan: state_module.Plan) -> list[state_module.Milestone]:
631
+ """Return all pending milestones whose dependencies are all completed or skipped."""
632
+ done_statuses = {"completed", "skipped"}
633
+ done_ids = {m.id for m in plan.milestones if m.status in done_statuses}
634
+ ready = []
635
+ for m in plan.milestones:
636
+ if m.status != "pending":
637
+ continue
638
+ if all(dep in done_ids for dep in m.depends_on):
639
+ ready.append(m)
640
+ return ready
641
+
642
+
643
+ def _process_milestone_verdict(verdict_result, result, milestone, plan, st, project_dir, args, logger, prompt):
644
+ """Process a single milestone's verdict (corrections, retries). Used by both sequential and turbo modes."""
645
+ if verdict_result.verdict == Verdict.RESOLVED:
646
+ milestone.status = "completed"
647
+ milestone.completed_at = datetime.now().isoformat()
648
+ state_module.save(st)
649
+ print(f"[Boris] Milestone {milestone.id} COMPLETE", flush=True)
650
+ logger.info("Milestone %s completed", milestone.id)
651
+
652
+ elif verdict_result.verdict == Verdict.OFF_PLAN:
653
+ correction_count = 0
654
+ resolved = False
655
+
656
+ while correction_count < MAX_CORRECTIONS:
657
+ correction_count += 1
658
+ print(
659
+ f" [Boris] Off-plan detected. Correction attempt {correction_count}/{MAX_CORRECTIONS}...",
660
+ flush=True,
661
+ )
662
+ logger.info(
663
+ "Off-plan correction attempt %d for milestone %s",
664
+ correction_count,
665
+ milestone.id,
666
+ )
667
+
668
+ correction_prompt = prompts.craft_correction(
669
+ result.output, milestone, plan, verdict_result.reason
670
+ )
671
+ result = engine.run(correction_prompt, project_dir, args.max_iter, milestone=milestone)
672
+ verdict_result = engine.check(result, milestone)
673
+
674
+ if verdict_result.verdict == Verdict.RESOLVED:
675
+ milestone.status = "completed"
676
+ milestone.completed_at = datetime.now().isoformat()
677
+ state_module.save(st)
678
+ print(f"[Boris] Milestone {milestone.id} COMPLETE (after correction)", flush=True)
679
+ logger.info("Milestone %s completed after correction", milestone.id)
680
+ resolved = True
681
+ break
682
+
683
+ if not resolved:
684
+ print(f" [Boris] Milestone {milestone.id} could not be corrected. Skipping.", flush=True)
685
+ logger.warning("Milestone %s skipped after failed corrections", milestone.id)
686
+ milestone.status = "skipped"
687
+ state_module.save(st)
688
+
689
+ elif verdict_result.verdict == Verdict.FAILED:
690
+ retry_count = 0
691
+ resolved = False
692
+
693
+ while retry_count < MAX_RETRIES:
694
+ retry_count += 1
695
+ milestone.retry_count = retry_count
696
+ print(
697
+ f" [Boris] Failed. Retry {retry_count}/{MAX_RETRIES}...",
698
+ flush=True,
699
+ )
700
+ logger.info(
701
+ "Retry %d for milestone %s", retry_count, milestone.id
702
+ )
703
+
704
+ result = engine.run(prompt, project_dir, args.max_iter, milestone=milestone)
705
+ verdict_result = engine.check(result, milestone)
706
+
707
+ if verdict_result.verdict == Verdict.RESOLVED:
708
+ milestone.status = "completed"
709
+ milestone.completed_at = datetime.now().isoformat()
710
+ state_module.save(st)
711
+ print(f"[Boris] Milestone {milestone.id} COMPLETE (after retry)", flush=True)
712
+ logger.info("Milestone %s completed after retry", milestone.id)
713
+ resolved = True
714
+ break
715
+
716
+ if not resolved:
717
+ print(
718
+ f" [Boris] Milestone {milestone.id} failed after {MAX_RETRIES} retries. Skipping.",
719
+ flush=True,
720
+ )
721
+ logger.warning("Milestone %s skipped after failed retries", milestone.id)
722
+ milestone.status = "skipped"
723
+ state_module.save(st)
724
+
725
+
726
+ def validate_turbo_batch(ready: list, plan: state_module.Plan, logger: logging.Logger) -> list:
727
+ """Filter out milestones from a turbo batch whose dependencies were skipped.
728
+
729
+ These milestones cannot succeed without their dependency's output.
730
+ Also enforces foundation-first: first batch only runs milestones with no dependencies.
731
+ """
732
+ skipped_ids = {m.id for m in plan.milestones if m.status == "skipped"}
733
+ valid = []
734
+ for m in ready:
735
+ skipped_deps = [d for d in m.depends_on if d in skipped_ids]
736
+ if skipped_deps:
737
+ logger.warning(
738
+ "Milestone %s depends on skipped milestone(s) %s - deferring from turbo batch",
739
+ m.id, skipped_deps
740
+ )
741
+ print(f"[Boris] WARNING: {m.id} skipped from turbo batch - depends on skipped: {skipped_deps}", flush=True)
742
+ else:
743
+ valid.append(m)
744
+ return valid
745
+
746
+
747
+ def _run_convergence(st, plan, project_dir, batch_milestones, logger):
748
+ """Run a convergence agent after a parallel batch to resolve conflicts."""
749
+ completed_in_batch = [m for m in batch_milestones if m.status == "completed"]
750
+ if len(completed_in_batch) < 2:
751
+ return # No conflicts possible with 0-1 completed milestones
752
+
753
+ # Collect all files touched by this batch and check for overlaps
754
+ file_owners = {}
755
+ conflicts = []
756
+ for m in completed_in_batch:
757
+ for f in (m.files_to_create or []) + (m.files_to_modify or []):
758
+ if f in file_owners:
759
+ conflicts.append((f, file_owners[f], m.id))
760
+ file_owners[f] = m.id
761
+
762
+ if not conflicts:
763
+ logger.info("Convergence: No file conflicts detected in batch")
764
+ print("[Boris] Convergence: No conflicts detected. Skipping.", flush=True)
765
+ return
766
+
767
+ print(f"[Boris] Convergence: {len(conflicts)} potential conflict(s) detected", flush=True)
768
+ for filepath, owner1, owner2 in conflicts:
769
+ print(f" [Boris] {filepath}: written by {owner1} and {owner2}", flush=True)
770
+
771
+ # Build convergence prompt
772
+ conflict_text = "\n".join(
773
+ f"- {f}: modified by {o1} and {o2}" for f, o1, o2 in conflicts
774
+ )
775
+
776
+ convergence_prompt = f"""# Convergence Task
777
+
778
+ Multiple parallel agents modified overlapping files. Resolve any conflicts.
779
+
780
+ ## Conflicts Detected
781
+ {conflict_text}
782
+
783
+ ## Milestones Completed in This Batch
784
+ {chr(10).join(f'- {m.id}: {m.title}' for m in completed_in_batch)}
785
+
786
+ ## Instructions
787
+ 1. Read each conflicted file
788
+ 2. Check for type mismatches, duplicate definitions, incompatible interfaces
789
+ 3. Reconcile into a consistent state
790
+ 4. Run `tsc --noEmit` (TypeScript) or equivalent type checker
791
+ 5. Fix any remaining build errors
792
+ 6. Do NOT add new features - only resolve conflicts between existing code
793
+
794
+ When all conflicts are resolved and the build is clean, output [DAVELOOP:RESOLVED].
795
+ """
796
+
797
+ # Run convergence via DaveLoop (without Task tool - no swarm for convergence)
798
+ result = engine.run(convergence_prompt, project_dir, max_iterations=5)
799
+ if result.resolved:
800
+ print("[Boris] Convergence: All conflicts resolved", flush=True)
801
+ logger.info("Convergence phase completed successfully")
802
+ else:
803
+ print("[Boris] WARNING: Convergence could not resolve all conflicts", flush=True)
804
+ logger.warning("Convergence phase did not fully resolve")
805
+
806
+
807
+ def _print_swarm_dashboard(project_dir: str, batch_num: int, batch_milestones: list):
808
+ """Print a swarm status dashboard showing all active workers in the current batch (B7)."""
809
+ statuses = engine.read_worker_statuses(project_dir)
810
+ if not statuses:
811
+ return
812
+
813
+ active = sum(1 for s in statuses.values() if s.get("state") in ("starting", "working"))
814
+ total_actions = sum(s.get("actions", 0) for s in statuses.values())
815
+
816
+ print(flush=True)
817
+ print("=== BORIS SWARM STATUS ===", flush=True)
818
+ print(f"Batch {batch_num} | Workers: {active} active / {len(statuses)} total | Actions: {total_actions}", flush=True)
819
+ print(flush=True)
820
+
821
+ for m in batch_milestones:
822
+ status = statuses.get(m.id, {})
823
+ state = status.get("state", "unknown")
824
+ actions = status.get("actions", 0)
825
+ reasoning = status.get("reasoning_blocks", 0)
826
+ interrupts = status.get("interrupts", 0)
827
+ last = status.get("last_action", "")
828
+
829
+ # Progress indicator based on reasoning blocks (rough proxy)
830
+ bar_len = min(reasoning, 10)
831
+ bar = "#" * bar_len + "-" * (10 - bar_len)
832
+
833
+ state_str = state.upper()
834
+ interrupt_str = f" | {interrupts} interrupts" if interrupts > 0 else ""
835
+ print(f"[{m.id}] {m.title[:30]:<30} [{bar}] {state_str} | {actions} actions{interrupt_str}", flush=True)
836
+ if last:
837
+ print(f" Last: {last}", flush=True)
838
+
839
+ # Show file locks if file_lock.py is available
840
+ try:
841
+ from file_lock import FileLockManager
842
+ flm = FileLockManager(project_dir)
843
+ locks = flm.get_locked_files()
844
+ if locks:
845
+ lock_strs = [f"{os.path.basename(f)} ({owner})" for f, owner in locks.items()]
846
+ print(f"\nFile locks: {', '.join(lock_strs)}", flush=True)
847
+ except ImportError:
848
+ pass
849
+
850
+ print("===========================", flush=True)
851
+ print(flush=True)
852
+
853
+
854
+ def _apply_preset(args):
855
+ """Apply a swarm preset to args if specified. Preset implies --swarm."""
856
+ if not args.preset:
857
+ return
858
+ args.swarm = True
859
+ preset = SWARM_PRESETS[args.preset]
860
+ # Only override if user didn't explicitly set these
861
+ if args.swarm_budget == 5: # default value
862
+ args.swarm_budget = preset["swarm_budget"]
863
+ if args.swarm_depth == 1: # default value
864
+ args.swarm_depth = preset["swarm_depth"]
865
+ if args.isolation == "none": # default value
866
+ args.isolation = preset.get("isolation", "none")
867
+ if not args.no_converge:
868
+ args.no_converge = preset["no_converge"]
869
+
870
+
403
871
  def main():
404
872
  """Main Boris orchestration loop."""
405
873
  args = parse_args()
874
+
875
+ # Apply swarm preset if specified (implies --swarm)
876
+ _apply_preset(args)
877
+
878
+ # --stop-at implies incremental mode
879
+ if args.stop_at:
880
+ args.incremental = True
881
+
882
+ # Handle --estimate: resolve the task description from either the flag or positional arg
883
+ if args.estimate is not None:
884
+ estimate_task = args.estimate if args.estimate != "__FROM_TASK__" else args.task
885
+ if not estimate_task:
886
+ print("[Boris] Error: --estimate requires a task description", flush=True)
887
+ sys.exit(1)
888
+ args.task = estimate_task
889
+
406
890
  logger = setup_logging()
407
891
 
892
+ # Initialize token estimator for swarm/turbo mode
893
+ token_estimator = None
894
+ if getattr(args, 'swarm', False) or getattr(args, 'max_tokens', None) or getattr(args, 'turbo', False):
895
+ token_estimator = TokenEstimator(max_tokens=args.max_tokens)
896
+
408
897
  # Create required dirs
409
898
  os.makedirs(PLANS_DIR, exist_ok=True)
410
899
  os.makedirs(LOGS_DIR, exist_ok=True)
@@ -427,21 +916,69 @@ def main():
427
916
  else:
428
917
  print(f"[Boris] Resuming from milestone {start_index + 1}...", flush=True)
429
918
  logger.info("Resuming from milestone %d", start_index + 1)
919
+ elif args.exec_only:
920
+ # Exec-only mode - load an existing plan file and execute it
921
+ plan_file = os.path.abspath(args.exec_only)
922
+ print_banner()
923
+ print(f"[Boris] Exec-only mode: loading plan from {plan_file}", flush=True)
924
+ logger.info("Exec-only mode: loading plan from %s", plan_file)
925
+
926
+ try:
927
+ plan = prompts.load_plan_from_file(plan_file)
928
+ except (FileNotFoundError, RuntimeError) as e:
929
+ print(f"[Boris] Error loading plan file: {e}", flush=True)
930
+ logger.error("Failed to load plan file: %s", e)
931
+ sys.exit(1)
932
+
933
+ print(f"[Boris] Plan loaded with {len(plan.milestones)} milestones", flush=True)
934
+ logger.info("Plan loaded with %d milestones", len(plan.milestones))
935
+
936
+ print_plan_summary(plan)
937
+
938
+ # Prompt user to skip milestones before execution
939
+ prompt_skip_milestones(plan, logger)
940
+
941
+ # Create initial state from the loaded plan
942
+ st = state_module.State(
943
+ plan=plan,
944
+ current_milestone_index=0,
945
+ project_dir=project_dir,
946
+ git_remote=args.remote,
947
+ no_git=args.no_git,
948
+ turbo=args.turbo,
949
+ )
950
+ state_module.save(st)
951
+ start_index = 0
430
952
  else:
431
- # New task mode
432
- if not args.task:
433
- print("[Boris] Error: task is required (unless using --resume)", flush=True)
953
+ # New task mode - resolve task from args or file
954
+ task = args.task
955
+ if args.file:
956
+ try:
957
+ with open(args.file, "r", encoding="utf-8") as f:
958
+ task = f.read().strip()
959
+ print(f"[Boris] Read task from file: {args.file} ({len(task)} chars)", flush=True)
960
+ except FileNotFoundError:
961
+ print(f"[Boris] Error: File not found: {args.file}", flush=True)
962
+ sys.exit(1)
963
+ except Exception as e:
964
+ print(f"[Boris] Error reading file: {e}", flush=True)
965
+ sys.exit(1)
966
+
967
+ if not task:
968
+ print("[Boris] Error: task is required (pass as argument or use -f FILE)", flush=True)
434
969
  sys.exit(1)
435
970
 
971
+ args.task = task
972
+
436
973
  print_banner()
437
- logger.info("Starting Boris for task: %s", args.task)
974
+ logger.info("Starting Boris for task: %s", args.task[:200])
438
975
 
439
976
  # Create plan (retry once on timeout)
440
977
  print("[Boris] Creating plan...", flush=True)
441
978
  plan = None
442
979
  for attempt in range(2):
443
980
  try:
444
- plan = prompts.create_plan(args.task, project_dir)
981
+ plan = prompts.create_plan(args.task, project_dir, turbo=args.turbo)
445
982
  break
446
983
  except RuntimeError as e:
447
984
  if attempt == 0 and "timed out" in str(e).lower():
@@ -459,10 +996,32 @@ def main():
459
996
 
460
997
  print_plan_summary(plan)
461
998
 
999
+ # --estimate mode: print token estimate and exit without executing
1000
+ if args.estimate is not None:
1001
+ estimator = TokenEstimator()
1002
+ estimate = estimator.estimate_task(plan)
1003
+ print("=" * 60, flush=True)
1004
+ print(" BORIS - Token Estimate", flush=True)
1005
+ print("=" * 60, flush=True)
1006
+ print(f" Milestones: {estimate['num_milestones']}", flush=True)
1007
+ print(f" Avg iterations each: {estimate['avg_iterations_per_milestone']}", flush=True)
1008
+ print(f" Tokens/milestone: {estimate['tokens_per_milestone']:,}", flush=True)
1009
+ print(f" Milestone tokens: {estimate['total_milestone_tokens']:,}", flush=True)
1010
+ print(f" Convergence tokens: {estimate['convergence_tokens']:,}", flush=True)
1011
+ print(f" ----------------------------------------", flush=True)
1012
+ print(f" TOTAL ESTIMATED: {estimate['total_tokens']:,} tokens", flush=True)
1013
+ print("=" * 60, flush=True)
1014
+ print(flush=True)
1015
+ print("[Boris] Estimate-only mode. No execution performed.", flush=True)
1016
+ return
1017
+
462
1018
  if args.plan_only:
463
1019
  print("[Boris] Plan-only mode. Exiting.", flush=True)
464
1020
  return
465
1021
 
1022
+ # Prompt user to skip milestones before execution
1023
+ prompt_skip_milestones(plan, logger)
1024
+
466
1025
  # Create initial state
467
1026
  st = state_module.State(
468
1027
  plan=plan,
@@ -470,6 +1029,7 @@ def main():
470
1029
  project_dir=project_dir,
471
1030
  git_remote=args.remote,
472
1031
  no_git=args.no_git,
1032
+ turbo=args.turbo,
473
1033
  )
474
1034
  state_module.save(st)
475
1035
  start_index = 0
@@ -491,156 +1051,337 @@ def main():
491
1051
  print(f"[Boris] Setting up remote: {args.remote}", flush=True)
492
1052
  git_manager.setup_remote(project_dir, args.remote)
493
1053
 
1054
+ # Determine turbo mode (from state on resume, from args on new run)
1055
+ turbo = getattr(st, "turbo", False) or getattr(args, "turbo", False)
1056
+
494
1057
  # Phase 1: Structural milestones (skip if resuming into UI phase)
495
1058
  try:
496
1059
  if st.phase != "ui_testing":
497
- for i in range(start_index, len(plan.milestones)):
498
- milestone = plan.milestones[i]
499
-
500
- if milestone.status == "completed":
501
- logger.info("Skipping already completed milestone %s", milestone.id)
502
- continue
503
-
504
- if milestone.status == "skipped":
505
- logger.info("Skipping previously skipped milestone %s", milestone.id)
506
- continue
507
-
1060
+ if turbo:
1061
+ # === TURBO MODE: Parallel DaveLoop execution ===
508
1062
  print(flush=True)
509
- print(f"=== Milestone {milestone.id}: {milestone.title} ===", flush=True)
510
- logger.info("Starting milestone %s: %s", milestone.id, milestone.title)
511
-
512
- # Mark in progress
513
- milestone.status = "in_progress"
514
- st.current_milestone_index = i
515
- state_module.save(st)
1063
+ print("[Boris] TURBO MODE: Parallel DaveLoop execution enabled", flush=True)
1064
+ logger.info("TURBO MODE enabled")
1065
+
1066
+ batch_num = 0
1067
+ while True:
1068
+ ready = get_ready_milestones(plan)
1069
+ if not ready:
1070
+ # Check if there are still pending milestones (deadlock detection)
1071
+ pending = [m for m in plan.milestones if m.status == "pending"]
1072
+ if pending:
1073
+ pending_ids = [m.id for m in pending]
1074
+ print(f"[Boris] TURBO: No milestones ready but {len(pending)} still pending: {pending_ids}", flush=True)
1075
+ print("[Boris] TURBO: Possible dependency deadlock. Skipping remaining.", flush=True)
1076
+ logger.warning("Turbo deadlock: pending=%s", pending_ids)
1077
+ for m in pending:
1078
+ m.status = "skipped"
1079
+ state_module.save(st)
1080
+ break
1081
+
1082
+ # Dependency-aware batch validation: filter milestones with skipped deps
1083
+ ready = validate_turbo_batch(ready, plan, logger)
1084
+ if not ready:
1085
+ logger.warning("All ready milestones filtered out by turbo batch validation")
1086
+ break
1087
+
1088
+ # Foundation-first: first batch only runs milestones with no dependencies
1089
+ if batch_num == 0:
1090
+ foundation = [m for m in ready if not m.depends_on]
1091
+ if foundation:
1092
+ ready = foundation[:1] # Only one foundation milestone at a time
1093
+
1094
+ # Token budget check (swarm/turbo mode)
1095
+ if token_estimator:
1096
+ estimated = token_estimator.estimate_batch(len(ready), args.max_iter, args.swarm_budget if getattr(args, 'swarm', False) else 0)
1097
+ if not token_estimator.check_budget(estimated):
1098
+ print(f"[Boris] Token budget exceeded. Estimated: {token_estimator.estimated_tokens + estimated:,}, Budget: {token_estimator.max_tokens:,} tokens", flush=True)
1099
+ logger.warning("Token budget exceeded, stopping execution")
1100
+ break
1101
+ token_estimator.record_tokens(estimated)
516
1102
 
517
- # Craft prompt and execute
518
- print(f"[Boris] Crafting prompt for {milestone.id}...", flush=True)
519
- prompt = prompts.craft_prompt(milestone, plan, project_dir)
520
- print(f"[Boris] Prompt ready ({len(prompt)} chars). Spawning DaveLoop...", flush=True)
521
- result = engine.run(prompt, project_dir, args.max_iter, milestone=milestone)
522
-
523
- # Check verdict
524
- print(f"[Boris] DaveLoop finished. Checking verdict for {milestone.id}...", flush=True)
525
- verdict_result = engine.check(result, milestone)
526
- logger.info(
527
- "Milestone %s verdict: %s - %s",
528
- milestone.id,
529
- verdict_result.verdict.value,
530
- verdict_result.reason,
531
- )
1103
+ batch_num += 1
1104
+ batch_ids = [m.id for m in ready]
1105
+ print(flush=True)
1106
+ print(f"[Boris] TURBO: Launching batch {batch_num} of {len(ready)} parallel DaveLoops: {batch_ids}", flush=True)
1107
+ logger.info("TURBO batch %d: %s", batch_num, batch_ids)
532
1108
 
533
- if verdict_result.verdict == Verdict.RESOLVED:
534
- milestone.status = "completed"
535
- milestone.completed_at = datetime.now().isoformat()
1109
+ # Mark all in batch as in_progress
1110
+ for m in ready:
1111
+ m.status = "in_progress"
536
1112
  state_module.save(st)
537
1113
 
538
- if not st.no_git:
539
- print(f"[Boris] Committing milestone {milestone.id} to git...", flush=True)
540
- if git_manager.commit_milestone(project_dir, milestone):
541
- logger.info("Git commit succeeded for milestone %s", milestone.id)
542
- else:
543
- logger.warning("Git commit FAILED for milestone %s", milestone.id)
544
- print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
545
-
546
- print(f"[Boris] Milestone {milestone.id} COMPLETE", flush=True)
547
- logger.info("Milestone %s completed", milestone.id)
548
-
549
- elif verdict_result.verdict == Verdict.OFF_PLAN:
550
- # Attempt correction
551
- correction_count = 0
552
- resolved = False
553
-
554
- while correction_count < MAX_CORRECTIONS:
555
- correction_count += 1
556
- print(
557
- f" [Boris] Off-plan detected. Correction attempt {correction_count}/{MAX_CORRECTIONS}...",
558
- flush=True,
559
- )
1114
+ # Craft prompts for all milestones in batch
1115
+ tasks = []
1116
+ prompt_map = {} # milestone.id -> prompt, for retries
1117
+ for m in ready:
1118
+ print(f"[Boris] TURBO: Crafting prompt for {m.id}...", flush=True)
1119
+ # Compute sibling milestones (all others in this batch)
1120
+ siblings = [sib for sib in ready if sib.id != m.id]
1121
+ prompt = prompts.craft_prompt(m, plan, project_dir,
1122
+ turbo=True, sibling_milestones=siblings)
1123
+ print(f"[Boris] TURBO: Prompt ready for {m.id} ({len(prompt)} chars)", flush=True)
1124
+ tasks.append((prompt, m))
1125
+ prompt_map[m.id] = prompt
1126
+
1127
+ # Run all DaveLoops in parallel
1128
+ isolation = getattr(args, 'isolation', 'none') if getattr(args, 'swarm', False) else 'none'
1129
+ parallel_results = engine.run_parallel(tasks, project_dir, args.max_iter, isolation=isolation)
1130
+
1131
+ # Print swarm dashboard after parallel run completes (B7)
1132
+ _print_swarm_dashboard(project_dir, batch_num, ready)
1133
+ engine.clear_worker_statuses(project_dir)
1134
+
1135
+ # Process verdicts sequentially (corrections/retries run sequentially)
1136
+ batch_summary = {}
1137
+ for milestone, result in parallel_results:
1138
+ print(f"[Boris] TURBO: Checking verdict for {milestone.id}...", flush=True)
1139
+ verdict_result = engine.check(result, milestone)
560
1140
  logger.info(
561
- "Off-plan correction attempt %d for milestone %s",
562
- correction_count,
1141
+ "TURBO milestone %s verdict: %s - %s",
563
1142
  milestone.id,
1143
+ verdict_result.verdict.value,
1144
+ verdict_result.reason,
564
1145
  )
565
1146
 
566
- correction_prompt = prompts.craft_correction(
567
- result.output, milestone, plan, verdict_result.reason
1147
+ _process_milestone_verdict(
1148
+ verdict_result, result, milestone, plan, st,
1149
+ project_dir, args, logger, prompt_map[milestone.id]
568
1150
  )
569
- result = engine.run(correction_prompt, project_dir, args.max_iter, milestone=milestone)
570
- verdict_result = engine.check(result, milestone)
1151
+ batch_summary[milestone.id] = milestone.status.upper()
571
1152
 
572
- if verdict_result.verdict == Verdict.RESOLVED:
573
- milestone.status = "completed"
574
- milestone.completed_at = datetime.now().isoformat()
575
- state_module.save(st)
1153
+ # Convergence phase: reconcile type conflicts from parallel workers
1154
+ if not getattr(args, 'no_converge', False):
1155
+ completed_in_batch = [m for m, r in parallel_results if m.status == "completed"]
1156
+ if len(completed_in_batch) > 1:
1157
+ print(f"[Boris] TURBO: Running convergence phase for batch {batch_num}...", flush=True)
1158
+ _run_convergence(st, plan, project_dir, completed_in_batch, logger)
576
1159
 
577
- if not st.no_git:
578
- print(f"[Boris] Committing corrected milestone {milestone.id} to git...", flush=True)
1160
+ # Git commits sequentially after entire batch
1161
+ if not st.no_git:
1162
+ for milestone, result in parallel_results:
1163
+ if milestone.status == "completed":
1164
+ print(f"[Boris] TURBO: Committing {milestone.id} to git...", flush=True)
579
1165
  if git_manager.commit_milestone(project_dir, milestone):
580
- logger.info("Git commit succeeded for milestone %s (after correction)", milestone.id)
1166
+ logger.info("Git commit succeeded for milestone %s", milestone.id)
581
1167
  else:
582
- logger.warning("Git commit FAILED for milestone %s (after correction)", milestone.id)
1168
+ logger.warning("Git commit FAILED for milestone %s", milestone.id)
583
1169
  print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
584
1170
 
585
- print(f"[Boris] Milestone {milestone.id} COMPLETE (after correction)", flush=True)
586
- logger.info("Milestone %s completed after correction", milestone.id)
587
- resolved = True
588
- break
589
-
590
- if not resolved:
591
- print(f" [Boris] Milestone {milestone.id} could not be corrected. Skipping.", flush=True)
592
- logger.warning("Milestone %s skipped after failed corrections", milestone.id)
593
- milestone.status = "skipped"
594
- state_module.save(st)
1171
+ # Save state after whole batch
1172
+ state_module.save(st)
595
1173
 
596
- elif verdict_result.verdict == Verdict.FAILED:
597
- # Retry logic
598
- retry_count = 0
599
- resolved = False
600
-
601
- while retry_count < MAX_RETRIES:
602
- retry_count += 1
603
- milestone.retry_count = retry_count
604
- print(
605
- f" [Boris] Failed. Retry {retry_count}/{MAX_RETRIES}...",
606
- flush=True,
607
- )
608
- logger.info(
609
- "Retry %d for milestone %s", retry_count, milestone.id
610
- )
1174
+ # Print batch summary
1175
+ summary_parts = [f"{mid}={status}" for mid, status in batch_summary.items()]
1176
+ print(f"[Boris] TURBO: Batch {batch_num} complete. Results: {', '.join(summary_parts)}", flush=True)
1177
+ logger.info("TURBO batch %d complete: %s", batch_num, batch_summary)
1178
+
1179
+ # Incremental / --stop-at: pause after batch in turbo
1180
+ should_pause = False
1181
+ if args.stop_at:
1182
+ # Pause only if the --stop-at milestone was in this batch
1183
+ batch_completed_ids = {mid for mid, status in batch_summary.items()
1184
+ if status in ("COMPLETED", "SKIPPED")}
1185
+ if args.stop_at in batch_completed_ids:
1186
+ should_pause = True
1187
+ elif args.incremental:
1188
+ should_pause = True
1189
+
1190
+ if should_pause:
1191
+ remaining = sum(1 for m in plan.milestones if m.status == "pending")
1192
+ if remaining > 0:
1193
+ print(flush=True)
1194
+ if args.stop_at:
1195
+ print(f"[Boris] --stop-at {args.stop_at}: pausing after batch {batch_num}.", flush=True)
1196
+ else:
1197
+ print(f"[Boris] Incremental mode: pausing after batch {batch_num}.", flush=True)
1198
+ print(f"[Boris] {remaining} milestone(s) remaining.", flush=True)
1199
+ resume_flags = f"-r --turbo -d \"{project_dir}\""
1200
+ if args.stop_at:
1201
+ resume_flags += f" --stop-at {args.stop_at}"
1202
+ elif args.incremental:
1203
+ resume_flags += " --incremental"
1204
+ print(f"[Boris] Resume with: boris {resume_flags}", flush=True)
1205
+ logger.info("Pausing after batch %d. %d remaining.", batch_num, remaining)
1206
+ state_module.save(st)
1207
+ sys.exit(0)
1208
+ else:
1209
+ # === SEQUENTIAL MODE (original behavior) ===
1210
+ for i in range(start_index, len(plan.milestones)):
1211
+ milestone = plan.milestones[i]
1212
+
1213
+ if milestone.status == "completed":
1214
+ logger.info("Skipping already completed milestone %s", milestone.id)
1215
+ continue
1216
+
1217
+ if milestone.status == "skipped":
1218
+ logger.info("Skipping previously skipped milestone %s", milestone.id)
1219
+ continue
1220
+
1221
+ print(flush=True)
1222
+ print(f"=== Milestone {milestone.id}: {milestone.title} ===", flush=True)
1223
+ logger.info("Starting milestone %s: %s", milestone.id, milestone.title)
1224
+
1225
+ # Mark in progress
1226
+ milestone.status = "in_progress"
1227
+ st.current_milestone_index = i
1228
+ state_module.save(st)
611
1229
 
612
- result = engine.run(prompt, project_dir, args.max_iter, milestone=milestone)
613
- verdict_result = engine.check(result, milestone)
1230
+ # Craft prompt and execute
1231
+ print(f"[Boris] Crafting prompt for {milestone.id}...", flush=True)
1232
+ prompt = prompts.craft_prompt(milestone, plan, project_dir)
1233
+ print(f"[Boris] Prompt ready ({len(prompt)} chars). Spawning DaveLoop...", flush=True)
1234
+ result = engine.run(prompt, project_dir, args.max_iter, milestone=milestone)
1235
+
1236
+ # Check verdict
1237
+ print(f"[Boris] DaveLoop finished. Checking verdict for {milestone.id}...", flush=True)
1238
+ verdict_result = engine.check(result, milestone)
1239
+ logger.info(
1240
+ "Milestone %s verdict: %s - %s",
1241
+ milestone.id,
1242
+ verdict_result.verdict.value,
1243
+ verdict_result.reason,
1244
+ )
1245
+
1246
+ if verdict_result.verdict == Verdict.RESOLVED:
1247
+ milestone.status = "completed"
1248
+ milestone.completed_at = datetime.now().isoformat()
1249
+ state_module.save(st)
614
1250
 
615
- if verdict_result.verdict == Verdict.RESOLVED:
616
- milestone.status = "completed"
617
- milestone.completed_at = datetime.now().isoformat()
1251
+ if not st.no_git:
1252
+ print(f"[Boris] Committing milestone {milestone.id} to git...", flush=True)
1253
+ if git_manager.commit_milestone(project_dir, milestone):
1254
+ logger.info("Git commit succeeded for milestone %s", milestone.id)
1255
+ else:
1256
+ logger.warning("Git commit FAILED for milestone %s", milestone.id)
1257
+ print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
1258
+
1259
+ print(f"[Boris] Milestone {milestone.id} COMPLETE", flush=True)
1260
+ logger.info("Milestone %s completed", milestone.id)
1261
+
1262
+ elif verdict_result.verdict == Verdict.OFF_PLAN:
1263
+ # Attempt correction
1264
+ correction_count = 0
1265
+ resolved = False
1266
+
1267
+ while correction_count < MAX_CORRECTIONS:
1268
+ correction_count += 1
1269
+ print(
1270
+ f" [Boris] Off-plan detected. Correction attempt {correction_count}/{MAX_CORRECTIONS}...",
1271
+ flush=True,
1272
+ )
1273
+ logger.info(
1274
+ "Off-plan correction attempt %d for milestone %s",
1275
+ correction_count,
1276
+ milestone.id,
1277
+ )
1278
+
1279
+ correction_prompt = prompts.craft_correction(
1280
+ result.output, milestone, plan, verdict_result.reason
1281
+ )
1282
+ result = engine.run(correction_prompt, project_dir, args.max_iter, milestone=milestone)
1283
+ verdict_result = engine.check(result, milestone)
1284
+
1285
+ if verdict_result.verdict == Verdict.RESOLVED:
1286
+ milestone.status = "completed"
1287
+ milestone.completed_at = datetime.now().isoformat()
1288
+ state_module.save(st)
1289
+
1290
+ if not st.no_git:
1291
+ print(f"[Boris] Committing corrected milestone {milestone.id} to git...", flush=True)
1292
+ if git_manager.commit_milestone(project_dir, milestone):
1293
+ logger.info("Git commit succeeded for milestone %s (after correction)", milestone.id)
1294
+ else:
1295
+ logger.warning("Git commit FAILED for milestone %s (after correction)", milestone.id)
1296
+ print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
1297
+
1298
+ print(f"[Boris] Milestone {milestone.id} COMPLETE (after correction)", flush=True)
1299
+ logger.info("Milestone %s completed after correction", milestone.id)
1300
+ resolved = True
1301
+ break
1302
+
1303
+ if not resolved:
1304
+ print(f" [Boris] Milestone {milestone.id} could not be corrected. Skipping.", flush=True)
1305
+ logger.warning("Milestone %s skipped after failed corrections", milestone.id)
1306
+ milestone.status = "skipped"
618
1307
  state_module.save(st)
619
1308
 
620
- if not st.no_git:
621
- print(f"[Boris] Committing retried milestone {milestone.id} to git...", flush=True)
622
- if git_manager.commit_milestone(project_dir, milestone):
623
- logger.info("Git commit succeeded for milestone %s (after retry)", milestone.id)
624
- else:
625
- logger.warning("Git commit FAILED for milestone %s (after retry)", milestone.id)
626
- print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
627
-
628
- print(f"[Boris] Milestone {milestone.id} COMPLETE (after retry)", flush=True)
629
- logger.info("Milestone %s completed after retry", milestone.id)
630
- resolved = True
631
- break
1309
+ elif verdict_result.verdict == Verdict.FAILED:
1310
+ # Retry logic
1311
+ retry_count = 0
1312
+ resolved = False
1313
+
1314
+ while retry_count < MAX_RETRIES:
1315
+ retry_count += 1
1316
+ milestone.retry_count = retry_count
1317
+ print(
1318
+ f" [Boris] Failed. Retry {retry_count}/{MAX_RETRIES}...",
1319
+ flush=True,
1320
+ )
1321
+ logger.info(
1322
+ "Retry %d for milestone %s", retry_count, milestone.id
1323
+ )
1324
+
1325
+ result = engine.run(prompt, project_dir, args.max_iter, milestone=milestone)
1326
+ verdict_result = engine.check(result, milestone)
1327
+
1328
+ if verdict_result.verdict == Verdict.RESOLVED:
1329
+ milestone.status = "completed"
1330
+ milestone.completed_at = datetime.now().isoformat()
1331
+ state_module.save(st)
1332
+
1333
+ if not st.no_git:
1334
+ print(f"[Boris] Committing retried milestone {milestone.id} to git...", flush=True)
1335
+ if git_manager.commit_milestone(project_dir, milestone):
1336
+ logger.info("Git commit succeeded for milestone %s (after retry)", milestone.id)
1337
+ else:
1338
+ logger.warning("Git commit FAILED for milestone %s (after retry)", milestone.id)
1339
+ print(f" [Boris] WARNING: Git commit failed for {milestone.id}", flush=True)
1340
+
1341
+ print(f"[Boris] Milestone {milestone.id} COMPLETE (after retry)", flush=True)
1342
+ logger.info("Milestone %s completed after retry", milestone.id)
1343
+ resolved = True
1344
+ break
1345
+
1346
+ if not resolved:
1347
+ print(
1348
+ f" [Boris] Milestone {milestone.id} failed after {MAX_RETRIES} retries. Skipping.",
1349
+ flush=True,
1350
+ )
1351
+ logger.warning("Milestone %s skipped after failed retries", milestone.id)
1352
+ milestone.status = "skipped"
1353
+ state_module.save(st)
632
1354
 
633
- if not resolved:
634
- print(
635
- f" [Boris] Milestone {milestone.id} failed after {MAX_RETRIES} retries. Skipping.",
636
- flush=True,
637
- )
638
- logger.warning("Milestone %s skipped after failed retries", milestone.id)
639
- milestone.status = "skipped"
640
- state_module.save(st)
1355
+ # Save state after each milestone outcome
1356
+ state_module.save(st)
641
1357
 
642
- # Save state after each milestone outcome
643
- state_module.save(st)
1358
+ # Incremental mode: pause after each milestone
1359
+ # --stop-at: only pause when the target milestone completes
1360
+ should_pause = False
1361
+ if args.stop_at and milestone.status in ("completed", "skipped"):
1362
+ if milestone.id == args.stop_at:
1363
+ should_pause = True
1364
+ elif args.incremental and milestone.status in ("completed", "skipped"):
1365
+ should_pause = True
1366
+
1367
+ if should_pause:
1368
+ remaining = sum(1 for m in plan.milestones if m.status == "pending")
1369
+ if remaining > 0:
1370
+ print(flush=True)
1371
+ if args.stop_at:
1372
+ print(f"[Boris] --stop-at {args.stop_at}: pausing after {milestone.id}.", flush=True)
1373
+ else:
1374
+ print(f"[Boris] Incremental mode: pausing after {milestone.id}.", flush=True)
1375
+ print(f"[Boris] {remaining} milestone(s) remaining.", flush=True)
1376
+ resume_flags = f"-r -d \"{project_dir}\""
1377
+ if args.stop_at:
1378
+ resume_flags += f" --stop-at {args.stop_at}"
1379
+ elif args.incremental:
1380
+ resume_flags += " --incremental"
1381
+ print(f"[Boris] Resume with: boris {resume_flags}", flush=True)
1382
+ logger.info("Pausing after %s. %d remaining.", milestone.id, remaining)
1383
+ state_module.save(st)
1384
+ sys.exit(0)
644
1385
 
645
1386
  # Phase 2: UI Testing & Polish
646
1387
  _run_ui_phase(st, plan, project_dir, args, logger)