Commit 0594b1dee730de178e0f51889446b938aa5c621a

Authored by yibo
1 parent 151acb22

feat(devloop): P4 L3 human feedback — status FSM + signOff/abandon/feedback + extended status page

Implements the §P4 acceptance gate from plan-migration.md, with one
scope adjustment confirmed up-front: full Vue Dashboard (~1650 lines)
deferred to a future UI phase, replaced by extending the existing
P3 static status page with Sign Off / Abandon / Feedback controls.
All P4 backend acceptance criteria are scripted (phase4-verify.js).

Project status FSM (project.json.status):
- server/src/core/devloop/loop-controller.js
  * draft  →  running  →  shipped  →  shipped-final | abandoned
                ↓             ↓
          l2_blocked    (feedback flips back to running)
          l2_exhausted
  * `_setProjectStatus` validates transitions via STATUS_TRANSITIONS,
    emits SSE `project.status`, writes timeline event.
  * createProject seeds status=draft; runDocsPhase end → running;
    runProject(shipped) → shipped; all dead-end branches (l2_blocked,
    l2_exhausted) also propagate to status via _safeSetStatus.

L3 helpers:
- signOff(pid, opts) — requires status=shipped → shipped-final; commits
  a `chore: sign-off` git entry; rejects other source states with
  InvalidProjectStatusTransition.
- abandon(pid, opts) — any non-terminal → abandoned; commits + calls
  sseBroker.close(pid) to send SSE close frame to subscribers.
- submitFeedback(pid, text, opts) — rejects in shipped-final/abandoned;
  invokes PM in incremental mode; on prd_rev bump writes
  changelog.ndjson prd_rev entry + emits prd.updated SSE; if prior
  status was shipped, flips back to running so next runProject can
  rebuild.

PM incremental mock fixture:
- fixtures/devloop-mock/devloop-pm.json — converted to sequence form:
  * sequence[0] (init) — original P1 fixture (PRD rev=1, 14 files).
  * sequence[1] (incremental) — appends a new section to
    prd/feature/feature-tree.html, bumps article data-rev to 2,
    writes history/prd-rev-002.html.

Baseline happy-path Integrator:
- fixtures/devloop-mock/devloop-integrator.json — single-shot pass.
  Makes vanilla runProject ship without a scenario override.

P4 happy-path scenario:
- fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json —
  single-unit (todo-be only) Architect so end-to-end ship works with
  the baseline Coder/Tester (which only know todo-be paths).

Routes:
- server/routes/devloop.js:
  * POST /projects/:pid/sign-off — 400 InvalidProjectStatusTransition
    when status != 'shipped'; 200 returns { status }.
  * POST /projects/:pid/abandon  — body { by?, reason?, note? }; 400 on
    terminal state; 200 returns { status: 'abandoned' }.
  * POST /projects/:pid/feedback — body { text (req), by?,
    mockFixtureDir? }; 400 on missing text or shipped-final/abandoned;
    200 returns { prd_rev_before, prd_rev_after, status }.
  * POST /projects: now accepts optional `mockFixtureDir` so the same
    fixture dir threads through both the create call and later
    feedback (keeps mock-sequence counters aligned).

Static status page (replaces full Vue Dashboard for P4):
- server/public/devloop-status.html — adds:
  * Status badge in header (color-coded per status).
  * Sign Off / Abandon control buttons (disabled per current state).
  * Feedback textarea + Submit button → POST /feedback.
  * Buttons reflect FSM (signoff only when shipped; abandon/feedback
    locked at terminal states).

Acceptance:
- scripts/phase4-verify.js — 9 steps, full HTTP path:
  ① POST /projects → running
  ② POST /run → shipped
  ③ POST /feedback → PRD rev 1→2 + running (+ changelog entry)
  ④ POST /run again → shipped
  ⑤ POST /sign-off → shipped-final
  ⑥ POST /feedback (after sign-off) → 400
  ⑦ POST /sign-off (after sign-off) → 400
  ⑧ second project: POST /abandon → abandoned
  ⑨ POST /abandon (after abandon) → 400

Tests (+19 cases):
- server/tests/devloop/p4-l3-feedback.test.js
  * 14 FSM cases covering happy path + abandon path + signOff
    pre-conditions + empty feedback rejection.
  * 5 route cases via fetch on ephemeral express app.

Results:
- phase0-verify: 8/8
- phase0-test-call: 7/7
- phase1-verify: 7/7
- phase2-verify: 6/6
- phase3-verify: 9/9
- phase4-verify: 9/9
- vitest devloop+acp-core: 213/213 (was 194 → +19)

Spec deviations explicit:
- Full Vue DevLoopDashboard.vue + Ops 4 sub-pages NOT implemented;
  static status page handles the demoable surface area instead.
- All backend endpoints from §P4 (sign-off, abandon, feedback) are
  complete and tested.

