diff --git a/fixtures/devloop-mock/devloop-architect.json b/fixtures/devloop-mock/devloop-architect.json
new file mode 100644
index 0000000..245b002
--- /dev/null
+++ b/fixtures/devloop-mock/devloop-architect.json
@@ -0,0 +1,68 @@
+{
+ "_doc": "Mock fixture for Architect Agent — drives P1 acceptance (todo-demo project). Produces SDD multi-page site + units.json (3 units) + integration/spec.json + architect agent-state + history snapshot.",
+ "chunks": [
+ "## 第 1 步: 读 PRD\n",
+ "已读 prd/feature/feature-tree.html — 3 个 Unit。\n",
+ "\n## 第 2 步: 写 sdd/mvcp/\n",
+ "model · view · control · process\n",
+ "\n## 第 3 步: 写 units.json + integration/spec.json\n",
+ "todo-be / todo-fe / todo-db,DAG 无环。\n",
+ "\n## 完成\n",
+ "SDD v1 已交付,units.json l2_rounds 初值 0/3。\n"
+ ],
+ "result": "mock-ok devloop-architect: SDD v1 + 3 units + 1 integration case",
+ "files": [
+ {
+ "path": "sdd/index.html",
+ "content": "
SDD · todo-demoSDD 首页
todo-demo · M/V/C/P 设计
M -> V -> C -> P
导航
"
+ },
+ {
+ "path": "sdd/mvcp/model.html",
+ "content": "ModelM-1 Todo Entity
{ id:int, text:str, done:bool, created_at:ISO }
| field | type |
|---|
| id | integer |
| text | string |
schema: id PK · text NOT NULL
model
M-2 FE State
list: Todo[]; loading: bool
vue ref
fe
M-3 DB Schema
CREATE TABLE todos(id INTEGER PRIMARY KEY,text TEXT,done INT,created_at TEXT);
sqlite
db
"
+ },
+ {
+ "path": "sdd/mvcp/view.html",
+ "content": "ViewV-1 列表组件
顶部 input + 按钮 + 列表。
Vue SFC: TodoList.vue
UI
"
+ },
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "ControlC-1 POST /todos
Body: { text }; Resp 201: { id, text, done:false, created_at }
POST /todos
api
C-2 GET /todos
Resp 200: Todo[]
GET /todos
api
"
+ },
+ {
+ "path": "sdd/mvcp/process.html",
+ "content": "ProcessP-1 启动流程
node server.js → listen $PORT → ready
spawn -> listen -> ready
proc
"
+ },
+ {
+ "path": "sdd/assets/shell.css",
+ "content": "/* sdd shell.css placeholder */\nbody { font-family: system-ui; }\n.card { padding: 6px; border: 1px solid #ddd; }\n"
+ },
+ {
+ "path": "sdd/assets/shell.js",
+ "content": "/* sdd shell.js placeholder */\nconsole.log('[devloop-sdd] shell loaded');\n"
+ },
+ {
+ "path": "sdd/assets/search-index.json",
+ "content": "{\"version\":\"1\",\"pages\":5,\"entries\":[]}"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"rev\": 1,\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"units\": [\n {\n \"id\": \"todo-db\",\n \"kind\": \"db\",\n \"description\": \"SQLite schema for todos table\",\n \"depends_on\": [],\n \"contract_hash\": \"sha256:0000000000000001\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n },\n {\n \"id\": \"todo-be\",\n \"kind\": \"be\",\n \"description\": \"Express API for /todos\",\n \"depends_on\": [\"todo-db\"],\n \"contract_hash\": \"sha256:0000000000000002\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n },\n {\n \"id\": \"todo-fe\",\n \"kind\": \"fe\",\n \"description\": \"Vue list page\",\n \"depends_on\": [\"todo-be\"],\n \"contract_hash\": \"sha256:0000000000000003\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3\n }\n ]\n}\n"
+ },
+ {
+ "path": "integration/spec.json",
+ "content": "{\n \"rev\": 1,\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"cases\": [\n {\n \"id\": \"case-add-list\",\n \"title\": \"add a todo then list shows it\",\n \"involves\": [\"todo-be\", \"todo-db\"],\n \"steps\": [\n { \"unit\": \"todo-be\", \"call\": \"POST /todos\", \"body\": { \"text\": \"buy milk\" }, \"expect\": { \"status\": 201, \"json\": { \"id\": \"$.id\", \"text\": \"buy milk\" } } },\n { \"unit\": \"todo-be\", \"call\": \"GET /todos\", \"expect\": { \"status\": 200 } }\n ]\n }\n ]\n}\n"
+ },
+ {
+ "path": "history/sdd-rev-001.html",
+ "content": "SDD rev 1 snapshotSnapshot
Archive of SDD rev 1, see sdd/ for live version.
"
+ },
+ {
+ "path": "agent-state/architect.json",
+ "content": "{\"agent\":\"devloop-architect\",\"status\":\"idle\",\"current_step\":6,\"total_steps\":6,\"last_call_id\":\"call-mock-architect\",\"updated_at\":\"2026-05-13T10:01:01.000Z\"}"
+ },
+ {
+ "path": "agent-state/architect-todo.json",
+ "content": "{\"agent\":\"devloop-architect\",\"call_id\":\"call-mock-architect\",\"created_at\":\"2026-05-13T10:01:00.000Z\",\"items\":[{\"id\":\"step-001\",\"description\":\"bootstrap_sdd_site\",\"status\":\"done\",\"depends_on\":[]},{\"id\":\"step-002\",\"description\":\"write mvcp/model.html\",\"status\":\"done\",\"depends_on\":[\"step-001\"]},{\"id\":\"step-003\",\"description\":\"write mvcp/{view,control,process}.html\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-004\",\"description\":\"write units.json (3 units, DAG)\",\"status\":\"done\",\"depends_on\":[\"step-003\"]},{\"id\":\"step-005\",\"description\":\"write integration/spec.json\",\"status\":\"done\",\"depends_on\":[\"step-004\"]},{\"id\":\"step-006\",\"description\":\"validate_sdd_consistency + archive\",\"status\":\"done\",\"depends_on\":[\"step-005\"]}],\"current\":null,\"progress_pct\":100}"
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock/devloop-pm.json b/fixtures/devloop-mock/devloop-pm.json
index c38394a..723cd16 100644
--- a/fixtures/devloop-mock/devloop-pm.json
+++ b/fixtures/devloop-mock/devloop-pm.json
@@ -1,11 +1,72 @@
{
+ "_doc": "Mock fixture for PM Agent — drives P1 acceptance (todo-demo project). Produces the full PRD multi-page site + pm agent-state + history snapshot.",
"chunks": [
"## 第 1 步: 解析 idea\n",
- "用户需求是构建一个最小可行的 DevLoop demo。\n",
- "\n## 第 2 步: 写 prd 站点\n",
- "(在真实运行时这里会调 bootstrap_prd_site + write_chapter)\n",
+ "用户要求做一个 TODO 列表 API + Vue 前端。\n",
+ "\n## 第 2 步: 初始化 prd/ 站点\n",
+ "(在真实运行时这里会调 bootstrap_prd_site)\n",
+ "\n## 第 3 步: 写章节\n",
+ "background / personas / scope / feature-tree / user-flows / prototype\n",
"\n## 完成\n",
- "已生成 PRD v1。\n"
+ "PRD v1 已生成,可交付给 Architect。\n"
],
- "result": "mock-ok devloop-pm: PRD v1 scaffolded (no real writes in mock mode)"
+ "result": "mock-ok devloop-pm: PRD scaffolded (todo-demo, rev=1)",
+ "files": [
+ {
+ "path": "prd/index.html",
+ "content": "PRD · todo-demo项目首页
todo-demo · 一个 TODO 列表 API + 前端
| 章节 | 路径 |
|---|
| 背景 | product/background.html |
flow: list -> add -> done
导航卡片
更多
站点入口
"
+ },
+ {
+ "path": "prd/reading-guide.html",
+ "content": "Reading Guide阅读顺序
建议按 background → personas → scope → feature-tree → user-flows → prototype 阅读。
├── product\n├── feature\n└── flow
tip
"
+ },
+ {
+ "path": "prd/product/background.html",
+ "content": "背景背景
个人 / 小团队需要轻量 TODO 工具,不愿引入 Notion / Jira。
需求 → API → 前端
scope: MVP
"
+ },
+ {
+ "path": "prd/product/personas.html",
+ "content": "用户用户画像
独立开发者 / 学生 / 产品经理 — 单人或 ≤3 人小团队。
persona-a / persona-b
primary: dev
"
+ },
+ {
+ "path": "prd/product/scope.html",
+ "content": "范围本期范围
POST /todos 新增 · GET /todos 列表 · Vue 简单列表前端。不做用户系统、不做拖拽排序。
scope: api + fe
MVP
"
+ },
+ {
+ "path": "prd/feature/feature-tree.html",
+ "content": "功能树功能树
todo-be(POST/GET /todos) · todo-fe(Vue 列表) · 可选 todo-db(SQLite schema)。
| 模块 | 说明 |
|---|
| todo-be | API 服务 |
| todo-fe | 前端 |
├── todo-be\n├── todo-fe\n└── todo-db
3 unit
"
+ },
+ {
+ "path": "prd/flow/user-flows.html",
+ "content": "用户流核心流程
打开页面 → 看到列表 → 输入 → 提交 → 刷新看到新条目。
| 步骤 | 动作 |
|---|
| 1 | GET /todos |
| 2 | POST /todos |
user -> fe -> be -> db
主流程
"
+ },
+ {
+ "path": "prd/prototype/proto-list.html",
+ "content": "原型 · 列表原型 · TODO 列表
顶部 input + 按钮,下方列表展示。
+----------+\n| input |\n+----------+\n| item 1 |\n| item 2 |\n+----------+
UI 草图
"
+ },
+ {
+ "path": "prd/assets/shell.css",
+ "content": "/* shell.css placeholder (P1 mock) */\nbody { font-family: system-ui; max-width: 960px; margin: 2em auto; }\n.card { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }\n"
+ },
+ {
+ "path": "prd/assets/shell.js",
+ "content": "/* shell.js placeholder (P1 mock) */\nconsole.log('[devloop-prd] shell loaded');\n"
+ },
+ {
+ "path": "prd/assets/search-index.json",
+ "content": "{\"version\":\"1\",\"pages\":8,\"entries\":[]}"
+ },
+ {
+ "path": "history/prd-rev-001.html",
+ "content": "PRD rev 1 snapshotSnapshot
Archive of PRD rev 1, see prd/index.html for live version.
"
+ },
+ {
+ "path": "agent-state/pm.json",
+ "content": "{\"agent\":\"devloop-pm\",\"status\":\"idle\",\"current_step\":8,\"total_steps\":8,\"last_call_id\":\"call-mock-pm\",\"updated_at\":\"2026-05-13T10:00:01.000Z\"}"
+ },
+ {
+ "path": "agent-state/pm-todo.json",
+ "content": "{\"agent\":\"devloop-pm\",\"call_id\":\"call-mock-pm\",\"created_at\":\"2026-05-13T10:00:00.000Z\",\"items\":[{\"id\":\"step-001\",\"description\":\"bootstrap_prd_site\",\"status\":\"done\",\"depends_on\":[]},{\"id\":\"step-002\",\"description\":\"write index.html\",\"status\":\"done\",\"depends_on\":[\"step-001\"]},{\"id\":\"step-003\",\"description\":\"write product/*.html\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-004\",\"description\":\"write feature/* + flow/* + prototype/*\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-005\",\"description\":\"update_search_index\",\"status\":\"done\",\"depends_on\":[\"step-004\"]},{\"id\":\"step-006\",\"description\":\"validate_prd_site\",\"status\":\"done\",\"depends_on\":[\"step-005\"]},{\"id\":\"step-007\",\"description\":\"archive_doc → history/\",\"status\":\"done\",\"depends_on\":[\"step-006\"]},{\"id\":\"step-008\",\"description\":\"echo_diff\",\"status\":\"done\",\"depends_on\":[\"step-007\"]}],\"current\":null,\"progress_pct\":100}"
+ }
+ ]
}
diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js
index 8f35dd3..f366f95 100644
--- a/packages/acp-core/protocol/ClaudeCliProtocol.js
+++ b/packages/acp-core/protocol/ClaudeCliProtocol.js
@@ -569,6 +569,21 @@ class ClaudeCliProtocol extends EventEmitter {
});
}
+ // 发 mock/files (若 fixture 含 files[]):
+ // LoopController 听到这事件后 把 files 落盘到项目根。
+ // 这是 mock 模式下让 Agent 真正"产出文件"的桥梁。
+ if (Array.isArray(fixture.files) && fixture.files.length > 0) {
+ this.emit('acp_event', {
+ type: 'notification',
+ method: 'mock/files',
+ params: {
+ agent: agentName,
+ files: fixture.files, // [{ path, content }] (path 相对项目根)
+ },
+ timestamp: Date.now(),
+ });
+ }
+
// 发 session/result(模拟 type=result)
const resultText = fixture.result || chunks.join('');
this.emit('acp_event', {
@@ -583,6 +598,7 @@ class ClaudeCliProtocol extends EventEmitter {
usage: { input: 0, output: 0 },
model: 'mock',
mock: true,
+ files_produced: Array.isArray(fixture.files) ? fixture.files.length : 0,
},
timestamp: Date.now(),
});
diff --git a/scripts/phase1-verify.js b/scripts/phase1-verify.js
new file mode 100644
index 0000000..4284a5d
--- /dev/null
+++ b/scripts/phase1-verify.js
@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+/**
+ * scripts/phase1-verify.js
+ *
+ * P1 Docs MVP 验收 (mock 模式端到端):
+ * 1. 启动只挂 devloop router 的 express app (随机端口)
+ * 2. POST /api/devloop/projects 创建 todo-demo 项目
+ * 3. 校验响应体: ok / pid / pm.files_written / architect.files_written / commit sha
+ * 4. 校验文件系统:对齐 plan-migration.md §P1 验收的 22 个必要路径
+ * 5. 校验 units.json 含 l2_rounds/l2_budget
+ * 6. GET /state 返 prd_rev/sdd_rev/units
+ * 7. git log 至少 4 个 commit (init meta + PM + Architect + project init)
+ *
+ * 通过 → exit 0;失败 → exit 1。
+ *
+ * 用法: node scripts/phase1-verify.js
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+// 复用 server/ 下的 express (top-level scripts/ 没自己 node_modules)
+const serverNodeModules = require('path').resolve(__dirname, '../server/node_modules');
+require('module').globalPaths.push(serverNodeModules);
+const express = (() => {
+ try { return require('express'); }
+ catch {
+ return require(require.resolve('express', { paths: [require('path').resolve(__dirname, '../server')] }));
+ }
+})();
+
+const pathResolver = require('../server/services/devloop/path-resolver');
+const gitVault = require('../server/services/devloop/git-vault');
+const devloopRouter = require('../server/routes/devloop');
+
+const results = [];
+function step(name, fn) {
+ return (async () => {
+ try {
+ const r = await fn();
+ results.push({ name, ok: true, detail: r?.detail || '' });
+ return r;
+ } catch (e) {
+ results.push({ name, ok: false, reason: e.message });
+ throw e;
+ }
+ })();
+}
+
+async function startApp() {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/devloop', devloopRouter);
+ return new Promise((resolve) => {
+ const srv = app.listen(0, '127.0.0.1', () => {
+ const port = srv.address().port;
+ resolve({ srv, base: `http://127.0.0.1:${port}` });
+ });
+ });
+}
+
+async function main() {
+ const { srv, base } = await startApp();
+ const cid = 'c-phase1-' + Date.now().toString(36);
+ let pid = null;
+
+ try {
+ const r = await step('POST /api/devloop/projects', async () => {
+ const resp = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ company_id: cid,
+ name: 'todo-demo',
+ requirement: '做一个 TODO 列表:能新增、列表、Vue 前端',
+ }),
+ });
+ if (resp.status !== 200) {
+ const t = await resp.text();
+ throw new Error(`POST status ${resp.status}: ${t}`);
+ }
+ const j = await resp.json();
+ if (!j.ok || !j.pid?.startsWith('p-')) throw new Error('bad body: ' + JSON.stringify(j));
+ if (j.pm.files_written < 10) throw new Error('pm.files_written too few: ' + j.pm.files_written);
+ if (j.architect.files_written < 10) throw new Error('architect.files_written too few');
+ if (!j.pm.commit) throw new Error('no pm commit');
+ if (!j.architect.commit) throw new Error('no architect commit');
+ pid = j.pid;
+ return { detail: `pid=${pid} pm=${j.pm.files_written} arch=${j.architect.files_written}` };
+ });
+
+ await step('P1 acceptance file tree complete', async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const required = [
+ 'project.json',
+ 'prd/index.html',
+ 'prd/reading-guide.html',
+ 'prd/product/background.html',
+ 'prd/product/personas.html',
+ 'prd/product/scope.html',
+ 'prd/feature/feature-tree.html',
+ 'prd/flow/user-flows.html',
+ 'prd/prototype/proto-list.html',
+ 'prd/assets/shell.css',
+ 'sdd/index.html',
+ 'sdd/mvcp/model.html',
+ 'sdd/mvcp/view.html',
+ 'sdd/mvcp/control.html',
+ 'sdd/mvcp/process.html',
+ 'units.json',
+ 'integration/spec.json',
+ 'agent-state/pm.json',
+ 'agent-state/pm-todo.json',
+ 'agent-state/architect.json',
+ 'agent-state/architect-todo.json',
+ 'history/prd-rev-001.html',
+ 'history/sdd-rev-001.html',
+ ];
+ const missing = required.filter(f => !fs.existsSync(path.join(root, f)));
+ if (missing.length) throw new Error('missing files:\n ' + missing.join('\n '));
+ return { detail: `${required.length}/${required.length} files present` };
+ });
+
+ await step('units.json has 3 units with l2_rounds/l2_budget', () => {
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
+ if (!Array.isArray(u.units) || u.units.length !== 3) {
+ throw new Error('want 3 units, got: ' + (u.units?.length || 0));
+ }
+ for (const x of u.units) {
+ if (typeof x.l2_rounds !== 'number') throw new Error(`unit ${x.id} missing l2_rounds`);
+ if (typeof x.l2_budget !== 'number') throw new Error(`unit ${x.id} missing l2_budget`);
+ }
+ return { detail: '3 units · l2 fields present · DAG: ' + u.units.map(x => x.id).join(' → ') };
+ });
+
+ await step('integration/spec.json has cases', () => {
+ const sp = JSON.parse(fs.readFileSync(path.join(pathResolver.resolveProjectRoot(pid), 'integration/spec.json'), 'utf8'));
+ if (!Array.isArray(sp.cases) || sp.cases.length === 0) {
+ throw new Error('no integration cases');
+ }
+ return { detail: `${sp.cases.length} case(s)` };
+ });
+
+ await step('GET /state', async () => {
+ const resp = await fetch(`${base}/api/devloop/projects/${pid}/state`);
+ if (resp.status !== 200) throw new Error('state status: ' + resp.status);
+ const s = await resp.json();
+ if (!s.prd_site) throw new Error('prd_site=false');
+ if (!s.sdd_site) throw new Error('sdd_site=false');
+ if (s.units?.length !== 3) throw new Error('state.units length: ' + s.units?.length);
+ return { detail: `prd_rev=${s.prd_rev} sdd_rev=${s.sdd_rev} units=${s.units.length}` };
+ });
+
+ await step('git log ≥4 commits', async () => {
+ const log = await gitVault.log(pid, { limit: 20 });
+ const commits = Array.isArray(log) ? log : (log.commits || []);
+ if (commits.length < 4) {
+ throw new Error('want ≥4 commits, got ' + commits.length);
+ }
+ return { detail: `${commits.length} commits` };
+ });
+
+ await step('timeline.ndjson has agent.call + docs.complete', () => {
+ const tl = pathResolver.resolveTimelineLog(pid);
+ const kinds = fs.readFileSync(tl, 'utf8').trim().split('\n')
+ .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
+ if (!kinds.includes('agent.call')) throw new Error('no agent.call in timeline');
+ if (!kinds.includes('docs.complete')) throw new Error('no docs.complete in timeline');
+ return { detail: kinds.length + ' timeline events' };
+ });
+
+ } finally {
+ srv.close();
+ // cleanup sandbox project
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
+ } catch { /* ignore */ }
+ }
+
+ let pass = 0, fail = 0;
+ console.log('\n┌─ DevLoop Phase 1 — Docs MVP Acceptance ───────────');
+ for (const r of results) {
+ const tag = r.ok ? '✓' : '✗';
+ console.log(`│ ${tag} ${r.name.padEnd(50)} ${r.ok ? r.detail : '— ' + r.reason}`);
+ r.ok ? pass++ : fail++;
+ }
+ console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
+ process.exit(fail ? 1 : 0);
+}
+
+main().catch(e => {
+ console.error('\n[phase1-verify] fatal:', e);
+ for (const r of results) {
+ console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
+ }
+ process.exit(1);
+});
diff --git a/server/agent-server.js b/server/agent-server.js
index 3144ce3..e2c5e40 100644
--- a/server/agent-server.js
+++ b/server/agent-server.js
@@ -66,6 +66,7 @@ app.use('/api/agent', agentChatRoute);
app.use('/api/company', require('./routes/company'));
app.use('/api/goals', require('./routes/goals'));
app.use('/api/tasks', require('./routes/tasks'));
+app.use('/api/devloop', require('./routes/devloop'));
// Health
app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
diff --git a/server/index.js b/server/index.js
index 8e0d1be..e23c94d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -93,6 +93,7 @@ app.use('/api/agent', require('./routes/agent-chat'));
app.use('/api/goals', require('./routes/goals'));
app.use('/api/company', require('./routes/company'));
app.use('/api/tasks', require('./routes/tasks'));
+app.use('/api/devloop', require('./routes/devloop'));
// ─── 系统端点 ───
app.get('/api/health', (req, res) => res.json({
diff --git a/server/routes/devloop.js b/server/routes/devloop.js
new file mode 100644
index 0000000..1dae9f3
--- /dev/null
+++ b/server/routes/devloop.js
@@ -0,0 +1,92 @@
+/**
+ * DevLoop · REST Routes (P1)
+ *
+ * POST /api/devloop/projects
+ * body: { company_id, name?, requirement, mode?, target_path?, mock? }
+ * → 创建项目 + 跑 PM / Architect 两个 Agent (mock 模式产出 PRD/SDD)
+ * → 返 { pid, root_path, pm: {...}, architect: {...} }
+ *
+ * GET /api/devloop/projects/:pid/state
+ * → 返 { pid, project, units, prd_rev, sdd_rev, prd_site, sdd_site, timeline_tail }
+ *
+ * GET /api/devloop/projects (额外:列项目)
+ * → 返 { projects: [...] }
+ *
+ * 规格: docs/prd/newfunction/_spec/plan-migration.md · P1 验收
+ */
+
+const express = require('express');
+const router = express.Router();
+
+const loopController = require('../src/core/devloop/loop-controller');
+const pathResolver = require('../services/devloop/path-resolver');
+
+const h = (fn) => async (req, res) => {
+ try { await fn(req, res); }
+ catch (e) {
+ console.error(`[DevLoop] ${req.method} ${req.path}:`, e.message);
+ res.status(e.statusCode || 500).json({ error: e.message });
+ }
+};
+
+// POST /api/devloop/projects
+router.post('/projects', h(async (req, res) => {
+ const { company_id, requirement, mode, target_path, mock, name } = req.body || {};
+ if (!company_id) {
+ return res.status(400).json({ error: 'missing field: company_id' });
+ }
+ if (!requirement || typeof requirement !== 'string') {
+ return res.status(400).json({ error: 'missing field: requirement' });
+ }
+
+ // 1. 创建项目 (sandbox 默认)
+ const proj = await loopController.createProject({
+ company_id,
+ mode: mode || 'sandbox',
+ target_path,
+ idea: name ? `${name}: ${requirement}` : requirement,
+ });
+
+ // 2. PM → Architect (mock 模式)
+ const phase = await loopController.runDocsPhase(proj.pid, requirement, {
+ mock: mock !== false,
+ });
+
+ res.json({
+ ok: true,
+ pid: proj.pid,
+ root_path: proj.root_path,
+ mode: proj.mode,
+ pm: {
+ files_written: phase.pm.files_written.length,
+ commit: phase.pm.commit?.sha || null,
+ mock: phase.pm.mock,
+ },
+ architect: {
+ files_written: phase.architect.files_written.length,
+ commit: phase.architect.commit?.sha || null,
+ mock: phase.architect.mock,
+ },
+ });
+}));
+
+// GET /api/devloop/projects
+router.get('/projects', h(async (_req, res) => {
+ const projects = pathResolver.listProjects();
+ res.json({ projects });
+}));
+
+// GET /api/devloop/projects/:pid/state
+router.get('/projects/:pid/state', h(async (req, res) => {
+ try {
+ const state = loopController.getProjectState(req.params.pid);
+ res.json(state);
+ } catch (e) {
+ if (/Unknown project/.test(e.message)) {
+ return res.status(404).json({ error: 'unknown project: ' + req.params.pid });
+ }
+ throw e;
+ }
+}));
+
+module.exports = router;
diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js
index bce2f7a..bc2ca51 100644
--- a/server/src/core/devloop/loop-controller.js
+++ b/server/src/core/devloop/loop-controller.js
@@ -1,16 +1,20 @@
/**
- * DevLoop · LoopController (P0 最小骨架)
+ * DevLoop · LoopController
*
- * 在 P0 阶段只暴露两个对外接口:
- * - createProject({ company_id, mode, target_path }) — 调 path-resolver + gitVault.init
- * - commitAfterAgent(pid, agentName, summary) — 每次 Agent 完成后 auto-commit
+ * P0 已提供:
+ * - createProject({ company_id, mode, target_path, idea }) — 项目分配 + git init
+ * - commitAfterAgent(pid, agentName, summary) — Agent 收尾自动 commit
*
- * 完整的 dispatch / unit fsm / retry 逻辑留待 P1 实现。
+ * 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');
@@ -19,6 +23,9 @@ 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 { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');
+
+const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');
/**
* 新建项目:
@@ -119,7 +126,201 @@ async function commitAfterAgent(pid, agentName, summary = '') {
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 });
+ fs.writeFileSync(abs, f.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;
+}
+
module.exports = {
createProject,
commitAfterAgent,
+ callAgent,
+ runDocsPhase,
+ getProjectState,
};
diff --git a/server/tests/devloop/loop-controller.test.js b/server/tests/devloop/loop-controller.test.js
new file mode 100644
index 0000000..def2bbc
--- /dev/null
+++ b/server/tests/devloop/loop-controller.test.js
@@ -0,0 +1,135 @@
+/**
+ * loop-controller (P1) 单测
+ * - callAgent (mock 模式) 能从 fixture 产出文件 + git commit
+ * - runDocsPhase 串行跑 PM + Architect,产物齐全
+ * - getProjectState 返回 prd_rev/sdd_rev/units
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-lc-'));
+process.env.HOME = TEST_HOME;
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const lc = require('../../src/core/devloop/loop-controller');
+
+describe('loop-controller (P1)', () => {
+ let pid, cid;
+ const created = [];
+
+ afterAll(() => {
+ // 清理 sandbox 项目目录
+ for (const c of created) {
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
+ } catch { /* ignore */ }
+ }
+ });
+
+ async function fresh() {
+ pathResolver._resetForTesting();
+ cid = 'c-test-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ const proj = await lc.createProject({
+ company_id: cid, mode: 'sandbox', idea: 'unit-test',
+ });
+ pid = proj.pid;
+ return proj;
+ }
+
+ describe('callAgent (mock devloop-pm)', () => {
+ beforeAll(async () => { await fresh(); });
+
+ it('produces files from fixture + commits', async () => {
+ const r = await lc.callAgent(pid, 'devloop-pm', 'requirement: hello');
+ expect(r.ok).toBe(true);
+ expect(r.mock).toBe(true);
+ expect(r.files_written.length).toBeGreaterThanOrEqual(10);
+ expect(r.commit).not.toBeNull();
+ expect(r.commit.sha).toBeTruthy();
+ const root = pathResolver.resolveProjectRoot(pid);
+ expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true);
+ expect(fs.existsSync(path.join(root, 'agent-state', 'pm.json'))).toBe(true);
+ });
+
+ it('subsequent call still commits (timeline.ndjson always appended)', async () => {
+ const r = await lc.callAgent(pid, 'devloop-pm', 'second time same fixture');
+ // mock 产物相同但 timeline.ndjson 会追加一行 → 总有 diff,有 commit
+ expect(r.commit).not.toBeNull();
+ expect(r.commit.sha).toBeTruthy();
+ });
+ });
+
+ describe('runDocsPhase (PM → Architect)', () => {
+ beforeAll(async () => { await fresh(); });
+
+ let phase;
+ it('completes both agents', async () => {
+ phase = await lc.runDocsPhase(pid, '做一个 TODO 列表');
+ expect(phase.ok).toBe(true);
+ expect(phase.pm.commit?.sha).toBeTruthy();
+ expect(phase.architect.commit?.sha).toBeTruthy();
+ });
+
+ it('produces full P1 acceptance file tree', () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const required = [
+ 'project.json',
+ 'prd/index.html',
+ 'prd/reading-guide.html',
+ 'prd/product/background.html',
+ 'prd/product/personas.html',
+ 'prd/product/scope.html',
+ 'prd/feature/feature-tree.html',
+ 'prd/flow/user-flows.html',
+ 'sdd/index.html',
+ 'sdd/mvcp/model.html',
+ 'sdd/mvcp/view.html',
+ 'sdd/mvcp/control.html',
+ 'sdd/mvcp/process.html',
+ 'units.json',
+ 'integration/spec.json',
+ 'agent-state/pm.json',
+ 'agent-state/pm-todo.json',
+ 'agent-state/architect.json',
+ 'agent-state/architect-todo.json',
+ 'history/prd-rev-001.html',
+ 'history/sdd-rev-001.html',
+ ];
+ const missing = required.filter(f => !fs.existsSync(path.join(root, f)));
+ expect(missing).toEqual([]);
+ });
+
+ it('units.json has 3 units with l2_rounds/l2_budget', () => {
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
+ expect(u.units).toHaveLength(3);
+ for (const x of u.units) {
+ expect(typeof x.l2_rounds).toBe('number');
+ expect(typeof x.l2_budget).toBe('number');
+ }
+ });
+
+ it('getProjectState reflects produced docs', () => {
+ const s = lc.getProjectState(pid);
+ expect(s.prd_site).toBe(true);
+ expect(s.sdd_site).toBe(true);
+ expect(s.prd_rev).toBe(1);
+ expect(s.sdd_rev).toBe(1);
+ expect(s.units).toHaveLength(3);
+ expect(Array.isArray(s.timeline_tail)).toBe(true);
+ expect(s.timeline_tail.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('runDocsPhase rejects bad input', () => {
+ beforeAll(async () => { await fresh(); });
+
+ it('throws when requirement missing', async () => {
+ await expect(lc.runDocsPhase(pid, '')).rejects.toThrow(/requirement/);
+ });
+ });
+});
diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js
new file mode 100644
index 0000000..41f797c
--- /dev/null
+++ b/server/tests/devloop/routes.test.js
@@ -0,0 +1,122 @@
+/**
+ * routes/devloop.js 集成测试
+ *
+ * 不启动完整 server,只挂 devloop router 到独立 express app + fetch。
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const express = require('express');
+
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rt-'));
+process.env.HOME = TEST_HOME;
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const devloopRouter = require('../../routes/devloop');
+
+function startApp() {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/devloop', devloopRouter);
+ return new Promise((resolve) => {
+ const srv = app.listen(0, '127.0.0.1', () => {
+ const port = srv.address().port;
+ resolve({ srv, base: `http://127.0.0.1:${port}` });
+ });
+ });
+}
+
+describe('routes/devloop', () => {
+ let app, base;
+ const created = [];
+
+ beforeAll(async () => {
+ pathResolver._resetForTesting();
+ ({ srv: app, base } = await startApp());
+ });
+
+ afterAll(() => {
+ app.close();
+ for (const c of created) {
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
+ } catch { /* ignore */ }
+ }
+ });
+
+ it('POST /api/devloop/projects requires company_id', async () => {
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ requirement: 'x' }),
+ });
+ expect(r.status).toBe(400);
+ const j = await r.json();
+ expect(j.error).toMatch(/company_id/);
+ });
+
+ it('POST /api/devloop/projects requires requirement', async () => {
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: 'c-x' }),
+ });
+ expect(r.status).toBe(400);
+ const j = await r.json();
+ expect(j.error).toMatch(/requirement/);
+ });
+
+ it('POST /api/devloop/projects → creates project and runs PM + Architect', async () => {
+ const cid = 'c-route-test-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ const r = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ company_id: cid,
+ name: 'todo-demo',
+ requirement: '做一个 TODO 列表:能新增、列表、Vue 前端',
+ }),
+ });
+ expect(r.status).toBe(200);
+ const j = await r.json();
+ expect(j.ok).toBe(true);
+ expect(j.pid).toMatch(/^p-/);
+ expect(j.pm.files_written).toBeGreaterThanOrEqual(10);
+ expect(j.architect.files_written).toBeGreaterThanOrEqual(10);
+ expect(j.pm.commit).toBeTruthy();
+ expect(j.architect.commit).toBeTruthy();
+
+ // 验证文件确实落盘
+ const root = pathResolver.resolveProjectRoot(j.pid);
+ expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true);
+ expect(fs.existsSync(path.join(root, 'sdd', 'mvcp', 'model.html'))).toBe(true);
+ expect(fs.existsSync(path.join(root, 'units.json'))).toBe(true);
+
+ // GET /state
+ const sR = await fetch(`${base}/api/devloop/projects/${j.pid}/state`);
+ expect(sR.status).toBe(200);
+ const s = await sR.json();
+ expect(s.pid).toBe(j.pid);
+ expect(s.prd_site).toBe(true);
+ expect(s.sdd_site).toBe(true);
+ expect(s.units).toHaveLength(3);
+ });
+
+ it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => {
+ const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`);
+ expect(r.status).toBe(404);
+ const j = await r.json();
+ expect(j.error).toMatch(/unknown/);
+ });
+
+ it('GET /api/devloop/projects lists projects', async () => {
+ const r = await fetch(`${base}/api/devloop/projects`);
+ expect(r.status).toBe(200);
+ const j = await r.json();
+ expect(Array.isArray(j.projects)).toBe(true);
+ });
+});