borisxdave 0.3.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.
@@ -0,0 +1,191 @@
1
+ # Boris - Project Manager Orchestrator
2
+
3
+ Boris is a **project manager**, He plans, delegates, and verifies. DaveLoop is the builder.
4
+
5
+ ---
6
+
7
+ ## Boris's Job
8
+
9
+ 1. **Plan** - Break the user's task into ordered milestones
10
+ 2. **Craft** - Write precise, context-rich prompts for each milestone
11
+ 3. **Delegate** - Spawn DaveLoop with the crafted prompt
12
+ 4. **Verify** - Check DaveLoop's output against acceptance criteria
13
+ 5. **Manage Git** - init git add and stage then commit when user request fully built
14
+ 6. **Repeat** - Move to next milestone until project is done
15
+
16
+ ---
17
+
18
+ ## How Boris Writes Prompts for DaveLoop
19
+
20
+ This is the most critical part. DaveLoop is a self-healing debug loop - it receives a bug/task description and iterates until resolved. Boris must give DaveLoop everything it needs in ONE prompt.
21
+
22
+ ### Every DaveLoop prompt MUST include:
23
+
24
+ 1. **What the project is** - High-level description so DaveLoop understands context
25
+ 2. **What already exists** - Exact files and modules from completed milestones
26
+ 3. **What to build NOW** - The specific milestone spec, detailed and unambiguous
27
+ 4. **How it integrates** - Which existing files to import from, which functions to call
28
+ 5. **Acceptance criteria** - Concrete, testable criteria DaveLoop can verify
29
+ 6. **Boundaries** - What NOT to touch (files from other milestones)
30
+ 7. **Verification steps** - Exact commands to prove the milestone works
31
+
32
+ ### Prompt quality rules:
33
+
34
+ - **Be specific, not vague** - "Create a Flask app with /api/users GET endpoint returning JSON" not "build a backend"
35
+ - **Name files explicitly** - "Create src/routes/users.py" not "create the routes"
36
+ - **Name functions explicitly** - "Implement get_users() that queries the User model" not "add user functionality"
37
+ - **Describe data flow** - "The frontend calls /api/users, which calls db.get_all_users(), which returns List[User]"
38
+ - **Include test commands** - "Verify with: python -m pytest tests/test_users.py -v"
39
+
40
+ ---
41
+
42
+ ## How Boris Checks DaveLoop's Work
43
+
44
+ After DaveLoop finishes, Boris checks:
45
+
46
+ 1. **Did DaveLoop report [DAVELOOP:RESOLVED]?** - If yes, likely success
47
+ 2. **Did DaveLoop's exit code = 0?** - If not, something crashed
48
+ 3. **Do the acceptance criteria pass?** - Boris can ask Claude to analyze the output
49
+ 4. **Did DaveLoop stay in scope?** - No scope creep into other milestones
50
+
51
+ ### Verdicts:
52
+ - **RESOLVED** - Milestone done, commit and move on
53
+ - **OFF_PLAN** - DaveLoop built the wrong thing, send correction prompt
54
+ - **FAILED** - DaveLoop couldn't finish, retry or skip
55
+
56
+ ---
57
+
58
+ ## How Boris Monitors DaveLoop in Real-Time
59
+
60
+ Boris doesn't just fire-and-forget. He watches DaveLoop's output line by line as it streams.
61
+
62
+ ### Reasoning Block = Boris Check-in
63
+
64
+ DaveLoop outputs structured reasoning blocks (KNOWN/UNKNOWN/HYPOTHESIS/NEXT/WHY) before every action. Each reasoning block triggers a **Boris check-in** - Boris reports what DaveLoop accomplished since the last reasoning block and what he's about to do next:
65
+
66
+ ```
67
+ [Boris] === DaveLoop Check-in #3 ===
68
+ [Boris] Done so far:
69
+ [Boris] - Created models.py
70
+ [Boris] - Created config.py
71
+ [Boris] - Ran tests: pytest tests/ -v
72
+ [Boris] Knows: Database models created, need seed data next
73
+ [Boris] Thinking: Seed data should include sample products and users
74
+ [Boris] Next: Create seed_data.py with 10 sample products
75
+ [Boris] ===========================
76
+ ```
77
+
78
+ Boris tracks every file write, edit, bash command, and test result between reasoning blocks. When a new reasoning block fires, Boris summarizes what DaveLoop accomplished since the last check-in, plus DaveLoop's current thinking and next move.
79
+
80
+ When DaveLoop finishes, Boris prints a full run summary of all tracked actions.
81
+
82
+ ### Off-Rail Detection and Text Interrupt
83
+
84
+ Boris watches for signs that DaveLoop is going off-rail:
85
+ - **Wrong files** - DaveLoop creating/modifying files outside the milestone's allowed list
86
+ - **Scope creep** - DaveLoop mentioning "build the entire project" or "implement all milestones"
87
+ - **Wrong milestone** - DaveLoop referencing other milestone IDs (M2, M3) while building M1
88
+
89
+ When Boris detects off-rail behavior, he sends a **text interrupt** to DaveLoop's stdin:
90
+ ```
91
+ [Boris INTERRUPT] wait - you are creating orders.py which is outside the scope of M1.
92
+ Only touch: models.py, config.py. Focus on M1: Project Setup only.
93
+ ```
94
+
95
+ DaveLoop supports text interrupts (wait/pause/add/done) and will process Boris's correction mid-run.
96
+
97
+ ### Interrupt Limits
98
+
99
+ Boris sends a maximum of 3 interrupts per DaveLoop run. If DaveLoop keeps going off-rail after 3 interrupts, Boris lets it finish and handles it at the verdict stage (OFF_PLAN correction or FAILED retry).
100
+
101
+ ---
102
+
103
+ ## How Boris Handles Failures
104
+
105
+ 1. **First failure** - Retry with the same prompt (DaveLoop might just need another iteration)
106
+ 2. **Off-plan work** - Send correction prompt explaining what went wrong and what's expected
107
+ 3. **Repeated failure** - Skip milestone, log warning, continue with next milestone
108
+ 4. **Never get stuck** - Boris always moves forward. Skip and warn, don't loop forever.
109
+
110
+ ---
111
+
112
+ ## How Boris Manages Git
113
+
114
+ After each RESOLVED milestone:
115
+ 1. `git add -A` in the project directory
116
+ 2. `git commit -m "feat(milestone-{id}): {title}"`
117
+ 3. `git push` if remote is configured
118
+
119
+ On completion: final commit + push with "chore: Boris orchestration complete"
120
+
121
+ ---
122
+
123
+ ## Boris's State
124
+
125
+ Boris saves progress after every milestone to `.boris/state.json` so he can resume if interrupted. The state tracks:
126
+ - The full plan
127
+ - Which milestones are completed/skipped/pending
128
+ - Current milestone index
129
+ - Retry counts
130
+ - Timestamps
131
+
132
+ ---
133
+
134
+ ## How Boris Exits
135
+
136
+ Boris always exits cleanly with a proper summary and exit code.
137
+
138
+ ### Exit Codes:
139
+ - **0** - All milestones completed successfully
140
+ - **1** - Some milestones were skipped or failed
141
+ - **130** - Interrupted by user (Ctrl+C), state saved for resume
142
+
143
+ ### Summary Report:
144
+
145
+ When Boris finishes (all milestones processed), he generates a **summary markdown file** at `plans/summary_YYYYMMDD_HHMMSS.md` containing:
146
+ - The original task description
147
+ - Total milestones: completed, skipped, failed
148
+ - Per-milestone breakdown: status, title, files created/modified
149
+ - Timestamps: start time, end time, total duration
150
+ - Skipped milestones: reasons why they were skipped
151
+
152
+ This summary is Boris's final deliverable - a complete record of what was built, what was skipped, and why.
153
+
154
+ ---
155
+
156
+ ## Phase 2: UI Testing & Polish (DaveLoop v1.4)
157
+
158
+ After all structural milestones are completed, Boris enters the UI Testing & Polish phase.
159
+
160
+ ### How It Works:
161
+ 1. Boris asks Claude to create UI testing milestones (Claude already knows the project - it just built it)
162
+ 2. Claude decides the project type and test tool (Playwright for web, Maestro for mobile)
163
+ 3. Boris shifts DaveLoop to UI Tester Mode (v1.4) - same DaveLoop, different orders
164
+ 4. DaveLoop tests UI flows, finds issues, fixes them
165
+ 5. Boris verifies each UI milestone with UI-specific verdicts
166
+
167
+ ### DaveLoop v1.4 - UI Tester Mode:
168
+ - Does NOT build new features
169
+ - Tests existing UI flows with Playwright/Maestro
170
+ - Reports issues: `ISSUE FOUND: <description>`
171
+ - Applies fixes: `FIX APPLIED: <description>`
172
+ - Captures screenshots for visual verification
173
+
174
+ Boris doesn't teach DaveLoop how to use Playwright or Maestro. Boris scopes the task, ships DaveLoop off, and DaveLoop handles the rest.
175
+
176
+ ### Skip UI Testing:
177
+ Use `--skip-ui` flag to skip the UI testing phase entirely.
178
+
179
+ ### Resume Support:
180
+ If interrupted during UI testing, `boris -r -d <project>` resumes directly into the UI phase.
181
+
182
+ ---
183
+
184
+ ## Boris's Personality
185
+
186
+ Boris is methodical, relentless, and focused:
187
+ - He does not write code. He manages.
188
+ - He does not discuss. He acts.
189
+ - He does not get stuck. He moves forward.
190
+ - He trusts DaveLoop to build. He verifies the results.
191
+ - He keeps perfect records (state, logs, plan markdown, summary report).
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)
@@ -245,6 +417,41 @@ def parse_args() -> argparse.Namespace:
245
417
  help="Stop execution after completing this milestone ID (e.g. M4). Implies --incremental."
