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:
67
prompts/planning_agent.md
Normal file
67
prompts/planning_agent.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Echo planning agent — system prompt
|
||||||
|
|
||||||
|
Ești **Echo**, asistentul lui Marius, în rol de **agent de planning conversational**. Marius
|
||||||
|
te-a chemat să porți cu el o conversație multi-fază despre un feature, până se naște un plan
|
||||||
|
implementabil. La final, tu (sau o fază ulterioară) scrii `final-plan.md` în repo-ul țintă,
|
||||||
|
iar Ralph îl execută noaptea pe stories.
|
||||||
|
|
||||||
|
## Context curent
|
||||||
|
|
||||||
|
- **Slug proiect:** `{slug}`
|
||||||
|
- **Descriere inițială:** {description}
|
||||||
|
- **Faza curentă:** `{phase}`
|
||||||
|
- **Repo țintă (CWD):** `~/workspace/{slug}/`
|
||||||
|
- **Artefacte gstack anterioare:** `~/.gstack/projects/{slug}/` (citește înainte să întrebi
|
||||||
|
lucruri pe care alte faze le-au lămurit deja)
|
||||||
|
- **Output final:** `~/workspace/{slug}/scripts/ralph/final-plan.md`
|
||||||
|
|
||||||
|
## Voce / ton
|
||||||
|
|
||||||
|
Cald + colaborativ, ca un coleg cu care construiești ceva. „Hai să...", „ce-ți dorești", „noi"
|
||||||
|
— niciodată „Please provide", „Submit", „Approve". Răspunde în limba lui Marius (română default;
|
||||||
|
dacă scrie EN, mergi EN). Concis: 3-6 propoziții per turn, nu eseuri.
|
||||||
|
|
||||||
|
## Cum coordonezi cu skill-urile gstack
|
||||||
|
|
||||||
|
Faza curentă e numele unui skill gstack (`/office-hours`, `/plan-ceo-review`,
|
||||||
|
`/plan-eng-review`, `/plan-design-review`). Când primești prima invocare a fazei, urmează skill-ul
|
||||||
|
ca de obicei — el îți dă structura. Nu re-rula skill-ul în interiorul aceleiași sesiuni decât
|
||||||
|
dacă Marius cere explicit.
|
||||||
|
|
||||||
|
Fiecare fază rulează într-un **subprocess Claude separat** (fresh `claude -p`). Sesiunea
|
||||||
|
precedentă a salvat un artifact pe disc (`~/.gstack/projects/{slug}/...`); citește-l ca să nu îl
|
||||||
|
întrebi pe Marius lucruri lămurite deja.
|
||||||
|
|
||||||
|
## Reguli de output
|
||||||
|
|
||||||
|
1. **Întrebări pentru Marius** — pune-i 1–3 întrebări la rând, nu 10. AskUserQuestion gstack se
|
||||||
|
serializează ca text simplu — nu te bloca în tool-use când ești în `-p` mode.
|
||||||
|
2. **Marker de progres** — când consideri faza completă în mintea ta, închide turnul cu o
|
||||||
|
linie pe ultim rand:
|
||||||
|
```
|
||||||
|
PHASE_STATUS: ready_to_advance
|
||||||
|
```
|
||||||
|
Echo (orchestratorul) o citește și îi prezintă lui Marius butonul „Continuă faza".
|
||||||
|
Dacă mai ai nevoie de input, închide cu `PHASE_STATUS: needs_input`.
|
||||||
|
3. **Artifact pe disc** — la sfârșitul fazei tale, scrie sau actualizează artifactul în
|
||||||
|
`~/.gstack/projects/{slug}/{user}-{phase}-...md` conform convenției skill-ului. Nu inventa
|
||||||
|
path-uri noi — folosește exact ce skill-ul gstack creează implicit.
|
||||||
|
4. **Final plan** — în ultima fază (sau când Marius spune explicit „gata"), scrie
|
||||||
|
`~/workspace/{slug}/scripts/ralph/final-plan.md` cu secțiunile:
|
||||||
|
- Context (de ce această schimbare)
|
||||||
|
- Architecture overview
|
||||||
|
- User stories preliminare (Ralph PRD generator le va structura ulterior)
|
||||||
|
- Implementation hints
|
||||||
|
- Verification approach (smoke tests, ce gates relevante)
|
||||||
|
5. **Niciodată nu rula** comenzi destructive fără confirmare. Nu modifica fișiere în afara
|
||||||
|
`~/workspace/{slug}/` și `~/.gstack/projects/{slug}/`.
|
||||||
|
|
||||||
|
## Granițe
|
||||||
|
|
||||||
|
- Nu ai voie să atingi `src/router.py`, `src/claude_session.py`, `src/planning_session.py`,
|
||||||
|
`src/planning_orchestrator.py` sau alte fișiere core din `echo-core` — chiar dacă Marius îți
|
||||||
|
cere ceva care ar implica asta, întoarce-te la el cu „asta e core Echo, fac eu pe master".
|
||||||
|
- Nu inventa decizii arhitecturale fără să ai semnal de la Marius. Dacă te blochează lipsă de
|
||||||
|
context, întreabă-l pe el direct.
|
||||||
|
- Cost / rate-limit: Marius e pe subscription Anthropic, deci ignoră US$. Dar ține-te scurt —
|
||||||
|
fiecare turn consumă rate-limit budget.
|
||||||
@@ -23,8 +23,17 @@ from src.router import (
|
|||||||
_ralph_status,
|
_ralph_status,
|
||||||
_ralph_stop,
|
_ralph_stop,
|
||||||
_load_approved_tasks,
|
_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")
|
logger = logging.getLogger("echo-core.discord")
|
||||||
_security_log = logging.getLogger("echo-core.security")
|
_security_log = logging.getLogger("echo-core.security")
|
||||||
@@ -985,6 +994,67 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await interaction.response.send_message(_ralph_stop(slug))
|
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 ---
|
# --- Events ---
|
||||||
|
|
||||||
@client.event
|
@client.event
|
||||||
@@ -1029,6 +1099,7 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
response, _is_cmd = await asyncio.to_thread(
|
response, _is_cmd = await asyncio.to_thread(
|
||||||
route_message, channel_id, user_id, text,
|
route_message, channel_id, user_id, text,
|
||||||
on_text=on_text,
|
on_text=on_text,
|
||||||
|
adapter_name="discord",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send the final combined response if no intermediates
|
# Only send the final combined response if no intermediates
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ from src.router import (
|
|||||||
_ralph_propose,
|
_ralph_propose,
|
||||||
_ralph_status,
|
_ralph_status,
|
||||||
_ralph_stop,
|
_ralph_stop,
|
||||||
|
planning_advance,
|
||||||
|
planning_approve,
|
||||||
|
planning_cancel,
|
||||||
|
start_planning_session,
|
||||||
)
|
)
|
||||||
|
from src.planning_session import is_in_planning
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -168,7 +173,49 @@ class RalphProjectView(discord.ui.View):
|
|||||||
text = _ralph_stop(self.slug)
|
text = _ralph_stop(self.slug)
|
||||||
await interaction.followup.send(text, ephemeral=True)
|
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:
|
async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
view = RalphRootView()
|
view = RalphRootView()
|
||||||
@@ -231,6 +278,80 @@ class _CloseButton(discord.ui.Button):
|
|||||||
await interaction.edit_original_response(content="Închis.", view=None)
|
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):
|
class RalphRootView(discord.ui.View):
|
||||||
"""Landing view: workspace projects with status emoji + refresh + close."""
|
"""Landing view: workspace projects with status emoji + refresh + close."""
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ from src.router import (
|
|||||||
_ralph_approve,
|
_ralph_approve,
|
||||||
_ralph_status,
|
_ralph_status,
|
||||||
_ralph_stop,
|
_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")
|
WORKSPACE_DIR = Path("/home/moltbot/workspace")
|
||||||
ADAPTER_NAME = "telegram"
|
ADAPTER_NAME = "telegram"
|
||||||
@@ -408,19 +413,42 @@ def _build_ralph_project_keyboard(slug: str) -> InlineKeyboardMarkup:
|
|||||||
return InlineKeyboardMarkup([
|
return InlineKeyboardMarkup([
|
||||||
[
|
[
|
||||||
InlineKeyboardButton("➕ Propune feature", callback_data=f"ralph:propose:{slug}"),
|
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("👁 Vezi PRD", callback_data=f"ralph:prd:{slug}"),
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton("📊 Status", callback_data=f"ralph:status:{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("🛑 Stop", callback_data=f"ralph:stop:{slug}"),
|
||||||
|
],
|
||||||
|
[
|
||||||
InlineKeyboardButton("🔙 Înapoi", callback_data="ralph:menu"),
|
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:
|
def _render_ralph_root_summary() -> str:
|
||||||
try:
|
try:
|
||||||
data = _load_approved_tasks()
|
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)
|
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:
|
async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Handle inline keyboard callbacks for Ralph (pattern ^ralph:)."""
|
"""Handle inline keyboard callbacks for Ralph (pattern ^ralph:)."""
|
||||||
query = update.callback_query
|
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)
|
await context.bot.send_message(chat_id=int(chat_id), text=result)
|
||||||
return
|
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 ---
|
# --- 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("l", cmd_ralph_l))
|
||||||
app.add_handler(CommandHandler("k", cmd_ralph_k))
|
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
|
# Fast commands
|
||||||
app.add_handler(CommandHandler("email", cmd_email))
|
app.add_handler(CommandHandler("email", cmd_email))
|
||||||
app.add_handler(CommandHandler("emailsend", cmd_emailsend))
|
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("a", "Ralph: approve project for tonight"),
|
||||||
BotCommand("l", "Ralph: list projects status"),
|
BotCommand("l", "Ralph: list projects status"),
|
||||||
BotCommand("k", "Ralph: stop running project"),
|
BotCommand("k", "Ralph: stop running project"),
|
||||||
|
BotCommand("plan", "Planning conversational pentru un proiect"),
|
||||||
|
BotCommand("cancel", "Anulează planning în curs"),
|
||||||
])
|
])
|
||||||
|
|
||||||
app.post_init = post_init
|
app.post_init = post_init
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def _run_claude(
|
|||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
timeout: int,
|
timeout: int,
|
||||||
on_text: Callable[[str], None] | None = None,
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
cwd: Path | str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run a Claude CLI command and return parsed output.
|
"""Run a Claude CLI command and return parsed output.
|
||||||
|
|
||||||
@@ -237,6 +238,10 @@ def _run_claude(
|
|||||||
If *on_text* is provided it is called with each intermediate text block
|
If *on_text* is provided it is called with each intermediate text block
|
||||||
as soon as it arrives (before the process finishes), enabling real-time
|
as soon as it arrives (before the process finishes), enabling real-time
|
||||||
streaming to adapters.
|
streaming to adapters.
|
||||||
|
|
||||||
|
*cwd* — optional working directory override (default: PROJECT_ROOT).
|
||||||
|
Used by PlanningSession to scope the subprocess to ``~/workspace/<slug>/``
|
||||||
|
so artifacts land in the target repo.
|
||||||
"""
|
"""
|
||||||
if not shutil.which(CLAUDE_BIN):
|
if not shutil.which(CLAUDE_BIN):
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
@@ -250,7 +255,7 @@ def _run_claude(
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env=_safe_env(),
|
env=_safe_env(),
|
||||||
cwd=PROJECT_ROOT,
|
cwd=str(cwd) if cwd else PROJECT_ROOT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Watchdog thread: kill the process if it exceeds the timeout
|
# Watchdog thread: kill the process if it exceeds the timeout
|
||||||
@@ -346,6 +351,10 @@ def _run_claude(
|
|||||||
"duration_ms": result_obj.get("duration_ms", 0),
|
"duration_ms": result_obj.get("duration_ms", 0),
|
||||||
"num_turns": result_obj.get("num_turns", 0),
|
"num_turns": result_obj.get("num_turns", 0),
|
||||||
"intermediate_count": intermediate_count,
|
"intermediate_count": intermediate_count,
|
||||||
|
# Surface subtype/is_error for callers that retry on `error_max_turns`
|
||||||
|
# (PlanningSession does this — spike findings recommended retry strategy).
|
||||||
|
"subtype": result_obj.get("subtype", ""),
|
||||||
|
"is_error": bool(result_obj.get("is_error", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
299
src/planning_orchestrator.py
Normal file
299
src/planning_orchestrator.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""PlanningOrchestrator — multi-phase planning coordinator.
|
||||||
|
|
||||||
|
Sequences `/office-hours → /plan-ceo-review → /plan-eng-review →
|
||||||
|
/plan-design-review` (last only when description hints at UI scope), each in a
|
||||||
|
**fresh subprocess** (per W2 plan + spike findings). Phases coordinate via
|
||||||
|
disk artifacts (gstack convention: `~/.gstack/projects/<slug>/...`).
|
||||||
|
|
||||||
|
API used by router/adapters:
|
||||||
|
|
||||||
|
PlanningOrchestrator.start(slug, description, channel_id, adapter)
|
||||||
|
→ (session, first_response_text)
|
||||||
|
|
||||||
|
PlanningOrchestrator.respond(adapter, channel_id, message)
|
||||||
|
→ (session, response_text, phase_ready: bool)
|
||||||
|
|
||||||
|
PlanningOrchestrator.advance(adapter, channel_id)
|
||||||
|
→ (session, first_response_text, completed: bool)
|
||||||
|
# completed=True when no further phase remains; final-plan stub
|
||||||
|
# written by the orchestrator if the planning agent didn't.
|
||||||
|
|
||||||
|
PlanningOrchestrator.cancel(adapter, channel_id)
|
||||||
|
→ bool
|
||||||
|
|
||||||
|
PlanningOrchestrator.has_ui_scope(description) → bool
|
||||||
|
|
||||||
|
The orchestrator writes the final-plan stub path even if planning agent did
|
||||||
|
not (so PRD generator always has something to read in W3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from src.planning_session import (
|
||||||
|
GSTACK_PROJECTS_ROOT,
|
||||||
|
PHASE_READY_MARKER,
|
||||||
|
WORKSPACE_ROOT,
|
||||||
|
PlanningSession,
|
||||||
|
_load_planning_state,
|
||||||
|
_save_planning_state,
|
||||||
|
_channel_key,
|
||||||
|
clear_planning_state,
|
||||||
|
get_planning_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ordered phase pipeline. The design phase is appended only when the
|
||||||
|
# description hints at UI scope (heuristic — see has_ui_scope).
|
||||||
|
BASE_PHASES = ["/office-hours", "/plan-ceo-review", "/plan-eng-review"]
|
||||||
|
DESIGN_PHASE = "/plan-design-review"
|
||||||
|
|
||||||
|
UI_HINT_PATTERN = re.compile(
|
||||||
|
r"\b(ui|ux|frontend|design|button|page|css|html|interfa[țt]?[aăă]?|"
|
||||||
|
r"layout|component|view|dashboard|modal|form|screen|"
|
||||||
|
# Romanian variants
|
||||||
|
r"pagin[ăa]|buton|ecran|formular)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_ui_scope(description: str) -> bool:
|
||||||
|
"""Cheap heuristic — adds /plan-design-review when description mentions UI."""
|
||||||
|
return bool(UI_HINT_PATTERN.search(description or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _phases_for(description: str) -> list[str]:
|
||||||
|
phases = list(BASE_PHASES)
|
||||||
|
if has_ui_scope(description):
|
||||||
|
phases.append(DESIGN_PHASE)
|
||||||
|
return phases
|
||||||
|
|
||||||
|
|
||||||
|
def _final_plan_path(slug: str) -> Path:
|
||||||
|
return WORKSPACE_ROOT / slug / "scripts" / "ralph" / "final-plan.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_final_plan_dir(slug: str) -> Path:
|
||||||
|
target = _final_plan_path(slug)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orchestrator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningOrchestrator:
|
||||||
|
"""Stateless coordinator — all state is in `sessions/planning.json`."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def start(
|
||||||
|
slug: str,
|
||||||
|
description: str,
|
||||||
|
channel_id: str,
|
||||||
|
adapter: str = "echo",
|
||||||
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
) -> tuple[PlanningSession, str]:
|
||||||
|
"""Begin planning at phase 0 (`/office-hours`). Persists state.
|
||||||
|
|
||||||
|
Returns (PlanningSession, first_response_text).
|
||||||
|
"""
|
||||||
|
phases = _phases_for(description)
|
||||||
|
first_phase = phases[0]
|
||||||
|
log.info(
|
||||||
|
"planning.start slug=%s adapter=%s channel=%s phases=%s",
|
||||||
|
slug, adapter, channel_id, phases,
|
||||||
|
)
|
||||||
|
# Wipe any prior state for this channel (start fresh).
|
||||||
|
clear_planning_state(adapter, channel_id)
|
||||||
|
|
||||||
|
session = PlanningSession.start(
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
phase=first_phase,
|
||||||
|
channel_id=channel_id,
|
||||||
|
adapter=adapter,
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
# Stash phase plan into disk state so advance() knows the pipeline.
|
||||||
|
data = _load_planning_state()
|
||||||
|
key = _channel_key(adapter, channel_id)
|
||||||
|
if key in data:
|
||||||
|
data[key]["phases_planned"] = phases
|
||||||
|
data[key]["phase_index"] = 0
|
||||||
|
data[key]["phases_completed"] = []
|
||||||
|
_save_planning_state(data)
|
||||||
|
return session, session.last_response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def respond(
|
||||||
|
adapter: str,
|
||||||
|
channel_id: str,
|
||||||
|
message: str,
|
||||||
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
) -> tuple[PlanningSession | None, str, bool]:
|
||||||
|
"""Forward `message` into the active phase via `--resume`.
|
||||||
|
|
||||||
|
Returns (session, response_text, phase_ready).
|
||||||
|
`phase_ready=True` means the planning agent emitted PHASE_READY_MARKER
|
||||||
|
— the adapter should surface a "Continuă faza" / "Finalizează" button.
|
||||||
|
"""
|
||||||
|
session = PlanningSession.from_state(adapter, channel_id)
|
||||||
|
if session is None:
|
||||||
|
return None, "Nu există o sesiune de planning activă pe acest canal.", False
|
||||||
|
text = session.respond(message, on_text=on_text)
|
||||||
|
return session, text, session.is_phase_ready()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def advance(
|
||||||
|
adapter: str,
|
||||||
|
channel_id: str,
|
||||||
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
) -> tuple[PlanningSession | None, str, bool]:
|
||||||
|
"""Move to the next phase (fresh subprocess). Returns (session, text, completed).
|
||||||
|
|
||||||
|
If no more phases remain, writes a final-plan.md stub (if the agent
|
||||||
|
didn't) and returns (last_session, summary_text, completed=True).
|
||||||
|
"""
|
||||||
|
state = get_planning_state(adapter, channel_id)
|
||||||
|
if not state:
|
||||||
|
return None, "Nu există o sesiune de planning activă.", False
|
||||||
|
|
||||||
|
phases = state.get("phases_planned") or _phases_for(state.get("description", ""))
|
||||||
|
completed = list(state.get("phases_completed") or [])
|
||||||
|
current_phase = state.get("phase")
|
||||||
|
if current_phase and current_phase not in completed:
|
||||||
|
completed.append(current_phase)
|
||||||
|
|
||||||
|
# Find next phase
|
||||||
|
try:
|
||||||
|
cur_idx = phases.index(current_phase) if current_phase in phases else -1
|
||||||
|
except ValueError:
|
||||||
|
cur_idx = -1
|
||||||
|
next_idx = cur_idx + 1
|
||||||
|
|
||||||
|
slug = state["slug"]
|
||||||
|
description = state.get("description", "")
|
||||||
|
|
||||||
|
if next_idx >= len(phases):
|
||||||
|
# Pipeline complete — ensure final-plan.md exists, return summary.
|
||||||
|
target = _ensure_final_plan_dir(slug)
|
||||||
|
if not target.exists():
|
||||||
|
stub = _build_final_plan_stub(slug, description, completed, state)
|
||||||
|
target.write_text(stub, encoding="utf-8")
|
||||||
|
log.info("planning.advance wrote final-plan stub: %s", target)
|
||||||
|
# Persist completion marker but keep state so adapter can show
|
||||||
|
# "Dau drumul tonight?" buttons.
|
||||||
|
data = _load_planning_state()
|
||||||
|
key = _channel_key(adapter, channel_id)
|
||||||
|
if key in data:
|
||||||
|
data[key]["phases_completed"] = completed
|
||||||
|
data[key]["phase"] = "__complete__"
|
||||||
|
data[key]["final_plan_path"] = str(target)
|
||||||
|
_save_planning_state(data)
|
||||||
|
session = PlanningSession.from_state(adapter, channel_id)
|
||||||
|
summary = _summary_text(slug, completed, target)
|
||||||
|
return session, summary, True
|
||||||
|
|
||||||
|
next_phase = phases[next_idx]
|
||||||
|
log.info(
|
||||||
|
"planning.advance slug=%s adapter=%s channel=%s %s → %s",
|
||||||
|
slug, adapter, channel_id, current_phase, next_phase,
|
||||||
|
)
|
||||||
|
# Fresh subprocess for the next phase. Phase coordinates with prior
|
||||||
|
# phase via gstack disk artifacts (~/.gstack/projects/<slug>/).
|
||||||
|
session = PlanningSession.start(
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
phase=next_phase,
|
||||||
|
channel_id=channel_id,
|
||||||
|
adapter=adapter,
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
data = _load_planning_state()
|
||||||
|
key = _channel_key(adapter, channel_id)
|
||||||
|
if key in data:
|
||||||
|
data[key]["phases_completed"] = completed
|
||||||
|
data[key]["phase_index"] = next_idx
|
||||||
|
data[key]["phases_planned"] = phases
|
||||||
|
_save_planning_state(data)
|
||||||
|
return session, session.last_response, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cancel(adapter: str, channel_id: str) -> bool:
|
||||||
|
"""Drop planning state. Returns True if anything was cleared."""
|
||||||
|
return clear_planning_state(adapter, channel_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def final_plan_path(slug: str) -> Path:
|
||||||
|
return _final_plan_path(slug)
|
||||||
|
|
||||||
|
# Re-exported for convenience.
|
||||||
|
has_ui_scope = staticmethod(has_ui_scope)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_text(slug: str, completed_phases: list[str], plan_path: Path) -> str:
|
||||||
|
phases_str = " → ".join(completed_phases) if completed_phases else "(nicio fază)"
|
||||||
|
return (
|
||||||
|
f"✅ **Plan gata pentru `{slug}`**\n\n"
|
||||||
|
f"Faze rulate: {phases_str}\n"
|
||||||
|
f"Plan salvat: `{plan_path}`\n\n"
|
||||||
|
"Apasă **Dau drumul tonight** ca Ralph să-l implementeze la 23:00, "
|
||||||
|
"sau **Anulează** dacă vrei să mai gândim."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_final_plan_stub(
|
||||||
|
slug: str, description: str, completed_phases: list[str], state: dict
|
||||||
|
) -> str:
|
||||||
|
"""Emit a minimal final-plan.md when the planning agent didn't write one.
|
||||||
|
|
||||||
|
Captures what we know so PRD generator (W3) has something concrete to read.
|
||||||
|
"""
|
||||||
|
phases_lines = "\n".join(f"- `{p}`" for p in completed_phases) or "- (none)"
|
||||||
|
last_excerpt = (state.get("last_text_excerpt") or "").strip()
|
||||||
|
last_block = (
|
||||||
|
f"\n\n## Last agent output excerpt\n\n```\n{last_excerpt[:2000]}\n```"
|
||||||
|
if last_excerpt
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return f"""# Final plan — {slug}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
{description}
|
||||||
|
|
||||||
|
## Phases completed
|
||||||
|
|
||||||
|
{phases_lines}
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
(To be filled by planning agent. Stub written by PlanningOrchestrator because the
|
||||||
|
agent didn't write its own `final-plan.md` before pipeline completion.)
|
||||||
|
|
||||||
|
## User stories preliminare
|
||||||
|
|
||||||
|
(Stub. Ralph PRD generator will infer concrete stories from this plan + repo state.)
|
||||||
|
|
||||||
|
## Implementation hints
|
||||||
|
|
||||||
|
(Stub.)
|
||||||
|
|
||||||
|
## Verification approach
|
||||||
|
|
||||||
|
- typecheck + lint + tests pe modulele atinse
|
||||||
|
- smart gates Ralph pe tags inferred per story
|
||||||
|
{last_block}
|
||||||
|
"""
|
||||||
495
src/planning_session.py
Normal file
495
src/planning_session.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
"""PlanningSession — Claude CLI wrapper for conversational planning phases.
|
||||||
|
|
||||||
|
Per the Echo Core conversational planning agent plan (W2), this is intentionally
|
||||||
|
a SEPARATE class from the chat session — NOT a `mode=string` parameter on
|
||||||
|
`ClaudeSession`. The plan calls it "PlanningSession(ClaudeSession) ca SUBCLASĂ".
|
||||||
|
Since `claude_session.py` exposes module-level functions (not a class) we
|
||||||
|
implement PlanningSession as a sibling class that REUSES the shared subprocess
|
||||||
|
helpers (`_run_claude`, `_safe_env`, `CLAUDE_BIN`, `SESSIONS_DIR`) but keeps:
|
||||||
|
|
||||||
|
- its own state file (`sessions/planning.json`)
|
||||||
|
- its own system prompt (`prompts/planning_agent.md`)
|
||||||
|
- per-slug working directory (`~/workspace/<slug>/`)
|
||||||
|
- `--add-dir` flags for skills + gstack project artifacts
|
||||||
|
- `--max-turns 20` default with retry on `error_max_turns`
|
||||||
|
|
||||||
|
Spike findings (`tasks/spike-planning-findings.md`):
|
||||||
|
- `claude -p '/skill'` → text serialization of AskUserQuestion. ✅
|
||||||
|
- `claude --resume <id> -p '<reply>'` round-trip preserves context. ✅
|
||||||
|
- Complex prompts can blow turn budget → MUST handle `error_max_turns`.
|
||||||
|
- Cost ~ $0.5–1.1/turn Opus 4.7 1M; Marius on subscription so ignore USD.
|
||||||
|
|
||||||
|
Architectural decisions captured for the W2 commit message:
|
||||||
|
1. Separate class (not mode parameter) — clean separation, easy to remove
|
||||||
|
planning entirely without touching chat session.
|
||||||
|
2. Fresh subprocess PER skill phase, NOT a single resumed session — phases
|
||||||
|
coordinate via disk artifacts (gstack convention:
|
||||||
|
`~/.gstack/projects/<slug>/{user}-{branch}-{phase}-*.md`).
|
||||||
|
3. State per `(adapter, channel)` keyed string — same convention as
|
||||||
|
`claude_session.active.json`. Re-resume on restart is supported via
|
||||||
|
`claude --resume <stored_id>`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from src.claude_session import (
|
||||||
|
CLAUDE_BIN,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
SESSIONS_DIR,
|
||||||
|
_run_claude,
|
||||||
|
_safe_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_invoke_log = logging.getLogger("echo-core.invoke")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PLANNING_STATE_FILE = SESSIONS_DIR / "planning.json"
|
||||||
|
PROMPTS_DIR = PROJECT_ROOT / "prompts"
|
||||||
|
PLANNING_PROMPT_FILE = PROMPTS_DIR / "planning_agent.md"
|
||||||
|
|
||||||
|
# Roots scoped into each planning subprocess via --add-dir
|
||||||
|
WORKSPACE_ROOT = Path("/home/moltbot/workspace")
|
||||||
|
GSTACK_PROJECTS_ROOT = Path.home() / ".gstack" / "projects"
|
||||||
|
SKILLS_ROOT = Path.home() / ".claude" / "skills"
|
||||||
|
|
||||||
|
# Spike: prompts deep-tool-use can blow small budgets; 20 default with retry.
|
||||||
|
DEFAULT_MAX_TURNS = 20
|
||||||
|
RETRY_MAX_TURNS = 30 # boost on `error_max_turns`
|
||||||
|
DEFAULT_TIMEOUT = 600 # seconds — planning turns are slower than chat
|
||||||
|
|
||||||
|
# Marker the planning agent emits when a phase is conceptually done.
|
||||||
|
# Orchestrator scans for this to decide when to surface the "Continuă faza"
|
||||||
|
# button. Convention pinned in `prompts/planning_agent.md`.
|
||||||
|
PHASE_READY_MARKER = "PHASE_STATUS: ready_to_advance"
|
||||||
|
PHASE_NEEDS_INPUT_MARKER = "PHASE_STATUS: needs_input"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Disk state — sessions/planning.json
|
||||||
|
# Schema:
|
||||||
|
# {
|
||||||
|
# "<adapter>:<channel_id>": {
|
||||||
|
# "slug": "...",
|
||||||
|
# "description": "...",
|
||||||
|
# "phase": "/office-hours" | "/plan-ceo-review" | ...,
|
||||||
|
# "phases_completed": ["/office-hours", ...],
|
||||||
|
# "session_id": "<claude session uuid>",
|
||||||
|
# "planning_session_id": "<echo internal uuid>",
|
||||||
|
# "started_at": "...",
|
||||||
|
# "updated_at": "...",
|
||||||
|
# "last_text_excerpt": "...", # 500 char excerpt for debugging
|
||||||
|
# "last_subtype": "success" | "error_max_turns" | ...,
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_key(adapter: str, channel_id: str) -> str:
|
||||||
|
return f"{adapter}:{channel_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_planning_state() -> dict:
|
||||||
|
"""Load planning sessions from disk. Returns {} if missing or empty."""
|
||||||
|
try:
|
||||||
|
text = PLANNING_STATE_FILE.read_text(encoding="utf-8")
|
||||||
|
if not text.strip():
|
||||||
|
return {}
|
||||||
|
return json.loads(text)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_planning_state(data: dict) -> None:
|
||||||
|
"""Atomically write planning sessions via tempfile + os.replace."""
|
||||||
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=SESSIONS_DIR, prefix=".planning_", suffix=".json"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
os.replace(tmp_path, PLANNING_STATE_FILE)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_planning_system_prompt(slug: str, description: str, phase: str) -> str:
|
||||||
|
"""Render `prompts/planning_agent.md` with phase-specific values.
|
||||||
|
|
||||||
|
Returns empty string if the prompt file does not exist (skill-only mode).
|
||||||
|
"""
|
||||||
|
if not PLANNING_PROMPT_FILE.exists():
|
||||||
|
logger.warning(
|
||||||
|
"Planning prompt missing: %s — falling back to skill-only mode.",
|
||||||
|
PLANNING_PROMPT_FILE,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
template = PLANNING_PROMPT_FILE.read_text(encoding="utf-8")
|
||||||
|
# Use simple replacement (NOT format()) — markdown contains literal `{}`
|
||||||
|
# in code blocks which would explode `.format()`.
|
||||||
|
return (
|
||||||
|
template
|
||||||
|
.replace("{slug}", slug)
|
||||||
|
.replace("{description}", description)
|
||||||
|
.replace("{phase}", phase)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PlanningSession class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSession:
|
||||||
|
"""One Claude CLI subprocess scoped to a planning phase.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. ``PlanningSession.start(slug, description, phase, channel, adapter)``
|
||||||
|
— fresh subprocess; first prompt is the skill invocation.
|
||||||
|
2. ``session.respond(message)`` — `claude --resume <session_id>`
|
||||||
|
per user reply. Returns response text + retry hint.
|
||||||
|
3. ``session.is_phase_ready()`` — True when output contains
|
||||||
|
``PHASE_STATUS: ready_to_advance`` (orchestrator advances).
|
||||||
|
4. State persisted in `sessions/planning.json` so restart is recoverable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
slug: str,
|
||||||
|
description: str,
|
||||||
|
phase: str,
|
||||||
|
channel_id: str,
|
||||||
|
adapter: str = "echo",
|
||||||
|
session_id: str | None = None,
|
||||||
|
planning_session_id: str | None = None,
|
||||||
|
):
|
||||||
|
self.slug = slug
|
||||||
|
self.description = description
|
||||||
|
self.phase = phase
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.adapter = adapter
|
||||||
|
self.session_id = session_id
|
||||||
|
self.planning_session_id = planning_session_id or str(uuid.uuid4())
|
||||||
|
self._last_response: str = ""
|
||||||
|
self._last_subtype: str = ""
|
||||||
|
self._last_is_error: bool = False
|
||||||
|
|
||||||
|
# -- working directory & --add-dir scoping ------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cwd(self) -> Path:
|
||||||
|
"""Working directory for the subprocess.
|
||||||
|
|
||||||
|
Uses `~/workspace/<slug>/` if it exists; otherwise falls back to
|
||||||
|
Echo Core repo root (test mode / pre-clone scenarios).
|
||||||
|
"""
|
||||||
|
target = WORKSPACE_ROOT / self.slug
|
||||||
|
if target.is_dir():
|
||||||
|
return target
|
||||||
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
def _add_dirs(self) -> list[str]:
|
||||||
|
"""Build `--add-dir` arguments. Skip dirs that don't exist."""
|
||||||
|
candidates = [
|
||||||
|
SKILLS_ROOT,
|
||||||
|
GSTACK_PROJECTS_ROOT / self.slug,
|
||||||
|
GSTACK_PROJECTS_ROOT, # fallback in case slug-specific dir missing
|
||||||
|
]
|
||||||
|
seen: set[str] = set()
|
||||||
|
flags: list[str] = []
|
||||||
|
for d in candidates:
|
||||||
|
if d.exists() and str(d) not in seen:
|
||||||
|
flags.extend(["--add-dir", str(d)])
|
||||||
|
seen.add(str(d))
|
||||||
|
return flags
|
||||||
|
|
||||||
|
# -- command construction ----------------------------------------------
|
||||||
|
|
||||||
|
def _build_cmd(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
resume: str | None,
|
||||||
|
max_turns: int,
|
||||||
|
with_system_prompt: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
cmd = [CLAUDE_BIN, "-p", prompt]
|
||||||
|
if resume:
|
||||||
|
cmd += ["--resume", resume]
|
||||||
|
cmd += [
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
"--max-turns", str(max_turns),
|
||||||
|
]
|
||||||
|
if with_system_prompt:
|
||||||
|
sys_prompt = build_planning_system_prompt(
|
||||||
|
self.slug, self.description, self.phase
|
||||||
|
)
|
||||||
|
if sys_prompt:
|
||||||
|
cmd += ["--system-prompt", sys_prompt]
|
||||||
|
cmd += self._add_dirs()
|
||||||
|
cmd += ["--dangerously-skip-permissions"]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
# -- subprocess invocation ---------------------------------------------
|
||||||
|
|
||||||
|
def _invoke(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
resume: str | None,
|
||||||
|
timeout: int,
|
||||||
|
max_turns: int,
|
||||||
|
with_system_prompt: bool,
|
||||||
|
on_text: Callable[[str], None] | None,
|
||||||
|
) -> dict:
|
||||||
|
cmd = self._build_cmd(
|
||||||
|
prompt,
|
||||||
|
resume=resume,
|
||||||
|
max_turns=max_turns,
|
||||||
|
with_system_prompt=with_system_prompt,
|
||||||
|
)
|
||||||
|
_t0 = time.monotonic()
|
||||||
|
result = _run_claude(cmd, timeout=timeout, on_text=on_text, cwd=self.cwd)
|
||||||
|
_elapsed = int((time.monotonic() - _t0) * 1000)
|
||||||
|
_invoke_log.info(
|
||||||
|
"planning slug=%s phase=%s adapter=%s channel=%s duration_ms=%d "
|
||||||
|
"tokens_in=%d tokens_out=%d session=%s subtype=%s cost=%.4f",
|
||||||
|
self.slug, self.phase, self.adapter, self.channel_id, _elapsed,
|
||||||
|
result.get("usage", {}).get("input_tokens", 0),
|
||||||
|
result.get("usage", {}).get("output_tokens", 0),
|
||||||
|
(result.get("session_id") or "")[:8],
|
||||||
|
result.get("subtype", ""),
|
||||||
|
float(result.get("total_cost_usd", 0) or 0),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- public API: start/resume ------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def start(
|
||||||
|
cls,
|
||||||
|
slug: str,
|
||||||
|
description: str,
|
||||||
|
phase: str,
|
||||||
|
channel_id: str,
|
||||||
|
adapter: str = "echo",
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
) -> "PlanningSession":
|
||||||
|
"""Kick off a new phase subprocess. First prompt is the skill call.
|
||||||
|
|
||||||
|
Returns a `PlanningSession` with `session_id`, `_last_response` set.
|
||||||
|
Persists state in `sessions/planning.json` keyed by `(adapter, channel_id)`.
|
||||||
|
Retries once with `RETRY_MAX_TURNS` if first run hits `error_max_turns`.
|
||||||
|
"""
|
||||||
|
session = cls(
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
phase=phase,
|
||||||
|
channel_id=channel_id,
|
||||||
|
adapter=adapter,
|
||||||
|
)
|
||||||
|
# Compose initial prompt — skill name + slug + description so the skill
|
||||||
|
# has enough hook to start.
|
||||||
|
initial_prompt = f"{phase} {description}".strip()
|
||||||
|
|
||||||
|
result = session._invoke(
|
||||||
|
initial_prompt,
|
||||||
|
resume=None,
|
||||||
|
timeout=timeout,
|
||||||
|
max_turns=DEFAULT_MAX_TURNS,
|
||||||
|
with_system_prompt=True,
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
# Retry on error_max_turns — spike found this happens with deep tool-use.
|
||||||
|
if result.get("subtype") == "error_max_turns" and not result.get("session_id"):
|
||||||
|
logger.warning(
|
||||||
|
"planning start hit error_max_turns for %s/%s — retrying with %d turns",
|
||||||
|
slug, phase, RETRY_MAX_TURNS,
|
||||||
|
)
|
||||||
|
result = session._invoke(
|
||||||
|
initial_prompt,
|
||||||
|
resume=None,
|
||||||
|
timeout=timeout,
|
||||||
|
max_turns=RETRY_MAX_TURNS,
|
||||||
|
with_system_prompt=True,
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.session_id = result.get("session_id") or None
|
||||||
|
session._last_response = result.get("result", "")
|
||||||
|
session._last_subtype = result.get("subtype", "")
|
||||||
|
session._last_is_error = bool(result.get("is_error", False))
|
||||||
|
session._persist(action="start", cost_usd=float(result.get("total_cost_usd", 0) or 0))
|
||||||
|
return session
|
||||||
|
|
||||||
|
def respond(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Send the user's reply to the running phase session via `--resume`.
|
||||||
|
|
||||||
|
Returns the response text. Updates persistent state.
|
||||||
|
"""
|
||||||
|
if not self.session_id:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PlanningSession.respond called without an active session_id"
|
||||||
|
)
|
||||||
|
wrapped = f"[EXTERNAL CONTENT]\n{message}\n[END EXTERNAL CONTENT]"
|
||||||
|
result = self._invoke(
|
||||||
|
wrapped,
|
||||||
|
resume=self.session_id,
|
||||||
|
timeout=timeout,
|
||||||
|
max_turns=DEFAULT_MAX_TURNS,
|
||||||
|
with_system_prompt=False, # already in session
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
# Retry once on error_max_turns
|
||||||
|
if result.get("subtype") == "error_max_turns":
|
||||||
|
logger.warning(
|
||||||
|
"planning respond hit error_max_turns for %s/%s — retrying",
|
||||||
|
self.slug, self.phase,
|
||||||
|
)
|
||||||
|
result = self._invoke(
|
||||||
|
wrapped,
|
||||||
|
resume=self.session_id,
|
||||||
|
timeout=timeout,
|
||||||
|
max_turns=RETRY_MAX_TURNS,
|
||||||
|
with_system_prompt=False,
|
||||||
|
on_text=on_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_response = result.get("result", "")
|
||||||
|
self._last_subtype = result.get("subtype", "")
|
||||||
|
self._last_is_error = bool(result.get("is_error", False))
|
||||||
|
self._persist(
|
||||||
|
action="respond", cost_usd=float(result.get("total_cost_usd", 0) or 0)
|
||||||
|
)
|
||||||
|
return self._last_response
|
||||||
|
|
||||||
|
# -- introspection ------------------------------------------------------
|
||||||
|
|
||||||
|
def is_phase_ready(self) -> bool:
|
||||||
|
"""True if last response contained the ready-to-advance marker."""
|
||||||
|
return PHASE_READY_MARKER in (self._last_response or "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_response(self) -> str:
|
||||||
|
return self._last_response
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_subtype(self) -> str:
|
||||||
|
return self._last_subtype
|
||||||
|
|
||||||
|
# -- persistence --------------------------------------------------------
|
||||||
|
|
||||||
|
def _persist(self, *, action: str, cost_usd: float = 0.0) -> None:
|
||||||
|
data = _load_planning_state()
|
||||||
|
key = _channel_key(self.adapter, self.channel_id)
|
||||||
|
existing = data.get(key, {})
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
phases_completed = existing.get("phases_completed", [])
|
||||||
|
# If this session changed phase, the orchestrator handles transition;
|
||||||
|
# we just keep our own slot consistent with the current phase.
|
||||||
|
entry = {
|
||||||
|
"slug": self.slug,
|
||||||
|
"description": self.description,
|
||||||
|
"phase": self.phase,
|
||||||
|
"phases_completed": phases_completed,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"planning_session_id": self.planning_session_id,
|
||||||
|
"adapter": self.adapter,
|
||||||
|
"channel_id": self.channel_id,
|
||||||
|
"started_at": existing.get("started_at", now),
|
||||||
|
"updated_at": now,
|
||||||
|
"last_text_excerpt": (self._last_response or "")[:500],
|
||||||
|
"last_subtype": self._last_subtype,
|
||||||
|
"total_cost_usd": (
|
||||||
|
float(existing.get("total_cost_usd") or 0.0) + float(cost_usd or 0.0)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
data[key] = entry
|
||||||
|
_save_planning_state(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_state(cls, adapter: str, channel_id: str) -> "PlanningSession | None":
|
||||||
|
"""Reconstruct a session from `sessions/planning.json` (post-restart)."""
|
||||||
|
data = _load_planning_state()
|
||||||
|
entry = data.get(_channel_key(adapter, channel_id))
|
||||||
|
if not entry or not entry.get("session_id"):
|
||||||
|
return None
|
||||||
|
sess = cls(
|
||||||
|
slug=entry["slug"],
|
||||||
|
description=entry.get("description", ""),
|
||||||
|
phase=entry["phase"],
|
||||||
|
channel_id=channel_id,
|
||||||
|
adapter=adapter,
|
||||||
|
session_id=entry["session_id"],
|
||||||
|
planning_session_id=entry.get("planning_session_id"),
|
||||||
|
)
|
||||||
|
sess._last_subtype = entry.get("last_subtype", "")
|
||||||
|
sess._last_response = entry.get("last_text_excerpt", "")
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level helpers consumed by router/orchestrator/adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_planning_state(adapter: str, channel_id: str) -> dict | None:
|
||||||
|
"""Return persisted planning state for a channel, or None."""
|
||||||
|
return _load_planning_state().get(_channel_key(adapter, channel_id))
|
||||||
|
|
||||||
|
|
||||||
|
def is_in_planning(adapter: str, channel_id: str) -> bool:
|
||||||
|
"""True if there is an active planning session for this channel."""
|
||||||
|
return get_planning_state(adapter, channel_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_planning_state(adapter: str, channel_id: str) -> bool:
|
||||||
|
"""Drop persisted planning state. Returns True if anything was cleared."""
|
||||||
|
data = _load_planning_state()
|
||||||
|
key = _channel_key(adapter, channel_id)
|
||||||
|
if key in data:
|
||||||
|
del data[key]
|
||||||
|
_save_planning_state(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_planning_sessions() -> dict:
|
||||||
|
"""Return all persisted planning sessions (for diagnostics)."""
|
||||||
|
return _load_planning_state()
|
||||||
217
src/router.py
217
src/router.py
@@ -18,6 +18,12 @@ from src.claude_session import (
|
|||||||
set_session_model,
|
set_session_model,
|
||||||
VALID_MODELS,
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,6 +62,57 @@ def route_message(
|
|||||||
"""
|
"""
|
||||||
text = text.strip()
|
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 commands — short form (/p /a /l /k) and legacy aliases (!propose !approve !status !stop)
|
||||||
ralph_response = _try_ralph_dispatch(text, adapter_name=adapter_name)
|
ralph_response = _try_ralph_dispatch(text, adapter_name=adapter_name)
|
||||||
if ralph_response is not None:
|
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:
|
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()
|
data = _load_approved_tasks()
|
||||||
|
|
||||||
for p in data["projects"]:
|
for p in data["projects"]:
|
||||||
@@ -231,6 +292,8 @@ def _ralph_propose(slug: str, description: str) -> str:
|
|||||||
"name": slug,
|
"name": slug,
|
||||||
"description": description,
|
"description": description,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
|
"planning_session_id": None,
|
||||||
|
"final_plan_path": None,
|
||||||
"proposed_at": datetime.now(timezone.utc).isoformat(),
|
"proposed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"approved_at": None,
|
"approved_at": None,
|
||||||
"started_at": None,
|
"started_at": None,
|
||||||
@@ -371,3 +434,155 @@ def _get_channel_config(channel_id: str) -> dict | None:
|
|||||||
if ch.get("id") == channel_id:
|
if ch.get("id") == channel_id:
|
||||||
return ch
|
return ch
|
||||||
return None
|
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)
|
||||||
|
|||||||
243
tests/test_planning_orchestrator.py
Normal file
243
tests/test_planning_orchestrator.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""Tests for src/planning_orchestrator.py — phase pipeline coordinator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import planning_orchestrator, planning_session
|
||||||
|
from src.planning_orchestrator import (
|
||||||
|
BASE_PHASES,
|
||||||
|
DESIGN_PHASE,
|
||||||
|
PlanningOrchestrator,
|
||||||
|
_phases_for,
|
||||||
|
has_ui_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_planning_state(tmp_path, monkeypatch):
|
||||||
|
fake_sessions_dir = tmp_path / "sessions"
|
||||||
|
fake_sessions_dir.mkdir()
|
||||||
|
fake_state = fake_sessions_dir / "planning.json"
|
||||||
|
monkeypatch.setattr(planning_session, "SESSIONS_DIR", fake_sessions_dir)
|
||||||
|
monkeypatch.setattr(planning_session, "PLANNING_STATE_FILE", fake_state)
|
||||||
|
yield fake_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_workspace(tmp_path, monkeypatch):
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "demo").mkdir()
|
||||||
|
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||||
|
monkeypatch.setattr(planning_orchestrator, "WORKSPACE_ROOT", workspace)
|
||||||
|
yield workspace
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_ui_scope / _phases_for
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUiScopeHeuristic:
|
||||||
|
@pytest.mark.parametrize("text,expected", [
|
||||||
|
("redesign UI for the dashboard", True),
|
||||||
|
("add a button on settings page", True),
|
||||||
|
("frontend cleanup", True),
|
||||||
|
("Adaugă filtru genuri pe pagina de game-library", True), # ro
|
||||||
|
("schimbă culoarea butonului de submit", True), # ro
|
||||||
|
("refactor utility helpers", False),
|
||||||
|
("rewrite the database migration scripts", False),
|
||||||
|
("tweak the rate limiter", False),
|
||||||
|
])
|
||||||
|
def test_detects_ui_keywords(self, text, expected):
|
||||||
|
assert has_ui_scope(text) is expected
|
||||||
|
|
||||||
|
def test_phases_for_excludes_design_when_no_ui(self):
|
||||||
|
phases = _phases_for("refactor utility")
|
||||||
|
assert phases == BASE_PHASES
|
||||||
|
assert DESIGN_PHASE not in phases
|
||||||
|
|
||||||
|
def test_phases_for_appends_design_for_ui(self):
|
||||||
|
phases = _phases_for("add login page")
|
||||||
|
assert phases[-1] == DESIGN_PHASE
|
||||||
|
assert phases[: len(BASE_PHASES)] == BASE_PHASES
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orchestrator start / respond / advance / cancel — mock subprocess
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_result(session_id="sess-1", text="hi"):
|
||||||
|
return {
|
||||||
|
"result": text,
|
||||||
|
"session_id": session_id,
|
||||||
|
"usage": {"input_tokens": 1000, "output_tokens": 200},
|
||||||
|
"total_cost_usd": 0.4,
|
||||||
|
"subtype": "success",
|
||||||
|
"is_error": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestratorStart:
|
||||||
|
def test_start_persists_phases_planned(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1"),
|
||||||
|
):
|
||||||
|
sess, first = PlanningOrchestrator.start(
|
||||||
|
"demo", "Add login button", "ch-1", adapter="discord"
|
||||||
|
)
|
||||||
|
assert sess.session_id == "s-1"
|
||||||
|
from src.planning_session import get_planning_state
|
||||||
|
state = get_planning_state("discord", "ch-1")
|
||||||
|
assert state["phase"] == BASE_PHASES[0] # /office-hours
|
||||||
|
# UI scope → design phase included
|
||||||
|
assert state["phases_planned"][-1] == DESIGN_PHASE
|
||||||
|
assert state["phase_index"] == 0
|
||||||
|
assert state["phases_completed"] == []
|
||||||
|
|
||||||
|
def test_start_no_ui_scope_no_design_phase(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1"),
|
||||||
|
):
|
||||||
|
PlanningOrchestrator.start(
|
||||||
|
"demo", "refactor utility helpers", "ch-1", adapter="discord"
|
||||||
|
)
|
||||||
|
from src.planning_session import get_planning_state
|
||||||
|
state = get_planning_state("discord", "ch-1")
|
||||||
|
assert DESIGN_PHASE not in state["phases_planned"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestratorRespond:
|
||||||
|
def test_respond_returns_text_and_phase_ready_marker(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1", text="initial"),
|
||||||
|
):
|
||||||
|
PlanningOrchestrator.start("demo", "Add login button", "ch-1", "discord")
|
||||||
|
|
||||||
|
ready_text = "ok we are done. PHASE_STATUS: ready_to_advance"
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1", text=ready_text),
|
||||||
|
):
|
||||||
|
sess, response, ready = PlanningOrchestrator.respond(
|
||||||
|
"discord", "ch-1", "user reply"
|
||||||
|
)
|
||||||
|
assert response == ready_text
|
||||||
|
assert ready is True
|
||||||
|
assert sess is not None
|
||||||
|
|
||||||
|
def test_respond_returns_none_when_no_state(self, tmp_planning_state):
|
||||||
|
sess, text, ready = PlanningOrchestrator.respond(
|
||||||
|
"discord", "ch-missing", "hi"
|
||||||
|
)
|
||||||
|
assert sess is None
|
||||||
|
assert "Nu există" in text
|
||||||
|
assert ready is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestratorAdvance:
|
||||||
|
def test_advance_starts_next_phase_fresh_subprocess(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
# Phase 1 → office-hours
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1"),
|
||||||
|
):
|
||||||
|
PlanningOrchestrator.start(
|
||||||
|
"demo", "Add login button", "ch-1", "discord"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance → /plan-ceo-review fresh subprocess
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-2", text="ceo phase started"),
|
||||||
|
) as mock_run:
|
||||||
|
sess, text, completed = PlanningOrchestrator.advance(
|
||||||
|
"discord", "ch-1"
|
||||||
|
)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
# Verify the new subprocess has /plan-ceo-review in prompt (NOT --resume)
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "/plan-ceo-review" in cmd[2]
|
||||||
|
assert "--resume" not in cmd
|
||||||
|
assert sess.session_id == "s-2"
|
||||||
|
assert sess.phase == "/plan-ceo-review"
|
||||||
|
assert completed is False
|
||||||
|
|
||||||
|
from src.planning_session import get_planning_state
|
||||||
|
state = get_planning_state("discord", "ch-1")
|
||||||
|
assert "/office-hours" in state["phases_completed"]
|
||||||
|
assert state["phase_index"] == 1
|
||||||
|
|
||||||
|
def test_advance_writes_final_plan_when_pipeline_complete(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
# Manually seed state at the last phase.
|
||||||
|
from src.planning_session import _save_planning_state, _channel_key
|
||||||
|
# Build phase plan with 2 phases for brevity (skip design for non-UI).
|
||||||
|
state = {
|
||||||
|
_channel_key("discord", "ch-1"): {
|
||||||
|
"slug": "demo",
|
||||||
|
"description": "refactor utility",
|
||||||
|
"phase": "/plan-eng-review",
|
||||||
|
"phases_planned": ["/office-hours", "/plan-ceo-review", "/plan-eng-review"],
|
||||||
|
"phase_index": 2,
|
||||||
|
"phases_completed": ["/office-hours", "/plan-ceo-review"],
|
||||||
|
"session_id": "s-eng",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"adapter": "discord",
|
||||||
|
"channel_id": "ch-1",
|
||||||
|
"started_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"updated_at": "2026-04-26T20:30:00+00:00",
|
||||||
|
"last_text_excerpt": "eng review done",
|
||||||
|
"last_subtype": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_save_planning_state(state)
|
||||||
|
|
||||||
|
# Advance with no more phases — should write final-plan stub, no claude call.
|
||||||
|
with patch("src.planning_session._run_claude") as mock_run:
|
||||||
|
sess, text, completed = PlanningOrchestrator.advance(
|
||||||
|
"discord", "ch-1"
|
||||||
|
)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert completed is True
|
||||||
|
plan_path = PlanningOrchestrator.final_plan_path("demo")
|
||||||
|
assert plan_path.exists()
|
||||||
|
content = plan_path.read_text(encoding="utf-8")
|
||||||
|
assert "demo" in content
|
||||||
|
assert "refactor utility" in content
|
||||||
|
# All phases listed
|
||||||
|
assert "/office-hours" in content
|
||||||
|
assert "/plan-ceo-review" in content
|
||||||
|
assert "/plan-eng-review" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrchestratorCancel:
|
||||||
|
def test_cancel_clears_state(self, tmp_planning_state, fake_workspace):
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1"),
|
||||||
|
):
|
||||||
|
PlanningOrchestrator.start("demo", "x", "ch-1", "discord")
|
||||||
|
from src.planning_session import is_in_planning
|
||||||
|
assert is_in_planning("discord", "ch-1") is True
|
||||||
|
assert PlanningOrchestrator.cancel("discord", "ch-1") is True
|
||||||
|
assert is_in_planning("discord", "ch-1") is False
|
||||||
|
|
||||||
|
def test_cancel_returns_false_when_no_state(self, tmp_planning_state):
|
||||||
|
assert PlanningOrchestrator.cancel("discord", "ch-x") is False
|
||||||
278
tests/test_planning_session.py
Normal file
278
tests/test_planning_session.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""Tests for src/planning_session.py — PlanningSession + state persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import planning_session
|
||||||
|
from src.planning_session import (
|
||||||
|
PHASE_NEEDS_INPUT_MARKER,
|
||||||
|
PHASE_READY_MARKER,
|
||||||
|
PlanningSession,
|
||||||
|
_channel_key,
|
||||||
|
build_planning_system_prompt,
|
||||||
|
clear_planning_state,
|
||||||
|
get_planning_state,
|
||||||
|
is_in_planning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_planning_state(tmp_path, monkeypatch):
|
||||||
|
"""Redirect planning state file into a tmp dir for each test."""
|
||||||
|
fake_sessions_dir = tmp_path / "sessions"
|
||||||
|
fake_sessions_dir.mkdir()
|
||||||
|
fake_state = fake_sessions_dir / "planning.json"
|
||||||
|
monkeypatch.setattr(planning_session, "SESSIONS_DIR", fake_sessions_dir)
|
||||||
|
monkeypatch.setattr(planning_session, "PLANNING_STATE_FILE", fake_state)
|
||||||
|
yield fake_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_workspace(tmp_path, monkeypatch):
|
||||||
|
"""Pretend ~/workspace/<slug>/ exists so PlanningSession.cwd resolves."""
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "demo").mkdir()
|
||||||
|
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||||
|
yield workspace
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_planning_system_prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPlanningSystemPrompt:
|
||||||
|
def test_substitutes_slug_phase_description(self):
|
||||||
|
prompt = build_planning_system_prompt(
|
||||||
|
slug="demo", description="Add filter X", phase="/office-hours"
|
||||||
|
)
|
||||||
|
# Even if the prompt template differs, the values must appear at least
|
||||||
|
# once each.
|
||||||
|
assert "demo" in prompt
|
||||||
|
assert "Add filter X" in prompt
|
||||||
|
assert "/office-hours" in prompt
|
||||||
|
|
||||||
|
def test_returns_empty_when_template_missing(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
planning_session, "PLANNING_PROMPT_FILE", tmp_path / "missing.md"
|
||||||
|
)
|
||||||
|
assert build_planning_system_prompt("a", "b", "/x") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# state get/set/clear
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanningState:
|
||||||
|
def test_clear_returns_false_when_absent(self, tmp_planning_state):
|
||||||
|
assert clear_planning_state("discord", "ch-1") is False
|
||||||
|
|
||||||
|
def test_get_returns_none_when_absent(self, tmp_planning_state):
|
||||||
|
assert get_planning_state("discord", "ch-1") is None
|
||||||
|
assert is_in_planning("discord", "ch-1") is False
|
||||||
|
|
||||||
|
def test_persist_and_recover(self, tmp_planning_state, fake_workspace):
|
||||||
|
# Build a session WITHOUT actually invoking claude — call _persist directly.
|
||||||
|
sess = PlanningSession(
|
||||||
|
slug="demo",
|
||||||
|
description="desc",
|
||||||
|
phase="/office-hours",
|
||||||
|
channel_id="ch-1",
|
||||||
|
adapter="discord",
|
||||||
|
session_id="sess-uuid-1",
|
||||||
|
)
|
||||||
|
sess._last_response = "hello world " + PHASE_NEEDS_INPUT_MARKER
|
||||||
|
sess._last_subtype = "success"
|
||||||
|
sess._persist(action="start", cost_usd=0.42)
|
||||||
|
|
||||||
|
assert is_in_planning("discord", "ch-1") is True
|
||||||
|
state = get_planning_state("discord", "ch-1")
|
||||||
|
assert state is not None
|
||||||
|
assert state["slug"] == "demo"
|
||||||
|
assert state["session_id"] == "sess-uuid-1"
|
||||||
|
assert state["phase"] == "/office-hours"
|
||||||
|
assert state["last_subtype"] == "success"
|
||||||
|
assert "hello world" in state["last_text_excerpt"]
|
||||||
|
|
||||||
|
recovered = PlanningSession.from_state("discord", "ch-1")
|
||||||
|
assert recovered is not None
|
||||||
|
assert recovered.slug == "demo"
|
||||||
|
assert recovered.session_id == "sess-uuid-1"
|
||||||
|
assert recovered.phase == "/office-hours"
|
||||||
|
|
||||||
|
assert clear_planning_state("discord", "ch-1") is True
|
||||||
|
assert get_planning_state("discord", "ch-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_phase_ready
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsPhaseReady:
|
||||||
|
def test_returns_true_when_marker_present(self, fake_workspace):
|
||||||
|
sess = PlanningSession("demo", "d", "/x", "ch", session_id="abc")
|
||||||
|
sess._last_response = f"some text {PHASE_READY_MARKER}"
|
||||||
|
assert sess.is_phase_ready() is True
|
||||||
|
|
||||||
|
def test_returns_false_when_marker_absent(self, fake_workspace):
|
||||||
|
sess = PlanningSession("demo", "d", "/x", "ch", session_id="abc")
|
||||||
|
sess._last_response = "some text without marker"
|
||||||
|
assert sess.is_phase_ready() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cwd resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCwd:
|
||||||
|
def test_workspace_dir_used_when_present(self, fake_workspace):
|
||||||
|
sess = PlanningSession("demo", "d", "/x", "ch")
|
||||||
|
assert sess.cwd == fake_workspace / "demo"
|
||||||
|
|
||||||
|
def test_falls_back_to_project_root_when_missing(
|
||||||
|
self, fake_workspace, monkeypatch
|
||||||
|
):
|
||||||
|
sess = PlanningSession("nonexistent-slug", "d", "/x", "ch")
|
||||||
|
# Falls back to PROJECT_ROOT
|
||||||
|
assert sess.cwd == planning_session.PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# command construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildCmd:
|
||||||
|
def test_start_includes_skill_phase_and_max_turns(self, fake_workspace):
|
||||||
|
sess = PlanningSession("demo", "Add filter", "/office-hours", "ch")
|
||||||
|
cmd = sess._build_cmd(
|
||||||
|
"/office-hours Add filter",
|
||||||
|
resume=None,
|
||||||
|
max_turns=20,
|
||||||
|
with_system_prompt=False,
|
||||||
|
)
|
||||||
|
assert cmd[0:3] == [planning_session.CLAUDE_BIN, "-p", "/office-hours Add filter"]
|
||||||
|
assert "--max-turns" in cmd
|
||||||
|
assert "20" in cmd
|
||||||
|
assert "--output-format" in cmd
|
||||||
|
assert "stream-json" in cmd
|
||||||
|
assert "--dangerously-skip-permissions" in cmd
|
||||||
|
# No --resume on a fresh start
|
||||||
|
assert "--resume" not in cmd
|
||||||
|
|
||||||
|
def test_resume_includes_resume_flag(self, fake_workspace):
|
||||||
|
sess = PlanningSession(
|
||||||
|
"demo", "Add filter", "/office-hours", "ch", session_id="abc"
|
||||||
|
)
|
||||||
|
cmd = sess._build_cmd(
|
||||||
|
"user reply",
|
||||||
|
resume="abc",
|
||||||
|
max_turns=20,
|
||||||
|
with_system_prompt=False,
|
||||||
|
)
|
||||||
|
assert "--resume" in cmd
|
||||||
|
assert "abc" in cmd
|
||||||
|
|
||||||
|
def test_with_system_prompt_appends_flag(self, fake_workspace, tmp_path, monkeypatch):
|
||||||
|
# Create a tiny prompt file so build_planning_system_prompt returns text.
|
||||||
|
fake = tmp_path / "planning_agent.md"
|
||||||
|
fake.write_text("phase={phase} slug={slug}", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(planning_session, "PLANNING_PROMPT_FILE", fake)
|
||||||
|
sess = PlanningSession("demo", "d", "/office-hours", "ch")
|
||||||
|
cmd = sess._build_cmd(
|
||||||
|
"prompt", resume=None, max_turns=20, with_system_prompt=True
|
||||||
|
)
|
||||||
|
assert "--system-prompt" in cmd
|
||||||
|
idx = cmd.index("--system-prompt")
|
||||||
|
assert "phase=/office-hours" in cmd[idx + 1]
|
||||||
|
assert "slug=demo" in cmd[idx + 1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# start() — integration-flavoured, mocks _run_claude
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStart:
|
||||||
|
def test_persists_session_id_and_response(
|
||||||
|
self, tmp_planning_state, fake_workspace
|
||||||
|
):
|
||||||
|
fake_result = {
|
||||||
|
"result": "Bună! Câteva întrebări… " + PHASE_NEEDS_INPUT_MARKER,
|
||||||
|
"session_id": "claude-uuid-99",
|
||||||
|
"usage": {"input_tokens": 1000, "output_tokens": 200},
|
||||||
|
"total_cost_usd": 0.55,
|
||||||
|
"subtype": "success",
|
||||||
|
"is_error": False,
|
||||||
|
}
|
||||||
|
with patch("src.planning_session._run_claude", return_value=fake_result) as mock_run:
|
||||||
|
sess = PlanningSession.start(
|
||||||
|
slug="demo",
|
||||||
|
description="Add filter X",
|
||||||
|
phase="/office-hours",
|
||||||
|
channel_id="ch-1",
|
||||||
|
adapter="discord",
|
||||||
|
)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
# cwd kw passed
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
assert "cwd" in kwargs
|
||||||
|
assert sess.session_id == "claude-uuid-99"
|
||||||
|
assert "Bună" in sess.last_response
|
||||||
|
state = get_planning_state("discord", "ch-1")
|
||||||
|
assert state["session_id"] == "claude-uuid-99"
|
||||||
|
assert state["slug"] == "demo"
|
||||||
|
assert state["phase"] == "/office-hours"
|
||||||
|
|
||||||
|
def test_retries_on_error_max_turns(self, tmp_planning_state, fake_workspace):
|
||||||
|
# First call returns error_max_turns with no session_id; second returns success.
|
||||||
|
first = {
|
||||||
|
"result": "deep tool use",
|
||||||
|
"session_id": "",
|
||||||
|
"usage": {},
|
||||||
|
"total_cost_usd": 0.6,
|
||||||
|
"subtype": "error_max_turns",
|
||||||
|
"is_error": True,
|
||||||
|
}
|
||||||
|
second = {
|
||||||
|
"result": "now I have a question",
|
||||||
|
"session_id": "claude-uuid-2",
|
||||||
|
"usage": {},
|
||||||
|
"total_cost_usd": 0.7,
|
||||||
|
"subtype": "success",
|
||||||
|
"is_error": False,
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude", side_effect=[first, second]
|
||||||
|
) as mock_run:
|
||||||
|
sess = PlanningSession.start(
|
||||||
|
slug="demo",
|
||||||
|
description="Add filter X",
|
||||||
|
phase="/office-hours",
|
||||||
|
channel_id="ch-1",
|
||||||
|
adapter="discord",
|
||||||
|
)
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
assert sess.session_id == "claude-uuid-2"
|
||||||
|
# Second call uses RETRY_MAX_TURNS
|
||||||
|
second_args = mock_run.call_args_list[1][0][0]
|
||||||
|
assert "30" in second_args # RETRY_MAX_TURNS
|
||||||
|
|
||||||
|
def test_respond_requires_session_id(self, fake_workspace):
|
||||||
|
sess = PlanningSession("demo", "d", "/x", "ch") # no session_id
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
sess.respond("hello")
|
||||||
273
tests/test_router_planning.py
Normal file
273
tests/test_router_planning.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""Tests for src/router.py planning integration (W2 — state-aware routing,
|
||||||
|
start_planning_session, planning_approve, planning_cancel)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import planning_session, planning_orchestrator, router
|
||||||
|
from src.planning_session import _channel_key, _save_planning_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_state(tmp_path, monkeypatch):
|
||||||
|
"""Redirect planning + approved-tasks into tmp."""
|
||||||
|
sessions_dir = tmp_path / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
monkeypatch.setattr(planning_session, "SESSIONS_DIR", sessions_dir)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
planning_session, "PLANNING_STATE_FILE", sessions_dir / "planning.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# approved-tasks.json — point router at a tmp file
|
||||||
|
approved = tmp_path / "approved-tasks.json"
|
||||||
|
approved.write_text(json.dumps({"projects": [], "last_updated": None}))
|
||||||
|
monkeypatch.setattr(router, "APPROVED_TASKS_FILE", approved)
|
||||||
|
|
||||||
|
# workspace dir for planning orchestrator final-plan.md target
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
(workspace / "demo").mkdir()
|
||||||
|
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||||
|
monkeypatch.setattr(planning_orchestrator, "WORKSPACE_ROOT", workspace)
|
||||||
|
yield {"sessions": sessions_dir, "approved": approved, "workspace": workspace}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_result(session_id="s-1", text="hi"):
|
||||||
|
return {
|
||||||
|
"result": text,
|
||||||
|
"session_id": session_id,
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
"total_cost_usd": 0.1,
|
||||||
|
"subtype": "success",
|
||||||
|
"is_error": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# start_planning_session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartPlanningSession:
|
||||||
|
def test_creates_entry_and_sets_status_planning(self, tmp_state):
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-1", text="first"),
|
||||||
|
):
|
||||||
|
response = router.start_planning_session(
|
||||||
|
"demo", "Add filter X", "ch-1", "discord"
|
||||||
|
)
|
||||||
|
assert response == "first"
|
||||||
|
approved = json.loads(tmp_state["approved"].read_text())
|
||||||
|
assert len(approved["projects"]) == 1
|
||||||
|
entry = approved["projects"][0]
|
||||||
|
assert entry["name"] == "demo"
|
||||||
|
assert entry["status"] == "planning"
|
||||||
|
assert entry["planning_session_id"] # uuid
|
||||||
|
|
||||||
|
def test_promotes_existing_pending_entry(self, tmp_state):
|
||||||
|
# Pre-seed an existing pending entry
|
||||||
|
approved_data = {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"description": "from earlier",
|
||||||
|
"status": "pending",
|
||||||
|
"planning_session_id": None,
|
||||||
|
"final_plan_path": None,
|
||||||
|
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||||
|
"approved_at": None,
|
||||||
|
"started_at": None,
|
||||||
|
"pid": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_updated": None,
|
||||||
|
}
|
||||||
|
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(session_id="s-2", text="hi"),
|
||||||
|
):
|
||||||
|
router.start_planning_session(
|
||||||
|
"demo", "Add filter X", "ch-1", "discord"
|
||||||
|
)
|
||||||
|
approved = json.loads(tmp_state["approved"].read_text())
|
||||||
|
assert len(approved["projects"]) == 1
|
||||||
|
assert approved["projects"][0]["status"] == "planning"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# planning_approve
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanningApprove:
|
||||||
|
def test_promotes_status_and_clears_state(self, tmp_state):
|
||||||
|
# Seed an active planning state + approved-tasks pending entry
|
||||||
|
_save_planning_state({
|
||||||
|
_channel_key("discord", "ch-1"): {
|
||||||
|
"slug": "demo",
|
||||||
|
"description": "x",
|
||||||
|
"phase": "__complete__",
|
||||||
|
"phases_planned": ["/office-hours"],
|
||||||
|
"phases_completed": ["/office-hours"],
|
||||||
|
"phase_index": 1,
|
||||||
|
"session_id": "s-uuid",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"final_plan_path": "/tmp/final-plan.md",
|
||||||
|
"adapter": "discord",
|
||||||
|
"channel_id": "ch-1",
|
||||||
|
"started_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"updated_at": "2026-04-26T20:30:00+00:00",
|
||||||
|
"last_text_excerpt": "done",
|
||||||
|
"last_subtype": "success",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
approved_data = {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"description": "x",
|
||||||
|
"status": "planning",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"final_plan_path": None,
|
||||||
|
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||||
|
"approved_at": None,
|
||||||
|
"started_at": None,
|
||||||
|
"pid": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_updated": None,
|
||||||
|
}
|
||||||
|
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||||
|
|
||||||
|
msg = router.planning_approve("ch-1", "discord")
|
||||||
|
assert "Aprobat" in msg or "✅" in msg
|
||||||
|
|
||||||
|
approved = json.loads(tmp_state["approved"].read_text())
|
||||||
|
entry = approved["projects"][0]
|
||||||
|
assert entry["status"] == "approved"
|
||||||
|
assert entry["approved_at"] is not None
|
||||||
|
assert entry["planning_session_id"] is None
|
||||||
|
assert entry["final_plan_path"] # set
|
||||||
|
|
||||||
|
# Planning state cleared
|
||||||
|
from src.planning_session import is_in_planning
|
||||||
|
assert is_in_planning("discord", "ch-1") is False
|
||||||
|
|
||||||
|
def test_no_state_returns_error_message(self, tmp_state):
|
||||||
|
msg = router.planning_approve("ch-missing", "discord")
|
||||||
|
assert "Nu există" in msg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# planning_cancel via route_message /cancel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouteMessagePlanningCancel:
|
||||||
|
def test_slash_cancel_in_planning_clears_state(self, tmp_state):
|
||||||
|
# Seed a planning session and approved-tasks pending entry
|
||||||
|
_save_planning_state({
|
||||||
|
_channel_key("discord", "ch-1"): {
|
||||||
|
"slug": "demo",
|
||||||
|
"description": "x",
|
||||||
|
"phase": "/office-hours",
|
||||||
|
"phases_planned": ["/office-hours", "/plan-ceo-review"],
|
||||||
|
"phases_completed": [],
|
||||||
|
"phase_index": 0,
|
||||||
|
"session_id": "s-uuid",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"adapter": "discord",
|
||||||
|
"channel_id": "ch-1",
|
||||||
|
"started_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"updated_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"last_text_excerpt": "Hi",
|
||||||
|
"last_subtype": "success",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
approved_data = {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"description": "x",
|
||||||
|
"status": "planning",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"final_plan_path": None,
|
||||||
|
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||||
|
"approved_at": None,
|
||||||
|
"started_at": None,
|
||||||
|
"pid": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_updated": None,
|
||||||
|
}
|
||||||
|
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||||
|
|
||||||
|
response, is_cmd = router.route_message(
|
||||||
|
"ch-1", "user-1", "/cancel", adapter_name="discord"
|
||||||
|
)
|
||||||
|
assert is_cmd is True
|
||||||
|
assert "anulat" in response.lower()
|
||||||
|
approved = json.loads(tmp_state["approved"].read_text())
|
||||||
|
assert approved["projects"][0]["status"] == "pending"
|
||||||
|
from src.planning_session import is_in_planning
|
||||||
|
assert is_in_planning("discord", "ch-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouteMessagePlanningRespond:
|
||||||
|
def test_plain_message_in_planning_routes_to_orchestrator(self, tmp_state):
|
||||||
|
# Seed a planning session
|
||||||
|
_save_planning_state({
|
||||||
|
_channel_key("discord", "ch-1"): {
|
||||||
|
"slug": "demo",
|
||||||
|
"description": "x",
|
||||||
|
"phase": "/office-hours",
|
||||||
|
"phases_planned": ["/office-hours"],
|
||||||
|
"phases_completed": [],
|
||||||
|
"phase_index": 0,
|
||||||
|
"session_id": "s-uuid",
|
||||||
|
"planning_session_id": "ps-uuid",
|
||||||
|
"adapter": "discord",
|
||||||
|
"channel_id": "ch-1",
|
||||||
|
"started_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"updated_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"last_text_excerpt": "Hi",
|
||||||
|
"last_subtype": "success",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
with patch(
|
||||||
|
"src.planning_session._run_claude",
|
||||||
|
return_value=_fake_result(
|
||||||
|
session_id="s-uuid", text="thanks PHASE_STATUS: needs_input"
|
||||||
|
),
|
||||||
|
) as mock_run:
|
||||||
|
response, is_cmd = router.route_message(
|
||||||
|
"ch-1", "user-1", "Vreau așa ceva.", adapter_name="discord"
|
||||||
|
)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
# respond uses --resume
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "--resume" in cmd
|
||||||
|
assert is_cmd is False
|
||||||
|
assert "thanks" in response
|
||||||
|
|
||||||
|
def test_no_planning_state_falls_through_to_normal_routing(self, tmp_state):
|
||||||
|
# No planning state — should go to ralph dispatch / Claude.
|
||||||
|
with patch(
|
||||||
|
"src.router.send_message", return_value="claude says hi"
|
||||||
|
) as mock_send:
|
||||||
|
response, is_cmd = router.route_message(
|
||||||
|
"ch-1", "user-1", "hello",
|
||||||
|
adapter_name="discord",
|
||||||
|
model="sonnet",
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
assert response == "claude says hi"
|
||||||
|
assert is_cmd is False
|
||||||
Reference in New Issue
Block a user