bumblebee-cli 0.1.1 → 0.2.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.
Files changed (38) hide show
  1. package/README.md +49 -47
  2. package/bin/bb.mjs +132 -132
  3. package/package.json +28 -28
  4. package/python/bb_cli/__main__.py +3 -0
  5. package/python/bb_cli/api_client.py +7 -0
  6. package/python/bb_cli/commands/agent.py +2287 -1030
  7. package/python/bb_cli/commands/auth.py +79 -79
  8. package/python/bb_cli/commands/board.py +47 -47
  9. package/python/bb_cli/commands/comment.py +34 -34
  10. package/python/bb_cli/commands/daemon.py +153 -0
  11. package/python/bb_cli/commands/init.py +83 -62
  12. package/python/bb_cli/commands/item.py +192 -192
  13. package/python/bb_cli/commands/project.py +175 -111
  14. package/python/bb_cli/config.py +136 -136
  15. package/python/bb_cli/main.py +44 -44
  16. package/python/bb_cli/progress.py +117 -0
  17. package/python/bb_cli/streaming.py +168 -0
  18. package/python/pyproject.toml +1 -1
  19. package/python/{bb_cli/bumblebee_cli.egg-info/requires.txt → requirements.txt} +4 -4
  20. package/scripts/build.sh +20 -20
  21. package/scripts/postinstall.mjs +146 -146
  22. package/python/bb_cli/bumblebee_cli.egg-info/PKG-INFO +0 -9
  23. package/python/bb_cli/bumblebee_cli.egg-info/SOURCES.txt +0 -21
  24. package/python/bb_cli/bumblebee_cli.egg-info/dependency_links.txt +0 -1
  25. package/python/bb_cli/bumblebee_cli.egg-info/entry_points.txt +0 -2
  26. package/python/bb_cli/bumblebee_cli.egg-info/top_level.txt +0 -5
  27. package/python/bb_cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  28. package/python/bb_cli/commands/__pycache__/agent.cpython-313.pyc +0 -0
  29. package/python/bb_cli/commands/__pycache__/auth.cpython-313.pyc +0 -0
  30. package/python/bb_cli/commands/__pycache__/board.cpython-313.pyc +0 -0
  31. package/python/bb_cli/commands/__pycache__/comment.cpython-313.pyc +0 -0
  32. package/python/bb_cli/commands/__pycache__/init.cpython-313.pyc +0 -0
  33. package/python/bb_cli/commands/__pycache__/item.cpython-313.pyc +0 -0
  34. package/python/bb_cli/commands/__pycache__/label.cpython-313.pyc +0 -0
  35. package/python/bb_cli/commands/__pycache__/project.cpython-313.pyc +0 -0
  36. package/python/bb_cli/commands/__pycache__/sprint.cpython-313.pyc +0 -0
  37. package/python/bb_cli/commands/__pycache__/story.cpython-313.pyc +0 -0
  38. package/python/bb_cli/commands/__pycache__/task.cpython-313.pyc +0 -0
