/** * 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 { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); 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 const meta = { pid, company_id, mode, created_at: new Date().toISOString(), idea_summary: (idea || '').slice(0, 200), }; 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 阶段) ); 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'); } const onChunk = opts.onChunk ? (agent) => (text) => opts.onChunk(agent, text) : null; const pm = await callAgent(pid, 'devloop-pm', `Requirement:\n${requirement}\n\nmode: init`, { 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}`, { 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 */ } 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')); } 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 }; } } module.exports = { createProject, commitAfterAgent, callAgent, runDocsPhase, runUnit, getProjectState, };