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

@@ -18,6 +18,12 @@ from src.claude_session import (
set_session_model,
VALID_MODELS,
)
from src.planning_orchestrator import PlanningOrchestrator
from src.planning_session import (
clear_planning_state,
get_planning_state,
is_in_planning,
)
log = logging.getLogger(__name__)
@@ -56,6 +62,57 @@ def route_message(
"""
text = text.strip()
# ---- Planning state-aware routing -----------------------------------
# If the channel is in an active planning session, the user's message is
# part of that conversation — route it to the orchestrator (NOT Claude
# main session, NOT slash commands except explicit /cancel and /advance).
in_planning = is_in_planning(adapter_name or "echo", channel_id)
if in_planning:
low = text.lower().strip()
if low in ("/cancel", "/anuleaza", "/anulează", "anulează planning", "anuleaza planning"):
# Capture slug BEFORE clearing state so we can revert approved-tasks status.
adapter_key = adapter_name or "echo"
state_snapshot = get_planning_state(adapter_key, channel_id)
cleared = PlanningOrchestrator.cancel(adapter_key, channel_id)
if state_snapshot and state_snapshot.get("slug"):
_revert_status_for_slug(state_snapshot["slug"], to="pending")
if cleared:
return "Planning anulat. Status revenit la pending.", True
return "Nu era nicio sesiune activă.", True
if low in ("/advance", "/continua", "/continuă", "continuă faza", "continua faza"):
session, response, completed = PlanningOrchestrator.advance(
adapter_name or "echo", channel_id, on_text=on_text,
)
return response, True
if low in ("/finalize", "/dau drumul", "dau drumul"):
return _approve_from_planning(channel_id, adapter_name or "echo"), True
if text.startswith("/"):
# Allow other commands to fall through (e.g. /status, /clear),
# but skip Ralph dispatch and Claude routing below.
pass
else:
# Plain message → planning conversation.
try:
session, response, phase_ready = PlanningOrchestrator.respond(
adapter_name or "echo", channel_id, text, on_text=on_text,
)
if session is None:
# State raced — drop planning marker, fall through.
log.warning(
"planning state vanished mid-respond for channel=%s", channel_id
)
else:
if phase_ready:
response = (
response
+ "\n\n— Apasă **Continuă faza** ca să trec la următoarea, "
"sau **Anulează** dacă te-ai răzgândit."
)
return response, False
except Exception as e:
log.error("Planning respond failed for %s: %s", channel_id, e)
return f"Planning blocat: {e}", False
# Ralph commands — short form (/p /a /l /k) and legacy aliases (!propose !approve !status !stop)
ralph_response = _try_ralph_dispatch(text, adapter_name=adapter_name)
if ralph_response is not None:
@@ -220,7 +277,11 @@ def _try_ralph_dispatch(text: str, adapter_name: str | None = None) -> str | Non
def _ralph_propose(slug: str, description: str) -> str:
"""Adaugă un proiect cu status pending în approved-tasks.json."""
"""Adaugă un proiect cu status pending în approved-tasks.json.
Schema includes the W2 planning fields (`planning_session_id`,
`final_plan_path`) so the orchestrator and PRD generator can find them.
"""
data = _load_approved_tasks()
for p in data["projects"]:
@@ -231,6 +292,8 @@ def _ralph_propose(slug: str, description: str) -> str:
"name": slug,
"description": description,
"status": "pending",
"planning_session_id": None,
"final_plan_path": None,
"proposed_at": datetime.now(timezone.utc).isoformat(),
"approved_at": None,
"started_at": None,
@@ -371,3 +434,155 @@ def _get_channel_config(channel_id: str) -> dict | None:
if ch.get("id") == channel_id:
return ch
return None
# ---------------------------------------------------------------------------
# Planning session entry points (W2)
# ---------------------------------------------------------------------------
def start_planning_session(
slug: str,
description: str,
channel_id: str,
adapter_name: str,
on_text: Callable[[str], None] | None = None,
) -> str:
"""Begin a conversational planning session for `slug` on this channel.
Updates approved-tasks.json: status `planning`, `planning_session_id` set.
Returns the first response text from the planning agent — the adapter
will display it and the user replies in the same channel.
"""
data = _load_approved_tasks()
# Locate or create the project entry.
entry = None
for p in data["projects"]:
if p["name"].lower() == slug.lower():
entry = p
break
if entry is None:
entry = {
"name": slug,
"description": description,
"status": "pending",
"planning_session_id": None,
"final_plan_path": None,
"proposed_at": datetime.now(timezone.utc).isoformat(),
"approved_at": None,
"started_at": None,
"pid": None,
}
data["projects"].append(entry)
# Kick off orchestrator (this can take ~60s on first turn — caller should
# have already shown a "Echo se gândește..." indicator).
try:
session, first_response = PlanningOrchestrator.start(
slug=slug,
description=description,
channel_id=channel_id,
adapter=adapter_name or "echo",
on_text=on_text,
)
except Exception as e:
log.error("Planning session start failed for %s: %s", slug, e)
return f"Planning blocat: {e}\n\nÎncearcă din nou cu /plan {slug} <descriere>."
entry["status"] = "planning"
entry["planning_session_id"] = session.planning_session_id
if not entry.get("description"):
entry["description"] = description
_save_approved_tasks(data)
return first_response
def _revert_status_for_slug(slug: str, to: str = "pending") -> None:
"""Revert a project's status (planning → `to`) given its slug."""
if not slug:
return
data = _load_approved_tasks()
changed = False
for p in data["projects"]:
if p["name"].lower() == slug.lower() and p.get("status") == "planning":
p["status"] = to
p["planning_session_id"] = None
changed = True
break
if changed:
_save_approved_tasks(data)
def _approve_from_planning(channel_id: str, adapter_name: str) -> str:
"""User clicked 'Dau drumul' inside an active planning session.
Promotes status `planning` → `approved` and clears planning state.
Returns confirmation text.
"""
state = get_planning_state(adapter_name, channel_id)
if not state:
return "Nu există o sesiune de planning activă."
slug = state.get("slug")
if not slug:
return "Sesiunea de planning nu are slug — anulează cu /cancel și ia-o de la capăt."
data = _load_approved_tasks()
final_plan_path = state.get("final_plan_path") or str(
PlanningOrchestrator.final_plan_path(slug)
)
found = False
for p in data["projects"]:
if p["name"].lower() == slug.lower():
p["status"] = "approved"
p["approved_at"] = datetime.now(timezone.utc).isoformat()
p["planning_session_id"] = None
p["final_plan_path"] = final_plan_path
found = True
break
if not found:
return f"Proiectul `{slug}` lipsește din approved-tasks.json. Anulează cu /cancel."
_save_approved_tasks(data)
clear_planning_state(adapter_name, channel_id)
return (
f"✅ Aprobat: `{slug}`. Ralph începe la 23:00.\n"
f" Plan: `{final_plan_path}`"
)
# Public helpers — re-exported for adapter wiring.
def planning_state_for(channel_id: str, adapter_name: str) -> dict | None:
"""Return current planning state for (adapter, channel) — adapter helper."""
return get_planning_state(adapter_name, channel_id)
def planning_advance(
channel_id: str,
adapter_name: str,
on_text: Callable[[str], None] | None = None,
) -> tuple[str, bool]:
"""Advance the planning pipeline by one phase.
Returns (response_text, completed_bool).
"""
_session, text, completed = PlanningOrchestrator.advance(
adapter_name, channel_id, on_text=on_text,
)
return text, completed
def planning_cancel(channel_id: str, adapter_name: str) -> str:
"""Cancel an active planning session and revert project status."""
state = get_planning_state(adapter_name, channel_id)
if not state:
return "Nu era nicio sesiune de planning activă."
slug = state.get("slug")
PlanningOrchestrator.cancel(adapter_name, channel_id)
if slug:
_revert_status_for_slug(slug, to="pending")
return "Planning anulat. Status revenit la pending."
def planning_approve(channel_id: str, adapter_name: str) -> str:
"""Promote planning → approved (e.g. button click 'Dau drumul')."""
return _approve_from_planning(channel_id, adapter_name)