Merge branch 'ralph/ralph-qc' — W3 smart gates + DAG + dashboard live

Restructurare Ralph:
- tools/ralph_prd_generator.py — citește final-plan.md (de la W2 PlanningOrchestrator);
  prd.json schema extins cu acceptanceCriteria[], tags[], dependsOn[]
- tools/ralph/prompt.md — smart gates dispatcher pe story.tags (refactor→simplify,
  ui→qa+screenshot, vercel→push+gh checks, db→schema diff, default→/review)
- tools/ralph_dag.py — pure functions Python (infer_tags, force_include_tags,
  topological_eligible) + CLI subcommands chemate din ralph.sh
- tools/ralph/ralph.sh — DAG-aware story selection, 3-retry guard, rate limit
  detection (sleep 30min + 1 retry → mark failed: rate_limited)

Dashboard live:
- dashboard/handlers/ralph.py — /api/ralph/status, /<slug>/log, /<slug>/prd, /<slug>/stop
- dashboard/ralph.html — UI cards per project, polling 5s, status badges, ETA
- atomic prd.json writes (temp + rename) anti-coruption mid-write

Tests: 72 pass (test_smart_gates 30, test_dag_execution 22, test_dashboard_ralph_endpoint 20)
— 0 regressions.
This commit is contained in:
2026-04-26 18:41:57 +00:00
11 changed files with 2282 additions and 189 deletions

View File

@@ -40,6 +40,7 @@ from handlers.files import FilesHandlers # noqa: E402
from handlers.git import GitHandlers # noqa: E402 from handlers.git import GitHandlers # noqa: E402
from handlers.habits import HabitsHandlers # noqa: E402 from handlers.habits import HabitsHandlers # noqa: E402
from handlers.pdf import PDFHandlers # 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.workspace import WorkspaceHandlers # noqa: E402
from handlers.youtube import YoutubeHandlers # noqa: E402 from handlers.youtube import YoutubeHandlers # noqa: E402
@@ -95,6 +96,7 @@ class TaskBoardHandler(
PDFHandlers, PDFHandlers,
YoutubeHandlers, YoutubeHandlers,
WorkspaceHandlers, WorkspaceHandlers,
RalphHandlers,
CronHandlers, CronHandlers,
SimpleHTTPRequestHandler, SimpleHTTPRequestHandler,
): ):
@@ -155,6 +157,23 @@ class TaskBoardHandler(
self.handle_eco_logs() self.handle_eco_logs()
elif self.path == '/api/eco/doctor': elif self.path == '/api/eco/doctor':
self.handle_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/'): elif self.path.startswith('/api/'):
self.send_error(404) self.send_error(404)
else: else:
@@ -214,6 +233,13 @@ class TaskBoardHandler(
self.handle_eco_git_commit() self.handle_eco_git_commit()
elif self.path == '/api/eco/restart-taskboard': elif self.path == '/api/eco/restart-taskboard':
self.handle_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: else:
self.send_error(404) self.send_error(404)

305
dashboard/handlers/ralph.py Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 &lt;slug&gt;</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
View 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"]

View 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
View 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

View File

@@ -2,6 +2,17 @@
"projectName": "feature-name", "projectName": "feature-name",
"branchName": "ralph/feature-name", "branchName": "ralph/feature-name",
"description": "Descriere scurtă a feature-ului", "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": [ "userStories": [
{ {
"id": "US-001", "id": "US-001",
@@ -12,7 +23,15 @@
"Criteriu specific și verificabil", "Criteriu specific și verificabil",
"npm run typecheck passes" "npm run typecheck passes"
], ],
"tags": [],
"dependsOn": [],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": false, "passes": false,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "" "notes": ""
} }
] ]

View File

