Skill: extraterrest/deep-coding (version 0.0.3)

--- name: deep-coding description: "Deep coding multi-agent development system. Use when the user wants to build software projects using the Orchestrator to Builder to Reviewer workflow, mentions deep coding, multi-agent collaboration, orchestrator, spawn builder, spawn reviewer, or asks for complex coding projects that need module decomposition and iterative review. NOT for: simple one-liner fixes (just edit), reading code, or single-file changes." --- ## System Dependencies This skill requires the following system capabilities: | Dependency | Purpose | Required? | Check | |-----------|---------|-----------|-------| | `python3` | Dashboard server (port 8765) | Yes | `python3 --version` | | `node` / `npm` | Project builds, Playwright | For web projects | `node -v`, `npm -v` | | `playwright` | E2E browser testing (Reviewers) | Optional, for E2E | `npx playwright --version` | | ACP runtime | Builder/Reviewer agent execution | Optional, see below | Platform-specific | **No specific coding agent is required.** The default configuration uses ACP + qoder, but you can use any available agent runtime. See [First-Time Setup](#first-time-setup) for configuration options. ## Security Notes **⚠️ Dashboard server (`server.py`)**: - Binds to `127.0.0.1:8765` only — never expose to public network - Serves files from the project directory — verify no secrets (API keys, tokens) are present - Includes path traversal protection via `safe_path()` check **⚠️ Code execution**: - Builders and Reviewers will **execute and run arbitrary project code** - For web projects: HTTP server serves project files locally - E2E tests use Playwright to open and interact with pages in a real browser - **Only run on machines where executing generated code is acceptable** - Use containers/VMs for untrusted projects ## First-Time Setup When a user installs this skill for the first time, guide them through the following steps: ### Step 1: Create Project Workspace ```bash mkdir -p my-projects/{requests/done,logs} cp <skill-dir>/assets/server.py my-projects/ cp <skill-dir>/assets/dashboard.html my-projects/ cd my-projects ``` This creates the project root with all required directories and the Dashboard assets. ### Step 2: Configure Orchestrator Agent Create an Orchestrator agent in your `openclaw.json` (or equivalent config): ```json { "id": "orchestrator", "name": "Orchestrator", "workspace": "<your-path>/my-projects" } ``` Give the Orchestrator a heartbeat prompt that references `references/orchestrator-rules.md`. ### Step 3: Configure Builder Agent(s) Choose your preferred coding agent(s). Options: | Option | Configuration | Notes | |--------|--------------|-------| | **ACP + qoder** | `runtime: "acp"`, `agentId: "qoder"` | Default, requires acpx plugin | | **ACP + claude** | `runtime: "acp"`, `agentId: "claude"` | Alternative ACP agent | | **ACP + codex** | `runtime: "acp"`, `agentId: "codex"` | OpenAI Codex | | **Subagent runtime** | `runtime: "subagent"` | Built-in, no extra setup | | **PTY coding agents** | `exec` with PTY | Claude Code, Codex CLI, etc. | The Orchestrator rules (`references/orchestrator-rules.md`) default to ACP + qoder, but you should update the agent ID to match your setup. **Recommended: Set up a 3-tier fallback chain** 1. Primary: Your preferred coding agent (e.g., qoder, claude) 2. Fallback 1: Alternative ACP agent (e.g., claude if qoder is 429'd) 3. Fallback 2: Built-in subagent runtime ### Step 4: Allow Tool Access Ensure your Orchestrator and Builder agents have access to: - `read`, `write`, `edit` — for file operations - `exec` — for running builds, tests, servers - `sessions_spawn`, `sessions_send`, `sessions_list` — for agent communication - `subagents` — for managing spawned agents In `openclaw.json`: ```json { "tools": { "sessions": { "visibility": "all" }, "agentToAgent": { "enabled": true, "allow": ["main", "orchestrator", "qoder-dev", "claude-dev"] } }, "acp": { "enabled": true, "backend": "acpx", "defaultAgent": "qoder", "allowedAgents": ["qoder", "claude", "codex"] } } ``` ### Step 5: Choose Your LLM Set the default model for the Orchestrator and agents: ```json { "agents": { "defaults": { "model": { "primary": "your-provider/your-model" } } } } ``` For coding agents (qoder, claude, codex), they use their own model — no LLM config needed. ### Step 6: Verify Setup ```bash cd my-projects python3 server.py # Open http://localhost:8765 — should show empty dashboard ``` --- # Harness Deep Coding System Multi-agent development: Orchestrator decomposes → Builders code → Reviewers verify → E2E test → deliver. ## Roles ### User-Facing Agent (you) - Gather requirements through conversation - Create request JSON at `projects/requests/TIMESTAMP.json` (use actual timestamp) - Notify Orchestrator via `sessions_send` to `agent:orchestrator:main` - Report progress every heartbeat when project is active ### Orchestrator - Decomposes project into 2-4 modules + mandatory integration-test - Creates `project-state.json` with module states - Spawns Builders and Reviewers via `sessions_spawn` - Monitors progress via heartbeat, handles failures - Runs E2E smoke test after bugfix/feature accepted ### Builder - Codes independently per module - Uses configured agent runtime (ACP subagent, or fallback) - Writes to `logs/builder-MODULE.log` (APPEND, UTC+8) ### Reviewer - MUST actually test the application, not just read code - For web projects: serve via HTTP, verify in browser - Writes detailed review results to `review_history` - Writes to `logs/reviewer-MODULE.log` (APPEND, UTC+8) ## User-Facing Workflow ### 1. Gather Requirements - What to build, key features, constraints, tech stack - Break into 2-4 logical modules (data → core → render → UI) - Auto-add final `integration-test` module depending on ALL others ### 2. Create Request ```json { "name": "Project Name", "description": "What it does", "owner": "user name", "tags": ["web", "game"] } ``` Path: `<project-root>/requests/TIMESTAMP.json` (use actual timestamp) ### 3. Notify Orchestrator Send to `agent:orchestrator:main`: - Request file path - Instructions to decompose into modules - Create project-state.json - Spawn Builder for first module - Use per-agent logs, APPEND mode, UTC+8 - Run E2E smoke test after acceptance ### 4. Progress Reporting Read `project-state.json` every heartbeat: - Report completion % and module states - Announce 100% completion ## Project Structure All paths are relative to your project root directory: ``` <project-root>/ ├── projects-registry.json ← All projects overview ├── server.py ← Dashboard server (port 8765) ├── dashboard.html ← Dashboard UI ├── requests/ │ └── done/ ← Processed requests ├── logs/ ← Agent activity logs ├── PROJECT-SLUG/ │ ├── project-state.json ← Module states, review history │ ├── logs/ │ │ ├── orchestrator.log ← Orchestrator decisions │ │ ├── builder-MODULE.log ← Each Builder writes own file │ │ └── reviewer-MODULE.log ← Each Reviewer writes own file │ └── SOURCE CODE (generated files) ``` See `references/architecture.md` for full project structure, module lifecycle, and dashboard details. ## Module Lifecycle ``` pending → in_progress → ready_for_review → in_review → accepted ↑ | └── needs_revision ──┘ ``` ### Critical Rules | Rule | Description | |------|-------------| | One action per heartbeat | Never do multiple spawns in one cycle | | Spawn Reviewer immediately | Never leave `ready_for_review` more than one cycle | | Reviewer writes results | Must write to `review_history` array, never just change state | | E2E smoke test | Mandatory for bugfixes and new features before delivery | | No archive copies | DO NOT copy project-state.json to archive/ | ## Common Issues | Issue | Fix | |-------|-----| | 429 rate limit | Wait, then re-spawn. Do NOT self-accept | | Missing E2E | Bugfix/feature accepted → must spawn E2E Reviewer | | Reviewer not spawned | Check sessions_list, spawn if missing | | Builder timeout | Check if files exist, accept if complete | | Archive duplicates | Orchestrator should NOT copy to archive/ | ## Dashboard Dashboard is included in `assets/server.py` and `assets/dashboard.html`. **Usage:** 1. Copy `assets/server.py` and `assets/dashboard.html` to your project root directory 2. Run: `python3 server.py` 3. Open: `http://localhost:8765` **Security:** The server binds to `127.0.0.1` only and includes path traversal protection. Features: project list, completion status, module states, agent activity timeline.

path: assets/dashboard.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OpenClaw Harness Dashboard</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/markdown-it@13/dist/markdown-it.min.js"></script>
    <style>
        .status-pending { @apply bg-gray-100 border-gray-300; }
        .status-in_progress { @apply bg-blue-50 border-blue-400; }
        .status-ready_for_review { @apply bg-yellow-50 border-yellow-400; }
        .status-in_review { @apply bg-orange-50 border-orange-400; }
        .status-needs_work { @apply bg-red-50 border-red-400; }
        .status-passed { @apply bg-green-50 border-green-400; }
        .status-accepted { @apply bg-emerald-50 border-emerald-500; }
        .status-rejected { @apply bg-red-100 border-red-600; }
        .status-blocked { @apply bg-purple-50 border-purple-400; }
        
        .agent-working { 
            animation: pulse 2s infinite;
            @apply ring-2 ring-blue-500 ring-offset-2;
        }
        
        @keyframes pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.7; }
        }
        
        .markdown-body {
            font-size: 0.875rem;
            line-height: 1.6;
        }
        .markdown-body h1, .markdown-body h2, .markdown-body h3 {
            margin-top: 1em;
            margin-bottom: 0.5em;
            font-weight: 600;
        }
        .markdown-body ul, .markdown-body ol {
            padding-left: 1.5em;
            margin: 0.5em 0;
        }
        .markdown-body code {
            background: #f3f4f6;
            padding: 0.125em 0.375em;
            border-radius: 0.25em;
            font-size: 0.85em;
        }
        .markdown-body pre {
            background: #f9fafb;
            padding: 0.75em;
            border-radius: 0.375em;
            overflow-x: auto;
            margin: 0.5em 0;
        }
        .markdown-body p { margin: 0.5em 0; }
        
        [v-cloak] { display: none; }
    </style>