@@ -1,1030 +1,2287 @@
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
- )
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich import print as rprint
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from ..api_client import api_get, api_post, api_put
16
+ from ..config import CONFIG_DIR, get_api_url, get_current_project, get_project_path, get_token
17
+ from ..progress import AgentProgressTracker
18
+ from ..streaming import AgentStreamer, complete_session, update_phase
19
+ from .item import _resolve_item
20
+
21
+ from . import daemon as _daemon_module
22
+
23
+ app = typer.Typer(help="Agent session management")
24
+ app.add_typer(_daemon_module.app, name="daemon")
25
+
26
+ WORKTREES_DIR = CONFIG_DIR / "worktrees"
27
+
28
+ # Maps work item type to git branch prefix.
29
+ TYPE_BRANCH_PREFIX: dict[str, str] = {
30
+ "epic": "epic",
31
+ "story": "feat",
32
+ "task": "task",
33
+ "bug": "fix",
34
+ "feature": "feat",
35
+ "chore": "chore",
36
+ "spike": "spike",
37
+ }
38
+
39
+
40
+ def _slugify(text: str, max_len: int = 48) -> str:
41
+ """Turn a title into a branch-safe slug: lowercase, hyphens, no specials."""
42
+ slug = text.lower().strip()
43
+ slug = re.sub(r"[^a-z0-9]+", "-", slug) # replace non-alnum with hyphen
44
+ slug = slug.strip("-")
45
+ if len(slug) > max_len:
46
+ slug = slug[:max_len].rsplit("-", 1)[0] # cut at last hyphen
47
+ return slug
48
+
49
+
50
+ def _build_branch_name(item: dict) -> str:
51
+ """Build a descriptive branch name from a work item.
52
+
53
+ Format: {type_prefix}/{key}_{slugified_title}
54
+ Example: feat/bb-42_authentication-by-google
55
+ """
56
+ prefix = TYPE_BRANCH_PREFIX.get(item["type"], "task")
57
+ key = (item.get("key") or f"item-{item['number']}").lower()
58
+ title_slug = _slugify(item["title"])
59
+ return f"{prefix}/{key}_{title_slug}"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Helpers
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _require_project() -> str:
68
+ slug = get_current_project()
69
+ if not slug:
70
+ rprint("[red]No project selected. Run [bold]bb project switch <slug>[/bold] first.[/red]")
71
+ raise typer.Exit(1)
72
+ return slug
73
+
74
+
75
+ def _require_project_path(slug: str) -> str:
76
+ path = get_project_path(slug)
77
+ if not path:
78
+ rprint("[red]No source code directory linked to this project.[/red]")
79
+ rprint("[yellow]Run [bold]bb project link <path>[/bold] to set it.[/yellow]")
80
+ raise typer.Exit(1)
81
+ if not Path(path).is_dir():
82
+ rprint(f"[red]Linked directory not found: {path}[/red]")
83
+ raise typer.Exit(1)
84
+ return path
85
+
86
+
87
+ def _read_knowledge(project_path: str) -> str:
88
+ """Read knowledge base files from the project directory."""
89
+ candidates = [
90
+ "CLAUDE.md",
91
+ "docs/knowledge.md",
92
+ ".claude/lessons-learned.md",
93
+ ]
94
+ parts = []
95
+ for rel in candidates:
96
+ fp = Path(project_path) / rel
97
+ if fp.exists():
98
+ try:
99
+ text = fp.read_text(encoding="utf-8", errors="replace").strip()
100
+ if text:
101
+ parts.append(f"### {rel}\n\n{text}")
102
+ except Exception:
103
+ pass
104
+ return "\n\n---\n\n".join(parts)
105
+
106
+
107
+ def _get_item_comments(item_id: str) -> list[dict]:
108
+ try:
109
+ return api_get(f"/api/work-items/{item_id}/comments")
110
+ except Exception:
111
+ return []
112
+
113
+
114
+ def _claude_env() -> dict[str, str]:
115
+ """Return env dict with CLAUDECODE unset so we can spawn a new Claude session."""
116
+ env = os.environ.copy()
117
+ env.pop("CLAUDECODE", None)
118
+ return env
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Docker verification helpers
123
+ # ---------------------------------------------------------------------------
124
+
125
+ COMPOSE_FILES = ["docker-compose.test.yml", "Dockerfile.api-test", "Dockerfile.web-test"]
126
+
127
+
128
+ def _truncate_output(output: str, max_chars: int = 4000) -> str:
129
+ """Keep the last *max_chars* characters of output for posting as a comment."""
130
+ if len(output) <= max_chars:
131
+ return output
132
+ return f"... (truncated, showing last {max_chars} chars)\n" + output[-max_chars:]
133
+
134
+
135
+ def _ensure_compose_files(work_dir: str, project_path: str):
136
+ """Copy Docker test files into the worktree if they are missing."""
137
+ for name in COMPOSE_FILES:
138
+ src = Path(project_path) / name
139
+ dst = Path(work_dir) / name
140
+ if src.exists() and not dst.exists():
141
+ shutil.copy2(str(src), str(dst))
142
+
143
+
144
+ def _run_docker_tests(work_dir: str, timeout: int = 600) -> tuple[bool, str, str]:
145
+ """Run Docker Compose test pipeline.
146
+
147
+ Returns (success, raw_output, details_summary).
148
+ Always tears down containers afterwards.
149
+ """
150
+ compose_cmd = ["docker", "compose", "-f", "docker-compose.test.yml"]
151
+
152
+ try:
153
+ result = subprocess.run(
154
+ compose_cmd + ["up", "--build", "--abort-on-container-exit"],
155
+ cwd=work_dir,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=timeout,
159
+ )
160
+ raw = result.stdout + "\n" + result.stderr
161
+ exit_code = result.returncode
162
+
163
+ # Gather per-service exit codes
164
+ ps_result = subprocess.run(
165
+ compose_cmd + ["ps", "-a", "--format", "{{.Service}}\t{{.ExitCode}}"],
166
+ cwd=work_dir,
167
+ capture_output=True,
168
+ text=True,
169
+ )
170
+
171
+ service_results: dict[str, int] = {}
172
+ for line in ps_result.stdout.strip().splitlines():
173
+ parts = line.split("\t")
174
+ if len(parts) == 2:
175
+ svc, code = parts
176
+ try:
177
+ service_results[svc] = int(code)
178
+ except ValueError:
179
+ service_results[svc] = -1
180
+
181
+ # Build summary
182
+ lines = []
183
+ all_pass = True
184
+ for svc in ["api-test", "web-build"]:
185
+ code = service_results.get(svc)
186
+ if code is None:
187
+ lines.append(f"- **{svc}**: unknown (container not found)")
188
+ all_pass = False
189
+ elif code == 0:
190
+ lines.append(f"- **{svc}**: passed")
191
+ else:
192
+ lines.append(f"- **{svc}**: FAILED (exit code {code})")
193
+ all_pass = False
194
+
195
+ success = exit_code == 0 and all_pass
196
+ details = "\n".join(lines)
197
+ return success, raw, details
198
+
199
+ except subprocess.TimeoutExpired:
200
+ return False, "", f"- Docker tests timed out after {timeout}s"
201
+ except FileNotFoundError:
202
+ return False, "", "- `docker` command not found. Is Docker installed?"
203
+ finally:
204
+ # Always clean up
205
+ subprocess.run(
206
+ compose_cmd + ["down", "-v", "--remove-orphans"],
207
+ cwd=work_dir,
208
+ capture_output=True,
209
+ timeout=60,
210
+ )
211
+
212
+
213
+ def _do_single_merge(project_path: str, branch: str, target: str) -> bool:
214
+ """Merge a single branch into target. Returns True on success."""
215
+ # Remember current branch
216
+ current = subprocess.run(
217
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
218
+ cwd=project_path, capture_output=True, text=True,
219
+ ).stdout.strip()
220
+
221
+ # Ensure target exists
222
+ check = subprocess.run(
223
+ ["git", "rev-parse", "--verify", target],
224
+ cwd=project_path, capture_output=True, text=True,
225
+ )
226
+ if check.returncode != 0:
227
+ subprocess.run(
228
+ ["git", "branch", target],
229
+ cwd=project_path, capture_output=True,
230
+ )
231
+
232
+ # Prune stale worktrees so checkout succeeds
233
+ subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
234
+
235
+ # Checkout target
236
+ co = subprocess.run(
237
+ ["git", "checkout", target],
238
+ cwd=project_path, capture_output=True, text=True,
239
+ )
240
+ if co.returncode != 0:
241
+ return False
242
+
243
+ # Merge
244
+ merge_result = subprocess.run(
245
+ ["git", "merge", branch, "--no-ff", "-m", f"Merge {branch} into {target}"],
246
+ cwd=project_path, capture_output=True, text=True,
247
+ )
248
+ ok = merge_result.returncode == 0
249
+
250
+ if not ok:
251
+ subprocess.run(["git", "merge", "--abort"], cwd=project_path, capture_output=True)
252
+
253
+ # Return to original branch
254
+ subprocess.run(
255
+ ["git", "checkout", current],
256
+ cwd=project_path, capture_output=True,
257
+ )
258
+
259
+ return ok
260
+
261
+
262
+ def _format_comments_context(comments: list[dict]) -> str:
263
+ if not comments:
264
+ return ""
265
+ parts = ["## Previous Comments / Progress"]
266
+ for c in comments:
267
+ author = c.get("author", "unknown")
268
+ ctype = c.get("type", "discussion")
269
+ tag = f" [{ctype}]" if ctype != "discussion" else ""
270
+ body = c.get("body", "")
271
+ created = c.get("created_at", "")
272
+ parts.append(f"\n### {author}{tag} -- {created}\n{body}")
273
+ return "\n".join(parts)
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Prompt builders
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ def _build_suggest_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
282
+ """Phase 1 prompt -- analyse only, no code changes."""
283
+ key = item.get("key") or f"#{item['number']}"
284
+ parts = [
285
+ f"You are analysing {item['type']} {key}: {item['title']}",
286
+ "",
287
+ f"Type: {item['type']} | Priority: {item['priority']} | Status: {item['status']}",
288
+ ]
289
+ if item.get("description"):
290
+ parts.extend(["", "## Description", item["description"]])
291
+ if item.get("acceptance_criteria"):
292
+ parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
293
+ if item.get("plan"):
294
+ parts.extend(["", "## Existing Plan", item["plan"]])
295
+ if comments_ctx:
296
+ parts.extend(["", comments_ctx])
297
+ if knowledge:
298
+ parts.extend(["", "## Project Knowledge Base", knowledge])
299
+ parts.extend([
300
+ "",
301
+ "## Your Task",
302
+ "",
303
+ "Analyse this work item **and** the project source code. Return a Markdown plan:",
304
+ "",
305
+ "1. **Root Cause / Analysis** -- what needs to change and why",
306
+ "2. **Files to Modify** -- list every file with a short description of the change",
307
+ "3. **Implementation Steps** -- numbered, concrete steps",
308
+ "4. **Testing Strategy** -- how to verify the changes",
309
+ "5. **Risks & Considerations** -- edge cases, breaking changes",
310
+ "",
311
+ "IMPORTANT: Do NOT modify any files. Only analyse and produce the plan.",
312
+ ])
313
+ return "\n".join(parts)
314
+
315
+
316
+ def _build_execute_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
317
+ """Phase 2 prompt -- implement the changes."""
318
+ key = item.get("key") or f"#{item['number']}"
319
+ parts = [
320
+ f"You are implementing {item['type']} {key}: {item['title']}",
321
+ "",
322
+ f"Type: {item['type']} | Priority: {item['priority']}",
323
+ ]
324
+ if item.get("description"):
325
+ parts.extend(["", "## Description", item["description"]])
326
+ if item.get("acceptance_criteria"):
327
+ parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
328
+ if item.get("plan"):
329
+ parts.extend(["", "## Implementation Plan", item["plan"]])
330
+ if comments_ctx:
331
+ parts.extend(["", comments_ctx])
332
+ if knowledge:
333
+ parts.extend(["", "## Project Knowledge Base", knowledge])
334
+ parts.extend([
335
+ "",
336
+ "## Instructions",
337
+ "",
338
+ "Implement the changes described in the plan / comments above.",
339
+ "",
340
+ "1. Follow the project's existing coding conventions and patterns",
341
+ "2. Work through changes one file at a time",
342
+ "3. Run existing tests after your changes and fix any failures",
343
+ "4. Add new tests where appropriate",
344
+ "5. Commit your work with a clear, descriptive commit message",
345
+ "6. If you hit a blocker, document it clearly so the next run can continue",
346
+ ])
347
+ return "\n".join(parts)
348
+
349
+
350
+ def _build_test_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
351
+ """Phase 3 prompt -- run tests and verify implementation."""
352
+ key = item.get("key") or f"#{item['number']}"
353
+ parts = [
354
+ f"You are verifying the implementation of {item['type']} {key}: {item['title']}",
355
+ "",
356
+ f"Type: {item['type']} | Priority: {item['priority']}",
357
+ ]
358
+ if item.get("description"):
359
+ parts.extend(["", "## Description", item["description"]])
360
+ if item.get("acceptance_criteria"):
361
+ parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
362
+ if comments_ctx:
363
+ parts.extend(["", comments_ctx])
364
+ if knowledge:
365
+ parts.extend(["", "## Project Knowledge Base", knowledge])
366
+ parts.extend([
367
+ "",
368
+ "## Your Task",
369
+ "",
370
+ "Run ALL relevant tests and verify the implementation. Follow these steps:",
371
+ "",
372
+ "1. Identify test commands from CLAUDE.md or project config (pytest, vitest, npm test, etc.)",
373
+ "2. Run all relevant test suites",
374
+ "3. Check acceptance criteria from the work item (if any)",
375
+ "4. Review the git diff for obvious issues",
376
+ "",
377
+ "Return a structured test report in this format:",
378
+ "",
379
+ "## Test Report",
380
+ "",
381
+ "### Results",
382
+ "- **Status**: PASS or FAIL",
383
+ "- **Tests run**: <count>",
384
+ "- **Passed**: <count>",
385
+ "- **Failed**: <count>",
386
+ "",
387
+ "### Failing Tests (if any)",
388
+ "- Test name: reason for failure",
389
+ "",
390
+ "### Acceptance Criteria Check",
391
+ "- [x] or [ ] for each criterion",
392
+ "",
393
+ "### Root Cause Analysis (if failures)",
394
+ "Brief analysis of why tests are failing.",
395
+ "",
396
+ "IMPORTANT: Do NOT fix any code. Only run tests and report results.",
397
+ ])
398
+ return "\n".join(parts)
399
+
400
+
401
+ def _build_reimplement_prompt(item: dict, knowledge: str, comments_ctx: str, docker_output: str = "") -> str:
402
+ """Re-implementation prompt -- reads previous failure reasons and tries again."""
403
+ key = item.get("key") or f"#{item['number']}"
404
+ parts = [
405
+ f"You are RE-IMPLEMENTING {item['type']} {key}: {item['title']}",
406
+ "",
407
+ f"Type: {item['type']} | Priority: {item['priority']}",
408
+ "",
409
+ "**IMPORTANT: A previous implementation attempt had test failures.**",
410
+ "Read the comments below carefully — they contain the original plan,",
411
+ "execution report, and test failure details. Fix the issues identified",
412
+ "in the test report.",
413
+ ]
414
+ if item.get("description"):
415
+ parts.extend(["", "## Description", item["description"]])
416
+ if item.get("acceptance_criteria"):
417
+ parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
418
+ if item.get("plan"):
419
+ parts.extend(["", "## Implementation Plan", item["plan"]])
420
+ if comments_ctx:
421
+ parts.extend(["", comments_ctx])
422
+ if knowledge:
423
+ parts.extend(["", "## Project Knowledge Base", knowledge])
424
+ if docker_output:
425
+ truncated = _truncate_output(docker_output, 3000)
426
+ parts.extend([
427
+ "",
428
+ "## Docker Test Output (from last run)",
429
+ "",
430
+ "The following is the raw Docker test output. Use this to identify exact failures:",
431
+ "",
432
+ f"```\n{truncated}\n```",
433
+ ])
434
+ parts.extend([
435
+ "",
436
+ "## Instructions",
437
+ "",
438
+ "1. Read the test report and failure reasons from previous comments AND the Docker output above",
439
+ "2. Identify what went wrong in the previous implementation",
440
+ "3. Fix the issues — focus on the root causes identified in the test report",
441
+ "4. Ensure all tests pass after your changes",
442
+ "5. Run the full test suite to verify no regressions",
443
+ "6. Commit your fixes with a clear message referencing the re-implementation",
444
+ "7. If you hit a blocker, document it clearly",
445
+ ])
446
+ return "\n".join(parts)
447
+
448
+
449
+ def _build_verify_prompt(item: dict, knowledge: str, comments_ctx: str) -> str:
450
+ """Verify prompt -- analyse requirements feasibility, return READY or NEEDS_INFO verdict."""
451
+ key = item.get("key") or f"#{item['number']}"
452
+ parts = [
453
+ f"You are verifying requirements for {item['type']} {key}: {item['title']}",
454
+ "",
455
+ f"Type: {item['type']} | Priority: {item['priority']} | Status: {item['status']}",
456
+ ]
457
+ if item.get("description"):
458
+ parts.extend(["", "## Description", item["description"]])
459
+ if item.get("acceptance_criteria"):
460
+ parts.extend(["", "## Acceptance Criteria", item["acceptance_criteria"]])
461
+ if item.get("plan"):
462
+ parts.extend(["", "## Existing Plan", item["plan"]])
463
+ if comments_ctx:
464
+ parts.extend(["", comments_ctx])
465
+ if knowledge:
466
+ parts.extend(["", "## Project Knowledge Base", knowledge])
467
+ parts.extend([
468
+ "",
469
+ "## Your Task",
470
+ "",
471
+ "Analyse this work item's requirements AND the project source code. Determine if the item is ready for implementation.",
472
+ "",
473
+ "Check the following:",
474
+ "1. Are the requirements clear and specific enough to implement?",
475
+ "2. Do acceptance criteria exist and are they testable?",
476
+ "3. Is the requested change feasible given the current codebase?",
477
+ "4. Are there any blockers, missing dependencies, or unclear areas?",
478
+ "",
479
+ "Return a structured analysis:",
480
+ "",
481
+ "## Requirement Analysis",
482
+ "",
483
+ "### Clarity",
484
+ "- Are requirements clear? What's ambiguous?",
485
+ "",
486
+ "### Feasibility",
487
+ "- What files need to change?",
488
+ "- Is the architecture compatible?",
489
+ "- Any technical blockers?",
490
+ "",
491
+ "### Solution Approach",
492
+ "- Proposed approach (high-level)",
493
+ "- Files to modify with brief description",
494
+ "- Estimated complexity (low/medium/high)",
495
+ "",
496
+ "### Blockers & Missing Information",
497
+ "- List anything that would prevent implementation",
498
+ "",
499
+ "### VERDICT: READY",
500
+ "or",
501
+ "### VERDICT: NEEDS_INFO",
502
+ "- If NEEDS_INFO, list exactly what information is missing",
503
+ "",
504
+ "IMPORTANT:",
505
+ "- You MUST include exactly one verdict line: `VERDICT: READY` or `VERDICT: NEEDS_INFO`",
506
+ "- Do NOT modify any files. Only analyse and produce the assessment.",
507
+ ])
508
+ return "\n".join(parts)
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Git worktree utilities
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _worktree_path(slug: str, item_number: int, title: str | None = None) -> Path:
517
+ """Return the worktree directory path for an item.
518
+
519
+ New format: item-{number}-{title-slug} (e.g. item-2-enhance-users-table)
520
+ Legacy format: item-{number} (still discovered by _find_worktree)
521
+ """
522
+ if title:
523
+ short_slug = _slugify(title, max_len=40)
524
+ return WORKTREES_DIR / slug / f"item-{item_number}-{short_slug}"
525
+ return WORKTREES_DIR / slug / f"item-{item_number}"
526
+
527
+
528
+ def _find_worktree(slug: str, item_number: int) -> Path:
529
+ """Find an existing worktree for an item (handles both old and new naming)."""
530
+ parent = WORKTREES_DIR / slug
531
+ if parent.exists():
532
+ # Match item-{number}-* (new) or item-{number} (legacy)
533
+ for entry in sorted(parent.iterdir(), reverse=True):
534
+ if entry.is_dir() and (
535
+ entry.name == f"item-{item_number}"
536
+ or entry.name.startswith(f"item-{item_number}-")
537
+ ):
538
+ return entry
539
+ # Fallback: return the legacy path (caller checks .exists())
540
+ return WORKTREES_DIR / slug / f"item-{item_number}"
541
+
542
+
543
+ def _create_worktree(project_path: str, slug: str, item: dict) -> tuple[str, str]:
544
+ """Create (or reuse) a git worktree. Returns (worktree_path, branch_name).
545
+
546
+ Branch format: {type_prefix}/{key}_{slugified_title}
547
+ e.g. feat/bb-42_authentication-by-google
548
+ """
549
+ item_number = item["number"]
550
+ branch = _build_branch_name(item)
551
+
552
+ # Check for any existing worktree (legacy or new naming) first
553
+ existing = _find_worktree(slug, item_number)
554
+ if existing.exists():
555
+ wt = existing
556
+ else:
557
+ wt = _worktree_path(slug, item_number, title=item.get("title"))
558
+ wt.parent.mkdir(parents=True, exist_ok=True)
559
+
560
+ # Already exists and valid?
561
+ if wt.exists():
562
+ probe = subprocess.run(
563
+ ["git", "worktree", "list", "--porcelain"],
564
+ cwd=project_path, capture_output=True, text=True,
565
+ )
566
+ # Normalise to forward-slash for reliable comparison
567
+ wt_norm = str(wt).replace("\\", "/")
568
+ if any(wt_norm in ln.replace("\\", "/") for ln in probe.stdout.splitlines()):
569
+ return str(wt), branch
570
+ # Stale -- clean up
571
+ subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
572
+ if wt.exists():
573
+ shutil.rmtree(wt)
574
+
575
+ # Does branch already exist?
576
+ check = subprocess.run(
577
+ ["git", "rev-parse", "--verify", branch],
578
+ cwd=project_path, capture_output=True, text=True,
579
+ )
580
+
581
+ if check.returncode == 0:
582
+ subprocess.run(
583
+ ["git", "worktree", "add", str(wt), branch],
584
+ cwd=project_path, check=True, capture_output=True, text=True,
585
+ )
586
+ else:
587
+ subprocess.run(
588
+ ["git", "worktree", "add", "-b", branch, str(wt)],
589
+ cwd=project_path, check=True, capture_output=True, text=True,
590
+ )
591
+
592
+ return str(wt), branch
593
+
594
+
595
+ def _remove_worktree(project_path: str, wt_path: str):
596
+ subprocess.run(
597
+ ["git", "worktree", "remove", "--force", wt_path],
598
+ cwd=project_path, capture_output=True,
599
+ )
600
+ subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Batch internal helpers (thread-safe, no rich print inside workers)
605
+ # ---------------------------------------------------------------------------
606
+
607
+
608
+ def _suggest_one(slug: str, project_path: str, id_or_number: str, tracker=None) -> dict:
609
+ """Run suggest for a single item. Thread-safe — returns result dict."""
610
+ try:
611
+ item = _resolve_item(slug, id_or_number)
612
+ except Exception as e:
613
+ return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
614
+
615
+ item_id = item["id"]
616
+ key = item.get("key") or f"#{item['number']}"
617
+
618
+ # Start agent session for streaming
619
+ try:
620
+ session = api_post(
621
+ "/api/agent-sessions/start",
622
+ json={"work_item_id": item_id, "origin": "cli", "phase": "suggest"},
623
+ params={"project_slug": slug},
624
+ )
625
+ session_id = session["id"]
626
+ except Exception:
627
+ session_id = None
628
+
629
+ if tracker and session_id:
630
+ tracker.update(key, "suggest", "running", "Starting analysis...")
631
+
632
+ knowledge = _read_knowledge(project_path)
633
+ comments = _get_item_comments(item_id)
634
+ comments_ctx = _format_comments_context(comments)
635
+ prompt = _build_suggest_prompt(item, knowledge, comments_ctx)
636
+
637
+ streamer = None
638
+ if session_id:
639
+ streamer = AgentStreamer(session_id)
640
+ if tracker:
641
+ streamer.set_callback(lambda p, t: tracker.update(key, "suggest", "running", (t or "")[:60]) if t else None)
642
+ streamer.start()
643
+ update_phase(session_id, "suggest")
644
+
645
+ try:
646
+ proc = subprocess.Popen(
647
+ ["claude", "-p", prompt, "--output-format", "stream-json"],
648
+ cwd=project_path,
649
+ stdout=subprocess.PIPE,
650
+ stderr=subprocess.PIPE,
651
+ text=True,
652
+ env=_claude_env(),
653
+ )
654
+
655
+ for line in proc.stdout:
656
+ if streamer:
657
+ streamer.feed(line)
658
+
659
+ proc.wait()
660
+ except FileNotFoundError:
661
+ if streamer:
662
+ streamer.stop()
663
+ return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
664
+
665
+ text_blocks = streamer.stop() if streamer else []
666
+
667
+ if proc.returncode != 0:
668
+ if session_id:
669
+ complete_session(session_id, "failed", error=f"Exit code {proc.returncode}")
670
+ return {"key": key, "status": "failed", "error": proc.stderr.read()[:200] if proc.stderr else "Non-zero exit"}
671
+
672
+ suggestion = "\n\n".join(text_blocks) if text_blocks else ""
673
+ if not suggestion:
674
+ if session_id:
675
+ complete_session(session_id, "failed", error="Empty response")
676
+ return {"key": key, "status": "failed", "error": "Empty response"}
677
+
678
+ # Post comment
679
+ api_post(f"/api/work-items/{item_id}/comments", json={
680
+ "body": suggestion,
681
+ "author": "bb-agent",
682
+ "type": "proposal",
683
+ })
684
+
685
+ # Advance status
686
+ if item["status"] == "open":
687
+ api_put(f"/api/work-items/{item_id}", json={"status": "confirmed"})
688
+
689
+ if session_id:
690
+ complete_session(session_id, "completed")
691
+
692
+ return {"key": key, "status": "ok", "suggestion": suggestion[:200]}
693
+
694
+
695
+ def _execute_one(slug: str, project_path: str, id_or_number: str, tracker=None) -> dict:
696
+ """Run execute for a single item in its own worktree. Thread-safe."""
697
+ try:
698
+ item = _resolve_item(slug, id_or_number)
699
+ except Exception as e:
700
+ return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
701
+
702
+ item_id = item["id"]
703
+ item_number = item["number"]
704
+ key = item.get("key") or f"#{item_number}"
705
+ knowledge = _read_knowledge(project_path)
706
+ comments = _get_item_comments(item_id)
707
+ comments_ctx = _format_comments_context(comments)
708
+ prompt = _build_execute_prompt(item, knowledge, comments_ctx)
709
+
710
+ # Create worktree
711
+ try:
712
+ work_dir, branch_name = _create_worktree(project_path, slug, item)
713
+ except subprocess.CalledProcessError as e:
714
+ return {"key": key, "status": "failed", "error": f"Worktree failed: {e.stderr or e}"}
715
+
716
+ # Agent session
717
+ try:
718
+ session = api_post(
719
+ "/api/agent-sessions/start",
720
+ json={"work_item_id": item_id, "origin": "cli", "phase": "execute"},
721
+ params={"project_slug": slug},
722
+ )
723
+ session_id = session["id"]
724
+ except Exception as e:
725
+ return {"key": key, "status": "failed", "error": f"Session start failed: {e}", "branch": branch_name}
726
+
727
+ # Update session with branch/worktree info
728
+ update_phase(session_id, "execute", branch_name=branch_name, worktree_path=work_dir)
729
+
730
+ # Status -> in_progress
731
+ if item["status"] in ("open", "confirmed", "approved"):
732
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
733
+
734
+ if tracker:
735
+ tracker.update(key, "execute", "running", "Starting implementation...")
736
+
737
+ # Streamer for buffered relay
738
+ streamer = AgentStreamer(session_id)
739
+ if tracker:
740
+ streamer.set_callback(lambda p, t: tracker.update(key, "execute", "running", (t or "")[:60]) if t else None)
741
+ streamer.start()
742
+
743
+ # MCP config
744
+ api_url = get_api_url()
745
+ token = get_token()
746
+ mcp_cfg = json.dumps({
747
+ "mcpServers": {
748
+ "bumblebee": {
749
+ "url": f"{api_url}/mcp",
750
+ "headers": {"Authorization": f"Bearer {token}"} if token else {},
751
+ }
752
+ }
753
+ })
754
+
755
+ try:
756
+ proc = subprocess.Popen(
757
+ [
758
+ "claude",
759
+ "--output-format", "stream-json",
760
+ "--verbose",
761
+ "--permission-mode", "bypassPermissions",
762
+ "--mcp-config", "-",
763
+ "-p", prompt,
764
+ ],
765
+ cwd=work_dir,
766
+ stdin=subprocess.PIPE,
767
+ stdout=subprocess.PIPE,
768
+ stderr=subprocess.PIPE,
769
+ text=True,
770
+ env=_claude_env(),
771
+ )
772
+
773
+ proc.stdin.write(mcp_cfg)
774
+ proc.stdin.close()
775
+
776
+ for line in proc.stdout:
777
+ streamer.feed(line)
778
+
779
+ proc.wait()
780
+
781
+ text_blocks = streamer.stop()
782
+
783
+ # Post completion comment
784
+ tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
785
+ body_lines = [
786
+ "## Agent Execution Report\n",
787
+ f"**Branch**: `{branch_name}`\n",
788
+ f"**Exit code**: `{proc.returncode}`\n",
789
+ f"\n### Output (last messages)\n\n{tail}",
790
+ ]
791
+ api_post(f"/api/work-items/{item_id}/comments", json={
792
+ "body": "\n".join(body_lines),
793
+ "author": "bb-agent",
794
+ "type": "agent_output",
795
+ })
796
+
797
+ if proc.returncode == 0:
798
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
799
+ complete_session(session_id, "completed")
800
+ return {"key": key, "status": "ok", "branch": branch_name, "worktree": work_dir}
801
+ else:
802
+ complete_session(session_id, "failed", error=f"Exit code {proc.returncode}")
803
+ return {"key": key, "status": "failed", "error": f"Exit code {proc.returncode}", "branch": branch_name}
804
+
805
+ except FileNotFoundError:
806
+ streamer.stop()
807
+ return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
808
+
809
+
810
+ def _test_one(slug: str, project_path: str, id_or_number: str, tracker=None) -> dict:
811
+ """Run test phase for a single item. Thread-safe — returns result dict."""
812
+ try:
813
+ item = _resolve_item(slug, id_or_number)
814
+ except Exception as e:
815
+ return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
816
+
817
+ item_id = item["id"]
818
+ item_number = item["number"]
819
+ key = item.get("key") or f"#{item_number}"
820
+
821
+ # Worktree must already exist
822
+ wt = _find_worktree(slug, item_number)
823
+ if not wt.exists():
824
+ return {"key": key, "status": "failed", "error": "No worktree found (execute first)"}
825
+
826
+ work_dir = str(wt)
827
+ knowledge = _read_knowledge(project_path)
828
+ comments = _get_item_comments(item_id)
829
+ comments_ctx = _format_comments_context(comments)
830
+ prompt = _build_test_prompt(item, knowledge, comments_ctx)
831
+
832
+ if tracker:
833
+ tracker.update(key, "test", "running", "Starting tests...")
834
+
835
+ # Create a session for streaming
836
+ session_id = None
837
+ try:
838
+ session = api_post(
839
+ "/api/agent-sessions/start",
840
+ json={"work_item_id": item_id, "origin": "cli", "phase": "test"},
841
+ params={"project_slug": slug},
842
+ )
843
+ session_id = session["id"]
844
+ except Exception:
845
+ pass
846
+
847
+ streamer = None
848
+ if session_id:
849
+ streamer = AgentStreamer(session_id)
850
+ if tracker:
851
+ streamer.set_callback(lambda p, t: tracker.update(key, "test", "running", (t or "")[:60]) if t else None)
852
+ streamer.start()
853
+ update_phase(session_id, "test")
854
+
855
+ try:
856
+ proc = subprocess.Popen(
857
+ ["claude", "-p", prompt, "--output-format", "stream-json"],
858
+ cwd=work_dir,
859
+ stdout=subprocess.PIPE,
860
+ stderr=subprocess.PIPE,
861
+ text=True,
862
+ env=_claude_env(),
863
+ )
864
+
865
+ for line in proc.stdout:
866
+ if streamer:
867
+ streamer.feed(line)
868
+
869
+ proc.wait()
870
+ except FileNotFoundError:
871
+ if streamer:
872
+ streamer.stop()
873
+ return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
874
+
875
+ text_blocks = streamer.stop() if streamer else []
876
+
877
+ if proc.returncode != 0:
878
+ if session_id:
879
+ complete_session(session_id, "failed", error=f"Exit code {proc.returncode}")
880
+ return {"key": key, "status": "failed", "error": (proc.stderr.read() if proc.stderr else "")[:200]}
881
+
882
+ report = "\n\n".join(text_blocks) if text_blocks else ""
883
+ if not report:
884
+ if session_id:
885
+ complete_session(session_id, "failed", error="Empty test report")
886
+ return {"key": key, "status": "failed", "error": "Empty test report"}
887
+
888
+ # Determine pass/fail from the report
889
+ report_lower = report.lower()
890
+ tests_passed = (
891
+ "**status**: pass" in report_lower
892
+ or "status: pass" in report_lower
893
+ or ("all tests pass" in report_lower and "fail" not in report_lower.split("all tests pass")[0][-50:])
894
+ )
895
+
896
+ # Post test report comment
897
+ api_post(f"/api/work-items/{item_id}/comments", json={
898
+ "body": report,
899
+ "author": "bb-agent",
900
+ "type": "test_report",
901
+ })
902
+
903
+ if tests_passed:
904
+ if session_id:
905
+ complete_session(session_id, "completed")
906
+ return {"key": key, "status": "ok", "report": report[:200]}
907
+ else:
908
+ api_put(f"/api/work-items/{item_id}", json={"status": "failed"})
909
+ if session_id:
910
+ complete_session(session_id, "failed", error="Tests failed")
911
+ return {"key": key, "status": "failed", "error": "Tests failed", "report": report[:200]}
912
+
913
+
914
+ def _reimplement_one(slug: str, project_path: str, id_or_number: str, docker_output: str = "", tracker=None) -> dict:
915
+ """Re-implement a failed item. Thread-safe returns result dict."""
916
+ try:
917
+ item = _resolve_item(slug, id_or_number)
918
+ except Exception as e:
919
+ return {"key": id_or_number, "status": "failed", "error": f"Resolve failed: {e}"}
920
+
921
+ item_id = item["id"]
922
+ item_number = item["number"]
923
+ key = item.get("key") or f"#{item_number}"
924
+
925
+ # Reuse existing worktree
926
+ wt = _find_worktree(slug, item_number)
927
+ if not wt.exists():
928
+ return {"key": key, "status": "failed", "error": "No worktree found (execute first)"}
929
+
930
+ work_dir = str(wt)
931
+ branch_name = _detect_worktree_branch(project_path, wt)
932
+ knowledge = _read_knowledge(project_path)
933
+ comments = _get_item_comments(item_id)
934
+ comments_ctx = _format_comments_context(comments)
935
+ prompt = _build_reimplement_prompt(item, knowledge, comments_ctx, docker_output=docker_output)
936
+
937
+ # Status -> in_progress
938
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
939
+
940
+ if tracker:
941
+ tracker.update(key, "reimplement", "running", "Re-implementing...")
942
+
943
+ # Agent session for streaming
944
+ session_id = None
945
+ try:
946
+ session = api_post(
947
+ "/api/agent-sessions/start",
948
+ json={"work_item_id": item_id, "origin": "cli", "phase": "reimplement"},
949
+ params={"project_slug": slug},
950
+ )
951
+ session_id = session["id"]
952
+ except Exception:
953
+ pass
954
+
955
+ streamer = None
956
+ if session_id:
957
+ streamer = AgentStreamer(session_id)
958
+ if tracker:
959
+ streamer.set_callback(lambda p, t: tracker.update(key, "reimplement", "running", (t or "")[:60]) if t else None)
960
+ streamer.start()
961
+ update_phase(session_id, "reimplement", branch_name=branch_name or "", worktree_path=work_dir)
962
+
963
+ # MCP config
964
+ api_url = get_api_url()
965
+ token = get_token()
966
+ mcp_cfg = json.dumps({
967
+ "mcpServers": {
968
+ "bumblebee": {
969
+ "url": f"{api_url}/mcp",
970
+ "headers": {"Authorization": f"Bearer {token}"} if token else {},
971
+ }
972
+ }
973
+ })
974
+
975
+ try:
976
+ proc = subprocess.Popen(
977
+ [
978
+ "claude",
979
+ "--output-format", "stream-json",
980
+ "--verbose",
981
+ "--permission-mode", "bypassPermissions",
982
+ "--mcp-config", "-",
983
+ "-p", prompt,
984
+ ],
985
+ cwd=work_dir,
986
+ stdin=subprocess.PIPE,
987
+ stdout=subprocess.PIPE,
988
+ stderr=subprocess.PIPE,
989
+ text=True,
990
+ env=_claude_env(),
991
+ )
992
+
993
+ proc.stdin.write(mcp_cfg)
994
+ proc.stdin.close()
995
+
996
+ for line in proc.stdout:
997
+ if streamer:
998
+ streamer.feed(line)
999
+
1000
+ proc.wait()
1001
+
1002
+ text_blocks = streamer.stop() if streamer else []
1003
+
1004
+ # Post re-implementation report
1005
+ tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
1006
+ body_lines = [
1007
+ "## Re-implementation Report\n",
1008
+ f"**Branch**: `{branch_name or 'unknown'}`\n",
1009
+ f"**Exit code**: `{proc.returncode}`\n",
1010
+ f"\n### Output (last messages)\n\n{tail}",
1011
+ ]
1012
+ api_post(f"/api/work-items/{item_id}/comments", json={
1013
+ "body": "\n".join(body_lines),
1014
+ "author": "bb-agent",
1015
+ "type": "agent_output",
1016
+ })
1017
+
1018
+ if proc.returncode == 0:
1019
+ if session_id:
1020
+ complete_session(session_id, "completed")
1021
+ return {"key": key, "status": "ok", "branch": branch_name, "worktree": work_dir}
1022
+ else:
1023
+ if session_id:
1024
+ complete_session(session_id, "failed", error=f"Exit code {proc.returncode}")
1025
+ return {"key": key, "status": "failed", "error": f"Exit code {proc.returncode}", "branch": branch_name}
1026
+
1027
+ except FileNotFoundError:
1028
+ if streamer:
1029
+ streamer.stop()
1030
+ return {"key": key, "status": "failed", "error": "'claude' CLI not found"}
1031
+
1032
+
1033
+ def _docker_test_one(slug: str, project_path: str, id_or_number: str, timeout: int = 600) -> dict:
1034
+ """Run Docker-based tests for a single item. Thread-safe — returns result dict."""
1035
+ try:
1036
+ item = _resolve_item(slug, id_or_number)
1037
+ except Exception as e:
1038
+ return {"key": id_or_number, "status": "failed", "output": "", "details": f"Resolve failed: {e}"}
1039
+
1040
+ item_id = item["id"]
1041
+ item_number = item["number"]
1042
+ key = item.get("key") or f"#{item_number}"
1043
+
1044
+ # Worktree must already exist
1045
+ wt = _find_worktree(slug, item_number)
1046
+ if not wt.exists():
1047
+ return {"key": key, "status": "failed", "output": "", "details": "No worktree found (execute first)"}
1048
+
1049
+ work_dir = str(wt)
1050
+
1051
+ # Ensure Docker files exist in worktree
1052
+ _ensure_compose_files(work_dir, project_path)
1053
+
1054
+ # Run Docker tests
1055
+ success, raw_output, details = _run_docker_tests(work_dir, timeout)
1056
+
1057
+ # Post test results as comment
1058
+ if success:
1059
+ body = f"## Docker Test Report\n\n**Status**: PASS\n\n{details}"
1060
+ comment_type = "test_report"
1061
+ else:
1062
+ truncated = _truncate_output(raw_output, 4000)
1063
+ body = f"## Docker Test Report\n\n**Status**: FAIL\n\n{details}\n\n### Docker Output\n\n```\n{truncated}\n```"
1064
+ comment_type = "test_failure"
1065
+
1066
+ api_post(f"/api/work-items/{item_id}/comments", json={
1067
+ "body": body,
1068
+ "author": "bb-agent",
1069
+ "type": comment_type,
1070
+ })
1071
+
1072
+ status = "ok" if success else "failed"
1073
+ return {"key": key, "status": status, "output": raw_output, "details": details}
1074
+
1075
+
1076
+ # ---------------------------------------------------------------------------
1077
+ # Commands
1078
+ # ---------------------------------------------------------------------------
1079
+
1080
+
1081
+ @app.command()
1082
+ def suggest(
1083
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to analyse"),
1084
+ ):
1085
+ """Phase 1: Analyse a work item and post a solution plan as a comment."""
1086
+ slug = _require_project()
1087
+ project_path = _require_project_path(slug)
1088
+
1089
+ rprint(f"[cyan]Fetching work item {id_or_number}...[/cyan]")
1090
+ item = _resolve_item(slug, id_or_number)
1091
+ item_id = item["id"]
1092
+ knowledge = _read_knowledge(project_path)
1093
+ comments = _get_item_comments(item_id)
1094
+ comments_ctx = _format_comments_context(comments)
1095
+ prompt = _build_suggest_prompt(item, knowledge, comments_ctx)
1096
+
1097
+ rprint(f"[cyan]Running Claude Code analysis in {project_path}...[/cyan]")
1098
+
1099
+ try:
1100
+ result = subprocess.run(
1101
+ ["claude", "-p", prompt, "--output-format", "text"],
1102
+ cwd=project_path,
1103
+ capture_output=True,
1104
+ text=True,
1105
+ timeout=600,
1106
+ env=_claude_env(),
1107
+ )
1108
+
1109
+ if result.returncode != 0:
1110
+ rprint(f"[red]Claude analysis failed:[/red]\n{result.stderr}")
1111
+ raise typer.Exit(1)
1112
+
1113
+ suggestion = result.stdout.strip()
1114
+ if not suggestion:
1115
+ rprint("[red]Claude returned an empty response.[/red]")
1116
+ raise typer.Exit(1)
1117
+
1118
+ rprint()
1119
+ key = item.get("key") or f"#{item['number']}"
1120
+ rprint(Panel(
1121
+ Markdown(suggestion),
1122
+ title=f"Suggested Solution -- {key}",
1123
+ border_style="green",
1124
+ ))
1125
+
1126
+ # Post as agent comment
1127
+ api_post(f"/api/work-items/{item_id}/comments", json={
1128
+ "body": suggestion,
1129
+ "author": "bb-agent",
1130
+ "type": "proposal",
1131
+ })
1132
+ rprint("[green]Suggestion posted as comment on the work item.[/green]")
1133
+
1134
+ # Save plan to work item
1135
+ api_put(f"/api/work-items/{item_id}", json={"plan": suggestion})
1136
+ rprint("[green]Plan saved to work item.[/green]")
1137
+
1138
+ # Advance status open -> confirmed
1139
+ if item["status"] == "open":
1140
+ api_put(f"/api/work-items/{item_id}", json={"status": "confirmed"})
1141
+ rprint("[dim]Status -> confirmed[/dim]")
1142
+
1143
+ except FileNotFoundError:
1144
+ rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
1145
+ raise typer.Exit(1)
1146
+ except subprocess.TimeoutExpired:
1147
+ rprint("[red]Analysis timed out (10 min limit).[/red]")
1148
+ raise typer.Exit(1)
1149
+
1150
+
1151
+ @app.command()
1152
+ def execute(
1153
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to implement"),
1154
+ no_worktree: bool = typer.Option(False, "--no-worktree", help="Work in main directory (skip worktree)"),
1155
+ cleanup: bool = typer.Option(False, "--cleanup", help="Remove worktree after completion"),
1156
+ ):
1157
+ """Phase 2: Create a worktree and implement the work item with Claude Code."""
1158
+ slug = _require_project()
1159
+ project_path = _require_project_path(slug)
1160
+
1161
+ # Context
1162
+ item = _resolve_item(slug, id_or_number)
1163
+ item_id = item["id"]
1164
+ item_number = item["number"]
1165
+ knowledge = _read_knowledge(project_path)
1166
+ comments = _get_item_comments(item_id)
1167
+ comments_ctx = _format_comments_context(comments)
1168
+ prompt = _build_execute_prompt(item, knowledge, comments_ctx)
1169
+
1170
+ # Worktree
1171
+ work_dir = project_path
1172
+ branch_name = None
1173
+
1174
+ if not no_worktree:
1175
+ try:
1176
+ key = item.get("key") or f"#{item_number}"
1177
+ rprint(f"[cyan]Creating worktree for {key}...[/cyan]")
1178
+ work_dir, branch_name = _create_worktree(project_path, slug, item)
1179
+ rprint(f"[green]Worktree: {work_dir}[/green]")
1180
+ rprint(f"[green]Branch: {branch_name}[/green]")
1181
+ except subprocess.CalledProcessError as e:
1182
+ rprint(f"[red]Worktree failed: {e.stderr or e}[/red]")
1183
+ rprint("[yellow]Falling back to main directory.[/yellow]")
1184
+
1185
+ # Agent session
1186
+ session = api_post(
1187
+ "/api/agent-sessions/start",
1188
+ json={"work_item_id": item_id, "origin": "cli"},
1189
+ params={"project_slug": slug},
1190
+ )
1191
+ session_id = session["id"]
1192
+ rprint(f"[green]Session: {session_id}[/green]")
1193
+
1194
+ # Status -> in_progress
1195
+ if item["status"] in ("open", "confirmed", "approved"):
1196
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
1197
+ rprint("[dim]Status -> in_progress[/dim]")
1198
+
1199
+ # MCP config (Bumblebee tools for Claude)
1200
+ api_url = get_api_url()
1201
+ token = get_token()
1202
+ mcp_cfg = json.dumps({
1203
+ "mcpServers": {
1204
+ "bumblebee": {
1205
+ "url": f"{api_url}/mcp",
1206
+ "headers": {"Authorization": f"Bearer {token}"} if token else {},
1207
+ }
1208
+ }
1209
+ })
1210
+
1211
+ rprint(f"\n[cyan]Spawning Claude Code agent in {work_dir}...[/cyan]\n")
1212
+
1213
+ try:
1214
+ proc = subprocess.Popen(
1215
+ [
1216
+ "claude",
1217
+ "--output-format", "stream-json",
1218
+ "--verbose",
1219
+ "--permission-mode", "bypassPermissions",
1220
+ "--mcp-config", "-",
1221
+ "-p", prompt,
1222
+ ],
1223
+ cwd=work_dir,
1224
+ stdin=subprocess.PIPE,
1225
+ stdout=subprocess.PIPE,
1226
+ stderr=subprocess.PIPE,
1227
+ text=True,
1228
+ env=_claude_env(),
1229
+ )
1230
+
1231
+ proc.stdin.write(mcp_cfg)
1232
+ proc.stdin.close()
1233
+
1234
+ # Stream output -> terminal + API relay
1235
+ text_blocks: list[str] = []
1236
+ for line in proc.stdout:
1237
+ line = line.strip()
1238
+ if not line:
1239
+ continue
1240
+ try:
1241
+ payload = json.loads(line)
1242
+ if payload.get("type") == "assistant":
1243
+ for block in payload.get("content", []):
1244
+ if block.get("type") == "text":
1245
+ rprint(block["text"])
1246
+ text_blocks.append(block["text"])
1247
+ try:
1248
+ api_post(f"/api/agent-sessions/{session_id}/relay", json=payload)
1249
+ except Exception:
1250
+ pass
1251
+ except json.JSONDecodeError:
1252
+ rprint(f"[dim]{line}[/dim]")
1253
+
1254
+ proc.wait()
1255
+
1256
+ # Completion comment
1257
+ tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
1258
+ body_lines = ["## Agent Execution Report\n"]
1259
+ if branch_name:
1260
+ body_lines.append(f"**Branch**: `{branch_name}`\n")
1261
+ body_lines.append(f"**Exit code**: `{proc.returncode}`\n")
1262
+ body_lines.append(f"\n### Output (last messages)\n\n{tail}")
1263
+
1264
+ api_post(f"/api/work-items/{item_id}/comments", json={
1265
+ "body": "\n".join(body_lines),
1266
+ "author": "bb-agent",
1267
+ "type": "agent_output",
1268
+ })
1269
+
1270
+ if proc.returncode == 0:
1271
+ rprint("\n[green]Agent completed successfully.[/green]")
1272
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
1273
+ rprint("[dim]Status -> in_review[/dim]")
1274
+ else:
1275
+ rprint(f"\n[yellow]Agent exited with code {proc.returncode}.[/yellow]")
1276
+
1277
+ # Worktree post-run
1278
+ if branch_name and work_dir != project_path:
1279
+ if cleanup:
1280
+ _remove_worktree(project_path, work_dir)
1281
+ rprint("[dim]Worktree removed.[/dim]")
1282
+ else:
1283
+ rprint(f"\n[dim]Worktree: {work_dir}[/dim]")
1284
+ rprint(f"[dim]Merge: cd {project_path} && git merge {branch_name}[/dim]")
1285
+ rprint(f"[dim]Cleanup: bb agent cleanup {item_number}[/dim]")
1286
+
1287
+ except FileNotFoundError:
1288
+ rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
1289
+ raise typer.Exit(1)
1290
+ except KeyboardInterrupt:
1291
+ rprint("\n[yellow]Agent interrupted.[/yellow]")
1292
+ api_post(f"/api/agent-sessions/{session_id}/abort")
1293
+ api_post(f"/api/work-items/{item_id}/comments", json={
1294
+ "body": "## Agent Interrupted\n\nManually stopped by user.",
1295
+ "author": "bb-agent",
1296
+ "type": "agent_output",
1297
+ })
1298
+
1299
+
1300
+ @app.command(name="test")
1301
+ def test_item(
1302
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to test"),
1303
+ ):
1304
+ """Phase 3: Run tests in the worktree and report results."""
1305
+ slug = _require_project()
1306
+ project_path = _require_project_path(slug)
1307
+
1308
+ rprint(f"[cyan]Fetching work item {id_or_number}...[/cyan]")
1309
+ item = _resolve_item(slug, id_or_number)
1310
+ item_id = item["id"]
1311
+ item_number = item["number"]
1312
+ key = item.get("key") or f"#{item_number}"
1313
+
1314
+ # Worktree must already exist
1315
+ wt = _find_worktree(slug, item_number)
1316
+ if not wt.exists():
1317
+ rprint(f"[red]No worktree found for {key}. Run 'bb agent execute {id_or_number}' first.[/red]")
1318
+ raise typer.Exit(1)
1319
+
1320
+ work_dir = str(wt)
1321
+ knowledge = _read_knowledge(project_path)
1322
+ comments = _get_item_comments(item_id)
1323
+ comments_ctx = _format_comments_context(comments)
1324
+ prompt = _build_test_prompt(item, knowledge, comments_ctx)
1325
+
1326
+ rprint(f"[cyan]Running tests in {work_dir}...[/cyan]")
1327
+
1328
+ try:
1329
+ result = subprocess.run(
1330
+ ["claude", "-p", prompt, "--output-format", "text"],
1331
+ cwd=work_dir,
1332
+ capture_output=True,
1333
+ text=True,
1334
+ timeout=600,
1335
+ env=_claude_env(),
1336
+ )
1337
+
1338
+ if result.returncode != 0:
1339
+ rprint(f"[red]Claude test runner failed:[/red]\n{result.stderr}")
1340
+ raise typer.Exit(1)
1341
+
1342
+ report = result.stdout.strip()
1343
+ if not report:
1344
+ rprint("[red]Empty test report.[/red]")
1345
+ raise typer.Exit(1)
1346
+
1347
+ rprint()
1348
+ rprint(Panel(
1349
+ Markdown(report),
1350
+ title=f"Test Report -- {key}",
1351
+ border_style="cyan",
1352
+ ))
1353
+
1354
+ # Post test report comment
1355
+ api_post(f"/api/work-items/{item_id}/comments", json={
1356
+ "body": report,
1357
+ "author": "bb-agent",
1358
+ "type": "test_report",
1359
+ })
1360
+ rprint("[green]Test report posted as comment.[/green]")
1361
+
1362
+ # Determine pass/fail
1363
+ report_lower = report.lower()
1364
+ tests_passed = (
1365
+ "**status**: pass" in report_lower
1366
+ or "status: pass" in report_lower
1367
+ or ("all tests pass" in report_lower and "fail" not in report_lower.split("all tests pass")[0][-50:])
1368
+ )
1369
+
1370
+ if tests_passed:
1371
+ rprint("[green]All tests passed![/green]")
1372
+ else:
1373
+ api_put(f"/api/work-items/{item_id}", json={"status": "failed"})
1374
+ rprint("[red]Tests failed. Status -> failed[/red]")
1375
+ rprint(f"[dim]Fix with: bb agent reimplement {id_or_number}[/dim]")
1376
+ raise typer.Exit(1)
1377
+
1378
+ except FileNotFoundError:
1379
+ rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
1380
+ raise typer.Exit(1)
1381
+ except subprocess.TimeoutExpired:
1382
+ rprint("[red]Test run timed out (10 min limit).[/red]")
1383
+ raise typer.Exit(1)
1384
+
1385
+
1386
+ @app.command()
1387
+ def reimplement(
1388
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to re-implement"),
1389
+ run_tests: bool = typer.Option(True, "--test/--no-test", help="Run tests after re-implementation"),
1390
+ auto_merge: bool = typer.Option(False, "--auto-merge", help="Auto-merge to target on test pass"),
1391
+ target: str = typer.Option("release/dev", "--target", "-t", help="Target branch for auto-merge"),
1392
+ ):
1393
+ """Re-implement a failed work item using previous feedback."""
1394
+ slug = _require_project()
1395
+ project_path = _require_project_path(slug)
1396
+
1397
+ rprint(f"[cyan]Fetching work item {id_or_number}...[/cyan]")
1398
+ item = _resolve_item(slug, id_or_number)
1399
+ item_id = item["id"]
1400
+ item_number = item["number"]
1401
+ key = item.get("key") or f"#{item_number}"
1402
+
1403
+ # Worktree must already exist
1404
+ wt = _find_worktree(slug, item_number)
1405
+ if not wt.exists():
1406
+ rprint(f"[red]No worktree found for {key}. Run 'bb agent execute {id_or_number}' first.[/red]")
1407
+ raise typer.Exit(1)
1408
+
1409
+ work_dir = str(wt)
1410
+ branch_name = _detect_worktree_branch(project_path, wt)
1411
+ knowledge = _read_knowledge(project_path)
1412
+ comments = _get_item_comments(item_id)
1413
+ comments_ctx = _format_comments_context(comments)
1414
+ prompt = _build_reimplement_prompt(item, knowledge, comments_ctx)
1415
+
1416
+ # Status -> in_progress
1417
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_progress"})
1418
+ rprint("[dim]Status -> in_progress[/dim]")
1419
+
1420
+ # Agent session
1421
+ session = api_post(
1422
+ "/api/agent-sessions/start",
1423
+ json={"work_item_id": item_id, "origin": "cli"},
1424
+ params={"project_slug": slug},
1425
+ )
1426
+ session_id = session["id"]
1427
+ rprint(f"[green]Session: {session_id}[/green]")
1428
+
1429
+ # MCP config
1430
+ api_url = get_api_url()
1431
+ token = get_token()
1432
+ mcp_cfg = json.dumps({
1433
+ "mcpServers": {
1434
+ "bumblebee": {
1435
+ "url": f"{api_url}/mcp",
1436
+ "headers": {"Authorization": f"Bearer {token}"} if token else {},
1437
+ }
1438
+ }
1439
+ })
1440
+
1441
+ rprint(f"\n[cyan]Re-implementing in {work_dir}...[/cyan]\n")
1442
+
1443
+ try:
1444
+ proc = subprocess.Popen(
1445
+ [
1446
+ "claude",
1447
+ "--output-format", "stream-json",
1448
+ "--verbose",
1449
+ "--permission-mode", "bypassPermissions",
1450
+ "--mcp-config", "-",
1451
+ "-p", prompt,
1452
+ ],
1453
+ cwd=work_dir,
1454
+ stdin=subprocess.PIPE,
1455
+ stdout=subprocess.PIPE,
1456
+ stderr=subprocess.PIPE,
1457
+ text=True,
1458
+ env=_claude_env(),
1459
+ )
1460
+
1461
+ proc.stdin.write(mcp_cfg)
1462
+ proc.stdin.close()
1463
+
1464
+ text_blocks: list[str] = []
1465
+ for line in proc.stdout:
1466
+ line = line.strip()
1467
+ if not line:
1468
+ continue
1469
+ try:
1470
+ payload = json.loads(line)
1471
+ if payload.get("type") == "assistant":
1472
+ for block in payload.get("content", []):
1473
+ if block.get("type") == "text":
1474
+ rprint(block["text"])
1475
+ text_blocks.append(block["text"])
1476
+ try:
1477
+ api_post(f"/api/agent-sessions/{session_id}/relay", json=payload)
1478
+ except Exception:
1479
+ pass
1480
+ except json.JSONDecodeError:
1481
+ rprint(f"[dim]{line}[/dim]")
1482
+
1483
+ proc.wait()
1484
+
1485
+ # Post re-implementation report
1486
+ tail = "\n\n".join(text_blocks[-3:]) if text_blocks else "No text output captured."
1487
+ body_lines = ["## Re-implementation Report\n"]
1488
+ if branch_name:
1489
+ body_lines.append(f"**Branch**: `{branch_name}`\n")
1490
+ body_lines.append(f"**Exit code**: `{proc.returncode}`\n")
1491
+ body_lines.append(f"\n### Output (last messages)\n\n{tail}")
1492
+
1493
+ api_post(f"/api/work-items/{item_id}/comments", json={
1494
+ "body": "\n".join(body_lines),
1495
+ "author": "bb-agent",
1496
+ "type": "agent_output",
1497
+ })
1498
+
1499
+ if proc.returncode != 0:
1500
+ rprint(f"\n[yellow]Agent exited with code {proc.returncode}.[/yellow]")
1501
+ return
1502
+
1503
+ rprint("\n[green]Re-implementation completed.[/green]")
1504
+
1505
+ # Auto-test after re-implementation
1506
+ if run_tests:
1507
+ rprint("\n[cyan]Running tests...[/cyan]")
1508
+ test_result = _test_one(slug, project_path, id_or_number)
1509
+ if test_result["status"] == "ok":
1510
+ rprint("[green]All tests passed![/green]")
1511
+ if auto_merge and branch_name:
1512
+ rprint(f"\n[cyan]Merging {branch_name} into {target}...[/cyan]")
1513
+ merge_ok = _do_single_merge(project_path, branch_name, target)
1514
+ if merge_ok:
1515
+ rprint(f"[green]Merged into {target}.[/green]")
1516
+ api_put(f"/api/work-items/{item_id}", json={"status": "resolved"})
1517
+ rprint("[dim]Status -> resolved[/dim]")
1518
+ else:
1519
+ rprint("[red]Merge failed. Resolve conflicts manually.[/red]")
1520
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
1521
+ else:
1522
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
1523
+ rprint("[dim]Status -> in_review[/dim]")
1524
+ else:
1525
+ rprint("[red]Tests still failing after re-implementation.[/red]")
1526
+ rprint(f"[dim]Try again: bb agent reimplement {id_or_number}[/dim]")
1527
+ else:
1528
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
1529
+ rprint("[dim]Status -> in_review[/dim]")
1530
+
1531
+ except FileNotFoundError:
1532
+ rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
1533
+ raise typer.Exit(1)
1534
+ except KeyboardInterrupt:
1535
+ rprint("\n[yellow]Agent interrupted.[/yellow]")
1536
+ api_post(f"/api/agent-sessions/{session_id}/abort")
1537
+
1538
+
1539
+ @app.command()
1540
+ def run(
1541
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number"),
1542
+ skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip the requirement verification phase"),
1543
+ yes: bool = typer.Option(False, "--yes", "-y", help="Auto-confirm after verification"),
1544
+ no_worktree: bool = typer.Option(False, "--no-worktree", help="Skip worktree creation"),
1545
+ auto_merge: bool = typer.Option(True, "--auto-merge/--no-auto-merge", help="Auto-merge to target branch on test pass"),
1546
+ target: str = typer.Option("release/dev", "--target", "-t", help="Target branch for auto-merge"),
1547
+ max_retries: int = typer.Option(3, "--max-retries", help="Max re-implementation attempts on test failure"),
1548
+ timeout: int = typer.Option(600, "--timeout", help="Docker test timeout in seconds"),
1549
+ ):
1550
+ """Full autonomous loop: verify -> execute -> Docker test -> reimplement retry -> merge."""
1551
+ slug = _require_project()
1552
+ project_path = _require_project_path(slug)
1553
+
1554
+ # Phase 1: Verify requirements
1555
+ if not skip_verify:
1556
+ rprint("[bold cyan]Phase 1: Verifying requirements...[/bold cyan]\n")
1557
+ try:
1558
+ verify(id_or_number)
1559
+ except SystemExit as e:
1560
+ if e.code:
1561
+ # verify() exited with error (NEEDS_INFO or failure)
1562
+ rprint("\n[red]Verification failed — aborting run.[/red]")
1563
+ raise typer.Exit(1)
1564
+ if not yes:
1565
+ rprint()
1566
+ if not typer.confirm("Proceed with implementation?"):
1567
+ rprint(f"[yellow]Aborted. Run [bold]bb agent execute {id_or_number}[/bold] when ready.[/yellow]")
1568
+ raise typer.Exit()
1569
+ else:
1570
+ rprint("[dim]Skipping verification phase.[/dim]")
1571
+
1572
+ # Phase 2: Execute
1573
+ rprint("\n[bold cyan]Phase 2: Implementing...[/bold cyan]\n")
1574
+ execute(id_or_number, no_worktree=no_worktree, cleanup=False)
1575
+
1576
+ # Phase 3: Docker test
1577
+ rprint("\n[bold cyan]Phase 3: Docker testing...[/bold cyan]")
1578
+ docker_result = _docker_test_one(slug, project_path, id_or_number, timeout=timeout)
1579
+
1580
+ if docker_result["status"] == "ok":
1581
+ rprint("[green]Docker tests passed![/green]")
1582
+ else:
1583
+ # Tests failed — retry loop
1584
+ rprint("[red]Docker tests failed.[/red]")
1585
+ docker_output = docker_result.get("output", "")
1586
+ retries_done = 0
1587
+ while retries_done < max_retries:
1588
+ retries_done += 1
1589
+ rprint(f"\n[bold cyan]Retry {retries_done}/{max_retries}: Re-implementing...[/bold cyan]")
1590
+
1591
+ reimpl_result = _reimplement_one(slug, project_path, id_or_number, docker_output=docker_output)
1592
+ if reimpl_result["status"] != "ok":
1593
+ rprint(f"[red]Re-implementation failed: {reimpl_result.get('error', '?')}[/red]")
1594
+ continue
1595
+
1596
+ rprint("[cyan]Re-testing with Docker...[/cyan]")
1597
+ docker_result = _docker_test_one(slug, project_path, id_or_number, timeout=timeout)
1598
+ if docker_result["status"] == "ok":
1599
+ rprint("[green]Docker tests passed after re-implementation![/green]")
1600
+ break
1601
+ docker_output = docker_result.get("output", "")
1602
+ else:
1603
+ # All retries exhausted
1604
+ item = _resolve_item(slug, id_or_number)
1605
+ item_id = item["id"]
1606
+ api_put(f"/api/work-items/{item_id}", json={"status": "failed"})
1607
+ rprint("[dim]Status -> failed[/dim]")
1608
+
1609
+ # Post failure summary
1610
+ api_post(f"/api/work-items/{item_id}/comments", json={
1611
+ "body": (
1612
+ f"## Agent Run Failed\n\n"
1613
+ f"All {max_retries} re-implementation retries exhausted.\n\n"
1614
+ f"### Last Docker Output\n\n{docker_result.get('details', 'No details')}\n\n"
1615
+ f"Manual intervention required."
1616
+ ),
1617
+ "author": "bb-agent",
1618
+ "type": "test_failure",
1619
+ })
1620
+
1621
+ rprint(f"\n[red]All {max_retries} retries exhausted.[/red]")
1622
+ rprint(f"[dim]Status -> failed. Manual fix: bb agent continue {id_or_number}[/dim]")
1623
+ return
1624
+
1625
+ # Phase 4: Merge (only reached if Docker tests passed)
1626
+ if auto_merge:
1627
+ item = _resolve_item(slug, id_or_number)
1628
+ item_id = item["id"]
1629
+ item_number = item["number"]
1630
+ branch_name = _detect_worktree_branch(project_path, _find_worktree(slug, item_number))
1631
+ if branch_name:
1632
+ rprint(f"\n[bold cyan]Phase 4: Merging {branch_name} into {target}...[/bold cyan]")
1633
+ merge_ok = _do_single_merge(project_path, branch_name, target)
1634
+ if merge_ok:
1635
+ rprint(f"[green]Merged into {target}.[/green]")
1636
+ api_put(f"/api/work-items/{item_id}", json={"status": "resolved"})
1637
+ rprint("[dim]Status -> resolved[/dim]")
1638
+ wt = _find_worktree(slug, item_number)
1639
+ _remove_worktree(project_path, str(wt))
1640
+ rprint("[dim]Worktree cleaned up.[/dim]")
1641
+ else:
1642
+ rprint("[red]Merge failed — conflicts detected.[/red]")
1643
+ api_put(f"/api/work-items/{item_id}", json={"status": "in_review"})
1644
+ rprint("[dim]Status -> in_review (manual merge needed)[/dim]")
1645
+ else:
1646
+ rprint("[yellow]Could not detect branch — skipping merge.[/yellow]")
1647
+ else:
1648
+ rprint("[dim]Auto-merge disabled. Merge manually when ready.[/dim]")
1649
+
1650
+
1651
+ @app.command(name="continue")
1652
+ def continue_work(
1653
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to continue"),
1654
+ ):
1655
+ """Continue a previous agent run (reads prior comments for context)."""
1656
+ execute(id_or_number, no_worktree=False, cleanup=False)
1657
+
1658
+
1659
+ @app.command()
1660
+ def verify(
1661
+ id_or_number: str = typer.Argument(..., help="Work item ID, number, or KEY-number to verify"),
1662
+ ):
1663
+ """Phase 0: Verify requirements — analyse feasibility and return READY or NEEDS_INFO verdict."""
1664
+ slug = _require_project()
1665
+ project_path = _require_project_path(slug)
1666
+
1667
+ rprint(f"[cyan]Fetching work item {id_or_number}...[/cyan]")
1668
+ item = _resolve_item(slug, id_or_number)
1669
+ item_id = item["id"]
1670
+ key = item.get("key") or f"#{item['number']}"
1671
+ knowledge = _read_knowledge(project_path)
1672
+ comments = _get_item_comments(item_id)
1673
+ comments_ctx = _format_comments_context(comments)
1674
+ prompt = _build_verify_prompt(item, knowledge, comments_ctx)
1675
+
1676
+ rprint(f"[cyan]Running requirement analysis for {key}...[/cyan]")
1677
+
1678
+ # Create session for streaming
1679
+ session_id = None
1680
+ try:
1681
+ session = api_post(
1682
+ "/api/agent-sessions/start",
1683
+ json={"work_item_id": item_id, "origin": "cli", "phase": "verify"},
1684
+ params={"project_slug": slug},
1685
+ )
1686
+ session_id = session["id"]
1687
+ except Exception:
1688
+ pass
1689
+
1690
+ streamer = None
1691
+ if session_id:
1692
+ streamer = AgentStreamer(session_id)
1693
+ streamer.start()
1694
+ update_phase(session_id, "verify")
1695
+
1696
+ try:
1697
+ proc = subprocess.Popen(
1698
+ ["claude", "-p", prompt, "--output-format", "stream-json"],
1699
+ cwd=project_path,
1700
+ stdout=subprocess.PIPE,
1701
+ stderr=subprocess.PIPE,
1702
+ text=True,
1703
+ env=_claude_env(),
1704
+ )
1705
+
1706
+ for line in proc.stdout:
1707
+ if streamer:
1708
+ streamer.feed(line)
1709
+
1710
+ proc.wait()
1711
+
1712
+ text_blocks = streamer.stop() if streamer else []
1713
+
1714
+ if proc.returncode != 0:
1715
+ stderr_out = proc.stderr.read() if proc.stderr else ""
1716
+ rprint(f"[red]Claude analysis failed:[/red]\n{stderr_out}")
1717
+ if session_id:
1718
+ complete_session(session_id, "failed", error=f"Exit code {proc.returncode}")
1719
+ raise typer.Exit(1)
1720
+
1721
+ analysis = "\n\n".join(text_blocks) if text_blocks else ""
1722
+ if not analysis:
1723
+ rprint("[red]Claude returned an empty response.[/red]")
1724
+ if session_id:
1725
+ complete_session(session_id, "failed", error="Empty response")
1726
+ raise typer.Exit(1)
1727
+
1728
+ rprint()
1729
+ rprint(Panel(
1730
+ Markdown(analysis),
1731
+ title=f"Requirement Analysis -- {key}",
1732
+ border_style="cyan",
1733
+ ))
1734
+
1735
+ # Parse verdict from output
1736
+ analysis_upper = analysis.upper()
1737
+ if "VERDICT: READY" in analysis_upper:
1738
+ verdict = "ready"
1739
+ elif "VERDICT: NEEDS_INFO" in analysis_upper:
1740
+ verdict = "needs_info"
1741
+ else:
1742
+ rprint("[yellow]No explicit verdict found in analysis. Defaulting to READY.[/yellow]")
1743
+ verdict = "ready"
1744
+
1745
+ if verdict == "ready":
1746
+ # Post as proposal comment and save plan
1747
+ api_post(f"/api/work-items/{item_id}/comments", json={
1748
+ "body": analysis,
1749
+ "author": "bb-agent",
1750
+ "type": "proposal",
1751
+ })
1752
+ rprint("[green]Analysis posted as proposal comment.[/green]")
1753
+
1754
+ api_put(f"/api/work-items/{item_id}", json={"plan": analysis})
1755
+ rprint("[green]Plan saved to work item.[/green]")
1756
+
1757
+ if item["status"] == "open":
1758
+ api_put(f"/api/work-items/{item_id}", json={"status": "confirmed"})
1759
+ rprint("[dim]Status -> confirmed[/dim]")
1760
+
1761
+ if session_id:
1762
+ complete_session(session_id, "completed")
1763
+ rprint(f"\n[green]VERDICT: READY — {key} is ready for implementation.[/green]")
1764
+ else:
1765
+ # Post as analysis comment, set needs_info
1766
+ api_post(f"/api/work-items/{item_id}/comments", json={
1767
+ "body": analysis,
1768
+ "author": "bb-agent",
1769
+ "type": "analysis",
1770
+ })
1771
+ rprint("[yellow]Analysis posted as comment.[/yellow]")
1772
+
1773
+ api_put(f"/api/work-items/{item_id}", json={"status": "needs_info"})
1774
+ rprint("[dim]Status -> needs_info[/dim]")
1775
+
1776
+ if session_id:
1777
+ complete_session(session_id, "failed", error="NEEDS_INFO")
1778
+ rprint(f"\n[yellow]VERDICT: NEEDS_INFO — {key} requires clarification before implementation.[/yellow]")
1779
+ raise typer.Exit(1)
1780
+
1781
+ except FileNotFoundError:
1782
+ if streamer:
1783
+ streamer.stop()
1784
+ rprint("[red]'claude' CLI not found. Install Claude Code first.[/red]")
1785
+ raise typer.Exit(1)
1786
+
1787
+
1788
+ @app.command(name="status")
1789
+ def agent_status():
1790
+ """Show agent sessions for the current project."""
1791
+ slug = _require_project()
1792
+ sessions = api_get("/api/agent-sessions", params={"project_slug": slug})
1793
+ if not sessions:
1794
+ rprint("[dim]No agent sessions.[/dim]")
1795
+ return
1796
+ for s in sessions:
1797
+ color = {"running": "yellow", "completed": "green", "failed": "red"}.get(
1798
+ s["status"], "white"
1799
+ )
1800
+ rprint(
1801
+ f" [{color}]{s['status']:>10}[/{color}] "
1802
+ f"{s['id'][:8]} item: {s.get('work_item_id', '--')}"
1803
+ )
1804
+
1805
+
1806
+ @app.command()
1807
+ def abort(session_id: str = typer.Argument(...)):
1808
+ """Abort a running agent session."""
1809
+ api_post(f"/api/agent-sessions/{session_id}/abort")
1810
+ rprint(f"[yellow]Session {session_id[:8]} aborted.[/yellow]")
1811
+
1812
+
1813
+ def _detect_worktree_branch(project_path: str, wt_path: Path) -> str | None:
1814
+ """Detect the branch name for a worktree directory from git metadata."""
1815
+ probe = subprocess.run(
1816
+ ["git", "worktree", "list", "--porcelain"],
1817
+ cwd=project_path, capture_output=True, text=True,
1818
+ )
1819
+ wt_norm = str(wt_path).replace("\\", "/")
1820
+ lines = probe.stdout.splitlines()
1821
+ for i, ln in enumerate(lines):
1822
+ if wt_norm in ln.replace("\\", "/"):
1823
+ # Next line with "branch" has the ref
1824
+ for j in range(i + 1, min(i + 5, len(lines))):
1825
+ if lines[j].startswith("branch "):
1826
+ return lines[j].replace("branch refs/heads/", "")
1827
+ break
1828
+ return None
1829
+
1830
+
1831
+ @app.command(name="cleanup")
1832
+ def cleanup_worktree(
1833
+ item_number: int = typer.Argument(..., help="Work item number whose worktree to remove"),
1834
+ delete_branch: bool = typer.Option(False, "--delete-branch", "-D", help="Also delete the git branch"),
1835
+ ):
1836
+ """Remove the worktree created for a work item."""
1837
+ slug = _require_project()
1838
+ project_path = _require_project_path(slug)
1839
+
1840
+ wt = _find_worktree(slug, item_number)
1841
+
1842
+ if not wt.exists():
1843
+ rprint(f"[yellow]No worktree found for item #{item_number}.[/yellow]")
1844
+ raise typer.Exit()
1845
+
1846
+ # Detect branch before removing worktree
1847
+ branch = _detect_worktree_branch(project_path, wt)
1848
+
1849
+ _remove_worktree(project_path, str(wt))
1850
+ rprint(f"[green]Worktree removed: {wt}[/green]")
1851
+
1852
+ if delete_branch and branch:
1853
+ subprocess.run(
1854
+ ["git", "branch", "-D", branch],
1855
+ cwd=project_path, capture_output=True,
1856
+ )
1857
+ rprint(f"[green]Branch deleted: {branch}[/green]")
1858
+ elif branch:
1859
+ rprint(f"[dim]Branch '{branch}' kept. Delete with: git branch -D {branch}[/dim]")
1860
+ else:
1861
+ rprint("[dim]Could not detect branch name (worktree may have been stale).[/dim]")
1862
+
1863
+
1864
+ @app.command(name="worktrees")
1865
+ def list_worktrees():
1866
+ """List active agent worktrees for the current project."""
1867
+ slug = _require_project()
1868
+ project_path = _require_project_path(slug)
1869
+
1870
+ result = subprocess.run(
1871
+ ["git", "worktree", "list"],
1872
+ cwd=project_path, capture_output=True, text=True,
1873
+ )
1874
+ if result.returncode != 0:
1875
+ rprint("[red]Failed to list worktrees.[/red]")
1876
+ raise typer.Exit(1)
1877
+
1878
+ # Filter to worktrees under our managed directory
1879
+ wt_dir = str(WORKTREES_DIR / slug).replace("\\", "/")
1880
+ lines = result.stdout.strip().splitlines()
1881
+ bb_lines = [ln for ln in lines if wt_dir in ln.replace("\\", "/")]
1882
+
1883
+ if not bb_lines:
1884
+ rprint("[dim]No agent worktrees.[/dim]")
1885
+ return
1886
+
1887
+ rprint("[bold]Agent worktrees:[/bold]")
1888
+ for ln in bb_lines:
1889
+ rprint(f" {ln}")
1890
+
1891
+
1892
+ # ---------------------------------------------------------------------------
1893
+ # Batch (parallel) commands
1894
+ # ---------------------------------------------------------------------------
1895
+
1896
+
1897
+ @app.command(name="batch-suggest")
1898
+ def batch_suggest(
1899
+ items: list[str] = typer.Argument(None, help="Work item IDs/numbers to analyse (e.g. BD-2 BD-3 BD-4)"),
1900
+ all_open: bool = typer.Option(False, "--all", "-A", help="Suggest all open items in the project"),
1901
+ max_parallel: int = typer.Option(3, "--parallel", "-P", help="Max parallel Claude analyses"),
1902
+ ):
1903
+ """Analyse multiple work items in parallel. Each gets a proposal comment."""
1904
+ slug = _require_project()
1905
+ project_path = _require_project_path(slug)
1906
+
1907
+ if all_open:
1908
+ open_items = api_get(f"/api/projects/{slug}/work-items", params={"status": "open"})
1909
+ # Exclude epics — they contain children, not implementable directly
1910
+ open_items = [i for i in open_items if i.get("type") != "epic"]
1911
+ if not open_items:
1912
+ rprint("[yellow]No open items found.[/yellow]")
1913
+ return
1914
+ items = [i.get("key") or str(i["number"]) for i in open_items]
1915
+ rprint(f"[cyan]Found {len(items)} open items: {', '.join(items)}[/cyan]")
1916
+ elif not items:
1917
+ rprint("[red]Provide item IDs or use --all flag.[/red]")
1918
+ raise typer.Exit(1)
1919
+
1920
+ rprint(f"[cyan]Suggesting {len(items)} items (max {max_parallel} parallel)...[/cyan]\n")
1921
+
1922
+ results: list[dict] = []
1923
+ with AgentProgressTracker() as tracker:
1924
+ for item_ref in items:
1925
+ tracker.register(item_ref)
1926
+
1927
+ with ThreadPoolExecutor(max_workers=max_parallel) as pool:
1928
+ futures = {
1929
+ pool.submit(_suggest_one, slug, project_path, item, tracker=tracker): item
1930
+ for item in items
1931
+ }
1932
+ for future in as_completed(futures):
1933
+ item_ref = futures[future]
1934
+ try:
1935
+ r = future.result()
1936
+ results.append(r)
1937
+ tracker.complete(item_ref, r["status"] == "ok",
1938
+ r.get("error", "Done") if r["status"] != "ok" else "Done")
1939
+ except Exception as e:
1940
+ results.append({"key": item_ref, "status": "error", "error": str(e)})
1941
+ tracker.complete(item_ref, False, str(e)[:60])
1942
+
1943
+ ok = sum(1 for r in results if r["status"] == "ok")
1944
+ rprint(f"\n[bold]Done: {ok}/{len(items)} succeeded.[/bold]")
1945
+
1946
+ if ok > 0:
1947
+ rprint("\n[dim]Review suggestions, then run:[/dim]")
1948
+ suggested = [r["key"] for r in results if r["status"] == "ok"]
1949
+ rprint(f"[dim] bb agent batch-execute {' '.join(suggested)}[/dim]")
1950
+
1951
+
1952
+ @app.command(name="batch-execute")
1953
+ def batch_execute(
1954
+ items: list[str] = typer.Argument(..., help="Work item IDs/numbers to implement (e.g. BD-2 BD-3 BD-4)"),
1955
+ max_parallel: int = typer.Option(2, "--parallel", "-P", help="Max parallel Claude agents"),
1956
+ ):
1957
+ """Implement multiple work items in parallel. Each gets its own git worktree."""
1958
+ slug = _require_project()
1959
+ project_path = _require_project_path(slug)
1960
+
1961
+ rprint(f"[cyan]Executing {len(items)} items (max {max_parallel} parallel, each in own worktree)...[/cyan]\n")
1962
+
1963
+ results: list[dict] = []
1964
+ with AgentProgressTracker() as tracker:
1965
+ for item_ref in items:
1966
+ tracker.register(item_ref)
1967
+
1968
+ with ThreadPoolExecutor(max_workers=max_parallel) as pool:
1969
+ futures = {
1970
+ pool.submit(_execute_one, slug, project_path, item, tracker=tracker): item
1971
+ for item in items
1972
+ }
1973
+ for future in as_completed(futures):
1974
+ item_ref = futures[future]
1975
+ try:
1976
+ r = future.result()
1977
+ results.append(r)
1978
+ tracker.complete(item_ref, r["status"] == "ok",
1979
+ r.get("branch", r.get("error", "Done"))[:60])
1980
+ except Exception as e:
1981
+ results.append({"key": item_ref, "status": "error", "error": str(e)})
1982
+ tracker.complete(item_ref, False, str(e)[:60])
1983
+
1984
+ ok = sum(1 for r in results if r["status"] == "ok")
1985
+ rprint(f"\n[bold]Done: {ok}/{len(items)} succeeded.[/bold]")
1986
+
1987
+ branches = [r["branch"] for r in results if r.get("branch")]
1988
+ if branches:
1989
+ table = Table(title="Branches Created", show_header=True)
1990
+ table.add_column("Item")
1991
+ table.add_column("Branch")
1992
+ table.add_column("Status")
1993
+ for r in results:
1994
+ if r.get("branch"):
1995
+ color = "green" if r["status"] == "ok" else "red"
1996
+ table.add_row(r["key"], r["branch"], f"[{color}]{r['status']}[/{color}]")
1997
+ rprint(table)
1998
+ rprint(f"\n[dim]Merge all with: bb agent merge --target release/dev[/dim]")
1999
+
2000
+
2001
+ @app.command(name="batch-run")
2002
+ def batch_run(
2003
+ items: list[str] = typer.Argument(..., help="Work item IDs/numbers for full loop"),
2004
+ max_parallel: int = typer.Option(2, "--parallel", "-P", help="Max parallel agents"),
2005
+ auto_merge: bool = typer.Option(False, "--auto-merge", help="Auto-merge passing items to target"),
2006
+ target: str = typer.Option("release/dev", "--target", "-t", help="Target branch for auto-merge"),
2007
+ ):
2008
+ """Execute -> test -> merge for multiple items. Run 'bb agent batch-suggest' separately first."""
2009
+ slug = _require_project()
2010
+ project_path = _require_project_path(slug)
2011
+
2012
+ # Phase 1: parallel execute
2013
+ rprint(f"[bold cyan]Phase 1: Implementing {len(items)} items (max {max_parallel} parallel)...[/bold cyan]\n")
2014
+ exec_results: list[dict] = []
2015
+ with AgentProgressTracker() as tracker:
2016
+ for item_ref in items:
2017
+ tracker.register(item_ref)
2018
+
2019
+ with ThreadPoolExecutor(max_workers=max_parallel) as pool:
2020
+ futures = {
2021
+ pool.submit(_execute_one, slug, project_path, item, tracker=tracker): item
2022
+ for item in items
2023
+ }
2024
+ for future in as_completed(futures):
2025
+ item_ref = futures[future]
2026
+ try:
2027
+ r = future.result()
2028
+ exec_results.append(r)
2029
+ tracker.complete(item_ref, r["status"] == "ok",
2030
+ r.get("branch", r.get("error", ""))[:60])
2031
+ except Exception as e:
2032
+ exec_results.append({"key": item_ref, "status": "error", "error": str(e)})
2033
+ tracker.complete(item_ref, False, str(e)[:60])
2034
+
2035
+ executed = [r["key"] for r in exec_results if r["status"] == "ok"]
2036
+ ok = len(executed)
2037
+ rprint(f"\n[bold]Execute done: {ok}/{len(items)} succeeded.[/bold]")
2038
+
2039
+ if not executed:
2040
+ return
2041
+
2042
+ # Phase 2: parallel Docker test
2043
+ rprint("\n[bold cyan]Phase 2: Docker testing...[/bold cyan]\n")
2044
+ test_results: list[dict] = []
2045
+ with AgentProgressTracker() as tracker:
2046
+ for item_ref in executed:
2047
+ tracker.register(item_ref)
2048
+
2049
+ with ThreadPoolExecutor(max_workers=max_parallel) as pool:
2050
+ futures = {
2051
+ pool.submit(_docker_test_one, slug, project_path, item): item
2052
+ for item in executed
2053
+ }
2054
+ for future in as_completed(futures):
2055
+ item_ref = futures[future]
2056
+ try:
2057
+ r = future.result()
2058
+ test_results.append(r)
2059
+ tracker.complete(item_ref, r["status"] == "ok",
2060
+ r.get("details", "")[:60])
2061
+ except Exception as e:
2062
+ test_results.append({"key": item_ref, "status": "error", "details": str(e)})
2063
+ tracker.complete(item_ref, False, str(e)[:60])
2064
+
2065
+ passed = [r["key"] for r in test_results if r["status"] == "ok"]
2066
+ failed = [r["key"] for r in test_results if r["status"] != "ok"]
2067
+ rprint(f"\n[bold]Test done: {len(passed)}/{len(executed)} passed.[/bold]")
2068
+
2069
+ # Phase 3: merge passing items
2070
+ if auto_merge and passed:
2071
+ rprint(f"\n[bold cyan]Phase 3: Merging to {target}...[/bold cyan]\n")
2072
+ for item_key in passed:
2073
+ try:
2074
+ item = _resolve_item(slug, item_key)
2075
+ item_number = item["number"]
2076
+ wt = _find_worktree(slug, item_number)
2077
+ branch_name = _detect_worktree_branch(project_path, wt)
2078
+ if branch_name:
2079
+ merge_ok = _do_single_merge(project_path, branch_name, target)
2080
+ if merge_ok:
2081
+ rprint(f" [green]{item_key:>8} -- merged[/green]")
2082
+ api_put(f"/api/work-items/{item['id']}", json={"status": "resolved"})
2083
+ _remove_worktree(project_path, str(wt))
2084
+ else:
2085
+ rprint(f" [red]{item_key:>8} -- merge conflict[/red]")
2086
+ except Exception as e:
2087
+ rprint(f" [red]{item_key:>8} -- merge error: {e}[/red]")
2088
+ elif not auto_merge and passed:
2089
+ rprint("[dim]Merge with: bb agent merge --target release/dev[/dim]")
2090
+
2091
+ if failed:
2092
+ rprint(f"\n[yellow]Failed items: {' '.join(failed)}[/yellow]")
2093
+ rprint(f"[dim]Re-implement: bb agent reimplement <item>[/dim]")
2094
+
2095
+
2096
+ # ---------------------------------------------------------------------------
2097
+ # Merge command
2098
+ # ---------------------------------------------------------------------------
2099
+
2100
+
2101
+ def _list_agent_branches(project_path: str) -> list[str]:
2102
+ """List all agent branches (both new and legacy naming conventions)."""
2103
+ # New convention: {prefix}/{key}_{slug} — we look for known prefixes
2104
+ prefixes = list(set(TYPE_BRANCH_PREFIX.values())) # feat, fix, task, epic, chore, spike
2105
+ all_branches: list[str] = []
2106
+
2107
+ for prefix in prefixes:
2108
+ result = subprocess.run(
2109
+ ["git", "branch", "--list", f"{prefix}/*"],
2110
+ cwd=project_path, capture_output=True, text=True,
2111
+ )
2112
+ all_branches.extend(
2113
+ b.strip().lstrip("* ") for b in result.stdout.strip().splitlines() if b.strip()
2114
+ )
2115
+
2116
+ # Legacy convention: bb/item-*
2117
+ result = subprocess.run(
2118
+ ["git", "branch", "--list", "bb/item-*"],
2119
+ cwd=project_path, capture_output=True, text=True,
2120
+ )
2121
+ all_branches.extend(
2122
+ b.strip().lstrip("* ") for b in result.stdout.strip().splitlines() if b.strip()
2123
+ )
2124
+
2125
+ return sorted(set(all_branches))
2126
+
2127
+
2128
+ def _extract_item_number_from_branch(branch: str) -> int | None:
2129
+ """Extract item number from branch name (supports both conventions).
2130
+
2131
+ New: feat/bb-42_some-title -> 42
2132
+ Legacy: bb/item-42 -> 42
2133
+ """
2134
+ # Legacy: bb/item-{N}
2135
+ if branch.startswith("bb/item-"):
2136
+ try:
2137
+ return int(branch.replace("bb/item-", ""))
2138
+ except ValueError:
2139
+ return None
2140
+ # New: {prefix}/{key}_{slug} where key is like bb-42 or item-42
2141
+ m = re.search(r"/(?:[a-z]+-)?(\d+)", branch)
2142
+ if m:
2143
+ return int(m.group(1))
2144
+ return None
2145
+
2146
+
2147
+ @app.command(name="merge")
2148
+ def merge_branches(
2149
+ target: str = typer.Option("release/dev", "--target", "-t", help="Target branch to merge into"),
2150
+ items: list[str] = typer.Argument(None, help="Specific item numbers (default: all agent branches)"),
2151
+ cleanup_after: bool = typer.Option(False, "--cleanup", help="Remove worktrees + branches after successful merge"),
2152
+ use_agent: bool = typer.Option(False, "--agent", help="Use Claude to resolve merge conflicts"),
2153
+ ):
2154
+ """Merge agent branches into a target branch (e.g. release/dev)."""
2155
+ slug = _require_project()
2156
+ project_path = _require_project_path(slug)
2157
+
2158
+ # Remember current branch
2159
+ current = subprocess.run(
2160
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
2161
+ cwd=project_path, capture_output=True, text=True,
2162
+ ).stdout.strip()
2163
+
2164
+ all_branches = _list_agent_branches(project_path)
2165
+
2166
+ if items:
2167
+ wanted_nums = {int(n) for n in items}
2168
+ branches = [
2169
+ b for b in all_branches
2170
+ if _extract_item_number_from_branch(b) in wanted_nums
2171
+ ]
2172
+ else:
2173
+ branches = all_branches
2174
+
2175
+ if not branches:
2176
+ rprint("[yellow]No agent branches found to merge.[/yellow]")
2177
+ return
2178
+
2179
+ rprint(f"[cyan]Merging {len(branches)} branches into {target}...[/cyan]\n")
2180
+ for b in branches:
2181
+ rprint(f" {b}")
2182
+ rprint()
2183
+
2184
+ # Ensure target branch exists
2185
+ check = subprocess.run(
2186
+ ["git", "rev-parse", "--verify", target],
2187
+ cwd=project_path, capture_output=True, text=True,
2188
+ )
2189
+ if check.returncode != 0:
2190
+ rprint(f"[yellow]Branch '{target}' does not exist. Creating from HEAD...[/yellow]")
2191
+ subprocess.run(
2192
+ ["git", "branch", target],
2193
+ cwd=project_path, check=True, capture_output=True,
2194
+ )
2195
+
2196
+ # Prune stale worktrees
2197
+ subprocess.run(["git", "worktree", "prune"], cwd=project_path, capture_output=True)
2198
+
2199
+ # Checkout target branch
2200
+ co = subprocess.run(
2201
+ ["git", "checkout", target],
2202
+ cwd=project_path, capture_output=True, text=True,
2203
+ )
2204
+ if co.returncode != 0:
2205
+ rprint(f"[red]Failed to checkout {target}: {co.stderr}[/red]")
2206
+ return
2207
+
2208
+ merged: list[str] = []
2209
+ failed: list[tuple[str, str]] = []
2210
+
2211
+ for branch in branches:
2212
+ rprint(f" Merging {branch}...", end=" ")
2213
+ merge_result = subprocess.run(
2214
+ ["git", "merge", branch, "--no-ff", "-m", f"Merge {branch} into {target}"],
2215
+ cwd=project_path, capture_output=True, text=True,
2216
+ )
2217
+ if merge_result.returncode == 0:
2218
+ rprint("[green]ok[/green]")
2219
+ merged.append(branch)
2220
+ else:
2221
+ if use_agent:
2222
+ rprint("[yellow]conflict -> resolving with Claude...[/yellow]")
2223
+ subprocess.run(
2224
+ ["claude", "-p",
2225
+ f"Resolve all merge conflicts in this git repo. The merge of '{branch}' into '{target}' has conflicts. "
2226
+ f"Use 'git diff' to find conflicts, resolve them keeping both sets of changes where possible, "
2227
+ f"then stage and commit. Do NOT abort the merge.",
2228
+ "--output-format", "text",
2229
+ "--permission-mode", "bypassPermissions"],
2230
+ cwd=project_path, capture_output=True, text=True,
2231
+ timeout=300, env=_claude_env(),
2232
+ )
2233
+ # Check if conflicts are resolved
2234
+ status_check = subprocess.run(
2235
+ ["git", "diff", "--name-only", "--diff-filter=U"],
2236
+ cwd=project_path, capture_output=True, text=True,
2237
+ )
2238
+ if status_check.stdout.strip() == "":
2239
+ rprint(f" [green]Conflict resolved by agent[/green]")
2240
+ merged.append(branch)
2241
+ else:
2242
+ rprint(f" [red]Agent could not resolve all conflicts[/red]")
2243
+ subprocess.run(["git", "merge", "--abort"], cwd=project_path, capture_output=True)
2244
+ failed.append((branch, "conflict (agent failed)"))
2245
+ else:
2246
+ rprint("[red]CONFLICT[/red]")
2247
+ subprocess.run(["git", "merge", "--abort"], cwd=project_path, capture_output=True)
2248
+ failed.append((branch, "conflict"))
2249
+
2250
+ # Summary
2251
+ rprint()
2252
+ table = Table(title=f"Merge Results -> {target}", show_header=True)
2253
+ table.add_column("Branch")
2254
+ table.add_column("Status")
2255
+ for b in merged:
2256
+ table.add_row(b, "[green]merged[/green]")
2257
+ for b, reason in failed:
2258
+ table.add_row(b, f"[red]{reason}[/red]")
2259
+ rprint(table)
2260
+
2261
+ # Cleanup if requested
2262
+ if cleanup_after and merged:
2263
+ for branch in merged:
2264
+ num = _extract_item_number_from_branch(branch)
2265
+ if num is not None:
2266
+ try:
2267
+ wt = _find_worktree(slug, num)
2268
+ if wt.exists():
2269
+ _remove_worktree(project_path, str(wt))
2270
+ except Exception:
2271
+ pass
2272
+ subprocess.run(
2273
+ ["git", "branch", "-D", branch],
2274
+ cwd=project_path, capture_output=True,
2275
+ )
2276
+ rprint("[dim]Cleaned up merged worktrees and branches.[/dim]")
2277
+
2278
+ if failed:
2279
+ rprint("\n[yellow]Failed branches can be retried:[/yellow]")
2280
+ rprint("[dim] bb agent merge --agent (use Claude to resolve conflicts)[/dim]")
2281
+ rprint("[dim] or resolve manually: git checkout {target} && git merge {branch}[/dim]")
2282
+
2283
+ # Return to original branch
2284
+ subprocess.run(
2285
+ ["git", "checkout", current],
2286
+ cwd=project_path, capture_output=True,
2287
+ )