bumblebee-cli 0.1.1 → 0.3.0
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.
- package/dist/index.js +4214 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -28
- package/templates/skills/bb-agent/SKILL.md +180 -0
- package/templates/skills/bb-agent/references/bb-commands.md +124 -0
- package/templates/skills/bb-agent/references/investigate-workflow.md +150 -0
- package/templates/skills/bb-agent/references/parallel-workflow.md +105 -0
- package/templates/skills/bb-agent/references/prompts.md +144 -0
- package/templates/skills/bb-agent/references/status-transitions.md +93 -0
- package/templates/skills/bb-agent/references/workflow.md +178 -0
- package/README.md +0 -47
- package/bin/bb.mjs +0 -132
- package/python/bb_cli/__init__.py +0 -0
- package/python/bb_cli/api_client.py +0 -38
- package/python/bb_cli/bumblebee_cli.egg-info/PKG-INFO +0 -9
- package/python/bb_cli/bumblebee_cli.egg-info/SOURCES.txt +0 -21
- package/python/bb_cli/bumblebee_cli.egg-info/dependency_links.txt +0 -1
- package/python/bb_cli/bumblebee_cli.egg-info/entry_points.txt +0 -2
- package/python/bb_cli/bumblebee_cli.egg-info/requires.txt +0 -4
- package/python/bb_cli/bumblebee_cli.egg-info/top_level.txt +0 -5
- package/python/bb_cli/commands/__init__.py +0 -0
- package/python/bb_cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/agent.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/auth.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/board.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/comment.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/init.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/item.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/label.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/project.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/sprint.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/story.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/__pycache__/task.cpython-313.pyc +0 -0
- package/python/bb_cli/commands/agent.py +0 -1030
- package/python/bb_cli/commands/auth.py +0 -79
- package/python/bb_cli/commands/board.py +0 -47
- package/python/bb_cli/commands/comment.py +0 -34
- package/python/bb_cli/commands/init.py +0 -62
- package/python/bb_cli/commands/item.py +0 -192
- package/python/bb_cli/commands/label.py +0 -43
- package/python/bb_cli/commands/project.py +0 -111
- package/python/bb_cli/commands/sprint.py +0 -84
- package/python/bb_cli/config.py +0 -136
- package/python/bb_cli/main.py +0 -44
- package/python/pyproject.toml +0 -18
- package/scripts/build.sh +0 -20
- package/scripts/postinstall.mjs +0 -146
|
@@ -1,1030 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import typer
|
|
9
|
-
from rich import print as rprint
|
|
10
|
-
from rich.markdown import Markdown
|
|
11
|
-
from rich.panel import Panel
|
|
12
|
-
from rich.table import Table
|
|
13
|
-
|
|
14
|
-
from ..api_client import api_get, api_post, api_put
|
|
15
|
-
from ..config import CONFIG_DIR, get_api_url, get_current_project, get_project_path, get_token
|
|
16
|
-
from .item import _resolve_item
|
|
17
|
-
|
|
18
|
-
app = typer.Typer(help="Agent session management")
|
|
19
|
-
|
|
20
|
-
WORKTREES_DIR = CONFIG_DIR / "worktrees"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# ---------------------------------------------------------------------------
|
|
24
|
-
# Helpers
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _require_project() -> str:
|
|
29
|
-
slug = get_current_project()
|
|
30
|
-
if not slug:
|
|
31
|
-
rprint("[red]No project selected. Run [bold]bb project switch <slug>[/bold] first.[/red]")
|
|
32
|
-
raise typer.Exit(1)
|
|
33
|
-
return slug
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _require_project_path(slug: str) -> str:
|
|
37
|
-
path = get_project_path(slug)
|
|
38
|
-
if not path:
|
|
39
|
-
rprint("[red]No source code directory linked to this project.[/red]")
|
|
40
|
-
rprint("[yellow]Run [bold]bb project link <path>[/bold] to set it.[/yellow]")
|
|
41
|
-
raise typer.Exit(1)
|
|
42
|
-
if not Path(path).is_dir():
|
|
43
|
-
rprint(f"[red]Linked directory not found: {path}[/red]")
|
|
44
|
-
raise typer.Exit(1)
|
|
45
|
-
return path
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _read_knowledge(project_path: str) -> str:
|
|
49
|
-
"""Read knowledge base files from the project directory."""
|
|
50
|
-
candidates = [
|
|
51
|
-
"CLAUDE.md",
|
|
52
|
-
"docs/knowledge.md",
|
|
53
|
-
".claude/lessons-learned.md",
|
|
54
|
-
]
|
|
55
|
-
parts = []
|
|
56
|
-
for rel in candidates:
|
|
57
|
-
fp = Path(project_path) / rel
|
|
58
|
-
if fp.exists():
|
|
59
|
-
try:
|
|
60
|
-
text = fp.read_text(encoding="utf-8", errors="replace").strip()
|
|
61
|
-
if text:
|
|
62
|
-
parts.append(f"### {rel}\n\n{text}")
|
|
63
|
-
except Exception:
|
|
64
|
-
pass
|
|
65
|
-
return "\n\n---\n\n".join(parts)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _get_item_comments(item_id: str) -> list[dict]:
|
|
69
|
-
try:
|
|
70
|
-
return api_get(f"/api/work-items/{item_id}/comments")
|
|
71
|
-
except Exception:
|
|
72
|
-
return []
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _claude_env() -> dict[str, str]:
|
|
76
|
-
"""Return env dict with CLAUDECODE unset so we can spawn a new Claude session."""
|
|
77
|
-
env = os.environ.copy()
|
|
78
|
-
env.pop("CLAUDECODE", None)
|
|
79
|
-
return env
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _format_comments_context(comments: list[dict]) -> str:
|
|
83
|
-
if not comments:
|
|
84
|
-
return ""
|
|
85
|
-
parts = ["## Previous Comments / Progress"]
|
|
86
|
-
for c in comments:
|
|
87
|
-
author = c.get("author", "unknown")
|
|
88
|
-
ctype = c.get("type", "discussion")
|
|
89
|
-
tag = f" [{ctype}]" if ctype != "discussion" else ""
|
|
90
|
-
body = c.get("body", "")
|
|
91
|
-
created = c.get("created_at", "")
|
|
92
|
-
parts.append(f"\n### {author}{tag} -- {created}\n{body}")
|
|
93
|
-
return "\n".join(parts)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# ---------------------------------------------------------------------------
|
|
97
|
-
# Prompt builders
|
|
98
|
-
# ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _build_suggest_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
|
|
102
|
-
"""Phase 1 prompt -- analyse only, no code changes."""
|
|
103
|
-
key = item.get("key") or f"#{item['number']}"
|
|
104
|
-
parts = [
|
|
105
|
-
f"You are analysing {item['type']} {key}: {item['title']}",
|
|
106
|
-
"",
|
|
107
|
-
f"Type: {item['type']} | Priority: {item['priority']} | Status: {item['status']}",
|
|
108
|
-
]
|
|
109
|
-
if item.get("description"):
|
|
110
|
-
parts.extend(["", "## Description", item["description"]])
|
|
111
|
-
if item.get("acceptance_criteria"):
|
|
112
|
-
parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
|
|
113
|
-
if item.get("plan"):
|
|
114
|
-
parts.extend(["", "## Existing Plan", item["plan"]])
|
|
115
|
-
if comments_ctx:
|
|
116
|
-
parts.extend(["", comments_ctx])
|
|
117
|
-
if knowledge:
|
|
118
|
-
parts.extend(["", "## Project Knowledge Base", knowledge])
|
|
119
|
-
parts.extend([
|
|
120
|
-
"",
|
|
121
|
-
"## Your Task",
|
|
122
|
-
"",
|
|
123
|
-
"Analyse this work item **and** the project source code. Return a Markdown plan:",
|
|
124
|
-
"",
|
|
125
|
-
"1. **Root Cause / Analysis** -- what needs to change and why",
|
|
126
|
-
"2. **Files to Modify** -- list every file with a short description of the change",
|
|
127
|
-
"3. **Implementation Steps** -- numbered, concrete steps",
|
|
128
|
-
"4. **Testing Strategy** -- how to verify the changes",
|
|
129
|
-
"5. **Risks & Considerations** -- edge cases, breaking changes",
|
|
130
|
-
"",
|
|
131
|
-
"IMPORTANT: Do NOT modify any files. Only analyse and produce the plan.",
|
|
132
|
-
])
|
|
133
|
-
return "\n".join(parts)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _build_execute_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
|
|
137
|
-
"""Phase 2 prompt -- implement the changes."""
|
|
138
|
-
key = item.get("key") or f"#{item['number']}"
|
|
139
|
-
parts = [
|
|
140
|
-
f"You are implementing {item['type']} {key}: {item['title']}",
|
|
141
|
-
"",
|
|
142
|
-
f"Type: {item['type']} | Priority: {item['priority']}",
|
|
143
|
-
]
|
|
144
|
-
if item.get("description"):
|
|
145
|
-
parts.extend(["", "## Description", item["description"]])
|
|
146
|
-
if item.get("acceptance_criteria"):
|
|
147
|
-
parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
|
|
148
|
-
if item.get("plan"):
|
|
149
|
-
parts.extend(["", "## Implementation Plan", item["plan"]])
|
|
150
|
-
if comments_ctx:
|
|
151
|
-
parts.extend(["", comments_ctx])
|
|
152
|
-
if knowledge:
|
|
153
|
-
parts.extend(["", "## Project Knowledge Base", knowledge])
|
|
154
|
-
parts.extend([
|
|
155
|
-
"",
|
|
156
|
-
"## Instructions",
|
|
157
|
-
"",
|
|
158
|
-
"Implement the changes described in the plan / comments above.",
|
|
159
|
-
"",
|
|
160
|
-
"1. Follow the project's existing coding conventions and patterns",
|
|
161
|
-
"2. Work through changes one file at a time",
|
|
162
|
-
"3. Run existing tests after your changes and fix any failures",
|
|
163
|
-
"4. Add new tests where appropriate",
|
|
164
|
-
"5. Commit your work with a clear, descriptive commit message",
|
|
165
|
-
"6. If you hit a blocker, document it clearly so the next run can continue",
|
|
166
|
-
])
|
|
167
|
-
return "\n".join(parts)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
# ---------------------------------------------------------------------------
|
|
171
|
-
# Git worktree utilities
|
|
172
|
-
# ---------------------------------------------------------------------------
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _worktree_path(slug: str, item_number: int) -> Path:
|
|
176
|
-
return WORKTREES_DIR / slug / f"item-{item_number}"
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _create_worktree(project_path: str, slug: str, item_number: int) -> tuple[str, str]:
|
|
180
|
-
"""Create (or reuse) a git worktree. Returns (worktree_path, branch_name)."""
|
|
181
|
-
branch = f"bb/item-{item_number}"
|
|
182
|
-
wt = _worktree_path(slug, item_number)
|
|
183
|
-
wt.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
-
|
|
185
|
-
# Already exists and valid?
|
|
186
|
-
if wt.exists():
|
|
187
|
-
probe = subprocess.run(
|
|
188
|
-
["git", "worktree", "list", "--porcelain"],
|
|
189
|
-
cwd=project_path, capture_output=True, text=True,
|
|
190
|
-
)
|
|
191
|
-
# Normalise to forward-slash for reliable comparison
|
|
192
|
-
wt_norm = str(wt).replace("\\", "/")
|
|
193
|
-
if any(wt_norm in ln.replace("\\", "/") for ln in probe.stdout.splitlines()):
|
|
194
|
-
return str(wt), branch
|
|
195
|
-
# Stale -- clean up
|
|
196
|
-
subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
|
|
197
|
-
if wt.exists():
|
|
198
|
-
shutil.rmtree(wt)
|
|
199
|
-
|
|
200
|
-
# Does branch already exist?
|
|
201
|
-
check = subprocess.run(
|
|
202
|
-
["git", "rev-parse", "--verify", branch],
|
|
203
|
-
cwd=project_path, capture_output=True, text=True,
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
if check.returncode == 0:
|
|
207
|
-
subprocess.run(
|
|
208
|
-
["git", "worktree", "add", str(wt), branch],
|
|
209
|
-
cwd=project_path, check=True, capture_output=True, text=True,
|
|
210
|
-
)
|
|
211
|
-
else:
|
|
212
|
-
subprocess.run(
|
|
213
|
-
["git", "worktree", "add", "-b", branch, str(wt)],
|
|
214
|
-
cwd=project_path, check=True, capture_output=True, text=True,
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
return str(wt), branch
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _remove_worktree(project_path: str, wt_path: str):
|
|
221
|
-
subprocess.run(
|
|
222
|
-
["git", "worktree", "remove", "--force", wt_path],
|
|
223
|
-
cwd=project_path, capture_output=True,
|
|
224
|
-
)
|
|
225
|
-
subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
# ---------------------------------------------------------------------------
|
|
229
|
-
# Batch internal helpers (thread-safe, no rich print inside workers)
|
|
230
|
-
# ---------------------------------------------------------------------------
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _suggest_one(slug: str, project_path: str, id_or_number: str) -> dict:
|
|
234
|
-
"""Run suggest for a single item. Thread-safe — returns result dict."""
|
|
235
|
-
try:
|
|
236
|
-
item = _resolve_item(slug, id_or_number)
|
|
237
|
-
except Exception as e:
|
|
238
|
-
return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
|
|
239
|
-
|
|
240
|
-
item_id = item["id"]
|
|
241
|
-
key = item.get("key") or f"#{item['number']}"
|
|
242
|
-
|
|
243
|
-
knowledge = _read_knowledge(project_path)
|
|
244
|
-
comments = _get_item_comments(item_id)
|
|
245
|
-
comments_ctx = _format_comments_context(comments)
|
|
246
|
-
prompt = _build_suggest_prompt(item, knowledge, comments_ctx)
|
|
247
|
-
|
|
248
|
-
try:
|
|
249
|
-
result = subprocess.run(
|
|
250
|
-
["claude", "-p", prompt, "--output-format", "text"],
|
|
251
|
-
cwd=project_path,
|
|
252
|
-
capture_output=True,
|
|
253
|
-
text=True,
|
|
254
|
-
timeout=600,
|
|
255
|
-
env=_claude_env(),
|
|
256
|
-
)
|
|
257
|
-
except FileNotFoundError:
|
|
258
|
-
return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
|
|
259
|
-
except subprocess.TimeoutExpired:
|
|
260
|
-
return {"key": key, "status": "failed", "error": "Timed out (10 min)"}
|
|
261
|
-
|
|
262
|
-
if result.returncode != 0:
|
|
263
|
-
return {"key": key, "status": "failed", "error": result.stderr[:200]}
|
|
264
|
-
|
|
265
|
-
suggestion = result.stdout.strip()
|
|
266
|
-
if not suggestion:
|
|
267
|
-
return {"key": key, "status": "failed", "error": "Empty response"}
|
|
268
|
-
|
|
269
|
-
# Post comment
|
|
270
|
-
api_post(f"/api/work-items/{item_id}/comments", json={
|
|
271
|
-
"body": suggestion,
|
|
272
|
-
"author": "bb-agent",
|
|
273
|
-
"type": "proposal",
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
# Advance status
|
|
277
|
-
if item["status"] == "open":
|
|
278
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "confirmed"})
|
|
279
|
-
|
|
280
|
-
return {"key": key, "status": "ok", "suggestion": suggestion[:200]}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _execute_one(slug: str, project_path: str, id_or_number: str) -> dict:
|
|
284
|
-
"""Run execute for a single item in its own worktree. Thread-safe."""
|
|
285
|
-
try:
|
|
286
|
-
item = _resolve_item(slug, id_or_number)
|
|
287
|
-
except Exception as e:
|
|
288
|
-
return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
|
|
289
|
-
|
|
290
|
-
item_id = item["id"]
|
|
291
|
-
item_number = item["number"]
|
|
292
|
-
key = item.get("key") or f"#{item_number}"
|
|
293
|
-
knowledge = _read_knowledge(project_path)
|
|
294
|
-
comments = _get_item_comments(item_id)
|
|
295
|
-
comments_ctx = _format_comments_context(comments)
|
|
296
|
-
prompt = _build_execute_prompt(item, knowledge, comments_ctx)
|
|
297
|
-
|
|
298
|
-
# Create worktree
|
|
299
|
-
try:
|
|
300
|
-
work_dir, branch_name = _create_worktree(project_path, slug, item_number)
|
|
301
|
-
except subprocess.CalledProcessError as e:
|
|
302
|
-
return {"key": key, "status": "failed", "error": f"Worktree failed: {e.stderr or e}"}
|
|
303
|
-
|
|
304
|
-
# Agent session
|
|
305
|
-
try:
|
|
306
|
-
session = api_post(
|
|
307
|
-
"/api/agent-sessions/start",
|
|
308
|
-
json={"work_item_id": item_id, "origin": "cli"},
|
|
309
|
-
params={"project_slug": slug},
|
|
310
|
-
)
|
|
311
|
-
session_id = session["id"]
|
|
312
|
-
except Exception as e:
|
|
313
|
-
return {"key": key, "status": "failed", "error": f"Session start failed: {e}", "branch": branch_name}
|
|
314
|
-
|
|
315
|
-
# Status -> in_progress
|
|
316
|
-
if item["status"] in ("open", "confirmed", "approved"):
|
|
317
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
|
|
318
|
-
|
|
319
|
-
# MCP config
|
|
320
|
-
api_url = get_api_url()
|
|
321
|
-
token = get_token()
|
|
322
|
-
mcp_cfg = json.dumps({
|
|
323
|
-
"mcpServers": {
|
|
324
|
-
"bumblebee": {
|
|
325
|
-
"url": f"{api_url}/mcp",
|
|
326
|
-
"headers": {"Authorization": f"Bearer {token}"} if token else {},
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
try:
|
|
332
|
-
proc = subprocess.Popen(
|
|
333
|
-
[
|
|
334
|
-
"claude",
|
|
335
|
-
"--output-format", "stream-json",
|
|
336
|
-
"--verbose",
|
|
337
|
-
"--permission-mode", "bypassPermissions",
|
|
338
|
-
"--mcp-config", "-",
|
|
339
|
-
"-p", prompt,
|
|
340
|
-
],
|
|
341
|
-
cwd=work_dir,
|
|
342
|
-
stdin=subprocess.PIPE,
|
|
343
|
-
stdout=subprocess.PIPE,
|
|
344
|
-
stderr=subprocess.PIPE,
|
|
345
|
-
text=True,
|
|
346
|
-
env=_claude_env(),
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
proc.stdin.write(mcp_cfg)
|
|
350
|
-
proc.stdin.close()
|
|
351
|
-
|
|
352
|
-
text_blocks: list[str] = []
|
|
353
|
-
for line in proc.stdout:
|
|
354
|
-
line = line.strip()
|
|
355
|
-
if not line:
|
|
356
|
-
continue
|
|
357
|
-
try:
|
|
358
|
-
payload = json.loads(line)
|
|
359
|
-
if payload.get("type") == "assistant":
|
|
360
|
-
for block in payload.get("content", []):
|
|
361
|
-
if block.get("type") == "text":
|
|
362
|
-
text_blocks.append(block["text"])
|
|
363
|
-
try:
|
|
364
|
-
api_post(f"/api/agent-sessions/{session_id}/relay", json=payload)
|
|
365
|
-
except Exception:
|
|
366
|
-
pass
|
|
367
|
-
except json.JSONDecodeError:
|
|
368
|
-
pass
|
|
369
|
-
|
|
370
|
-
proc.wait()
|
|
371
|
-
|
|
372
|
-
# Post completion comment
|
|
373
|
-
tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
|
|
374
|
-
body_lines = [
|
|
375
|
-
"## Agent Execution Report\n",
|
|
376
|
-
f"**Branch**: `{branch_name}`\n",
|
|
377
|
-
f"**Exit code**: `{proc.returncode}`\n",
|
|
378
|
-
f"\n### Output (last messages)\n\n{tail}",
|
|
379
|
-
]
|
|
380
|
-
api_post(f"/api/work-items/{item_id}/comments", json={
|
|
381
|
-
"body": "\n".join(body_lines),
|
|
382
|
-
"author": "bb-agent",
|
|
383
|
-
"type": "agent_output",
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
if proc.returncode == 0:
|
|
387
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
|
|
388
|
-
return {"key": key, "status": "ok", "branch": branch_name, "worktree": work_dir}
|
|
389
|
-
else:
|
|
390
|
-
return {"key": key, "status": "failed", "error": f"Exit code {proc.returncode}", "branch": branch_name}
|
|
391
|
-
|
|
392
|
-
except FileNotFoundError:
|
|
393
|
-
return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
# ---------------------------------------------------------------------------
|
|
397
|
-
# Commands
|
|
398
|
-
# ---------------------------------------------------------------------------
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
@app.command()
|
|
402
|
-
def suggest(
|
|
403
|
-
id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to analyse"),
|
|
404
|
-
):
|
|
405
|
-
"""Phase 1: Analyse a work item and post a solution plan as a comment."""
|
|
406
|
-
slug = _require_project()
|
|
407
|
-
project_path = _require_project_path(slug)
|
|
408
|
-
|
|
409
|
-
rprint(f"[cyan]Fetching work item {id_or_number}...[/cyan]")
|
|
410
|
-
item = _resolve_item(slug, id_or_number)
|
|
411
|
-
item_id = item["id"]
|
|
412
|
-
knowledge = _read_knowledge(project_path)
|
|
413
|
-
comments = _get_item_comments(item_id)
|
|
414
|
-
comments_ctx = _format_comments_context(comments)
|
|
415
|
-
prompt = _build_suggest_prompt(item, knowledge, comments_ctx)
|
|
416
|
-
|
|
417
|
-
rprint(f"[cyan]Running Claude Code analysis in {project_path}...[/cyan]")
|
|
418
|
-
|
|
419
|
-
try:
|
|
420
|
-
result = subprocess.run(
|
|
421
|
-
["claude", "-p", prompt, "--output-format", "text"],
|
|
422
|
-
cwd=project_path,
|
|
423
|
-
capture_output=True,
|
|
424
|
-
text=True,
|
|
425
|
-
timeout=600,
|
|
426
|
-
env=_claude_env(),
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
if result.returncode != 0:
|
|
430
|
-
rprint(f"[red]Claude analysis failed:[/red]\n{result.stderr}")
|
|
431
|
-
raise typer.Exit(1)
|
|
432
|
-
|
|
433
|
-
suggestion = result.stdout.strip()
|
|
434
|
-
if not suggestion:
|
|
435
|
-
rprint("[red]Claude returned an empty response.[/red]")
|
|
436
|
-
raise typer.Exit(1)
|
|
437
|
-
|
|
438
|
-
rprint()
|
|
439
|
-
key = item.get("key") or f"#{item['number']}"
|
|
440
|
-
rprint(Panel(
|
|
441
|
-
Markdown(suggestion),
|
|
442
|
-
title=f"Suggested Solution -- {key}",
|
|
443
|
-
border_style="green",
|
|
444
|
-
))
|
|
445
|
-
|
|
446
|
-
# Post as agent comment
|
|
447
|
-
api_post(f"/api/work-items/{item_id}/comments", json={
|
|
448
|
-
"body": suggestion,
|
|
449
|
-
"author": "bb-agent",
|
|
450
|
-
"type": "proposal",
|
|
451
|
-
})
|
|
452
|
-
rprint("[green]Suggestion posted as comment on the work item.[/green]")
|
|
453
|
-
|
|
454
|
-
# Advance status open -> confirmed
|
|
455
|
-
if item["status"] == "open":
|
|
456
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "confirmed"})
|
|
457
|
-
rprint("[dim]Status -> confirmed[/dim]")
|
|
458
|
-
|
|
459
|
-
except FileNotFoundError:
|
|
460
|
-
rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
|
|
461
|
-
raise typer.Exit(1)
|
|
462
|
-
except subprocess.TimeoutExpired:
|
|
463
|
-
rprint("[red]Analysis timed out (10 min limit).[/red]")
|
|
464
|
-
raise typer.Exit(1)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
@app.command()
|
|
468
|
-
def execute(
|
|
469
|
-
id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to implement"),
|
|
470
|
-
no_worktree: bool = typer.Option(False, "--no-worktree", help="Work in main directory (skip worktree)"),
|
|
471
|
-
cleanup: bool = typer.Option(False, "--cleanup", help="Remove worktree after completion"),
|
|
472
|
-
):
|
|
473
|
-
"""Phase 2: Create a worktree and implement the work item with Claude Code."""
|
|
474
|
-
slug = _require_project()
|
|
475
|
-
project_path = _require_project_path(slug)
|
|
476
|
-
|
|
477
|
-
# Context
|
|
478
|
-
item = _resolve_item(slug, id_or_number)
|
|
479
|
-
item_id = item["id"]
|
|
480
|
-
item_number = item["number"]
|
|
481
|
-
knowledge = _read_knowledge(project_path)
|
|
482
|
-
comments = _get_item_comments(item_id)
|
|
483
|
-
comments_ctx = _format_comments_context(comments)
|
|
484
|
-
prompt = _build_execute_prompt(item, knowledge, comments_ctx)
|
|
485
|
-
|
|
486
|
-
# Worktree
|
|
487
|
-
work_dir = project_path
|
|
488
|
-
branch_name = None
|
|
489
|
-
|
|
490
|
-
if not no_worktree:
|
|
491
|
-
try:
|
|
492
|
-
key = item.get("key") or f"#{item_number}"
|
|
493
|
-
rprint(f"[cyan]Creating worktree for {key}...[/cyan]")
|
|
494
|
-
work_dir, branch_name = _create_worktree(project_path, slug, item_number)
|
|
495
|
-
rprint(f"[green]Worktree: {work_dir}[/green]")
|
|
496
|
-
rprint(f"[green]Branch: {branch_name}[/green]")
|
|
497
|
-
except subprocess.CalledProcessError as e:
|
|
498
|
-
rprint(f"[red]Worktree failed: {e.stderr or e}[/red]")
|
|
499
|
-
rprint("[yellow]Falling back to main directory.[/yellow]")
|
|
500
|
-
|
|
501
|
-
# Agent session
|
|
502
|
-
session = api_post(
|
|
503
|
-
"/api/agent-sessions/start",
|
|
504
|
-
json={"work_item_id": item_id, "origin": "cli"},
|
|
505
|
-
params={"project_slug": slug},
|
|
506
|
-
)
|
|
507
|
-
session_id = session["id"]
|
|
508
|
-
rprint(f"[green]Session: {session_id}[/green]")
|
|
509
|
-
|
|
510
|
-
# Status -> in_progress
|
|
511
|
-
if item["status"] in ("open", "confirmed", "approved"):
|
|
512
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
|
|
513
|
-
rprint("[dim]Status -> in_progress[/dim]")
|
|
514
|
-
|
|
515
|
-
# MCP config (Bumblebee tools for Claude)
|
|
516
|
-
api_url = get_api_url()
|
|
517
|
-
token = get_token()
|
|
518
|
-
mcp_cfg = json.dumps({
|
|
519
|
-
"mcpServers": {
|
|
520
|
-
"bumblebee": {
|
|
521
|
-
"url": f"{api_url}/mcp",
|
|
522
|
-
"headers": {"Authorization": f"Bearer {token}"} if token else {},
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
rprint(f"\n[cyan]Spawning Claude Code agent in {work_dir}...[/cyan]\n")
|
|
528
|
-
|
|
529
|
-
try:
|
|
530
|
-
proc = subprocess.Popen(
|
|
531
|
-
[
|
|
532
|
-
"claude",
|
|
533
|
-
"--output-format", "stream-json",
|
|
534
|
-
"--verbose",
|
|
535
|
-
"--permission-mode", "bypassPermissions",
|
|
536
|
-
"--mcp-config", "-",
|
|
537
|
-
"-p", prompt,
|
|
538
|
-
],
|
|
539
|
-
cwd=work_dir,
|
|
540
|
-
stdin=subprocess.PIPE,
|
|
541
|
-
stdout=subprocess.PIPE,
|
|
542
|
-
stderr=subprocess.PIPE,
|
|
543
|
-
text=True,
|
|
544
|
-
env=_claude_env(),
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
proc.stdin.write(mcp_cfg)
|
|
548
|
-
proc.stdin.close()
|
|
549
|
-
|
|
550
|
-
# Stream output -> terminal + API relay
|
|
551
|
-
text_blocks: list[str] = []
|
|
552
|
-
for line in proc.stdout:
|
|
553
|
-
line = line.strip()
|
|
554
|
-
if not line:
|
|
555
|
-
continue
|
|
556
|
-
try:
|
|
557
|
-
payload = json.loads(line)
|
|
558
|
-
if payload.get("type") == "assistant":
|
|
559
|
-
for block in payload.get("content", []):
|
|
560
|
-
if block.get("type") == "text":
|
|
561
|
-
rprint(block["text"])
|
|
562
|
-
text_blocks.append(block["text"])
|
|
563
|
-
try:
|
|
564
|
-
api_post(f"/api/agent-sessions/{session_id}/relay", json=payload)
|
|
565
|
-
except Exception:
|
|
566
|
-
pass
|
|
567
|
-
except json.JSONDecodeError:
|
|
568
|
-
rprint(f"[dim]{line}[/dim]")
|
|
569
|
-
|
|
570
|
-
proc.wait()
|
|
571
|
-
|
|
572
|
-
# Completion comment
|
|
573
|
-
tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
|
|
574
|
-
body_lines = ["## Agent Execution Report\n"]
|
|
575
|
-
if branch_name:
|
|
576
|
-
body_lines.append(f"**Branch**: `{branch_name}`\n")
|
|
577
|
-
body_lines.append(f"**Exit code**: `{proc.returncode}`\n")
|
|
578
|
-
body_lines.append(f"\n### Output (last messages)\n\n{tail}")
|
|
579
|
-
|
|
580
|
-
api_post(f"/api/work-items/{item_id}/comments", json={
|
|
581
|
-
"body": "\n".join(body_lines),
|
|
582
|
-
"author": "bb-agent",
|
|
583
|
-
"type": "agent_output",
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
if proc.returncode == 0:
|
|
587
|
-
rprint("\n[green]Agent completed successfully.[/green]")
|
|
588
|
-
api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
|
|
589
|
-
rprint("[dim]Status -> in_review[/dim]")
|
|
590
|
-
else:
|
|
591
|
-
rprint(f"\n[yellow]Agent exited with code {proc.returncode}.[/yellow]")
|
|
592
|
-
|
|
593
|
-
# Worktree post-run
|
|
594
|
-
if branch_name and work_dir != project_path:
|
|
595
|
-
if cleanup:
|
|
596
|
-
_remove_worktree(project_path, work_dir)
|
|
597
|
-
rprint("[dim]Worktree removed.[/dim]")
|
|
598
|
-
else:
|
|
599
|
-
rprint(f"\n[dim]Worktree: {work_dir}[/dim]")
|
|
600
|
-
rprint(f"[dim]Merge: cd {project_path} && git merge {branch_name}[/dim]")
|
|
601
|
-
rprint(f"[dim]Cleanup: bb agent cleanup {item_number}[/dim]")
|
|
602
|
-
|
|
603
|
-
except FileNotFoundError:
|
|
604
|
-
rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
|
|
605
|
-
raise typer.Exit(1)
|
|
606
|
-
except KeyboardInterrupt:
|
|
607
|
-
rprint("\n[yellow]Agent interrupted.[/yellow]")
|
|
608
|
-
api_post(f"/api/agent-sessions/{session_id}/abort")
|
|
609
|
-
api_post(f"/api/work-items/{item_id}/comments", json={
|
|
610
|
-
"body": "## Agent Interrupted\n\nManually stopped by user.",
|
|
611
|
-
"author": "bb-agent",
|
|
612
|
-
"type": "agent_output",
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
@app.command()
|
|
617
|
-
def run(
|
|
618
|
-
id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number"),
|
|
619
|
-
skip_suggest: bool = typer.Option(False, "--skip-suggest", help="Skip the analysis phase"),
|
|
620
|
-
yes: bool = typer.Option(False, "--yes", "-y", help="Auto-confirm the suggestion"),
|
|
621
|
-
no_worktree: bool = typer.Option(False, "--no-worktree", help="Skip worktree creation"),
|
|
622
|
-
):
|
|
623
|
-
"""Full loop: analyse -> confirm -> implement."""
|
|
624
|
-
_require_project()
|
|
625
|
-
|
|
626
|
-
if not skip_suggest:
|
|
627
|
-
suggest(id_or_number)
|
|
628
|
-
if not yes:
|
|
629
|
-
rprint()
|
|
630
|
-
if not typer.confirm("Proceed with implementation?"):
|
|
631
|
-
rprint(f"[yellow]Aborted. Run [bold]bb agent execute {id_or_number}[/bold] when ready.[/yellow]")
|
|
632
|
-
raise typer.Exit()
|
|
633
|
-
|
|
634
|
-
execute(id_or_number, no_worktree=no_worktree, cleanup=False)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
@app.command(name="continue")
|
|
638
|
-
def continue_work(
|
|
639
|
-
id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to continue"),
|
|
640
|
-
):
|
|
641
|
-
"""Continue a previous agent run (reads prior comments for context)."""
|
|
642
|
-
execute(id_or_number, no_worktree=False, cleanup=False)
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
@app.command(name="status")
|
|
646
|
-
def agent_status():
|
|
647
|
-
"""Show agent sessions for the current project."""
|
|
648
|
-
slug = _require_project()
|
|
649
|
-
sessions = api_get("/api/agent-sessions", params={"project_slug": slug})
|
|
650
|
-
if not sessions:
|
|
651
|
-
rprint("[dim]No agent sessions.[/dim]")
|
|
652
|
-
return
|
|
653
|
-
for s in sessions:
|
|
654
|
-
color = {"running": "yellow", "completed": "green", "failed": "red"}.get(
|
|
655
|
-
s["status"], "white"
|
|
656
|
-
)
|
|
657
|
-
rprint(
|
|
658
|
-
f" [{color}]{s['status']:>10}[/{color}] "
|
|
659
|
-
f"{s['id'][:8]} item: {s.get('work_item_id', '--')}"
|
|
660
|
-
)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
@app.command()
|
|
664
|
-
def abort(session_id: str = typer.Argument(...)):
|
|
665
|
-
"""Abort a running agent session."""
|
|
666
|
-
api_post(f"/api/agent-sessions/{session_id}/abort")
|
|
667
|
-
rprint(f"[yellow]Session {session_id[:8]} aborted.[/yellow]")
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
@app.command(name="cleanup")
|
|
671
|
-
def cleanup_worktree(
|
|
672
|
-
item_number: int = typer.Argument(..., help="Work item number whose worktree to remove"),
|
|
673
|
-
delete_branch: bool = typer.Option(False, "--delete-branch", "-D", help="Also delete the git branch"),
|
|
674
|
-
):
|
|
675
|
-
"""Remove the worktree created for a work item."""
|
|
676
|
-
slug = _require_project()
|
|
677
|
-
project_path = _require_project_path(slug)
|
|
678
|
-
|
|
679
|
-
wt = _worktree_path(slug, item_number)
|
|
680
|
-
branch = f"bb/item-{item_number}"
|
|
681
|
-
|
|
682
|
-
if not wt.exists():
|
|
683
|
-
rprint(f"[yellow]No worktree found for item #{item_number}.[/yellow]")
|
|
684
|
-
raise typer.Exit()
|
|
685
|
-
|
|
686
|
-
_remove_worktree(project_path, str(wt))
|
|
687
|
-
rprint(f"[green]Worktree removed: {wt}[/green]")
|
|
688
|
-
|
|
689
|
-
if delete_branch:
|
|
690
|
-
subprocess.run(
|
|
691
|
-
["git", "branch", "-D", branch],
|
|
692
|
-
cwd=project_path, capture_output=True,
|
|
693
|
-
)
|
|
694
|
-
rprint(f"[green]Branch deleted: {branch}[/green]")
|
|
695
|
-
else:
|
|
696
|
-
rprint(f"[dim]Branch '{branch}' kept. Delete with: git branch -D {branch}[/dim]")
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
@app.command(name="worktrees")
|
|
700
|
-
def list_worktrees():
|
|
701
|
-
"""List active agent worktrees for the current project."""
|
|
702
|
-
slug = _require_project()
|
|
703
|
-
project_path = _require_project_path(slug)
|
|
704
|
-
|
|
705
|
-
result = subprocess.run(
|
|
706
|
-
["git", "worktree", "list"],
|
|
707
|
-
cwd=project_path, capture_output=True, text=True,
|
|
708
|
-
)
|
|
709
|
-
if result.returncode != 0:
|
|
710
|
-
rprint("[red]Failed to list worktrees.[/red]")
|
|
711
|
-
raise typer.Exit(1)
|
|
712
|
-
|
|
713
|
-
lines = result.stdout.strip().splitlines()
|
|
714
|
-
bb_lines = [ln for ln in lines if "bb/item-" in ln]
|
|
715
|
-
|
|
716
|
-
if not bb_lines:
|
|
717
|
-
rprint("[dim]No agent worktrees.[/dim]")
|
|
718
|
-
return
|
|
719
|
-
|
|
720
|
-
rprint("[bold]Agent worktrees:[/bold]")
|
|
721
|
-
for ln in bb_lines:
|
|
722
|
-
rprint(f" {ln}")
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
# ---------------------------------------------------------------------------
|
|
726
|
-
# Batch (parallel) commands
|
|
727
|
-
# ---------------------------------------------------------------------------
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
@app.command(name="batch-suggest")
|
|
731
|
-
def batch_suggest(
|
|
732
|
-
items: list[str] = typer.Argument(..., help="Work item IDs/numbers to analyse (e.g. BD-2 BD-3 BD-4)"),
|
|
733
|
-
max_parallel: int = typer.Option(3, "--parallel", "-P", help="Max parallel Claude analyses"),
|
|
734
|
-
):
|
|
735
|
-
"""Analyse multiple work items in parallel. Each gets a proposal comment."""
|
|
736
|
-
slug = _require_project()
|
|
737
|
-
project_path = _require_project_path(slug)
|
|
738
|
-
|
|
739
|
-
rprint(f"[cyan]Suggesting {len(items)} items (max {max_parallel} parallel)...[/cyan]\n")
|
|
740
|
-
|
|
741
|
-
results: list[dict] = []
|
|
742
|
-
with ThreadPoolExecutor(max_workers=max_parallel) as pool:
|
|
743
|
-
futures = {
|
|
744
|
-
pool.submit(_suggest_one, slug, project_path, item): item
|
|
745
|
-
for item in items
|
|
746
|
-
}
|
|
747
|
-
for future in as_completed(futures):
|
|
748
|
-
item_ref = futures[future]
|
|
749
|
-
try:
|
|
750
|
-
r = future.result()
|
|
751
|
-
results.append(r)
|
|
752
|
-
color = "green" if r["status"] == "ok" else "red"
|
|
753
|
-
rprint(f" [{color}]{r['key']:>8} -- {r['status']}[/{color}]"
|
|
754
|
-
+ (f" ({r.get('error', '')})" if r["status"] != "ok" else ""))
|
|
755
|
-
except Exception as e:
|
|
756
|
-
results.append({"key": item_ref, "status": "error", "error": str(e)})
|
|
757
|
-
rprint(f" [red]{item_ref:>8} -- error: {e}[/red]")
|
|
758
|
-
|
|
759
|
-
ok = sum(1 for r in results if r["status"] == "ok")
|
|
760
|
-
rprint(f"\n[bold]Done: {ok}/{len(items)} succeeded.[/bold]")
|
|
761
|
-
|
|
762
|
-
if ok > 0:
|
|
763
|
-
rprint("\n[dim]Review suggestions, then run:[/dim]")
|
|
764
|
-
suggested = [r["key"] for r in results if r["status"] == "ok"]
|
|
765
|
-
rprint(f"[dim] bb agent batch-execute {' '.join(suggested)}[/dim]")
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
@app.command(name="batch-execute")
|
|
769
|
-
def batch_execute(
|
|
770
|
-
items: list[str] = typer.Argument(..., help="Work item IDs/numbers to implement (e.g. BD-2 BD-3 BD-4)"),
|
|
771
|
-
max_parallel: int = typer.Option(2, "--parallel", "-P", help="Max parallel Claude agents"),
|
|
772
|
-
):
|
|
773
|
-
"""Implement multiple work items in parallel. Each gets its own git worktree."""
|
|
774
|
-
slug = _require_project()
|
|
775
|
-
project_path = _require_project_path(slug)
|
|
776
|
-
|
|
777
|
-
rprint(f"[cyan]Executing {len(items)} items (max {max_parallel} parallel, each in own worktree)...[/cyan]\n")
|
|
778
|
-
|
|
779
|
-
results: list[dict] = []
|
|
780
|
-
with ThreadPoolExecutor(max_workers=max_parallel) as pool:
|
|
781
|
-
futures = {
|
|
782
|
-
pool.submit(_execute_one, slug, project_path, item): item
|
|
783
|
-
for item in items
|
|
784
|
-
}
|
|
785
|
-
for future in as_completed(futures):
|
|
786
|
-
item_ref = futures[future]
|
|
787
|
-
try:
|
|
788
|
-
r = future.result()
|
|
789
|
-
results.append(r)
|
|
790
|
-
if r["status"] == "ok":
|
|
791
|
-
rprint(f" [green]{r['key']:>8} -- ok branch: {r.get('branch', '?')}[/green]")
|
|
792
|
-
else:
|
|
793
|
-
rprint(f" [red]{r['key']:>8} -- {r.get('error', 'unknown error')}[/red]")
|
|
794
|
-
except Exception as e:
|
|
795
|
-
results.append({"key": item_ref, "status": "error", "error": str(e)})
|
|
796
|
-
rprint(f" [red]{item_ref:>8} -- error: {e}[/red]")
|
|
797
|
-
|
|
798
|
-
ok = sum(1 for r in results if r["status"] == "ok")
|
|
799
|
-
rprint(f"\n[bold]Done: {ok}/{len(items)} succeeded.[/bold]")
|
|
800
|
-
|
|
801
|
-
branches = [r["branch"] for r in results if r.get("branch")]
|
|
802
|
-
if branches:
|
|
803
|
-
table = Table(title="Branches Created", show_header=True)
|
|
804
|
-
table.add_column("Item")
|
|
805
|
-
table.add_column("Branch")
|
|
806
|
-
table.add_column("Status")
|
|
807
|
-
for r in results:
|
|
808
|
-
if r.get("branch"):
|
|
809
|
-
color = "green" if r["status"] == "ok" else "red"
|
|
810
|
-
table.add_row(r["key"], r["branch"], f"[{color}]{r['status']}[/{color}]")
|
|
811
|
-
rprint(table)
|
|
812
|
-
rprint(f"\n[dim]Merge all with: bb agent merge --target release/dev[/dim]")
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
@app.command(name="batch-run")
|
|
816
|
-
def batch_run(
|
|
817
|
-
items: list[str] = typer.Argument(..., help="Work item IDs/numbers for full loop"),
|
|
818
|
-
max_parallel: int = typer.Option(2, "--parallel", "-P", help="Max parallel agents"),
|
|
819
|
-
yes: bool = typer.Option(False, "--yes", "-y", help="Auto-confirm after suggest phase"),
|
|
820
|
-
):
|
|
821
|
-
"""Full loop for multiple items: suggest all -> review -> execute all."""
|
|
822
|
-
slug = _require_project()
|
|
823
|
-
project_path = _require_project_path(slug)
|
|
824
|
-
|
|
825
|
-
# Phase 1: parallel suggest
|
|
826
|
-
rprint("[bold cyan]Phase 1: Analysing...[/bold cyan]\n")
|
|
827
|
-
suggest_results: list[dict] = []
|
|
828
|
-
with ThreadPoolExecutor(max_workers=max_parallel) as pool:
|
|
829
|
-
futures = {
|
|
830
|
-
pool.submit(_suggest_one, slug, project_path, item): item
|
|
831
|
-
for item in items
|
|
832
|
-
}
|
|
833
|
-
for future in as_completed(futures):
|
|
834
|
-
item_ref = futures[future]
|
|
835
|
-
try:
|
|
836
|
-
r = future.result()
|
|
837
|
-
suggest_results.append(r)
|
|
838
|
-
color = "green" if r["status"] == "ok" else "red"
|
|
839
|
-
rprint(f" [{color}]{r['key']:>8} -- {r['status']}[/{color}]")
|
|
840
|
-
except Exception as e:
|
|
841
|
-
suggest_results.append({"key": item_ref, "status": "error"})
|
|
842
|
-
rprint(f" [red]{item_ref:>8} -- error: {e}[/red]")
|
|
843
|
-
|
|
844
|
-
succeeded = [r["key"] for r in suggest_results if r["status"] == "ok"]
|
|
845
|
-
if not succeeded:
|
|
846
|
-
rprint("\n[red]No items analysed successfully. Aborting.[/red]")
|
|
847
|
-
raise typer.Exit(1)
|
|
848
|
-
|
|
849
|
-
rprint(f"\n[bold]Suggest done: {len(succeeded)}/{len(items)} ready.[/bold]")
|
|
850
|
-
|
|
851
|
-
# Confirmation gate
|
|
852
|
-
if not yes:
|
|
853
|
-
rprint("\n[yellow]Review the suggestions in the web UI or via 'bb comment list <item>'.[/yellow]")
|
|
854
|
-
if not typer.confirm(f"Proceed to execute {len(succeeded)} items?"):
|
|
855
|
-
rprint("[yellow]Aborted. Run 'bb agent batch-execute' when ready.[/yellow]")
|
|
856
|
-
raise typer.Exit()
|
|
857
|
-
|
|
858
|
-
# Phase 2: parallel execute
|
|
859
|
-
rprint("\n[bold cyan]Phase 2: Implementing...[/bold cyan]\n")
|
|
860
|
-
exec_results: list[dict] = []
|
|
861
|
-
with ThreadPoolExecutor(max_workers=max_parallel) as pool:
|
|
862
|
-
futures = {
|
|
863
|
-
pool.submit(_execute_one, slug, project_path, item): item
|
|
864
|
-
for item in succeeded
|
|
865
|
-
}
|
|
866
|
-
for future in as_completed(futures):
|
|
867
|
-
item_ref = futures[future]
|
|
868
|
-
try:
|
|
869
|
-
r = future.result()
|
|
870
|
-
exec_results.append(r)
|
|
871
|
-
if r["status"] == "ok":
|
|
872
|
-
rprint(f" [green]{r['key']:>8} -- ok branch: {r.get('branch', '?')}[/green]")
|
|
873
|
-
else:
|
|
874
|
-
rprint(f" [red]{r['key']:>8} -- {r.get('error', '?')}[/red]")
|
|
875
|
-
except Exception as e:
|
|
876
|
-
rprint(f" [red]{item_ref:>8} -- error: {e}[/red]")
|
|
877
|
-
|
|
878
|
-
ok = sum(1 for r in exec_results if r["status"] == "ok")
|
|
879
|
-
rprint(f"\n[bold]Execute done: {ok}/{len(succeeded)} succeeded.[/bold]")
|
|
880
|
-
if ok > 0:
|
|
881
|
-
rprint("[dim]Merge with: bb agent merge --target release/dev[/dim]")
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
# ---------------------------------------------------------------------------
|
|
885
|
-
# Merge command
|
|
886
|
-
# ---------------------------------------------------------------------------
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
@app.command(name="merge")
|
|
890
|
-
def merge_branches(
|
|
891
|
-
target: str = typer.Option("release/dev", "--target", "-t", help="Target branch to merge into"),
|
|
892
|
-
items: list[str] = typer.Argument(None, help="Specific item numbers (default: all bb/item-* branches)"),
|
|
893
|
-
cleanup_after: bool = typer.Option(False, "--cleanup", help="Remove worktrees + branches after successful merge"),
|
|
894
|
-
use_agent: bool = typer.Option(False, "--agent", help="Use Claude to resolve merge conflicts"),
|
|
895
|
-
):
|
|
896
|
-
"""Merge agent branches into a target branch (e.g. release/dev)."""
|
|
897
|
-
slug = _require_project()
|
|
898
|
-
project_path = _require_project_path(slug)
|
|
899
|
-
|
|
900
|
-
# Remember current branch
|
|
901
|
-
current = subprocess.run(
|
|
902
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
903
|
-
cwd=project_path, capture_output=True, text=True,
|
|
904
|
-
).stdout.strip()
|
|
905
|
-
|
|
906
|
-
# List all bb/item-* branches
|
|
907
|
-
result = subprocess.run(
|
|
908
|
-
["git", "branch", "--list", "bb/item-*"],
|
|
909
|
-
cwd=project_path, capture_output=True, text=True,
|
|
910
|
-
)
|
|
911
|
-
all_branches = [b.strip().lstrip("* ") for b in result.stdout.strip().splitlines() if b.strip()]
|
|
912
|
-
|
|
913
|
-
if items:
|
|
914
|
-
wanted = {f"bb/item-{n}" for n in items}
|
|
915
|
-
branches = [b for b in all_branches if b in wanted]
|
|
916
|
-
else:
|
|
917
|
-
branches = all_branches
|
|
918
|
-
|
|
919
|
-
if not branches:
|
|
920
|
-
rprint("[yellow]No agent branches found to merge.[/yellow]")
|
|
921
|
-
return
|
|
922
|
-
|
|
923
|
-
rprint(f"[cyan]Merging {len(branches)} branches into {target}...[/cyan]\n")
|
|
924
|
-
for b in branches:
|
|
925
|
-
rprint(f" {b}")
|
|
926
|
-
rprint()
|
|
927
|
-
|
|
928
|
-
# Ensure target branch exists
|
|
929
|
-
check = subprocess.run(
|
|
930
|
-
["git", "rev-parse", "--verify", target],
|
|
931
|
-
cwd=project_path, capture_output=True, text=True,
|
|
932
|
-
)
|
|
933
|
-
if check.returncode != 0:
|
|
934
|
-
rprint(f"[yellow]Branch '{target}' does not exist. Creating from HEAD...[/yellow]")
|
|
935
|
-
subprocess.run(
|
|
936
|
-
["git", "branch", target],
|
|
937
|
-
cwd=project_path, check=True, capture_output=True,
|
|
938
|
-
)
|
|
939
|
-
|
|
940
|
-
# Prune stale worktrees
|
|
941
|
-
subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
|
|
942
|
-
|
|
943
|
-
# Checkout target branch
|
|
944
|
-
co = subprocess.run(
|
|
945
|
-
["git", "checkout", target],
|
|
946
|
-
cwd=project_path, capture_output=True, text=True,
|
|
947
|
-
)
|
|
948
|
-
if co.returncode != 0:
|
|
949
|
-
rprint(f"[red]Failed to checkout {target}: {co.stderr}[/red]")
|
|
950
|
-
return
|
|
951
|
-
|
|
952
|
-
merged: list[str] = []
|
|
953
|
-
failed: list[tuple[str, str]] = []
|
|
954
|
-
|
|
955
|
-
for branch in branches:
|
|
956
|
-
rprint(f" Merging {branch}...", end=" ")
|
|
957
|
-
merge_result = subprocess.run(
|
|
958
|
-
["git", "merge", branch, "--no-ff", "-m", f"Merge {branch} into {target}"],
|
|
959
|
-
cwd=project_path, capture_output=True, text=True,
|
|
960
|
-
)
|
|
961
|
-
if merge_result.returncode == 0:
|
|
962
|
-
rprint("[green]ok[/green]")
|
|
963
|
-
merged.append(branch)
|
|
964
|
-
else:
|
|
965
|
-
if use_agent:
|
|
966
|
-
rprint("[yellow]conflict -> resolving with Claude...[/yellow]")
|
|
967
|
-
resolve = subprocess.run(
|
|
968
|
-
["claude", "-p",
|
|
969
|
-
f"Resolve all merge conflicts in this git repo. The merge of '{branch}' into '{target}' has conflicts. "
|
|
970
|
-
f"Use 'git diff' to find conflicts, resolve them keeping both sets of changes where possible, "
|
|
971
|
-
f"then stage and commit. Do NOT abort the merge.",
|
|
972
|
-
"--output-format", "text",
|
|
973
|
-
"--permission-mode", "bypassPermissions"],
|
|
974
|
-
cwd=project_path, capture_output=True, text=True,
|
|
975
|
-
timeout=300, env=_claude_env(),
|
|
976
|
-
)
|
|
977
|
-
# Check if conflicts are resolved
|
|
978
|
-
status_check = subprocess.run(
|
|
979
|
-
["git", "diff", "--name-only", "--diff-filter=U"],
|
|
980
|
-
cwd=project_path, capture_output=True, text=True,
|
|
981
|
-
)
|
|
982
|
-
if status_check.stdout.strip() == "":
|
|
983
|
-
rprint(f" [green]Conflict resolved by agent[/green]")
|
|
984
|
-
merged.append(branch)
|
|
985
|
-
else:
|
|
986
|
-
rprint(f" [red]Agent could not resolve all conflicts[/red]")
|
|
987
|
-
subprocess.run(["git", "merge", "--abort"], cwd=project_path, capture_output=True)
|
|
988
|
-
failed.append((branch, "conflict (agent failed)"))
|
|
989
|
-
else:
|
|
990
|
-
rprint("[red]CONFLICT[/red]")
|
|
991
|
-
subprocess.run(["git", "merge", "--abort"], cwd=project_path, capture_output=True)
|
|
992
|
-
failed.append((branch, "conflict"))
|
|
993
|
-
|
|
994
|
-
# Summary
|
|
995
|
-
rprint()
|
|
996
|
-
table = Table(title=f"Merge Results -> {target}", show_header=True)
|
|
997
|
-
table.add_column("Branch")
|
|
998
|
-
table.add_column("Status")
|
|
999
|
-
for b in merged:
|
|
1000
|
-
table.add_row(b, "[green]merged[/green]")
|
|
1001
|
-
for b, reason in failed:
|
|
1002
|
-
table.add_row(b, f"[red]{reason}[/red]")
|
|
1003
|
-
rprint(table)
|
|
1004
|
-
|
|
1005
|
-
# Cleanup if requested
|
|
1006
|
-
if cleanup_after and merged:
|
|
1007
|
-
for branch in merged:
|
|
1008
|
-
num_str = branch.replace("bb/item-", "")
|
|
1009
|
-
try:
|
|
1010
|
-
wt = _worktree_path(slug, int(num_str))
|
|
1011
|
-
if wt.exists():
|
|
1012
|
-
_remove_worktree(project_path, str(wt))
|
|
1013
|
-
subprocess.run(
|
|
1014
|
-
["git", "branch", "-D", branch],
|
|
1015
|
-
cwd=project_path, capture_output=True,
|
|
1016
|
-
)
|
|
1017
|
-
except (ValueError, Exception):
|
|
1018
|
-
pass
|
|
1019
|
-
rprint("[dim]Cleaned up merged worktrees and branches.[/dim]")
|
|
1020
|
-
|
|
1021
|
-
if failed:
|
|
1022
|
-
rprint("\n[yellow]Failed branches can be retried:[/yellow]")
|
|
1023
|
-
rprint("[dim] bb agent merge --agent (use Claude to resolve conflicts)[/dim]")
|
|
1024
|
-
rprint("[dim] or resolve manually: git checkout {target} && git merge {branch}[/dim]")
|
|
1025
|
-
|
|
1026
|
-
# Return to original branch
|
|
1027
|
-
subprocess.run(
|
|
1028
|
-
["git", "checkout", current],
|
|
1029
|
-
cwd=project_path, capture_output=True,
|
|
1030
|
-
)
|