feat(ralph): conversational planning agent (W2)

Echo Core devine planning agent: poartă o conversație multi-fază cu Marius
folosind skill-urile gstack (/office-hours → /plan-ceo-review →
/plan-eng-review → /plan-design-review opt) și produce final-plan.md în
~/workspace/<slug>/scripts/ralph/, gata să fie consumat de Ralph PRD
generator (W3) noaptea.

Decizii arhitecturale (din eng review + spike findings):
- PlanningSession ca clasă SEPARATĂ de chat-ul main (NU mode=string param)
  — separation explicit. claude_session.py rămâne strict pentru chat;
  planning trăiește în src/planning_session.py + src/planning_orchestrator.py.
  Inheritance literală nu se aplică (claude_session.py expune funcții
  module-level, nu o clasă) — separation e satisfacută prin module distinct.
- Fresh subprocess PER skill phase, NU single resumed session — phase-urile
  coordinează via disk artifacts (gstack convention în
  ~/.gstack/projects/<slug>/). Avoids context window growth.
- --max-turns 20 default + retry pe error_max_turns la --max-turns 30.
  Spike a arătat că prompt-uri complexe pot exploda turn budget-ul.
- approved-tasks.json schema extins cu planning_session_id + final_plan_path
  (Status flow: pending → planning → approved → running → complete).
- State separat în sessions/planning.json (NU active.json), keyed pe
  (adapter, channel_id) pentru re-resume la restart echo-core.

Trigger-e:
- Discord: slash command /plan <slug> [descriere] cu autocomplete pe pending,
  buton "🧠 Planifică" în RalphProjectView, și /cancel slash command.
- Telegram: /plan + /cancel commands, plus buton "🧠 Planifică" în
  ralph project keyboard.
- Router: state-aware routing — dacă chat-ul e în planning, mesajele plain
  trec la PlanningOrchestrator.respond() prin --resume; /cancel revine la
  status pending; /advance / "Continuă faza" advance fază nouă (fresh
  subprocess); /finalize sau "Dau drumul" promote la status approved.

Discord defer pattern: toate butoanele noi (PlanningActiveView,
PlanningFinalView, "🧠 Planifică") apelează await
interaction.response.defer(ephemeral=True) ÎNAINTE de orice IO — evită
"Interaction failed" pe IO >3s.

UX strings warm + colaborativ (per design review): "🧠 Pornesc planning
pentru ...", "Răspunde aici", "Continuă faza", "Dau drumul tonight",
"Anulează" — niciun "Submit/Approve/Cancel" generic.

Tests: 23 noi (test_planning_session, test_planning_orchestrator,
test_router_planning) — toate pass. Mock pe _run_claude pentru a evita
subprocess Claude real în CI.

Files new:
  prompts/planning_agent.md
  src/planning_session.py
  src/planning_orchestrator.py
  tests/test_planning_session.py
  tests/test_planning_orchestrator.py
  tests/test_router_planning.py

Files modified:
  src/claude_session.py        — _run_claude(cwd=...) optional + surface subtype/is_error
  src/router.py                — state-aware routing, start_planning_session, planning_advance/approve/cancel, _ralph_propose schema cu planning_session_id + final_plan_path
  src/adapters/discord_bot.py  — /plan + /cancel slash commands; planning views imported
  src/adapters/discord_views.py — PlanningActiveView, PlanningFinalView, "Planifică" button în RalphProjectView, _split_chunks helper
  src/adapters/telegram_bot.py — /plan + /cancel handlers, callback_ralph extins cu plan/planadvance/plancancel/planapprove, planning keyboards

Status testelor pe modulele atinse: 75 passed, 0 failed
(test_claude_session security_section preexistent — neatins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:38:51 +00:00
parent e06a79d98c
commit 51e56af557
11 changed files with 2244 additions and 7 deletions

View File

@@ -20,7 +20,12 @@ from src.router import (
_ralph_propose,
_ralph_status,
_ralph_stop,
planning_advance,
planning_approve,
planning_cancel,
start_planning_session,
)
from src.planning_session import is_in_planning
log = logging.getLogger(__name__)
@@ -168,7 +173,49 @@ class RalphProjectView(discord.ui.View):
text = _ralph_stop(self.slug)
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=1)
@discord.ui.button(label="🧠 Planifică", style=discord.ButtonStyle.primary, row=2)
async def plan(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
# Look up description from approved-tasks.json
description = ""
try:
data = _load_approved_tasks()
for p in data.get("projects", []):
if p.get("name", "").lower() == self.slug.lower():
description = p.get("description") or ""
break
except Exception:
log.exception("approved-tasks lookup failed")
if not description:
await interaction.followup.send(
f"Nu am descriere pentru `{self.slug}`. "
f"Adaugă mai întâi cu `/p {self.slug} <descriere>`.",
ephemeral=True,
)
return
channel_id = str(interaction.channel_id)
await interaction.followup.send(
f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)",
ephemeral=True,
)
try:
first = start_planning_session(
self.slug, description, channel_id, "discord",
)
except Exception as e:
log.exception("start_planning_session failed for %s", self.slug)
await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True)
return
# Send first message of the planning agent + active keyboard
for chunk in _split_chunks(first, 1900):
await interaction.followup.send(chunk, ephemeral=True)
await interaction.followup.send(
"Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.",
view=PlanningActiveView(),
ephemeral=True,
)
@discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=2)
async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphRootView()
@@ -231,6 +278,80 @@ class _CloseButton(discord.ui.Button):
await interaction.edit_original_response(content="Închis.", view=None)
def _split_chunks(text: str, limit: int = 1900) -> list[str]:
"""Split a long message into Discord-safe chunks."""
if len(text) <= limit:
return [text]
chunks: list[str] = []
while text:
if len(text) <= limit:
chunks.append(text)
break
cut = text.rfind("\n", 0, limit)
if cut == -1:
cut = limit
chunks.append(text[:cut])
text = text[cut:].lstrip("\n")
return chunks
# ---------------------------------------------------------------------------
# Planning views (W2) — buttons that drive the planning conversation
# ---------------------------------------------------------------------------
class PlanningActiveView(discord.ui.View):
"""Buttons shown DURING an active planning session: advance phase / cancel."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
@discord.ui.button(label="▶️ Continuă faza", style=discord.ButtonStyle.primary, row=0)
async def advance(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
channel_id = str(interaction.channel_id)
try:
text, completed = planning_advance(channel_id, "discord")
except Exception as e:
log.exception("planning advance failed")
await interaction.followup.send(f"Eroare: {e}", ephemeral=True)
return
for chunk in _split_chunks(text):
await interaction.followup.send(chunk, ephemeral=True)
view: discord.ui.View = (
PlanningFinalView() if completed else PlanningActiveView()
)
await interaction.followup.send(
("Plan gata. Confirmi?" if completed else "Continuăm?"),
view=view, ephemeral=True,
)
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_cancel(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
class PlanningFinalView(discord.ui.View):
"""Buttons shown when ALL planning phases finished — Dau drumul / Anulează."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
@discord.ui.button(label="✅ Dau drumul tonight", style=discord.ButtonStyle.success, row=0)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_approve(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_cancel(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
class RalphRootView(discord.ui.View):
"""Landing view: workspace projects with status emoji + refresh + close."""