feat(ralph): add autonomous project execution system

- router.py: add !approve, !status, !stop, !propose commands for project lifecycle management
- approved-tasks.json: coordination schema for evening→night→morning pipeline
- tools/ralph/: ralph.sh loop, prompt.md, prd-template.json
- cron/jobs.json: enable morning-report, evening-report, night-execute (23:00 opus)

Evening-report proposes features to approved-tasks.json as 'pending'; Marius
approves via !approve; night-execute launches ralph.sh per project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:20:52 +00:00
parent bee409d164
commit 90c2a90b5e
6 changed files with 665 additions and 8 deletions

View File

@@ -1,6 +1,11 @@
"""Echo Core message router — routes messages to Claude or commands."""
import json
import logging
import os
import signal
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from src.config import Config
@@ -16,6 +21,8 @@ from src.claude_session import (
log = logging.getLogger(__name__)
APPROVED_TASKS_FILE = Path(__file__).parent.parent / "approved-tasks.json"
# Module-level config instance (lazy singleton)
_config: Config | None = None
@@ -45,6 +52,19 @@ def route_message(
"""
text = text.strip()
# Ralph commands (!approve, !status, !stop, !propose)
if text.lower().startswith("!approve ") or text.lower() == "!approve":
return _ralph_approve(text), True
if text.lower() == "!status" or text.lower().startswith("!status "):
return _ralph_status(text), True
if text.lower().startswith("!stop "):
return _ralph_stop(text), True
if text.lower().startswith("!propose "):
return _ralph_propose(text), True
# Text-based commands (not slash commands — these work in any adapter)
if text.lower() == "/clear":
default_model = _get_config().get("bot.default_model", "sonnet")
@@ -136,6 +156,168 @@ def _model_command(channel_id: str, text: str) -> str:
return f"Model changed to {choice}."
def _load_approved_tasks() -> dict:
"""Load approved-tasks.json, return empty structure if missing."""
if APPROVED_TASKS_FILE.exists():
return json.loads(APPROVED_TASKS_FILE.read_text())
return {"projects": [], "last_updated": None}
def _save_approved_tasks(data: dict) -> None:
data["last_updated"] = datetime.now(timezone.utc).isoformat()
APPROVED_TASKS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def _ralph_approve(text: str) -> str:
"""!approve P1,P2 sau !approve roa2web — aprobă proiecte pentru night-execute."""
parts = text.split(None, 1)
if len(parts) < 2:
data = _load_approved_tasks()
pending = [p for p in data["projects"] if p.get("status") == "pending"]
if not pending:
return "Niciun proiect pending. Folosește !propose <nume> <descriere> pentru a adăuga."
lines = [f"Proiecte pending (aprobă cu !approve <nume>):"]
for p in pending:
lines.append(f" - {p['name']}: {p['description'][:60]}")
return "\n".join(lines)
names_raw = parts[1].strip()
names = [n.strip() for n in names_raw.replace(",", " ").split() if n.strip()]
data = _load_approved_tasks()
approved = []
not_found = []
for name in names:
found = False
for p in data["projects"]:
if p["name"].lower() == name.lower():
p["status"] = "approved"
p["approved_at"] = datetime.now(timezone.utc).isoformat()
approved.append(p["name"])
found = True
break
if not found:
not_found.append(name)
if not_found:
return f"Nu am găsit proiectele: {', '.join(not_found)}. Verifică !status pentru lista completă."
_save_approved_tasks(data)
names_str = ", ".join(approved)
return f"✅ Aprobat pentru tonight: {names_str}\nNight-execute rulează la 23:00 și va implementa stories autonom."
def _ralph_status(text: str) -> str:
"""!status sau !status <proiect> — status Ralph pentru proiecte."""
parts = text.split(None, 1)
filter_name = parts[1].strip().lower() if len(parts) > 1 else None
data = _load_approved_tasks()
projects = data.get("projects", [])
if filter_name:
projects = [p for p in projects if filter_name in p["name"].lower()]
if not projects:
return "Niciun proiect în approved-tasks.json. Adaugă cu !propose."
lines = ["📊 Status proiecte Ralph:"]
for p in projects:
status = p.get("status", "unknown")
name = p["name"]
pid = p.get("pid")
started = p.get("started_at", "")[:16] if p.get("started_at") else "-"
# Verifică dacă procesul mai rulează
if pid and status == "running":
try:
os.kill(pid, 0)
running_indicator = f"🟢 PID {pid}"
except (ProcessLookupError, PermissionError):
running_indicator = "🔴 PID mort"
p["status"] = "stopped"
_save_approved_tasks(data)
else:
running_indicator = {"approved": "⏳ aștept 23:00", "pending": "📋 pending",
"complete": "✅ complet", "failed": "❌ eșuat", "stopped": "⏹ oprit"}.get(status, status)
# Stories complete din prd.json
prd_path = Path(f"/home/moltbot/workspace/{name}/scripts/ralph/prd.json")
stories_info = ""
if prd_path.exists():
try:
prd = json.loads(prd_path.read_text())
total = len(prd.get("userStories", []))
done = sum(1 for s in prd.get("userStories", []) if s.get("passes"))
stories_info = f" | Stories: {done}/{total}"
except Exception:
pass
lines.append(f" {name}: {running_indicator}{stories_info} | Start: {started}")
return "\n".join(lines)
def _ralph_stop(text: str) -> str:
"""!stop <proiect> — oprește Ralph loop pentru un proiect."""
parts = text.split(None, 1)
if len(parts) < 2:
return "Folosire: !stop <nume-proiect>"
name = parts[1].strip()
data = _load_approved_tasks()
for p in data["projects"]:
if p["name"].lower() == name.lower():
pid = p.get("pid")
if pid:
try:
os.kill(pid, signal.SIGTERM)
p["status"] = "stopped"
p["stopped_at"] = datetime.now(timezone.utc).isoformat()
_save_approved_tasks(data)
return f"⏹ Ralph oprit pentru {name} (PID {pid} terminat)."
except ProcessLookupError:
p["status"] = "stopped"
_save_approved_tasks(data)
return f"PID {pid} nu mai rula pentru {name}. Status actualizat."
except PermissionError:
return f"❌ Nu am permisiune să opresc PID {pid}."
else:
return f"{name} nu are un PID activ (status: {p.get('status', 'unknown')})."
return f"Proiect '{name}' nu găsit în approved-tasks.json."
def _ralph_propose(text: str) -> str:
"""!propose <nume> <descriere> — adaugă un proiect pentru aprobare."""
parts = text.split(None, 2)
if len(parts) < 3:
return "Folosire: !propose <nume-proiect> <descriere scurtă>\nEx: !propose roa2web Homepage redesign cu hero section și animații"
name = parts[1].strip()
description = parts[2].strip()
data = _load_approved_tasks()
for p in data["projects"]:
if p["name"].lower() == name.lower():
return f"Proiectul '{name}' există deja cu status: {p.get('status', 'unknown')}."
data["projects"].append({
"name": name,
"description": description,
"status": "pending",
"proposed_at": datetime.now(timezone.utc).isoformat(),
"approved_at": None,
"started_at": None,
"pid": None
})
_save_approved_tasks(data)
return f"📋 Adăugat: {name}\n{description}\n\nAprobă cu: !approve {name}"
def _get_channel_config(channel_id: str) -> dict | None:
"""Find channel config by ID."""
channels = _get_config().get("channels", {})