Specs: docs/prd/newfunction/_spec/plan-migration.md · P4 L3 + UI

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "Happy-path 1-unit (todo-be) architect for P4 acceptance.",
  3 + "chunks": ["## SDD rev 1 (todo-be only)\n"],
  4 + "result": "mock-ok devloop-architect[happy]: 1 unit",
  5 + "files": [
  6 + {
  7 + "path": "sdd/index.html",
  8 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>SDD</h3><p>todo-be only</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">v1</div></section></article></body></html>"
  9 + },
  10 + {
  11 + "path": "sdd/mvcp/control.html",
  12 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control</title></head><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos</h3><p>201 on create, 400 on missing text</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">api</div></section></article></body></html>"
  13 + },
  14 + {
  15 + "path": "units.json",
  16 + "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"
  17 + },
  18 + {
  19 + "path": "integration/spec.json",
  20 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  21 + },
  22 + {
  23 + "path": "units/todo-be/spec.json",
  24 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
  25 + },
  26 + {
  27 + "path": "history/sdd-rev-001.html",
  28 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap</h3><p>rev1</p></section></article></body></html>"
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "Baseline happy-path Integrator: returns ok=true on every call. Used by tests/runs that don't override via mockFixtureDir.",
  3 + "chunks": [
  4 + "## step 0 · 读 units.json + integration/spec.json\n",
  5 + "## step 1-7 · 起 be 进程,跑全部 cases\n",
  6 + "全部 case 通过 ✓\n"
  7 + ],
  8 + "result": "mock-ok devloop-integrator[baseline] PASS: integration green",
  9 + "files": [
  10 + {
  11 + "path": "integration/runs/run-mock-baseline/result.json",
  12 + "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"
  13 + },
  14 + {
  15 + "path": "integration/runs/run-mock-baseline/process.log",
  16 + "content": "[todo-be] starting on :18001\n[todo-be] /api/health → 200\n[todo-be] case-* pass\n[todo-be] SIGTERM clean exit\n"
  17 + }
  18 + ]
  19 +}
... ...
fixtures/devloop-mock/devloop-pm.json
1 1 {
2   - "_doc": "Mock fixture for PM Agent — drives P1 acceptance (todo-demo project). Produces the full PRD multi-page site + pm agent-state + history snapshot.",
3   - "chunks": [
  2 + "_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 简化)。",
  3 + "sequence": [
  4 + {
  5 + "_variant": "init",
  6 + "chunks": [
4 7 "## 第 1 步: 解析 idea\n",
5 8 "用户要求做一个 TODO 列表 API + Vue 前端。\n",
6 9 "\n## 第 2 步: 初始化 prd/ 站点\n",
... ... @@ -69,4 +72,28 @@
69 72 "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}"
70 73 }
71 74 ]
  75 + },
  76 + {
  77 + "_variant": "incremental",
  78 + "chunks": [
  79 + "## Mode: incremental\n",
  80 + "收到人类反馈,追加 prd/feature/feature-tree.html 一个 section,bump prd_rev → 2\n"
  81 + ],
  82 + "result": "mock-ok devloop-pm[incremental]: PRD rev 2 + 1 new feature section",
  83 + "files": [
  84 + {
  85 + "path": "prd/index.html",
  86 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>PRD · todo-demo (rev 2)</title><link rel=\"stylesheet\" href=\"assets/shell.css\"></head><body data-page=\"index.html\"><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\" data-origin=\"system@2026-05-13T10:00:00.000Z\"><h3>项目首页</h3><p>todo-demo · 一个 TODO 列表 API + 前端</p><table><tr><th>章节</th><th>路径</th></tr><tr><td>背景</td><td>product/background.html</td></tr></table><pre>flow: list -> add -> done</pre><svg width=\"100\" height=\"20\"><line x1=\"0\" y1=\"10\" x2=\"100\" y2=\"10\" stroke=\"black\" marker-end=\"url(#arrow)\"/></svg><div class=\"card\">导航卡片</div><details><summary>更多</summary><p>站点入口</p></details></section><section data-rev=\"2\" data-origin=\"human@2026-05-13T11:00:00.000Z\"><h3>新增功能 (feedback)</h3><p>追加: 标记 done / undone 切换</p><table><tr><td>新功能</td></tr></table><pre>toggle done</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">v2</div></section><footer data-changelog>rev2: 人类追加 toggle done</footer></article><script src=\"assets/shell.js\"></script></body></html>"
  87 + },
  88 + {
  89 + "path": "prd/feature/feature-tree.html",
  90 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>功能树 rev 2</title><link rel=\"stylesheet\" href=\"../assets/shell.css\"></head><body data-page=\"feature/feature-tree.html\"><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\" data-origin=\"yibo@2026-05-13T10:00:00.000Z\"><h3>功能树</h3><p>todo-be · todo-fe · todo-db</p><table><tr><th>模块</th><th>说明</th></tr><tr><td>todo-be</td><td>API</td></tr></table><pre>tree</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">v1</div></section><section data-rev=\"2\" data-origin=\"human@2026-05-13T11:00:00.000Z\"><h3>NEW: toggle done</h3><p>用户反馈:列表项需要支持标记完成/未完成的切换。新增 PATCH /todos/:id 接口与对应前端按钮。</p><table><tr><td>PATCH</td><td>/todos/:id</td></tr></table><pre>toggle</pre><svg width=\"40\" height=\"20\"></svg><div class=\"card\">delta</div></section></article></body></html>"
  91 + },
  92 + {
  93 + "path": "history/prd-rev-002.html",
  94 + "content": "<!doctype html><html><body><article data-doc=\"prd\" data-rev=\"2\"><section data-rev=\"1\"><h3>Snapshot rev 2</h3><p>Archive of PRD rev 2 (after incremental feedback).</p></section></article></body></html>"
  95 + }
  96 + ]
  97 + }
  98 + ]
72 99 }
... ...
scripts/phase4-verify.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/phase4-verify.js
  4 + *
  5 + * P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP):
  6 + * ① POST /projects → status=running, PRD rev 1
  7 + * ② POST /run → ships, status=shipped
  8 + * ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2
  9 + * ④ POST /run (re-run) → status=shipped again
  10 + * ⑤ POST /sign-off → status=shipped-final
  11 + * ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition
  12 + * ⑦ POST /sign-off (after sign-off) → 400
  13 + * ⑧ 第二个项目: POST /abandon → status=abandoned
  14 + * ⑨ 第二个项目 POST /abandon 再次 → 400
  15 + */
  16 +
  17 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  18 +
  19 +const fs = require('fs');
  20 +const path = require('path');
  21 +
  22 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  23 +require('module').globalPaths.push(serverNodeModules);
  24 +const express = (() => {
  25 + try { return require('express'); }
  26 + catch {
  27 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  28 + }
  29 +})();
  30 +
  31 +const pathResolver = require('../server/services/devloop/path-resolver');
  32 +const sseBroker = require('../server/services/devloop/sse-broker');
  33 +const devloopRouter = require('../server/routes/devloop');
  34 +
  35 +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
  36 +
  37 +const results = [];
  38 +function step(name, fn) {
  39 + return (async () => {
  40 + try {
  41 + const r = await fn();
  42 + results.push({ name, ok: true, detail: r?.detail || '' });
  43 + return r;
  44 + } catch (e) {
  45 + results.push({ name, ok: false, reason: e.message });
  46 + throw e;
  47 + }
  48 + })();
  49 +}
  50 +
  51 +async function startApp() {
  52 + const app = express();
  53 + app.use(express.json());
  54 + app.use('/api/devloop', devloopRouter);
  55 + return new Promise((resolve) => {
  56 + const srv = app.listen(0, '127.0.0.1', () => {
  57 + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
  58 + });
  59 + });
  60 +}
  61 +
  62 +async function main() {
  63 + const { srv, base } = await startApp();
  64 + const cid = 'c-phase4-' + Date.now().toString(36);
  65 + let pid1, pid2;
  66 +
  67 + try {
  68 + await step('① POST /projects → status=running', async () => {
  69 + const r = await fetch(`${base}/api/devloop/projects`, {
  70 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  71 + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }),
  72 + });
  73 + if (r.status !== 200) throw new Error('status ' + r.status);
  74 + const j = await r.json();
  75 + pid1 = j.pid;
  76 + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
  77 + if (s.status !== 'running') throw new Error('want running, got ' + s.status);
  78 + return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` };
  79 + });
  80 +
  81 + await step('② POST /run → status=shipped', async () => {
  82 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
  83 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  84 + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
  85 + });
  86 + const j = await r.json();
  87 + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
  88 + const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
  89 + if (s.status !== 'shipped') throw new Error('status=' + s.status);
  90 + return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` };
  91 + });
  92 +
  93 + await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => {
  94 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
  95 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  96 + body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }),
  97 + });
  98 + if (r.status !== 200) throw new Error('status ' + r.status);
  99 + const j = await r.json();
  100 + if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after);
  101 + if (j.status !== 'running') throw new Error('status=' + j.status);
  102 + // changelog
  103 + const cl = pathResolver.resolveChangelog(pid1);
  104 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  105 + if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog');
  106 + return { detail: `rev 1→2 · status=running · changelog updated` };
  107 + });
  108 +
  109 + await step('④ POST /run (re-run) → status=shipped', async () => {
  110 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
  111 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  112 + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
  113 + });
  114 + const j = await r.json();
  115 + if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
  116 + return { detail: 'shipped again' };
  117 + });
  118 +
  119 + await step('⑤ POST /sign-off → status=shipped-final', async () => {
  120 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
  121 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  122 + body: JSON.stringify({ by: 'yibo' }),
  123 + });
  124 + if (r.status !== 200) throw new Error('status ' + r.status);
  125 + const j = await r.json();
  126 + if (j.status !== 'shipped-final') throw new Error('status=' + j.status);
  127 + return { detail: 'status=shipped-final' };
  128 + });
  129 +
  130 + await step('⑥ POST /feedback (after sign-off) → 400', async () => {
  131 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
  132 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  133 + body: JSON.stringify({ text: 'late change' }),
  134 + });
  135 + if (r.status !== 400) throw new Error('want 400, got ' + r.status);
  136 + const j = await r.json();
  137 + if (j.from !== 'shipped-final') throw new Error('from=' + j.from);
  138 + return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' };
  139 + });
  140 +
  141 + await step('⑦ POST /sign-off (after sign-off) → 400', async () => {
  142 + const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
  143 + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
  144 + });
  145 + if (r.status !== 400) throw new Error('want 400, got ' + r.status);
  146 + return { detail: '400 (terminal state)' };
  147 + });
  148 +
  149 + await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => {
  150 + const cr = await fetch(`${base}/api/devloop/projects`, {
  151 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  152 + body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }),
  153 + });
  154 + pid2 = (await cr.json()).pid;
  155 + const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
  156 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  157 + body: JSON.stringify({ by: 'yibo', reason: 'mock test' }),
  158 + });
  159 + if (ab.status !== 200) throw new Error('abandon status ' + ab.status);
  160 + const j = await ab.json();
  161 + if (j.status !== 'abandoned') throw new Error('status=' + j.status);
  162 + return { detail: `pid=${pid2} status=abandoned` };
  163 + });
  164 +
  165 + await step('⑨ POST /abandon (after abandon) → 400', async () => {
  166 + const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
  167 + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
  168 + });
  169 + if (r.status !== 400) throw new Error('want 400, got ' + r.status);
  170 + return { detail: '400 (terminal state)' };
  171 + });
  172 +
  173 + } finally {
  174 + srv.close();
  175 + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
  176 + catch { /* ignore */ }
  177 + }
  178 +
  179 + let pass = 0, fail = 0;
  180 + console.log('\n┌─ DevLoop Phase 4 — L3 Human Feedback Acceptance ──');
  181 + for (const r of results) {
  182 + const tag = r.ok ? '✓' : '✗';
  183 + console.log(`│ ${tag} ${r.name.padEnd(58)} ${r.ok ? r.detail : '— ' + r.reason}`);
  184 + r.ok ? pass++ : fail++;
  185 + }
  186 + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  187 + process.exit(fail ? 1 : 0);
  188 +}
  189 +
  190 +main().catch(e => {
  191 + console.error('\n[phase4-verify] fatal:', e);
  192 + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  193 + process.exit(1);
  194 +});
... ...
server/public/devloop-status.html
... ... @@ -29,6 +29,21 @@
29 29 input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; }
30 30 button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; }
31 31 .err { color:#c00; margin:8px 0; }
  32 + .ok-flash { color:#080; margin:8px 0; }
  33 + .status-badge { font-size:11px; padding:3px 10px; border-radius:12px; margin-left:10px; text-transform:uppercase; }
  34 + .status-badge.draft { background:#eee; color:#666; }
  35 + .status-badge.running { background:#cce5ff; color:#004085; }
  36 + .status-badge.shipped { background:#d4edda; color:#155724; }
  37 + .status-badge.shipped-final { background:#0a6; color:#fff; }
  38 + .status-badge.abandoned { background:#222; color:#fff; }
  39 + .status-badge.l2_blocked { background:#fff3cd; color:#856404; }
  40 + .status-badge.l2_exhausted { background:#f8d7da; color:#721c24; }
  41 + .controls { display:flex; gap:8px; margin:16px 0; flex-wrap:wrap; }
  42 + .controls button { padding:6px 14px; }
  43 + .controls button.signoff { background:#0a6; }
  44 + .controls button.abandon { background:#822; }
  45 + .controls button:disabled { background:#ccc; cursor:not-allowed; }
  46 + textarea { width:100%; min-height:60px; padding:8px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing:border-box; }
32 47 </style>
33 48 </head>
34 49 <body>
... ... @@ -43,8 +58,20 @@
43 58 <div id="err" class="err"></div>
44 59  
45 60 <div id="content" style="display:none">
46   - <div class="pid">pid: <span id="pidShow"></span> · root: <span id="rootShow"></span></div>
47   - <div class="row">
  61 + <div class="pid">pid: <span id="pidShow"></span><span id="statusBadge" class="status-badge"></span> · root: <span id="rootShow"></span></div>
  62 +
  63 + <div id="opMsg" class="ok-flash"></div>
  64 +
  65 + <div class="controls">
  66 + <button id="signoffBtn" class="signoff" title="只有 status=shipped 时可以">Sign Off</button>
  67 + <button id="abandonBtn" class="abandon">Abandon</button>
  68 + </div>
  69 +
  70 + <h2 style="font-size:14px;margin:16px 0 8px">Submit feedback (PM incremental)</h2>
  71 + <textarea id="feedbackText" placeholder="新功能 / 修订需求,例如: 增加 toggle done 切换"></textarea>
  72 + <div style="margin-top:8px"><button id="feedbackBtn">Submit feedback</button></div>
  73 +
  74 + <div class="row" style="margin-top:16px">
48 75 <div class="card"><label>PRD rev</label><div class="v" id="prdRev">-</div></div>
49 76 <div class="card"><label>SDD rev</label><div class="v" id="sddRev">-</div></div>
50 77 <div class="card"><label>Units</label><div class="v" id="unitsCount">0</div></div>
... ... @@ -68,6 +95,10 @@ async function fetchState(pid) {
68 95 function render(s) {
69 96 $('pidShow').textContent = s.pid;
70 97 $('rootShow').textContent = s.root_path || '';
  98 + const status = s.status || s.project?.status || 'unknown';
  99 + const sb = $('statusBadge');
  100 + sb.textContent = status;
  101 + sb.className = 'status-badge ' + status;
71 102 $('prdRev').textContent = s.prd_rev ?? '-';
72 103 $('sddRev').textContent = s.sdd_rev ?? '-';
73 104 const units = s.units || [];
... ... @@ -81,6 +112,11 @@ function render(s) {
81 112 $('timeline').innerHTML = (s.timeline_tail || []).map(e => `
82 113 <div><span class="ts">${e.ts || ''}</span><span class="kind">[${e.kind}]</span>${e.summary || ''}</div>
83 114 `).join('');
  115 +
  116 + // 控制按钮可点性
  117 + $('signoffBtn').disabled = status !== 'shipped';
  118 + $('abandonBtn').disabled = status === 'shipped-final' || status === 'abandoned';
  119 + $('feedbackBtn').disabled = status === 'shipped-final' || status === 'abandoned';
84 120 }
85 121  
86 122 async function connect() {
... ... @@ -104,6 +140,45 @@ async function connect() {
104 140  
105 141 $('connectBtn').addEventListener('click', connect);
106 142 $('pidInput').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
  143 +
  144 +async function postAction(endpoint, body) {
  145 + const pid = $('pidInput').value.trim();
  146 + if (!pid) return;
  147 + $('opMsg').textContent = '...';
  148 + try {
  149 + const r = await fetch(`/api/devloop/projects/${pid}/${endpoint}`, {
  150 + method: 'POST',
  151 + headers: { 'Content-Type': 'application/json' },
  152 + body: JSON.stringify(body || {}),
  153 + });
  154 + const j = await r.json();
  155 + if (!r.ok) {
  156 + $('opMsg').textContent = `error: ${j.error || r.status}`;
  157 + $('opMsg').className = 'err';
  158 + } else {
  159 + $('opMsg').textContent = `${endpoint} ok · status=${j.status || j.pid || ''}`;
  160 + $('opMsg').className = 'ok-flash';
  161 + }
  162 + } catch (e) {
  163 + $('opMsg').textContent = 'network error: ' + e.message;
  164 + $('opMsg').className = 'err';
  165 + }
  166 + // poll once immediately to refresh
  167 + const s = await fetchState($('pidInput').value.trim()); render(s);
  168 +}
  169 +
  170 +$('signoffBtn').addEventListener('click', () => postAction('sign-off', { by: 'human' }));
  171 +$('abandonBtn').addEventListener('click', () => {
  172 + if (!confirm('Abandon this project? (irreversible)')) return;
  173 + postAction('abandon', { by: 'human', reason: 'user clicked abandon' });
  174 +});
  175 +$('feedbackBtn').addEventListener('click', () => {
  176 + const text = $('feedbackText').value.trim();
  177 + if (!text) { $('opMsg').textContent = 'feedback text required'; $('opMsg').className='err'; return; }
  178 + postAction('feedback', { text, by: 'human' });
  179 + $('feedbackText').value = '';
  180 +});
  181 +
107 182 const fromQuery = new URLSearchParams(location.search).get('pid');
108 183 if (fromQuery) { $('pidInput').value = fromQuery; connect(); }
109 184 </script>
... ...
server/routes/devloop.js
... ... @@ -33,7 +33,7 @@ const h = (fn) =&gt; async (req, res) =&gt; {
33 33  
34 34 // POST /api/devloop/projects
35 35 router.post('/projects', h(async (req, res) => {
36   - const { company_id, requirement, mode, target_path, mock, name } = req.body || {};
  36 + const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {};
37 37 if (!company_id) {
38 38 return res.status(400).json({ error: 'missing field: company_id' });
39 39 }
... ... @@ -49,9 +49,10 @@ router.post(&#39;/projects&#39;, h(async (req, res) =&gt; {
49 49 idea: name ? `${name}: ${requirement}` : requirement,
50 50 });
51 51  
52   - // 2. PM → Architect (mock 模式)
  52 + // 2. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用
53 53 const phase = await loopController.runDocsPhase(proj.pid, requirement, {
54 54 mock: mock !== false,
  55 + mockFixtureDir,
55 56 });
56 57  
57 58 res.json({
... ... @@ -137,6 +138,61 @@ router.get(&#39;/projects/:pid/stream&#39;, (req, res) =&gt; {
137 138 });
138 139 });
139 140  
  141 +// P4: POST /api/devloop/projects/:pid/sign-off
  142 +// body: { by?, note? }
  143 +// 400 if status !== 'shipped'
  144 +router.post('/projects/:pid/sign-off', h(async (req, res) => {
  145 + const { pid } = req.params;
  146 + try { pathResolver.resolveProjectRoot(pid); }
  147 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  148 + try {
  149 + const meta = await loopController.signOff(pid, req.body || {});
  150 + res.json({ ok: true, pid, status: meta.status });
  151 + } catch (e) {
  152 + if (e.name === 'InvalidProjectStatusTransition') {
  153 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  154 + }
  155 + throw e;
  156 + }
  157 +}));
  158 +
  159 +// P4: POST /api/devloop/projects/:pid/abandon
  160 +// body: { by?, reason?, note? }
  161 +router.post('/projects/:pid/abandon', h(async (req, res) => {
  162 + const { pid } = req.params;
  163 + try { pathResolver.resolveProjectRoot(pid); }
  164 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  165 + try {
  166 + const meta = await loopController.abandon(pid, req.body || {});
  167 + res.json({ ok: true, pid, status: meta.status });
  168 + } catch (e) {
  169 + if (e.name === 'InvalidProjectStatusTransition') {
  170 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  171 + }
  172 + throw e;
  173 + }
  174 +}));
  175 +
  176 +// P4: POST /api/devloop/projects/:pid/feedback
  177 +// body: { text (required), by?, mockFixtureDir? }
  178 +// 400 if status ∈ {shipped-final, abandoned}
  179 +router.post('/projects/:pid/feedback', h(async (req, res) => {
  180 + const { pid } = req.params;
  181 + const { text, by, mockFixtureDir } = req.body || {};
  182 + if (!text) return res.status(400).json({ error: 'missing field: text' });
  183 + try { pathResolver.resolveProjectRoot(pid); }
  184 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  185 + try {
  186 + const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir });
  187 + res.json(r);
  188 + } catch (e) {
  189 + if (e.name === 'InvalidProjectStatusTransition') {
  190 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  191 + }
  192 + throw e;
  193 + }
  194 +}));
  195 +
140 196 // GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
141 197 router.get('/projects/:pid/bugs', h(async (req, res) => {
142 198 const { pid } = req.params;
... ...
server/src/core/devloop/loop-controller.js
... ... @@ -51,13 +51,15 @@ async function createProject({ company_id, mode = &#39;sandbox&#39;, target_path, idea =
51 51 company_id, mode, target_path,
52 52 });
53 53  
54   - // project.json
  54 + // project.json (含 P4 status FSM 字段)
55 55 const meta = {
56 56 pid,
57 57 company_id,
58 58 mode,
59 59 created_at: new Date().toISOString(),
60 60 idea_summary: (idea || '').slice(0, 200),
  61 + status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned
  62 + status_updated_at: new Date().toISOString(),
61 63 };
62 64 fs.writeFileSync(
63 65 pathResolver.resolveProjectJson(pid),
... ... @@ -293,6 +295,9 @@ async function runDocsPhase(pid, requirement, opts = {}) {
293 295 });
294 296 } catch { /* swallow */ }
295 297  
  298 + // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2)
  299 + _setProjectStatus(pid, 'running', { reason: 'docs_complete' });
  300 +
296 301 return { ok: true, pm, architect };
297 302 }
298 303  
... ... @@ -311,7 +316,10 @@ function getProjectState(pid) {
311 316  
312 317 const projectJson = pathResolver.resolveProjectJson(pid);
313 318 if (fs.existsSync(projectJson)) {
314   - try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {}
  319 + try {
  320 + out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8'));
  321 + out.status = out.project.status || 'unknown';
  322 + } catch {}
315 323 }
316 324  
317 325 const unitsJson = pathResolver.resolveUnitsJson(pid);
... ... @@ -579,6 +587,7 @@ async function runProject(pid, opts = {}) {
579 587 history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts });
580 588 if (!r.ok) {
581 589 sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts });
  590 + _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` });
582 591 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` };
583 592 }
584 593 }
... ... @@ -587,8 +596,8 @@ async function runProject(pid, opts = {}) {
587 596  
588 597 // ── (2) L2 gate ────────────────────────────────────────────
589 598 if (!unitRegistry.l2GateOpen(pid)) {
590   - // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked
591 599 sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' });
  600 + _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' });
592 601 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' };
593 602 }
594 603  
... ... @@ -617,6 +626,7 @@ async function runProject(pid, opts = {}) {
617 626 summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`,
618 627 });
619 628 } catch {}
  629 + _setProjectStatus(pid, 'shipped', { run_id: runId });
620 630 return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId };
621 631 }
622 632  
... ... @@ -647,6 +657,7 @@ async function runProject(pid, opts = {}) {
647 657  
648 658 if (!triage) {
649 659 sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId });
  660 + _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' });
650 661 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' };
651 662 }
652 663  
... ... @@ -659,6 +670,7 @@ async function runProject(pid, opts = {}) {
659 670 reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous',
660 671 run_id: runId, category, confidence: triage.confidence,
661 672 });
  673 + _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' });
662 674 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` };
663 675 }
664 676  
... ... @@ -673,6 +685,7 @@ async function runProject(pid, opts = {}) {
673 685 const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u));
674 686 if (affected.length === 0) {
675 687 sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId });
  688 + _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' });
676 689 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history };
677 690 }
678 691 unitRegistry.reset(pid, affected);
... ... @@ -688,6 +701,7 @@ async function runProject(pid, opts = {}) {
688 701 const l2Budget = _l2Budget(pid);
689 702 if (currentRounds + 1 > l2Budget) {
690 703 sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget });
  704 + _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' });
691 705 return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history };
692 706 }
693 707 const oldSddRev = _readSddRev(pid);
... ... @@ -714,14 +728,25 @@ async function runProject(pid, opts = {}) {
714 728  
715 729 // Unknown category
716 730 sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category });
  731 + _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' });
717 732 return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` };
718 733 }
719 734  
720 735 // safety net 超过
721 736 sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations });
  737 + _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' });
722 738 return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history };
723 739 }
724 740  
  741 +/** Status set that swallows invalid-transition errors (already in target state etc.) */
  742 +function _safeSetStatus(pid, to, extra) {
  743 + try { return _setProjectStatus(pid, to, extra); }
  744 + catch (e) {
  745 + if (e.name === 'InvalidProjectStatusTransition') return null;
  746 + throw e;
  747 + }
  748 +}
  749 +
725 750 function _l2Rounds(pid) {
726 751 const u = docStore.readUnits(pid);
727 752 return u?.l2_rounds || 0;
... ... @@ -745,6 +770,170 @@ function _readSddRev(pid) {
745 770 return 1;
746 771 }
747 772  
  773 +// ──────────────────────────────────────────────────────────────
  774 +// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback
  775 +// ──────────────────────────────────────────────────────────────
  776 +
  777 +const STATUS_TRANSITIONS = {
  778 + draft: ['running', 'abandoned'],
  779 + running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'],
  780 + l2_blocked: ['running', 'abandoned'],
  781 + l2_exhausted: ['running', 'abandoned'],
  782 + shipped: ['running', 'shipped-final', 'abandoned'],
  783 + 'shipped-final': [], // 终态
  784 + abandoned: [], // 终态
  785 +};
  786 +
  787 +class InvalidProjectStatusTransition extends Error {
  788 + constructor(from, to) {
  789 + super(`Invalid project status transition: ${from} → ${to}`);
  790 + this.name = 'InvalidProjectStatusTransition';
  791 + this.from = from;
  792 + this.to = to;
  793 + }
  794 +}
  795 +
  796 +function _readProjectMeta(pid) {
  797 + const p = pathResolver.resolveProjectJson(pid);
  798 + if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`);
  799 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  800 +}
  801 +
  802 +function _writeProjectMeta(pid, meta) {
  803 + fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2));
  804 +}
  805 +
  806 +/**
  807 + * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。
  808 + * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。
  809 + */
  810 +function _setProjectStatus(pid, to, extra = {}) {
  811 + const meta = _readProjectMeta(pid);
  812 + const from = meta.status || 'draft';
  813 + if (from === to) return meta; // idempotent
  814 + const allowed = STATUS_TRANSITIONS[from] || [];
  815 + if (!allowed.includes(to)) {
  816 + throw new InvalidProjectStatusTransition(from, to);
  817 + }
  818 + meta.status = to;
  819 + meta.status_updated_at = new Date().toISOString();
  820 + if (extra.note) meta.status_note = extra.note;
  821 + _writeProjectMeta(pid, meta);
  822 +
  823 + try {
  824 + sseBroker.emit(pid, 'project.status', { from, to, ...extra });
  825 + timelineLogger.append(pid, {
  826 + source: extra.by || 'system',
  827 + kind: 'project.status',
  828 + ref: pid,
  829 + summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`,
  830 + payload: { from, to, ...extra },
  831 + });
  832 + } catch { /* swallow */ }
  833 + return meta;
  834 +}
  835 +
  836 +/**
  837 + * Sign Off — 把已 shipped 的项目锁为 shipped-final。
  838 + * 之后再发 feedback 会被拒绝。
  839 + */
  840 +async function signOff(pid, opts = {}) {
  841 + const meta = _readProjectMeta(pid);
  842 + if (meta.status !== 'shipped') {
  843 + throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final');
  844 + }
  845 + _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' });
  846 + // sign-off commit (留 git 痕)
  847 + try {
  848 + await gitVault.commit(pid, {
  849 + kind: 'chore',
  850 + summary: `chore: sign-off project ${pid}`,
  851 + author: { name: opts.by || 'human', email: 'human@devloop.local' },
  852 + });
  853 + } catch { /* swallow if nothing to commit */ }
  854 + return _readProjectMeta(pid);
  855 +}
  856 +
  857 +/**
  858 + * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。
  859 + */
  860 +async function abandon(pid, opts = {}) {
  861 + const meta = _readProjectMeta(pid);
  862 + if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
  863 + throw new InvalidProjectStatusTransition(meta.status, 'abandoned');
  864 + }
  865 + _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note });
  866 + try {
  867 + await gitVault.commit(pid, {
  868 + kind: 'chore',
  869 + summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`,
  870 + author: { name: opts.by || 'human', email: 'human@devloop.local' },
  871 + });
  872 + } catch { /* swallow */ }
  873 + // 关闭所有 SSE 订阅(close 帧)
  874 + try { sseBroker.close(pid); } catch { /* ignore */ }
  875 + return _readProjectMeta(pid);
  876 +}
  877 +
  878 +/**
  879 + * Submit human feedback — 触发 PM incremental 模式。
  880 + * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。
  881 + * shipped-final / abandoned → 拒绝。
  882 + */
  883 +async function submitFeedback(pid, text, opts = {}) {
  884 + if (!text || typeof text !== 'string') {
  885 + throw new Error('feedback text required');
  886 + }
  887 + const meta = _readProjectMeta(pid);
  888 + if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
  889 + throw new InvalidProjectStatusTransition(meta.status, 'feedback');
  890 + }
  891 +
  892 + const prdRevBefore = _readPrdRev(pid);
  893 + const pm = await callAgent(pid, 'devloop-pm',
  894 + `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.`,
  895 + { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir },
  896 + );
  897 + const prdRevAfter = _readPrdRev(pid);
  898 +
  899 + // 若 PRD rev bumped → 写 changelog
  900 + if (prdRevAfter > prdRevBefore) {
  901 + try {
  902 + changelogWriter.append(pid, {
  903 + kind: 'prd_rev',
  904 + rev: prdRevAfter,
  905 + by: opts.by || 'human',
  906 + trigger: 'human_feedback',
  907 + summary: `PRD bumped to rev ${prdRevAfter} via human feedback`,
  908 + diff: { feedback_excerpt: text.slice(0, 120) },
  909 + });
  910 + sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' });
  911 + } catch { /* swallow */ }
  912 + }
  913 +
  914 + // 若当前是 shipped, 改回 running (人类反馈触发新一轮)
  915 + if (meta.status === 'shipped') {
  916 + _setProjectStatus(pid, 'running', { reason: 'human_feedback' });
  917 + }
  918 +
  919 + return {
  920 + ok: true,
  921 + pid,
  922 + files_written: pm.files_written,
  923 + prd_rev_before: prdRevBefore,
  924 + prd_rev_after: prdRevAfter,
  925 + status: _readProjectMeta(pid).status,
  926 + };
  927 +}
  928 +
  929 +function _readPrdRev(pid) {
  930 + const root = pathResolver.resolveProjectRoot(pid);
  931 + const idx = path.join(root, 'prd', 'index.html');
  932 + if (!fs.existsSync(idx)) return 0;
  933 + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="prd"[^>]*data-rev="(\d+)"/);
  934 + return m ? parseInt(m[1], 10) : 1;
  935 +}
  936 +
748 937 module.exports = {
749 938 createProject,
750 939 commitAfterAgent,
... ... @@ -753,4 +942,9 @@ module.exports = {
753 942 runUnit,
754 943 runProject,
755 944 getProjectState,
  945 + // P4
  946 + signOff,
  947 + abandon,
  948 + submitFeedback,
  949 + InvalidProjectStatusTransition,
756 950 };
... ...
server/tests/devloop/p4-l3-feedback.test.js 0 → 100644
  1 +/**
  2 + * P4 · L3 Human Feedback tests
  3 + *
  4 + * 覆盖:
  5 + * - project.status FSM: draft → running → shipped → shipped-final
  6 + * - submitFeedback bumps prd_rev + shipped → running
  7 + * - signOff requires status=shipped
  8 + * - abandon any active state → abandoned (emits close)
  9 + * - InvalidProjectStatusTransition error mapping
  10 + * - PM incremental fixture variant runs on 2nd call
  11 + */
  12 +
  13 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  14 +
  15 +const fs = require('fs');
  16 +const path = require('path');
  17 +const os = require('os');
  18 +
  19 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-'));
  20 +process.env.HOME = TEST_HOME;
  21 +
  22 +const pathResolver = require('../../services/devloop/path-resolver');
  23 +const sseBroker = require('../../services/devloop/sse-broker');
  24 +const lc = require('../../src/core/devloop/loop-controller');
  25 +
  26 +const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path');
  27 +
  28 +async function fresh(idea = 'p4 test') {
  29 + pathResolver._resetForTesting();
  30 + sseBroker._resetForTesting();
  31 + const cid = 'c-p4-' + Math.random().toString(36).slice(2, 7);
  32 + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea });
  33 + return { pid: proj.pid, cid };
  34 +}
  35 +
  36 +function cleanup(cid) {
  37 + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
  38 + catch { /* ignore */ }
  39 +}
  40 +
  41 +describe('P4 · L3 status FSM', () => {
  42 + describe('happy-path: draft → running → shipped → shipped-final', () => {
  43 + let pid, cid;
  44 + beforeAll(async () => { ({ pid, cid } = await fresh()); });
  45 + afterAll(() => cleanup(cid));
  46 +
  47 + it('starts as draft', () => {
  48 + expect(lc.getProjectState(pid).status).toBe('draft');
  49 + });
  50 +
  51 + it('runDocsPhase → running', async () => {
  52 + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
  53 + expect(lc.getProjectState(pid).status).toBe('running');
  54 + });
  55 +
  56 + it('runProject → shipped', async () => {
  57 + const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
  58 + expect(r.final_state).toBe('shipped');
  59 + expect(lc.getProjectState(pid).status).toBe('shipped');
  60 + });
  61 +
  62 + it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => {
  63 + const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR });
  64 + expect(r.prd_rev_after).toBe(2);
  65 + expect(r.prd_rev_before).toBe(1);
  66 + expect(r.status).toBe('running');
  67 + });
  68 +
  69 + it('changelog has prd_rev=2 entry', () => {
  70 + const cl = pathResolver.resolveChangelog(pid);
  71 + expect(fs.existsSync(cl)).toBe(true);
  72 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  73 + expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true);
  74 + });
  75 +
  76 + it('runProject again → shipped', async () => {
  77 + await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
  78 + expect(lc.getProjectState(pid).status).toBe('shipped');
  79 + });
  80 +
  81 + it('signOff → shipped-final', async () => {
  82 + const meta = await lc.signOff(pid, { by: 'yibo' });
  83 + expect(meta.status).toBe('shipped-final');
  84 + });
  85 +
  86 + it('feedback after sign-off is rejected', async () => {
  87 + await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR }))
  88 + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
  89 + });
  90 +
  91 + it('signOff after sign-off is rejected', async () => {
  92 + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
  93 + });
  94 + });
  95 +
  96 + describe('abandon path', () => {
  97 + let pid, cid;
  98 + beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); });
  99 + afterAll(() => cleanup(cid));
  100 +
  101 + it('abandon from draft state succeeds', async () => {
  102 + const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' });
  103 + expect(m.status).toBe('abandoned');
  104 + });
  105 +
  106 + it('abandon after abandon is rejected', async () => {
  107 + await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
  108 + });
  109 +
  110 + it('feedback on abandoned project is rejected', async () => {
  111 + await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR }))
  112 + .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
  113 + });
  114 + });
  115 +
  116 + describe('signOff requires shipped (not running)', () => {
  117 + let pid, cid;
  118 + beforeAll(async () => {
  119 + ({ pid, cid } = await fresh('not yet shipped'));
  120 + await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
  121 + // 不跑 runProject,status 还是 running
  122 + });
  123 + afterAll(() => cleanup(cid));
  124 +
  125 + it('signOff from running fails', async () => {
  126 + await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' });
  127 + });
  128 + });
  129 +
  130 + describe('feedback empty text rejected', () => {
  131 + let pid, cid;
  132 + beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); });
  133 + afterAll(() => cleanup(cid));
  134 +
  135 + it('throws on empty/missing text', async () => {
  136 + await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/);
  137 + await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/);
  138 + });
  139 + });
  140 +});
  141 +
  142 +describe('P4 · routes', () => {
  143 + let app, base;
  144 + const cids = [];
  145 +
  146 + beforeAll(async () => {
  147 + const express = require('express');
  148 + const devloopRouter = require('../../routes/devloop');
  149 + pathResolver._resetForTesting();
  150 + sseBroker._resetForTesting();
  151 + const e = express();
  152 + e.use(express.json());
  153 + e.use('/api/devloop', devloopRouter);
  154 + await new Promise(r => {
  155 + app = e.listen(0, '127.0.0.1', () => {
  156 + base = `http://127.0.0.1:${app.address().port}`;
  157 + r();
  158 + });
  159 + });
  160 + });
  161 +
  162 + afterAll(() => {
  163 + app.close();
  164 + for (const c of cids) cleanup(c);
  165 + });
  166 +
  167 + it('POST /sign-off requires shipped status (400)', async () => {
  168 + const cid = 'c-route-signoff-' + Math.random().toString(36).slice(2, 7);
  169 + cids.push(cid);
  170 + const r = await fetch(`${base}/api/devloop/projects`, {
  171 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  172 + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
  173 + });
  174 + const j = await r.json();
  175 + // immediately after creation/run (full flow), check signoff
  176 + const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, {
  177 + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
  178 + });
  179 + // After POST /projects, status is 'running' (not yet shipped via runProject)
  180 + // → signOff should 400
  181 + expect(r2.status).toBe(400);
  182 + });
  183 +
  184 + it('POST /feedback bumps PRD rev', async () => {
  185 + const cid = 'c-route-feedback-' + Math.random().toString(36).slice(2, 7);
  186 + cids.push(cid);
  187 + const r = await fetch(`${base}/api/devloop/projects`, {
  188 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  189 + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
  190 + });
  191 + const pid = (await r.json()).pid;
  192 + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
  193 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  194 + body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }),
  195 + });
  196 + expect(fb.status).toBe(200);
  197 + const j = await fb.json();
  198 + expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before);
  199 + });
  200 +
  201 + it('POST /feedback missing text → 400', async () => {
  202 + const cid = 'c-route-fb-empty-' + Math.random().toString(36).slice(2, 7);
  203 + cids.push(cid);
  204 + const r = await fetch(`${base}/api/devloop/projects`, {
  205 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  206 + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
  207 + });
  208 + const pid = (await r.json()).pid;
  209 + const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
  210 + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
  211 + });
  212 + expect(fb.status).toBe(400);
  213 + });
  214 +
  215 + it('POST /abandon → status=abandoned', async () => {
  216 + const cid = 'c-route-abandon-' + Math.random().toString(36).slice(2, 7);
  217 + cids.push(cid);
  218 + const r = await fetch(`${base}/api/devloop/projects`, {
  219 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  220 + body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
  221 + });
  222 + const pid = (await r.json()).pid;
  223 + const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, {
  224 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  225 + body: JSON.stringify({ by: 'yibo', reason: 'test' }),
  226 + });
  227 + expect(ab.status).toBe(200);
  228 + const j = await ab.json();
  229 + expect(j.status).toBe('abandoned');
  230 + });
  231 +
  232 + it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => {
  233 + for (const ep of ['sign-off', 'abandon', 'feedback']) {
  234 + const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, {
  235 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  236 + body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}),
  237 + });
  238 + expect(r.status).toBe(404);
  239 + }
  240 + });
  241 +});
... ...