diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1057008..c6a750e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
- Vue Templates tab: markdown + ADF preview, and **Open in ADF Viewer after write** (`open_viewer` on `/api/templates/render`)
- Vue SQLite work browser: `/api/sqlite/works` filter + jump to Templates / Issues / ADF with the Work ID filled
- Vue SQLite work detail: click a Work ID to read requirement / canvas / analysis via `POST /api/sqlite/work` (git files, not sqlite blobs)
+- Vue Install tab: detect cards + parsed `/api/run` summary (would/created/checks/next steps); raw log remains
- Vue Dashboard jumps: active Work ID, suggestions, and activity open SQLite / Templates / Issues / ADF with that id filled
- Vue3 ops console **Dashboard** + **Issues** tabs (parity with the Flask console) and Playwright coverage for refresh, tracker save/toggle, link preview, and sync dry-run
- Vue3 Persistence **Check ledger parity** / **Parity + repair** buttons (same `/api/persistence/parity` as the Flask console)
diff --git a/console-ui/README.md b/console-ui/README.md
index 9eae530..32c9c40 100644
--- a/console-ui/README.md
+++ b/console-ui/README.md
@@ -7,7 +7,7 @@ Vite + Vue3 shell that talks to the existing Flask installer JSON API (`/api/*`)
- **Dashboard** (default) → status / activity / suggestions; jumps to SQLite / Templates / Issues / ADF (`/api/dashboard/*`)
- **Persistence** → status + save + ledger parity (`/api/persistence/*`)
- **Templates** → list/render/write ADF with markdown preview; optional open-in-viewer (`/api/templates/*`)
-- **Install** → detect + run/verify (`/api/detect`, `/api/run`)
+- **Install** → detect cards + run/verify with parsed summary (`/api/detect`, `/api/run`)
- **SQLite** → status + rebuild + filterable work browser + requirement/canvas/analysis detail (`/api/sqlite/*`)
- **Rollback** → backups + restore (`/api/backups`, `/api/rollback`)
- **Guide** → config/probe/lifecycle (`/api/guide/*`)
diff --git a/console-ui/src/components/InstallTab.vue b/console-ui/src/components/InstallTab.vue
index 7073efe..0a5848f 100644
--- a/console-ui/src/components/InstallTab.vue
+++ b/console-ui/src/components/InstallTab.vue
@@ -22,6 +22,7 @@ const statusClass = ref("");
const log = ref("Awaiting action…");
const loading = ref(false);
const lastDetect = ref(null);
+const lastResult = ref(null);
function assistants() {
if (asAll.value) return ["all"];
@@ -96,6 +97,7 @@ async function run(actionOverride) {
no_backup: noBackup.value,
with_python_engine: withEngine.value,
});
+ lastResult.value = data;
const cmd = (data.command || []).join(" ");
log.value = (cmd ? `$ ${cmd}\n\n` : "") + (data.log || data.error || "");
if (ok && data.ok !== false) {
@@ -115,8 +117,14 @@ async function run(actionOverride) {
}
}
+function assistantNames(info) {
+ const a = (info && info.assistants) || {};
+ return ["cursor", "copilot", "claude"].filter((name) => a[name]);
+}
+
function clearLog() {
log.value = "Awaiting action…";
+ lastResult.value = null;
statusText.value = "Ready.";
statusClass.value = "";
}
@@ -139,6 +147,27 @@ function clearLog() {
{{ modePill }}
{{ detectDetail }}
+
+
+
{{ lastDetect.mode }}
+
mode
+
+
+
{{ lastDetect.recommendation }}
+
recommend
+
+
+
{{ (lastDetect.markers || []).length }}
+
markers
+
+
+
{{ assistantNames(lastDetect).join(", ") || "none" }}
+
adapters
+
+
+
@@ -166,6 +195,47 @@ function clearLog() {
{{ statusText }}
+
+ {{ lastResult.summary.headline || lastResult.summary.action }}
+
+
+
{{ lastResult.summary.exit_code }}
+
exit
+
+
+
{{ lastResult.summary.would_count }}
+
would
+
+
+
{{ lastResult.summary.created_count }}
+
created
+
+
+
+ {{ lastResult.summary.check_ok_count }}/{{ lastResult.summary.check_ok_count + lastResult.summary.check_fail_count }}
+
+
checks
+
+
+ {{ lastResult.summary.command }}
+ {{ lastResult.summary.framework_home }}
+ {{ lastResult.summary.checks_summary }}
+
+
+
+
+
+
{{ log }}
diff --git a/console-ui/src/styles.css b/console-ui/src/styles.css
index 5246b46..489ef71 100644
--- a/console-ui/src/styles.css
+++ b/console-ui/src/styles.css
@@ -301,6 +301,21 @@ td {
border: 1px solid rgba(230, 184, 77, 0.4);
}
+.result-list {
+ margin: 0 0 0.75rem;
+ padding-left: 1.15rem;
+ max-height: 12rem;
+ overflow: auto;
+ color: var(--ink);
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+}
+
+.result-list li {
+ margin: 0.15rem 0;
+ word-break: break-word;
+}
+
.mode-pill {
display: inline-flex;
align-items: center;
diff --git a/docs/ops-console.md b/docs/ops-console.md
index 7be2841..0a5dd56 100644
--- a/docs/ops-console.md
+++ b/docs/ops-console.md
@@ -48,7 +48,7 @@ the `--root` passed when starting the ADF Viewer.
| Tab | What it does |
|-----|----------------|
| **Dashboard** | **Default landing tab.** Active Work ID, phase, gates, suggested next command, accepted vs staged lesson counts, backend status, integration shortcuts. Work ID / suggestions / activity jump to SQLite, Templates, Issues, or ADF with that id filled. |
-| **Install / Upgrade** | Detect fresh vs upgrade; run setup/upgrade/verify (dry-run supported) |
+| **Install / Upgrade** | Detect fresh vs upgrade (mode / markers / adapters). Run setup/upgrade/verify and show a parsed summary (would/created/checks/next steps) plus the raw log. Dry-run supported. |
| **Persistence** | Toggle `CONTEXT_BACKENDS` backends (`git-pointers`, `sqlite`, `guide-dice`); optional Guide URL + notes → `.sdlc/persistence-config.json`. **Check ledger parity** and **Parity + repair** buttons call `sdlc-engine context parity`. Operator guide: [triple-path-context.md](triple-path-context.md) |
| **Templates** | Render ADF combos for a Work ID with markdown + JSON preview; optional write to `adf/.adf.json` and open the ADF Viewer on that file |
| **SQLite** | `.sdlc/index.sqlite` status + rebuild, plus a filterable work browser. Click a Work ID to read the requirement / canvas / analysis (from git files). Jump to Templates / Issues / ADF |
diff --git a/engine/src/sdlc_engine/installer/runner.py b/engine/src/sdlc_engine/installer/runner.py
index 0d03ec8..4012138 100644
--- a/engine/src/sdlc_engine/installer/runner.py
+++ b/engine/src/sdlc_engine/installer/runner.py
@@ -3,10 +3,185 @@
from __future__ import annotations
import os
+import re
import subprocess
from pathlib import Path
from typing import Any
+_LIST_CAP = 40
+_SECTION_HEADERS = (
+ ("Created or updated", "created"),
+ ("Created (", "created"),
+ ("Skipped existing", "skipped"),
+ ("Updated framework files", "updated"),
+ ("Unchanged framework files", "unchanged"),
+ ("Preserved existing project content", "preserved"),
+ ("Consolidated", "consolidated"),
+ ("Backups", "backups"),
+)
+_HEADLINE_HINTS = (
+ "initialization complete",
+ "upgrade complete",
+ "setup complete",
+ "verification passed",
+ "verification failed",
+)
+_DRY_WOULD = re.compile(r"^\[dry-run\] would (.+)$")
+_VERIFY_OK = re.compile(r"^ok\s+(.+)$")
+_VERIFY_FAIL = re.compile(r"^fail\s+(.+)$")
+_NUMBERED = re.compile(r"^\d+\.\s+")
+
+
+def summarize_run_log(
+ *,
+ action: str,
+ log: str,
+ command: list[str] | None = None,
+ dry_run: bool = False,
+ ok: bool = False,
+ exit_code: int = 0,
+) -> dict[str, Any]:
+ """Parse install/upgrade/verify script output into console-friendly lists."""
+ command = command or []
+ buckets: dict[str, list[str]] = {
+ "would": [],
+ "created": [],
+ "skipped": [],
+ "updated": [],
+ "unchanged": [],
+ "preserved": [],
+ "consolidated": [],
+ "backups": [],
+ "checks_ok": [],
+ "checks_fail": [],
+ "warnings": [],
+ "next_steps": [],
+ }
+ headline = ""
+ home = ""
+ checks_summary = ""
+ section: str | None = None
+
+ def _section_header(stripped: str) -> str | None:
+ for prefix, key in _SECTION_HEADERS:
+ if stripped.startswith(prefix):
+ return key
+ return None
+
+ for raw in (log or "").splitlines():
+ stripped = raw.strip()
+ if not stripped:
+ if section and section != "next_steps":
+ section = None
+ continue
+
+ dry = _DRY_WOULD.match(stripped)
+ if dry:
+ buckets["would"].append(dry.group(1))
+ section = None
+ continue
+
+ if stripped.startswith("WARNING"):
+ buckets["warnings"].append(stripped)
+ section = None
+ continue
+
+ if stripped == "Next steps:":
+ section = "next_steps"
+ continue
+
+ header = _section_header(stripped)
+ if header:
+ section = header
+ continue
+
+ if section == "next_steps":
+ if _NUMBERED.match(stripped):
+ buckets["next_steps"].append(stripped)
+ elif buckets["next_steps"] and (raw.startswith(" ") or stripped.startswith("/")):
+ buckets["next_steps"][-1] = f"{buckets['next_steps'][-1]} {stripped}"
+ elif stripped.startswith("For "):
+ section = None
+ continue
+
+ if section and raw.startswith(" ") and stripped != "none":
+ buckets[section].append(stripped)
+ continue
+ if section and not raw.startswith(" "):
+ section = None
+
+ if stripped.startswith("Framework home:"):
+ home = stripped.split(":", 1)[1].strip()
+ if stripped.startswith("Recommended next step:"):
+ buckets["next_steps"].append(stripped.split(":", 1)[1].strip())
+ if stripped.startswith("Summary:") and "checks passed" in stripped:
+ checks_summary = stripped
+ if any(hint in stripped.lower() for hint in _HEADLINE_HINTS):
+ headline = stripped
+
+ verify_ok = _VERIFY_OK.match(stripped)
+ if verify_ok and raw.lstrip().startswith("ok"):
+ buckets["checks_ok"].append(verify_ok.group(1))
+ continue
+ verify_fail = _VERIFY_FAIL.match(stripped)
+ if verify_fail and raw.lstrip().startswith("fail"):
+ buckets["checks_fail"].append(verify_fail.group(1))
+
+ would_count = len(buckets["would"])
+ return {
+ "action": action,
+ "ok": ok,
+ "exit_code": exit_code,
+ "dry_run": dry_run,
+ "headline": headline,
+ "framework_home": home,
+ "command": " ".join(str(part) for part in command),
+ "would": buckets["would"][:_LIST_CAP],
+ "created": buckets["created"][:_LIST_CAP],
+ "skipped": buckets["skipped"][:_LIST_CAP],
+ "updated": buckets["updated"][:_LIST_CAP],
+ "backups": buckets["backups"][:_LIST_CAP],
+ "checks_ok": buckets["checks_ok"][:_LIST_CAP],
+ "checks_fail": buckets["checks_fail"][:_LIST_CAP],
+ "warnings": buckets["warnings"][:20],
+ "next_steps": buckets["next_steps"][:12],
+ "checks_summary": checks_summary,
+ "would_count": would_count,
+ "created_count": len(buckets["created"]),
+ "check_ok_count": len(buckets["checks_ok"]),
+ "check_fail_count": len(buckets["checks_fail"]),
+ }
+
+
+def _run_payload(
+ *,
+ action: str,
+ ok: bool,
+ exit_code: int,
+ command: list[str],
+ log: str,
+ dry_run: bool = False,
+ engine_log: str = "",
+) -> dict[str, Any]:
+ text = (log or "").strip()
+ return {
+ "ok": ok,
+ "action": action,
+ "exit_code": exit_code,
+ "command": command,
+ "log": text,
+ "engine_log": (engine_log or "").strip(),
+ "dry_run": dry_run,
+ "summary": summarize_run_log(
+ action=action,
+ log=text,
+ command=command,
+ dry_run=dry_run,
+ ok=ok,
+ exit_code=exit_code,
+ ),
+ }
+
def orchestrator_root() -> Path:
"""Locate the SDLC-SPDD orchestrator repo (engine → repo root)."""
@@ -77,20 +252,22 @@ def run_action(
if want_all or name in selected:
cmd.append(f"--require-{name}")
else:
- return {
- "ok": False,
- "exit_code": 2,
- "command": [],
- "log": f"Unknown action: {action}",
- }
+ return _run_payload(
+ action=action,
+ ok=False,
+ exit_code=2,
+ command=[],
+ log=f"Unknown action: {action}",
+ )
if not script.is_file():
- return {
- "ok": False,
- "exit_code": 2,
- "command": cmd,
- "log": f"Script not found: {script}",
- }
+ return _run_payload(
+ action=action,
+ ok=False,
+ exit_code=2,
+ command=cmd,
+ log=f"Script not found: {script}",
+ )
proc = subprocess.run(
cmd,
@@ -106,13 +283,15 @@ def run_action(
engine_log = _install_python_engine(root, target_path, timeout_sec=timeout_sec)
log = (log + "\n" + engine_log).strip()
- return {
- "ok": proc.returncode == 0,
- "exit_code": proc.returncode,
- "command": cmd,
- "log": log.strip(),
- "engine_log": engine_log.strip() if engine_log else "",
- }
+ return _run_payload(
+ action=action,
+ ok=proc.returncode == 0,
+ exit_code=proc.returncode,
+ command=cmd,
+ log=log,
+ dry_run=dry_run,
+ engine_log=engine_log,
+ )
def _install_python_engine(root: Path, target: Path, *, timeout_sec: int) -> str:
diff --git a/engine/tests_e2e/test_vue3_console_playwright.py b/engine/tests_e2e/test_vue3_console_playwright.py
index 71019c4..67dde79 100644
--- a/engine/tests_e2e/test_vue3_console_playwright.py
+++ b/engine/tests_e2e/test_vue3_console_playwright.py
@@ -520,6 +520,11 @@ def test_vue3_install_detect_and_dry_run(page, live_vue_console) -> None: # typ
log = page.get_by_test_id("install-log").inner_text()
assert log.strip()
assert "dry" in log.lower() or "setup-agent-prompts" in log or "Would" in log
+ page.get_by_test_id("install-summary").wait_for(state="visible")
+ assert page.get_by_test_id("detect-mode").inner_text().strip() == "fresh"
+ assert int(page.get_by_test_id("install-would-count").inner_text()) >= 1
+ next_steps = page.get_by_test_id("install-next-steps").inner_text()
+ assert "sdlc-spdd-init" in next_steps
def test_vue3_sqlite_refresh_and_rebuild(page, live_vue_console) -> None: # type: ignore[no-untyped-def]
@@ -875,7 +880,7 @@ def test_vue3_adf_start_status_stop(page, live_vue_console) -> None: # type: ig
page.wait_for_function(
"""() => {
const meta = document.querySelector('[data-testid="adf-meta"]')?.textContent || '';
- return meta.includes('process alive') || meta.includes('url http');
+ return meta.includes('process alive');
}"""
)
assert live_vue_console["state"]["alive"] is True
diff --git a/engine/tests_integration/test_installer.py b/engine/tests_integration/test_installer.py
index 64af68a..1561082 100644
--- a/engine/tests_integration/test_installer.py
+++ b/engine/tests_integration/test_installer.py
@@ -83,6 +83,11 @@ def test_api_run_dry_install(tmp_path: Path) -> None:
assert data["exit_code"] == 0
assert "setup-agent-prompts.sh" in " ".join(data["command"])
assert "--dry-run" in data["command"]
+ summary = data["summary"]
+ assert summary["dry_run"] is True
+ assert summary["would_count"] >= 1
+ assert summary["would"]
+ assert any("sdlc-spdd-init" in step for step in summary["next_steps"])
def test_api_run_dry_upgrade(tmp_path: Path) -> None:
diff --git a/engine/tests_unit/test_installer_runtime_units.py b/engine/tests_unit/test_installer_runtime_units.py
index 0de5379..c2b2460 100644
--- a/engine/tests_unit/test_installer_runtime_units.py
+++ b/engine/tests_unit/test_installer_runtime_units.py
@@ -224,6 +224,74 @@ def read(self, n: int = -1) -> bytes:
assert written["ok"] is True
+def test_summarize_run_log_parses_install_upgrade_verify() -> None:
+ install = rn.summarize_run_log(
+ action="install",
+ log=(
+ "[dry-run] would create /tmp/app/sdlc-spdd/ROADMAP.md\n"
+ "[dry-run] would copy templates/x -> /tmp/app/sdlc-spdd/x\n"
+ "SDLC-SPDD initialization complete for: /tmp/app\n"
+ "Created or updated (2):\n"
+ " sdlc-spdd/ROADMAP.md\n"
+ " none\n"
+ "Skipped existing (0):\n"
+ " none\n"
+ "Recommended next step: run /sdlc-spdd-init then /sdlc-spdd-plan\n"
+ "Framework home: /tmp/app/sdlc-spdd (docs at sdlc-spdd/docs/)\n"
+ "Next steps:\n"
+ " 1. Open the target project.\n"
+ " 2. Start or resume context:\n"
+ " /tmp/app/sdlc-spdd/scripts/start-agent-session.sh --phase init\n"
+ " 3. Invoke:\n"
+ " /sdlc-spdd-init\n"
+ ),
+ command=["setup-agent-prompts.sh", "--dry-run"],
+ dry_run=True,
+ ok=True,
+ exit_code=0,
+ )
+ assert install["would_count"] == 2
+ assert install["would"][0].startswith("create ")
+ assert install["headline"].startswith("SDLC-SPDD initialization complete")
+ assert "/sdlc-spdd-init" in install["next_steps"][0]
+ assert any("Open the target" in step for step in install["next_steps"])
+ assert any("start-agent-session.sh" in step for step in install["next_steps"])
+ assert install["dry_run"] is True
+
+ upgrade = rn.summarize_run_log(
+ action="upgrade",
+ log=(
+ "WARNING: leftover agent-context/\n"
+ "SDLC-SPDD framework upgrade complete for: /tmp/app\n"
+ "Created (1):\n"
+ " sdlc-spdd/docs/README.md\n"
+ "Backups (1):\n"
+ " /tmp/app/.sdlc-spdd-upgrade-backups/20260816T000000Z/x\n"
+ ),
+ ok=True,
+ exit_code=0,
+ )
+ assert upgrade["created"] == ["sdlc-spdd/docs/README.md"]
+ assert upgrade["backups"]
+ assert upgrade["warnings"][0].startswith("WARNING")
+
+ verify = rn.summarize_run_log(
+ action="verify",
+ log=(
+ " ok Home: sdlc-spdd/\n"
+ " fail Cursor commands: .cursor/commands/sdlc-spdd-init.md\n"
+ "Summary: 1/2 checks passed\n"
+ "Install verification failed (1 missing or invalid items).\n"
+ ),
+ ok=False,
+ exit_code=1,
+ )
+ assert verify["check_ok_count"] == 1
+ assert verify["check_fail_count"] == 1
+ assert "1/2 checks passed" in verify["checks_summary"]
+ assert "verification failed" in verify["headline"].lower()
+
+
def test_runner_flags_and_engine_install(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: