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.
- Users/david/AppData/Local/Programs/Python/Python313/Lib/site-packages/boris_prompt.md +191 -0
- boris.py +427 -1
- {borisxdave-0.3.0.dist-info → borisxdave-0.3.1.dist-info}/METADATA +1 -1
- borisxdave-0.3.1.dist-info/RECORD +14 -0
- {borisxdave-0.3.0.dist-info → borisxdave-0.3.1.dist-info}/top_level.txt +1 -0
- engine.py +212 -14
- file_lock.py +123 -0
- prompts.py +23 -8
- state.py +80 -1
- borisxdave-0.3.0.dist-info/RECORD +0 -12
- {borisxdave-0.3.0.dist-info → borisxdave-0.3.1.dist-info}/WHEEL +0 -0
- {borisxdave-0.3.0.dist-info → borisxdave-0.3.1.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
-
|
|
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:
|
|
@@ -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,,
|
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.
|
|
419
|
+
f"DaveLoop keeps going off-rail. Terminating process."
|
|
336
420
|
)
|
|
337
421
|
print(f"\n {warn}\n", flush=True)
|
|
338
|
-
logger.warning(
|
|
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
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,,
|
|
File without changes
|
|
File without changes
|