Add Cursor CLI–based YOLO development automation script
This commit is contained in:
parent
f11be2b2e2
commit
6f2e04fc6e
|
|
@ -0,0 +1,97 @@
|
|||
## BMAD Automation Scripts
|
||||
|
||||
This directory contains **optional automation utilities** for running BMAD workflows via external agent CLIs.
|
||||
|
||||
Scripts in this folder **do not modify** core BMAD roles, prompts, or methodology.
|
||||
They are reference implementations and power-user tools.
|
||||
|
||||
---
|
||||
|
||||
## `yolo_cursor.py`
|
||||
|
||||
Runs a full end-to-end BMAD development workflow for one or more stories using the **Cursor CLI (`cursor-agent`)**.
|
||||
|
||||
The workflow includes **12 explicit steps**:
|
||||
|
||||
1. SM creates the story
|
||||
2. SM validates the story draft
|
||||
3. SM optionally applies recommendations (logged even if skipped)
|
||||
4. TEA generates ATDD
|
||||
5. Dev implements the story
|
||||
6. Dev verifies the ATDD checklist
|
||||
7. Dev runs tests and fixes issues (pre-review)
|
||||
8. Dev performs first auto-fixing code review
|
||||
9. Dev performs second auto-fixing code review
|
||||
10. Dev runs tests and fixes issues (post-review)
|
||||
11. SM updates development status
|
||||
12. Dev provides final handoff notes
|
||||
|
||||
All steps are logged to Markdown files.
|
||||
|
||||
---
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- Cursor installed with `cursor-agent` available on `PATH`
|
||||
- `pyyaml`
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
```
|
||||
|
||||
> Note: `--list-backlog` only reads YAML and does not require Cursor. All workflow execution commands require `cursor-agent`.
|
||||
|
||||
---
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Run first backlog story
|
||||
python scripts/yolo_cursor.py
|
||||
|
||||
# List backlog stories
|
||||
python scripts/yolo_cursor.py --list-backlog
|
||||
|
||||
# Run a specific story
|
||||
python scripts/yolo_cursor.py --story 2-4-user-login-frontend
|
||||
|
||||
# Interactive selection
|
||||
python scripts/yolo_cursor.py --pick
|
||||
|
||||
# Batch run multiple stories
|
||||
python scripts/yolo_cursor.py --batch 2-4-user-login-frontend,2-5-auth-context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Logs
|
||||
|
||||
Logs are written to:
|
||||
|
||||
```
|
||||
docs/sprint-artifacts/yolo-logs/<story-id>/
|
||||
```
|
||||
|
||||
Each step produces a Markdown file, plus a `RESULT.md` summary.
|
||||
|
||||
---
|
||||
|
||||
### Reliability Features
|
||||
|
||||
- **Step timing** is recorded in every step log.
|
||||
- **Heartbeat output** is printed every minute while a step is running.
|
||||
- **Global step timeout** (10 minutes by default) prevents infinite hangs.
|
||||
- On failure/timeout/Ctrl+C, an additional `*_ERROR.md` log is written for the step.
|
||||
- **Resume support:** if step logs already exist for a story, the script resumes **after the last successful step**.
|
||||
|
||||
---
|
||||
|
||||
### Scope & Notes
|
||||
|
||||
- Cursor CLI only (for now)
|
||||
- Sequential execution (no parallelism)
|
||||
- No changes to BMAD core prompts or roles
|
||||
- Designed as a reference pattern for future automation scripts
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,690 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
BMAD YOLO (Cursor CLI)
|
||||
|
||||
Automates a full "draft -> build -> test -> review -> status update -> handoff notes"
|
||||
development loop for one or more BMAD stories using Cursor's `cursor-agent` CLI.
|
||||
|
||||
Supports:
|
||||
- Default: resume first unfinished story that has YOLO logs; else run first backlog story
|
||||
- Force first backlog story (--first-backlog)
|
||||
- Run a specific story by ID (--story)
|
||||
- List backlog stories (--list-backlog)
|
||||
- Interactive backlog selection (--pick)
|
||||
- Batch run multiple stories sequentially (--batch)
|
||||
|
||||
Logs:
|
||||
- Writes Markdown logs to: docs/sprint-artifacts/yolo-logs/<story-id>/<step>.md
|
||||
- On failure/timeout/Ctrl+C writes: <step>_ERROR.md
|
||||
|
||||
Exit codes:
|
||||
- 0 success
|
||||
- 1 usage / input error
|
||||
- 2 runtime failure (cursor-agent failure, missing YAML, etc.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Defaults / paths
|
||||
# ---------------------------
|
||||
|
||||
DEFAULT_SPRINT_STATUS_PATH = Path("docs/sprint-artifacts/sprint-status.yaml")
|
||||
DEFAULT_LOGS_ROOT = Path("docs/sprint-artifacts/yolo-logs")
|
||||
|
||||
# YAML key(s) seen in the wild
|
||||
DEV_STATUS_KEYS = ("development_status", "development-status")
|
||||
|
||||
# Matches story IDs like "2-4-user-login-frontend"
|
||||
STORY_ID_PATTERN = re.compile(r"^\d+-\d+-")
|
||||
|
||||
# Step filenames like "07-dev-run-tests-fix.md"
|
||||
STEP_FILENAME_PATTERN = re.compile(r"^(?P<num>\d{2})-(?P<slug>.+)\.md$")
|
||||
|
||||
# Global behavior
|
||||
HEARTBEAT_SECONDS = 60
|
||||
STEP_TIMEOUT_SECONDS = 10 * 60 # 10 minutes
|
||||
|
||||
# Validation phrases for optional "apply recommendations" step
|
||||
APPLY_ENHANCEMENTS_REGEX = re.compile(r"Apply the \[\d+\] enhancements to the story file\?")
|
||||
APPLY_CHOICE_ENDING = "Your choice:"
|
||||
APPLY_DONE_PHRASE = "All changes have been applied."
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Data model
|
||||
# ---------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunResult:
|
||||
story_id: str
|
||||
ok: bool
|
||||
steps_completed: List[str]
|
||||
failed_step: Optional[str]
|
||||
error: Optional[str]
|
||||
logs_dir: str
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Cursor runner
|
||||
# ---------------------------
|
||||
|
||||
def require_cursor_agent() -> None:
|
||||
"""Ensures 'cursor-agent' is available on PATH."""
|
||||
if shutil.which("cursor-agent") is None:
|
||||
raise RuntimeError(
|
||||
"cursor-agent not found on PATH. Install Cursor and ensure the 'cursor-agent' "
|
||||
"CLI is available before running this script."
|
||||
)
|
||||
|
||||
|
||||
def run_cursor_agent(
|
||||
prompt: str,
|
||||
*,
|
||||
heartbeat_seconds: int = HEARTBEAT_SECONDS,
|
||||
timeout_seconds: int = STEP_TIMEOUT_SECONDS,
|
||||
) -> str:
|
||||
"""
|
||||
Run Cursor Agent once in headless mode, returning final text output.
|
||||
|
||||
Adds:
|
||||
- Heartbeats every `heartbeat_seconds`
|
||||
- Hard timeout after `timeout_seconds`
|
||||
- On Ctrl+C: kills cursor-agent and re-raises KeyboardInterrupt
|
||||
"""
|
||||
print(f"\n[cursor-agent] Running prompt:\n{prompt}\n", flush=True)
|
||||
|
||||
cmd = ["cursor-agent", "-p", "--force", "--output-format", "text", prompt]
|
||||
|
||||
start = time.time()
|
||||
last_beat = start
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# finished?
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
|
||||
# heartbeat
|
||||
if now - last_beat >= heartbeat_seconds:
|
||||
elapsed = int(now - start)
|
||||
print(f"[cursor-agent] ...still running ({elapsed}s elapsed)", flush=True)
|
||||
last_beat = now
|
||||
|
||||
# timeout
|
||||
if timeout_seconds and (now - start) > timeout_seconds:
|
||||
try:
|
||||
proc.kill()
|
||||
finally:
|
||||
out, err = proc.communicate()
|
||||
raise TimeoutError(
|
||||
f"cursor-agent timed out after {timeout_seconds}s.\n\n"
|
||||
f"Last stdout tail:\n{(out or '')[-2000:]}\n\n"
|
||||
f"Last stderr tail:\n{(err or '')[-2000:]}\n"
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
out, err = proc.communicate()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
if err and err.strip():
|
||||
print("[cursor-agent stderr]:", file=sys.stderr)
|
||||
print(err, file=sys.stderr)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"cursor-agent failed with code {proc.returncode}")
|
||||
|
||||
return (out or "").strip()
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# YAML helpers
|
||||
# ---------------------------
|
||||
|
||||
def load_yaml(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Cannot find YAML file: {path}")
|
||||
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = yaml.safe_load(raw)
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Expected a YAML mapping at top-level in {path}")
|
||||
return data
|
||||
|
||||
|
||||
def get_development_status_map(data: dict) -> dict:
|
||||
for key in DEV_STATUS_KEYS:
|
||||
val = data.get(key)
|
||||
if isinstance(val, dict):
|
||||
return val
|
||||
return {}
|
||||
|
||||
|
||||
def list_backlog_story_ids(dev_status: dict) -> List[str]:
|
||||
"""Returns all story IDs in backlog state in file order."""
|
||||
backlog: List[str] = []
|
||||
for story_id, status in dev_status.items():
|
||||
if not isinstance(story_id, str):
|
||||
continue
|
||||
if not STORY_ID_PATTERN.match(story_id):
|
||||
continue
|
||||
if str(status).strip().lower() == "backlog":
|
||||
backlog.append(story_id)
|
||||
return backlog
|
||||
|
||||
|
||||
def find_first_backlog_story_id(dev_status: dict) -> str:
|
||||
"""Finds the first backlog story ID in file order."""
|
||||
for story_id, status in dev_status.items():
|
||||
if not isinstance(story_id, str):
|
||||
continue
|
||||
if STORY_ID_PATTERN.match(story_id) and str(status).strip().lower() == "backlog":
|
||||
return story_id
|
||||
raise RuntimeError(f"No backlog story found in any of: {', '.join(DEV_STATUS_KEYS)}")
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Logging helpers
|
||||
# ---------------------------
|
||||
|
||||
def write_md(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def format_seconds(seconds: float) -> str:
|
||||
seconds = max(0.0, seconds)
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
mins = int(seconds // 60)
|
||||
rem = seconds - mins * 60
|
||||
return f"{mins}m {rem:.1f}s"
|
||||
|
||||
|
||||
def save_step_log(
|
||||
log_dir: Path,
|
||||
step_slug: str,
|
||||
prompt: str,
|
||||
output: str,
|
||||
*,
|
||||
duration_seconds: float,
|
||||
) -> None:
|
||||
"""Store a Markdown log with prompt, output, and timing."""
|
||||
md: List[str] = []
|
||||
md.append(f"# {step_slug}\n\n")
|
||||
md.append(f"- Duration: **{format_seconds(duration_seconds)}**\n\n")
|
||||
md.append("## Prompt\n\n")
|
||||
md.append("```text\n" + prompt.strip() + "\n```\n\n")
|
||||
md.append("## Output\n\n")
|
||||
md.append("```text\n" + (output.strip() if output else "") + "\n```\n")
|
||||
write_md(log_dir / f"{step_slug}.md", "".join(md))
|
||||
|
||||
|
||||
def save_step_error_log(
|
||||
log_dir: Path,
|
||||
step_slug: str,
|
||||
prompt: str,
|
||||
error: BaseException,
|
||||
*,
|
||||
duration_seconds: float,
|
||||
) -> None:
|
||||
"""Writes a useful error log named '<step>_ERROR.md'."""
|
||||
name = f"{step_slug}_ERROR"
|
||||
md: List[str] = []
|
||||
md.append(f"# {name}\n\n")
|
||||
md.append(f"- Duration before failure: **{format_seconds(duration_seconds)}**\n\n")
|
||||
md.append("## Prompt\n\n")
|
||||
md.append("```text\n" + prompt.strip() + "\n```\n\n")
|
||||
md.append("## Error\n\n")
|
||||
md.append(f"- Type: `{type(error).__name__}`\n")
|
||||
md.append("```text\n" + str(error) + "\n```\n")
|
||||
write_md(log_dir / f"{name}.md", "".join(md))
|
||||
|
||||
|
||||
def extract_output_from_step_log(path: Path) -> str:
|
||||
"""
|
||||
Extracts the 'Output' fenced block from our step log format.
|
||||
Best-effort; returns entire file if parsing fails.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marker = "## Output"
|
||||
idx = text.find(marker)
|
||||
if idx == -1:
|
||||
return text
|
||||
after = text[idx:]
|
||||
fence = "```text"
|
||||
fidx = after.find(fence)
|
||||
if fidx == -1:
|
||||
return text
|
||||
after2 = after[fidx + len(fence):]
|
||||
endf = after2.find("```")
|
||||
if endf == -1:
|
||||
return after2.strip()
|
||||
return after2[:endf].strip()
|
||||
|
||||
|
||||
def get_last_completed_step_number(log_dir: Path) -> int:
|
||||
"""
|
||||
Looks for existing successful step logs and returns the highest step number found.
|
||||
- Ignores *_ERROR.md
|
||||
- Treats any '<NN>-*.md' as completed
|
||||
"""
|
||||
if not log_dir.exists():
|
||||
return 0
|
||||
|
||||
max_num = 0
|
||||
for p in log_dir.iterdir():
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name.endswith("_ERROR.md"):
|
||||
continue
|
||||
m = STEP_FILENAME_PATTERN.match(p.name)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
n = int(m.group("num"))
|
||||
max_num = max(max_num, n)
|
||||
except ValueError:
|
||||
continue
|
||||
return max_num
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Resume-first selection helper (NO hardcoded step count)
|
||||
# ---------------------------
|
||||
|
||||
def find_first_resumable_story_id(dev_status: dict, logs_root: Path) -> Optional[str]:
|
||||
"""
|
||||
Resume-first logic:
|
||||
- Iterate stories in YAML order.
|
||||
- Pick the first story that:
|
||||
* matches STORY_ID_PATTERN
|
||||
* status is NOT "done" (per sprint-status.yaml)
|
||||
* has an existing logs folder with at least one successful step log
|
||||
"""
|
||||
for story_id, status in dev_status.items():
|
||||
if not isinstance(story_id, str):
|
||||
continue
|
||||
if not STORY_ID_PATTERN.match(story_id):
|
||||
continue
|
||||
|
||||
status_str = str(status).strip().lower()
|
||||
if status_str == "done":
|
||||
continue
|
||||
|
||||
log_dir = logs_root / story_id
|
||||
last_completed = get_last_completed_step_number(log_dir)
|
||||
if last_completed > 0:
|
||||
return story_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# CLI selection
|
||||
# ---------------------------
|
||||
|
||||
def parse_selection(selection: str, max_index: int) -> List[int]:
|
||||
"""Parse '1,3,5-7' -> [1,3,5,6,7]."""
|
||||
indices: List[int] = []
|
||||
parts = [p.strip() for p in selection.split(",") if p.strip()]
|
||||
if not parts:
|
||||
raise ValueError("Empty selection")
|
||||
|
||||
for part in parts:
|
||||
if "-" in part:
|
||||
a, b = part.split("-", 1)
|
||||
if not a.isdigit() or not b.isdigit():
|
||||
raise ValueError(f"Invalid range: {part}")
|
||||
start, end = int(a), int(b)
|
||||
if start < 1 or end < 1 or start > end or end > max_index:
|
||||
raise ValueError(f"Range out of bounds: {part} (1..{max_index})")
|
||||
indices.extend(range(start, end + 1))
|
||||
else:
|
||||
if not part.isdigit():
|
||||
raise ValueError(f"Invalid index: {part}")
|
||||
idx = int(part)
|
||||
if idx < 1 or idx > max_index:
|
||||
raise ValueError(f"Index out of bounds: {idx} (1..{max_index})")
|
||||
indices.append(idx)
|
||||
|
||||
seen = set()
|
||||
out: List[int] = []
|
||||
for i in indices:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
def interactive_pick(backlog: List[str]) -> List[str]:
|
||||
if not backlog:
|
||||
raise RuntimeError("No backlog stories available to pick from.")
|
||||
|
||||
print("\nBacklog stories:\n")
|
||||
for i, sid in enumerate(backlog, start=1):
|
||||
print(f" {i}. {sid}")
|
||||
print("")
|
||||
|
||||
while True:
|
||||
choice = input("Pick stories (e.g. 1,3,5-7 | 'all' | 'q'): ").strip().lower()
|
||||
if choice in {"q", "quit", "exit"}:
|
||||
print("Exiting.")
|
||||
sys.exit(0)
|
||||
if choice in {"all", "*"}:
|
||||
return backlog
|
||||
try:
|
||||
idxs = parse_selection(choice, len(backlog))
|
||||
picked = [backlog[i - 1] for i in idxs]
|
||||
print("\nSelected:")
|
||||
for sid in picked:
|
||||
print(f" - {sid}")
|
||||
confirm = input("\nProceed? [y/N]: ").strip().lower()
|
||||
if confirm == "y":
|
||||
return picked
|
||||
except ValueError as e:
|
||||
print(f"Invalid selection: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# YOLO flow (Cursor-only)
|
||||
# ---------------------------
|
||||
|
||||
def run_yolo_for_story(
|
||||
story_id: str,
|
||||
logs_root: Path,
|
||||
*,
|
||||
atdd_checklist_filename: Optional[str] = None,
|
||||
) -> RunResult:
|
||||
"""Run the multi-step YOLO development flow for one story ID (with resume + timeouts)."""
|
||||
require_cursor_agent()
|
||||
|
||||
log_dir = logs_root / story_id
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
last_completed_num = get_last_completed_step_number(log_dir)
|
||||
if last_completed_num > 0:
|
||||
print(
|
||||
f"\n[resume] Found existing logs for {story_id}. "
|
||||
f"Last completed step: {last_completed_num:02d}. Resuming after it.\n",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
steps_completed: List[str] = []
|
||||
|
||||
def step(step_num: int, step_slug: str, prompt: str) -> str:
|
||||
"""
|
||||
Runs a step unless resuming past it.
|
||||
On failure/timeout/Ctrl+C, writes '<step>_ERROR.md' and re-raises.
|
||||
"""
|
||||
if step_num <= last_completed_num:
|
||||
steps_completed.append(step_slug)
|
||||
print(f"[resume] Skipping {step_slug} (already completed).", flush=True)
|
||||
return ""
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
out = run_cursor_agent(prompt, heartbeat_seconds=HEARTBEAT_SECONDS, timeout_seconds=STEP_TIMEOUT_SECONDS)
|
||||
duration = time.time() - t0
|
||||
save_step_log(log_dir, step_slug, prompt, out, duration_seconds=duration)
|
||||
steps_completed.append(step_slug)
|
||||
return out
|
||||
except BaseException as e:
|
||||
duration = time.time() - t0
|
||||
save_step_error_log(log_dir, step_slug, prompt, e, duration_seconds=duration)
|
||||
raise
|
||||
|
||||
try:
|
||||
# 01
|
||||
step(1, "01-sm-create-story", f"@sm *create-story {story_id}")
|
||||
|
||||
# 02
|
||||
validate_out = ""
|
||||
if 2 <= last_completed_num:
|
||||
validate_path = log_dir / "02-sm-validate-create-story.md"
|
||||
if validate_path.exists():
|
||||
validate_out = extract_output_from_step_log(validate_path)
|
||||
else:
|
||||
validate_out = step(
|
||||
2,
|
||||
"02-sm-validate-create-story",
|
||||
f"@sm *validate-create-story {story_id} and apply all recommended improvements",
|
||||
)
|
||||
|
||||
# 03 (conditional)
|
||||
needs_apply = (
|
||||
bool(validate_out)
|
||||
and APPLY_ENHANCEMENTS_REGEX.search(validate_out) is not None
|
||||
and validate_out.rstrip().endswith(APPLY_CHOICE_ENDING)
|
||||
)
|
||||
|
||||
if needs_apply:
|
||||
apply_out = step(3, "03-sm-apply-recommendations", f"@sm apply recommendations for {story_id}")
|
||||
if 3 > last_completed_num and APPLY_DONE_PHRASE not in apply_out:
|
||||
raise RuntimeError(f'Apply recommendations did not confirm: "{APPLY_DONE_PHRASE}"')
|
||||
else:
|
||||
if 3 > last_completed_num:
|
||||
save_step_log(
|
||||
log_dir,
|
||||
"03-sm-apply-recommendations",
|
||||
"(skipped)",
|
||||
"No enhancements prompt detected; apply step skipped.",
|
||||
duration_seconds=0.0,
|
||||
)
|
||||
steps_completed.append("03-sm-apply-recommendations")
|
||||
else:
|
||||
steps_completed.append("03-sm-apply-recommendations")
|
||||
print("[resume] Skipping 03-sm-apply-recommendations (already completed).", flush=True)
|
||||
|
||||
# 04
|
||||
step(4, "04-tea-atdd", f"@tea *atdd {story_id}")
|
||||
|
||||
# 05
|
||||
step(5, "05-dev-develop-story", f"@dev *develop-story {story_id}")
|
||||
|
||||
# 06
|
||||
checklist_file = atdd_checklist_filename or f"atdd-checklist-{story_id}.md"
|
||||
step(6, "06-dev-atdd-checklist", f"@dev verify and check all tasks in {checklist_file}")
|
||||
|
||||
# 07
|
||||
step(7, "07-dev-run-tests-fix", "@dev run all tests (e2e and unit tests) and fix all issues")
|
||||
|
||||
# 08
|
||||
step(8, "08-dev-code-review-round-1", f"@dev *code-review {story_id} and fix automatically")
|
||||
|
||||
# 09
|
||||
step(9, "09-dev-code-review-round-2", f"@dev *code-review {story_id} and fix automatically")
|
||||
|
||||
# 10
|
||||
step(10, "10-dev-run-tests-fix-after-review", "@dev run all tests (e2e and unit tests) and fix all issues")
|
||||
|
||||
# 11
|
||||
step(11, "11-sm-update-status", f"@sm update the story {story_id} development status")
|
||||
|
||||
# 12
|
||||
step(
|
||||
12,
|
||||
"12-dev-final-notes",
|
||||
f"@dev what do I need to know about the last story implemented {story_id}? "
|
||||
f"anything I need to know, or run locally, or test?",
|
||||
)
|
||||
|
||||
return RunResult(
|
||||
story_id=story_id,
|
||||
ok=True,
|
||||
steps_completed=steps_completed,
|
||||
failed_step=None,
|
||||
error=None,
|
||||
logs_dir=str(log_dir),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return RunResult(
|
||||
story_id=story_id,
|
||||
ok=False,
|
||||
steps_completed=steps_completed,
|
||||
failed_step=steps_completed[-1] if steps_completed else None,
|
||||
error=str(e),
|
||||
logs_dir=str(log_dir),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Main
|
||||
# ---------------------------
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="yolo_cursor.py",
|
||||
description="Run BMAD YOLO development workflow using Cursor CLI (cursor-agent).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--sprint-status",
|
||||
default=str(DEFAULT_SPRINT_STATUS_PATH),
|
||||
help="Path to sprint-status.yaml (default: docs/sprint-artifacts/sprint-status.yaml)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--logs-root",
|
||||
default=str(DEFAULT_LOGS_ROOT),
|
||||
help="Root folder for logs (default: docs/sprint-artifacts/yolo-logs)",
|
||||
)
|
||||
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--story", help="Run a specific story by ID (e.g. 2-4-user-login-frontend)")
|
||||
g.add_argument("--first-backlog", action="store_true", help="Run the first backlog story (skip resume)")
|
||||
g.add_argument("--list-backlog", action="store_true", help="List backlog stories and exit")
|
||||
g.add_argument("--pick", action="store_true", help="Interactively pick backlog stories to run")
|
||||
|
||||
p.add_argument(
|
||||
"--batch",
|
||||
help="Comma-separated list of story IDs to run in order. Example: 2-4-foo,2-5-bar",
|
||||
)
|
||||
p.add_argument(
|
||||
"--atdd-checklist-file",
|
||||
default=None,
|
||||
help="Override checklist filename used in the Dev checklist step (default: atdd-checklist-<story>.md)",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
sprint_status_path = Path(args.sprint_status)
|
||||
logs_root = Path(args.logs_root)
|
||||
|
||||
data = load_yaml(sprint_status_path)
|
||||
dev_status = get_development_status_map(data)
|
||||
|
||||
if not dev_status:
|
||||
print(
|
||||
f"ERROR: Could not find a development status map under any of {DEV_STATUS_KEYS} "
|
||||
f"in {sprint_status_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
backlog = list_backlog_story_ids(dev_status)
|
||||
|
||||
if args.list_backlog:
|
||||
for sid in backlog:
|
||||
print(sid)
|
||||
sys.exit(0)
|
||||
|
||||
to_run: List[str] = []
|
||||
|
||||
if args.batch:
|
||||
to_run = [s.strip() for s in args.batch.split(",") if s.strip()]
|
||||
elif args.story:
|
||||
to_run = [args.story.strip()]
|
||||
elif args.pick:
|
||||
to_run = interactive_pick(backlog)
|
||||
elif args.first_backlog:
|
||||
# Explicitly skip resume behavior
|
||||
try:
|
||||
to_run = [find_first_backlog_story_id(dev_status)]
|
||||
except RuntimeError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
# Default behavior:
|
||||
# 1) Resume first unfinished story that already has logs (status != done)
|
||||
# 2) Else run first backlog story
|
||||
try:
|
||||
resumable = find_first_resumable_story_id(dev_status, logs_root)
|
||||
if resumable:
|
||||
to_run = [resumable]
|
||||
else:
|
||||
to_run = [find_first_backlog_story_id(dev_status)]
|
||||
except RuntimeError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
results: List[RunResult] = []
|
||||
for sid in to_run:
|
||||
print("\n" + "=" * 90)
|
||||
print(f"YOLO: {sid}")
|
||||
print("=" * 90)
|
||||
res = run_yolo_for_story(sid, logs_root, atdd_checklist_filename=args.atdd_checklist_file)
|
||||
results.append(res)
|
||||
if not res.ok:
|
||||
break
|
||||
|
||||
summary = {
|
||||
"ok": all(r.ok for r in results),
|
||||
"results": [
|
||||
{
|
||||
"story_id": r.story_id,
|
||||
"ok": r.ok,
|
||||
"steps_completed": r.steps_completed,
|
||||
"failed_step": r.failed_step,
|
||||
"error": r.error,
|
||||
"logs_dir": r.logs_dir,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
print("\n=== YOLO SUMMARY (json) ===")
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
sys.exit(0 if summary["ok"] else 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue