From 0594b1dee730de178e0f51889446b938aa5c621a Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 13 May 2026 18:15:02 +0800 Subject: [PATCH] feat(devloop): P4 L3 human feedback — status FSM + signOff/abandon/feedback + extended status page --- fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json | 31 +++++++++++++++++++++++++++++++ fixtures/devloop-mock/devloop-integrator.json | 19 +++++++++++++++++++ fixtures/devloop-mock/devloop-pm.json | 31 +++++++++++++++++++++++++++++-- scripts/phase4-verify.js | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/public/devloop-status.html | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- server/routes/devloop.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- server/src/core/devloop/loop-controller.js | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- server/tests/devloop/p4-l3-feedback.test.js | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 846 insertions(+), 9 deletions(-) create mode 100644 fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json create mode 100644 fixtures/devloop-mock/devloop-integrator.json create mode 100644 scripts/phase4-verify.js create mode 100644 server/tests/devloop/p4-l3-feedback.test.js diff --git a/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json b/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json new file mode 100644 index 0000000..707e473 --- /dev/null +++ b/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json @@ -0,0 +1,31 @@ +{ + "_doc": "Happy-path 1-unit (todo-be) architect for P4 acceptance.", + "chunks": ["## SDD rev 1 (todo-be only)\n"], + "result": "mock-ok devloop-architect[happy]: 1 unit", + "files": [ + { + "path": "sdd/index.html", + "content": "SDD

SDD

todo-be only

m
m
v1
" + }, + { + "path": "sdd/mvcp/control.html", + "content": "Control

C-1 POST /todos

201 on create, 400 on missing text

text
POST
api
" + }, + { + "path": "units.json", + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n" + }, + { + "path": "integration/spec.json", + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" + }, + { + "path": "units/todo-be/spec.json", + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}" + }, + { + "path": "history/sdd-rev-001.html", + "content": "

snap

rev1

" + } + ] +} diff --git a/fixtures/devloop-mock/devloop-integrator.json b/fixtures/devloop-mock/devloop-integrator.json new file mode 100644 index 0000000..fb9c7fa --- /dev/null +++ b/fixtures/devloop-mock/devloop-integrator.json @@ -0,0 +1,19 @@ +{ + "_doc": "Baseline happy-path Integrator: returns ok=true on every call. Used by tests/runs that don't override via mockFixtureDir.", + "chunks": [ + "## step 0 · 读 units.json + integration/spec.json\n", + "## step 1-7 · 起 be 进程,跑全部 cases\n", + "全部 case 通过 ✓\n" + ], + "result": "mock-ok devloop-integrator[baseline] PASS: integration green", + "files": [ + { + "path": "integration/runs/run-mock-baseline/result.json", + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-baseline\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 850\n}\n" + }, + { + "path": "integration/runs/run-mock-baseline/process.log", + "content": "[todo-be] starting on :18001\n[todo-be] /api/health → 200\n[todo-be] case-* pass\n[todo-be] SIGTERM clean exit\n" + } + ] +} diff --git a/fixtures/devloop-mock/devloop-pm.json b/fixtures/devloop-mock/devloop-pm.json index 723cd16..cc84063 100644 --- a/fixtures/devloop-mock/devloop-pm.json +++ b/fixtures/devloop-mock/devloop-pm.json @@ -1,6 +1,9 @@ { - "_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": [ + "_doc": "Mock fixture for PM Agent. sequence[0]=init (P1+,full PRD site rev=1), sequence[1]=incremental (P4,appends a section + bumps rev to 2). 第 2+ 次反馈 sticks 在 incremental,每次重复写相同 rev=2 内容 (mock 简化)。", + "sequence": [ + { + "_variant": "init", + "chunks": [ "## 第 1 步: 解析 idea\n", "用户要求做一个 TODO 列表 API + Vue 前端。\n", "\n## 第 2 步: 初始化 prd/ 站点\n", @@ -69,4 +72,28 @@ "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}" } ] + }, + { + "_variant": "incremental", + "chunks": [ + "## Mode: incremental\n", + "收到人类反馈,追加 prd/feature/feature-tree.html 一个 section,bump prd_rev → 2\n" + ], + "result": "mock-ok devloop-pm[incremental]: PRD rev 2 + 1 new feature section", + "files": [ + { + "path": "prd/index.html", + "content": "PRD · todo-demo (rev 2)

项目首页

todo-demo · 一个 TODO 列表 API + 前端

章节路径
背景product/background.html
flow: list -> add -> done
导航卡片
更多

站点入口

新增功能 (feedback)

追加: 标记 done / undone 切换

新功能
toggle done
v2
rev2: 人类追加 toggle done
" + }, + { + "path": "prd/feature/feature-tree.html", + "content": "功能树 rev 2

功能树

todo-be · todo-fe · todo-db

模块说明
todo-beAPI
tree
v1

NEW: toggle done

用户反馈:列表项需要支持标记完成/未完成的切换。新增 PATCH /todos/:id 接口与对应前端按钮。

PATCH/todos/:id
toggle
delta
" + }, + { + "path": "history/prd-rev-002.html", + "content": "

Snapshot rev 2

Archive of PRD rev 2 (after incremental feedback).

" + } + ] + } + ] } diff --git a/scripts/phase4-verify.js b/scripts/phase4-verify.js new file mode 100644 index 0000000..8ea12e6 --- /dev/null +++ b/scripts/phase4-verify.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * scripts/phase4-verify.js + * + * P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP): + * ① POST /projects → status=running, PRD rev 1 + * ② POST /run → ships, status=shipped + * ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2 + * ④ POST /run (re-run) → status=shipped again + * ⑤ POST /sign-off → status=shipped-final + * ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition + * ⑦ POST /sign-off (after sign-off) → 400 + * ⑧ 第二个项目: POST /abandon → status=abandoned + * ⑨ 第二个项目 POST /abandon 再次 → 400 + */ + +process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; + +const fs = require('fs'); +const path = require('path'); + +const serverNodeModules = path.resolve(__dirname, '../server/node_modules'); +require('module').globalPaths.push(serverNodeModules); +const express = (() => { + try { return require('express'); } + catch { + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] })); + } +})(); + +const pathResolver = require('../server/services/devloop/path-resolver'); +const sseBroker = require('../server/services/devloop/sse-broker'); +const devloopRouter = require('../server/routes/devloop'); + +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path'); + +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', () => { + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); + }); + }); +} + +async function main() { + const { srv, base } = await startApp(); + const cid = 'c-phase4-' + Date.now().toString(36); + let pid1, pid2; + + try { + await step('① POST /projects → status=running', async () => { + const r = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + pid1 = j.pid; + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); + if (s.status !== 'running') throw new Error('want running, got ' + s.status); + return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` }; + }); + + await step('② POST /run → status=shipped', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + const j = await r.json(); + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json(); + if (s.status !== 'shipped') throw new Error('status=' + s.status); + return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` }; + }); + + await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after); + if (j.status !== 'running') throw new Error('status=' + j.status); + // changelog + const cl = pathResolver.resolveChangelog(pid1); + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog'); + return { detail: `rev 1→2 · status=running · changelog updated` }; + }); + + await step('④ POST /run (re-run) → status=shipped', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }), + }); + const j = await r.json(); + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state); + return { detail: 'shipped again' }; + }); + + await step('⑤ POST /sign-off → status=shipped-final', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo' }), + }); + if (r.status !== 200) throw new Error('status ' + r.status); + const j = await r.json(); + if (j.status !== 'shipped-final') throw new Error('status=' + j.status); + return { detail: 'status=shipped-final' }; + }); + + await step('⑥ POST /feedback (after sign-off) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'late change' }), + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + const j = await r.json(); + if (j.from !== 'shipped-final') throw new Error('from=' + j.from); + return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' }; + }); + + await step('⑦ POST /sign-off (after sign-off) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + return { detail: '400 (terminal state)' }; + }); + + await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => { + const cr = await fetch(`${base}/api/devloop/projects`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }), + }); + pid2 = (await cr.json()).pid; + const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ by: 'yibo', reason: 'mock test' }), + }); + if (ab.status !== 200) throw new Error('abandon status ' + ab.status); + const j = await ab.json(); + if (j.status !== 'abandoned') throw new Error('status=' + j.status); + return { detail: `pid=${pid2} status=abandoned` }; + }); + + await step('⑨ POST /abandon (after abandon) → 400', async () => { + const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (r.status !== 400) throw new Error('want 400, got ' + r.status); + return { detail: '400 (terminal state)' }; + }); + + } finally { + srv.close(); + 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 4 — L3 Human Feedback Acceptance ──'); + for (const r of results) { + const tag = r.ok ? '✓' : '✗'; + console.log(`│ ${tag} ${r.name.padEnd(58)} ${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[phase4-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/public/devloop-status.html b/server/public/devloop-status.html index c01d685..b1cd1bb 100644 --- a/server/public/devloop-status.html +++ b/server/public/devloop-status.html @@ -29,6 +29,21 @@ input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; } button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; } .err { color:#c00; margin:8px 0; } + .ok-flash { color:#080; margin:8px 0; } + .status-badge { font-size:11px; padding:3px 10px; border-radius:12px; margin-left:10px; text-transform:uppercase; } + .status-badge.draft { background:#eee; color:#666; } + .status-badge.running { background:#cce5ff; color:#004085; } + .status-badge.shipped { background:#d4edda; color:#155724; } + .status-badge.shipped-final { background:#0a6; color:#fff; } + .status-badge.abandoned { background:#222; color:#fff; } + .status-badge.l2_blocked { background:#fff3cd; color:#856404; } + .status-badge.l2_exhausted { background:#f8d7da; color:#721c24; } + .controls { display:flex; gap:8px; margin:16px 0; flex-wrap:wrap; } + .controls button { padding:6px 14px; } + .controls button.signoff { background:#0a6; } + .controls button.abandon { background:#822; } + .controls button:disabled { background:#ccc; cursor:not-allowed; } + textarea { width:100%; min-height:60px; padding:8px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing:border-box; } @@ -43,8 +58,20 @@