246
418
  )
247
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
+ )
454
+
248
455
  return parser.parse_args()
249
456
 
250
457
 
@@ -516,16 +723,177 @@ def _process_milestone_verdict(verdict_result, result, milestone, plan, st, proj
516
723
  state_module.save(st)
517
724
 
518
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
+
519
871
  def main():
520
872
  """Main Boris orchestration loop."""
521
873
  args = parse_args()
522
874
 
875
+ # Apply swarm preset if specified (implies --swarm)
876
+ _apply_preset(args)
877
+
523
878
  # --stop-at implies incremental mode
524
879
  if args.stop_at:
525
880
  args.incremental = True
526
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
+
527
890
  logger = setup_logging()
528
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
+
529
897
  # Create required dirs
530
898
  os.makedirs(PLANS_DIR, exist_ok=True)
531
899
  os.makedirs(LOGS_DIR, exist_ok=True)
@@ -567,6 +935,9 @@ def main():
567
935
 
568
936
  print_plan_summary(plan)
569
937
 
938
+ # Prompt user to skip milestones before execution
939
+ prompt_skip_milestones(plan, logger)
940
+
570
941
  # Create initial state from the loaded plan
571
942
  st = state_module.State(
572
943
  plan=plan,
@@ -625,10 +996,32 @@ def main():
625
996
 
626
997
  print_plan_summary(plan)
627
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
+
628
1018
  if args.plan_only:
629
1019
  print("[Boris] Plan-only mode. Exiting.", flush=True)
630
1020
  return
631
1021
 
1022
+ # Prompt user to skip milestones before execution
1023
+ prompt_skip_milestones(plan, logger)
1024
+
632
1025
  # Create initial state
633
1026
  st = state_module.State(
634
1027
  plan=plan,
@@ -686,6 +1079,27 @@ def main():
686
1079
  state_module.save(st)
687
1080
  break
688
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)
1102
+
689
1103
  batch_num += 1
690
1104
  batch_ids = [m.id for m in ready]
691
1105
  print(flush=True)
@@ -711,7 +1125,12 @@ def main():
711
1125
  prompt_map[m.id] = prompt
712
1126
 
713
1127
  # Run all DaveLoops in parallel
714
- parallel_results = engine.run_parallel(tasks, project_dir, args.max_iter)
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)
715
1134
 
716
1135
  # Process verdicts sequentially (corrections/retries run sequentially)
717
1136
  batch_summary = {}
@@ -731,6 +1150,13 @@ def main():
731
1150
  )
732
1151
  batch_summary[milestone.id] = milestone.status.upper()
733
1152
 
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)
1159
+
734
1160
  # Git commits sequentially after entire batch
735
1161
  if not st.no_git:
736
1162
  for milestone, result in parallel_results:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: borisxdave
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Boris - Autonomous Project Orchestrator
5
5
  Requires-Python: >=3.8
6
6
 
@@ -0,0 +1,14 @@
1
+ boris.py,sha256=VwVjetw6Tfs397btdCDRuM_iRlPI5XSzqfalqlwSLlA,62940
2
+ config.py,sha256=KfFKyCGasdm1yBvIRFv-ykzA_oRo-zu1Euu9YC7V1Cg,324
3
+ engine.py,sha256=Pdu0i4XrNxiU246EV8MjXvYp9CBvuJWGLA18QMIYvFM,37468
4
+ file_lock.py,sha256=1YriAAayVy8YFe7JFuGIloiJWWvN2FSY0Ry1sB043Sc,4823
5
+ git_manager.py,sha256=BuuTT4naPb5-jLhOik1xHM2ztzuKvJ_bnecZmlYgwFs,8493
6
+ planner.py,sha256=UrU--kBvzvyD1gOVxIn-kdbJiu8tt4rcowsln66WkGw,5670
7
+ prompts.py,sha256=Sln8ukCby2gWcs_U3ru4YSXCTWI5MgkI4WB4ONLIyWk,34779
8
+ state.py,sha256=2DCPlcM7SBlCkwWvcnIabltcduv74W46FZ7DxKurWkw,5752
9
+ Users/david/AppData/Local/Programs/Python/Python313/Lib/site-packages/boris_prompt.md,sha256=W8bQP4c-iLLtxSsscIxbjXI2PlWTNbOrq05UGp9mLWs,7839
10
+ borisxdave-0.3.1.dist-info/METADATA,sha256=1Q8uBCfA2BpHdmgK-6kwR58ESpChi2JcTsRoLbz2MoU,133
11
+ borisxdave-0.3.1.dist-info/WHEEL,sha256=hPN0AlP2dZM_3ZJZWP4WooepkmU9wzjGgCLCeFjkHLA,92
12
+ borisxdave-0.3.1.dist-info/entry_points.txt,sha256=a6FLWgxiQjGMJIRSV5sDxaaaaQchunm04ZuzX8N7-6I,61
13
+ borisxdave-0.3.1.dist-info/top_level.txt,sha256=GSKxzJ_M15C-hpRGaC1C5pusFxA1JIaxaSHYaLg4rQc,64
14
+ borisxdave-0.3.1.dist-info/RECORD,,
@@ -1,6 +1,7 @@
1
1
  boris
2
2
  config
3
3
  engine
4
+ file_lock
4
5
  git_manager
5
6
  planner
6
7
  prompts
