#!/usr/bin/env python3 """ Ralph PRD Generator - implementare Python a skill-ului ralph-prd Generează PRD și prd.json din descriere (heuristic) sau din final-plan.md (Opus). Schema extinsă (W3 / smart gates + DAG): - tags[] : "ui" | "db" | "vercel" | "refactor" | "docs" | "backend" | "infra" - dependsOn[] : alte story IDs (DAG topological sort) - acceptanceCriteria: 3-5 criterii verificabile concret - passes/failed/blocked/retries: state pentru ralph.sh loop guard - failureReason : populat când failed=true (ex: "rate_limited", "max_retries") """ import json import os import re import subprocess from pathlib import Path from datetime import datetime from typing import Optional # Constants pentru smart gates dispatcher VALID_TAGS = {"ui", "db", "vercel", "refactor", "docs", "backend", "infra"} CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") OPUS_TIMEOUT = int(os.environ.get("RALPH_PRD_OPUS_TIMEOUT", "600")) # 10 min def _normalize_story(story: dict, idx: int = 0) -> dict: """Asigură că un story dict are toate câmpurile schemei extinse W3.""" sid = story.get("id") or f"US-{idx + 1:03d}" title = (story.get("title") or "").strip() or f"Story {sid}" description = (story.get("description") or "").strip() or title # Tags: filter la VALID_TAGS, păstrează ordine raw_tags = story.get("tags") or [] tags = [t for t in raw_tags if isinstance(t, str) and t in VALID_TAGS] # dependsOn: lista de story IDs (string-uri) raw_deps = story.get("dependsOn") or [] depends_on = [d for d in raw_deps if isinstance(d, str) and d.strip()] # acceptance criteria: cel puțin 1, ideal 3-5 raw_ac = story.get("acceptanceCriteria") or [] acceptance = [c.strip() for c in raw_ac if isinstance(c, str) and c.strip()] if not acceptance: acceptance = ["Funcționalitatea implementată conform descrierii"] return { "id": sid, "title": title, "description": description, "priority": int(story.get("priority") or (idx + 1) * 10), "acceptanceCriteria": acceptance, "tags": tags, "dependsOn": depends_on, "requiresBrowserCheck": bool(story.get("requiresBrowserCheck", "ui" in tags)), "requiresDesignReview": bool(story.get("requiresDesignReview", False)), "passes": bool(story.get("passes", False)), "failed": bool(story.get("failed", False)), "blocked": bool(story.get("blocked", False)), "retries": int(story.get("retries") or 0), "failureReason": story.get("failureReason") or "", "notes": story.get("notes") or "", } def extract_stories_from_final_plan(final_plan_path: Path) -> Optional[list]: """Invocă Claude CLI (Opus) pe final-plan.md și extrage user stories în schema extinsă. Returnează listă de stories normalizate, sau None dacă invocarea eșuează. Backward-compat: caller-ul poate fallback la heuristic dacă None. """ if not final_plan_path.exists(): return None try: plan_content = final_plan_path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return None valid_tags_csv = ", ".join(sorted(VALID_TAGS)) prompt = ( f"Citește final-plan.md de mai jos și extrage user stories implementabile autonom de către un agent AI (Ralph).\n\n" f"Pentru fiecare story extrage:\n" f"- id (format US-001, US-002...)\n" f"- title (scurt, imperativ)\n" f"- description (1-2 propoziții, ce face story-ul)\n" f"- acceptanceCriteria[] (3-5 criterii verificabile concret — comenzi care trebuie să iasă PASS, fișiere create, comportament observabil)\n" f"- tags[] (subset din: {valid_tags_csv}; un story poate avea 1-3 tags)\n" f"- dependsOn[] (alte story IDs de care depinde — pentru DAG topological sort; goală dacă independent)\n" f"- priority (10, 20, 30... în ordinea din plan)\n\n" f"Reguli:\n" f"- Fiecare story IMPLEMENTABIL — nu task-uri de research sau design (alea s-au făcut deja în plan).\n" f"- Tags ghidează Ralph-ul să ruleze gates corecte: ui→/qa Playwright, db→schema diff, vercel→PR checks, refactor→/workflow:simplify.\n" f"- dependsOn pentru ordering real (US-002 are nevoie de DB-ul din US-001) — NU pentru tot ce vine după.\n" f"- Nu inventa stories peste plan; extrage doar ce e acolo.\n\n" f"Răspunde DOAR cu JSON valid (fără markdown fence, fără comentarii) în formatul:\n" f'{{"userStories": [{{"id":"US-001","title":"...","description":"...","acceptanceCriteria":["..."],"tags":["..."],"dependsOn":[],"priority":10}}, ...]}}\n\n' f"=== FINAL PLAN ===\n{plan_content}\n=== END PLAN ===\n" ) cmd = [ CLAUDE_BIN, "-p", prompt, "--model", "opus", "--output-format", "json", ] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=OPUS_TIMEOUT, ) except (subprocess.TimeoutExpired, FileNotFoundError) as exc: print(f"⚠️ Opus extraction failed: {exc}") return None if proc.returncode != 0: print(f"⚠️ Opus exit {proc.returncode}: {proc.stderr[:300]}") return None # Claude --output-format json wrap-uiește răspunsul în {"result": "..."} raw_result = proc.stdout try: wrapper = json.loads(raw_result) result_text = wrapper.get("result", raw_result) if isinstance(wrapper, dict) else raw_result except json.JSONDecodeError: result_text = raw_result # Caută primul block JSON în răspuns (defensiv contra prefix/suffix text) match = re.search(r'\{[\s\S]*"userStories"[\s\S]*\}', result_text) if not match: print(f"⚠️ Niciun JSON cu userStories în output Opus") return None try: parsed = json.loads(match.group(0)) except json.JSONDecodeError as exc: print(f"⚠️ JSON parse error în output Opus: {exc}") return None raw_stories = parsed.get("userStories") or [] if not isinstance(raw_stories, list) or not raw_stories: return None return [_normalize_story(s, i) for i, s in enumerate(raw_stories)] def detect_project_context(project_dir: Path): """Detectează tech stack și mod (NEW_PROJECT vs FEATURE)""" context = { "mode": "NEW_PROJECT", "stack_type": None, "config_file": None, "scripts": {}, "dependencies": [] } # Verifică fișiere de config config_files = { "package.json": "nodejs", "pyproject.toml": "python", "requirements.txt": "python-legacy", "go.mod": "go", "Cargo.toml": "rust", "pom.xml": "java-maven", "build.gradle": "java-gradle", "composer.json": "php", "Gemfile": "ruby" } for filename, stack in config_files.items(): config_path = project_dir / filename if config_path.exists(): context["mode"] = "FEATURE" context["stack_type"] = stack context["config_file"] = filename # Extrage info din package.json if filename == "package.json": try: with open(config_path) as f: data = json.load(f) context["scripts"] = data.get("scripts", {}) context["dependencies"] = list(data.get("dependencies", {}).keys()) except: pass break return context def generate_prd_markdown(project_name: str, description: str, context: dict): """Generează PRD markdown bazat pe descriere și context""" # Parse descriere pentru a extrage info lines = description.strip().split('\n') features = [] requirements = [] for line in lines: line = line.strip() if line.startswith('Features:') or line.startswith('Feature:'): continue if line.startswith('-'): features.append(line[1:].strip()) elif line and not line.endswith(':'): requirements.append(line) if not features: # Dacă nu sunt features explicit, folosim descrierea ca feature features = [description.split('.')[0]] # Detectează tip proiect din descriere desc_lower = description.lower() if 'cli' in desc_lower or 'command' in desc_lower: project_type = "CLI Tool" elif 'api' in desc_lower or 'backend' in desc_lower: project_type = "API / Backend" elif 'web' in desc_lower or 'app' in desc_lower: project_type = "Web Application" else: project_type = "Application" # Detectează stack din descriere sau context if 'python' in desc_lower: stack = "Python" test_framework = "pytest" elif 'javascript' in desc_lower or 'node' in desc_lower or 'typescript' in desc_lower: stack = "Node.js / TypeScript" test_framework = "jest" elif context["stack_type"] == "nodejs": stack = "Node.js / TypeScript" test_framework = "jest" elif context["stack_type"] and context["stack_type"].startswith("python"): stack = "Python" test_framework = "pytest" else: stack = "Python" # Default test_framework = "pytest" # Template PRD prd = f"""# PRD: {project_name.replace('-', ' ').title()} ## 1. Introducere {description.split('.')[0]}. **Data:** {datetime.now().strftime('%Y-%m-%d')} **Status:** Draft **Mode:** {context['mode']} ## 2. Context Tehnic """ if context["mode"] == "FEATURE": prd += f"""**Proiect existent detectat:** - Stack: {context['stack_type']} - Config: {context['config_file']} - Scripts: {', '.join(context['scripts'].keys())} """ else: prd += f"""**Proiect nou:** - Tip: {project_type} - Stack recomandat: {stack} - Test framework: {test_framework} """ prd += f"""## 3. Obiective ### Obiectiv Principal {features[0] if features else description.split('.')[0]} ### Obiective Secundare """ for i, feature in enumerate(features[1:] if len(features) > 1 else features, 1): prd += f"- {feature}\n" prd += f""" ### Metrici de Succes - Toate funcționalitățile implementate conform spec - Tests passing (coverage > 80%) - Code quality: lint + typecheck pass ## 4. User Stories """ # Generează user stories din features for i, feature in enumerate(features, 1): story_id = f"US-{i:03d}" title = feature.strip() prd += f"""### {story_id}: {title} **Ca** utilizator **Vreau** {title.lower()} **Pentru că** pot folosi aplicația eficient **Acceptance Criteria:** - [ ] Funcționalitatea implementată conform descrierii - [ ] Input validation în loc - [ ] Error handling pentru cazuri edge - [ ] Tests cu {test_framework} (coverage > 80%) - [ ] Code quality: lint + typecheck pass **Priority:** {i * 10} """ # Dacă nu sunt multe features, adaugă story pentru tests if len(features) < 3: prd += f"""### US-{len(features)+1:03d}: Tests și Documentație **Ca** developer **Vreau** teste comprehensive și documentație **Pentru că** asigur calitatea codului **Acceptance Criteria:** - [ ] Unit tests pentru toate funcțiile (coverage > 80%) - [ ] Integration tests pentru flow-uri principale - [ ] README cu instrucțiuni de utilizare - [ ] Docstrings pentru funcții publice - [ ] {test_framework} rulează fără erori **Priority:** {(len(features)+1) * 10} """ prd += f"""## 5. Cerințe Funcționale """ for i, req in enumerate(requirements if requirements else features, 1): prd += f"{i}. [REQ-{i:03d}] {req}\n" prd += f""" ## 6. Non-Goals (Ce NU facem) - Interfață grafică (GUI) - doar CLI/API - Suport multiple limbaje - doar {stack} - Deployment infrastructure - doar cod functional ## 7. Considerații Tehnice ### Stack/Tehnologii - Limbaj: {stack} - Testing: {test_framework} - Linting: pylint / eslint (depinde de stack) - Type checking: mypy / typescript ### Patterns de Urmat - Clean code principles - SOLID principles unde aplicabil - Error handling consistent - Input validation strict ### Riscuri Tehnice - Edge cases la input validation - Performance pentru volume mari de date (dacă aplicabil) ## 8. Considerații Security - Input validation pentru toate datele externe - Error messages fără info sensibilă - Principle of least privilege ## 9. Open Questions - [ ] Performance requirements specifice? - [ ] Limite pe input sizes? - [ ] Specific error handling patterns preferați? --- **Generated by:** Echo (Ralph PRD Generator) **Date:** {datetime.now().strftime('%Y-%m-%d %H:%M')} """ return prd def prd_to_stories(prd_content: str, project_name: str): """Extrage user stories din PRD și le convertește în format prd.json""" stories = [] # Parse PRD pentru stories story_pattern = r'### (US-\d+): (.+?)\n\*\*Ca\*\* (.+?)\n\*\*Vreau\*\* (.+?)\n\*\*Pentru că\*\* (.+?)\n\n\*\*Acceptance Criteria:\*\*\n(.*?)\n\n\*\*Priority:\*\* (\d+)' matches = re.finditer(story_pattern, prd_content, re.DOTALL) for match in matches: story_id = match.group(1) title = match.group(2).strip() user_type = match.group(3).strip() want = match.group(4).strip() because = match.group(5).strip() criteria_text = match.group(6).strip() priority = int(match.group(7)) # Parse acceptance criteria criteria = [] for line in criteria_text.split('\n'): line = line.strip() if line.startswith('- [ ]'): criteria.append(line[5:].strip()) # Detectează dacă necesită browser check (pentru UI) requires_browser = 'ui' in title.lower() or 'interface' in title.lower() # Heuristic tags din titlu tags_inferred = [] title_lower = title.lower() if requires_browser or 'ui' in title_lower or 'frontend' in title_lower: tags_inferred.append('ui') if 'database' in title_lower or 'schema' in title_lower or 'migration' in title_lower: tags_inferred.append('db') if 'refactor' in title_lower or 'cleanup' in title_lower: tags_inferred.append('refactor') if 'doc' in title_lower or 'readme' in title_lower: tags_inferred.append('docs') story = _normalize_story({ "id": story_id, "title": title, "description": f"Ca {user_type}, vreau {want} pentru că {because}", "priority": priority, "acceptanceCriteria": criteria, "tags": tags_inferred, "dependsOn": [], "requiresBrowserCheck": requires_browser, }, idx=int(story_id.split('-')[-1]) - 1 if story_id.startswith("US-") else 0) stories.append(story) # Dacă nu găsim stories (regex failed), generăm basic if not stories: stories = [_normalize_story({ "id": "US-001", "title": "Implementare funcționalitate principală", "description": f"Implementează {project_name}", "priority": 10, "acceptanceCriteria": [ "Funcționalitatea implementată", "Tests passing", "Lint + typecheck pass" ], "tags": [], "dependsOn": [], }, idx=0)] return stories def detect_tech_stack_commands(project_dir: Path, context: dict): """Detectează comenzile tech stack pentru prd.json""" stack_type = context.get("stack_type", "python") scripts = context.get("scripts", {}) # Default commands per stack if stack_type == "nodejs" or "package.json" in str(context.get("config_file", "")): commands = { "start": scripts.get("dev", scripts.get("start", "npm run dev")), "build": scripts.get("build", "npm run build"), "lint": scripts.get("lint", "npm run lint"), "typecheck": scripts.get("typecheck", "npm run typecheck"), "test": scripts.get("test", "npm test") } port = 3000 stack_name = "nextjs" if "next" in str(context.get("dependencies", [])) else "nodejs" elif stack_type and stack_type.startswith("python"): commands = { "start": "python main.py", "build": "", "lint": "ruff check .", "typecheck": "mypy .", "test": "pytest" } port = 8000 stack_name = "python" else: # Generic/fallback commands = { "start": "python main.py", "build": "", "lint": "echo 'No linter configured'", "typecheck": "echo 'No typecheck configured'", "test": "pytest" } port = 8000 stack_name = "python" return { "type": stack_name, "commands": commands, "port": port } def create_prd_and_json( project_name: str, description: str, workspace_dir: Path, final_plan_path: Optional[Path] = None, ): """ Generează PRD markdown și prd.json pentru un proiect. Args: project_name: slug proiect (folder în workspace_dir) description: descriere scurtă (folosită ca fallback și pentru PRD markdown) workspace_dir: rădăcina workspace (default ~/workspace/) final_plan_path: opțional, calea către final-plan.md produs de planning agent (W2); când e furnizat, user stories sunt extrase prin Claude Opus din plan; când e None, păstrăm comportamentul vechi (heuristic din description). Returns: tuple: (prd_file_path, prd_json_path) sau (None, None) dacă eroare """ project_dir = workspace_dir / project_name project_dir.mkdir(parents=True, exist_ok=True) # Detectează context context = detect_project_context(project_dir) print(f"📊 Context detectat:") print(f" Mode: {context['mode']}") if context['stack_type']: print(f" Stack: {context['stack_type']}") print(f" Config: {context['config_file']}") if final_plan_path: print(f" Final plan: {final_plan_path}") # Generează PRD markdown (mereu — folosit pentru read humans) prd_content = generate_prd_markdown(project_name, description, context) # Salvează PRD tasks_dir = project_dir / "tasks" tasks_dir.mkdir(exist_ok=True) prd_file = tasks_dir / f"prd-{project_name}.md" with open(prd_file, 'w', encoding='utf-8') as f: f.write(prd_content) print(f"✅ PRD salvat: {prd_file}") # Generează stories — preferă Opus din final-plan.md când disponibil stories = None if final_plan_path is not None: plan = Path(final_plan_path) if not isinstance(final_plan_path, Path) else final_plan_path print(f"🧠 Extrag stories din final-plan.md cu Opus...") stories = extract_stories_from_final_plan(plan) if stories: print(f" ↳ {len(stories)} stories extrase din plan") else: print(f" ↳ Opus extraction eșuat — fallback la heuristic") if not stories: stories = prd_to_stories(prd_content, project_name) tech_stack = detect_tech_stack_commands(project_dir, context) prd_json_data = { "projectName": project_name, "branchName": f"ralph/{project_name}", "description": description.split('\n')[0], "techStack": tech_stack, "userStories": stories } # Creează structura ralph ralph_dir = project_dir / "scripts" / "ralph" ralph_dir.mkdir(parents=True, exist_ok=True) (ralph_dir / "logs").mkdir(exist_ok=True) (ralph_dir / "archive").mkdir(exist_ok=True) (ralph_dir / "screenshots").mkdir(exist_ok=True) # Salvează prd.json prd_json_file = ralph_dir / "prd.json" with open(prd_json_file, 'w', encoding='utf-8') as f: json.dump(prd_json_data, f, indent=2, ensure_ascii=False) print(f"✅ prd.json salvat: {prd_json_file}") print(f"📋 Stories: {len(stories)}") for story in stories: print(f" - {story['id']}: {story['title']}") # Copiază template-uri ralph templates_dir = Path.home() / ".claude" / "skills" / "ralph" / "templates" if not templates_dir.exists(): templates_dir = Path.home() / "echo-core" / "skills" / "ralph" / "templates" if templates_dir.exists(): # Copiază ralph.sh ralph_sh_src = templates_dir / "ralph.sh" if ralph_sh_src.exists(): ralph_sh_dst = ralph_dir / "ralph.sh" with open(ralph_sh_src) as f: content = f.read() with open(ralph_sh_dst, 'w') as f: f.write(content) ralph_sh_dst.chmod(0o755) print(f"✅ ralph.sh copiat") # Copiază prompt.md prompt_src = templates_dir / "prompt.md" if prompt_src.exists(): prompt_dst = ralph_dir / "prompt.md" with open(prompt_src) as f: content = f.read() with open(prompt_dst, 'w') as f: f.write(content) print(f"✅ prompt.md copiat") # Init progress.txt progress_file = ralph_dir / "progress.txt" with open(progress_file, 'w') as f: f.write(f"# Ralph Progress Log\n") f.write(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n") f.write(f"Project: {project_name}\n") f.write(f"---\n") print(f"✅ Structură Ralph completă în {ralph_dir}") return prd_file, prd_json_file if __name__ == "__main__": import sys if len(sys.argv) < 3: print("Usage: python ralph_prd_generator.py PROJECT_NAME 'description' [final_plan_path]") sys.exit(1) project_name = sys.argv[1] description = sys.argv[2] final_plan_arg = Path(sys.argv[3]) if len(sys.argv) > 3 else None workspace = Path.home() / "workspace" print(f"🔄 Generez PRD pentru {project_name}") print("=" * 70) prd_file, prd_json = create_prd_and_json( project_name, description, workspace, final_plan_path=final_plan_arg ) if prd_file and prd_json: print("\n" + "=" * 70) print("✅ PRD și prd.json generate cu succes!") print(f"📄 PRD: {prd_file}") print(f"📋 JSON: {prd_json}") print("\n📌 Următorii pași:") print(f" 1. Revizuiește PRD în {prd_file}") print(f" 2. Rulează Ralph: cd {prd_file.parent.parent} && ./scripts/ralph/ralph.sh 20") print("=" * 70) else: print("\n❌ Eroare la generare PRD") sys.exit(1)