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:
@@ -23,8 +23,17 @@ from src.router import (
|
||||
_ralph_status,
|
||||
_ralph_stop,
|
||||
_load_approved_tasks,
|
||||
planning_advance,
|
||||
planning_approve,
|
||||
planning_cancel,
|
||||
start_planning_session,
|
||||
)
|
||||
from src.adapters.discord_views import (
|
||||
RalphRootView,
|
||||
PlanningActiveView,
|
||||
PlanningFinalView,
|
||||
_split_chunks,
|
||||
)
|
||||
from src.adapters.discord_views import RalphRootView
|
||||
|
||||
logger = logging.getLogger("echo-core.discord")
|
||||
_security_log = logging.getLogger("echo-core.security")
|
||||
@@ -985,6 +994,67 @@ def create_bot(config: Config) -> discord.Client:
|
||||
) -> None:
|
||||
await interaction.response.send_message(_ralph_stop(slug))
|
||||
|
||||
# ---- Planning agent (W2) ---------------------------------------------
|
||||
|
||||
@tree.command(name="plan", description="Pornește o sesiune de planning conversational pentru un proiect")
|
||||
@app_commands.describe(
|
||||
slug="Project slug (folosește /p ca să-l adaugi întâi)",
|
||||
description="Descriere opțională (default: cea din approved-tasks.json)",
|
||||
)
|
||||
@app_commands.autocomplete(slug=_ralph_autocomplete_pending)
|
||||
async def plan_cmd(
|
||||
interaction: discord.Interaction,
|
||||
slug: str,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
# Resolve description: explicit param wins, else look up in approved-tasks.
|
||||
desc = (description or "").strip()
|
||||
if not desc:
|
||||
try:
|
||||
data = _load_approved_tasks()
|
||||
for p in data.get("projects", []):
|
||||
if p.get("name", "").lower() == slug.lower():
|
||||
desc = p.get("description") or ""
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("approved-tasks lookup failed")
|
||||
if not desc:
|
||||
await interaction.followup.send(
|
||||
f"Nu am descriere pentru `{slug}`. Adaugă cu `/p {slug} <descriere>` "
|
||||
"sau pasează `description` la `/plan`.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
channel_id = str(interaction.channel_id)
|
||||
await interaction.followup.send(
|
||||
f"🧠 Pornesc planning pentru `{slug}`… (durează ~60s)", ephemeral=True
|
||||
)
|
||||
try:
|
||||
first = await asyncio.to_thread(
|
||||
start_planning_session, slug, desc, channel_id, "discord",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("start_planning_session failed for %s", slug)
|
||||
await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True)
|
||||
return
|
||||
for chunk in _split_chunks(first):
|
||||
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,
|
||||
)
|
||||
|
||||
@tree.command(name="cancel", description="Anulează sesiunea de planning curentă")
|
||||
async def cancel_planning_cmd(interaction: discord.Interaction) -> None:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
text = await asyncio.to_thread(
|
||||
planning_cancel, str(interaction.channel_id), "discord",
|
||||
)
|
||||
await interaction.followup.send(text, ephemeral=True)
|
||||
|
||||
# --- Events ---
|
||||
|
||||
@client.event
|
||||
@@ -1029,6 +1099,7 @@ def create_bot(config: Config) -> discord.Client:
|
||||
response, _is_cmd = await asyncio.to_thread(
|
||||
route_message, channel_id, user_id, text,
|
||||
on_text=on_text,
|
||||
adapter_name="discord",
|
||||
)
|
||||
|
||||
# Only send the final combined response if no intermediates
|
||||
|
||||
Reference in New Issue
Block a user