engine.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Boris engine - execution and monitoring (merged from executor + monitor)."""
2
2
  import concurrent.futures
3
3
  import enum
4
+ import json
4
5
  import logging
5
6
  import os
6
7
  import re
@@ -8,8 +9,10 @@ import shutil
8
9
  import subprocess
9
10
  import sys
10
11
  import tempfile
12
+ import time
11
13
  from dataclasses import dataclass
12
14
  from datetime import datetime
15
+ from pathlib import Path
13
16
  from typing import Optional
14
17
 
15
18
  # Force unbuffered stdout for real-time output on Windows
@@ -62,6 +65,49 @@ def _clean_output(text: str) -> str:
62
65
  return text
63
66
 
64
67
 
68
+ # --- Worker Status (B7: Swarm Dashboard) ---
69
+
70
+
71
+ def _write_worker_status(project_dir: str, milestone_id: str, status: dict):
72
+ """Write worker status to .boris/workers/ for the swarm dashboard."""
73
+ try:
74
+ status_dir = Path(project_dir) / ".boris" / "workers"
75
+ status_dir.mkdir(parents=True, exist_ok=True)
76
+ status_file = status_dir / f"{milestone_id}.json"
77
+ status["updated_at"] = time.time()
78
+ status_file.write_text(json.dumps(status, indent=2), encoding="utf-8")
79
+ except OSError:
80
+ pass # Non-critical: dashboard is informational only
81
+
82
+
83
+ def read_worker_statuses(project_dir: str) -> dict:
84
+ """Read all worker status files from .boris/workers/. Returns {milestone_id: status_dict}."""
85
+ statuses = {}
86
+ status_dir = Path(project_dir) / ".boris" / "workers"
87
+ if not status_dir.exists():
88
+ return statuses
89
+ for status_file in status_dir.glob("*.json"):
90
+ try:
91
+ data = json.loads(status_file.read_text(encoding="utf-8"))
92
+ milestone_id = status_file.stem
93
+ statuses[milestone_id] = data
94
+ except (json.JSONDecodeError, OSError):
95
+ pass
96
+ return statuses
97
+
98
+
99
+ def clear_worker_statuses(project_dir: str):
100
+ """Remove all worker status files (call after a batch completes)."""
101
+ status_dir = Path(project_dir) / ".boris" / "workers"
102
+ if not status_dir.exists():
103
+ return
104
+ for status_file in status_dir.glob("*.json"):
105
+ try:
106
+ status_file.unlink()
107
+ except (FileNotFoundError, OSError):
108
+ pass
109
+
110
+
65
111
  # --- Execution (from executor.py) ---
66
112
 
67
113
 
@@ -246,6 +292,17 @@ def run(prompt: str, project_dir: str, max_iterations: int = None,
246
292
 
247
293
  print(f" [Boris] Spawning DaveLoop: max_iter={max_iter}, project={project_dir}", flush=True)
248
294
  logger.info("Spawning DaveLoop: max_iter=%d, project=%s", max_iter, project_dir)
295
+
296
+ # Write initial worker status for dashboard (B7)
297
+ if milestone:
298
+ _write_worker_status(project_dir, milestone.id, {
299
+ "milestone_id": milestone.id,
300
+ "title": milestone.title,
301
+ "state": "starting",
302
+ "started_at": time.time(),
303
+ "actions": 0,
304
+ "interrupts": 0,
305
+ })
249
306
  logger.debug("Prompt length: %d chars", len(prompt))
250
307
 
251
308
  # Boris's own log for this execution
@@ -280,6 +337,14 @@ def run(prompt: str, project_dir: str, max_iterations: int = None,
280
337
  all_accomplishments = [] # cumulative for the whole run
281
338
  interrupt_count = 0
282
339
  MAX_INTERRUPTS = 3 # After 3 interrupts, let DaveLoop finish and fail at verdict
340
+ # Off-rail detection is suppressed during prompt echo phase.
341
+ # DaveLoop echoes/processes the prompt at startup, which contains sibling
342
+ # milestone IDs (from the PARALLEL EXECUTION WARNING section). Without this
343
+ # guard, _check_off_rail() fires false positives on the prompt's own text.
344
+ # We suppress until DaveLoop starts actual work (first reasoning block) or
345
+ # after a generous line threshold.
346
+ offrail_active = False
347
+ OFFRAIL_WARMUP_LINES = 80 # Lines before off-rail activates even without reasoning
283
348
 
284
349
  for raw_line in process.stdout:
285
350
  line = raw_line.decode("utf-8", errors="replace")
@@ -298,10 +363,17 @@ def run(prompt: str, project_dir: str, max_iterations: int = None,
298
363
  accomplishments.append(acc)
299
364
  all_accomplishments.append(acc)
300
365
 
366
+ # Activate off-rail detection after warmup threshold (prompt echo complete)
367
+ if not offrail_active and len(output_lines) >= OFFRAIL_WARMUP_LINES:
368
+ offrail_active = True
369
+
301
370
  # --- Reasoning block detection ---
302
371
  if "REASONING" in clean and ("===" in clean or "---" in clean or "KNOWN" in clean):
303
372
  in_reasoning = True
304
373
  reasoning_lines = []
374
+ # First reasoning block means DaveLoop is doing real work - activate off-rail
375
+ if not offrail_active:
376
+ offrail_active = True
305
377
  continue
306
378
 
307
379
  if in_reasoning:
@@ -317,14 +389,26 @@ def run(prompt: str, project_dir: str, max_iterations: int = None,
317
389
  reasoning[key] = rl.split(":", 1)[1].strip()
318
390
  if reasoning:
319
391
  _boris_commentary(reasoning, reasoning_count, accomplishments)
392
+ # Update worker status for dashboard (B7)
393
+ if milestone:
394
+ _write_worker_status(project_dir, milestone.id, {
395
+ "milestone_id": milestone.id,
396
+ "title": milestone.title,
397
+ "state": "working",
398
+ "started_at": time.time(),
399
+ "reasoning_blocks": reasoning_count,
400
+ "actions": len(all_accomplishments),
401
+ "interrupts": interrupt_count,
402
+ "last_action": all_accomplishments[-1] if all_accomplishments else None,
403
+ })
320
404
  # Reset per-block accomplishments, keep cumulative
321
405
  accomplishments = []
322
406
  reasoning_lines = []
323
407
  else:
324
408
  reasoning_lines.append(clean)
325
409
 
326
- # --- Off-rail detection ---
327
- if milestone and interrupt_count < MAX_INTERRUPTS:
410
+ # --- Off-rail detection (suppressed during prompt echo phase) ---
411
+ if milestone and interrupt_count < MAX_INTERRUPTS and offrail_active:
328
412
  interrupt_msg = _check_off_rail(clean, milestone)
329
413
  if interrupt_msg:
330
414
  _send_interrupt(process, interrupt_msg, boris_log)
@@ -332,12 +416,29 @@ def run(prompt: str, project_dir: str, max_iterations: int = None,
332
416
  if interrupt_count >= MAX_INTERRUPTS:
333
417
  warn = (
334
418
  f"[Boris] Sent {MAX_INTERRUPTS} interrupts. "
335
- f"DaveLoop keeps going off-rail. Will check at verdict."
419
+ f"DaveLoop keeps going off-rail. Terminating process."
336
420
  )
337
421
  print(f"\n {warn}\n", flush=True)
338
- logger.warning(warn)
422
+ logger.warning("Terminating DaveLoop process after %d ignored interrupts", MAX_INTERRUPTS)
339
423
  if boris_log:
340
424
  boris_log.write(f"\n{warn}\n")
425
+ # Hard kill: terminate the process since interrupts are being ignored
426
+ process.terminate()
427
+ try:
428
+ process.wait(timeout=10)
429
+ except subprocess.TimeoutExpired:
430
+ process.kill()
431
+ process.wait(timeout=5)
432
+ output = "".join(output_lines)
433
+ boris_log.write(f"\n=== DaveLoop FORCE KILLED after {MAX_INTERRUPTS} ignored interrupts ===\n")
434
+ boris_log.close()
435
+ boris_log = None # prevent double-close in finally
436
+ return ExecutionResult(
437
+ output=output,
438
+ exit_code=-1,
439
+ resolved=False,
440
+ log_path=log_path,
441
+ )
341
442
 
342
443
  process.wait()
343
444
  output = "".join(output_lines)
@@ -414,28 +515,125 @@ def _setup_log(project_dir: str) -> str:
414
515
  return os.path.join(_LOGS_DIR, f"boris_exec_{timestamp}.log")
415
516
 
416
517
 
417
- def run_parallel(tasks: list, project_dir: str, max_iterations: int = None) -> list:
518
+ def _create_worktree(project_dir: str, milestone_id: str) -> tuple:
519
+ """Create a git worktree for a milestone. Returns (worktree_path, branch_name) or None on failure."""
520
+ worktree_path = os.path.join(project_dir, f".boris_worktree_{milestone_id}")
521
+ branch_name = f"boris/{milestone_id}"
522
+ try:
523
+ result = subprocess.run(
524
+ ["git", "worktree", "add", "-b", branch_name, worktree_path],
525
+ cwd=project_dir, capture_output=True, timeout=30,
526
+ encoding="utf-8", errors="replace",
527
+ )
528
+ if result.returncode == 0:
529
+ logger.info("Created worktree for %s at %s", milestone_id, worktree_path)
530
+ return (worktree_path, branch_name)
531
+ else:
532
+ logger.warning("Failed to create worktree for %s: %s", milestone_id, result.stderr.strip())
533
+ return None
534
+ except (subprocess.SubprocessError, OSError) as e:
535
+ logger.warning("Worktree creation error for %s: %s", milestone_id, e)
536
+ return None
537
+
538
+
539
+ def _merge_worktree(project_dir: str, worktree_path: str, branch_name: str, milestone_id: str) -> bool:
540
+ """Merge a worktree branch back into the current branch and clean up. Returns success."""
541
+ try:
542
+ # Merge the branch back
543
+ merge_result = subprocess.run(
544
+ ["git", "merge", branch_name, "--no-edit", "-m",
545
+ f"Merge boris/{milestone_id} worktree back"],
546
+ cwd=project_dir, capture_output=True, timeout=60,
547
+ encoding="utf-8", errors="replace",
548
+ )
549
+ if merge_result.returncode != 0:
550
+ logger.warning("Merge failed for %s: %s", milestone_id, merge_result.stderr.strip())
551
+ # Abort merge on conflict
552
+ subprocess.run(["git", "merge", "--abort"], cwd=project_dir,
553
+ capture_output=True, timeout=10)
554
+ return False
555
+ return True
556
+ except (subprocess.SubprocessError, OSError) as e:
557
+ logger.warning("Merge error for %s: %s", milestone_id, e)
558
+ return False
559
+ finally:
560
+ _cleanup_worktree(project_dir, worktree_path, branch_name)
561
+
562
+
563
+ def _cleanup_worktree(project_dir: str, worktree_path: str, branch_name: str):
564
+ """Remove a git worktree and its branch."""
565
+ try:
566
+ subprocess.run(["git", "worktree", "remove", worktree_path, "--force"],
567
+ cwd=project_dir, capture_output=True, timeout=30)
568
+ except (subprocess.SubprocessError, OSError):
569
+ pass
570
+ try:
571
+ subprocess.run(["git", "branch", "-D", branch_name],
572
+ cwd=project_dir, capture_output=True, timeout=10)
573
+ except (subprocess.SubprocessError, OSError):
574
+ pass
575
+
576
+
577
+ def run_parallel(tasks: list, project_dir: str, max_iterations: int = None,
578
+ isolation: str = "none") -> list:
418
579
  """Run multiple DaveLoop instances in parallel using ThreadPoolExecutor.