</head>
<body class="bg-gray-50 min-h-screen">
    <div id="app" v-cloak>
        <!-- Header -->
        <header class="bg-white shadow-sm border-b">
            <div class="max-w-7xl mx-auto px-4 py-4">
                <div class="flex items-center justify-between">
                    <div>
                        <h1 class="text-2xl font-bold text-gray-900">🦞 OpenClaw Harness Dashboard</h1>
                        <p class="text-sm text-gray-500 mt-1">多 Agent 项目进度监控平台</p>
                    </div>
                    <div class="flex items-center gap-4">
                        <div class="text-right">
                            <p class="text-xs text-gray-500">最后更新</p>
                            <p class="text-sm font-medium">{{ lastUpdateTime }}</p>
                        </div>
                        <button 
                            @click="refreshData" 
                            class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
                            :disabled="loading"
                        >
                            <svg v-if="loading" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
                                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
                                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
                            </svg>
                            <svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
                            </svg>
                            {{ loading ? '刷新中...' : '刷新' }}
                        </button>
                    </div>
                </div>
            </div>
        </header>

        <main class="max-w-7xl mx-auto px-4 py-6">
            <!-- No projects message -->
            <div v-if="allProjects.length === 0 && !loading" class="text-center py-20">
                <div class="text-6xl mb-4">🦞</div>
                <h2 class="text-xl font-bold text-gray-700 mb-2">暂无项目</h2>
                <p class="text-gray-500">Harness 系统就绪，等待新的项目 request</p>
            </div>

            <!-- Project List -->
            <div v-if="allProjects.length > 0" class="bg-white rounded-lg shadow p-4 mb-6">
                <div class="flex items-center justify-between mb-3">
                    <h2 class="text-lg font-bold text-gray-900">📁 项目列表</h2>
                    <div class="flex items-center gap-2 text-sm">
                        <span class="px-2 py-1 bg-green-100 text-green-700 rounded">已完成: {{ completedProjectsCount }}</span>
                        <span class="px-2 py-1 bg-blue-100 text-blue-700 rounded">进行中: {{ inProgressProjectsCount }}</span>
                        <span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded">待启动: {{ pendingProjectsCount }}</span>
                        <span class="px-2 py-1 bg-gray-100 text-gray-700 rounded">总计: {{ allProjects.length }}</span>
                    </div>
                </div>
                
                <!-- Project Cards -->
                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
                    <div v-for="project in allProjects" :key="project.id"
                         @click="selectProject(project)"
                         class="p-3 rounded-lg border cursor-pointer transition-all hover:shadow-md"
                         :class="getProjectCardClass(project)"
                    >
                        <div class="flex items-start justify-between">
                            <div class="flex-1">
                                <p class="font-medium text-sm">{{ project.name }}</p>
                                <p v-if="project.description" class="text-xs text-gray-400 mt-1 line-clamp-2">{{ project.description }}</p>
                                <p class="text-xs text-gray-500 mt-1">{{ project.total_modules }} 个模块</p>
                            </div>
                            <span class="text-xs px-2 py-0.5 rounded ml-2" :class="getProjectStatusBadgeClass(project.status)">
                                {{ formatStatus(project.status) }}
                            </span>
                        </div>
                        <div v-if="!project.is_request" class="mt-2">
                            <div class="flex items-center justify-between text-xs text-gray-500">
                                <span>{{ project.completed_modules }}/{{ project.total_modules }}</span>
                                <span>{{ project.progress }}%</span>
                            </div>
                            <div class="w-full bg-gray-200 rounded-full h-1.5 mt-1">
                                <div class="h-1.5 rounded-full transition-all" 
                                     :class="getProjectProgressColor(project.status)"
                                     :style="{ width: project.progress + '%' }"></div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Current Project Header -->
            <div v-if="currentProject && !currentProject.is_request" class="bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg shadow-lg p-6 mb-6 text-white">
                <div class="flex items-start justify-between">
                    <div class="flex-1">
                        <h2 class="text-2xl font-bold">{{ projectState.project_name || projectState.name || currentProject.name }}</h2>
                        <div class="mt-1">
                            <p class="text-blue-100" :class="descExpanded ? '' : 'line-clamp-2'">{{ projectState.description || currentProject.description }}</p>
                            <button v-if="getFullDescription().length > 120" @click="descExpanded = !descExpanded"
                                    class="text-xs text-blue-200 hover:text-white mt-1 underline">
                                {{ descExpanded ? '收起' : '展开' }}
                            </button>
                        </div>
                    </div>
                    <div class="text-right ml-4">
                        <p class="text-3xl font-bold">{{ summary.completion_percentage }}%</p>
                        <p class="text-blue-100 text-sm">项目进度</p>
                    </div>
                </div>
            </div>

            <!-- Pending Request Banner -->
            <div v-if="currentProject && currentProject.is_request" class="bg-gradient-to-r from-yellow-500 to-orange-500 rounded-lg shadow-lg p-6 mb-6 text-white">
                <div>
                    <h2 class="text-2xl font-bold">⏳ {{ currentProject.name.replace('[待启动] ', '') }}</h2>
                    <p class="text-yellow-100 mt-2">{{ currentProject.description }}</p>
                    <p class="text-yellow-200 text-sm mt-2">{{ currentProject.total_modules }} 个模块，等待 Orchestrator 启动</p>
                </div>
            </div>

            <!-- Project Overview Cards (only for active projects) -->
            <div v-if="currentProject && !currentProject.is_request" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
                <!-- Overall Status -->
                <div class="bg-white rounded-lg shadow p-4 border-l-4" :class="overallStatusColor">
                    <p class="text-xs text-gray-500 uppercase tracking-wide">整体状态</p>
                    <p class="text-lg font-bold mt-1">{{ formatStatus(projectState.status) }}</p>
                </div>
                
                <!-- Progress -->
                <div class="bg-white rounded-lg shadow p-4 border-l-4 border-blue-500">
                    <div class="flex items-center justify-between">
                        <p class="text-xs text-gray-500 uppercase tracking-wide">实时进度</p>
                        <span class="flex items-center gap-1 text-xs text-green-600">
                            <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
                            5s刷新
                        </span>
                    </div>
                    <div class="flex items-end justify-between mt-1">
                        <p class="text-2xl font-bold">{{ summary.completed_modules }}/{{ summary.total_modules }}</p>
                        <p class="text-sm text-gray-500">{{ summary.completion_percentage }}%</p>
                    </div>
                    <div class="w-full bg-gray-200 rounded-full h-2 mt-2">
                        <div class="bg-blue-600 h-2 rounded-full transition-all duration-500" :style="{ width: summary.completion_percentage + '%' }"></div>
                    </div>
                    <p v-if="recentActivity" class="mt-2 text-xs text-blue-600 animate-pulse">
                        🔔 {{ recentActivity }}
                    </p>
                </div>
                
                <!-- In Review -->
                <div class="bg-white rounded-lg shadow p-4 border-l-4 border-yellow-500">
                    <p class="text-xs text-gray-500 uppercase tracking-wide">审查中</p>
                    <p class="text-2xl font-bold mt-1">{{ inReviewCount }}</p>
                </div>
                
                <!-- Blocked -->
                <div class="bg-white rounded-lg shadow p-4 border-l-4 border-red-500">
                    <p class="text-xs text-gray-500 uppercase tracking-wide">阻塞</p>
                    <p class="text-2xl font-bold mt-1">{{ summary.blocked_modules || 0 }}</p>
                </div>
            </div>

            <!-- Currently Working Agent -->
            <div v-if="currentlyWorkingAgents.length > 0" class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg shadow-md p-4 mb-6 border border-blue-200">
                <div class="flex items-center gap-2 mb-3">
                    <svg class="h-5 w-5 text-blue-600 animate-spin" style="animation-duration: 3s" fill="none" viewBox="0 0 24 24">
                        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
                        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
                    </svg>
                    <h2 class="text-lg font-bold text-blue-900">正在工作的 Agent</h2>
                </div>
                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
                    <div v-for="agent in currentlyWorkingAgents" :key="agent.module_id" 
                         class="bg-white rounded-lg p-3 agent-working">
                        <div class="flex items-start justify-between">
                            <div class="flex-1">
                                <div class="flex items-center gap-2 mb-1">
                                    <span class="text-xs font-semibold px-2 py-0.5 rounded" :class="getAgentRoleBadgeClass(agent.role)">
                                        {{ agent.role === 'builder' ? '🔨 Builder' : '👁️ Reviewer' }}
                                    </span>
                                    <span class="text-xs text-gray-500">{{ agent.agent_id }}</span>
                                </div>
                                <p class="text-sm font-medium text-gray-900">{{ getModuleName(agent.module_id) }}</p>
                                <p class="text-xs text-gray-500 mt-1">
                                    开始于 {{ formatTime(agent.started_at) }}
                                    <span v-if="agent.elapsed_time" class="ml-2 text-gray-400">已工作 {{ agent.elapsed_time }}</span>
                                </p>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Agent Panel -->
            <div v-if="currentProject && !currentProject.is_request && allAgents.length > 0" class="bg-white rounded-lg shadow mb-6">
                <div class="px-4 py-3 border-b border-gray-200">
                    <h2 class="text-lg font-bold text-gray-900">🤖 Agent 总览 <span class="text-sm font-normal text-gray-500">(共 {{ allAgents.length }} 位)</span></h2>
                </div>
                <div class="p-4">
                    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
                        <div v-for="agent in allAgents" :key="agent.id" 
                             class="rounded-lg border p-3"
                             :class="agent.status === 'working' ? 'border-blue-400 bg-blue-50/50' : agent.status === 'idle' ? 'border-gray-200 bg-gray-50' : 'border-green-200 bg-green-50/50'">
                            <div class="flex items-center gap-2 mb-2">
                                <span class="text-lg">{{ agent.role === 'builder' ? '🔨' : '👁️' }}</span>
                                <div class="flex-1 min-w-0">
                                    <p class="text-sm font-medium text-gray-900 truncate">{{ agent.id }}</p>
                                    <p class="text-xs text-gray-500">{{ agent.role === 'builder' ? 'Builder' : 'Reviewer' }}</p>
                                </div>
                                <span class="flex-shrink-0 text-xs px-2 py-0.5 rounded-full"
                                      :class="{
                                          'bg-blue-100 text-blue-700 animate-pulse': agent.status === 'working',
                                          'bg-gray-100 text-gray-500': agent.status === 'idle',
                                          'bg-green-100 text-green-700': agent.status === 'done'
                                      }">
                                    {{ agent.status === 'working' ? '工作中' : agent.status === 'idle' ? '空闲' : '已完成' }}
                                </span>
                            </div>
                            <p class="text-xs text-gray-600 truncate">{{ agent.moduleName }}</p>
                            <p v-if="agent.runtime || agent.elapsed" class="text-xs text-gray-400 mt-1">{{ agent.status === 'done' ? '⏱️ 运行' : '⏱️ 已运行' }}: {{ agent.runtime || agent.elapsed }}</p>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Task List -->
            <div v-if="currentProject && !currentProject.is_request" class="bg-white rounded-lg shadow">
                <div class="px-4 py-3 border-b border-gray-200">
                    <h2 class="text-lg font-bold text-gray-900">任务列表</h2>
                </div>
                
                <div class="divide-y divide-gray-200">
                    <div v-for="module in sortedModules" :key="module.module_id" 
                         class="p-4 hover:bg-gray-50 transition-colors"
                         :class="getModuleRowClass(module)"
                    >
                        <div class="flex items-start justify-between gap-4">
                            <div class="flex-1">
                                <div class="flex items-center gap-2 mb-2">
                                    <span class="text-xs font-mono text-gray-500">{{ module.module_id }}</span>
                                    <span class="text-xs px-2 py-0.5 rounded-full border" :class="getStatusBadgeClass(module.status)">
                                        {{ formatStatus(module.status) }}
                                    </span>
                                    <span v-if="module.priority === 'high'" class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 border border-red-300">
                                        🔥 高优先级
                                    </span>
                                </div>
                                
                                <h3 class="text-base font-semibold text-gray-900 mb-2">{{ module.name }}</h3>
                                
                                <!-- Assigned Agents -->
                                <div v-if="module.assigned_to" class="flex items-center gap-3 mb-2">
                                    <div class="flex items-center gap-1 text-sm">
                                        <span class="text-gray-500">🔨 Builder:</span>
                                        <span class="font-medium text-gray-700">{{ module.assigned_to.builder || '未分配' }}</span>
                                    </div>
                                    <div class="flex items-center gap-1 text-sm">
                                        <span class="text-gray-500">👁️ Reviewer:</span>
                                        <span class="font-medium text-gray-700">{{ module.assigned_to.reviewer || '未分配' }}</span>
                                    </div>
                                </div>
                                
                                <!-- Progress Info -->
                                <div v-if="module.progress" class="flex items-center gap-4 text-xs text-gray-500">
                                    <span>迭代：{{ module.progress.review_iterations || 0 }} 轮</span>
                                    <span v-if="module.progress.started_at">
                                        开始：{{ formatTime(module.progress.started_at) }}
                                    </span>
                                    <span v-if="module.progress.completed_at">
                                        完成：{{ formatTime(module.progress.completed_at) }}
                                    </span>
                                </div>
                                
                                <!-- Dependencies -->
                                <div v-if="module.dependencies && module.dependencies.length > 0" class="mt-2">
                                    <span class="text-xs text-gray-500">依赖：</span>
                                    <span v-for="dep in module.dependencies" :key="dep" class="text-xs text-blue-600 mr-2">
                                        {{ dep }}
                                    </span>
                                </div>
                            </div>
                            
                            <div class="flex-shrink-0">
                                <button 
                                    @click="toggleModuleDetail(module.module_id)"
                                    class="text-sm text-blue-600 hover:text-blue-800 font-medium"
                                >
                                    {{ expandedModules.includes(module.module_id) ? '收起' : '展开' }}
                                </button>
                            </div>
                        </div>
                        
                        <!-- Expanded Detail -->
                        <div v-if="expandedModules.includes(module.module_id)" class="mt-4 pt-4 border-t border-gray-200">
                            
                            <!-- Metadata: Success Criteria -->
                            <div v-if="module.metadata && module.metadata.success_criteria" class="mb-4">
                                <h4 class="text-sm font-semibold text-gray-700 mb-2">✅ 验收标准</h4>
                                <ul class="list-disc list-inside text-xs text-gray-600 space-y-0.5">
                                    <li v-for="(c, ci) in module.metadata.success_criteria" :key="ci">{{ c }}</li>
                                </ul>
                            </div>

                            <!-- Deliverables -->
                            <div v-if="getDeliverablesList(module).length > 0" class="mb-4">
                                <h4 class="text-sm font-semibold text-gray-700 mb-2">📦 交付物</h4>
                                <div class="flex flex-wrap gap-2">
                                    <a v-for="(item, idx) in getDeliverablesList(module)" :key="idx"
                                       :href="getDeliverableLink(module.module_id, item)"
                                       target="_blank"
                                       class="px-2.5 py-1 bg-blue-50 rounded border border-blue-200 text-xs text-blue-700 font-mono hover:bg-blue-100 hover:border-blue-300 transition-colors inline-flex items-center gap-1">
                                        <svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
                                        <span class="truncate max-w-[200px]">{{ item }}</span>
                                    </a>
                                </div>
                            </div>
                            
                            <!-- Review Iterations -->
                            <div v-if="module.review_history && module.review_history.length > 0" class="mb-4">
                                <h4 class="text-sm font-semibold text-gray-700 mb-2">📋 审查记录</h4>
                                <div class="space-y-3">
                                    <div v-for="(it, index) in module.review_history" :key="index"
                                         class="rounded-lg overflow-hidden border"
                                         :class="getIterationBorderClass(it.status)">
                                        <!-- Header bar -->
                                        <div class="flex items-center justify-between px-3 py-2"
                                             :class="getIterationBgClass(it.status)">
                                            <div class="flex items-center gap-2">
                                                <span class="text-xs font-semibold">第 {{ it.iteration }} 轮</span>
                                                <span class="text-xs px-2 py-0.5 rounded" :class="getIterationStatusClass(it.status)">
                                                    {{ formatStatus(it.status) }}
                                                </span>
                                            </div>
                                            <div class="flex items-center gap-3">
                                                <span class="text-xs text-gray-500">{{ formatTime(it.timestamp) }}</span>
                                                <button @click="toggleIterationDetail(module.module_id, it.iteration)"
                                                        class="text-xs text-blue-600 hover:text-blue-800 transition-colors">
                                                    {{ isIterationExpanded(module.module_id, it.iteration) ? '收起' : '展开详情' }}
                                                </button>
                                            </div>
                                        </div>
                                        
                                        <!-- Detail panel -->
                                        <div v-if="isIterationExpanded(module.module_id, it.iteration)" class="px-3 py-3 bg-white space-y-3">
                                            
                                            <!-- Parsed checklist -->
                                            <div v-if="getParsedChecks(it.summary).length > 0">
                                                <p class="text-xs font-medium text-gray-500 mb-1.5">检查项：</p>
                                                <div class="space-y-1">
                                                    <div v-for="(check, ci) in getParsedChecks(it.summary)" :key="ci"
                                                         class="flex items-start gap-2 text-xs"
                                                         :class="check.type === 'passed' ? 'text-green-700' : check.type === 'failed' ? 'text-red-700' : check.type === 'info' ? 'text-gray-500' : 'text-amber-700'">
                                                        <span class="flex-shrink-0 mt-0.5 leading-none">{{ check.icon }}</span>
                                                        <span class="leading-relaxed">{{ check.text }}</span>
                                                    </div>
                                                </div>
                                            </div>
                                            
                                            <!-- Non-check summary lines -->
                                            <div v-if="getNonCheckLines(it.summary).length > 0" class="text-xs text-gray-600 space-y-1">
                                                <div v-for="(line, li) in getNonCheckLines(it.summary)" :key="li">{{ line }}</div>
                                            </div>
                                            
                                            <!-- Feedback (only if different from summary) -->
                                            <div v-if="it.feedback && !isSameAsSummary(it.summary, it.feedback)" class="pt-3 border-t border-gray-100">
                                                <p class="text-xs font-medium text-gray-500 mb-1">反馈：</p>
                                                <div class="text-xs text-gray-600 space-y-1 leading-relaxed" v-html="renderMarkdown(it.feedback)"></div>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                            
                            <!-- Handoff (only if distinct from review) -->
                            <div v-if="module.handoff_summary && isHandoffDistinct(module)" class="mb-4">
                                <h4 class="text-sm font-semibold text-gray-700 mb-2">🤝 交接</h4>
                                <div class="bg-green-50 border border-green-200 rounded-lg p-3">
                                    <div class="text-sm text-gray-700 markdown-body" v-html="renderMarkdown(module.handoff_summary)"></div>
                                </div>
                            </div>
                            
                            <!-- Notes -->
                            <div v-if="module.notes" class="mb-4">
                                <h4 class="text-sm font-semibold text-gray-700 mb-2">📝 备注</h4>
                                <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
                                    <p class="text-sm text-gray-700">{{ module.notes }}</p>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Agent Logs Section -->
            <div v-if="currentProject && !currentProject.is_request" class="bg-white rounded-lg shadow mt-6">
                <div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
                    <h2 class="text-lg font-bold text-gray-900">📜 Agent 活动日志 <span class="text-sm font-normal text-gray-500">({{ projectLogs.length }} 条)</span></h2>
                    <button @click="toggleLogs" class="text-sm text-blue-600 hover:text-blue-800 font-medium">
                        {{ logsExpanded ? '收起' : '展开' }}
                    </button>
                </div>
                
                <div v-if="logsExpanded">
                    <div v-if="projectLogs.length === 0" class="p-6 text-center text-gray-500 text-sm">
                        暂无日志记录
                    </div>
                    <div v-else class="divide-y divide-gray-200">
                        <div v-for="(log, idx) in projectLogs" :key="idx" class="p-4">
                            <!-- Log header -->
                            <div class="flex items-center gap-2 mb-3">
                                <span class="text-lg">{{ getLogRoleIcon(log.agent) }}</span>
                                <span class="text-sm font-medium text-gray-900">{{ getLogRoleLabel(log.agent) }}</span>
                                <span class="text-xs text-gray-400">{{ getLogTimestamp(log.timestamp) }}</span>
                            </div>
                            <!-- Log entry -->
                            <div class="flex items-start gap-2 font-mono text-xs">
                                <span class="text-gray-400 whitespace-nowrap">{{ log.timestamp }}</span>
                                <span class="px-1.5 py-0.5 rounded font-semibold whitespace-nowrap"
                                      :class="getAgentBadgeClass(log.agent)">
                                    {{ log.agent }}
                                </span>
                                <span class="text-gray-700 leading-relaxed">{{ log.message }}</span>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Project Final Deliverables (shown when project is 100%) -->
            <div v-if="currentProject && !currentProject.is_request && summary.completion_percentage === 100" class="bg-gradient-to-r from-emerald-500 to-green-600 rounded-lg shadow-lg p-6 mt-6 mb-6 text-white">
                <h2 class="text-xl font-bold mb-4">🎉 项目最终交付物</h2>
                <div v-if="getProjectFinalDeliverables().length > 0" class="space-y-3">
                    <div v-for="(del, idx) in getProjectFinalDeliverables()" :key="idx" 
                         class="bg-white/10 backdrop-blur rounded-lg p-3">
                        <div class="flex items-center gap-3">
                            <span class="text-2xl">{{ del.icon }}</span>
                            <div class="flex-1">
                                <p class="font-semibold">{{ del.label }}</p>
                                <p class="text-green-100 text-sm">{{ del.description }}</p>
                            </div>
                            <a :href="del.url" :target="del.external ? '_blank' : ''"
                               class="px-4 py-2 bg-white text-green-700 rounded-lg font-medium text-sm hover:bg-green-50 transition-colors flex items-center gap-1">
                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
                                打开
                            </a>
                        </div>
                    </div>
                </div>
                <div v-else class="text-green-100 text-sm italic">暂无自动检测的交付物（需 Orchestrator 标记最终交付物）</div>
            </div>
        </main>
    </div>

    <script>
        const { createApp } = Vue;
        const md = window.markdownit();

        createApp({
            data() {
                return {
                    projectState: { modules: [] },
                    loading: false,
                    expandedModules: [],
                    _iterationExpanded: {},
                    descExpanded: false,
                    logsExpanded: false,
                    projectLogs: [],
                    expandedLogFiles: [],
                    lastLoaded: null,
                    refreshTimer: null,
                    recentActivity: null,
                    previousModules: [],
                    allProjects: [],
                    currentProject: null,
                }
            },
            computed: {
                lastUpdateTime() {
                    if (!this.lastLoaded) return '从未加载';
                    return dayjs(this.lastLoaded).format('HH:mm:ss');
                },
                completedProjectsCount() {
                    return this.allProjects.filter(p => p.status === 'completed').length;
                },
                inProgressProjectsCount() {
                    return this.allProjects.filter(p => p.status === 'in_progress').length;
                },
                pendingProjectsCount() {
                    return this.allProjects.filter(p => p.status === 'pending' || p.is_request).length;
                },
                summary() {
                    const modules = this.getModules();
                    const total = modules.length;
                    const completed = modules.filter(m => 
                        (m.status || m.state) === 'accepted' || (m.status || m.state) === 'passed'
                    ).length;
                    const blocked = modules.filter(m => 
                        (m.status || m.state) === 'blocked'
                    ).length;
                    // Use statistics if available for more accurate data
                    const stats = this.projectState.statistics;
                    if (stats) {
                        return {
                            total_modules: stats.total_modules || total,
                            completed_modules: stats.completed_modules || completed,
                            blocked_modules: stats.blocked_modules || blocked,
                            completion_percentage: stats.completion_percentage != null ? stats.completion_percentage : (total > 0 ? Math.round((completed / total) * 100) : 0)
                        };
                    }
                    return {
                        total_modules: total,
                        completed_modules: completed,
                        blocked_modules: blocked,
                        completion_percentage: total > 0 ? Math.round((completed / total) * 100) : 0
                    };
                },
                inReviewCount() {
                    const modules = this.getModules();
                    return modules.filter(
                        m => ['ready_for_review', 'in_review', 'needs_work'].includes(m.status || m.state)
                    ).length;
                },
                currentlyWorkingAgents() {
                    if (this.projectState.status === 'completed') return [];
                    const agents = [];
                    const modules = this.getModules();
                    const now = dayjs();
                    modules.forEach(module => {
                        if (!module.assigned_to) return;
                        if ((module.status || module.state) === 'in_progress') {
                            const startedAt = dayjs(module.progress?.started_at || module.assigned_to.assigned_at);
                            const elapsedHours = now.diff(startedAt, 'hour', true);
                            const elapsedTime = elapsedHours < 0.1 ? '刚刚' : elapsedHours < 1 ? Math.round(elapsedHours * 60) + ' 分钟' : Math.round(elapsedHours * 10) / 10 + ' 小时';
                            agents.push({
                                module_id: module.module_id,
                                role: 'builder',
                                agent_id: module.assigned_to.builder,
                                started_at: module.assigned_to.assigned_at,
                                elapsed_time: elapsedTime
                            });
                        }
                        if (['in_review', 'needs_work'].includes(module.status || module.state)) {
                            const startedAt = dayjs(module.progress?.first_review_at || module.progress?.started_at || module.assigned_to.assigned_at);
                            const elapsedHours = now.diff(startedAt, 'hour', true);
                            const elapsedTime = elapsedHours < 0.1 ? '刚刚' : elapsedHours < 1 ? Math.round(elapsedHours * 60) + ' 分钟' : Math.round(elapsedHours * 10) / 10 + ' 小时';
                            agents.push({
                                module_id: module.module_id,
                                role: 'reviewer',
                                agent_id: module.assigned_to.reviewer,
                                started_at: module.assigned_to.assigned_at,
                                elapsed_time: elapsedTime
                            });
                        }
                    });
                    return agents;
                },
                allAgents() {
                    const agents = [];
                    const seen = new Set();
                    const now = dayjs();
                    const modules = this.getModules();
                    const fmtDuration = (startMs, endMs) => {
                        if (!startMs) return '';
                        const diff = (endMs || now) - startMs;
                        const mins = diff / 60000;
                        if (mins < 1) return '刚刚';
                        if (mins < 60) return Math.round(mins) + ' 分钟';
                        const hrs = mins / 60;
                        if (hrs < 24) return Math.round(hrs * 10) / 10 + ' 小时';
                        const days = hrs / 24;
                        return Math.round(days * 10) / 10 + ' 天';
                    };
                    modules.forEach(module => {
                        const p = module.progress || {};
                        // Check assigned_to field
                        if (module.assigned_to && module.assigned_to.builder && !seen.has(module.assigned_to.builder)) {
                            seen.add(module.assigned_to.builder);
                            const isWorking = module.status === 'in_progress';
                            const isDone = module.status === 'accepted' || module.status === 'passed';
                            const startedAt = p.started_at ? dayjs(p.started_at) : null;
                            const completedAt = p.completed_at ? dayjs(p.completed_at) : null;
                            const runtime = startedAt ? fmtDuration(startedAt.valueOf(), completedAt ? completedAt.valueOf() : null) : '';
                            const elapsed = isWorking && startedAt ? fmtDuration(startedAt.valueOf(), null) : '';
                            agents.push({ id: module.assigned_to.builder, role: 'builder', status: isWorking ? 'working' : isDone ? 'done' : 'idle', moduleName: module.name, elapsed, runtime });
                        }
                        if (module.assigned_to && module.assigned_to.reviewer && !seen.has(module.assigned_to.reviewer)) {
                            seen.add(module.assigned_to.reviewer);
                            const isWorking = ['in_review','needs_work'].includes(module.status);
                            const isDone = module.status === 'accepted' || module.status === 'passed';
                            const startedAt = p.first_review_at ? dayjs(p.first_review_at) : null;
                            const completedAt = p.completed_at ? dayjs(p.completed_at) : null;
                            const runtime = startedAt ? fmtDuration(startedAt.valueOf(), completedAt ? completedAt.valueOf() : null) : '';
                            const elapsed = isWorking && startedAt ? fmtDuration(startedAt.valueOf(), null) : '';
                            agents.push({ id: module.assigned_to.reviewer, role: 'reviewer', status: isWorking ? 'working' : isDone ? 'done' : 'idle', moduleName: module.name, elapsed, runtime });
                        }
                        // Check agent_assignments field
                        if (module.agent_assignments && Array.isArray(module.agent_assignments)) {
                            module.agent_assignments.forEach(a => {
                                const aid = a.agent_id || a.id;
                                if (aid && !seen.has(aid)) {
                                    seen.add(aid);
                                    const role = a.role || 'builder';
                                    const isWorking = a.status === 'working' || a.status === 'in_progress';
                                    const isDone = a.status === 'done' || a.status === 'completed' || a.status === 'accepted';
                                    agents.push({ id: aid, role, status: isWorking ? 'working' : isDone ? 'done' : 'idle', moduleName: module.name, elapsed: a.elapsed || '', runtime: a.runtime || '' });
                                }
                            });
                        }
                    });
                    // Also extract agents from timeline logs
                    if (this.projectLogs && this.projectLogs.length > 0) {
                        const logAgents = new Map();
                        this.projectLogs.forEach(log => {
                            const agent = log.agent || '';
                            const a = agent.toLowerCase();
                            if (a.startsWith('builder-') || a.startsWith('reviewer-')) {
                                if (!seen.has(agent)) {
                                    const isBuilder = a.startsWith('builder-');
                                    const role = isBuilder ? 'builder' : 'reviewer';
                                    // Infer module from agent name: builder-game-engine-001 → game-engine
                                    let moduleId = agent.replace(/^(builder|reviewer)-/, '').replace(/-\d+$/, '');
                                    const mod = modules.find(m => m.module_id === moduleId);
                                    const modName = mod ? mod.name : moduleId;
                                    const isDone = mod && (mod.state === 'accepted' || mod.status === 'passed');
                                    const isWorking = mod && (mod.state === 'in_progress' || mod.status === 'in_review');
                                    logAgents.set(agent, { id: agent, role, status: isDone ? 'done' : isWorking ? 'working' : 'idle', moduleName: modName, elapsed: '', runtime: '' });
                                    seen.add(agent);
                                }
                            }
                        });
                        logAgents.forEach(a => agents.push(a));
                    }
                    return agents;
                },
                sortedModules() {
                    const priorityOrder = { high: 0, medium: 1, low: 2 };
                    const statusOrder = {
                        blocked: 0, needs_work: 1, in_review: 2, ready_for_review: 3,
                        in_progress: 4, pending: 5, passed: 6, accepted: 7, rejected: 8
                    };
                    return [...this.getModules()].sort((a, b) => {
                        const sa = statusOrder[a.status] ?? 9;
                        const sb = statusOrder[b.status] ?? 9;
                        if (sa !== sb) return sa - sb;
                        return (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1);
                    });
                },
                overallStatusColor() {
                    const colors = {
                        planning: 'border-gray-500',
                        in_progress: 'border-blue-500',
                        blocked: 'border-red-500',
                        completed: 'border-green-500',
                        cancelled: 'border-gray-400'
                    };
                    return colors[this.projectState.status] || 'border-gray-500';
                }
            },
            methods: {
                getModules() {
                    const m = this.projectState.modules || [];
                    return Array.isArray(m) ? m : Object.values(m);
                },
                async refreshData() {
                    this.loading = true;
                    try {
                        await this.scanProjects();
                        if (this.currentProject?.id) {
                            await this.loadProject(this.currentProject);
                        } else if (this.allProjects.length > 0) {
                            // Prefer in_progress projects, fallback to first
                            const active = this.allProjects.find(p => p.status === 'in_progress') || this.allProjects[0];
                            this.selectProject(active);
                        }
                    } catch (error) {
                        console.error('Failed to load data:', error);
                    } finally {
                        this.loading = false;
                    }
                },
                async scanProjects() {
                    try {
                        const res = await fetch('/api/projects?t=' + Date.now());
                        if (res.ok) {
                            const projects = await res.json();
                            // Preserve selection state
                            const currentId = this.currentProject?.id;
                            this.allProjects = projects.sort((a, b) => {
                                const order = { in_progress: 0, planning: 1, pending: 2, completed: 3 };
                                return (order[a.status] ?? 9) - (order[b.status] ?? 9);
                            });
                            // Re-select if project still exists
                            if (currentId) {
                                this.currentProject = this.allProjects.find(p => p.id === currentId) || this.currentProject;
                            }
                        }
                    } catch (e) {
                        console.error('API scan failed:', e);
                    }
                },
                async loadProject(project) {
                    if (project.is_request || !project.path) return;
                    this.previousModules = JSON.parse(JSON.stringify(this.getModules()));
                    try {
                        const res = await fetch(project.path + '?t=' + Date.now());
                        if (res.ok) {
                            const newState = await res.json();
                            this.detectChanges(newState);
                            this.projectState = newState;
                            this.lastLoaded = new Date().toISOString();
                        }
                    } catch (e) {
                        console.error('Failed to load project:', e);
                    }
                    // Load logs
                    this.loadLogs();
                },
                async loadLogs() {
                    if (!this.currentProject) return;
                    let projectId = this.currentProject.id;
                    // For archived projects, try to find the actual project directory
                    if (projectId.startsWith('archived-')) {
                        // Try to find the matching project-state.json in subdirectories
                        projectId = this.findProjectDirForArchive(this.currentProject);
                    }
                    if (!projectId) return;
                    try {
                        const res = await fetch('/api/logs/' + projectId + '?t=' + Date.now());
                        if (res.ok) {
                            this.projectLogs = await res.json();
                        }
                    } catch (e) {
                        console.error('Failed to load logs:', e);
                        this.projectLogs = [];
                    }
                },
                findProjectDirForArchive(project) {
                    // Try to find the actual project directory from the outputDir or name
                    if (project.outputDir) return project.outputDir;
                    // Fallback: search for matching project-state.json
                    return null;
                },
                selectProject(project) {
                    this.currentProject = project;
                    this.expandedModules = [];
                    this.recentActivity = null;
                    this.logsExpanded = false;
                    this.expandedLogFiles = [];
                    this.projectLogs = [];
                    if (!project.is_request) {
                        this.loadProject(project);
                    }
                },
                // Log display methods
                toggleLogs() {
                    this.logsExpanded = !this.logsExpanded;
                    if (this.logsExpanded && this.projectLogs.length === 0) {
                        this.loadLogs();
                    }
                },
                toggleLogFile(filename) {
                    const i = this.expandedLogFiles.indexOf(filename);
                    if (i > -1) this.expandedLogFiles.splice(i, 1);
                    else this.expandedLogFiles.push(filename);
                },
                isLogFileExpanded(filename) {
                    return this.expandedLogFiles.includes(filename);
                },
                getLogRoleIcon(filename) {
                    const f = filename.toLowerCase();
                    if (f.includes('orchestrator')) return '🎭';
                    if (f.includes('builder')) return '🔨';
                    if (f.includes('reviewer')) return '👁️';
                    return '📋';
                },
                getLogRoleLabel(filename) {
                    const f = filename.toLowerCase();
                    if (f.includes('orchestrator')) return 'Orchestrator';
                    if (f.includes('builder')) return 'Builder';
                    if (f.includes('reviewer')) return 'Reviewer';
                    return 'Agent';
                },
                getLogTimestamp(val) {
                    if (!val) return '';
                    // If it's a full ISO timestamp (from timeline.log parsed format)
                    const iso = val.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/);
                    if (iso) {
                        return dayjs(iso[1]).format('MM-DD HH:mm');
                    }
                    // If it's a filename format
                    const m = val.match(/(\d{4}-\d{2}-\d{2})T?(\d{2})?(\d{2})?(\d{2})?/);
                    if (m) {
                        const date = m[1];
                        const time = m[2] ? `${m[2]}:${m[3] || '00'}` : '';
                        return time ? `${date} ${time}` : date;
                    }
                    return '';
                },
                getAgentBadgeClass(agentName) {
                    const a = agentName.toLowerCase();
                    if (a === 'orchestrator') return 'bg-blue-100 text-blue-700';
                    if (a.startsWith('builder')) return 'bg-green-100 text-green-700';
                    if (a.startsWith('reviewer')) return 'bg-orange-100 text-orange-700';
                    return 'bg-gray-100 text-gray-600';
                },
                detectChanges(newState) {
                    const old = this.previousModules;
                    if (!old || old.length === 0) return;
                    const newMods = this.getModules.call({ projectState: newState });
                    const changes = [];
                    newMods.forEach(nm => {
                        const om = old.find(m => m.module_id === nm.module_id);
                        if (om && (om.status !== nm.status)) {
                            changes.push(`${nm.module_id}: ${this.formatStatus(om.status)} → ${this.formatStatus(nm.status)}`);
                        }
                    });
                    if (changes.length > 0) {
                        this.recentActivity = changes.join(', ');
                        setTimeout(() => this.recentActivity = null, 5000);
                    }
                },
                getProjectCardClass(project) {
                    if (project.is_request) return 'border-yellow-200 bg-yellow-50';
                    if (project.id === this.currentProject?.id) return 'border-blue-500 bg-blue-50 ring-2 ring-blue-200';
                    if (project.status === 'completed') return 'border-green-200 bg-green-50';
                    if (project.status === 'in_progress') return 'border-blue-200 bg-blue-50';
                    return 'border-gray-200 bg-white';
                },
                getProjectStatusBadgeClass(status) {
                    const c = { completed: 'bg-green-100 text-green-700', in_progress: 'bg-blue-100 text-blue-700', planning: 'bg-gray-100 text-gray-700', pending: 'bg-yellow-100 text-yellow-700' };
                    return c[status] || 'bg-gray-100 text-gray-700';
                },
                getProjectProgressColor(status) {
                    const c = { completed: 'bg-green-500', in_progress: 'bg-blue-500', planning: 'bg-gray-400', blocked: 'bg-red-500' };
                    return c[status] || 'bg-gray-400';
                },
                toggleModuleDetail(moduleId) {
                    const i = this.expandedModules.indexOf(moduleId);
                    if (i > -1) this.expandedModules.splice(i, 1);
                    else this.expandedModules.push(moduleId);
                },
                formatStatus(status) {
                    const m = { pending: '等待中', in_progress: '进行中', ready_for_review: '待审查', in_review: '审查中', needs_work: '需修改', passed: '已通过', accepted: '已验收', rejected: '已打回', blocked: '已阻塞', planning: '规划中', completed: '已完成' };
                    return m[status] || status;
                },
                getStatusBadgeClass(status) {
                    const c = { pending: 'bg-gray-100 text-gray-700 border-gray-300', in_progress: 'bg-blue-100 text-blue-700 border-blue-300', ready_for_review: 'bg-yellow-100 text-yellow-700 border-yellow-300', in_review: 'bg-orange-100 text-orange-700 border-orange-300', needs_work: 'bg-red-100 text-red-700 border-red-300', passed: 'bg-green-100 text-green-700 border-green-300', accepted: 'bg-emerald-100 text-emerald-700 border-emerald-300', rejected: 'bg-red-200 text-red-800 border-red-400', blocked: 'bg-purple-100 text-purple-700 border-purple-300' };
                    return c[status] || 'bg-gray-100 text-gray-700 border-gray-300';
                },
                getAgentRoleBadgeClass(role) {
                    return role === 'builder' ? 'bg-blue-100 text-blue-700 border border-blue-300' : 'bg-purple-100 text-purple-700 border border-purple-300';
                },
                getIterationBorderClass(status) {
                    return { needs_work: 'border-red-400', in_review: 'border-orange-400', passed: 'border-green-400', accepted: 'border-emerald-400' }[status] || 'border-gray-400';
                },
                getIterationBgClass(status) {
                    return { needs_work: 'bg-red-50', in_review: 'bg-orange-50', passed: 'bg-green-50', accepted: 'bg-emerald-50' }[status] || 'bg-gray-50';
                },
                getIterationStatusClass(status) { return this.getStatusBadgeClass(status); },
                getModuleRowClass(module) {
                    return { in_progress: 'bg-blue-50/30', in_review: 'bg-orange-50/30', needs_work: 'bg-red-50/30', blocked: 'bg-purple-50/30' }[module.status] || '';
                },
                getFullDescription() {
                    return (this.projectState.description || this.currentProject?.description || '');
                },
                getModuleName(moduleId) {
                    return this.getModules().find(m => m.module_id === moduleId)?.name || moduleId;
                },
                formatTime(ts) {
                    if (!ts) return '';
                    return dayjs(ts).format('MM-DD HH:mm');
                },
                renderMarkdown(text) {
                    if (!text) return '';
                    return md.render(text);
                },
                // Iteration detail toggle (per module+iteration)
                toggleIterationDetail(moduleId, iteration) {
                    const key = `${moduleId}::${iteration}`;
                    if (!this._iterationExpanded) this._iterationExpanded = {};
                    this._iterationExpanded[key] = !this._iterationExpanded[key];
                },
                isIterationExpanded(moduleId, iteration) {
                    if (!this._iterationExpanded) this._iterationExpanded = {};
                    return !!this._iterationExpanded[`${moduleId}::${iteration}`];
                },
                // Parse [PASSED] / [FAILED] / [INFO] style checklist items from summary
                getParsedChecks(summary) {
                    if (!summary) return [];
                    const checks = [];
                    const lines = summary.split('\n');
                    for (const line of lines) {
                        const trimmed = line.trim();
                        // Match [PASSED] ... or [FAILED] ... or [INFO] ...
                        const m = trimmed.match(/^\[(PASSED|FAILED|INFO)\]\s+(.*)/);
                        if (m) {
                            checks.push({
                                type: m[1].toLowerCase(),
                                icon: m[1] === 'PASSED' ? '✅' : m[1] === 'FAILED' ? '❌' : 'ℹ️',
                                text: m[2]
                            });
                        }
                    }
                    return checks;
                },
                // Get non-checklist lines from summary (overview text)
                getNonCheckLines(summary) {
                    if (!summary) return [];
                    return summary.split('\n')
                        .map(l => l.trim())
                        .filter(l => l && !l.match(/^\[(PASSED|FAILED|INFO)\]/))
                        .filter(l => !l.match(/^REVIEW\s*—/)); // skip header line
                },
                // Check if handoff_summary is the same as the last review summary
                isHandoffDistinct(module) {
                    const lastReview = module.review_history && module.review_history.length > 0
                        ? module.review_history[module.review_history.length - 1] : null;
                    if (!lastReview || !lastReview.summary) return true;
                    if (!module.handoff_summary) return true;
                    // Normalize whitespace for comparison
                    const normalize = s => s.replace(/\s+/g, ' ').trim();
                    return normalize(lastReview.summary) !== normalize(module.handoff_summary);
                },
                // Check if feedback is same as summary
                isSameAsSummary(summary, feedback) {
                    if (!summary || !feedback) return false;
                    const normalize = s => s.replace(/\s+/g, ' ').trim();
                    return normalize(summary) === normalize(feedback);
                },
                getDeliverablesList(module) {
                    // Merge deliverables_completed with metadata.deliverables paths
                    const items = new Set();
                    if (module.deliverables_completed) {
                        module.deliverables_completed.forEach(d => items.add(typeof d === 'string' ? d : d.path || d));
                    }
                    if (module.metadata && module.metadata.deliverables) {
                        module.metadata.deliverables.forEach(d => items.add(typeof d === 'string' ? d : d.path));
                    }
                    return [...items];
                },
                getDeliverableLink(moduleId, item) {
                    const clean = item.replace(/^\.\//, '');
                    let projectDir = '';
                    // Priority: outputDir > id from path > fallback
                    if (this.currentProject?.outputDir) {
                        projectDir = '/projects/' + this.currentProject.outputDir + '/';
                    } else if (this.currentProject?.id && this.currentProject.id !== 'default') {
                        projectDir = '/projects/' + this.currentProject.id + '/';
                    } else if (this.currentProject?.path) {
                        const parts = this.currentProject.path.split('/');
                        projectDir = parts.slice(0, -1).join('/') + '/';
                    } else {
                        projectDir = '/projects/';
                    }
                    return projectDir + clean;
                },
                getProjectOutputDir() {
                    // Priority: explicit outputDir from API
                    if (this.currentProject?.outputDir) return this.currentProject.outputDir;
                    if (this.currentProject?.id && this.currentProject.id !== 'default') {
                        return this.currentProject.id;
                    }
                    // Fallback: infer from name
                    if (this.projectState?.name?.includes('像素风')) return 'pixel-art-generator';
                    if (this.projectState?.name?.includes('星空')) return 'realtime-sky-simulator-001/star-simulator';
                    return '';
                },
                getProjectFinalDeliverables() {
                    const modules = this.getModules();
                    const allAccepted = modules.every(m => (m.state || m.status) === 'accepted');
                    if (!allAccepted) return [];
                    
                    const outputDir = this.getProjectOutputDir();
                    const projectDir = '/projects/' + outputDir + '/';
                    
                    const deliverables = [];
                    const seen = new Set();
                    
                    // Find index.html entry points (deduplicate by URL)
                    modules.forEach(m => {
                        const paths = this.getDeliverablesList(m);
                        paths.forEach(p => {
                            if (p.endsWith('index.html')) {
                                const url = projectDir + p;
                                if (!seen.has(url)) {
                                    seen.add(url);
                                    const name = this.projectState?.name || this.currentProject?.name || '';
                                    deliverables.push({
                                        icon: '🌐',
                                        label: name || 'Web 应用',
                                        description: '可直接浏览器打开的入口文件',
                                        url: url
                                    });
                                }
                            }
                        });
                    });
                    
                    return deliverables;
                },
            },
            mounted() {
                this.refreshData();
                this.refreshTimer = setInterval(() => this.refreshData(), 5000);
            },
            beforeUnmount() {
                if (this.refreshTimer) clearInterval(this.refreshTimer);
            }
        }).mount('#app');
    </script>
