From 655ed3ae0961bab32b483d6792f7fd97ac72dbb4 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sun, 26 Apr 2026 18:36:35 +0000 Subject: [PATCH] feat(ralph): smart gates + DAG + dashboard live (W3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + //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) --- dashboard/api.py | 26 ++ dashboard/handlers/ralph.py | 305 ++++++++++++ dashboard/ralph.html | 615 +++++++++++++++++++++++++ tests/test_dag_execution.py | 250 ++++++++++ tests/test_dashboard_ralph_endpoint.py | 209 +++++++++ tests/test_smart_gates.py | 174 +++++++ tools/ralph/prd-template.json | 19 + tools/ralph/prompt.md | 219 +++------ tools/ralph/ralph.sh | 144 +++++- tools/ralph_dag.py | 267 +++++++++++ tools/ralph_prd_generator.py | 243 ++++++++-- 11 files changed, 2282 insertions(+), 189 deletions(-) create mode 100644 dashboard/handlers/ralph.py create mode 100644 dashboard/ralph.html create mode 100644 tests/test_dag_execution.py create mode 100644 tests/test_dashboard_ralph_endpoint.py create mode 100644 tests/test_smart_gates.py create mode 100644 tools/ralph_dag.py diff --git a/dashboard/api.py b/dashboard/api.py index 10bbb49..09b48ff 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -40,6 +40,7 @@ from handlers.files import FilesHandlers # noqa: E402 from handlers.git import GitHandlers # noqa: E402 from handlers.habits import HabitsHandlers # noqa: E402 from handlers.pdf import PDFHandlers # noqa: E402 +from handlers.ralph import RalphHandlers # noqa: E402 from handlers.workspace import WorkspaceHandlers # noqa: E402 from handlers.youtube import YoutubeHandlers # noqa: E402 @@ -95,6 +96,7 @@ class TaskBoardHandler( PDFHandlers, YoutubeHandlers, WorkspaceHandlers, + RalphHandlers, CronHandlers, SimpleHTTPRequestHandler, ): @@ -155,6 +157,23 @@ class TaskBoardHandler( self.handle_eco_logs() elif self.path == '/api/eco/doctor': self.handle_eco_doctor() + elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'): + self.handle_ralph_status() + elif self.path.startswith('/api/ralph/'): + # /api/ralph//log or /api/ralph//prd + parts = self.path.split('?', 1)[0].split('/') + # parts: ['', 'api', 'ralph', '', ''] + if len(parts) >= 5: + slug = parts[3] + action = parts[4] + if action == 'log': + self.handle_ralph_log(slug) + elif action == 'prd': + self.handle_ralph_prd(slug) + else: + self.send_error(404) + else: + self.send_error(404) elif self.path.startswith('/api/'): self.send_error(404) else: @@ -214,6 +233,13 @@ class TaskBoardHandler( self.handle_eco_git_commit() elif self.path == '/api/eco/restart-taskboard': self.handle_eco_restart_taskboard() + elif self.path.startswith('/api/ralph/') and self.path.endswith('/stop'): + parts = self.path.split('?', 1)[0].split('/') + if len(parts) >= 5: + slug = parts[3] + self.handle_ralph_stop(slug) + else: + self.send_error(404) else: self.send_error(404) diff --git a/dashboard/handlers/ralph.py b/dashboard/handlers/ralph.py new file mode 100644 index 0000000..b2bd14b --- /dev/null +++ b/dashboard/handlers/ralph.py @@ -0,0 +1,305 @@ +"""Ralph live dashboard endpoints (W3). + +Endpoints: + GET /api/ralph/status — toate proiectele Ralph (cards data) + GET /api/ralph//log — tail progress.txt (default 100 lines) + GET /api/ralph//prd — full prd.json content + POST /api/ralph//stop — SIGTERM la Ralph PID + +Polling: 5s din ralph.html (suficient pentru iter 8-15min Ralph). +NU SSE/WebSocket pentru MVP. + +Citește status din `~/workspace//scripts/ralph/`: +- prd.json → stories (passes/failed/blocked/retries) +- progress.txt → log human-readable +- logs/iteration-*.log → mtime ultimului iter +- .ralph.pid → PID activ (verificat cu os.kill 0) + +Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR). +""" +import json +import os +import signal +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote + +import constants + + +# Path Ralph per proiect (mereu în scripts/ralph/) +def _ralph_dir(project_dir: Path) -> Path: + return project_dir / "scripts" / "ralph" + + +# Estimare ETA simplistă: avg iter time × stories rămase +DEFAULT_ITER_MINUTES = 12 # midpoint din intervalul 8-15min menționat în plan + + +class RalphHandlers: + """Mixin pentru /api/ralph/* — Ralph live status + control.""" + + # ── helpers ──────────────────────────────────────────────── + def _ralph_validate_slug(self, slug: str): + """Validează slug-ul + returnează project_dir sau None.""" + if not slug or "/" in slug or ".." in slug: + return None + slug = unquote(slug) + project_dir = constants.WORKSPACE_DIR / slug + try: + resolved = project_dir.resolve() + workspace_resolved = constants.WORKSPACE_DIR.resolve() + resolved.relative_to(workspace_resolved) + except (ValueError, OSError): + return None + if not project_dir.exists() or not project_dir.is_dir(): + return None + return project_dir + + def _ralph_pid_alive(self, ralph_dir: Path): + """Întoarce (running: bool, pid: int|None).""" + pid_file = ralph_dir / ".ralph.pid" + if not pid_file.exists(): + return False, None + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) # signal 0 = check existence + return True, pid + except (ValueError, ProcessLookupError, PermissionError, OSError): + return False, None + + def _ralph_eta_minutes(self, stories_remaining: int, last_iter_mtime: float | None) -> int | None: + """Estimează minute rămase — None dacă nu avem date.""" + if stories_remaining <= 0: + return 0 + return stories_remaining * DEFAULT_ITER_MINUTES + + def _ralph_summarize_project(self, project_dir: Path) -> dict | None: + """Construiește dict de status per proiect — None dacă nu e Ralph project.""" + ralph_dir = _ralph_dir(project_dir) + prd_json = ralph_dir / "prd.json" + if not prd_json.exists(): + return None + + # Defensive parse — corupt prd.json nu trebuie să dărâme dashboard + try: + prd = json.loads(prd_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return { + "slug": project_dir.name, + "status": "error", + "error": "prd.json invalid sau ilizibil", + "running": False, + "pid": None, + "stories": [], + "storiesTotal": 0, + "storiesComplete": 0, + "storiesFailed": 0, + "storiesBlocked": 0, + } + + stories = prd.get("userStories", []) or [] + total = len(stories) + complete = sum(1 for s in stories if s.get("passes")) + failed = sum(1 for s in stories if s.get("failed")) + blocked = sum(1 for s in stories if s.get("blocked")) + remaining = total - complete - failed - blocked + + running, pid = self._ralph_pid_alive(ralph_dir) + + # Last iteration mtime (pentru "acum X") + logs_dir = ralph_dir / "logs" + last_iter_mtime = None + last_iter_iso = None + if logs_dir.exists(): + iter_logs = sorted(logs_dir.glob("iteration-*.log"), key=lambda f: f.stat().st_mtime, reverse=True) + if iter_logs: + last_iter_mtime = iter_logs[0].stat().st_mtime + last_iter_iso = datetime.fromtimestamp(last_iter_mtime).isoformat() + + # Status compus pentru UI cards + if running: + top_status = "running" + elif failed > 0 and remaining == 0: + top_status = "failed" + elif complete == total and total > 0: + top_status = "complete" + elif blocked > 0 and running is False: + top_status = "blocked" + else: + top_status = "idle" + + # Current story (DAG-eligible cel mai mic priority) + current_story = None + if running: + eligible = [ + s for s in stories + if not s.get("passes") and not s.get("failed") and not s.get("blocked") + ] + eligible.sort(key=lambda s: (s.get("priority", 999), s.get("id", ""))) + if eligible: + current_story = { + "id": eligible[0].get("id"), + "title": eligible[0].get("title"), + "tags": eligible[0].get("tags", []), + "retries": eligible[0].get("retries", 0), + } + + return { + "slug": project_dir.name, + "status": top_status, + "running": running, + "pid": pid, + "branchName": prd.get("branchName", ""), + "storiesTotal": total, + "storiesComplete": complete, + "storiesFailed": failed, + "storiesBlocked": blocked, + "storiesRemaining": remaining, + "currentStory": current_story, + "lastIterAt": last_iter_iso, + "etaMinutes": self._ralph_eta_minutes(remaining, last_iter_mtime), + "stories": [ + { + "id": s.get("id"), + "title": s.get("title"), + "passes": bool(s.get("passes")), + "failed": bool(s.get("failed")), + "blocked": bool(s.get("blocked")), + "retries": int(s.get("retries", 0)), + "tags": s.get("tags", []), + "failureReason": s.get("failureReason", ""), + } + for s in stories + ], + } + + # ── /api/ralph/status (GET) ──────────────────────────────── + def handle_ralph_status(self): + """Întoarce status pentru toate proiectele Ralph din workspace.""" + try: + projects = [] + if not constants.WORKSPACE_DIR.exists(): + self.send_json({"projects": [], "fetchedAt": datetime.now().isoformat()}) + return + + for entry in sorted(constants.WORKSPACE_DIR.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + summary = self._ralph_summarize_project(entry) + if summary is not None: + projects.append(summary) + + self.send_json({ + "projects": projects, + "fetchedAt": datetime.now().isoformat(), + "count": len(projects), + }) + except Exception as exc: + self.send_json({"error": str(exc)}, 500) + + # ── /api/ralph//log (GET) ──────────────────────────── + def handle_ralph_log(self, slug: str): + """Tail progress.txt pentru un slug. Default last 100 lines.""" + try: + project_dir = self._ralph_validate_slug(slug) + if not project_dir: + self.send_json({"error": "Invalid project slug"}, 400) + return + + from urllib.parse import parse_qs, urlparse + qs = parse_qs(urlparse(self.path).query) + try: + lines_n = min(int(qs.get("lines", ["100"])[0]), 1000) + except ValueError: + lines_n = 100 + + progress = _ralph_dir(project_dir) / "progress.txt" + if not progress.exists(): + self.send_json({"slug": slug, "lines": [], "total": 0}) + return + + try: + content = progress.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + self.send_json({"error": f"read failed: {exc}"}, 500) + return + + all_lines = content.splitlines() + tail = all_lines[-lines_n:] if len(all_lines) > lines_n else all_lines + self.send_json({ + "slug": slug, + "lines": tail, + "total": len(all_lines), + }) + except Exception as exc: + self.send_json({"error": str(exc)}, 500) + + # ── /api/ralph//prd (GET) ──────────────────────────── + def handle_ralph_prd(self, slug: str): + """Returnează full prd.json pentru un slug.""" + try: + project_dir = self._ralph_validate_slug(slug) + if not project_dir: + self.send_json({"error": "Invalid project slug"}, 400) + return + + prd_json = _ralph_dir(project_dir) / "prd.json" + if not prd_json.exists(): + self.send_json({"error": "prd.json not found"}, 404) + return + + try: + data = json.loads(prd_json.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + self.send_json({"error": f"prd.json invalid: {exc}"}, 500) + return + + self.send_json(data) + except Exception as exc: + self.send_json({"error": str(exc)}, 500) + + # ── /api/ralph//stop (POST) ────────────────────────── + def handle_ralph_stop(self, slug: str): + """Trimite SIGTERM la Ralph PID. Verifică că PID-ul e în WORKSPACE_DIR.""" + try: + project_dir = self._ralph_validate_slug(slug) + if not project_dir: + self.send_json({"success": False, "error": "Invalid project slug"}, 400) + return + + ralph_dir = _ralph_dir(project_dir) + pid_file = ralph_dir / ".ralph.pid" + if not pid_file.exists(): + self.send_json({"success": False, "error": "No PID file"}, 404) + return + + try: + pid = int(pid_file.read_text().strip()) + except (ValueError, OSError) as exc: + self.send_json({"success": False, "error": f"Invalid PID file: {exc}"}, 500) + return + + # Sandbox: verifică că procesul e în workspace (nu omoară random PID) + try: + proc_cwd = Path(f"/proc/{pid}/cwd").resolve() + if not str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)): + self.send_json({"success": False, "error": "PID not in workspace"}, 403) + return + except (FileNotFoundError, PermissionError): + # Procesul nu mai există — best-effort cleanup + self.send_json({"success": True, "message": "Process already stopped"}) + return + + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except ProcessLookupError: + self.send_json({"success": True, "message": "Process already stopped"}) + return + except PermissionError: + self.send_json({"success": False, "error": "Permission denied"}, 403) + return + + self.send_json({"success": True, "message": f"Ralph stopped (PID {pid})"}) + except Exception as exc: + self.send_json({"success": False, "error": str(exc)}, 500) diff --git a/dashboard/ralph.html b/dashboard/ralph.html new file mode 100644 index 0000000..bc5f44c --- /dev/null +++ b/dashboard/ralph.html @@ -0,0 +1,615 @@ + + + + + + + Echo · Ralph + + + + + + + + +
+ + +
+
+ +
Se încarcă proiectele Ralph...
+
+
+
+ + + + + + + diff --git a/tests/test_dag_execution.py b/tests/test_dag_execution.py new file mode 100644 index 0000000..80da374 --- /dev/null +++ b/tests/test_dag_execution.py @@ -0,0 +1,250 @@ +"""Tests for W3 DAG execution + retry guard. + +Acoperă: +- topological_eligible: alegere story DAG-aware (passes/failed/blocked propagation) +- cmd_incr_retry: 3-retry guard cu auto-fail la max_retries +- cmd_mark_failed: propagare blocked la dependenți +- _normalize_story: validează schema extinsă (tags, dependsOn, retries, blocked) +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from tools import ralph_dag # noqa: E402 +from tools.ralph_prd_generator import _normalize_story # noqa: E402 + + +# ── topological_eligible ─────────────────────────────────────── + + +def _stories(*specs): + """Helper: build minimal stories list. Each spec = (id, priority, dependsOn, flags...)""" + out = [] + for spec in specs: + sid, prio, deps, *rest = spec + s = { + "id": sid, + "priority": prio, + "dependsOn": list(deps), + "passes": False, + "failed": False, + "blocked": False, + "retries": 0, + } + for flag in rest: + s[flag] = True + out.append(s) + return out + + +class TestTopologicalEligible: + def test_no_deps_lowest_priority_picked(self): + stories = _stories( + ("US-002", 20, []), + ("US-001", 10, []), + ) + chosen = ralph_dag.topological_eligible(stories) + assert chosen["id"] == "US-001" + + def test_dependent_skipped_until_dep_passes(self): + stories = _stories( + ("US-001", 10, []), + ("US-002", 20, ["US-001"]), + ) + # US-001 not done yet → US-001 picked + assert ralph_dag.topological_eligible(stories)["id"] == "US-001" + + # Mark US-001 passes → US-002 eligible + stories[0]["passes"] = True + assert ralph_dag.topological_eligible(stories)["id"] == "US-002" + + def test_failed_dep_propagates_blocked(self): + stories = _stories( + ("US-001", 10, []), + ("US-002", 20, ["US-001"]), + ) + stories[0]["failed"] = True + chosen = ralph_dag.topological_eligible(stories) + assert chosen is None # US-002 marcat blocked, nimic eligibil + assert stories[1]["blocked"] is True + assert stories[1]["failureReason"] == "blocked_by:US-001" + + def test_independent_runs_when_other_chain_failed(self): + # US-001 failed → US-002 blocked, dar US-003 e independent → eligibil + stories = _stories( + ("US-001", 10, []), + ("US-002", 20, ["US-001"]), + ("US-003", 30, []), + ) + stories[0]["failed"] = True + chosen = ralph_dag.topological_eligible(stories) + assert chosen["id"] == "US-003" + assert stories[1]["blocked"] is True + + def test_chain_blocking_propagates_transitively(self): + # US-001 → US-002 → US-003. US-001 failed → US-002 blocked → US-003 blocked. + stories = _stories( + ("US-001", 10, []), + ("US-002", 20, ["US-001"]), + ("US-003", 30, ["US-002"]), + ) + stories[0]["failed"] = True + chosen = ralph_dag.topological_eligible(stories) + assert chosen is None + assert stories[1]["blocked"] is True + assert stories[2]["blocked"] is True + + def test_all_complete_returns_none(self): + stories = _stories(("US-001", 10, [])) + stories[0]["passes"] = True + assert ralph_dag.topological_eligible(stories) is None + + def test_already_blocked_story_skipped(self): + stories = _stories( + ("US-001", 10, []), + ("US-002", 20, []), + ) + stories[0]["blocked"] = True + chosen = ralph_dag.topological_eligible(stories) + assert chosen["id"] == "US-002" + + +# ── cmd_incr_retry / cmd_mark_failed (file-based) ────────────── + + +@pytest.fixture +def prd_path(tmp_path): + """Construiește un prd.json minimal pentru test.""" + data = { + "projectName": "test-proj", + "branchName": "ralph/test-proj", + "userStories": [ + { + "id": "US-001", "title": "first", "priority": 10, + "dependsOn": [], "tags": [], "acceptanceCriteria": ["a"], + "passes": False, "failed": False, "blocked": False, "retries": 0, + }, + { + "id": "US-002", "title": "second", "priority": 20, + "dependsOn": ["US-001"], "tags": [], "acceptanceCriteria": ["a"], + "passes": False, "failed": False, "blocked": False, "retries": 0, + }, + ], + } + p = tmp_path / "prd.json" + p.write_text(json.dumps(data), encoding="utf-8") + return p + + +class TestRetryGuard: + def test_incr_retry_increments_count(self, prd_path): + rc = ralph_dag.cmd_incr_retry(prd_path, "US-001") + assert rc == 0 + data = json.loads(prd_path.read_text()) + assert data["userStories"][0]["retries"] == 1 + assert data["userStories"][0]["failed"] is False + + def test_three_retries_marks_failed_max_retries(self, prd_path): + # incr 3 times + for _ in range(3): + ralph_dag.cmd_incr_retry(prd_path, "US-001") + data = json.loads(prd_path.read_text()) + assert data["userStories"][0]["retries"] == 3 + assert data["userStories"][0]["failed"] is True + assert data["userStories"][0]["failureReason"] == "max_retries" + + def test_max_retries_propagates_blocked_to_dependent(self, prd_path): + for _ in range(3): + ralph_dag.cmd_incr_retry(prd_path, "US-001") + data = json.loads(prd_path.read_text()) + # US-002 depinde de US-001 → blocked + assert data["userStories"][1]["blocked"] is True + + def test_unknown_story_returns_error(self, prd_path): + rc = ralph_dag.cmd_incr_retry(prd_path, "US-999") + assert rc == 1 + + +class TestMarkFailed: + def test_mark_failed_sets_flags(self, prd_path): + rc = ralph_dag.cmd_mark_failed(prd_path, "US-001", "rate_limited") + assert rc == 0 + data = json.loads(prd_path.read_text()) + assert data["userStories"][0]["failed"] is True + assert data["userStories"][0]["failureReason"] == "rate_limited" + + def test_mark_failed_propagates_blocked(self, prd_path): + ralph_dag.cmd_mark_failed(prd_path, "US-001", "rate_limited") + data = json.loads(prd_path.read_text()) + assert data["userStories"][1]["blocked"] is True + assert data["userStories"][1]["failureReason"] == "blocked_by:US-001" + + +class TestNextStory: + def test_next_story_prints_id(self, prd_path, capsys): + rc = ralph_dag.cmd_next_story(prd_path) + assert rc == 0 + captured = capsys.readouterr() + assert captured.out.strip() == "US-001" + + def test_next_story_returns_1_when_none_eligible(self, prd_path, capsys): + # Mark all complete + data = json.loads(prd_path.read_text()) + for s in data["userStories"]: + s["passes"] = True + prd_path.write_text(json.dumps(data)) + rc = ralph_dag.cmd_next_story(prd_path) + assert rc == 1 + + +# ── _normalize_story (PRD generator schema) ──────────────────── + + +class TestNormalizeStory: + def test_default_fields_populated(self): + s = _normalize_story({"title": "x"}, idx=0) + # Schema W3 fields trebuie să existe toate + for key in ("id", "title", "description", "priority", "acceptanceCriteria", + "tags", "dependsOn", "passes", "failed", "blocked", "retries", + "failureReason", "notes"): + assert key in s, f"Missing schema field: {key}" + assert s["passes"] is False + assert s["failed"] is False + assert s["blocked"] is False + assert s["retries"] == 0 + + def test_invalid_tags_filtered(self): + s = _normalize_story({"title": "x", "tags": ["frontend", "ui", "made-up"]}, idx=0) + assert s["tags"] == ["ui"] # frontend & made-up nu sunt în VALID_TAGS + + def test_empty_acceptance_gets_default(self): + s = _normalize_story({"title": "x"}, idx=0) + assert len(s["acceptanceCriteria"]) >= 1 + + def test_ui_tag_implies_browser_check(self): + s = _normalize_story({"title": "x", "tags": ["ui"]}, idx=0) + assert s["requiresBrowserCheck"] is True + + def test_explicit_browser_check_preserved(self): + s = _normalize_story({"title": "x", "tags": [], "requiresBrowserCheck": True}, idx=0) + assert s["requiresBrowserCheck"] is True + + def test_id_auto_generated_from_idx(self): + s = _normalize_story({"title": "x"}, idx=4) + assert s["id"] == "US-005" + + def test_id_preserved_when_provided(self): + s = _normalize_story({"id": "US-042", "title": "x"}, idx=0) + assert s["id"] == "US-042" + + def test_depends_on_preserves_strings_filters_garbage(self): + s = _normalize_story({"title": "x", "dependsOn": ["US-001", "", None, "US-002"]}, idx=0) + assert s["dependsOn"] == ["US-001", "US-002"] diff --git a/tests/test_dashboard_ralph_endpoint.py b/tests/test_dashboard_ralph_endpoint.py new file mode 100644 index 0000000..f0a72a2 --- /dev/null +++ b/tests/test_dashboard_ralph_endpoint.py @@ -0,0 +1,209 @@ +"""Tests for /api/ralph/* endpoints (dashboard live). + +Acoperă: +- /api/ralph/status: list cards cu state + count + fetchedAt +- /api/ralph//log: tail progress.txt +- /api/ralph//prd: full prd.json +- _ralph_validate_slug: path traversal protection +- corrupt prd.json: graceful error (status='error', not 500) +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DASH = PROJECT_ROOT / "dashboard" +if str(DASH) not in sys.path: + sys.path.insert(0, str(DASH)) + + +@pytest.fixture(scope="module") +def ralph_module(): + from handlers import ralph as _r # type: ignore + return _r + + +@pytest.fixture +def handler(ralph_module, tmp_path, monkeypatch): + """Build a stubbed handler with a temp WORKSPACE_DIR.""" + import constants # type: ignore + + # Re-route WORKSPACE_DIR la tmp pentru izolare + monkeypatch.setattr(constants, "WORKSPACE_DIR", tmp_path) + + class _Stub(ralph_module.RalphHandlers): + def __init__(self): + self.captured = None + self.captured_code = None + self.path = "/api/ralph/status" + + def send_json(self, data, code=200): + self.captured = data + self.captured_code = code + + def send_error(self, code): + self.captured = {"error_code": code} + self.captured_code = code + + return _Stub() + + +def _make_ralph_project(workspace: Path, slug: str, stories: list, progress: str = "init"): + """Create a fake ralph project under workspace//scripts/ralph/.""" + ralph_dir = workspace / slug / "scripts" / "ralph" + ralph_dir.mkdir(parents=True, exist_ok=True) + (ralph_dir / "prd.json").write_text(json.dumps({ + "projectName": slug, + "branchName": f"ralph/{slug}", + "userStories": stories, + }), encoding="utf-8") + (ralph_dir / "progress.txt").write_text(progress, encoding="utf-8") + return ralph_dir + + +# ── /api/ralph/status ────────────────────────────────────────── + + +class TestStatus: + def test_empty_workspace_returns_empty(self, handler): + handler.handle_ralph_status() + assert handler.captured_code == 200 + assert handler.captured["projects"] == [] + assert "fetchedAt" in handler.captured + + def test_status_skips_non_ralph_projects(self, handler, tmp_path): + # Create a project WITHOUT scripts/ralph + (tmp_path / "regular-proj").mkdir() + handler.handle_ralph_status() + assert handler.captured["projects"] == [] + + def test_status_lists_ralph_projects(self, handler, tmp_path): + _make_ralph_project(tmp_path, "proj-a", [ + {"id": "US-001", "title": "a", "priority": 10, "passes": True, + "failed": False, "blocked": False, "retries": 0, "tags": []}, + {"id": "US-002", "title": "b", "priority": 20, "passes": False, + "failed": False, "blocked": False, "retries": 0, "tags": ["ui"]}, + ]) + handler.handle_ralph_status() + projects = handler.captured["projects"] + assert len(projects) == 1 + p = projects[0] + assert p["slug"] == "proj-a" + assert p["storiesTotal"] == 2 + assert p["storiesComplete"] == 1 + assert p["storiesFailed"] == 0 + assert p["storiesBlocked"] == 0 + assert p["status"] == "idle" # not running (no .ralph.pid) + + def test_status_corrupt_prd_returns_error_not_500(self, handler, tmp_path): + ralph_dir = tmp_path / "broken" / "scripts" / "ralph" + ralph_dir.mkdir(parents=True) + (ralph_dir / "prd.json").write_text("{not valid json", encoding="utf-8") + handler.handle_ralph_status() + assert handler.captured_code == 200 + assert any(p.get("status") == "error" for p in handler.captured["projects"]) + + def test_status_count_matches_projects(self, handler, tmp_path): + _make_ralph_project(tmp_path, "p1", []) + _make_ralph_project(tmp_path, "p2", []) + handler.handle_ralph_status() + assert handler.captured["count"] == 2 + + def test_complete_status_when_all_pass(self, handler, tmp_path): + _make_ralph_project(tmp_path, "donezo", [ + {"id": "US-001", "passes": True, "failed": False, "blocked": False, + "retries": 0, "tags": [], "title": "x", "priority": 10}, + ]) + handler.handle_ralph_status() + p = handler.captured["projects"][0] + assert p["status"] == "complete" + + def test_failed_status_propagation(self, handler, tmp_path): + _make_ralph_project(tmp_path, "broken-proj", [ + {"id": "US-001", "passes": False, "failed": True, "blocked": False, + "retries": 3, "tags": [], "title": "x", "priority": 10, + "failureReason": "max_retries"}, + ]) + handler.handle_ralph_status() + p = handler.captured["projects"][0] + assert p["status"] == "failed" + assert p["storiesFailed"] == 1 + + +# ── /api/ralph//log ────────────────────────────────────── + + +class TestLog: + def test_log_returns_progress_lines(self, handler, tmp_path): + _make_ralph_project(tmp_path, "p1", [], progress="line1\nline2\nline3") + handler.path = "/api/ralph/p1/log" + handler.handle_ralph_log("p1") + assert handler.captured_code == 200 + assert handler.captured["lines"] == ["line1", "line2", "line3"] + assert handler.captured["total"] == 3 + + def test_log_invalid_slug_400(self, handler): + handler.handle_ralph_log("../etc/passwd") + assert handler.captured_code == 400 + + def test_log_path_traversal_blocked(self, handler): + handler.handle_ralph_log("..") + assert handler.captured_code == 400 + + def test_log_missing_progress_returns_empty(self, handler, tmp_path): + ralph_dir = tmp_path / "noprogress" / "scripts" / "ralph" + ralph_dir.mkdir(parents=True) + (ralph_dir / "prd.json").write_text("{}") # no progress.txt + handler.path = "/api/ralph/noprogress/log" + handler.handle_ralph_log("noprogress") + assert handler.captured_code == 200 + assert handler.captured["lines"] == [] + + +# ── /api/ralph//prd ────────────────────────────────────── + + +class TestPrd: + def test_prd_returns_full_json(self, handler, tmp_path): + stories = [{"id": "US-001", "passes": False, "title": "t", "priority": 10}] + _make_ralph_project(tmp_path, "p1", stories) + handler.handle_ralph_prd("p1") + assert handler.captured_code == 200 + assert handler.captured["projectName"] == "p1" + assert len(handler.captured["userStories"]) == 1 + + def test_prd_404_when_missing(self, handler, tmp_path): + (tmp_path / "ghost").mkdir() # exists, but no prd.json + handler.handle_ralph_prd("ghost") + assert handler.captured_code == 404 + + def test_prd_invalid_slug_400(self, handler): + handler.handle_ralph_prd("/etc/passwd") + assert handler.captured_code == 400 + + +# ── _ralph_validate_slug ─────────────────────────────────────── + + +class TestValidateSlug: + def test_valid_slug_returns_path(self, handler, tmp_path): + (tmp_path / "good-slug").mkdir() + result = handler._ralph_validate_slug("good-slug") + assert result is not None + assert result.name == "good-slug" + + def test_slash_rejected(self, handler): + assert handler._ralph_validate_slug("a/b") is None + + def test_dotdot_rejected(self, handler): + assert handler._ralph_validate_slug("..") is None + + def test_empty_rejected(self, handler): + assert handler._ralph_validate_slug("") is None + + def test_nonexistent_returns_none(self, handler): + assert handler._ralph_validate_slug("does-not-exist") is None diff --git a/tests/test_smart_gates.py b/tests/test_smart_gates.py new file mode 100644 index 0000000..8bd03fb --- /dev/null +++ b/tests/test_smart_gates.py @@ -0,0 +1,174 @@ +"""Tests for W3 smart gates tag validation heuristic. + +Acoperă: +- infer_tags_from_paths: detect ui/db/vercel pe baza file extensions / paths +- force_include_tags: combinare tags Opus + tags inferate din diff (anti-silent-regression) +- Toate combinatii de tag types (ui, db, vercel, refactor, docs, backend, infra) +- Edge cases: tags vide, tags invalide, empty diff +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.ralph_dag import ( # noqa: E402 + VALID_TAGS, + force_include_tags, + infer_tags_from_paths, +) + + +# ── infer_tags_from_paths ────────────────────────────────────── + + +class TestInferTags: + def test_empty_diff_no_tags(self): + assert infer_tags_from_paths([]) == [] + + def test_only_readme_no_tags(self): + assert infer_tags_from_paths(["README.md", "CHANGELOG.md"]) == [] + + def test_vue_triggers_ui(self): + assert infer_tags_from_paths(["src/App.vue"]) == ["ui"] + + def test_tsx_triggers_ui(self): + assert infer_tags_from_paths(["app/page.tsx"]) == ["ui"] + + def test_jsx_triggers_ui(self): + assert infer_tags_from_paths(["src/Button.jsx"]) == ["ui"] + + def test_html_triggers_ui(self): + assert infer_tags_from_paths(["dashboard/index.html"]) == ["ui"] + + def test_css_scss_trigger_ui(self): + assert infer_tags_from_paths(["src/main.css"]) == ["ui"] + assert infer_tags_from_paths(["src/main.scss"]) == ["ui"] + + def test_svelte_triggers_ui(self): + assert infer_tags_from_paths(["src/App.svelte"]) == ["ui"] + + def test_migrations_triggers_db(self): + assert infer_tags_from_paths(["db/migrations/0001_init.sql"]) == ["db"] + + def test_top_level_migrations_triggers_db(self): + assert infer_tags_from_paths(["migrations/2026/04/add_users.sql"]) == ["db"] + + def test_sql_outside_migrations_still_triggers_db(self): + assert infer_tags_from_paths(["scripts/seed.sql"]) == ["db"] + + def test_vercel_json_only(self): + assert infer_tags_from_paths([], has_vercel_json=True) == ["vercel"] + + def test_combined_ui_db_vercel(self): + result = infer_tags_from_paths( + ["app/page.tsx", "db/migrations/0001.sql"], has_vercel_json=True + ) + assert result == ["ui", "db", "vercel"] + + def test_dedup_when_multiple_files_same_category(self): + result = infer_tags_from_paths(["a.tsx", "b.vue", "c.css"]) + assert result == ["ui"] + + def test_case_insensitive_extensions(self): + assert infer_tags_from_paths(["src/App.TSX"]) == ["ui"] + assert infer_tags_from_paths(["db/Init.SQL"]) == ["db"] + + +# ── force_include_tags ───────────────────────────────────────── + + +class TestForceIncludeTags: + def test_existing_only_no_diff(self): + assert force_include_tags(["backend"], [], False) == ["backend"] + + def test_diff_inferred_added_to_existing(self): + # Opus marcat docs, dar diff atinge .tsx → ui forțat + result = force_include_tags(["docs"], ["src/Page.tsx"], False) + assert "docs" in result + assert "ui" in result + + def test_filters_invalid_tags_from_existing(self): + # Tag-ul "frontend" nu e în VALID_TAGS — trebuie eliminat + result = force_include_tags(["frontend", "ui"], [], False) + assert "frontend" not in result + assert "ui" in result + + def test_empty_when_no_existing_no_diff(self): + assert force_include_tags([], [], False) == [] + + def test_dedup_existing_and_inferred(self): + # Existing are ui, diff are .tsx → un singur ui în output + result = force_include_tags(["ui"], ["src/A.tsx"], False) + assert result.count("ui") == 1 + + def test_vercel_added_when_vercel_json_present(self): + result = force_include_tags(["backend"], [], has_vercel_json=True) + assert "vercel" in result + assert "backend" in result + + def test_all_valid_tags_preserved(self): + # Verifică că force_include nu strică tags valide existente + all_valid = list(VALID_TAGS) + result = force_include_tags(all_valid, [], False) + for t in all_valid: + assert t in result + + def test_order_existing_first_then_inferred(self): + # Existing tags trebuie să apară primele (stabilitate API) + result = force_include_tags(["backend"], ["src/Page.tsx", "db/migrations/0001.sql"], False) + assert result[0] == "backend" + assert "ui" in result and "db" in result + + +# ── Smart gates dispatcher contract (combinatii tag → expected gates) ───────── + + +# Acesta e un table-test pentru contractul dispatcher-ului din prompt.md. +# Verifică doar mapping-ul tag → gate name (specifice prompt.md), nu execuția. +GATE_MAPPING = { + "refactor": "/workflow:simplify", + "ui": "/qa", + "vercel": "gh pr checks", + "db": "schema diff", + "docs": None, # docs => doar typecheck base + "backend": "/review", + "infra": "/review", +} + + +class TestGateMapping: + """Validează că prompt.md menționează gate-urile așteptate per tag.""" + + @pytest.fixture(scope="class") + def prompt_md(self): + path = PROJECT_ROOT / "tools" / "ralph" / "prompt.md" + return path.read_text(encoding="utf-8") + + def test_refactor_gate_documented(self, prompt_md): + assert "/workflow:simplify" in prompt_md + + def test_ui_gate_documented(self, prompt_md): + assert "/qa" in prompt_md + assert "agent-browser" in prompt_md.lower() + + def test_vercel_gate_documented(self, prompt_md): + assert "gh pr checks" in prompt_md + + def test_db_gate_documented(self, prompt_md): + assert "schema diff" in prompt_md.lower() or "alembic" in prompt_md.lower() + + def test_backend_gate_documented(self, prompt_md): + assert "/review" in prompt_md + + def test_run_all_fallback_documented(self, prompt_md): + # Tags vide → run-all-gates fallback (safe default) + assert "tags vide" in prompt_md.lower() or "run-all-gates" in prompt_md.lower() + + def test_dag_dependson_documented(self, prompt_md): + assert "dependsOn" in prompt_md or "DAG" in prompt_md diff --git a/tools/ralph/prd-template.json b/tools/ralph/prd-template.json index 42637e9..1bfabb8 100644 --- a/tools/ralph/prd-template.json +++ b/tools/ralph/prd-template.json @@ -2,6 +2,17 @@ "projectName": "feature-name", "branchName": "ralph/feature-name", "description": "Descriere scurtă a feature-ului", + "techStack": { + "type": "python", + "commands": { + "start": "python main.py", + "build": "", + "lint": "ruff check .", + "typecheck": "mypy .", + "test": "pytest" + }, + "port": 8000 + }, "userStories": [ { "id": "US-001", @@ -12,7 +23,15 @@ "Criteriu specific și verificabil", "npm run typecheck passes" ], + "tags": [], + "dependsOn": [], + "requiresBrowserCheck": false, + "requiresDesignReview": false, "passes": false, + "failed": false, + "blocked": false, + "retries": 0, + "failureReason": "", "notes": "" } ] diff --git a/tools/ralph/prompt.md b/tools/ralph/prompt.md index 25f7f9b..57419af 100644 --- a/tools/ralph/prompt.md +++ b/tools/ralph/prompt.md @@ -1,203 +1,120 @@ -# Ralph - Instrucțiuni pentru Iterație +# Ralph - Instrucțiuni pentru Iterație (smart gates) -Ești un agent autonom care implementează user stories dintr-un PRD. Aceasta este O SINGURĂ iterație - implementezi UN singur story și apoi te oprești. +Ești un agent autonom care implementează user stories dintr-un PRD. Aceasta este O SINGURĂ iterație — implementezi UN singur story, validezi prin gate-urile relevante, apoi te oprești. -## Workflow pentru această iterație +## Workflow per iterație (4 faze, gates condiționale pe `story.tags`) -### 1. Citește contextul -- PRD-ul și progress.txt sunt furnizate în context -- Înțelege ce stories sunt deja complete (`passes: true`) -- Identifică următorul story de implementat (prioritate cea mai mică dintre cele incomplete) -- Notează `techStack.commands` din PRD pentru comenzile corecte +### Faza 0: Citește contextul -### 2. Management branch -- Verifică dacă ești pe branch-ul corect (specificat în `branchName` din PRD) -- Dacă nu, creează și checkout branch-ul: - ```bash - git checkout -b - ``` -- Dacă branch-ul există deja, doar checkout: - ```bash - git checkout - ``` +- PRD-ul (`prd.json`) și `progress.txt` sunt furnizate în context. +- Identifică următorul story candidate: + - `passes != true` ȘI `failed != true` ȘI `blocked != true` + - DAG: toate ID-urile din `dependsOn[]` au `passes == true` (altfel sare la următorul independent) + - Cea mai mică `priority` printre cele eligibile. +- Notează `techStack.commands` (lint, typecheck, test, start) și `techStack.port`. +- Notează `story.tags[]` — alegerea Faza 3 depinde de ele. -### 3. Selectează story-ul -- Alege story-ul cu cea mai mică prioritate care are `passes: false` -- Citește atent acceptance criteria -- Verifică câmpul `requiresBrowserCheck` - dacă e `true`, trebuie verificare vizuală +### Faza 1: IMPLEMENTARE (mereu) -### 4. Implementare -- Implementează DOAR acest story -- Urmează patterns existente în codebase -- Fii minimal și focusat - nu adăuga funcționalități extra +1.1. **Branch management** — verifică să fii pe `branchName` din PRD; checkout/create dacă lipsește. +1.2. **Citește acceptance criteria** — fiecare criteriu e un test mental concret de trecut. +1.3. **Implementează cod minimal** — DOAR ce cere story-ul. Urmează patterns existente. Fără over-engineering, fără side features. +1.4. **Update `notes`** în `prd.json` cu fișierele atinse (pentru audit ulterior). -### 5. Quality Checks -Rulează TOATE verificările înainte de commit. Folosește comenzile din `techStack.commands`: +### Faza 2: QUALITY BASE (mereu, înainte de gates) + +Folosește `techStack.commands`: ```bash -# Folosește comenzile din prd.json techStack.commands: -{techStack.commands.typecheck} # Type checking -{techStack.commands.lint} # Linting -{techStack.commands.test} # Tests (dacă există) +{techStack.commands.typecheck} # ex: npm run typecheck / mypy . +{techStack.commands.lint} # ex: npm run lint / ruff check . +{techStack.commands.test} # ex: npm test / pytest ``` -**Comenzi standard per stack:** +**Loop intern**: dacă vreuna eșuează → repară și repetă, max 3 retries în această fază. Dacă încă fail după 3, ieși cu sumar de erori în `progress.txt` (ralph.sh va decide retry-ul iterației). -| Stack | Typecheck | Lint | Test | -|-------|-----------|------|------| -| Next.js/TS | npm run typecheck | npm run lint | npm test | -| Node.js | npm run typecheck | npm run lint | npm test | -| Python | mypy . | ruff check . | python -m pytest | -| Go | - | golangci-lint run | go test ./... | +### Faza 3: SMART GATES (dispatcher pe `story.tags`) -**IMPORTANT**: Nu face commit dacă verificările eșuează. Repară mai întâi. +Tags posibile: `ui`, `db`, `vercel`, `refactor`, `docs`, `backend`, `infra`. -### 6. Verificare Browser (pentru UI stories) +Aplică DOAR gate-urile potrivite — **nu rulează toate**: -**DACĂ story-ul are `requiresBrowserCheck: true` sau implică UI:** +| Tag | Gate | +|-------------|---------------------------------------------------------------------------------------| +| `refactor` | `/workflow:simplify` pe diff (reduce complexity fără behavior change) | +| `ui` | `/qa` Playwright/agent-browser snapshot pe `localhost:{techStack.port}` + screenshot | +| `ui` + `requiresDesignReview` | `/plan-design-review` pe screenshot capturat | +| `vercel` | push branch + `gh pr checks --watch` (timeout 5 min); fail dacă PR checks eșuează | +| `db` | verify schema diff (alembic / prisma migrate diff / `psql \\d+ tablename`) | +| `docs` | doar typecheck base (Faza 2 e suficient); skip gate dedicat | +| `backend` | `/review` pe diff (intern — second pass review pe API contracts, error handling) | +| `infra` | `/review` pe diff + manual smoke test al modificărilor (CI config, Dockerfile, etc.) | +| _(tags vide)_ | **run-all-gates fallback** — `/review` + `/qa` + `/workflow:simplify` (safe default) | -Folosește **agent-browser CLI** pentru verificare vizuală. Agent-browser e optimizat pentru agenți AI cu referințe compacte (@e1, @e2) care consumă minim tokeni. +**Mecanism**: skill-urile gstack se invocă prin text mention în prompt — Claude (subprocess `claude -p`) le vede ca tool-uri disponibile via `~/.claude/skills/gstack/`. -#### 6.1 Pornește dev server-ul -```bash -# Folosește comanda din techStack.commands.start -{techStack.commands.start} -# Exemplu: npm run dev -``` +**Multi-tag**: rulează gate-uri pentru fiecare tag (ex: `["ui", "backend"]` → atât `/qa` cât și `/review`). -Așteaptă să pornească (verifică output-ul pentru "ready" sau similar). +**Important**: dacă vreun gate eșuează, NU marca `passes=true`. Repară (max 3 fix-uri în iterație) sau lasă pentru iterația următoare (ralph.sh se ocupă de retry counter). -#### 6.2 Navighează la pagină -```bash -agent-browser navigate "http://localhost:{techStack.port}" -# Exemplu: agent-browser navigate "http://localhost:3000" -``` +### Faza 4: COMMIT + MARK -#### 6.3 Ia snapshot pentru verificare -```bash -agent-browser snapshot -``` - -Snapshot-ul returnează o listă de elemente cu referințe compacte: -``` -@e1: heading "Welcome" -@e2: button "Login" -@e3: textbox "Email" -@e4: textbox "Password" -@e5: button "Submit" -``` - -**Verifică în snapshot:** -- Elementele cheie din acceptance criteria există -- Textul e corect -- Structura paginii e corectă - -#### 6.4 Testează interacțiunile (dacă e cazul) -```bash -# Click pe un element -agent-browser click @e2 - -# Fill un input -agent-browser fill @e3 "test@example.com" - -# Așteaptă o schimbare -agent-browser snapshot # verifică noua stare -``` - -#### 6.5 Salvează screenshot ca dovadă -```bash -agent-browser screenshot ./scripts/ralph/screenshots/US-{id}-$(date +%Y%m%d-%H%M%S).png -# Exemplu: agent-browser screenshot ./scripts/ralph/screenshots/US-001-20240115-143022.png -``` - -#### 6.6 Verifică erori -```bash -# Verifică console pentru erori -agent-browser console -``` - -**IMPORTANT**: -- NU marca story-ul complete dacă verificarea vizuală eșuează! -- Dacă găsești erori în browser, repară-le înainte de commit -- Screenshots sunt salvate în `scripts/ralph/screenshots/` pentru referință - -### 7. Documentare (dacă ai descoperit ceva util) -Dacă ai descoperit patterns sau gotchas, actualizează `AGENTS.md` în directorul relevant: -- API patterns -- Dependențe non-evidente -- Convenții de cod -- Cum să testezi anumite funcționalități - -### 8. Commit -Format commit message: +4.1. **Commit** cu mesaj descriptiv: ``` feat: [Story ID] - [Story Title] + +- ce ai schimbat (1-3 bullets) +- gates rulate: typecheck PASS, lint PASS, /qa PASS ``` -### 9. Marchează story-ul ca complet -**CRITIC**: Actualizează `scripts/ralph/prd.json`: -- Setează `passes: true` pentru story-ul implementat -- Adaugă note relevante în câmpul `notes` - -### 10. Actualizează progress.txt -Adaugă la sfârșitul fișierului `scripts/ralph/progress.txt`: +4.2. **Update `prd.json`**: +- `passes: true` DOAR DACĂ toate gate-urile relevante au pasat +- `notes` populat cu rezultate gate (ex: "qa: ok, design-review: 8/10") +4.3. **Append `progress.txt`**: ```markdown ## Iterație: [timestamp] -### Story implementat: [ID] - [Title] -### Status: Complete +### Story implementat: [ID] - [Title] (tags: [ui, backend]) +### Status: Complete / Partial / Failed -### Verificări: +### Gates rulate: - Typecheck: PASS - Lint: PASS - Tests: PASS/SKIP -- Browser check: PASS/N/A +- /qa (ui): PASS — screenshot la scripts/ralph/screenshots/... +- /review (backend): PASS ### Learnings: -- [Ce ai învățat] -- [Patterns descoperite] +- [Patterns descoperite, gotchas] -### Next steps: -- [Ce rămâne de făcut] +### Next: +- [Stories eligibile pentru iterația următoare] --- ``` ## Reguli importante -1. **UN SINGUR STORY PE ITERAȚIE** - Nu implementa mai mult de un story -2. **TOATE CHECKS TREBUIE SĂ TREACĂ** - Nu face commit cu erori -3. **VERIFICARE BROWSER PENTRU UI** - Obligatorie dacă `requiresBrowserCheck: true` -4. **ACTUALIZEAZĂ prd.json** - Altfel iterația următoare va repeta munca -5. **FII CONCIS** - Nu over-engineer +1. **UN SINGUR STORY PE ITERAȚIE** — nu implementa mai mult de un story. +2. **DAG STRICT** — nu sări peste `dependsOn` neîmplinite. +3. **GATES PE TAGS** — rulează doar ce e relevant; tags vide = run-all-gates fallback. +4. **NU MARCA `passes=true` cu gate failed** — altfel ralph.sh nu va relua story-ul. +5. **FII CONCIS** — fără over-engineering, fără docs auto-generate dacă story-ul nu cere. -## Comenzi agent-browser (referință rapidă) +## Comenzi agent-browser (referință rapidă pentru gate `ui`) ```bash -# Navigare -agent-browser navigate "http://localhost:3000/page" - -# Snapshot (vedere compactă a paginii) -agent-browser snapshot - -# Click pe element (folosind ref din snapshot) +agent-browser navigate "http://localhost:{techStack.port}" +agent-browser snapshot # listă elemente compactă (@e1, @e2...) agent-browser click @e5 - -# Fill input agent-browser fill @e3 "value" - -# Screenshot -agent-browser screenshot ./path/to/file.png - -# Console logs -agent-browser console - -# Așteaptă text +agent-browser screenshot ./scripts/ralph/screenshots/US-{id}-$(date +%Y%m%d-%H%M%S).png +agent-browser console # erori JS agent-browser wait-for "Loading complete" ``` ## Condiție de terminare -Dacă TOATE stories au `passes: true`, răspunde cu: +Dacă TOATE story-urile au `passes: true` (sau combinat cu `failed: true` / `blocked: true` astfel că nimic nu mai e eligibil): ``` COMPLETE diff --git a/tools/ralph/ralph.sh b/tools/ralph/ralph.sh index 0ab755e..f30e103 100755 --- a/tools/ralph/ralph.sh +++ b/tools/ralph/ralph.sh @@ -1,7 +1,13 @@ #!/bin/bash -# Ralph pentru Claude Code - Loop autonom de agent AI +# Ralph pentru Claude Code - Loop autonom de agent AI (W3: smart gates + DAG + rate limit) # Adaptat din Ralph original (snarktank/ralph) pentru Claude Code CLI # Usage: ./ralph.sh [max_iterations] [project_dir] +# +# Env vars (opționale): +# RALPH_MAX_TURNS — --max-turns per iter (default 30) +# RALPH_RATE_LIMIT_SLEEP — sleep după rate limit detection (default 1800 = 30min) +# RALPH_DAG_HELPER — path la tools/ralph_dag.py (auto-detect default) +# RALPH_PYTHON — interpreter Python pentru DAG helper (default python3) set -e @@ -15,6 +21,24 @@ SCREENSHOTS_DIR="$SCRIPT_DIR/screenshots" LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch" PROMPT_FILE="$SCRIPT_DIR/prompt.md" +# W3 config +MAX_TURNS=${RALPH_MAX_TURNS:-30} +RATE_LIMIT_SLEEP=${RALPH_RATE_LIMIT_SLEEP:-1800} +RALPH_PYTHON=${RALPH_PYTHON:-python3} + +# DAG helper auto-detect: prefer co-located cu echo-core; fallback la $SCRIPT_DIR +if [ -n "$RALPH_DAG_HELPER" ] && [ -f "$RALPH_DAG_HELPER" ]; then + DAG_HELPER="$RALPH_DAG_HELPER" +elif [ -f "/home/moltbot/echo-core/tools/ralph_dag.py" ]; then + DAG_HELPER="/home/moltbot/echo-core/tools/ralph_dag.py" +elif [ -f "/home/moltbot/echo-core-qc/tools/ralph_dag.py" ]; then + DAG_HELPER="/home/moltbot/echo-core-qc/tools/ralph_dag.py" +elif [ -f "$SCRIPT_DIR/ralph_dag.py" ]; then + DAG_HELPER="$SCRIPT_DIR/ralph_dag.py" +else + DAG_HELPER="" +fi + # Verifică că jq este instalat if ! command -v jq &> /dev/null; then echo "Eroare: jq nu este instalat. Rulează: apt install jq" @@ -132,6 +156,51 @@ check_all_complete() { [ "$incomplete" -eq 0 ] } +# W3: nimic eligibil = toate sunt fie passes, fie failed, fie blocked +check_no_eligible() { + local n=$(jq '[.userStories[] | select(.passes != true and .failed != true and .blocked != true)] | length' "$PRD_FILE" 2>/dev/null || echo "999") + [ "$n" -eq 0 ] +} + +# W3: alege next eligible story via DAG helper. Print story ID sau "" dacă nimic. +dag_next_story() { + if [ -n "$DAG_HELPER" ]; then + "$RALPH_PYTHON" "$DAG_HELPER" next-story "$PRD_FILE" 2>/dev/null || echo "" + else + # Fallback simplu (fără DAG): primul story cu passes!=true && failed!=true && blocked!=true, priority asc + jq -r '[.userStories[] | select(.passes != true and .failed != true and .blocked != true)] | sort_by(.priority) | .[0].id // ""' "$PRD_FILE" + fi +} + +dag_incr_retry() { + local sid="$1" + if [ -n "$DAG_HELPER" ]; then + "$RALPH_PYTHON" "$DAG_HELPER" incr-retry "$PRD_FILE" "$sid" 2>/dev/null || echo "0" + else + echo "0" + fi +} + +dag_mark_failed() { + local sid="$1" reason="$2" + if [ -n "$DAG_HELPER" ]; then + "$RALPH_PYTHON" "$DAG_HELPER" mark-failed "$PRD_FILE" "$sid" "$reason" 2>/dev/null || true + fi +} + +dag_force_tags() { + local sid="$1" + if [ -n "$DAG_HELPER" ]; then + "$RALPH_PYTHON" "$DAG_HELPER" force-tags "$PRD_FILE" "$sid" "$PROJECT_DIR" 2>/dev/null || true + fi +} + +# W3: detectează rate limit în output Claude (heuristic — Anthropic nu are exit code dedicat) +is_rate_limited() { + local output="$1" + echo "$output" | grep -qiE "rate limit|rate_limit_exceeded|429|too many requests" +} + # Afișare status inițial echo "" echo "=======================================================================" @@ -155,6 +224,9 @@ if check_all_complete; then exit 0 fi +# Tracker pentru rate limit retry (max 1 retry de iterație-rate-limit per rulare) +RATE_LIMIT_RETRY_USED=0 + # Loop principal for i in $(seq 1 $MAX_ITERATIONS); do echo "" @@ -162,17 +234,38 @@ for i in $(seq 1 $MAX_ITERATIONS); do echo " Ralph Iterația $i din $MAX_ITERATIONS" echo "===================================================================" + # W3: alege next story via DAG (propagă blocked dacă vreun dep a eșuat) + CURRENT_STORY=$(dag_next_story) + if [ -z "$CURRENT_STORY" ]; then + echo "" + echo "===================================================================" + if check_all_complete; then + echo " TOATE STORY-URILE DIN PRD SUNT COMPLETE!" + exit 0 + else + echo " NICIUN STORY ELIGIBIL (toate fie complete, fie failed, fie blocked)" + echo " Stories incomplete:" + jq -r '.userStories[] | select(.passes != true) | " - \(.id): \(.title) [failed=\(.failed // false) blocked=\(.blocked // false) retries=\(.retries // 0)]"' "$PRD_FILE" + exit 0 + fi + fi + # Status curent COMPLETE_NOW=$(jq '[.userStories[] | select(.passes == true)] | length' "$PRD_FILE") - NEXT_STORY=$(jq -r '[.userStories[] | select(.passes != true)] | sort_by(.priority) | .[0] | "\(.id): \(.title)"' "$PRD_FILE") + NEXT_TITLE=$(jq -r --arg id "$CURRENT_STORY" '.userStories[] | select(.id == $id) | "\(.id): \(.title)"' "$PRD_FILE") + STORY_TAGS=$(jq -r --arg id "$CURRENT_STORY" '.userStories[] | select(.id == $id) | (.tags // []) | join(",")' "$PRD_FILE") + STORY_RETRIES=$(jq -r --arg id "$CURRENT_STORY" '.userStories[] | select(.id == $id) | (.retries // 0)' "$PRD_FILE") echo " Progress: $COMPLETE_NOW / $TOTAL_STORIES stories complete" - echo " Next: $NEXT_STORY" + echo " Next: $NEXT_TITLE [tags: ${STORY_TAGS:-}, retries: $STORY_RETRIES]" echo "" # Pregătește prompt-ul cu context FULL_PROMPT=$(cat <}) + ## PRD (prd.json): $(cat "$PRD_FILE") @@ -188,10 +281,34 @@ EOF LOG_FILE="$SCRIPT_DIR/logs/iteration-$i-$(date +%Y%m%d-%H%M%S).log" mkdir -p "$SCRIPT_DIR/logs" - # --output-format json avoids streaming mode issues - echo "$FULL_PROMPT" | claude -p --dangerously-skip-permissions --output-format json 2>&1 | tee "$LOG_FILE" || true + # --output-format json + --max-turns pentru control runtime + set +e + echo "$FULL_PROMPT" | claude -p \ + --dangerously-skip-permissions \ + --output-format json \ + --max-turns "$MAX_TURNS" \ + 2>&1 | tee "$LOG_FILE" + CLAUDE_EXIT=${PIPESTATUS[1]} + set -e OUTPUT=$(cat "$LOG_FILE") + # W3: rate limit detection (max 1 retry per rulare) + if is_rate_limited "$OUTPUT" || [ "$CLAUDE_EXIT" = "29" ]; then + if [ "$RATE_LIMIT_RETRY_USED" = "0" ]; then + echo "" + echo " ⏸️ Rate limit detectat. Sleep ${RATE_LIMIT_SLEEP}s, apoi retry o dată." + RATE_LIMIT_RETRY_USED=1 + echo "## Rate limit la iter $i — sleep $RATE_LIMIT_SLEEP" >> "$PROGRESS_FILE" + sleep "$RATE_LIMIT_SLEEP" + continue # retry aceeași iterație + else + echo " ❌ Rate limit din nou — abort run, mark $CURRENT_STORY rate_limited" + dag_mark_failed "$CURRENT_STORY" "rate_limited" + echo "## Rate limit final la iter $i — abort" >> "$PROGRESS_FILE" + exit 2 + fi + fi + # Verifică dacă toate task-urile sunt complete if echo "$OUTPUT" | grep -q "COMPLETE"; then echo "" @@ -211,6 +328,23 @@ EOF exit 0 fi + # W3: tag validation post-iter — chiar dacă Opus a marcat docs, dacă diff atinge .vue/.tsx, force ui + dag_force_tags "$CURRENT_STORY" >/dev/null 2>&1 || true + + # W3: dacă story-ul curent ÎNCĂ nu trece (passes==false), incrementăm retries + STILL_INCOMPLETE=$(jq -r --arg id "$CURRENT_STORY" '.userStories[] | select(.id == $id) | (.passes == true)' "$PRD_FILE") + if [ "$STILL_INCOMPLETE" != "true" ]; then + NEW_RETRY=$(dag_incr_retry "$CURRENT_STORY") + echo " Story $CURRENT_STORY încă incomplet. Retries: $NEW_RETRY/3" + if [ "$NEW_RETRY" -ge 3 ] 2>/dev/null; then + echo " ❌ $CURRENT_STORY failed: max_retries — sare la următorul" + # mark-failed e deja făcut de incr-retry când >=3, dar idempotent o re-aplicăm + dag_mark_failed "$CURRENT_STORY" "max_retries" + fi + else + echo " ✅ Story $CURRENT_STORY marcat passes=true în iterația asta." + fi + echo " Iterația $i completă. Continuăm..." sleep 2 done diff --git a/tools/ralph_dag.py b/tools/ralph_dag.py new file mode 100644 index 0000000..70bdc62 --- /dev/null +++ b/tools/ralph_dag.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Ralph DAG + tag-validation helpers (W3 smart gates). + +Două responsabilități: +1. Tag validation heuristic — anti-silent-regression. Forțează tags pe baza + diff content (chiar dacă Opus le-a omis). Rulat ÎNAINTE de smart gate dispatch. +2. DAG topological sort — alege următorul story eligibil (passes/failed/blocked + propagation). Rulat de ralph.sh la începutul fiecărei iterații. + +CLI subcommands (apelate din ralph.sh): + + python3 ralph_dag.py infer-tags + → printează tags inferate (newline-separated) pe baza git diff HEAD~1. + + python3 ralph_dag.py next-story + → printează story_id eligibil (DAG-aware) sau exit 1 dacă nimic. + + python3 ralph_dag.py mark-failed + → marchează story.failed=true cu motiv; propagă blocked la dependenți. + + python3 ralph_dag.py incr-retry + → +1 retries; dacă >=3, mark failed cu reason="max_retries"; print new count. + + python3 ralph_dag.py force-tags + → adaugă tags inferate DIN DIFF în story.tags (idempotent, deduplicat); + dacă tags sunt vide după → fallback la "run-all" (NU modifică, doar print "EMPTY"). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import List, Optional + + +VALID_TAGS = {"ui", "db", "vercel", "refactor", "docs", "backend", "infra"} + +# Heuristici diff → tag. Sortate stabil pentru determinism în teste. +UI_PATTERN = re.compile(r'\.(vue|tsx|jsx|html|css|scss|svelte)$', re.IGNORECASE) +DB_MIGRATIONS = re.compile(r'(^|/)migrations?/', re.IGNORECASE) +DB_SQL = re.compile(r'\.sql$', re.IGNORECASE) + + +def infer_tags_from_paths(paths: List[str], has_vercel_json: bool = False) -> List[str]: + """Pure function: dat list de file paths atinse + flag vercel.json, întoarce tags inferate. + + Returnează lista deduplicată, ordonată: ui, db, vercel. + """ + tags = set() + for p in paths: + if not p: + continue + if UI_PATTERN.search(p): + tags.add("ui") + if DB_MIGRATIONS.search(p) or DB_SQL.search(p): + tags.add("db") + if has_vercel_json: + tags.add("vercel") + + # Ordine stabilă pentru determinism (teste + diff-uri reproducibile) + return sorted(tags, key=lambda t: ("ui", "db", "vercel").index(t) if t in ("ui", "db", "vercel") else 99) + + +def force_include_tags(existing_tags: List[str], diff_paths: List[str], has_vercel_json: bool) -> List[str]: + """Combinator: existing tags ∪ inferred din diff. Filtrează la VALID_TAGS. + + Garanție anti-silent-regression: chiar dacă Opus a marcat story=docs, dacă diff + atinge .vue/.tsx → ui e forțat. Story=docs care realmente atinge UI ar fi avut + silent skip /qa. + """ + inferred = infer_tags_from_paths(diff_paths, has_vercel_json) + combined = [] + seen = set() + for t in list(existing_tags) + inferred: + if t in VALID_TAGS and t not in seen: + combined.append(t) + seen.add(t) + return combined + + +def get_diff_paths(project_dir: Path, ref: str = "HEAD~1") -> List[str]: + """Întoarce file paths din `git diff --name-only ` în project_dir. + + Lista vidă dacă git nu e disponibil sau nu există commit anterior. + """ + try: + result = subprocess.run( + ["git", "-C", str(project_dir), "diff", "--name-only", ref], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + # Fallback: diff vs HEAD (uncommitted changes) — util pe primul commit + result = subprocess.run( + ["git", "-C", str(project_dir), "diff", "--name-only", "HEAD"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return [] + return [p.strip() for p in result.stdout.splitlines() if p.strip()] + except (subprocess.TimeoutExpired, FileNotFoundError): + return [] + + +def topological_eligible(stories: List[dict]) -> Optional[dict]: + """Întoarce primul story eligibil (DAG-aware) — sau None dacă nimic. + + Eligibil = !passes ∧ !failed ∧ !blocked ∧ toate `dependsOn` au `passes==True`. + Dacă story.dependsOn conține un ID `failed` → story-ul DEVINE blocked (mutat in-place). + Sortare: priority asc. + """ + by_id = {s.get("id"): s for s in stories} + + # Pas 1: propagă blocked dacă vreun dep e failed + changed = True + while changed: + changed = False + for s in stories: + if s.get("passes") or s.get("failed") or s.get("blocked"): + continue + for dep_id in s.get("dependsOn") or []: + dep = by_id.get(dep_id) + if dep and (dep.get("failed") or dep.get("blocked")): + s["blocked"] = True + s["failureReason"] = f"blocked_by:{dep_id}" + changed = True + break + + # Pas 2: găsește story eligibil cu cea mai mică priority + eligible = [] + for s in stories: + if s.get("passes") or s.get("failed") or s.get("blocked"): + continue + deps = s.get("dependsOn") or [] + if all(by_id.get(d, {}).get("passes") for d in deps): + eligible.append(s) + + if not eligible: + return None + eligible.sort(key=lambda s: (s.get("priority", 999), s.get("id", ""))) + return eligible[0] + + +def _load_prd(prd_path: Path) -> dict: + with open(prd_path, encoding="utf-8") as f: + return json.load(f) + + +def _save_prd(prd_path: Path, data: dict) -> None: + """Atomic write — temp file + rename, evită corruption mid-write.""" + tmp = prd_path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + tmp.replace(prd_path) + + +def cmd_next_story(prd_path: Path) -> int: + data = _load_prd(prd_path) + stories = data.get("userStories", []) + chosen = topological_eligible(stories) + # Salvăm dacă topological_eligible a propagat blocked (mutație in-place) + _save_prd(prd_path, data) + if not chosen: + return 1 + print(chosen.get("id", "")) + return 0 + + +def cmd_mark_failed(prd_path: Path, story_id: str, reason: str) -> int: + data = _load_prd(prd_path) + found = False + for s in data.get("userStories", []): + if s.get("id") == story_id: + s["failed"] = True + s["passes"] = False + s["failureReason"] = reason or "unknown" + found = True + break + if not found: + print(f"Story {story_id} not found", file=sys.stderr) + return 1 + # Propagate blocked la dependenți + topological_eligible(data.get("userStories", [])) + _save_prd(prd_path, data) + print(f"failed: {story_id} ({reason})") + return 0 + + +def cmd_incr_retry(prd_path: Path, story_id: str) -> int: + data = _load_prd(prd_path) + for s in data.get("userStories", []): + if s.get("id") == story_id: + s["retries"] = int(s.get("retries", 0)) + 1 + new = s["retries"] + if new >= 3: + s["failed"] = True + s["failureReason"] = "max_retries" + topological_eligible(data.get("userStories", [])) + _save_prd(prd_path, data) + print(new) + return 0 + print(f"Story {story_id} not found", file=sys.stderr) + return 1 + + +def cmd_force_tags(prd_path: Path, story_id: str, project_dir: Path) -> int: + data = _load_prd(prd_path) + diff_paths = get_diff_paths(project_dir) + has_vercel = (project_dir / "vercel.json").exists() + + for s in data.get("userStories", []): + if s.get("id") == story_id: + existing = s.get("tags") or [] + forced = force_include_tags(existing, diff_paths, has_vercel) + s["tags"] = forced + _save_prd(prd_path, data) + if not forced: + print("EMPTY") # ralph.sh interpretează ca run-all-gates fallback + else: + for t in forced: + print(t) + return 0 + print(f"Story {story_id} not found", file=sys.stderr) + return 1 + + +def cmd_infer_tags(story_id: str, project_dir: Path) -> int: + """Variant care NU modifică prd.json — doar print tags inferate din diff curent.""" + diff_paths = get_diff_paths(project_dir) + has_vercel = (project_dir / "vercel.json").exists() + inferred = infer_tags_from_paths(diff_paths, has_vercel) + for t in inferred: + print(t) + return 0 if inferred else 1 + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + cmd = sys.argv[1] + args = sys.argv[2:] + try: + if cmd == "next-story" and len(args) == 1: + return cmd_next_story(Path(args[0])) + if cmd == "mark-failed" and len(args) == 3: + return cmd_mark_failed(Path(args[0]), args[1], args[2]) + if cmd == "incr-retry" and len(args) == 2: + return cmd_incr_retry(Path(args[0]), args[1]) + if cmd == "force-tags" and len(args) == 3: + return cmd_force_tags(Path(args[0]), args[1], Path(args[2])) + if cmd == "infer-tags" and len(args) == 2: + return cmd_infer_tags(args[0], Path(args[1])) + print(f"Unknown command: {cmd}", file=sys.stderr) + print(__doc__, file=sys.stderr) + return 2 + except FileNotFoundError as exc: + print(f"File not found: {exc}", file=sys.stderr) + return 3 + except json.JSONDecodeError as exc: + print(f"Invalid JSON: {exc}", file=sys.stderr) + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ralph_prd_generator.py b/tools/ralph_prd_generator.py index e85e9cd..9310816 100644 --- a/tools/ralph_prd_generator.py +++ b/tools/ralph_prd_generator.py @@ -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)