419
580
 
420
581
  Args:
421
582
  tasks: List of (prompt, milestone) tuples.
422
583
  project_dir: Working directory for the project.
423
584
  max_iterations: Max DaveLoop iterations per milestone.
585
+ isolation: Isolation strategy - "none" (shared dir), "worktree" (git worktrees).
424
586
 
425
587
  Returns:
426
588
  List of (milestone, ExecutionResult) tuples, one per input task.
427
589
  """
428
590
  results = []
429
591
 
430
- def _run_one(prompt_milestone):
431
- prompt, milestone = prompt_milestone
432
- result = run(prompt, project_dir, max_iterations, milestone=milestone)
433
- return (milestone, result)
434
-
435
- with concurrent.futures.ThreadPoolExecutor(max_workers=len(tasks)) as executor:
436
- futures = {executor.submit(_run_one, t): t for t in tasks}
437
- for future in concurrent.futures.as_completed(futures):
438
- results.append(future.result())
592
+ if isolation == "worktree" and len(tasks) > 1:
593
+ # Create worktrees for each task
594
+ worktree_map = {} # milestone_id -> (worktree_path, branch_name)
595
+ for prompt, milestone in tasks:
596
+ wt = _create_worktree(project_dir, milestone.id)
597
+ if wt:
598
+ worktree_map[milestone.id] = wt
599
+ else:
600
+ logger.warning("Worktree failed for %s, falling back to shared dir", milestone.id)
601
+
602
+ def _run_one_worktree(prompt_milestone):
603
+ prompt, milestone = prompt_milestone
604
+ wt_info = worktree_map.get(milestone.id)
605
+ work_dir = wt_info[0] if wt_info else project_dir
606
+ result = run(prompt, work_dir, max_iterations, milestone=milestone)
607
+ return (milestone, result)
608
+
609
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(tasks)) as executor:
610
+ futures = {executor.submit(_run_one_worktree, t): t for t in tasks}
611
+ for future in concurrent.futures.as_completed(futures):
612
+ milestone, result = future.result()
613
+ # Merge worktree back if it was used
614
+ wt_info = worktree_map.get(milestone.id)
615
+ if wt_info and result.resolved:
616
+ wt_path, branch = wt_info
617
+ merge_ok = _merge_worktree(project_dir, wt_path, branch, milestone.id)
618
+ if not merge_ok:
619
+ print(f" [Boris] WARNING: Merge conflict for {milestone.id} worktree", flush=True)
620
+ logger.warning("Worktree merge conflict for %s", milestone.id)
621
+ elif wt_info:
622
+ # Failed milestone - just clean up worktree
623
+ _cleanup_worktree(project_dir, wt_info[0], wt_info[1])
624
+ results.append((milestone, result))
625
+
626
+ else:
627
+ # No isolation or single task - original behavior
628
+ def _run_one(prompt_milestone):
629
+ prompt, milestone = prompt_milestone
630
+ result = run(prompt, project_dir, max_iterations, milestone=milestone)
631
+ return (milestone, result)
632
+
633
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(tasks)) as executor:
634
+ futures = {executor.submit(_run_one, t): t for t in tasks}
635
+ for future in concurrent.futures.as_completed(futures):
636
+ results.append(future.result())
439
637
 
440
638
  return results
441
639
 
file_lock.py ADDED
@@ -0,0 +1,123 @@
1
+ """File-level locking for parallel swarm workers.
2
+
3
+ Prevents parallel DaveLoop agents from corrupting shared files by providing
4
+ file-level locks via atomic file creation. Works on both Windows and Unix.
5
+ """
6
+ import json
7
+ import os
8
+ import time
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+
13
+ class FileLockManager:
14
+ """Manages file-level locks for parallel swarm workers.
15
+
16
+ Lock state is stored in .boris/locks/ in the project directory.
17
+ Each lock is an atomic file recording: owner (milestone ID), timestamp, file path.
18
+ """
19
+
20
+ def __init__(self, project_dir: str):
21
+ self.lock_dir = Path(project_dir) / ".boris" / "locks"
22
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ def _lock_path(self, filepath: str) -> Path:
25
+ """Get the lock file path for a given source file."""
26
+ normalized = os.path.normpath(filepath)
27
+ # Replace path separators with underscores for flat lock directory
28
+ safe_name = normalized.replace(os.sep, "_").replace("/", "_").replace("\\", "_")
29
+ return self.lock_dir / f"{safe_name}.lock"
30
+
31
+ @contextmanager
32
+ def lock_file(self, filepath: str, owner: str, timeout: int = 30):
33
+ """Acquire a lock on a file. Blocks until available or timeout.
34
+
35
+ Args:
36
+ filepath: The file to lock (relative or absolute path).
37
+ owner: Identifier for the lock owner (e.g. milestone ID).
38
+ timeout: Max seconds to wait for the lock.
39
+
40
+ Raises:
41
+ TimeoutError: If the lock cannot be acquired within timeout.
42
+ """
43
+ lock_path = self._lock_path(filepath)
44
+ start = time.time()
45
+
46
+ while True:
47
+ try:
48
+ # Atomic create-or-fail: 'x' mode fails if file exists
49
+ fd = open(lock_path, "x", encoding="utf-8")
50
+ fd.write(json.dumps({
51
+ "owner": owner,
52
+ "file": filepath,
53
+ "time": time.time(),
54
+ }))
55
+ fd.close()
56
+ break
57
+ except FileExistsError:
58
+ if time.time() - start > timeout:
59
+ # Read who holds the lock for better error messages
60
+ try:
61
+ holder = json.loads(lock_path.read_text(encoding="utf-8"))
62
+ holder_info = f" (held by {holder.get('owner', 'unknown')})"
63
+ except Exception:
64
+ holder_info = ""
65
+ raise TimeoutError(
66
+ f"Could not acquire lock on {filepath}{holder_info} "
67
+ f"after {timeout}s"
68
+ )
69
+ time.sleep(0.5)
70
+
71
+ try:
72
+ yield
73
+ finally:
74
+ try:
75
+ lock_path.unlink()
76
+ except FileNotFoundError:
77
+ pass
78
+
79
+ def get_locked_files(self) -> dict:
80
+ """Return dict of currently locked files and their owners."""
81
+ locks = {}
82
+ for lock_file in self.lock_dir.glob("*.lock"):
83
+ try:
84
+ data = json.loads(lock_file.read_text(encoding="utf-8"))
85
+ original_file = data.get("file", lock_file.stem.replace("_", os.sep))
86
+ locks[original_file] = data.get("owner", "unknown")
87
+ except (json.JSONDecodeError, KeyError, OSError):
88
+ pass
89
+ return locks
90
+
91
+ def is_locked(self, filepath: str) -> bool:
92
+ """Check if a file is currently locked."""
93
+ lock_path = self._lock_path(filepath)
94
+ return lock_path.exists()
95
+
96
+ def lock_owner(self, filepath: str) -> str:
97
+ """Return the owner of the lock on a file, or None if unlocked."""
98
+ lock_path = self._lock_path(filepath)
99
+ if not lock_path.exists():
100
+ return None
101
+ try:
102
+ data = json.loads(lock_path.read_text(encoding="utf-8"))
103
+ return data.get("owner")
104
+ except (json.JSONDecodeError, KeyError, OSError):
105
+ return None
106
+
107
+ def release_all(self, owner: str):
108
+ """Release all locks held by a specific owner (milestone cleanup)."""
109
+ for lock_file in self.lock_dir.glob("*.lock"):
110
+ try:
111
+ data = json.loads(lock_file.read_text(encoding="utf-8"))
112
+ if data.get("owner") == owner:
113
+ lock_file.unlink()
114
+ except (json.JSONDecodeError, KeyError, FileNotFoundError, OSError):
115
+ pass
116
+
117
+ def cleanup(self):
118
+ """Remove all lock files (use after all workers complete)."""
119
+ for lock_file in self.lock_dir.glob("*.lock"):
120
+ try:
121
+ lock_file.unlink()
122
+ except (FileNotFoundError, OSError):
123
+ pass
prompts.py CHANGED
@@ -29,17 +29,32 @@ _boris_prompt_cache = None
29
29
 
30
30
 
31
31
  def _load_boris_prompt() -> str:
32
- """Load Boris's management prompt from boris_prompt.md. Cached after first load."""
32
+ """Load Boris's management prompt from boris_prompt.md. Cached after first load.
33
+
34
+ Searches multiple locations to handle both editable installs (source dir)
35
+ and regular pip installs (site-packages).
36
+ """
33
37
  global _boris_prompt_cache
