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

@@ -38,7 +38,12 @@ from src.router import (
_ralph_approve,
_ralph_status,
_ralph_stop,
planning_advance,
planning_approve,
planning_cancel,
start_planning_session,
)
from src.planning_session import is_in_planning
WORKSPACE_DIR = Path("/home/moltbot/workspace")
ADAPTER_NAME = "telegram"
@@ -408,19 +413,42 @@ def _build_ralph_project_keyboard(slug: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[
InlineKeyboardButton(" Propune feature", callback_data=f"ralph:propose:{slug}"),
InlineKeyboardButton("🧠 Planifică", callback_data=f"ralph:plan:{slug}"),
],
[
InlineKeyboardButton("👁 Vezi PRD", callback_data=f"ralph:prd:{slug}"),
],
[
InlineKeyboardButton("📊 Status", callback_data=f"ralph:status:{slug}"),
InlineKeyboardButton("✅ Aprobă tonight", callback_data=f"ralph:approve:{slug}"),
],
[
InlineKeyboardButton("✅ Aprobă tonight", callback_data=f"ralph:approve:{slug}"),
InlineKeyboardButton("🛑 Stop", callback_data=f"ralph:stop:{slug}"),
],
[
InlineKeyboardButton("🔙 Înapoi", callback_data="ralph:menu"),
],
])
def _build_planning_active_keyboard() -> InlineKeyboardMarkup:
"""Keyboard shown DURING an active planning session (after each turn)."""
return InlineKeyboardMarkup([
[
InlineKeyboardButton("▶️ Continuă faza", callback_data="ralph:planadvance"),
InlineKeyboardButton("🛑 Anulează", callback_data="ralph:plancancel"),
],
])
def _build_planning_final_keyboard() -> InlineKeyboardMarkup:
"""Keyboard shown when the planning pipeline has finished all phases."""
return InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Dau drumul tonight", callback_data="ralph:planapprove"),
InlineKeyboardButton("🛑 Anulează", callback_data="ralph:plancancel"),
],
])
def _render_ralph_root_summary() -> str:
try:
data = _load_approved_tasks()
@@ -468,6 +496,73 @@ async def cmd_ralph_k(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
await update.message.reply_text(result)
def split_planning_chunks(text: str, limit: int = 4096) -> list[str]:
"""Telegram-safe split (mirrors split_message but local to avoid forward ref)."""
if len(text) <= limit:
return [text]
chunks = []
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
async def cmd_plan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/plan <slug> [descriere] — pornește o sesiune de planning conversational."""
args = list(context.args or [])
if not args:
await update.message.reply_text("Folosire: /plan <slug> [descriere]")
return
slug = args[0]
description = " ".join(args[1:]).strip()
if not description:
# Look up from approved-tasks
try:
data = _load_approved_tasks()
except Exception:
data = {"projects": []}
for p in data.get("projects", []):
if p.get("name", "").lower() == slug.lower():
description = p.get("description") or ""
break
if not description:
await update.message.reply_text(
f"Nu am descriere pentru `{slug}`. Adaugă cu /p {slug} <descriere>.",
parse_mode="Markdown",
)
return
chat_id = update.message.chat_id
await update.message.reply_text(
f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)",
parse_mode="Markdown",
)
first = await asyncio.to_thread(
start_planning_session, slug, description, str(chat_id), ADAPTER_NAME,
)
for chunk in split_planning_chunks(first):
await context.bot.send_message(chat_id=chat_id, text=chunk)
await context.bot.send_message(
chat_id=chat_id,
text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.",
reply_markup=_build_planning_active_keyboard(),
parse_mode="Markdown",
)
async def cmd_cancel_planning(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/cancel — anulează sesiunea de planning curentă."""
text = await asyncio.to_thread(
planning_cancel, str(update.message.chat_id), ADAPTER_NAME,
)
await update.message.reply_text(text)
async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle inline keyboard callbacks for Ralph (pattern ^ralph:)."""
query = update.callback_query
@@ -576,6 +671,71 @@ async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await context.bot.send_message(chat_id=int(chat_id), text=result)
return
# ---- Planning agent (W2) ---------------------------------------------
if action == "plan":
# Look up project description from approved-tasks.json (or workspace fallback).
try:
data = _load_approved_tasks()
except Exception:
data = {"projects": []}
description = ""
for p in data.get("projects", []):
if p.get("name", "").lower() == (slug or "").lower():
description = p.get("description") or ""
break
if not description:
await context.bot.send_message(
chat_id=int(chat_id),
text=(
f"Nu am descriere pentru `{slug}`. "
f"Adaugă mai întâi cu `/p {slug} <descriere>`."
),
parse_mode="Markdown",
)
return
await context.bot.send_message(
chat_id=int(chat_id),
text=f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)",
parse_mode="Markdown",
)
first = await asyncio.to_thread(
start_planning_session, slug, description, str(chat_id), ADAPTER_NAME,
)
for chunk in split_message(first):
await context.bot.send_message(chat_id=int(chat_id), text=chunk)
await context.bot.send_message(
chat_id=int(chat_id),
text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.",
reply_markup=_build_planning_active_keyboard(),
parse_mode="Markdown",
)
return
if action == "planadvance":
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
text, completed = await asyncio.to_thread(
planning_advance, str(chat_id), ADAPTER_NAME,
)
for chunk in split_message(text):
await context.bot.send_message(chat_id=int(chat_id), text=chunk)
kb = _build_planning_final_keyboard() if completed else _build_planning_active_keyboard()
await context.bot.send_message(
chat_id=int(chat_id),
text=("Plan gata. Confirmi?" if completed else "Continuăm?"),
reply_markup=kb,
)
return
if action == "plancancel":
text = await asyncio.to_thread(planning_cancel, str(chat_id), ADAPTER_NAME)
await context.bot.send_message(chat_id=int(chat_id), text=text)
return
if action == "planapprove":
text = await asyncio.to_thread(planning_approve, str(chat_id), ADAPTER_NAME)
await context.bot.send_message(chat_id=int(chat_id), text=text)
return
# --- Fast command handlers ---
@@ -826,6 +986,10 @@ def create_telegram_bot(config: Config, token: str) -> Application:
app.add_handler(CommandHandler("l", cmd_ralph_l))
app.add_handler(CommandHandler("k", cmd_ralph_k))
# Planning agent (W2)
app.add_handler(CommandHandler("plan", cmd_plan))
app.add_handler(CommandHandler("cancel", cmd_cancel_planning))
# Fast commands
app.add_handler(CommandHandler("email", cmd_email))
app.add_handler(CommandHandler("emailsend", cmd_emailsend))
@@ -880,6 +1044,8 @@ def create_telegram_bot(config: Config, token: str) -> Application:
BotCommand("a", "Ralph: approve project for tonight"),
BotCommand("l", "Ralph: list projects status"),
BotCommand("k", "Ralph: stop running project"),
BotCommand("plan", "Planning conversational pentru un proiect"),
BotCommand("cancel", "Anulează planning în curs"),
])
app.post_init = post_init