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:
2026-04-26 18:36:35 +00:00
parent e06a79d98c
commit 655ed3ae09
11 changed files with 2282 additions and 189 deletions

View File

@@ -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": ""
}
]

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

View File

@@ -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
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
"""
Ralph PRD Generator - implementare Python a skill-ului ralph-prd
Generează PRD și prd.json fără să apeleze Claude Code
Generează PRD și prd.json din descriere (heuristic) sau din final-plan.md (Opus).
Schema extinsă (W3 / smart gates + DAG):
- tags[] : "ui" | "db" | "vercel" | "refactor" | "docs" | "backend" | "infra"
- dependsOn[] : alte story IDs (DAG topological sort)
- acceptanceCriteria: 3-5 criterii verificabile concret
- passes/failed/blocked/retries: state pentru ralph.sh loop guard
- failureReason : populat când failed=true (ex: "rate_limited", "max_retries")
"""
import json
import os
import re
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Optional
# Constants pentru smart gates dispatcher
VALID_TAGS = {"ui", "db", "vercel", "refactor", "docs", "backend", "infra"}
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
OPUS_TIMEOUT = int(os.environ.get("RALPH_PRD_OPUS_TIMEOUT", "600")) # 10 min
def _normalize_story(story: dict, idx: int = 0) -> dict:
"""Asigură că un story dict are toate câmpurile schemei extinse W3."""
sid = story.get("id") or f"US-{idx + 1:03d}"
title = (story.get("title") or "").strip() or f"Story {sid}"
description = (story.get("description") or "").strip() or title
# Tags: filter la VALID_TAGS, păstrează ordine
raw_tags = story.get("tags") or []
tags = [t for t in raw_tags if isinstance(t, str) and t in VALID_TAGS]
# dependsOn: lista de story IDs (string-uri)
raw_deps = story.get("dependsOn") or []
depends_on = [d for d in raw_deps if isinstance(d, str) and d.strip()]
# acceptance criteria: cel puțin 1, ideal 3-5
raw_ac = story.get("acceptanceCriteria") or []
acceptance = [c.strip() for c in raw_ac if isinstance(c, str) and c.strip()]
if not acceptance:
acceptance = ["Funcționalitatea implementată conform descrierii"]
return {
"id": sid,
"title": title,
"description": description,
"priority": int(story.get("priority") or (idx + 1) * 10),
"acceptanceCriteria": acceptance,
"tags": tags,
"dependsOn": depends_on,
"requiresBrowserCheck": bool(story.get("requiresBrowserCheck", "ui" in tags)),
"requiresDesignReview": bool(story.get("requiresDesignReview", False)),
"passes": bool(story.get("passes", False)),
"failed": bool(story.get("failed", False)),
"blocked": bool(story.get("blocked", False)),
"retries": int(story.get("retries") or 0),
"failureReason": story.get("failureReason") or "",
"notes": story.get("notes") or "",
}
def extract_stories_from_final_plan(final_plan_path: Path) -> Optional[list]:
"""Invocă Claude CLI (Opus) pe final-plan.md și extrage user stories în schema extinsă.
Returnează listă de stories normalizate, sau None dacă invocarea eșuează.
Backward-compat: caller-ul poate fallback la heuristic dacă None.
"""
if not final_plan_path.exists():
return None
try:
plan_content = final_plan_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
valid_tags_csv = ", ".join(sorted(VALID_TAGS))
prompt = (
f"Citește final-plan.md de mai jos și extrage user stories implementabile autonom de către un agent AI (Ralph).\n\n"
f"Pentru fiecare story extrage:\n"
f"- id (format US-001, US-002...)\n"
f"- title (scurt, imperativ)\n"
f"- description (1-2 propoziții, ce face story-ul)\n"
f"- acceptanceCriteria[] (3-5 criterii verificabile concret — comenzi care trebuie să iasă PASS, fișiere create, comportament observabil)\n"
f"- tags[] (subset din: {valid_tags_csv}; un story poate avea 1-3 tags)\n"
f"- dependsOn[] (alte story IDs de care depinde — pentru DAG topological sort; goală dacă independent)\n"
f"- priority (10, 20, 30... în ordinea din plan)\n\n"
f"Reguli:\n"
f"- Fiecare story IMPLEMENTABIL — nu task-uri de research sau design (alea s-au făcut deja în plan).\n"
f"- Tags ghidează Ralph-ul să ruleze gates corecte: ui→/qa Playwright, db→schema diff, vercel→PR checks, refactor→/workflow:simplify.\n"
f"- dependsOn pentru ordering real (US-002 are nevoie de DB-ul din US-001) — NU pentru tot ce vine după.\n"
f"- Nu inventa stories peste plan; extrage doar ce e acolo.\n\n"
f"Răspunde DOAR cu JSON valid (fără markdown fence, fără comentarii) în formatul:\n"
f'{{"userStories": [{{"id":"US-001","title":"...","description":"...","acceptanceCriteria":["..."],"tags":["..."],"dependsOn":[],"priority":10}}, ...]}}\n\n'
f"=== FINAL PLAN ===\n{plan_content}\n=== END PLAN ===\n"
)
cmd = [
CLAUDE_BIN, "-p", prompt,
"--model", "opus",
"--output-format", "json",
]
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=OPUS_TIMEOUT,
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
print(f"⚠️ Opus extraction failed: {exc}")
return None
if proc.returncode != 0:
print(f"⚠️ Opus exit {proc.returncode}: {proc.stderr[:300]}")
return None
# Claude --output-format json wrap-uiește răspunsul în {"result": "..."}
raw_result = proc.stdout
try:
wrapper = json.loads(raw_result)
result_text = wrapper.get("result", raw_result) if isinstance(wrapper, dict) else raw_result
except json.JSONDecodeError:
result_text = raw_result
# Caută primul block JSON în răspuns (defensiv contra prefix/suffix text)
match = re.search(r'\{[\s\S]*"userStories"[\s\S]*\}', result_text)
if not match:
print(f"⚠️ Niciun JSON cu userStories în output Opus")
return None
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError as exc:
print(f"⚠️ JSON parse error în output Opus: {exc}")
return None
raw_stories = parsed.get("userStories") or []
if not isinstance(raw_stories, list) or not raw_stories:
return None
return [_normalize_story(s, i) for i, s in enumerate(raw_stories)]
def detect_project_context(project_dir: Path):
@@ -275,23 +413,33 @@ def prd_to_stories(prd_content: str, project_name: str):
# Detectează dacă necesită browser check (pentru UI)
requires_browser = 'ui' in title.lower() or 'interface' in title.lower()
story = {
# Heuristic tags din titlu
tags_inferred = []
title_lower = title.lower()
if requires_browser or 'ui' in title_lower or 'frontend' in title_lower:
tags_inferred.append('ui')
if 'database' in title_lower or 'schema' in title_lower or 'migration' in title_lower:
tags_inferred.append('db')
if 'refactor' in title_lower or 'cleanup' in title_lower:
tags_inferred.append('refactor')
if 'doc' in title_lower or 'readme' in title_lower:
tags_inferred.append('docs')
story = _normalize_story({
"id": story_id,
"title": title,
"description": f"Ca {user_type}, vreau {want} pentru că {because}",
"priority": priority,
"acceptanceCriteria": criteria,
"tags": tags_inferred,
"dependsOn": [],
"requiresBrowserCheck": requires_browser,
"passes": False,
"notes": ""
}
}, idx=int(story_id.split('-')[-1]) - 1 if story_id.startswith("US-") else 0)
stories.append(story)
# Dacă nu găsim stories (regex failed), generăm basic
if not stories:
stories = [{
stories = [_normalize_story({
"id": "US-001",
"title": "Implementare funcționalitate principală",
"description": f"Implementează {project_name}",
@@ -301,11 +449,10 @@ def prd_to_stories(prd_content: str, project_name: str):
"Tests passing",
"Lint + typecheck pass"
],
"requiresBrowserCheck": False,
"passes": False,
"notes": ""
}]
"tags": [],
"dependsOn": [],
}, idx=0)]
return stories
@@ -357,40 +504,67 @@ def detect_tech_stack_commands(project_dir: Path, context: dict):
}
def create_prd_and_json(project_name: str, description: str, workspace_dir: Path):
def create_prd_and_json(
project_name: str,
description: str,
workspace_dir: Path,
final_plan_path: Optional[Path] = None,
):
"""
Generează PRD markdown și prd.json pentru un proiect
Generează PRD markdown și prd.json pentru un proiect.
Args:
project_name: slug proiect (folder în workspace_dir)
description: descriere scurtă (folosită ca fallback și pentru PRD markdown)
workspace_dir: rădăcina workspace (default ~/workspace/)
final_plan_path: opțional, calea către final-plan.md produs de planning agent (W2);
când e furnizat, user stories sunt extrase prin Claude Opus din plan;
când e None, păstrăm comportamentul vechi (heuristic din description).
Returns:
tuple: (prd_file_path, prd_json_path) sau (None, None) dacă eroare
"""
project_dir = workspace_dir / project_name
project_dir.mkdir(parents=True, exist_ok=True)
# Detectează context
context = detect_project_context(project_dir)
print(f"📊 Context detectat:")
print(f" Mode: {context['mode']}")
if context['stack_type']:
print(f" Stack: {context['stack_type']}")
print(f" Config: {context['config_file']}")
# Generează PRD markdown
if final_plan_path:
print(f" Final plan: {final_plan_path}")
# Generează PRD markdown (mereu — folosit pentru read humans)
prd_content = generate_prd_markdown(project_name, description, context)
# Salvează PRD
tasks_dir = project_dir / "tasks"
tasks_dir.mkdir(exist_ok=True)
prd_file = tasks_dir / f"prd-{project_name}.md"
with open(prd_file, 'w', encoding='utf-8') as f:
f.write(prd_content)
print(f"✅ PRD salvat: {prd_file}")
# Generează prd.json
stories = prd_to_stories(prd_content, project_name)
# Generează stories — preferă Opus din final-plan.md când disponibil
stories = None
if final_plan_path is not None:
plan = Path(final_plan_path) if not isinstance(final_plan_path, Path) else final_plan_path
print(f"🧠 Extrag stories din final-plan.md cu Opus...")
stories = extract_stories_from_final_plan(plan)
if stories:
print(f"{len(stories)} stories extrase din plan")
else:
print(f" ↳ Opus extraction eșuat — fallback la heuristic")
if not stories:
stories = prd_to_stories(prd_content, project_name)
tech_stack = detect_tech_stack_commands(project_dir, context)
prd_json_data = {
@@ -460,19 +634,22 @@ def create_prd_and_json(project_name: str, description: str, workspace_dir: Path
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("Usage: python ralph_prd_generator.py PROJECT_NAME 'description'")
print("Usage: python ralph_prd_generator.py PROJECT_NAME 'description' [final_plan_path]")
sys.exit(1)
project_name = sys.argv[1]
description = sys.argv[2]
final_plan_arg = Path(sys.argv[3]) if len(sys.argv) > 3 else None
workspace = Path.home() / "workspace"
print(f"🔄 Generez PRD pentru {project_name}")
print("=" * 70)
prd_file, prd_json = create_prd_and_json(project_name, description, workspace)
prd_file, prd_json = create_prd_and_json(
project_name, description, workspace, final_plan_path=final_plan_arg
)
if prd_file and prd_json:
print("\n" + "=" * 70)