34
38
  if _boris_prompt_cache is not None:
35
39
  return _boris_prompt_cache
36
- try:
37
- with open(_BORIS_PROMPT_PATH, "r", encoding="utf-8") as f:
38
- _boris_prompt_cache = f.read().strip()
39
- logger.debug("Loaded Boris prompt: %d chars", len(_boris_prompt_cache))
40
- except FileNotFoundError:
41
- logger.warning("boris_prompt.md not found at %s", _BORIS_PROMPT_PATH)
42
- _boris_prompt_cache = ""
40
+
41
+ search_paths = [
42
+ _BORIS_PROMPT_PATH, # Next to this .py file (editable install / source)
43
+ os.path.join(sys.prefix, "boris_prompt.md"), # sys.prefix (some data_files installs)
44
+ os.path.join(os.path.dirname(shutil.which("boris") or ""), "boris_prompt.md"), # Next to boris script
45
+ ]
46
+
47
+ for path in search_paths:
48
+ try:
49
+ with open(path, "r", encoding="utf-8") as f:
50
+ _boris_prompt_cache = f.read().strip()
51
+ logger.debug("Loaded Boris prompt from %s (%d chars)", path, len(_boris_prompt_cache))
52
+ return _boris_prompt_cache
53
+ except (FileNotFoundError, OSError, TypeError):
54
+ continue
55
+
56
+ logger.warning("boris_prompt.md not found in any search path: %s", search_paths)
57
+ _boris_prompt_cache = ""
43
58
  return _boris_prompt_cache
