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)