feat(ralph): smart gates + DAG + dashboard live (W3)
Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de 5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate limit detection, plus dashboard live cu polling 5s. Changes: - tools/ralph_prd_generator.py: parametru optional final_plan_path; când e furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat păstrat — fără final_plan_path, fallback la heuristic-ul vechi. - tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries, failed, blocked, failureReason, requiresDesignReview). - tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) + dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default). - tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru migrations sau .sql; force vercel dacă există vercel.json) + topological sort cu blocked propagation + atomic prd.json updates. - tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry), CLI subcommands prin tools/ralph_dag.py helper. - dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill. - dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s, drawer pentru log/PRD viewer, status colors (--status-running/blocked/ failed/complete declarate inline), Lucide icons cu aria-labels. - dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop). - tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint). Note arhitecturale: - Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min) - Tag validation rulează POST-iter pe diff git pentru anti-silent-regression - Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ralph PRD Generator - implementare Python a skill-ului ralph-prd
|
||||
Generează PRD și prd.json fără să apeleze Claude Code
|
||||
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):
|
||||
@@ -275,23 +413,33 @@ def prd_to_stories(prd_content: str, project_name: str):
|
||||
|
||||
# Detectează dacă necesită browser check (pentru UI)
|
||||
requires_browser = 'ui' in title.lower() or 'interface' in title.lower()
|
||||
|
||||
story = {
|
||||
# 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,
|
||||
"passes": False,
|
||||
"notes": ""
|
||||
}
|
||||
|
||||
}, 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 = [{
|
||||
stories = [_normalize_story({
|
||||
"id": "US-001",
|
||||
"title": "Implementare funcționalitate principală",
|
||||
"description": f"Implementează {project_name}",
|
||||
@@ -301,11 +449,10 @@ def prd_to_stories(prd_content: str, project_name: str):
|
||||
"Tests passing",
|
||||
"Lint + typecheck pass"
|
||||
],
|
||||
"requiresBrowserCheck": False,
|
||||
"passes": False,
|
||||
"notes": ""
|
||||
}]
|
||||
|
||||
"tags": [],
|
||||
"dependsOn": [],
|
||||
}, idx=0)]
|
||||
|
||||
return stories
|
||||
|
||||
|
||||
@@ -357,40 +504,67 @@ def detect_tech_stack_commands(project_dir: Path, context: dict):
|
||||
}
|
||||
|
||||
|
||||
def create_prd_and_json(project_name: str, description: str, workspace_dir: Path):
|
||||
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
|
||||
|
||||
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']}")
|
||||
|
||||
# Generează PRD markdown
|
||||
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ă prd.json
|
||||
stories = prd_to_stories(prd_content, project_name)
|
||||
|
||||
# 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 = {
|
||||
@@ -460,19 +634,22 @@ def create_prd_and_json(project_name: str, description: str, workspace_dir: Path
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python ralph_prd_generator.py PROJECT_NAME 'description'")
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user