diff --git a/plugins/preview-forge/agents/meta/chief-engineer-pm.md b/plugins/preview-forge/agents/meta/chief-engineer-pm.md index 015b5ed..2c184bd 100644 --- a/plugins/preview-forge/agents/meta/chief-engineer-pm.md +++ b/plugins/preview-forge/agents/meta/chief-engineer-pm.md @@ -128,6 +128,23 @@ Task({ 이 dispatch는 markdown 지시가 아니라 **명령형 imperative** — LLM trust 줄이기 위해 의도적으로 명시적 Task block. + +#### H2 승인 후 즉시 preview 서버 자동 기동 (자동, 사용자 입력 없음) + +`/pf:export` (또는 사용자가 H2에서 deploy 승인) 후, M3는 **즉시** preview 서버를 기동한다. README의 "human clicks twice" 약속에 따라 H2 외에는 자동 진행. + +검증 스크립트 (Phase 1과 동일한 plugin-root 절대 경로 형태 — 사용자 workspace에서 `scripts/`가 없어도 동작): +```bash +bash "${CLAUDE_PLUGIN_ROOT}/../../scripts/start-preview-server.sh" runs// +# exit 0 → 서버 기동 + 브라우저 자동 오픈 +# exit 2 → scaffold 누락 (TestDD freeze 미완료); 사용자에게 보고 +``` + +수동 재실행 / 정지: `/pf:preview ` / `/pf:preview stop `. + +Idempotent: 이미 살아있는 서버에 대해서는 URL만 다시 열기, 재기동 안 함. + + ### 4. Memory 파일 관리 (쓰기 권한 독점) **Rule 3**에 따라 당신만 `memory/{CLAUDE,PROGRESS,LESSONS}.md`에 쓸 수 있습니다. 다른 agent는 Blackboard에 `memory.request.{file}` 키로 요청 → 당신이 검토 후 batch 반영. @@ -179,9 +196,10 @@ Auto-retro-trigger 훅이 Blackboard에 `retro.requested` 행을 기록하면: - `runs//design-approved.json` (Gate H1 수집 결과) - `/memories/m3-decisions/*.md` (자신의 reflection) - Task: 모든 department lead 호출 가능 -- Bash: **H1/H2 gate 지원용 read-only scripts만** 허용 (v1.6.0+). 구체적으로: - - `scripts/generate-gallery.sh ` (H1 gallery HTML 생성) - - `scripts/open-browser.sh ` (H1 gallery auto-open, 비블로킹) +- Bash: **H1/H2 gate 지원용 scripts만** 허용 (v1.6.0+). 구체적으로: + - `scripts/generate-gallery.sh ` (H1 gallery HTML 생성, read-only) + - `scripts/open-browser.sh ` (H1 gallery auto-open, 비블로킹, read-only) + - `scripts/start-preview-server.sh ` 및 `start|stop|status` 형태 (v1.7.0+ Phase 2 sanctioned exception: stateful이지만 idempotent + run_dir-local 작용으로 한정 — H2 finalize 직후 single-shot으로만 호출. PID/URL/log 파일은 모두 `/.preview-*`에만 기록되며, factory-policy/Rule 6 enforcement에서 명시적으로 화이트리스트 처리됨.) - 그 외 destructive·stateful Bash는 차단 (Rule 6). 상태 변화는 Write 또는 sub-agent 위임. ## forbidden diff --git a/plugins/preview-forge/commands/freeze.md b/plugins/preview-forge/commands/freeze.md index 0fdb2bb..17b93a5 100644 --- a/plugins/preview-forge/commands/freeze.md +++ b/plugins/preview-forge/commands/freeze.md @@ -20,6 +20,10 @@ description: Force evaluate Judges + Auditors and attempt freeze 현재 run의 Stage 7 (Judges + Auditors)를 강제 실행. 점수 미달이면 dissent와 함께 보고만 하고 freeze 안 함. +## After freeze + +Once `score/report.json` is locked and `.frozen-hash` written, M3 automatically launches the local preview server (`bash scripts/start-preview-server.sh runs//`) and opens your browser to the running app. To re-open or stop the server later: `/pf:preview ` / `/pf:preview stop `. + ## 관련 - 본 명령은 plugin `preview-forge`의 일부입니다. diff --git a/plugins/preview-forge/commands/preview.md b/plugins/preview-forge/commands/preview.md new file mode 100644 index 0000000..8c62122 --- /dev/null +++ b/plugins/preview-forge/commands/preview.md @@ -0,0 +1,45 @@ +--- +description: Launch the local preview server for a frozen run (post-H2 or manual re-open) +--- + +# /pf:preview — Launch the local preview server for a frozen run + +**Layer-0 정책**: Pro/Max 기본 포함. 별도 API 키 불필요. + +## Usage + +``` +/pf:preview [run_id] +/pf:preview stop [run_id] +/pf:preview status [run_id] +``` + +## 인자 + +- `run_id` (optional): 특정 run. 생략 시 가장 최근 run (`ls -t runs/r-* | head -1`). + +## 동작 + +`bash scripts/start-preview-server.sh runs//` 호출. `runs//` 의 내용으로 profile 자동 감지: + +1. `docker-compose.yml` 존재 (pro/max) → `docker compose up -d` + 첫 published port → 브라우저 자동 오픈. +2. `apps/api/package.json` + `apps/web/package.json` 존재 (standard) → 의존성 설치 → 18080부터 free port 자동 탐색 → `pnpm dev` 백그라운드 spawn → web TCP accept 대기 (≤60s) → 브라우저 자동 오픈. +3. 둘 다 없음 → exit 2 (TestDD freeze 미완료). + +본 명령은 H2 승인 직후 M3가 자동으로 한 번 호출하므로 수동 실행은 보통 **재오픈** 또는 **재기동** 용도다 (서버를 stop 했거나 머신을 재부팅한 경우). + +## Idempotency + +이미 살아있는 PID 가 `/.preview-server.pid` 에 있으면 재기동 없이 URL 만 다시 연다. 두 번 spawn 되지 않는다. + +## 종료 + +- `/pf:preview stop ` — SIGTERM → 5s 대기 → SIGKILL fallback. docker 프로필은 `docker compose down`. +- `/pf:preview status ` — 살아있으면 stdout 에 URL + exit 0, 아니면 exit 1. + +## 관련 + +- 본 명령은 plugin `preview-forge`의 일부입니다. +- 스크립트: `scripts/start-preview-server.sh` (CI 테스트용 `PF_PREVIEW_DRY_RUN=1` 지원). +- Gap B 배경: DEMO-STORYBOARD.md L1:50–2:00 의 자동 localhost:18080 약속. +- 상세 스펙: [preview-forge-proposal.html](../../../preview-forge-proposal.html) diff --git a/scripts/start-preview-server.sh b/scripts/start-preview-server.sh new file mode 100755 index 0000000..cfdb6bd --- /dev/null +++ b/scripts/start-preview-server.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# Preview Forge — Phase 2 (Gap B fix): post-H2 local preview server launcher. +# +# Bridges the gap between TestDD freeze (H2 approval) and the user actually +# seeing the generated app in their browser. Before this script the user had +# to run `pnpm -r dev` or `docker compose up` manually after every freeze, +# breaking the README "human clicks twice" promise and the +# DEMO-STORYBOARD.md L1:50–2:00 expectation that a new tab pops up at +# http://localhost:18080 automatically. +# +# Profiles auto-detected from contents (also probes /generated/ +# since be-lead.md and the QA leads write apps to runs//generated/, while +# /pf:freeze and /pf:preview pass `runs//`): +# 1. pro / max — docker-compose.yml at run_dir/ or run_dir/generated/. +# → `docker compose up -d`, wait for any service Up, +# extract first published port, open browser. +# 2. standard — apps/api/package.json AND apps/web/package.json at +# run_dir/ or run_dir/generated/. +# → install (pnpm > npm), pick free port from 18080+, +# spawn api + web `pnpm dev` in background, persist +# PIDs, wait for web TCP, open browser. +# 3. neither — exit 2 with stderr message (TestDD scaffold incomplete). +# +# CLI: +# start-preview-server.sh +# Idempotent: if PIDs in /.preview-server.pid are alive, +# only re-open the browser. Otherwise start fresh. +# start-preview-server.sh stop +# SIGTERM → wait 5s → SIGKILL. For docker, `docker compose down`. +# Removes .preview-server.{pid,id,url}. Idempotent (no-op if no PID). +# start-preview-server.sh status +# Exit 0 if a preview server is running for (PIDs alive +# OR docker project still up), prints URL on stdout. +# Exit 1 otherwise. +# +# Env flags (test/CI helpers): +# PF_PREVIEW_DRY_RUN=1 — print the actions that would happen, then exit +# 0. No `pnpm install`, no `docker compose up`, +# no background process spawn, no browser open. +# Used by tests/fixtures/post-h2-preview to keep +# the unit suite light. Profile detection still +# runs; missing-scaffold still exits 2. +# +# Style anchors: +# - exit-code contract follows scripts/h1-modal-helper.sh +# - browser open delegated to scripts/open-browser.sh +# - portability: lsof is preinstalled on macOS+Linux; pnpm fallback to +# npm; docker check via `command -v`. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OPENER="$SCRIPT_DIR/open-browser.sh" + +usage() { + cat <<'EOF' >&2 +usage: + start-preview-server.sh + start-preview-server.sh stop + start-preview-server.sh status +EOF + exit 1 +} + +# ---- arg parsing ---- +action="start" +case "${1:-}" in + "" ) + usage + ;; + stop|status ) + action="$1" + shift + ;; +esac + +run_dir="${1:-}" +[ -n "$run_dir" ] || usage +# Strip trailing slash for consistent file paths. +run_dir="${run_dir%/}" +if [ ! -d "$run_dir" ]; then + echo "start-preview-server.sh: run_dir not found: $run_dir" >&2 + exit 1 +fi + +PID_FILE="$run_dir/.preview-server.pid" +ID_FILE="$run_dir/.preview-server.id" +URL_FILE="$run_dir/.preview-server.url" +API_LOG="$run_dir/.preview-api.log" +WEB_LOG="$run_dir/.preview-web.log" + +# ---- helpers ---- +pids_alive() { + # Echo each alive PID found in PID_FILE; return 0 if any alive. + [ -f "$PID_FILE" ] || return 1 + local any=1 line pid + while IFS= read -r line; do + # lines look like "api 12345" or "web 67890" or just "12345" + pid="${line##* }" + case "$pid" in + ''|*[!0-9]*) continue ;; + esac + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + any=0 + fi + done <"$PID_FILE" + return "$any" +} + +docker_project_up() { + [ -f "$ID_FILE" ] || return 1 + local compose_file="${scaffold_root:-$run_dir}/docker-compose.yml" + [ -f "$compose_file" ] || return 1 + command -v docker >/dev/null 2>&1 || return 1 + # Any service in `running` state? + local running + running="$(docker compose -f "$compose_file" ps --status running --quiet 2>/dev/null || true)" + [ -n "$running" ] +} + +pick_free_port() { + # Print first free TCP port in [start, start+max-1]; exit 1 if none found. + local start="$1" max="${2:-11}" p + for ((i = 0; i < max; i++)); do + p=$((start + i)) + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$p" -sTCP:LISTEN -Pn >/dev/null 2>&1 || { echo "$p"; return 0; } + else + # Fallback: try /dev/tcp probe (bash-only; cheap). + (echo > "/dev/tcp/127.0.0.1/$p") >/dev/null 2>&1 || { echo "$p"; return 0; } + fi + done + return 1 +} + +wait_tcp() { + # wait_tcp + local host="$1" port="$2" timeout="$3" t=0 + while [ "$t" -lt "$timeout" ]; do + if command -v nc >/dev/null 2>&1; then + nc -z "$host" "$port" >/dev/null 2>&1 && return 0 + else + (echo > "/dev/tcp/$host/$port") >/dev/null 2>&1 && return 0 + fi + sleep 1 + t=$((t + 1)) + done + return 1 +} + +open_url() { + local url="$1" + if [ -x "$OPENER" ] || [ -r "$OPENER" ]; then + bash "$OPENER" "$url" >/dev/null 2>&1 || true + fi + echo "$url" >"$URL_FILE" + echo "preview server up: $url" +} + +# ---- profile detection ---- +# Engineering teams (be-lead.md) write apps to `runs//generated/`, but +# `/pf:freeze` and `/pf:preview` instruct callers to pass `runs//`. So if +# the scaffold isn't directly under run_dir, transparently fall through to +# `/generated/` before declaring "scaffold missing". +scaffold_root="" +profile="" +for cand in "$run_dir" "$run_dir/generated"; do + if [ -f "$cand/docker-compose.yml" ]; then + scaffold_root="$cand"; profile="docker"; break + elif [ -f "$cand/apps/api/package.json" ] && [ -f "$cand/apps/web/package.json" ]; then + scaffold_root="$cand"; profile="standard"; break + fi +done + +# ---- action: status ---- +if [ "$action" = "status" ]; then + case "$profile" in + standard ) + if pids_alive >/dev/null; then + [ -f "$URL_FILE" ] && cat "$URL_FILE" || echo "running" + exit 0 + fi + ;; + docker ) + if docker_project_up; then + [ -f "$URL_FILE" ] && cat "$URL_FILE" || echo "running" + exit 0 + fi + ;; + esac + echo "no preview server for $run_dir" >&2 + exit 1 +fi + +# ---- action: stop ---- +if [ "$action" = "stop" ]; then + # PID-based stop (standard). + if [ -f "$PID_FILE" ]; then + while IFS= read -r line; do + pid="${line##* }" + case "$pid" in ''|*[!0-9]*) continue ;; esac + kill -TERM "$pid" 2>/dev/null || true + done <"$PID_FILE" + # Wait up to 5s for graceful exit. + for _ in 1 2 3 4 5; do + pids_alive >/dev/null || break + sleep 1 + done + if pids_alive >/dev/null; then + while IFS= read -r line; do + pid="${line##* }" + case "$pid" in ''|*[!0-9]*) continue ;; esac + kill -KILL "$pid" 2>/dev/null || true + done <"$PID_FILE" + fi + rm -f "$PID_FILE" + fi + # Docker-based stop. Scaffold may live under either run_dir or run_dir/generated. + stop_compose="" + for cand in "$run_dir" "$run_dir/generated"; do + if [ -f "$cand/docker-compose.yml" ]; then + stop_compose="$cand/docker-compose.yml"; break + fi + done + if [ -f "$ID_FILE" ] && [ -n "$stop_compose" ] && command -v docker >/dev/null 2>&1; then + docker compose -f "$stop_compose" down >/dev/null 2>&1 || true + fi + rm -f "$ID_FILE" "$URL_FILE" + echo "preview server stopped (run_dir=$run_dir)" + exit 0 +fi + +# ---- action: start (default) ---- + +# No profile detected → caller has not run TestDD freeze yet. +if [ -z "$profile" ]; then + echo "neither apps/{api,web}/package.json nor docker-compose.yml found in $run_dir or $run_dir/generated; cannot start preview server" >&2 + exit 2 +fi + +# Idempotency: if something is already alive, just re-open the URL. +if [ "$profile" = "standard" ] && pids_alive >/dev/null; then + if [ -f "$URL_FILE" ]; then + url="$(cat "$URL_FILE")" + echo "preview server already running (idempotent re-open)" + if [ "${PF_PREVIEW_DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] would re-open $url" + exit 0 + fi + open_url "$url" + exit 0 + fi +fi +if [ "$profile" = "docker" ] && docker_project_up; then + if [ -f "$URL_FILE" ]; then + url="$(cat "$URL_FILE")" + echo "preview server already running (idempotent re-open)" + if [ "${PF_PREVIEW_DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] would re-open $url" + exit 0 + fi + open_url "$url" + exit 0 + fi +fi + +# ---- profile: docker (pro / max) ---- +if [ "$profile" = "docker" ]; then + compose_file="$scaffold_root/docker-compose.yml" + if [ "${PF_PREVIEW_DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] profile=docker compose_file=$compose_file" + echo "[dry-run] would: docker compose -f $compose_file up -d --quiet-pull" + echo "[dry-run] would: poll docker compose ps until any service Up (≤30s)" + echo "[dry-run] would: extract first published port and open-browser.sh" + exit 0 + fi + if ! command -v docker >/dev/null 2>&1; then + echo "start-preview-server.sh: docker not on PATH but $compose_file requires it" >&2 + exit 1 + fi + docker compose -f "$compose_file" up -d --quiet-pull >/dev/null 2>&1 || { + echo "start-preview-server.sh: docker compose up failed; see compose logs" >&2 + exit 1 + } + # Wait up to 30s for at least one service running. + t=0 + while [ "$t" -lt 30 ]; do + if [ -n "$(docker compose -f "$compose_file" ps --status running --quiet 2>/dev/null || true)" ]; then + break + fi + sleep 1 + t=$((t + 1)) + done + # Extract first host port via `docker compose ps --format json`. + port="" + host="localhost" + if command -v python3 >/dev/null 2>&1; then + port="$(docker compose -f "$compose_file" ps --format json 2>/dev/null \ + | python3 -c ' +import json, sys +raw = sys.stdin.read().strip() +records = [] +if raw: + # Older docker emits a single JSON array; newer ones emit NDJSON. + try: + parsed = json.loads(raw) + records = parsed if isinstance(parsed, list) else [parsed] + except json.JSONDecodeError: + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue +for rec in records: + if not isinstance(rec, dict): + continue + for p in rec.get("Publishers") or []: + pub = p.get("PublishedPort") + if pub: + print(pub); sys.exit(0) +' || true)" + fi + if [ -z "$port" ]; then + # Fallback: assume Caddy or web service on 18080 per project convention. + port="18080" + fi + # Stash compose project name (basename of run_dir is used by default). + project_name="$(basename "$run_dir")" + echo "$project_name" >"$ID_FILE" + url="http://$host:$port/" + open_url "$url" + exit 0 +fi + +# ---- profile: standard (apps/api + apps/web) ---- +api_dir="$scaffold_root/apps/api" +web_dir="$scaffold_root/apps/web" + +# Pick free ports: web on 18080+, api on 18180+ (offset of 100 keeps logs scannable). +web_port="$(pick_free_port 18080 11)" || { + echo "start-preview-server.sh: no free port in 18080..18090" >&2 + exit 1 +} +api_port="$(pick_free_port 18180 11)" || { + echo "start-preview-server.sh: no free port in 18180..18190" >&2 + exit 1 +} + +if [ "${PF_PREVIEW_DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] profile=standard" + echo "[dry-run] api_dir=$api_dir api_port=$api_port" + echo "[dry-run] web_dir=$web_dir web_port=$web_port" + echo "[dry-run] would: install deps (pnpm > npm), spawn api+web in background" + echo "[dry-run] would: wait_tcp 127.0.0.1 $web_port 60" + echo "[dry-run] would: open http://localhost:$web_port/" + exit 0 +fi + +# Install deps. Prefer pnpm if pnpm-lock.yaml exists; else npm. +pkg_mgr="" +if command -v pnpm >/dev/null 2>&1 && [ -f "$scaffold_root/pnpm-lock.yaml" ]; then + pkg_mgr="pnpm" +elif command -v pnpm >/dev/null 2>&1 && [ -f "$scaffold_root/pnpm-workspace.yaml" ]; then + pkg_mgr="pnpm" +elif command -v npm >/dev/null 2>&1; then + pkg_mgr="npm" +else + echo "start-preview-server.sh: neither pnpm nor npm available" >&2 + exit 1 +fi +case "$pkg_mgr" in + pnpm ) + (cd "$scaffold_root" && pnpm install --frozen-lockfile >/dev/null 2>&1) || \ + (cd "$scaffold_root" && pnpm install >/dev/null 2>&1) || { + echo "start-preview-server.sh: pnpm install failed in $scaffold_root" >&2 + exit 1 + } + dev_cmd="pnpm dev" + ;; + npm ) + (cd "$scaffold_root" && npm install >/dev/null 2>&1) || true + (cd "$api_dir" && npm install >/dev/null 2>&1) || true + (cd "$web_dir" && npm install >/dev/null 2>&1) || true + dev_cmd="npm run dev" + ;; +esac + +# Cleanup spawned api/web on early exit so a wait_tcp timeout does not leak +# zombie processes that would still hold the port and force the next retry to +# pick a higher port (causing PID_FILE drift). +cleanup_spawned() { + local pid + for pid in "${api_pid:-}" "${web_pid:-}"; do + case "$pid" in + ''|*[!0-9]*) continue ;; + esac + kill -TERM "$pid" 2>/dev/null || true + done + rm -f "$PID_FILE" +} + +# Spawn api + web in background, redirecting output. nohup detaches them +# from the controlling tty so they survive shell exit. +api_pid="$( ( cd "$api_dir" && PORT="$api_port" nohup $dev_cmd >"$API_LOG" 2>&1 & echo $! ) )" +web_pid="$( ( cd "$web_dir" && PORT="$web_port" NEXT_PUBLIC_API_URL="http://localhost:$api_port" nohup $dev_cmd >"$WEB_LOG" 2>&1 & echo $! ) )" + +# Persist PIDs (one per line, role-labeled). +{ + echo "api $api_pid" + echo "web $web_pid" +} >"$PID_FILE" + +# Wait for web to accept TCP (up to 60s). +if ! wait_tcp 127.0.0.1 "$web_port" 60; then + echo "start-preview-server.sh: web server did not start on :$web_port within 60s" >&2 + echo " api log: $API_LOG" + echo " web log: $WEB_LOG" + cleanup_spawned + exit 1 +fi + +url="http://localhost:$web_port/" +open_url "$url" +exit 0 diff --git a/scripts/verify-plugin.sh b/scripts/verify-plugin.sh index 5bd9fc6..80c04e5 100755 --- a/scripts/verify-plugin.sh +++ b/scripts/verify-plugin.sh @@ -5,7 +5,7 @@ # Checks: # 1. Manifest JSON syntax (marketplace.json + plugin.json) # 2. All 144 agents present with valid frontmatter -# 3. 14 slash commands present +# 3. 15 slash commands present # 4. 3 hooks + hooks.json valid # 5. Memory seed + methodology + assets + schemas + seeds present @@ -75,10 +75,10 @@ exit(1 if non_opus else 0) PYEOF echo -echo "[3/5] Slash commands (14 target)" +echo "[3/5] Slash commands (15 target)" cmd_count=$(find "$PLUGIN_DIR/commands" -maxdepth 1 -name "*.md" | wc -l | tr -d ' ') -[[ "$cmd_count" -eq 14 ]] && ok "command count: 14" || bad "command count: $cmd_count" -for cmd in bootstrap budget design export freeze gallery help lessons new panel replay retry seed status; do +[[ "$cmd_count" -eq 15 ]] && ok "command count: 15" || bad "command count: $cmd_count" +for cmd in bootstrap budget design export freeze gallery help lessons new panel preview replay retry seed status; do [[ -f "$PLUGIN_DIR/commands/$cmd.md" ]] && ok "/pf:$cmd" || bad "/pf:$cmd missing" done echo