/** * DevLoop · LoopController * * P0 已提供: * - createProject({ company_id, mode, target_path, idea }) — 项目分配 + git init * - commitAfterAgent(pid, agentName, summary) — Agent 收尾自动 commit * * P1 新增 (Docs MVP): * - runDocsPhase(pid, requirement, opts) — 串行跑 PM → Architect (mock 模式), * 把 fixture.files 落盘到项目根,每个 Agent 收尾自动 commit * - callAgent(pid, agentName, prompt, opts) — 单次 Agent 调用 (P1 仅支持 mock 模式) * * 规格: * - docs/prd/newfunction/_spec/sdd-process.md · P-10 GitCommit * - docs/prd/newfunction/_spec/arch-git-versioning.md * - docs/prd/newfunction/_spec/arch-project-root.md * - docs/prd/newfunction/_spec/plan-migration.md · P1 Docs MVP */ const fs = require('fs'); const path = require('path'); const pathResolver = require('../../../services/devloop/path-resolver'); const gitVault = require('../../../services/devloop/git-vault'); const timelineLogger = require('../../../services/devloop/timeline-logger'); const unitRegistry = require('../../../services/devloop/unit-registry'); const bugTracker = require('../../../services/devloop/bug-tracker'); const changelogWriter = require('../../../services/devloop/changelog-writer'); const sseBroker = require('../../../services/devloop/sse-broker'); const docStore = require('../../../services/devloop/doc-store'); const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); // 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地 unitRegistry.attachSseBroker(sseBroker); bugTracker.attachSseBroker(sseBroker); bugTracker.attachTimelineLogger(timelineLogger); const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates'); /** * 新建项目: * 1. allocateProjectRoot (sandbox / linked-new) * 2. 写 project.json (最小元数据) * 3. gitVault.init + 写 .gitignore (gitVault 内置 DEFAULT_GITIGNORE) * 4. 首次 commit "chore: init project" */ async function createProject({ company_id, mode = 'sandbox', target_path, idea = '' }) { if (!company_id) throw new Error('company_id required'); const { pid, root_path } = pathResolver.allocateProjectRoot({ company_id, mode, target_path, }); // project.json (含 P4 status FSM 字段) const meta = { pid, company_id, mode, created_at: new Date().toISOString(), idea_summary: (idea || '').slice(0, 200), status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned status_updated_at: new Date().toISOString(), }; fs.writeFileSync( pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2) ); // git init (内置首个 init commit) — gitVault.init 已自己做 commit await gitVault.init(pid, { summary: idea ? idea.slice(0, 80) : `init ${pid}`, }); // timeline (写在 init 之后,作为"项目创建"事件) try { timelineLogger.append(pid, { source: 'system', kind: 'project.created', ref: pid, summary: `Project ${pid} created (${mode})`, payload: { company_id, mode, root_path }, }); } catch (e) { console.warn('[LoopController] timeline append failed:', e.message); } // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净) const firstCommit = await gitVault.commit(pid, { kind: 'chore', summary: `chore: project metadata + first timeline entry for ${pid}`, author: { name: 'devloop-system', email: 'devloop@local' }, }); return { pid, root_path, mode, first_commit: firstCommit }; } /** * Agent 结束后,把工作树新文件 auto-commit。 * 调用方一般在 stream-executor 完成或 agent-manager.post-task-success 时调。 * * @param {string} pid * @param {string} agentName (devloop-pm / devloop-architect / ...) * @param {string} summary 一句话描述本次产出 (做 commit message 副标题) * @returns {{ sha, files_changed }} 或 null (无变更时) */ async function commitAfterAgent(pid, agentName, summary = '') { // commit kind 推断 const kindMap = { 'devloop-pm': 'prd', 'devloop-architect': 'sdd', 'devloop-coder': 'code', 'devloop-tester': 'code', 'devloop-integrator': 'integ', 'devloop-triager': 'triage', }; const kind = kindMap[agentName] || 'chore'; const msg = `${kind}(${agentName}): ${summary || 'agent update'}`; const commit = await gitVault.commit(pid, { kind, summary: msg, author: { name: agentName, email: `${agentName}@devloop.local` }, }); if (commit.skipped) { return null; } try { timelineLogger.append(pid, { source: agentName, kind: 'git.commit', ref: commit.sha, summary: msg.slice(0, 200), payload: { sha: commit.sha }, }); } catch { /* swallow */ } return commit; } /** * 单次 Agent 调用 (P1 mock 实现): * 1. 读 system/templates/{agentName}/prompt.md (系统提示) * 2. 用 ClaudeCliProtocol(mock=true) 跑一遍 * 3. 收集 chunks / files / result * 4. 把 fixture.files 落盘到项目根 * 5. 触发 commitAfterAgent * * @param {string} pid * @param {string} agentName e.g. "devloop-pm" * @param {string} userPrompt 用户/上一步追加的提示 * @param {object} opts * - onChunk?: (text) => void 流式回调 * - mock?: boolean 默认走 LEMONBOSS_CLAUDE_MODE=mock * - skipCommit?: boolean 默认 false * @returns { ok, agent, files_written, commit, result_text } */ async function callAgent(pid, agentName, userPrompt, opts = {}) { const projectRoot = pathResolver.resolveProjectRoot(pid); // 1. 系统提示 (可选 — 没文件就空串) const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md'); const systemPrompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, 'utf8') : ''; const fullPrompt = systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt; // 2. 启动 protocol const protocol = new ClaudeCliProtocol( { name: agentName }, { mock: opts.mock !== false, // 默认 mock=true (P1+ 阶段) mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用 }, ); let resultParams = null; const filesFromMock = []; protocol.on('session_update', (u) => { if (u.update?.sessionUpdate === 'agent_message_chunk' && opts.onChunk) { opts.onChunk(u.update.content.text); } }); protocol.on('acp_event', (ev) => { if (ev.method === 'session/result') resultParams = ev.params; else if (ev.method === 'mock/files' && Array.isArray(ev.params?.files)) { filesFromMock.push(...ev.params.files); } }); try { await protocol.connect(null, projectRoot); await protocol.sendPrompt(fullPrompt); } finally { protocol.disconnect(); } // 3. 落盘 fixture.files (path 相对 projectRoot) const filesWritten = []; for (const f of filesFromMock) { if (!f?.path || typeof f.content !== 'string') continue; if (path.isAbsolute(f.path) || f.path.includes('..')) { throw new Error(`unsafe mock file path: ${f.path}`); } const abs = path.join(projectRoot, f.path); fs.mkdirSync(path.dirname(abs), { recursive: true }); let content = f.content; // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_") if (f.path === 'units.json' || f.path.endsWith('/units.json')) { try { const u = JSON.parse(content); u.project_id = pid; content = JSON.stringify(u, null, 2); } catch { /* leave as-is, downstream will fail validation */ } } fs.writeFileSync(abs, content); filesWritten.push(f.path); } // 4. timeline + commit try { timelineLogger.append(pid, { source: agentName, kind: 'agent.call', ref: resultParams?.session_id || 'mock', summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`, payload: { files_written: filesWritten.length, mock: !!resultParams?.mock }, }); } catch { /* swallow */ } let commit = null; if (!opts.skipCommit) { const summary = (resultParams?.result || '').slice(0, 80) || `${agentName} produced ${filesWritten.length} files`; commit = await commitAfterAgent(pid, agentName, summary); } return { ok: true, agent: agentName, files_written: filesWritten, commit, result_text: resultParams?.result || '', mock: !!resultParams?.mock, }; } /** * Docs phase (P1): PM → Architect 串行跑。 * * @param {string} pid * @param {string} requirement 用户原始需求一段话 * @param {object} opts * - onChunk?: (agent, text) => void * @returns { ok, pm, architect } */ async function runDocsPhase(pid, requirement, opts = {}) { if (!requirement || typeof requirement !== 'string') { throw new Error('requirement (string) required'); } // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始 if (opts.resetMockSequence !== false) { ClaudeCliProtocol.resetMockState(); } const onChunk = opts.onChunk ? (agent) => (text) => opts.onChunk(agent, text) : null; const passthrough = { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir, }; const pm = await callAgent(pid, 'devloop-pm', `Requirement:\n${requirement}\n\nmode: init`, { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined }); const architect = await callAgent(pid, 'devloop-architect', `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`, { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined }); // timeline 总结 try { timelineLogger.append(pid, { source: 'system', kind: 'docs.complete', ref: pid, summary: `Docs phase complete: PM (${pm.files_written.length} files) + Architect (${architect.files_written.length} files)`, payload: { pm_files: pm.files_written.length, architect_files: architect.files_written.length, }, }); } catch { /* swallow */ } // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2) _setProjectStatus(pid, 'running', { reason: 'docs_complete' }); return { ok: true, pm, architect }; } /** * 读项目当前状态 (P1 用,routes/devloop.js GET /state 后端): * - project.json * - units.json (if any) * - PRD/SDD revs (扫 article[data-rev] 最大值) * - 最近一次 commit * - timeline 最近 5 条 * - bugs 计数 */ function getProjectState(pid) { const root = pathResolver.resolveProjectRoot(pid); const out = { pid, root_path: root }; const projectJson = pathResolver.resolveProjectJson(pid); if (fs.existsSync(projectJson)) { try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); out.status = out.project.status || 'unknown'; } catch {} } const unitsJson = pathResolver.resolveUnitsJson(pid); if (fs.existsSync(unitsJson)) { try { const u = JSON.parse(fs.readFileSync(unitsJson, 'utf8')); out.units = u.units || []; out.units_rev = u.rev || 0; } catch {} } // PRD/SDD rev = 站点根 index.html 的 article[data-rev] for (const kind of ['prd', 'sdd']) { const idx = path.join(root, kind, 'index.html'); if (fs.existsSync(idx)) { const html = fs.readFileSync(idx, 'utf8'); const m = html.match(/]*data-doc="(prd|sdd)"[^>]*data-rev="(\d+)"/); out[`${kind}_rev`] = m ? parseInt(m[2], 10) : 1; out[`${kind}_site`] = true; } else { out[`${kind}_site`] = false; } } // timeline tail const tl = pathResolver.resolveTimelineLog(pid); if (fs.existsSync(tl)) { const lines = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean); out.timeline_tail = lines.slice(-5).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } return out; } /** * runUnit — L1 单 Unit 循环 (P2)。 * * 串行 Coder(implement) → Tester(test)。失败时 retryWithReflection 把 * 上一次 failure + coder.log 摘要打回去重跑 Coder + Tester,直到通过或耗 * 尽 retry_budget。每个状态转换走 unit-registry FSM。 * * @param {string} pid * @param {string} unitId * @param {object} opts * - mock?: boolean (默认 true,P2 阶段) * - onChunk?: (agent, text) => void * - resetMockSequence?: boolean (默认 true,跑前清零 fixture sequence 计数) * @returns { ok, unit_id, attempts, final_state, history: [{ agent, attempt, ... }] } */ async function runUnit(pid, unitId, opts = {}) { if (opts.resetMockSequence !== false) { ClaudeCliProtocol.resetMockState(); } const unit = unitRegistry.get(pid, unitId); const budget = unit.retry_budget || 5; const history = []; let attempt = 0; let finalOk = false; while (attempt < budget) { attempt++; // ── coding ─────────────────────────────────────────────── unitRegistry.markCoding(pid, unitId); const coderHistorySummary = _buildRetryContext(history); const coderPrompt = attempt === 1 ? `Mode: implement\nUnit: ${unitId}\n\nRead units/${unitId}/spec.json and produce code/ + contract.json + coder.log.` : `Mode: retry (attempt ${attempt})\nUnit: ${unitId}\n\n${coderHistorySummary}\n\nReflect on the failure and patch code/ minimally; append a new coder.log entry with last_failure/last_cause/this_fix/rationale fields.`; const coderResult = await callAgent(pid, 'devloop-coder', coderPrompt, { mock: opts.mock !== false, onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-coder', t) : undefined, }); history.push({ agent: 'devloop-coder', attempt, files: coderResult.files_written, summary: coderResult.result_text, commit: coderResult.commit?.sha, }); // ── testing ────────────────────────────────────────────── unitRegistry.markTesting(pid, unitId); const testerPrompt = `Mode: ${attempt === 1 ? 'test' : 'retry'}\nUnit: ${unitId}\n\nRead units/${unitId}/code/ + spec.json, run vitest, parse the JSON reporter, and write runs/{runId}/result.json.`; const testerResult = await callAgent(pid, 'devloop-tester', testerPrompt, { mock: opts.mock !== false, onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-tester', t) : undefined, }); const resultRef = _findLatestTesterResult(pid, unitId); const testerOk = resultRef ? !!resultRef.parsed?.ok : false; history.push({ agent: 'devloop-tester', attempt, files: testerResult.files_written, summary: testerResult.result_text, commit: testerResult.commit?.sha, ok: testerOk, failures: resultRef?.parsed?.failures || [], }); // ── transition ────────────────────────────────────────── if (testerOk) { unitRegistry.pass(pid, unitId, unit.contract_hash); finalOk = true; break; } else { const reason = resultRef?.parsed?.failures?.[0]?.test || resultRef?.parsed?.failures?.[0]?.hint || `tester reported failure (attempt ${attempt})`; unitRegistry.fail(pid, unitId, reason); // 如果还有预算 → 再循环;否则 markBlocked if (attempt >= budget) { unitRegistry.block(pid, unitId, `retry_budget_exceeded after ${attempt} attempts`); break; } // 把 unit 从 failed → coding 由下一轮 markCoding 完成 (FSM 允许) } } try { timelineLogger.append(pid, { source: 'system', kind: 'unit.run_complete', ref: unitId, summary: `Unit ${unitId} ${finalOk ? 'passed' : 'blocked'} after ${attempt} attempt(s)`, payload: { ok: finalOk, attempts: attempt, budget }, }); } catch { /* swallow */ } return { ok: finalOk, unit_id: unitId, attempts: attempt, final_state: unitRegistry.get(pid, unitId).status, history, }; } /** 把 history 数组压成给 Coder 反思用的 markdown 摘要 */ function _buildRetryContext(history) { if (!history.length) return ''; const lines = ['## Retry History']; for (const h of history) { lines.push(`- attempt ${h.attempt} · ${h.agent}: ${(h.summary || '').slice(0, 120)}`); if (h.agent === 'devloop-tester' && Array.isArray(h.failures) && h.failures.length) { for (const f of h.failures.slice(0, 3)) { lines.push(` - failure: ${f.test || f.name || '(unnamed)'}: ${f.hint || f.stack?.slice(0, 80) || ''}`); } } } return lines.join('\n'); } /** * 找 units/{unitId}/runs/ 下最新一次 result.json (按字典序 — runId 含日期前缀)。 */ function _findLatestTesterResult(pid, unitId) { const runsDir = path.join(pathResolver.resolveUnitDir(pid, unitId), 'runs'); if (!fs.existsSync(runsDir)) return null; const ids = fs.readdirSync(runsDir).filter(n => fs.statSync(path.join(runsDir, n)).isDirectory()); if (!ids.length) return null; ids.sort(); const latest = ids[ids.length - 1]; const resultPath = path.join(runsDir, latest, 'result.json'); if (!fs.existsSync(resultPath)) return { runId: latest, parsed: null }; try { return { runId: latest, parsed: JSON.parse(fs.readFileSync(resultPath, 'utf8')) }; } catch { return { runId: latest, parsed: null }; } } // ────────────────────────────────────────────────────────────── // P3 · L2 Integration Loop · runProject // ────────────────────────────────────────────────────────────── /** * Topological wave grouping (Kahn). Returns string[][] — units per wave. */ function _groupUnitsByWave(units) { const groups = []; const remaining = new Set(units.map(u => u.id)); const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()])); while (remaining.size > 0) { const wave = []; for (const id of remaining) { if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); } if (wave.length === 0) { throw new Error('cycle in units.json (no unit has all dependencies cleared)'); } for (const id of wave) remaining.delete(id); groups.push(wave); } return groups; } /** find latest run dir under integration/runs/ */ function _findLatestIntegrationRunDir(pid) { const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs'); if (!fs.existsSync(dir)) return null; const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory()); if (!ids.length) return null; ids.sort(); return ids[ids.length - 1]; } function _readJsonSafe(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } } /** * runProject — P3 主循环 (mock 模式)。 * * 流程: * while not done and not blocked: * 1. 按 DAG wave 跑 L1 (per-unit runUnit) * 2. 若 l2GateOpen → 跑 Integrator * 3. result.ok=true → 项目完成 * 4. result.ok=false → Triager → dispatch: * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json) * → l2_rounds++; 超 l2_budget → l2_exhausted * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算 * - infra_flake → 直接重跑 (不 reset, 不烧预算) * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed * * @param {string} pid * @param {object} opts * - mockFixtureDir?: string (P3 scenario 测试用) * - maxRetries?: number (safety net 防 mock 死循环;默认 10) * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history } */ async function runProject(pid, opts = {}) { // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续, // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。 // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。 const projectRoot = pathResolver.resolveProjectRoot(pid); const history = []; const callOpts = { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir, resetMockSequence: false, // 一次 runProject 只 reset 一次 }; const safety = opts.maxRetries || 10; let iterations = 0; // SSE start sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() }); while (iterations < safety) { iterations++; // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ──────── const allUnits = unitRegistry.list(pid); const pendingUnits = allUnits.filter(u => u.status === 'pending'); if (pendingUnits.length > 0) { const waves = _groupUnitsByWave(pendingUnits); for (let w = 0; w < waves.length; w++) { const wave = waves[w]; sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave }); // 串行跑(mock 同步快,不需并行) for (const uid of wave) { const r = await runUnit(pid, uid, { ...callOpts }); history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); if (!r.ok) { sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; } } } } // ── (2) L2 gate ──────────────────────────────────────────── if (!unitRegistry.l2GateOpen(pid)) { sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; } // ── (3) 跑 Integrator ────────────────────────────────────── sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) }); const integ = await callAgent(pid, 'devloop-integrator', `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`, callOpts); const runId = _findLatestIntegrationRunDir(pid); const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null; history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed }); sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed }); // ── (4) 成功 → 项目完成 ──────────────────────────────────── if (intResult?.ok === true) { // 若有未关 bug → 转 fixed const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] }); for (const b of openBugs) { try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); } catch { /* skip invalid transitions */ } } sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId }); try { timelineLogger.append(pid, { source: 'system', kind: 'project.shipped', ref: runId, summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, }); } catch {} _setProjectStatus(pid, 'shipped', { run_id: runId }); return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; } // ── (5) 失败 → 开 bug + 调 Triager ────────────────────────── const bug = bugTracker.open(pid, { source: 'integration_test', run_id: runId, title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`, severity: intResult?.crashed ? 'high' : 'medium', category: 'unknown', detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200), affected_units: intResult?.failures?.[0]?.involves || [], by: 'devloop-integrator', }); bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' }); await callAgent(pid, 'devloop-triager', `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`, callOpts); const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null; history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence }); sseBroker.emit(pid, 'triage.result', { run_id: runId, category: triage?.category, confidence: triage?.confidence, affected_units: triage?.affected_units || [], }); if (!triage) { sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; } // ── (6) 分支调度 ──────────────────────────────────────────── const lowConfidence = (triage.confidence ?? 0) < 0.5; const category = triage.category; if (lowConfidence || category === 'spec_ambiguous') { sseBroker.emit(pid, 'human_intervention_needed', { reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', run_id: runId, category, confidence: triage.confidence, }); _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; } if (category === 'infra_flake') { // 不 reset 不烧预算,直接下一轮重跑 Integrator history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' }); continue; } if (category === 'impl_bug') { // reset affected units (不烧 l2_budget) const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); if (affected.length === 0) { sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; } unitRegistry.reset(pid, affected); history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected }); sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' }); continue; } if (category === 'contract_mismatch') { // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++) // 预检 l2_budget const currentRounds = _l2Rounds(pid); const l2Budget = _l2Budget(pid); if (currentRounds + 1 > l2Budget) { sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' }); return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; } const oldSddRev = _readSddRev(pid); await callAgent(pid, 'devloop-architect', `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`, callOpts); const newSddRev = _readSddRev(pid); if (newSddRev > oldSddRev) { try { changelogWriter.append(pid, { kind: 'sdd_rev', rev: newSddRev, by: 'devloop-architect', trigger: 'l2_triage', summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`, diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] }, }); } catch { /* swallow */ } sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` }); } history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev }); continue; } // Unknown category sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' }); return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; } // safety net 超过 sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' }); return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; } /** Status set that swallows invalid-transition errors (already in target state etc.) */ function _safeSetStatus(pid, to, extra) { try { return _setProjectStatus(pid, to, extra); } catch (e) { if (e.name === 'InvalidProjectStatusTransition') return null; throw e; } } function _l2Rounds(pid) { const u = docStore.readUnits(pid); return u?.l2_rounds || 0; } function _l2Budget(pid) { const u = docStore.readUnits(pid); return u?.l2_budget || 3; } function _readSddRev(pid) { const root = pathResolver.resolveProjectRoot(pid); const idx = path.join(root, 'sdd', 'index.html'); if (!fs.existsSync(idx)) return 0; const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); if (m) return parseInt(m[1], 10); // fallback: try mvcp/control.html (where patch goes) const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html'); if (fs.existsSync(ctrl)) { const m2 = fs.readFileSync(ctrl, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); if (m2) return parseInt(m2[1], 10); } return 1; } // ────────────────────────────────────────────────────────────── // P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback // ────────────────────────────────────────────────────────────── const STATUS_TRANSITIONS = { draft: ['running', 'abandoned'], running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'], l2_blocked: ['running', 'abandoned'], l2_exhausted: ['running', 'abandoned'], shipped: ['running', 'shipped-final', 'abandoned'], 'shipped-final': [], // 终态 abandoned: [], // 终态 }; class InvalidProjectStatusTransition extends Error { constructor(from, to) { super(`Invalid project status transition: ${from} → ${to}`); this.name = 'InvalidProjectStatusTransition'; this.from = from; this.to = to; } } function _readProjectMeta(pid) { const p = pathResolver.resolveProjectJson(pid); if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`); return JSON.parse(fs.readFileSync(p, 'utf8')); } function _writeProjectMeta(pid, meta) { fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2)); } /** * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。 * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。 */ function _setProjectStatus(pid, to, extra = {}) { const meta = _readProjectMeta(pid); const from = meta.status || 'draft'; if (from === to) return meta; // idempotent const allowed = STATUS_TRANSITIONS[from] || []; if (!allowed.includes(to)) { throw new InvalidProjectStatusTransition(from, to); } meta.status = to; meta.status_updated_at = new Date().toISOString(); if (extra.note) meta.status_note = extra.note; _writeProjectMeta(pid, meta); try { sseBroker.emit(pid, 'project.status', { from, to, ...extra }); timelineLogger.append(pid, { source: extra.by || 'system', kind: 'project.status', ref: pid, summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`, payload: { from, to, ...extra }, }); } catch { /* swallow */ } return meta; } /** * Sign Off — 把已 shipped 的项目锁为 shipped-final。 * 之后再发 feedback 会被拒绝。 */ async function signOff(pid, opts = {}) { const meta = _readProjectMeta(pid); if (meta.status !== 'shipped') { throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final'); } _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' }); // sign-off commit (留 git 痕) try { await gitVault.commit(pid, { kind: 'chore', summary: `chore: sign-off project ${pid}`, author: { name: opts.by || 'human', email: 'human@devloop.local' }, }); } catch { /* swallow if nothing to commit */ } return _readProjectMeta(pid); } /** * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。 */ async function abandon(pid, opts = {}) { const meta = _readProjectMeta(pid); if (meta.status === 'shipped-final' || meta.status === 'abandoned') { throw new InvalidProjectStatusTransition(meta.status, 'abandoned'); } _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note }); try { await gitVault.commit(pid, { kind: 'chore', summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`, author: { name: opts.by || 'human', email: 'human@devloop.local' }, }); } catch { /* swallow */ } // 关闭所有 SSE 订阅(close 帧) try { sseBroker.close(pid); } catch { /* ignore */ } return _readProjectMeta(pid); } /** * Submit human feedback — 触发 PM incremental 模式。 * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。 * shipped-final / abandoned → 拒绝。 */ async function submitFeedback(pid, text, opts = {}) { if (!text || typeof text !== 'string') { throw new Error('feedback text required'); } const meta = _readProjectMeta(pid); if (meta.status === 'shipped-final' || meta.status === 'abandoned') { throw new InvalidProjectStatusTransition(meta.status, 'feedback'); } const prdRevBefore = _readPrdRev(pid); const pm = await callAgent(pid, 'devloop-pm', `Mode: incremental\nFeedback from human (${opts.by || 'user'}):\n${text}\n\nAppend a new section under prd/feature/feature-tree.html (or appropriate chapter), bump prd_rev.`, { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir }, ); const prdRevAfter = _readPrdRev(pid); // 若 PRD rev bumped → 写 changelog if (prdRevAfter > prdRevBefore) { try { changelogWriter.append(pid, { kind: 'prd_rev', rev: prdRevAfter, by: opts.by || 'human', trigger: 'human_feedback', summary: `PRD bumped to rev ${prdRevAfter} via human feedback`, diff: { feedback_excerpt: text.slice(0, 120) }, }); sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' }); } catch { /* swallow */ } } // 若当前是 shipped, 改回 running (人类反馈触发新一轮) if (meta.status === 'shipped') { _setProjectStatus(pid, 'running', { reason: 'human_feedback' }); } return { ok: true, pid, files_written: pm.files_written, prd_rev_before: prdRevBefore, prd_rev_after: prdRevAfter, status: _readProjectMeta(pid).status, }; } function _readPrdRev(pid) { const root = pathResolver.resolveProjectRoot(pid); const idx = path.join(root, 'prd', 'index.html'); if (!fs.existsSync(idx)) return 0; const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="prd"[^>]*data-rev="(\d+)"/); return m ? parseInt(m[1], 10) : 1; } module.exports = { createProject, commitAfterAgent, callAgent, runDocsPhase, runUnit, runProject, getProjectState, // P4 signOff, abandon, submitFeedback, InvalidProjectStatusTransition, };