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.
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)