44
59
 
45
60
  # Regex to strip ANSI escape codes
state.py CHANGED
@@ -67,13 +67,92 @@ class State:
67
67
 
68
68
 
69
69
  def save(state: State) -> None:
70
- """Save state to {project_dir}/.boris/state.json."""
70
+ """Save state to {project_dir}/.boris/state.json and plan.md."""
71
71
  state_dir = os.path.join(state.project_dir, config.STATE_DIR)
72
72
  os.makedirs(state_dir, exist_ok=True)
73
73
  state_path = os.path.join(state_dir, config.STATE_FILE)
74
74
  state.updated_at = datetime.now().isoformat()
75
75
  with open(state_path, "w", encoding="utf-8") as f:
76
76
  json.dump(asdict(state), f, indent=2, ensure_ascii=False)
77
+ # Also export a human-readable markdown plan
78
+ _save_plan_md(state)
79
+
80
+
81
+ def _save_plan_md(state: State) -> None:
82
+ """Export the plan as a readable markdown file in the project directory."""
83
+ plan = state.plan
84
+ if not plan or not plan.milestones:
85
+ return
86
+
87
+ status_icon = {
88
+ "completed": "[x]",
89
+ "in_progress": "[-]",
90
+ "skipped": "[~]",
91
+ "pending": "[ ]",
92
+ }
93
+
94
+ lines = []
95
+ lines.append(f"# Boris Plan")
96
+ lines.append("")
97
+ lines.append(f"**Task:** {plan.task}")
98
+ lines.append(f"**Created:** {plan.created_at}")
99
+ lines.append(f"**Updated:** {state.updated_at}")
100
+ lines.append(f"**Mode:** {'Turbo (parallel)' if state.turbo else 'Sequential'}")
101
+ lines.append("")
102
+
103
+ # Summary counts
104
+ counts = {}
105
+ for m in plan.milestones:
106
+ counts[m.status] = counts.get(m.status, 0) + 1
107
+ total = len(plan.milestones)
108
+ done = counts.get("completed", 0)
109
+ lines.append(f"**Progress:** {done}/{total} milestones completed")
110
+ if counts.get("skipped", 0):
111
+ lines.append(f"**Skipped:** {counts['skipped']}")
112
+ lines.append("")
113
+ lines.append("---")
114
+ lines.append("")
115
+
116
+ # Milestone list
117
+ lines.append("## Milestones")
118
+ lines.append("")
119
+ for m in plan.milestones:
120
+ icon = status_icon.get(m.status, "[ ]")
121
+ lines.append(f"### {icon} {m.id}: {m.title}")
122
+ lines.append("")
123
+ if m.depends_on:
124
+ lines.append(f"**Depends on:** {', '.join(m.depends_on)}")
125
+ lines.append(f"**Status:** {m.status}")
126
+ if m.completed_at:
127
+ lines.append(f"**Completed:** {m.completed_at}")
128
+ lines.append("")
129
+ lines.append(m.description)
130
+ lines.append("")
131
+
132
+ if m.acceptance_criteria:
133
+ lines.append("**Acceptance Criteria:**")
134
+ for ac in m.acceptance_criteria:
135
+ lines.append(f"- {ac}")
136
+ lines.append("")
137
+
138
+ if m.files_to_create:
139
+ lines.append(f"**Files to create:** {len(m.files_to_create)}")
140
+ for f_path in m.files_to_create:
141
+ lines.append(f"- `{f_path}`")
142
+ lines.append("")
143
+
144
+ if m.files_to_modify:
145
+ lines.append(f"**Files to modify:** {len(m.files_to_modify)}")
146
+ for f_path in m.files_to_modify:
147
+ lines.append(f"- `{f_path}`")
148
+ lines.append("")
149
+
150
+ lines.append("---")
151
+ lines.append("")
152
+
153
+ md_path = os.path.join(state.project_dir, config.STATE_DIR, "plan.md")
154
+ with open(md_path, "w", encoding="utf-8") as f:
155
+ f.write("\n".join(lines))
77
156
 
