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:
217
src/router.py
217
src/router.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user