@@ -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 ### Faza 0: 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
### 2. Management branch - PRD-ul (`prd.json`) și `progress.txt` sunt furnizate în context.
- Verifică dacă ești pe branch-ul corect (specificat în `branchName` din PRD) - Identifică următorul story candidate:
- Dacă nu, creează și checkout branch-ul: - `passes != true` ȘI `failed != true` ȘI `blocked != true`
```bash - DAG: toate ID-urile din `dependsOn[]` au `passes == true` (altfel sare la următorul independent)
git checkout -b <branchName> - Cea mai mică `priority` printre cele eligibile.
``` - Notează `techStack.commands` (lint, typecheck, test, start) și `techStack.port`.
- Dacă branch-ul există deja, doar checkout: - Notează `story.tags[]` — alegerea Faza 3 depinde de ele.
```bash
git checkout <branchName>
```
### 3. Selectează story-ul ### Faza 1: IMPLEMENTARE (mereu)
- 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ă
### 4. Implementare 1.1. **Branch management** — verifică să fii pe `branchName` din PRD; checkout/create dacă lipsește.
- Implementează DOAR acest story 1.2. **Citește acceptance criteria** — fiecare criteriu e un test mental concret de trecut.
- Urmează patterns existente în codebase 1.3. **Implementează cod minimal** — DOAR ce cere story-ul. Urmează patterns existente. Fără over-engineering, fără side features.
- Fii minimal și focusat - nu adăuga funcționalități extra 1.4. **Update `notes`** în `prd.json` cu fișierele atinse (pentru audit ulterior).
### 5. Quality Checks ### Faza 2: QUALITY BASE (mereu, înainte de gates)
Rulează TOATE verificările înainte de commit. Folosește comenzile din `techStack.commands`:
Folosește `techStack.commands`:
```bash ```bash
# Folosește comenzile din prd.json techStack.commands: {techStack.commands.typecheck} # ex: npm run typecheck / mypy .
{techStack.commands.typecheck} # Type checking {techStack.commands.lint} # ex: npm run lint / ruff check .
{techStack.commands.lint} # Linting {techStack.commands.test} # ex: npm test / pytest
{techStack.commands.test} # Tests (dacă există)
``` ```
**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 | ### Faza 3: SMART GATES (dispatcher pe `story.tags`)
|-------|-----------|------|------|
| 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 ./... |
**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 **Multi-tag**: rulează gate-uri pentru fiecare tag (ex: `["ui", "backend"]` → atât `/qa` cât și `/review`).
```bash
# Folosește comanda din techStack.commands.start
{techStack.commands.start}
# Exemplu: npm run dev
```
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ă ### Faza 4: COMMIT + MARK
```bash
agent-browser navigate "http://localhost:{techStack.port}"
# Exemplu: agent-browser navigate "http://localhost:3000"
```
#### 6.3 Ia snapshot pentru verificare 4.1. **Commit** cu mesaj descriptiv:
```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:
``` ```
feat: [Story ID] - [Story Title] 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 4.2. **Update `prd.json`**:
**CRITIC**: Actualizează `scripts/ralph/prd.json`: - `passes: true` DOAR DACĂ toate gate-urile relevante au pasat
- Setează `passes: true` pentru story-ul implementat - `notes` populat cu rezultate gate (ex: "qa: ok, design-review: 8/10")
- Adaugă note relevante în câmpul `notes`
### 10. Actualizează progress.txt
Adaugă la sfârșitul fișierului `scripts/ralph/progress.txt`:
4.3. **Append `progress.txt`**:
```markdown ```markdown
## Iterație: [timestamp] ## Iterație: [timestamp]
### Story implementat: [ID] - [Title] ### Story implementat: [ID] - [Title] (tags: [ui, backend])
### Status: Complete ### Status: Complete / Partial / Failed
### Verificări: ### Gates rulate:
- Typecheck: PASS - Typecheck: PASS
- Lint: PASS - Lint: PASS
- Tests: PASS/SKIP - Tests: PASS/SKIP
- Browser check: PASS/N/A - /qa (ui): PASS — screenshot la scripts/ralph/screenshots/...
- /review (backend): PASS
### Learnings: ### Learnings:
- [Ce ai învățat] - [Patterns descoperite, gotchas]
- [Patterns descoperite]
### Next steps: ### Next:
- [Ce rămâne de făcut] - [Stories eligibile pentru iterația următoare]
--- ---
``` ```
## Reguli importante ## Reguli importante
1. **UN SINGUR STORY PE ITERAȚIE** - Nu implementa mai mult de un story 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 2. **DAG STRICT** — nu sări peste `dependsOn` neîmplinite.
3. **VERIFICARE BROWSER PENTRU UI** - Obligatorie dacă `requiresBrowserCheck: true` 3. **GATES PE TAGS** — rulează doar ce e relevant; tags vide = run-all-gates fallback.
4. **ACTUALIZEAZĂ prd.json** - Altfel iterația următoare va repeta munca 4. **NU MARCA `passes=true` cu gate failed** — altfel ralph.sh nu va relua story-ul.
5. **FII CONCIS** - Nu over-engineer 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 ```bash
# Navigare agent-browser navigate "http://localhost:{techStack.port}"
agent-browser navigate "http://localhost:3000/page" agent-browser snapshot # listă elemente compactă (@e1, @e2...)
# Snapshot (vedere compactă a paginii)
agent-browser snapshot
# Click pe element (folosind ref din snapshot)
agent-browser click @e5 agent-browser click @e5
# Fill input
agent-browser fill @e3 "value" agent-browser fill @e3 "value"
agent-browser screenshot ./scripts/ralph/screenshots/US-{id}-$(date +%Y%m%d-%H%M%S).png
# Screenshot agent-browser console # erori JS
agent-browser screenshot ./path/to/file.png
# Console logs
agent-browser console
# Așteaptă text
agent-browser wait-for "Loading complete" agent-browser wait-for "Loading complete"
``` ```
## Condiție de terminare ## 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> <promise>COMPLETE</promise>