78
157
 
79
158
  def load(project_dir: str) -> Optional[State]:
@@ -1,12 +0,0 @@
1
- boris.py,sha256=xStvDOxKqbvQiNsQORr4qcD5wEiruNIcm7v_fFPjwZ8,44667
2
- config.py,sha256=KfFKyCGasdm1yBvIRFv-ykzA_oRo-zu1Euu9YC7V1Cg,324
3
- engine.py,sha256=LuVUwtlE1j8K3MOIH_pozB6Vy04_kPJs7Q0l9g-ePa8,27875
4
- git_manager.py,sha256=BuuTT4naPb5-jLhOik1xHM2ztzuKvJ_bnecZmlYgwFs,8493
5
- planner.py,sha256=UrU--kBvzvyD1gOVxIn-kdbJiu8tt4rcowsln66WkGw,5670
6
- prompts.py,sha256=oBVDZ0dG50FcSBa4F9GTkCPxmMB653BsK3RShoGcWUM,34182
7
- state.py,sha256=8MeUiDwL9ecgD9N6RhNUQf_P-qE7HeG3WlgL_SbRQic,3160
8
- borisxdave-0.3.0.dist-info/METADATA,sha256=qP3P-mpjm-BaQuJBXasveJ4L_wGNH9S-pIhNLAdNVd4,133
9
- borisxdave-0.3.0.dist-info/WHEEL,sha256=hPN0AlP2dZM_3ZJZWP4WooepkmU9wzjGgCLCeFjkHLA,92
10
- borisxdave-0.3.0.dist-info/entry_points.txt,sha256=a6FLWgxiQjGMJIRSV5sDxaaaaQchunm04ZuzX8N7-6I,61
11
- borisxdave-0.3.0.dist-info/top_level.txt,sha256=zNzzkbLJWzWpTjJTQsnbdOBypcA4XpioE1dEgWZVBx4,54
12
- borisxdave-0.3.0.dist-info/RECORD,,