From 8432fe3150d20fadcc5e2e66c38df552fc38a34e Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 5 May 2026 07:47:10 +0000 Subject: [PATCH] feat(planning): full chat history + auto-advance phases Three fixes that together restore the planning UX: - Dashboard reopen showed only a 500-char truncated excerpt of the last assistant message. Backend now reads the Claude session JSONL directly and returns full per-turn history; frontend iterates and renders all bubbles, falling back to last_text_excerpt when the JSONL is missing. - Phases never advanced because the agent ran /plan-* skills inline as tool calls and the marker protocol was loose. Tightened the planning prompt (mandatory PHASE_STATUS marker on the last line of every turn, ban on inline phase invocation), and the frontend now auto-calls /plan/advance when phase_ready=true. - The phase strip never showed visual state because data-phase values ("office-hours") didn't match orchestrator phase names ("/office-hours"). Added normalizePhase + cleanup of PHASE_STATUS markers from rendered bubbles. Also bumps eco.py session-content truncation from 2k to 20k so /eco session views aren't cut mid-response either. Bumps last_text_excerpt fallback in planning_session.py from 500 to 50_000 so even when the JSONL is unavailable, the bubble isn't sliced mid-word. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/handlers/eco.py | 6 +- dashboard/handlers/projects.py | 101 ++++++++++++++++++++++++++++++++- dashboard/workspace.html | 75 +++++++++++++++++++++--- prompts/planning_agent.md | 33 ++++++++--- src/planning_session.py | 4 +- 5 files changed, 197 insertions(+), 22 deletions(-) diff --git a/dashboard/handlers/eco.py b/dashboard/handlers/eco.py index 91bc480..c029fc1 100644 --- a/dashboard/handlers/eco.py +++ b/dashboard/handlers/eco.py @@ -155,7 +155,7 @@ class EcoHandlers: if isinstance(content, str): text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip() if text: - messages.append({'role': 'user', 'text': text[:2000]}) + messages.append({'role': 'user', 'text': text[:20000]}) elif t == 'assistant': msg = d.get('message', {}) content = msg.get('content', '') @@ -163,9 +163,9 @@ class EcoHandlers: parts = [block['text'] for block in content if block.get('type') == 'text'] text = '\n'.join(parts).strip() if text: - messages.append({'role': 'assistant', 'text': text[:2000]}) + messages.append({'role': 'assistant', 'text': text[:20000]}) elif isinstance(content, str) and content.strip(): - messages.append({'role': 'assistant', 'text': content[:2000]}) + messages.append({'role': 'assistant', 'text': content[:20000]}) self.send_json({'messages': messages}) except Exception as e: diff --git a/dashboard/handlers/projects.py b/dashboard/handlers/projects.py index 2ebc773..b2097b3 100644 --- a/dashboard/handlers/projects.py +++ b/dashboard/handlers/projects.py @@ -43,6 +43,7 @@ import hashlib import json import logging import os +import re import subprocess import sys import time @@ -248,6 +249,102 @@ def _last_message_preview(slug: str) -> str: return best[:200] +# ─── planning transcript helpers ────────────────────────────────────── + + +_EXTERNAL_CONTENT_RE = re.compile( + r"^\s*\[EXTERNAL CONTENT\]\s*\n(.*)\n\[END EXTERNAL CONTENT\]\s*$", + re.DOTALL, +) +_COMMAND_ARGS_RE = re.compile(r"(.*?)", re.DOTALL) + + +def _planning_jsonl_path(slug: str, session_id: str) -> Path | None: + """Path to the Claude CLI JSONL transcript for a planning session. + + PlanningSession runs with cwd = `~/workspace//` if it exists, else + falls back to the echo-core repo root (see `PlanningSession.cwd`). + Claude CLI stores transcripts under + `~/.claude/projects//.jsonl`, where encoded-cwd + is the absolute path with `/` replaced by `-`. + """ + if not session_id: + return None + workspace = constants.WORKSPACE_DIR / slug + cwd = workspace if workspace.is_dir() else constants.BASE_DIR + encoded = str(cwd).replace("/", "-") + return Path.home() / ".claude" / "projects" / encoded / f"{session_id}.jsonl" + + +def _clean_user_text(text: str) -> str: + """Strip wrappers Echo adds around user input sent to the planning subprocess.""" + m = _EXTERNAL_CONTENT_RE.match(text) + if m: + text = m.group(1).strip() + if "" in text: + m = _COMMAND_ARGS_RE.search(text) + if m: + text = m.group(1).strip() + return text + + +def _extract_text_blocks(content) -> str: + """Concatenate `text`-type blocks from a Claude `message.content` field.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + t = block.get("text") or "" + if t: + parts.append(t) + return "\n\n".join(parts) + return "" + + +def _load_planning_history(slug: str, session_id: str | None) -> list[dict]: + """Parse the Claude session JSONL into a `[{role, text}, ...]` chat history. + + Skips queue / system / tool-result entries. Empty list on any structural + problem so callers can fall back to `last_text_excerpt`. + """ + if not session_id: + return [] + path = _planning_jsonl_path(slug, session_id) + if path is None or not path.exists(): + return [] + history: list[dict] = [] + try: + with path.open(encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + entry = json.loads(raw) + except json.JSONDecodeError: + continue + etype = entry.get("type") + if etype not in ("user", "assistant"): + continue + if etype == "user" and "toolUseResult" in entry: + continue + if entry.get("isMeta"): + continue + msg = entry.get("message") or {} + text = _extract_text_blocks(msg.get("content")) + if etype == "user": + text = _clean_user_text(text) + text = (text or "").strip() + if not text: + continue + history.append({"role": etype, "text": text}) + except OSError: + return [] + return history + + # ─── version helpers ────────────────────────────────────────────────── @@ -929,6 +1026,8 @@ class ProjectsHandlers: final_plan_text = fp.read_text(encoding="utf-8", errors="replace") except OSError: pass + session_id = (state or {}).get("session_id") or "" + history = _load_planning_history(slug, session_id) self.send_json({ "slug": slug, "phase": (state or {}).get("phase"), @@ -936,7 +1035,7 @@ class ProjectsHandlers: "last_text_excerpt": (state or {}).get("last_text_excerpt") or "", "final_plan_path": final_plan_path, "final_plan": final_plan_text, - "history": [], # reserved for future per-turn JSONL sidecar + "history": history, }) # ── POST /api/projects//plan/finalize ─────────────────── diff --git a/dashboard/workspace.html b/dashboard/workspace.html index 0854eb6..3aed7d7 100644 --- a/dashboard/workspace.html +++ b/dashboard/workspace.html @@ -1954,18 +1954,29 @@ // ────────────────────────────────────────── const PHASES = ['office-hours', 'ceo-review', 'eng-review']; + function normalizePhase(phase) { + return (phase || '').replace(/^\//, '').replace(/^plan-/, ''); + } + function setPhase(phase, completed) { state.planning.phase = phase; state.planning.phasesCompleted = completed || []; + const norm = normalizePhase(phase); + const completedNorm = (completed || []).map(normalizePhase); const steps = phaseStepper.querySelectorAll('.phase-step'); steps.forEach(step => { const ph = step.dataset.phase; step.classList.remove('active', 'complete'); - if ((completed || []).indexOf(ph) >= 0) step.classList.add('complete'); - if (ph === phase) step.classList.add('active'); + if (completedNorm.indexOf(ph) >= 0) step.classList.add('complete'); + if (ph === norm) step.classList.add('active'); }); } + function stripPhaseMarkers(text) { + if (!text) return text; + return text.replace(/\s*\nPHASE_STATUS:\s*(ready_to_advance|needs_input)\s*$/, '').trimEnd(); + } + function renderMarkdown(text) { if (!text) return ''; try { @@ -1977,15 +1988,16 @@ } function appendMessage(role, content) { - state.planning.messages.push({ role, content, ts: Date.now() }); + const display = role === 'assistant' ? stripPhaseMarkers(content) : content; + state.planning.messages.push({ role, content: display, ts: Date.now() }); const msg = document.createElement('div'); msg.className = 'chat-msg ' + role; const bubble = document.createElement('div'); bubble.className = 'chat-bubble'; if (role === 'assistant') { - bubble.innerHTML = renderMarkdown(content); + bubble.innerHTML = renderMarkdown(display); } else { - bubble.textContent = content; + bubble.textContent = display; } msg.appendChild(bubble); chatStream.appendChild(msg); @@ -2075,14 +2087,24 @@ if (tRes.ok) { const data = await safeJson(tRes); if (data) { - if (data.last_text_excerpt) { + let rendered = false; + if (Array.isArray(data.history) && data.history.length > 0) { + for (const m of data.history) { + if (m && m.text) appendMessage(m.role || 'assistant', m.text); + } + rendered = true; + } else if (data.last_text_excerpt) { appendMessage('assistant', data.last_text_excerpt); + rendered = true; } const phasesCompleted = data.phases_completed || []; const phase = data.phase || 'office-hours'; setPhase(phase === '__complete__' ? null : phase, phasesCompleted); // If we already have content, we're resuming - if (data.last_text_excerpt) { + if (rendered) { + requestAnimationFrame(() => { + chatStream.scrollTop = chatStream.scrollHeight; + }); try { composerInput.focus(); } catch (e) {} return; } @@ -2189,6 +2211,9 @@ } setPhase(data.phase === '__complete__' ? null : data.phase, completed); } + if (data.phase_ready) { + await autoAdvancePhase(slug); + } } } catch (err) { removeTypingIndicator(); @@ -2203,6 +2228,42 @@ } } + async function autoAdvancePhase(slug) { + const prevPhase = state.planning.phase; + appendMessage('assistant', '_Faza completă. Pornesc subprocess pentru următoarea fază..._'); + appendTypingIndicator(); + startElapsedCounter(); + try { + const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/advance', { + method: 'POST', + }); + removeTypingIndicator(); + stopElapsedCounter(); + if (!res.ok) { + const d = await safeJson(res); + appendMessage('assistant', '_Nu am putut avansa: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_'); + return; + } + const data = await safeJson(res); + if (!data) return; + const completed = state.planning.phasesCompleted.slice(); + if (prevPhase && !completed.includes(prevPhase)) completed.push(prevPhase); + if (data.completed) { + setPhase(null, completed); + if (data.message) appendMessage('assistant', data.message); + } else { + if (data.message) appendMessage('assistant', data.message); + setPhase(data.phase, completed); + } + } catch (err) { + removeTypingIndicator(); + stopElapsedCounter(); + if (err.message !== 'unauthorized') { + appendMessage('assistant', '_Eroare la avans fază: ' + (err.message || err) + '_'); + } + } + } + planCancelBtn.addEventListener('click', async () => { const slug = state.planning.slug; if (!slug) { closeModal(planModal); return; } diff --git a/prompts/planning_agent.md b/prompts/planning_agent.md index 1ac8e64..3a1fbe1 100644 --- a/prompts/planning_agent.md +++ b/prompts/planning_agent.md @@ -25,24 +25,39 @@ dacă scrie EN, mergi EN). Concis: 3-6 propoziții per turn, nu eseuri. 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. +ca de obicei — el îți dă structura. -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. +**INTERZIS** să rulezi `/plan-ceo-review`, `/plan-eng-review` sau `/plan-design-review` +ca tool-call în interiorul fazei curente. Fiecare fază rulează într-un **subprocess Claude +separat** (fresh `claude -p`) pe care îl pornește orchestratorul. Dacă te simți „gata" la +office-hours, nu continua singur cu CEO review — închide turnul cu marker-ul `ready_to_advance` +și orchestratorul va porni următorul subprocess. Asta e singura cale prin care +`phases_completed` din `sessions/planning.json` crește și UI-ul îți arată progresul real. + +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: +2. **Marker de progres — OBLIGATORIU pe ultima linie a FIECĂRUI turn**, fără excepție. + Două valori posibile: + - `PHASE_STATUS: needs_input` — încă mai ai nevoie de informații/clarificări de la Marius + în această fază. Default pentru orice turn intermediar. + - `PHASE_STATUS: ready_to_advance` — faza e completă în mintea ta, artefactul e scris pe + disc, e clar ce urmează. Echo o citește și avansează automat la faza următoare + (subprocess proaspăt, fără click suplimentar din partea lui Marius). + Marker-ul trebuie să fie **ultima linie** din răspuns, fără text după el. Fără el, sistemul + presupune `needs_input` și rămâi blocat în faza curentă. + + Exemplu de tranziție corectă la sfârșit de office-hours: ``` + Am surprins toate aspectele esențiale. Salvez artifact-ul în ~/.gstack/projects/{slug}/ + user-mariusm-master-office-hours-summary.md și sunt gata pentru CEO review. + 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. diff --git a/src/planning_session.py b/src/planning_session.py index 13336e8..97414c3 100644 --- a/src/planning_session.py +++ b/src/planning_session.py @@ -94,7 +94,7 @@ PHASE_NEEDS_INPUT_MARKER = "PHASE_STATUS: needs_input" # "planning_session_id": "", # "started_at": "...", # "updated_at": "...", -# "last_text_excerpt": "...", # 500 char excerpt for debugging +# "last_text_excerpt": "...", # ≤50K char fallback excerpt; full transcript lives in Claude's session JSONL # "last_subtype": "success" | "error_max_turns" | ..., # } # } @@ -418,7 +418,7 @@ class PlanningSession: "channel_id": self.channel_id, "started_at": existing.get("started_at", now), "updated_at": now, - "last_text_excerpt": (self._last_response or "")[:500], + "last_text_excerpt": (self._last_response or "")[:50000], "last_subtype": self._last_subtype, "total_cost_usd": ( float(existing.get("total_cost_usd") or 0.0) + float(cost_usd or 0.0)