View File

@@ -1,7 +1,13 @@
#!/bin/bash #!/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 # Adaptat din Ralph original (snarktank/ralph) pentru Claude Code CLI
# Usage: ./ralph.sh [max_iterations] [project_dir] # 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 set -e
@@ -15,6 +21,24 @@ SCREENSHOTS_DIR="$SCRIPT_DIR/screenshots"
LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch" LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
PROMPT_FILE="$SCRIPT_DIR/prompt.md" 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 # Verifică că jq este instalat
if ! command -v jq &> /dev/null; then if ! command -v jq &> /dev/null; then
echo "Eroare: jq nu este instalat. Rulează: apt install jq" echo "Eroare: jq nu este instalat. Rulează: apt install jq"
@@ -132,6 +156,51 @@ check_all_complete() {
[ "$incomplete" -eq 0 ] [ "$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 # Afișare status inițial
echo "" echo ""
echo "=======================================================================" echo "======================================================================="
@@ -155,6 +224,9 @@ if check_all_complete; then
exit 0 exit 0
fi fi
# Tracker pentru rate limit retry (max 1 retry de iterație-rate-limit per rulare)
RATE_LIMIT_RETRY_USED=0
# Loop principal # Loop principal
for i in $(seq 1 $MAX_ITERATIONS); do for i in $(seq 1 $MAX_ITERATIONS); do
echo "" echo ""
@@ -162,17 +234,38 @@ for i in $(seq 1 $MAX_ITERATIONS); do
echo " Ralph Iterația $i din $MAX_ITERATIONS" echo " Ralph Iterația $i din $MAX_ITERATIONS"
echo "===================================================================" 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 # Status curent
COMPLETE_NOW=$(jq '[.userStories[] | select(.passes == true)] | length' "$PRD_FILE") 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 " Progress: $COMPLETE_NOW / $TOTAL_STORIES stories complete"
echo " Next: $NEXT_STORY" echo " Next: $NEXT_TITLE [tags: ${STORY_TAGS:-<none>}, retries: $STORY_RETRIES]"
echo "" echo ""
# Pregătește prompt-ul cu context # Pregătește prompt-ul cu context
FULL_PROMPT=$(cat <<EOF FULL_PROMPT=$(cat <<EOF
# Context pentru această iterație Ralph # Context pentru această iterație Ralph
## Story țintă (DAG-eligible):
$CURRENT_STORY (tags: ${STORY_TAGS:-<none>})
## PRD (prd.json): ## PRD (prd.json):
$(cat "$PRD_FILE") $(cat "$PRD_FILE")
@@ -188,10 +281,34 @@ EOF
LOG_FILE="$SCRIPT_DIR/logs/iteration-$i-$(date +%Y%m%d-%H%M%S).log" LOG_FILE="$SCRIPT_DIR/logs/iteration-$i-$(date +%Y%m%d-%H%M%S).log"
mkdir -p "$SCRIPT_DIR/logs" mkdir -p "$SCRIPT_DIR/logs"
# --output-format json avoids streaming mode issues # --output-format json + --max-turns pentru control runtime
echo "$FULL_PROMPT" | claude -p --dangerously-skip-permissions --output-format json 2>&1 | tee "$LOG_FILE" || true 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") 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 # Verifică dacă toate task-urile sunt complete
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
echo "" echo ""
@@ -211,6 +328,23 @@ EOF
exit 0 exit 0
fi 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..." echo " Iterația $i completă. Continuăm..."
sleep 2 sleep 2
done done

267
tools/ralph_dag.py Normal file
View 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())

View File

@@ -1,13 +1,151 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Ralph PRD Generator - implementare Python a skill-ului ralph-prd 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 json
import os
import re import re
import subprocess
from pathlib import Path from pathlib import Path
from datetime import datetime 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): 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) # Detectează dacă necesită browser check (pentru UI)
requires_browser = 'ui' in title.lower() or 'interface' in title.lower() 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, "id": story_id,
"title": title, "title": title,
"description": f"Ca {user_type}, vreau {want} pentru că {because}", "description": f"Ca {user_type}, vreau {want} pentru că {because}",
"priority": priority, "priority": priority,
"acceptanceCriteria": criteria, "acceptanceCriteria": criteria,
"tags": tags_inferred,
"dependsOn": [],
"requiresBrowserCheck": requires_browser, "requiresBrowserCheck": requires_browser,
"passes": False, }, idx=int(story_id.split('-')[-1]) - 1 if story_id.startswith("US-") else 0)
"notes": ""
}
stories.append(story) stories.append(story)
# Dacă nu găsim stories (regex failed), generăm basic # Dacă nu găsim stories (regex failed), generăm basic
if not stories: if not stories:
stories = [{ stories = [_normalize_story({
"id": "US-001", "id": "US-001",
"title": "Implementare funcționalitate principală", "title": "Implementare funcționalitate principală",
"description": f"Implementează {project_name}", "description": f"Implementează {project_name}",
@@ -301,10 +449,9 @@ def prd_to_stories(prd_content: str, project_name: str):
"Tests passing", "Tests passing",
"Lint + typecheck pass" "Lint + typecheck pass"
], ],
"requiresBrowserCheck": False, "tags": [],
"passes": False, "dependsOn": [],
"notes": "" }, idx=0)]
}]
return stories 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: Returns:
tuple: (prd_file_path, prd_json_path) sau (None, None) dacă eroare 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']: if context['stack_type']:
print(f" Stack: {context['stack_type']}") print(f" Stack: {context['stack_type']}")
print(f" Config: {context['config_file']}") 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) prd_content = generate_prd_markdown(project_name, description, context)
# Salvează PRD # 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}") print(f"✅ PRD salvat: {prd_file}")
# Generează prd.json # Generează stories — preferă Opus din final-plan.md când disponibil
stories = prd_to_stories(prd_content, project_name) 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) tech_stack = detect_tech_stack_commands(project_dir, context)
prd_json_data = { prd_json_data = {
@@ -462,17 +636,20 @@ if __name__ == "__main__":
import sys import sys
if len(sys.argv) < 3: 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) sys.exit(1)
project_name = sys.argv[1] project_name = sys.argv[1]
description = sys.argv[2] description = sys.argv[2]
final_plan_arg = Path(sys.argv[3]) if len(sys.argv) > 3 else None
workspace = Path.home() / "workspace" workspace = Path.home() / "workspace"
print(f"🔄 Generez PRD pentru {project_name}") print(f"🔄 Generez PRD pentru {project_name}")
print("=" * 70) 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: if prd_file and prd_json:
print("\n" + "=" * 70) print("\n" + "=" * 70)