borisxdave 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boris.py +672 -0
- borisxdave-0.2.0.dist-info/METADATA +6 -0
- borisxdave-0.2.0.dist-info/RECORD +12 -0
- borisxdave-0.2.0.dist-info/WHEEL +5 -0
- borisxdave-0.2.0.dist-info/entry_points.txt +2 -0
- borisxdave-0.2.0.dist-info/top_level.txt +7 -0
- config.py +12 -0
- engine.py +684 -0
- git_manager.py +248 -0
- planner.py +161 -0
- prompts.py +687 -0
- state.py +103 -0
git_manager.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Boris git manager - git operations for milestone tracking."""
|
|
2
|
+
import logging
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from state import Milestone
|
|
7
|
+
|
|
8
|
+
# Git constants (inlined from config.py)
|
|
9
|
+
GIT_COMMIT_PREFIX = "feat"
|
|
10
|
+
GIT_PUSH_RETRIES = 1
|
|
11
|
+
|
|
12
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
13
|
+
|
|
14
|
+
# Common kwargs for all git subprocess calls on Windows
|
|
15
|
+
_SUBPROCESS_ENCODING = {"encoding": "utf-8", "errors": "replace"}
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("boris.git_manager")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_git_config(project_dir: str) -> bool:
|
|
21
|
+
"""Verify git user.name and user.email are configured. Sets defaults if missing.
|
|
22
|
+
|
|
23
|
+
Returns True if config is ready (either already set or defaults applied).
|
|
24
|
+
"""
|
|
25
|
+
config_ok = True
|
|
26
|
+
for key, default in [("user.name", "Boris Orchestrator"), ("user.email", "boris@localhost")]:
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "config", key],
|
|
30
|
+
cwd=project_dir,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
**_SUBPROCESS_ENCODING,
|
|
34
|
+
)
|
|
35
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
36
|
+
logger.warning("Git config '%s' not set. Setting default: %s", key, default)
|
|
37
|
+
set_result = subprocess.run(
|
|
38
|
+
["git", "config", key, default],
|
|
39
|
+
cwd=project_dir,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
**_SUBPROCESS_ENCODING,
|
|
43
|
+
)
|
|
44
|
+
if set_result.returncode != 0:
|
|
45
|
+
logger.error("Failed to set git config '%s': %s", key, set_result.stderr)
|
|
46
|
+
config_ok = False
|
|
47
|
+
else:
|
|
48
|
+
logger.info("Set git config '%s' = '%s'", key, default)
|
|
49
|
+
else:
|
|
50
|
+
logger.debug("Git config '%s' already set: %s", key, result.stdout.strip())
|
|
51
|
+
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
52
|
+
logger.error("Error checking git config '%s': %s", key, e)
|
|
53
|
+
config_ok = False
|
|
54
|
+
return config_ok
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_repo(project_dir: str) -> bool:
|
|
58
|
+
"""Initialize git repo if not already one. Returns success."""
|
|
59
|
+
try:
|
|
60
|
+
# Check if already a git repo
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
63
|
+
cwd=project_dir,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
**_SUBPROCESS_ENCODING,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
logger.info("Git repo already initialized in %s", project_dir)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
# Initialize new repo
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["git", "init"],
|
|
75
|
+
cwd=project_dir,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
**_SUBPROCESS_ENCODING,
|
|
79
|
+
)
|
|
80
|
+
if result.returncode == 0:
|
|
81
|
+
logger.info("Initialized git repo in %s", project_dir)
|
|
82
|
+
return True
|
|
83
|
+
else:
|
|
84
|
+
logger.warning("Failed to init git repo: %s", result.stderr)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
88
|
+
logger.warning("Git init error: %s", e)
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def commit_milestone(project_dir: str, milestone: Milestone) -> bool:
|
|
93
|
+
"""Stage all changes and commit with milestone message. Returns success."""
|
|
94
|
+
try:
|
|
95
|
+
# git add -A
|
|
96
|
+
add_result = subprocess.run(
|
|
97
|
+
["git", "add", "-A"],
|
|
98
|
+
cwd=project_dir,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
**_SUBPROCESS_ENCODING,
|
|
102
|
+
)
|
|
103
|
+
if add_result.returncode != 0:
|
|
104
|
+
logger.warning("git add failed: %s", add_result.stderr)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# git commit
|
|
108
|
+
message = f"{GIT_COMMIT_PREFIX}(milestone-{milestone.id}): {milestone.title}"
|
|
109
|
+
commit_result = subprocess.run(
|
|
110
|
+
["git", "commit", "-m", message],
|
|
111
|
+
cwd=project_dir,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
**_SUBPROCESS_ENCODING,
|
|
115
|
+
)
|
|
116
|
+
if commit_result.returncode != 0:
|
|
117
|
+
# Could be "nothing to commit"
|
|
118
|
+
if "nothing to commit" in commit_result.stdout:
|
|
119
|
+
logger.info("Nothing to commit for milestone %s", milestone.id)
|
|
120
|
+
return True
|
|
121
|
+
logger.warning("git commit failed: %s", commit_result.stderr)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
logger.info("Committed milestone %s: %s", milestone.id, milestone.title)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
128
|
+
logger.warning("Git commit error: %s", e)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def push(project_dir: str, remote: str = "origin") -> bool:
|
|
133
|
+
"""Push to remote with retry logic. Returns success."""
|
|
134
|
+
if not has_remote(project_dir):
|
|
135
|
+
logger.info("No remote configured, skipping push")
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
for attempt in range(GIT_PUSH_RETRIES + 1):
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["git", "push", remote],
|
|
142
|
+
cwd=project_dir,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=60,
|
|
146
|
+
**_SUBPROCESS_ENCODING,
|
|
147
|
+
)
|
|
148
|
+
if result.returncode == 0:
|
|
149
|
+
logger.info("Pushed to %s", remote)
|
|
150
|
+
return True
|
|
151
|
+
else:
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Push attempt %d failed: %s", attempt + 1, result.stderr
|
|
154
|
+
)
|
|
155
|
+
except (subprocess.SubprocessError, subprocess.TimeoutExpired) as e:
|
|
156
|
+
logger.warning("Push attempt %d error: %s", attempt + 1, e)
|
|
157
|
+
|
|
158
|
+
logger.warning("All push attempts failed")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def setup_remote(project_dir: str, remote_url: str) -> bool:
|
|
163
|
+
"""Set up or update the origin remote. Returns success."""
|
|
164
|
+
try:
|
|
165
|
+
# Check if origin already exists
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
["git", "remote", "get-url", "origin"],
|
|
168
|
+
cwd=project_dir,
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
**_SUBPROCESS_ENCODING,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if result.returncode == 0:
|
|
175
|
+
# Origin exists, update URL
|
|
176
|
+
set_result = subprocess.run(
|
|
177
|
+
["git", "remote", "set-url", "origin", remote_url],
|
|
178
|
+
cwd=project_dir,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
**_SUBPROCESS_ENCODING,
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
# Origin doesn't exist, add it
|
|
185
|
+
set_result = subprocess.run(
|
|
186
|
+
["git", "remote", "add", "origin", remote_url],
|
|
187
|
+
cwd=project_dir,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True,
|
|
190
|
+
**_SUBPROCESS_ENCODING,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if set_result.returncode == 0:
|
|
194
|
+
logger.info("Remote origin set to %s", remote_url)
|
|
195
|
+
return True
|
|
196
|
+
else:
|
|
197
|
+
logger.warning("Failed to set remote: %s", set_result.stderr)
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
201
|
+
logger.warning("Git remote setup error: %s", e)
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def has_remote(project_dir: str) -> bool:
|
|
206
|
+
"""Check if a remote is configured."""
|
|
207
|
+
try:
|
|
208
|
+
result = subprocess.run(
|
|
209
|
+
["git", "remote"],
|
|
210
|
+
cwd=project_dir,
|
|
211
|
+
capture_output=True,
|
|
212
|
+
text=True,
|
|
213
|
+
**_SUBPROCESS_ENCODING,
|
|
214
|
+
)
|
|
215
|
+
return result.returncode == 0 and result.stdout.strip() != ""
|
|
216
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def final_push(project_dir: str, remote: str = "origin") -> bool:
|
|
221
|
+
"""Commit any remaining changes and push. Returns success."""
|
|
222
|
+
try:
|
|
223
|
+
# Stage any remaining changes
|
|
224
|
+
subprocess.run(
|
|
225
|
+
["git", "add", "-A"],
|
|
226
|
+
cwd=project_dir,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
text=True,
|
|
229
|
+
**_SUBPROCESS_ENCODING,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Commit if there are changes
|
|
233
|
+
commit_result = subprocess.run(
|
|
234
|
+
["git", "commit", "-m", "chore: Boris orchestration complete"],
|
|
235
|
+
cwd=project_dir,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
**_SUBPROCESS_ENCODING,
|
|
239
|
+
)
|
|
240
|
+
if commit_result.returncode != 0 and "nothing to commit" not in commit_result.stdout:
|
|
241
|
+
logger.warning("Final commit failed: %s", commit_result.stderr)
|
|
242
|
+
|
|
243
|
+
# Push
|
|
244
|
+
return push(project_dir, remote)
|
|
245
|
+
|
|
246
|
+
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
247
|
+
logger.warning("Final push error: %s", e)
|
|
248
|
+
return False
|
planner.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
|
|
2
|
+
"""Boris planner - task breakdown via Claude CLI."""
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import config
|
|
12
|
+
from state import Milestone, Plan
|
|
13
|
+
|
|
14
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_plan(task: str, project_dir: str) -> Plan:
|
|
20
|
+
"""Break a task into milestones using Claude CLI."""
|
|
21
|
+
file_listing = _list_project_files(project_dir)
|
|
22
|
+
|
|
23
|
+
prompt = (
|
|
24
|
+
"You are a technical project planner. Break this task into ordered milestones.\n"
|
|
25
|
+
"Each milestone must be a concrete, buildable unit that DaveLoop can execute independently.\n"
|
|
26
|
+
"Order by dependency: foundation first, then features that depend on it.\n\n"
|
|
27
|
+
f"Task: {task}\n"
|
|
28
|
+
f"Project directory: {project_dir}\n"
|
|
29
|
+
f"Existing files:\n{file_listing}\n\n"
|
|
30
|
+
"Return ONLY a JSON array (no other text) of milestones:\n"
|
|
31
|
+
"[\n"
|
|
32
|
+
" {\n"
|
|
33
|
+
' "id": "M1",\n'
|
|
34
|
+
' "title": "Short title",\n'
|
|
35
|
+
' "description": "Detailed description of what to build",\n'
|
|
36
|
+
' "depends_on": [],\n'
|
|
37
|
+
' "acceptance_criteria": ["criterion 1", "criterion 2"],\n'
|
|
38
|
+
' "files_to_create": ["file1.py"],\n'
|
|
39
|
+
' "files_to_modify": []\n'
|
|
40
|
+
" }\n"
|
|
41
|
+
"]"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[config.CLAUDE_CMD, "-p"],
|
|
47
|
+
input=prompt,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=300,
|
|
51
|
+
shell=IS_WINDOWS,
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
errors="replace",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise RuntimeError(f"Claude CLI failed (exit {result.returncode}): {result.stderr}")
|
|
58
|
+
|
|
59
|
+
response = result.stdout.strip()
|
|
60
|
+
|
|
61
|
+
# Handle markdown code blocks - strip ```json and ``` if present
|
|
62
|
+
response = re.sub(r"^```(?:json)?\s*\n?", "", response)
|
|
63
|
+
response = re.sub(r"\n?```\s*$", "", response)
|
|
64
|
+
response = response.strip()
|
|
65
|
+
|
|
66
|
+
milestones_data = json.loads(response)
|
|
67
|
+
|
|
68
|
+
milestones = [
|
|
69
|
+
Milestone(
|
|
70
|
+
id=m["id"],
|
|
71
|
+
title=m["title"],
|
|
72
|
+
description=m["description"],
|
|
73
|
+
depends_on=m.get("depends_on", []),
|
|
74
|
+
acceptance_criteria=m.get("acceptance_criteria", []),
|
|
75
|
+
files_to_create=m.get("files_to_create", []),
|
|
76
|
+
files_to_modify=m.get("files_to_modify", []),
|
|
77
|
+
)
|
|
78
|
+
for m in milestones_data
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
plan = Plan(task=task, milestones=milestones)
|
|
82
|
+
|
|
83
|
+
# Save plan as markdown
|
|
84
|
+
plan_path = _save_plan_markdown(plan, task)
|
|
85
|
+
logger.info("Plan saved to %s", plan_path)
|
|
86
|
+
|
|
87
|
+
return plan
|
|
88
|
+
|
|
89
|
+
except json.JSONDecodeError as e:
|
|
90
|
+
raise RuntimeError(f"Failed to parse Claude's plan response as JSON: {e}") from e
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
raise RuntimeError("Claude CLI timed out while generating plan")
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
f"'{config.CLAUDE_CMD}' command not found. Is Claude CLI installed?"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _save_plan_markdown(plan: Plan, task: str) -> str:
|
|
100
|
+
"""Save a human-readable markdown version of the plan. Returns filepath."""
|
|
101
|
+
os.makedirs(config.PLANS_DIR, exist_ok=True)
|
|
102
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
103
|
+
filename = f"plan_{timestamp}.md"
|
|
104
|
+
filepath = os.path.join(config.PLANS_DIR, filename)
|
|
105
|
+
|
|
106
|
+
lines = [
|
|
107
|
+
f"# Boris Plan",
|
|
108
|
+
f"",
|
|
109
|
+
f"**Task:** {task}",
|
|
110
|
+
f"**Created:** {plan.created_at}",
|
|
111
|
+
f"**Milestones:** {len(plan.milestones)}",
|
|
112
|
+
f"",
|
|
113
|
+
f"---",
|
|
114
|
+
f"",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
for m in plan.milestones:
|
|
118
|
+
lines.append(f"## {m.id}: {m.title}")
|
|
119
|
+
lines.append(f"")
|
|
120
|
+
lines.append(f"{m.description}")
|
|
121
|
+
lines.append(f"")
|
|
122
|
+
if m.depends_on:
|
|
123
|
+
lines.append(f"**Depends on:** {', '.join(m.depends_on)}")
|
|
124
|
+
lines.append(f"")
|
|
125
|
+
lines.append(f"**Acceptance Criteria:**")
|
|
126
|
+
for c in m.acceptance_criteria:
|
|
127
|
+
lines.append(f"- {c}")
|
|
128
|
+
lines.append(f"")
|
|
129
|
+
if m.files_to_create:
|
|
130
|
+
lines.append(f"**Files to create:** {', '.join(m.files_to_create)}")
|
|
131
|
+
if m.files_to_modify:
|
|
132
|
+
lines.append(f"**Files to modify:** {', '.join(m.files_to_modify)}")
|
|
133
|
+
lines.append(f"")
|
|
134
|
+
lines.append(f"---")
|
|
135
|
+
lines.append(f"")
|
|
136
|
+
|
|
137
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
138
|
+
f.write("\n".join(lines))
|
|
139
|
+
|
|
140
|
+
return filepath
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _list_project_files(project_dir: str) -> str:
|
|
144
|
+
"""Walk project_dir and return formatted file listing."""
|
|
145
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".boris", ".venv", "venv"}
|
|
146
|
+
file_list = []
|
|
147
|
+
|
|
148
|
+
for root, dirs, files in os.walk(project_dir):
|
|
149
|
+
# Filter out hidden and skip dirs
|
|
150
|
+
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
|
|
151
|
+
rel_root = os.path.relpath(root, project_dir)
|
|
152
|
+
for fname in sorted(files):
|
|
153
|
+
if rel_root == ".":
|
|
154
|
+
file_list.append(fname)
|
|
155
|
+
else:
|
|
156
|
+
file_list.append(os.path.join(rel_root, fname))
|
|
157
|
+
|
|
158
|
+
if not file_list:
|
|
159
|
+
return "(empty project)"
|
|
160
|
+
|
|
161
|
+
return "\n".join(f" {f}" for f in file_list)
|