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:
@@ -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/<slug>/log or /api/ralph/<slug>/prd
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
# parts: ['', 'api', 'ralph', '<slug>', '<action>']
|
||||
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)
|
||||
|
||||
|
||||
305
dashboard/handlers/ralph.py
Normal file
305
dashboard/handlers/ralph.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Ralph live dashboard endpoints (W3).
|
||||
|
||||
Endpoints:
|
||||
GET /api/ralph/status — toate proiectele Ralph (cards data)
|
||||
GET /api/ralph/<slug>/log — tail progress.txt (default 100 lines)
|
||||
GET /api/ralph/<slug>/prd — full prd.json content
|
||||
POST /api/ralph/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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)
|
||||
615
dashboard/ralph.html
Normal file
615
dashboard/ralph.html
Normal file
@@ -0,0 +1,615 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
|
||||
<title>Echo · Ralph</title>
|
||||
<link rel="stylesheet" href="/echo/common.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="/echo/swipe-nav.js"></script>
|
||||
<style>
|
||||
/* ==========================================
|
||||
Ralph status extension tokens
|
||||
(existing common.css NU declară --status-*)
|
||||
========================================== */
|
||||
:root {
|
||||
--status-running: rgb(34, 197, 94); /* green */
|
||||
--status-blocked: rgb(245, 158, 11); /* amber */
|
||||
--status-failed: rgb(239, 68, 68); /* red */
|
||||
--status-complete: rgb(156, 163, 175); /* slate (done = neutral) */
|
||||
--status-idle: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Layout
|
||||
========================================== */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Live indicator pulse */
|
||||
.live-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-running);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.last-fetch {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Cards grid
|
||||
========================================== */
|
||||
.ralph-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.ralph-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ralph-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.ralph-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.ralph-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.ralph-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ralph-slug {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ralph-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ralph-status[data-status="running"] { background: rgba(34, 197, 94, 0.18); color: var(--status-running); }
|
||||
.ralph-status[data-status="blocked"] { background: rgba(245, 158, 11, 0.18); color: var(--status-blocked); }
|
||||
.ralph-status[data-status="failed"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
|
||||
.ralph-status[data-status="complete"] { background: rgba(156, 163, 175, 0.18); color: var(--status-complete); }
|
||||
.ralph-status[data-status="idle"] { background: var(--bg-surface-active); color: var(--status-idle); }
|
||||
.ralph-status[data-status="error"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
|
||||
|
||||
.ralph-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.ralph-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ralph-current {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ralph-current-id {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ralph-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ralph-tag {
|
||||
font-size: var(--text-xs);
|
||||
padding: 1px 8px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.ralph-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ralph-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ralph-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ralph-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--status-complete);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.ralph-card[data-status="running"] .ralph-progress-fill { background: var(--status-running); }
|
||||
.ralph-card[data-status="failed"] .ralph-progress-fill { background: var(--status-failed); }
|
||||
.ralph-card[data-status="blocked"] .ralph-progress-fill { background: var(--status-blocked); }
|
||||
|
||||
.ralph-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ralph-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ralph-icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.ralph-icon-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.ralph-icon-btn.danger {
|
||||
color: var(--status-failed);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.ralph-icon-btn.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.ralph-icon-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ralph-icon-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.ralph-icon-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty / loading / error states */
|
||||
.ralph-empty,
|
||||
.ralph-loading,
|
||||
.ralph-error {
|
||||
text-align: center;
|
||||
padding: var(--space-10) var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ralph-empty svg,
|
||||
.ralph-loading svg,
|
||||
.ralph-error svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ralph-empty-title {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Drawer (log + PRD viewer)
|
||||
========================================== */
|
||||
.ralph-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.ralph-drawer[data-open="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ralph-drawer-content {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ralph-drawer-head {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ralph-drawer-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.ralph-drawer-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.ralph-drawer-pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--NAV-->
|
||||
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<i data-lucide="bot" aria-hidden="true"></i>
|
||||
Echo · Ralph
|
||||
</div>
|
||||
<div class="page-subtitle">Live status pe proiectele autonome (polling 5s)</div>
|
||||
</div>
|
||||
<div class="live-indicator" aria-live="polite">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span id="liveLabel">Live</span>
|
||||
<span class="last-fetch" id="lastFetch"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="ralphContent" aria-live="polite">
|
||||
<div class="ralph-loading">
|
||||
<i data-lucide="loader" aria-hidden="true"></i>
|
||||
<div>Se încarcă proiectele Ralph...</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Drawer pentru log / PRD viewer -->
|
||||
<div class="ralph-drawer" id="ralphDrawer" data-open="false" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
|
||||
<div class="ralph-drawer-content">
|
||||
<div class="ralph-drawer-head">
|
||||
<div class="ralph-drawer-title" id="drawerTitle">—</div>
|
||||
<button type="button" class="ralph-icon-btn" id="drawerClose" aria-label="Închide drawer">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ralph-drawer-body">
|
||||
<pre class="ralph-drawer-pre" id="drawerBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const POLL_MS = 5000;
|
||||
const contentEl = document.getElementById('ralphContent');
|
||||
const lastFetchEl = document.getElementById('lastFetch');
|
||||
const liveLabel = document.getElementById('liveLabel');
|
||||
const drawer = document.getElementById('ralphDrawer');
|
||||
const drawerTitle = document.getElementById('drawerTitle');
|
||||
const drawerBody = document.getElementById('drawerBody');
|
||||
const drawerClose = document.getElementById('drawerClose');
|
||||
|
||||
function fmtAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (isNaN(t)) return '—';
|
||||
const diff = Math.max(0, Date.now() - t);
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `acum ${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `acum ${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `acum ${hr}h`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `acum ${day}z`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderCard(p) {
|
||||
const total = p.storiesTotal || 0;
|
||||
const done = p.storiesComplete || 0;
|
||||
const failed = p.storiesFailed || 0;
|
||||
const blocked = p.storiesBlocked || 0;
|
||||
const pct = total > 0 ? Math.round(((done + failed + blocked) / total) * 100) : 0;
|
||||
|
||||
const current = p.currentStory
|
||||
? `<div class="ralph-current"><span class="ralph-current-id">${escapeHtml(p.currentStory.id)}</span> · ${escapeHtml(p.currentStory.title || '')} ` +
|
||||
(p.currentStory.retries ? `<span title="retries">(${p.currentStory.retries}/3)</span>` : '') + `</div>` +
|
||||
(p.currentStory.tags && p.currentStory.tags.length
|
||||
? `<div class="ralph-tags">${p.currentStory.tags.map(t => `<span class="ralph-tag">${escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: '')
|
||||
: (p.status === 'complete'
|
||||
? `<div class="ralph-current">Toate stories complete (${done}/${total}).</div>`
|
||||
: `<div class="ralph-current" style="color:var(--text-muted)">Nu rulează acum.</div>`);
|
||||
|
||||
const eta = (p.etaMinutes != null && p.status === 'running')
|
||||
? `~${p.etaMinutes}min`
|
||||
: '';
|
||||
|
||||
const stopBtn = p.running
|
||||
? `<button type="button" class="ralph-icon-btn danger" data-action="stop" data-slug="${escapeHtml(p.slug)}" aria-label="Oprește Ralph">
|
||||
<i data-lucide="square" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="ralph-card" data-status="${escapeHtml(p.status)}">
|
||||
<header class="ralph-card-head">
|
||||
<div class="ralph-slug" title="${escapeHtml(p.slug)}">${escapeHtml(p.slug)}</div>
|
||||
<span class="ralph-status" data-status="${escapeHtml(p.status)}" aria-label="Status: ${escapeHtml(p.status)}">
|
||||
<span class="ralph-status-dot" aria-hidden="true"></span>${escapeHtml(p.status)}
|
||||
</span>
|
||||
</header>
|
||||
<div class="ralph-card-body">
|
||||
${current}
|
||||
<div class="ralph-progress">
|
||||
<div class="ralph-progress-meta">
|
||||
<span>${done}/${total} done${failed ? ` · ${failed} failed` : ''}${blocked ? ` · ${blocked} blocked` : ''}</span>
|
||||
<span>${eta}</span>
|
||||
</div>
|
||||
<div class="ralph-progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="ralph-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ralph-card-foot">
|
||||
<span title="Ultima iterație">${fmtAgo(p.lastIterAt)}</span>
|
||||
<div class="ralph-actions">
|
||||
<button type="button" class="ralph-icon-btn" data-action="log" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi log">
|
||||
<i data-lucide="terminal" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="ralph-icon-btn" data-action="prd" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi PRD">
|
||||
<i data-lucide="file-text" aria-hidden="true"></i>
|
||||
</button>
|
||||
${stopBtn}
|
||||
</div>
|
||||
</footer>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return `
|
||||
<div class="ralph-empty">
|
||||
<i data-lucide="inbox" aria-hidden="true"></i>
|
||||
<div class="ralph-empty-title">Niciun proiect aprobat.</div>
|
||||
<div>Aprobă ceva pe Discord/Telegram cu <code>/a <slug></code>.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `
|
||||
<div class="ralph-error">
|
||||
<i data-lucide="alert-triangle" aria-hidden="true"></i>
|
||||
<div>Cannot reach Echo Core: ${escapeHtml(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/ralph/status', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const projects = data.projects || [];
|
||||
if (projects.length === 0) {
|
||||
contentEl.innerHTML = renderEmpty();
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
|
||||
}
|
||||
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
|
||||
liveLabel.textContent = 'Live';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = renderError(err.message || String(err));
|
||||
liveLabel.textContent = 'Offline';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function openLog(slug) {
|
||||
drawerTitle.textContent = `${slug} · progress.txt`;
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = (data.lines || []).join('\n');
|
||||
} catch (err) {
|
||||
drawerBody.textContent = `Error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrd(slug) {
|
||||
drawerTitle.textContent = `${slug} · prd.json`;
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/prd`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
drawerBody.textContent = `Error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRalph(slug) {
|
||||
if (!confirm(`Oprești Ralph pe ${slug}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/stop`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Eșec: ' + (data.error || 'unknown'));
|
||||
} else {
|
||||
fetchStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const slug = btn.dataset.slug;
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'log') openLog(slug);
|
||||
else if (action === 'prd') openPrd(slug);
|
||||
else if (action === 'stop') stopRalph(slug);
|
||||
});
|
||||
|
||||
drawerClose.addEventListener('click', () => {
|
||||
drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
drawer.addEventListener('click', (e) => {
|
||||
if (e.target === drawer) drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
// Boot + poll
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, POLL_MS);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
250
tests/test_dag_execution.py
Normal file
250
tests/test_dag_execution.py
Normal file
@@ -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"]
|
||||
209
tests/test_dashboard_ralph_endpoint.py
Normal file
209
tests/test_dashboard_ralph_endpoint.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Tests for /api/ralph/* endpoints (dashboard live).
|
||||
|
||||
Acoperă:
|
||||
- /api/ralph/status: list cards cu state + count + fetchedAt
|
||||
- /api/ralph/<slug>/log: tail progress.txt
|
||||
- /api/ralph/<slug>/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/<slug>/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/<slug>/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/<slug>/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
|
||||
174
tests/test_smart_gates.py
Normal file
174
tests/test_smart_gates.py
Normal file
@@ -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
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 <branchName>
|
||||
```
|
||||
- Dacă branch-ul există deja, doar checkout:
|
||||
```bash
|
||||
git checkout <branchName>
|
||||
```
|
||||
- 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):
|
||||
|
||||
```
|
||||
<promise>COMPLETE</promise>
|
||||
|
||||
@@ -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:-<none>}, retries: $STORY_RETRIES]"
|
||||
echo ""
|
||||
|
||||
# Pregătește prompt-ul cu context
|
||||
FULL_PROMPT=$(cat <<EOF
|
||||
# Context pentru această iterație Ralph
|
||||
|
||||
## Story țintă (DAG-eligible):
|
||||
$CURRENT_STORY (tags: ${STORY_TAGS:-<none>})
|
||||
|
||||
## 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 "<promise>COMPLETE</promise>"; 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
|
||||
|
||||
267
tools/ralph_dag.py
Normal file
267
tools/ralph_dag.py
Normal file
@@ -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 <story_id> <project_dir>
|
||||
→ printează tags inferate (newline-separated) pe baza git diff HEAD~1.
|
||||
|
||||
python3 ralph_dag.py next-story <prd.json>
|
||||
→ printează story_id eligibil (DAG-aware) sau exit 1 dacă nimic.
|
||||
|
||||
python3 ralph_dag.py mark-failed <prd.json> <story_id> <reason>
|
||||
→ marchează story.failed=true cu motiv; propagă blocked la dependenți.
|
||||
|
||||
python3 ralph_dag.py incr-retry <prd.json> <story_id>
|
||||
→ +1 retries; dacă >=3, mark failed cu reason="max_retries"; print new count.
|
||||
|
||||
python3 ralph_dag.py force-tags <prd.json> <story_id> <project_dir>
|
||||
→ 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 <ref>` î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())
|
||||
@@ -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()
|
||||
# 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 = {
|
||||
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,10 +449,9 @@ 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,9 +504,22 @@ 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
|
||||
@@ -375,8 +535,10 @@ def create_prd_and_json(project_name: str, description: str, workspace_dir: Path
|
||||
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
|
||||
# Generează PRD markdown (mereu — folosit pentru read humans)
|
||||
prd_content = generate_prd_markdown(project_name, description, context)
|
||||
|
||||
# Salvează PRD
|
||||
@@ -389,8 +551,20 @@ def create_prd_and_json(project_name: str, description: str, workspace_dir: Path
|
||||
|
||||
print(f"✅ PRD salvat: {prd_file}")
|
||||
|
||||
# Generează prd.json
|
||||
# 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 = {
|
||||
@@ -462,17 +636,20 @@ 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