</body>
</html>


path: assets/server.py
content: #!/usr/bin/env python3
"""OpenClaw Harness Dashboard Server - Python version"""

import http.server
import json
import os
import re
import urllib.parse
import socketserver

PORT = 8765
# BASE_DIR is the directory containing this server.py.
# When copied to a project root, it serves files from that root.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
REQUESTS_DIR = os.path.join(BASE_DIR, 'requests')

MIME_TYPES = {
    '.html': 'text/html; charset=utf-8',
    '.js': 'application/javascript',
    '.css': 'text/css',
    '.json': 'application/json',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.svg': 'image/svg+xml',
}

def find_project_states():
    projects = []
    for root, dirs, files in os.walk(BASE_DIR):
        if 'node_modules' in root:
            continue
        if 'project-state.json' in files:
            full = os.path.join(root, 'project-state.json')
            try:
                with open(full, 'r') as f:
                    state = json.load(f)
                rel = os.path.relpath(full, BASE_DIR)
                project_dir = os.path.dirname(rel)
                project_id = 'default' if project_dir == '.' else project_dir
                modules = state.get('modules', [])
                if isinstance(modules, dict):
                    modules = list(modules.values())
                total = len(modules)
                completed = sum(1 for m in modules if m.get('status') in ('accepted', 'passed') or m.get('state') in ('accepted', 'passed'))
                top_status = state.get('status')
                if top_status and top_status != 'unknown':
                    inferred = top_status
                elif completed == total and total > 0:
                    inferred = 'completed'
                elif any(m.get('status') == 'in_progress' or m.get('state') == 'in_progress' for m in modules):
                    inferred = 'in_progress'
                elif all(m.get('status') == 'pending' or m.get('state') == 'pending' for m in modules):
                    inferred = 'pending'
                else:
                    inferred = 'in_progress'
                projects.append({
                    'id': project_id,
                    'path': '/projects/' + rel,
                    'name': state.get('project_name') or state.get('name', project_id),
                    'status': inferred,
                    'progress': round(completed / total * 100) if total > 0 else 0,
                    'total_modules': total,
                    'completed_modules': completed,
                    'description': state.get('description', ''),
                    'outputDir': project_dir,
                })
            except Exception as e:
                print('Error loading ' + full + ': ' + str(e))

    archive_dir = os.path.join(BASE_DIR, 'archive')
    if os.path.isdir(archive_dir):
        for af in sorted(os.listdir(archive_dir)):
            if af.endswith('.json'):
                try:
                    with open(os.path.join(archive_dir, af), 'r') as f:
                        state = json.load(f)
                    modules = state.get('modules', [])
                    if isinstance(modules, dict):
                        modules = list(modules.values())
                    total = len(modules)
                    completed = sum(1 for m in modules if m.get('status') in ('accepted', 'passed') or m.get('state') in ('accepted', 'passed'))
                    name = state.get('project_name') or state.get('name', af)
                    stem = os.path.splitext(af)[0]
                    for s in ['-2026-04-06', '-2026-04-07']:
                        stem = stem.replace(s, '')
                    projects.append({
                        'id': 'archived-' + os.path.splitext(af)[0],
                        'path': '/archive/' + af,
                        'name': name,
                        'status': 'completed',
                        'progress': round(completed / total * 100) if total > 0 else 0,
                        'total_modules': total,
                        'completed_modules': completed,
                        'description': state.get('description', ''),
                        'outputDir': stem,
                    })
                except Exception as e:
                    print('Error loading archive ' + af + ': ' + str(e))

    if os.path.isdir(REQUESTS_DIR):
        for rf in sorted(os.listdir(REQUESTS_DIR)):
            if rf.endswith('.json'):
                try:
                    with open(os.path.join(REQUESTS_DIR, rf), 'r') as f:
                        req = json.load(f)
                    req_name = req.get('name', os.path.splitext(rf)[0])
                    if not any(p['name'] == req_name for p in projects):
                        mods = req.get('modules', [])
                        projects.append({
                            'id': 'req-' + os.path.splitext(rf)[0],
                            'path': None,
                            'name': '[待启动] ' + req_name,
                            'status': 'pending',
                            'progress': 0,
                            'total_modules': len(mods),
                            'completed_modules': 0,
                            'description': req.get('description', ''),
                            'is_request': True,
                        })
                except Exception as e:
                    print('Error loading request ' + rf + ': ' + str(e))

    return projects


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        pathname = parsed.path

        if pathname == '/api/projects':
            data = json.dumps(find_project_states())
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(data.encode())))
            self.end_headers()
            self.wfile.write(data.encode())
            return

        if pathname.startswith('/api/logs/'):
            project_id = urllib.parse.unquote(pathname[len('/api/logs/'):])
            logs_dir = os.path.join(BASE_DIR, project_id, 'logs')
            parsed_logs = []
            if os.path.isdir(logs_dir):
                # Read all .log files and merge by timestamp
                for log_file in sorted(os.listdir(logs_dir)):
                    if not log_file.endswith('.log'):
                        continue
                    # Extract agent name from filename
                    agent_name = log_file.replace('.log', '')
                    fp = os.path.join(logs_dir, log_file)
                    if os.path.isfile(fp):
                        with open(fp, 'r') as f:
                            for line in f:
                                line = line.strip()
                                if not line:
                                    continue
                                # Parse: [timestamp] message
                                m = re.match(r'^\[([^\]]+)\]\s+(.*)', line)
                                if m:
                                    timestamp = m[1]
                                    rest = m[2]
                                    # Legacy timeline.log format: [timestamp] [agent_name] message
                                    agent_match = re.match(r'^\[([^\]]+)\]\s+(.*)', rest)
                                    if agent_match:
                                        agent_name = agent_match[1]
                                        message = agent_match[2]
                                    else:
                                        message = rest
                                    # Truncate timestamp microseconds to seconds
                                    if '.' in timestamp:
                                        base, tz = timestamp.split('.')
                                        tz_start = tz.find('+') if '+' in tz else tz.find('Z')
                                        if tz_start >= 0:
                                            timestamp = base + tz[tz_start:]
                                        else:
                                            timestamp = base + tz[-6:] if len(tz) > 6 else base + tz
                                    parsed_logs.append({
                                        'timestamp': timestamp,
                                        'agent': agent_name,
                                        'message': message
                                    })
            # Sort by timestamp (newest first)
            parsed_logs.sort(key=lambda x: x['timestamp'], reverse=True)
            data = json.dumps(parsed_logs)
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(data.encode())))
            self.end_headers()
            self.wfile.write(data.encode())
            return

        if pathname.startswith('/projects/'):
            rel = urllib.parse.unquote(pathname[len('/projects/'):])
            fp = os.path.join(BASE_DIR, rel)
            if os.path.isfile(fp) and self.safe_path(fp):
                self.send_file(fp)
            else:
                self.send_404()
            return

        if pathname.startswith('/archive/'):
            af = urllib.parse.unquote(pathname[len('/archive/'):])
            fp = os.path.join(BASE_DIR, 'archive', af)
            if os.path.isfile(fp) and self.safe_path(fp):
                with open(fp, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            else:
                self.send_404()
            return

        if pathname == '/':
            fp = os.path.join(BASE_DIR, 'dashboard.html')
        else:
            fp = os.path.join(BASE_DIR, pathname.lstrip('/'))

        if os.path.isfile(fp) and self.safe_path(fp):
            self.send_file(fp)
        else:
            self.send_404()

    def safe_path(self, fp):
        """Ensure filepath is within BASE_DIR (prevent path traversal)."""
        real = os.path.realpath(fp)
        return real.startswith(os.path.realpath(BASE_DIR) + os.sep) or real == os.path.realpath(BASE_DIR)

    def send_file(self, fp):
        ext = os.path.splitext(fp)[1].lower()
        ct = MIME_TYPES.get(ext, 'application/octet-stream')
        with open(fp, 'rb') as f:
            content = f.read()
        self.send_response(200)
        self.send_header('Content-Type', ct)
        self.send_header('Content-Length', str(len(content)))
        self.end_headers()
        self.wfile.write(content)

    def send_404(self):
        body = b'<h1>404 Not Found</h1>'
        self.send_response(404)
        self.send_header('Content-Type', 'text/html')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass


class ReusableTCPServer(socketserver.TCPServer):
    allow_reuse_address = True


def main():
    # Bind to localhost only — never expose to public network
    with ReusableTCPServer(("127.0.0.1", PORT), Handler) as httpd:
        print('\n🦞 OpenClaw Harness Dashboard (Python)')
        print('=' * 40)
        print('📂 Serving from: ' + BASE_DIR)
        print('🌐 Dashboard:    http://localhost:' + str(PORT))
        print('📊 API:         http://localhost:' + str(PORT) + '/api/projects')
        print('\nPress Ctrl+C to stop\n')
        httpd.serve_forever()


if __name__ == '__main__':
    main()


path: references/architecture.md
content: # Deep Coding Harness — Project Architecture

## Directory Structure

All paths are relative to your **project root directory** (where `projects-registry.json` and `server.py` live):

```
<project-root>/
├── projects-registry.json          ← All projects overview
├── server.py                       ← Dashboard HTTP server (port 8765)
├── dashboard.html                  ← Dashboard UI
├── requests/                        ← New project requests
│   └── done/                        ← Processed requests
├── logs/                            ← Orchestrator-level logs
├── <project-slug>/                  ← Project directory (e.g., slot-machine/)
│   ├── project-state.json           ← Module states, progress, review history
│   ├── logs/
│   │   ├── orchestrator.log         ← Orchestrator activity
│   │   ├── builder-<module>.log     ← Each Builder writes its own file
│   │   └── reviewer-<module>.log    ← Each Reviewer writes its own file
│   └── <source code>/               ← Generated project files
```

## Project State Format

```json
{
  "name": "Project Name",
  "status": "in_progress | completed | blocked",
  "statistics": {
    "completion_percentage": 0-100,
    "state_distribution": {
      "pending": 0,
      "in_progress": 0,
      "ready_for_review": 0,
      "in_review": 0,
      "needs_revision": 0,
      "accepted": 0
    }
  },
  "modules": [
    {
      "module_id": "module-name",
      "name": "Module Name",
      "description": "What this module does",
      "dependencies": ["dependency-module-id"],
      "state": "pending | in_progress | ready_for_review | in_review | needs_revision | accepted",
      "assigned_to": null,
      "agent_assignments": [],
      "review_history": [
        {
          "iteration": 1,
          "timestamp": "2026-04-07T17:00:00+08:00",
          "status": "passed | needs_revision",
          "summary": "Review summary",
          "checklist": [
            {"criterion": "Criterion name", "passed": true, "detail": "Details"}
          ]
        }
      ],
      "progress": {
        "started_at": null,
        "review_iterations": 0,
        "completed_at": null,
        "accepted_at": null
      }
    }
  ]
}
```

## Module Lifecycle

```
pending → in_progress → ready_for_review → in_review → accepted
                        ↑                    |
                        └── needs_revision ──┘
```

### State Transitions

| From State | Trigger | Action |
|------------|---------|--------|
| `pending` | All dependencies accepted | Set `in_progress` → spawn Builder |
| `in_progress` | Builder completes work | Set `ready_for_review` → spawn Reviewer |
| `ready_for_review` | - | **Immediately spawn Reviewer** → set `in_review` |
| `in_review` | Reviewer completes | Check result: `accepted` or `needs_revision` |
| `needs_revision` | Review feedback | Set `in_progress` → re-spawn Builder with feedback |
| `accepted` | - | No further action, check next module |

### Critical Rules

- **One action per heartbeat cycle**
- **Never leave `ready_for_review` more than one cycle** — spawn Reviewer immediately
- **Reviewer MUST write review result to `review_history` array**
- **E2E smoke test mandatory for bugfixes and new features**
- **DO NOT copy project-state.json to archive/**

## Builder Workflow

1. Spawn via your configured runtime (ACP or subagent)
2. Builder receives task prompt with:
   - Project path and module details
   - Success criteria
   - Files to create/modify
   - Log file path
3. Builder works independently, writes code, tests, docs
4. Builder sets module state to `ready_for_review`
5. Builder appends to `logs/builder-<module>.log`

## Reviewer Workflow

1. Spawn when module is `ready_for_review`
2. Reviewer MUST:
   - Read ALL modified files
   - Actually run/test the application (not just read code)
   - For web projects: serve via HTTP, verify in browser
   - Write detailed review results to `review_history`
3. If all criteria pass → set state to `accepted`
4. If any fail → set state to `needs_revision` with specific feedback
5. Reviewer appends to `logs/reviewer-<module>.log`

## E2E Smoke Test

**Mandatory for bugfixes and new features before delivery:**

1. After module accepted, spawn E2E Reviewer
2. E2E Reviewer:
   - Serves the application via HTTP
   - Opens in browser (Playwright for web projects, if available)
   - Tests the specific fix/feature
   - Confirms no regressions
3. Only after E2E passes → mark project completed

## Dashboard

Dashboard serves at `http://localhost:8765` via `server.py`.

Features:
- Lists all projects with completion status
- Shows per-module states with color coding
- Displays agent activity timeline (merged from per-agent logs)
- Links to project files for direct access
- Auto-refreshes project state

### Server Configuration

- Python 3 HTTP server
- Serves `dashboard.html` at root
- `/api/projects` endpoint returns project list
- `/api/projects/<id>` returns detailed state
- Static file serving for project directories
- PID tracking for graceful shutdown
- **Security**: Binds to `127.0.0.1` only, includes path traversal protection

### Dashboard Display

Projects shown as cards with:
- Project name and status badge
- Completion progress bar
- Module states (colored dots)
- Agent activity timeline
- Quick links to project files

## Agent Communication

- Orchestrator → Builder: via `sessions_spawn` with detailed task prompt
- Builder → Orchestrator: via completion event (push-based)
- Orchestrator → Reviewer: via `sessions_spawn` with review criteria
- Reviewer → Orchestrator: via completion event (push-based)
- Main Agent ↔ Orchestrator: via `sessions_send` to `agent:orchestrator:main`

## Logging Convention

Each agent writes to its own log file (APPEND mode only):

```
logs/orchestrator.log          ← Orchestrator decisions
logs/builder-game-engine.log   ← Builder progress
logs/reviewer-game-engine.log  ← Reviewer findings
```

Timestamp format: ISO 8601 with local timezone

Example: `[2026-04-07T17:00:00+08:00] Started work on module game-engine`


path: references/orchestrator-rules.md
content: # AGENTS.md - Orchestrator

You are the **Orchestrator** for the OpenClaw deep-coding multi-agent system.

## Two Working Modes

### Mode 1: Event-Driven (Primary)
Triggered when main agent sends you a new project request. You decompose, dispatch, and manage the full lifecycle.

### Mode 2: Heartbeat Monitoring (Safety Net)
On each 5-minute heartbeat, check for anomalies and handle them. This is a fallback, not the primary driver.

---

## Configuration

All paths in this document are **relative to your project root directory** (the directory containing `projects-registry.json` and `server.py`).

| Config Key | Default | Description |
|-----------|---------|-------------|
| `BUILDER_RUNTIME` | `"acp"` | Runtime for Builders/Reviewers: `"acp"` or `"subagent"` |
| `BUILDER_AGENT` | `"qoder"` | Agent ID to use (must be configured in your platform) |
| `FALLBACK_AGENTS` | `[]` | Ordered list of fallback agent IDs if primary fails |
| `TIMEZONE` | local system | Timezone for log timestamps |

**How to configure:** Set these values in your Orchestrator's environment or in the heartbeat prompt. If a Builder agent is unavailable, use the fallback chain or switch to `runtime: "subagent"`.

### Builder Agent Options

| Runtime | Agent ID | Requires |
|---------|----------|----------|
| ACP | `qoder` | acpx plugin + qodercli |
| ACP | `claude` | acpx plugin + Claude Code |
| ACP | `codex` | acpx plugin + Codex |
| subagent | (none) | Built-in OpenClaw subagent runtime |

**You are NOT required to use ACP.** If ACP is unavailable, use `runtime: "subagent"` for Builders and Reviewers.

---

## File Locations (Relative Paths)

| File | Relative Path |
|------|--------------|
| Projects registry | `projects-registry.json` |
| New requests | `requests/*.json` |
| Request archive | `requests/done/` |
| Per-project state | `<project-slug>/project-state.json` |
| Per-project logs | `<project-slug>/logs/` |

### Per-Project Structure

Each project gets its own directory under the project root:

```
<project-root>/
├── projects-registry.json          ← All projects overview
├── requests/                        ← New request entry
│   └── done/                        ← Processed requests
├── logs/                            ← Orchestrator-level logs
├── <project-slug>/                  ← Project directory
│   ├── project-state.json           ← This project's detailed state
│   ├── logs/                        ← Agent activity logs (MANDATORY)
│   └── <source code>/               ← Generated code
└── <project-slug-002>/              ← Next project
    ├── project-state.json
    ├── logs/
    └── ...
```

When decomposing a new request, create a project directory with a slug based on the project name (e.g., `realtime-sky-simulator-001`). **Always create a `logs/` directory inside the project folder.**

---

## Mode 1: Event-Driven Workflow

### Phase 1: Receive & Decompose

When you receive a new project request:

1. **Read** the request file (name, description, owner, tags)
2. **Decompose** into 2-4 modules based on the description:
   - Identify logical layers (data → core → render → UI)
   - Each module independently developable with clear dependencies
   - Define: module_id, name, description, dependencies, metadata (deliverables, success_criteria, constraints, review_focus)
3. **Auto-add integration-test module** (强制,不可跳过):
   - `module_id`: "integration-test"
   - `name`: "Integration Test (End-to-End)"
   - `description`: "End-to-end integration testing. Must include: project entry point (index.html), build configuration (package.json), and E2E tests."
   - `dependencies`: [ALL other module_ids]
   - `metadata`: deliverables=[index.html, package.json, tests/integration.test.js]
  
   **硬规则：如果创建 project-state.json 时模块列表中没有 integration-test，视为严重错误。必须重新拆解。**

## Reviewer Task Requirements

When spawning any Reviewer (including integration-test), include in the task prompt:
- **Use Playwright for end-to-end testing** — open the project in a real browser, verify rendering, test interactions (if Playwright is available)
- Reviewers must actually run and test the project, not just read code
- For web projects: serve via HTTP, navigate, interact, screenshot, and verify
- If Playwright is not available, use alternative testing methods or manual verification steps

4. **Write** the updated request file with module definitions
5. **Create** `project-state.json` (all modules "pending") using the enriched format (see Module Format below)
6. **Archive** the request (move to requests/done/)
7. **Update** `projects-registry.json`

### Module Format (enriched structure)

Each module in project-state.json must have:
```json
{
  "module_id": "module-name-001",
  "name": "Module Name",
  "description": "What this module does",
  "status": "pending",
  "manifest_file": "tasks/task-manifest-001.yaml",
  "priority": "high",
  "dependencies": [],
  "assigned_to": null,
  "progress": {
    "started_at": null,
    "review_iterations": 0,
    "completed_at": null,
    "accepted_at": null
  },
  "review_history": [
    {
      "iteration": 1,
      "timestamp": "2026-04-05T22:00:00+08:00",
      "status": "passed|needs_work|in_review",
      "summary": "Brief summary",
      "checklist": [
        {"criterion": "Success criterion 1", "passed": true, "detail": "What was verified"},
        {"criterion": "Success criterion 2", "passed": false, "detail": "What failed"}
      ],
      "feedback": "Detailed feedback",
      "screenshot_path": "path/to/screenshot.png"
    }
  ],
  "metadata": {
    "deliverables": [{"path": "src/file.ts", "description": "Core module"}],
    "success_criteria": ["criterion 1"],
    "constraints": ["constraint 1"],
    "review_focus": ["focus area 1"]
  },
  "handoff_file": "handoffs/module-handoff-001.md",
  "handoff_summary": "Delivery summary after acceptance"
}
```

When updating module state during scheduling:
- Set `assigned_to` with builder/reviewer info when spawning
- Set `progress.started_at` when Builder starts
- Increment `progress.review_iterations` on each review
- Set `progress.completed_at` when Builder finishes
- Set `progress.accepted_at` when Reviewer accepts
- Always update `updated_at` timestamp

### Phase 2: Dispatch & Manage

When you receive auto-announce completion events (push-based, via `streamTo: "parent"`):

| Event | Action |
|-------|--------|
| Builder completed | Verify deliverables → set `ready_for_review` → **write orchestrator log** → **spawn Reviewer** (`streamTo: "parent"`) → **STOP** |
| Reviewer accepted | **MUST write review result to module's review_history array** (iteration, status, summary, feedback) → increment progress.review_iterations → set `accepted` → **write orchestrator log** → check next module |
| Reviewer needs_revision | Set `needs_revision` → **write orchestrator log** → re-spawn Builder |
| All modules accepted | **DO NOT move/delete anything.** Update registry → notify user → **write completion log** → STOP |

### Phase 3: Scheduling Rules

| State | Condition | Action |
|-------|-----------|--------|
| `pending` | all deps accepted | Set `in_progress` → spawn Builder → STOP |
| `ready_for_review` | - | **Immediately spawn Reviewer** → set `in_review` → STOP |
| `in_review` | - | Wait for auto-announce. Do not poll. |
| `needs_revision` | - | Set `in_progress` → re-spawn Builder → STOP |
| `in_progress` | >30 min stale | Check files. If deliverables exist → `ready_for_review`. If not → `blocked`. |
| `accepted` | - | No action. |

**CRITICAL: One scheduling action per heartbeat cycle.**

**HARD RULE: Never leave a module in `ready_for_review` for more than one cycle. Spawn Reviewer immediately.**

**CRITICAL: When a Reviewer completes, you MUST write the review result to the module's `review_history` array in project-state.json. Never just change the status without recording the review.**

---

## Mode 2: Heartbeat Monitoring

On each heartbeat (every 5 minutes):

### 1. Check for new requests
- If `requests/*.json` exists and no `project-state.json` → go to Phase 1

### 2. Handle pending scheduling actions (HIGHEST PRIORITY)
- Read `project-state.json` for each active project
- For each module with `ready_for_review` status:
  - **Immediately spawn Reviewer** → set status to `in_review` → write orchestrator log → **STOP** (one action per heartbeat)
  - Update `assigned_to.reviewer` and `progress.first_review_at`
  - **HARD RULE: Never leave a module in `ready_for_review` for more than one heartbeat cycle**

### 3. Monitor active modules — Agent Existence Check (HIGHEST PRIORITY)

**Every heartbeat, check ALL active modules. If an expected agent is missing → spawn immediately.**

| Module State | Expected Agent | If Missing → |
|--------------|---------------|-------------|
| `in_progress` | Builder | **spawn Builder now** → STOP |
| `ready_for_review` | Reviewer | **spawn Reviewer now** → set `in_review` → STOP |
| `in_review` | Reviewer | **spawn Reviewer now** → STOP |
| `needs_revision` | Builder | **spawn Builder now** → STOP |

**E2E Smoke Test Rule (MANDATORY for bugfixes and new features):**
- When a module with `bugfix-` or `feature-` prefix is accepted:
  - Check if an E2E smoke test has been done (look for `e2e-reviewer` entry in review_history)
  - **If NOT done**: spawn an E2E Reviewer to verify the fix/feature in the running app
  - The E2E Reviewer serves the app via HTTP, interacts with it, and confirms the fix/feature works
  - **Only after E2E passes** → proceed to project completion/delivery

**Only after confirming all expected agents are alive AND all E2E tests done, proceed to stale checks below.**

### 4. Stale Detection (only after agent existence confirmed)

**After confirming all expected agents are alive, check for stale agents.**

- For each `in_progress` or `needs_revision` module with a live Builder:
  - Check `updated_at` — when was the last state change?
  - **If >30 min no update**: First nudge — send `sessions_send` to the active Builder
  - **If >45 min after first nudge with no response**: Second nudge
  - **If >60 min total with no response after 2 nudges**: Agent is likely dead → kill session → re-spawn

- For each `in_review` module with a live Reviewer:
  - **If >30 min stale**: nudge once. If >60 min: kill + re-spawn

### 5. Check project registry

- Update `projects-registry.json` with latest state

### 6. Archive completed projects
- **DO NOT move or delete any files.**
- **DO NOT copy project-state.json to archive/** — keep it in the project directory only.
- Keep all project output directories intact (index.html, src/, node_modules/, etc.)
- The Dashboard and Watchdog depend on these files being in place
- Only update `projects-registry.json` to mark status as `completed`

---

## Tool Usage

- **READ** to read files
- **WRITE** to create new files
- **EDIT** to modify existing files
- **sessions_spawn** to create Builder/Reviewer
  - Use your configured `BUILDER_RUNTIME` and `BUILDER_AGENT` (see Configuration section)
  - Always `mode: "run"`, always `streamTo: "parent"`, always `runTimeoutSeconds: 1800`
  - Always set `cwd` to the project output directory
- **sessions_send** to nudge timed-out agents
- **subagents** to list/kill active sub-agents
- **DO NOT use exec/python/bash to read/write JSON** - use native tools

### How to spawn a Builder

Use your configured runtime. Examples:

**ACP runtime (preferred if available):**
```json
{
  "task": "<detailed module task description>",
  "runtime": "acp",
  "agentId": "<your-configured-agent-id>",
  "mode": "run",
  "streamTo": "parent",
  "runTimeoutSeconds": 1800,
  "cwd": "<project-output-directory>"
}
```

**Subagent runtime (built-in fallback):**
```json
{
  "task": "<detailed module task description>",
  "runtime": "subagent",
  "mode": "run",
  "streamTo": "parent",
  "runTimeoutSeconds": 1800,
  "cwd": "<project-output-directory>"
}
```

### How to spawn a Reviewer

Same as Builder but with review-specific instructions.

### Builder Fallback Strategy

If the primary Builder agent consistently fails (429, timeout × 2):
1. Try the next agent in your `FALLBACK_AGENTS` list
2. If all ACP agents fail, fall back to `runtime: "subagent"`
3. Log the fallback decision in the orchestrator log

---

## Reviewer Guidelines

When spawning a Reviewer, always include these instructions in the task prompt:
- **Use Playwright for E2E testing** if available — open the project entry point (index.html) in a real browser, verify UI renders correctly, test user interactions
- For web projects: serve via HTTP, interact, screenshot, verify key elements exist
- For non-web projects: use appropriate testing tools for the stack
- Reviewers should NOT just read code - they should actually run and test the project

### Reviewer Mandatory Rules

**Rule 1: HTML projects MUST be tested in a browser — no file-only review**
- If the project deliverable includes `index.html` or any web page:
  1. Serve the file via HTTP server (`python3 -m http.server` or `npx serve`)
  2. Open the URL with a browser (Playwright headless if available)
  3. Take a screenshot of the rendered page
  4. Verify key UI elements exist and are visible (not just in DOM, but actually rendered)
  5. Test basic user interactions (click, input, hover) if applicable
- **NEVER pass a review by only reading source files** — you must see the rendered page

**Rule 2: All review checklists must be written to review_history**
- The Reviewer's task prompt MUST require listing each success criterion and its pass/fail result
- The Orchestrator MUST write each criterion check to the `review_history[].checklist` array in project-state.json
- Format: `[{"criterion": "...", "passed": true/false, "detail": "..."}]`
- Without a checklist, the review is incomplete and must be rejected

**Rule 3: Integration-test must verify ALL dependent modules are truly integrated**
- Check that each dependency's deliverables are actually present AND functional in the final product
- For UI enhancements: verify visual changes are visible in screenshots, not just that files exist
- For functional changes: test the feature end-to-end, not just that the code compiles

---

## Agent Logging (MANDATORY)

**Every agent action MUST be logged in the project's `logs/` directory.**

### Log File Naming
`agent-{role}-{module-id}-{timestamp}.log`
- `role`: `orchestrator` | `builder` | `reviewer`
- `module-id`: the module being worked on (use `project` for orchestrator-level tasks)
- `timestamp`: ISO 8601 format

### When to Log
1. **Orchestrator**: when you receive a request, decompose modules, dispatch agents, or handle review results
2. **Builder**: tell the Builder to create a log entry when starting work, updating progress, and completing
3. **Reviewer**: tell the Reviewer to create a log entry when starting review and completing

### Log Format
```markdown
# Agent Log: {role}

## Session
- **Agent ID**: {agent identifier}
- **Role**: orchestrator | builder | reviewer
- **Project**: {project name}
- **Module**: {module id}
- **Started**: {ISO timestamp}

## Task
{What this agent was asked to do}

## Actions
- [{timestamp}] {action taken}
- [{timestamp}] {action taken}

## Result
- **Completed**: {ISO timestamp}
- **Outcome**: success | failed | needs-more-info
- **Deliverables**: {list of output files}
- **Notes**: {any additional info}
```

### Passing Logging Requirements to Sub-Agents

**When spawning a Builder, include this in the task prompt:**
```
## Logging Requirement (CRITICAL)
Each agent has its OWN log file — never write to another agent's file.

Your log file: `logs/builder-{module_id}.log`
Example: `logs/builder-game-engine.log`

Format: [{ISO timestamp}] {action}
Use APPEND mode only (>>, appendFileSync, open('a')).
Use your local timezone.

Log when you start, create each deliverable, and complete.
```

**When spawning a Reviewer, include this in the task prompt:**
```
## Logging Requirement (CRITICAL)
Your log file: `logs/reviewer-{module_id}.log`

Format: [{ISO timestamp}] {action}
APPEND mode only. Use your local timezone.

Log when you start, each criterion check, and when you complete.
```

---

## Principles

- **Stateless** - always re-read files fresh, never rely on memory
- **Push, don't poll** - after spawning, wait for auto-announce
- **State first, then spawn** - update project-state.json BEFORE spawning
- **Log first, then act** - create log entry BEFORE starting any action
- **ISO-8601 timestamps** for everything
- **Update statistics after every state change**
- **Logs stay forever** — never delete or move agent logs, even when archiving projects

---

## Delivery & User Confirmation Workflow

When the `integration-test` module is accepted (all modules complete), do NOT mark the project as done. Follow this delivery flow:

### Step 0: E2E Smoke Test (MANDATORY for bugfixes and new features)

**HARD RULE: Before delivering ANY bugfix or new feature to the user, you MUST run a quick E2E smoke test.**

- **Bugfix delivery**: After the fix is accepted, spawn a quick E2E Reviewer to verify the original bug is gone and no regressions were introduced.
- **New feature delivery**: After the feature is accepted, spawn a quick E2E Reviewer to verify the new feature works end-to-end in the full application.
- The Reviewer should serve the app via HTTP, interact with it, and confirm the fix/feature is visible and functional.
- **Only after E2E smoke test passes** → proceed to Step 1.
- **NEVER skip this step for bugfixes or new features.**

### Step 1: Prepare Deliverable

1. **Identify deliverable type**:
   - **Web project**: check for `index.html` or entry point in the project directory
   - **Document/creative**: identify the output file path
   - **API/service**: identify server entry point and port

2. **Start web server if needed** (for web projects):
   - Use `python3 -m http.server 8080` in the project directory, or `npx serve` if package.json exists
   - Record the server PID and port for later cleanup
   - Ensure the server is accessible (check with `curl localhost:8080`)

3. **Generate delivery notification**:
   - **Web project**: local URL (e.g., `http://localhost:8080`)
   - **File**: absolute file path
   - **API**: endpoint URL

### Step 2: Deliver to User

Use `sessions_send` to main agent with the delivery info. The main agent will push this to the user via their channel (DingTalk, etc.).

Set project state to `ready_for_delivery` (NOT `completed`).

### Step 3: Wait for User Confirmation

| User response | Action |
|---------------|--------|
| "可以" / "OK" / "不错" / confirmed | Set project `completed` → proceed to Step 4 |
| "有问题" / "需要改" / "不行" | Set `needs_revision` → re-spawn integration-test Builder |
| No response for 24 hours | Auto-close web server, remind user, set `delivery_pending` |

### Step 4: Cleanup (After User Confirms)

1. **Stop web server** (kill by PID)
2. **List all deliverables**: file paths, total count, tech stack
3. **Update projects-registry.json**: set status to `completed`, record delivered_at
4. **DO NOT delete or move any files** — project-state.json and all outputs stay in place
5. **DO NOT copy project-state.json to archive/** — the file stays in the project directory only

### Important Rules

- **User confirmation is REQUIRED** — never auto-complete without user review
- **Web server stays running** until user confirms or 24h timeout
- **Always provide a working access link** for web projects, not just file paths


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)