Commit d1d06391150566e24e477b4ecdc0fd367e658ae7

Authored by yibo
2 parents 8b57a024 820049e4

Merge remote-tracking branch 'origin/main'

# Conflicts:
#	system/agents/master/.claude/scheduled_tasks.json
Showing 52 changed files with 5699 additions and 95 deletions
CLAUDE.md
... ... @@ -17,8 +17,11 @@ npm run setup
17 17 # 开发模式(前后端分离,跑完整 ArenaSK + LemonBoss)
18 18 npm run dev # 后端 server/index.js:3010 + 前端 vite:5173
19 19  
20   -# 仅 Agent Chat 子系统(轻量,不依赖 Skill DB)
21   -bash start.sh # server/agent-server.js:3010 + vite:5173
  20 +# 等价快捷脚本(先 kill 占用端口再起)
  21 +bash start.sh # server/index.js:3010 + vite:5173
  22 +
  23 +# (历史轻量入口,仅 Agent Chat / Company / OKR / Tasks / DevLoop 子集)
  24 +PORT=3010 node server/agent-server.js
22 25 ```
23 26  
24 27 > 端口默认 **3010**(`server/config.js`),不是 3000。`start.sh` 会先 kill 占用 3010 / 5173 的进程。
... ... @@ -64,17 +67,17 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js
64 67  
65 68 | 入口 | 端口 | 启动方式 | 挂载路由 |
66 69 |------|------|----------|----------|
67   -| `server/index.js` | 3010 | `npm run dev`, `npm run server` | 全量:`/api/claude-chat` `/api/config` `/api/evolution` `/api/innovator` `/api/master` `/api/open` `/api/arena` `/api/auth` `/api/users` `/api/whatsapp` `/api/agent` `/api/goals` `/api/company` `/api/tasks`,WS `/ws/claude`,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 |
68   -| `server/agent-server.js` | 3010 | `bash start.sh` | 仅 LemonBoss 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` |
  70 +| `server/index.js` | 3010 | `npm run dev` / `npm run server` / `bash start.sh` | 全量:`/api/claude-chat` `/api/config` `/api/evolution` `/api/innovator` `/api/master` `/api/open` `/api/arena` `/api/auth` `/api/users` `/api/whatsapp` `/api/agent` `/api/goals` `/api/company` `/api/tasks` `/api/devloop`,WS `/ws/claude`,插件 / 心跳 / 中断任务恢复,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 |
  71 +| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` `/api/devloop` |
69 72  
70   -两者**不要同时启动**(端口冲突)。需要 Skill / Arena / Master Agent 时跑 `index.js`;只调通 Agent Chat / Company / OKR 时跑 `agent-server.js`。
  73 +两者**不要同时启动**(端口冲突)。默认走 `index.js`(`start.sh` / `npm run dev` 都指向它);`agent-server.js` 只在你想跑无依赖最小子集时手动启动。
71 74  
72 75 ### 目录速查
73 76  
74 77 | 目录 | 说明 |
75 78 |------|------|
76   -| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管) |
77   -| `server/agent-server.js` | 独立 Agent Chat 服务(LemonBoss 子集) |
  79 +| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管),`start.sh` / `npm run dev` 默认入口 |
  80 +| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks / DevLoop),仅手动启动 |
78 81 | `server/routes/` | 15 个 API 路由 |
79 82 | `server/services/` | 26 个业务服务(skill-db, benchmark-runner, company-service, heartbeat, pipeline-service, retrain-pipeline, …) |
80 83 | `server/src/core/` | Agent 引擎核心(orchestrator, master-session, agent-manager, scheduler, session-pool, prompt-builder, runtime-resolver, …) |
... ... @@ -139,7 +142,7 @@ auto-dev "<task>" # bin 入口 → server/src/cli/auto-dev.js
139 142 ## 注意事项
140 143  
141 144 - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。
142   -- `start.sh` **只**做:kill 端口 → 起 `agent-server.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。
  145 +- `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。
143 146 - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。
144 147 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。
145 148 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。
... ...
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).",
  3 + "sequence": [
  4 + {
  5 + "_variant": "init",
  6 + "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"],
  7 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  8 + "files": [
  9 + {
  10 + "path": "sdd/index.html",
  11 + "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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  12 + },
  13 + {
  14 + "path": "sdd/mvcp/control.html",
  15 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev1</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 (ambiguous on errors)</p><table><tr><td>text</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v1</div></section></article></body></html>"
  16 + },
  17 + {
  18 + "path": "units.json",
  19 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"1\",\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0,\n \"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"
  20 + },
  21 + {
  22 + "path": "integration/spec.json",
  23 + "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
  24 + },
  25 + {
  26 + "path": "units/todo-be/spec.json",
  27 + "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}"
  28 + },
  29 + {
  30 + "path": "history/sdd-rev-001.html",
  31 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"1\"><section data-rev=\"1\"><h3>snap1</h3><p>rev1</p></section></article></body></html>"
  32 + }
  33 + ]
  34 + },
  35 + {
  36 + "_variant": "patch",
  37 + "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"],
  38 + "result": "mock-ok devloop-architect[patch]: SDD rev 2",
  39 + "files": [
  40 + {
  41 + "path": "sdd/mvcp/control.html",
  42 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>Control rev 2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\" data-unit-id=\"todo-be\"><h3>C-1 POST /todos (clarified)</h3><p>201 on create; 400 on missing text</p><table><tr><td>text</td><td>required</td></tr></table><pre>POST</pre><svg></svg><div class=\"card\">v2</div></section></article></body></html>"
  43 + },
  44 + {
  45 + "path": "sdd/index.html",
  46 + "content": "<!doctype html><html><head><meta charset=\"utf-8\"><title>SDD rev2</title></head><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"2\"><h3>SDD</h3><p>rev 2</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</div></section></article></body></html>"
  47 + },
  48 + {
  49 + "path": "units.json",
  50 + "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 2,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"2\",\n \"updated_at\": \"2026-05-13T10:05:00.000Z\",\n \"l2_rounds\": 1,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:00000000000000aa\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
  51 + },
  52 + {
  53 + "path": "history/sdd-rev-002.html",
  54 + "content": "<!doctype html><html><body><article data-doc=\"sdd\" data-rev=\"2\"><section data-rev=\"1\"><h3>snap2</h3><p>patch round 1</p></section></article></body></html>"
  55 + }
  56 + ]
  57 + }
  58 + ]
  59 +}
... ...
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-fail",
  6 + "chunks": [
  7 + "## step 0 · 读 units.json + integration/spec.json\n",
  8 + "## step 1-4 · 起 be 进程,跑 case-add-list\n",
  9 + "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n"
  10 + ],
  11 + "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch",
  12 + "files": [
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/result.json",
  15 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\", \"todo-fe\"],\n \"got\": { \"status\": 500, \"body\": {} },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"TypeError: cannot read property text\"\n }],\n \"duration_ms\": 1234\n}\n"
  16 + },
  17 + {
  18 + "path": "integration/runs/run-mock-l2-1/process.log",
  19 + "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n"
  20 + },
  21 + {
  22 + "path": "integration/runs/run-mock-l2-1/http-trace.ndjson",
  23 + "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n"
  24 + }
  25 + ]
  26 + },
  27 + {
  28 + "_variant": "round2-pass",
  29 + "chunks": [
  30 + "## L2 round 2 (after SDD patched + L1 reran)\n",
  31 + "case-add-list 通过 ✓\n"
  32 + ],
  33 + "result": "mock-ok devloop-integrator[round2] PASS: integration green",
  34 + "files": [
  35 + {
  36 + "path": "integration/runs/run-mock-l2-2/result.json",
  37 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 980\n}\n"
  38 + }
  39 + ]
  40 + }
  41 + ]
  42 +}
... ...
fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).",
  3 + "chunks": [
  4 + "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n",
  5 + "## step 1 · category_classifier → contract_mismatch\n",
  6 + "## step 2 · 起草 proposed_sdd_patch\n",
  7 + "## step 3 · 写 triage.json + evidence 自检\n"
  8 + ],
  9 + "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]",
  10 + "files": [
  11 + {
  12 + "path": "integration/runs/run-mock-l2-1/triage.json",
  13 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be 返 500 但 SDD 规定参数错应返 400; contract 描述与实现不齐\",\n \"category\": \"contract_mismatch\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": {\n \"target_section\": \"#unit-todo-be\",\n \"op\": \"update\",\n \"text\": \"明确: POST /todos 缺 text 字段必须返 400 not 500\"\n },\n \"confidence\": 0.85,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/result.json: status: 500\",\n \"integration/runs/run-mock-l2-1/process.log: TypeError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 320\n}\n"
  14 + }
  15 + ]
  16 +}
... ...
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-scenarios/impl_bug/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/impl_bug/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-fail",
  6 + "chunks": ["## case fail: todo-be assertion mismatch\n"],
  7 + "result": "mock-ok devloop-integrator[round1]: impl_bug failure",
  8 + "files": [
  9 + {
  10 + "path": "integration/runs/run-mock-l2-1/result.json",
  11 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 500 },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"ReferenceError: passwrd is not defined\"\n }],\n \"duration_ms\": 1100\n}\n"
  12 + },
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/process.log",
  15 + "content": "[todo-be] ReferenceError at line 8\n"
  16 + }
  17 + ]
  18 + },
  19 + {
  20 + "_variant": "round2-pass",
  21 + "chunks": ["## pass after coder reset\n"],
  22 + "result": "mock-ok devloop-integrator[round2]: pass",
  23 + "files": [
  24 + {
  25 + "path": "integration/runs/run-mock-l2-2/result.json",
  26 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 950\n}\n"
  27 + }
  28 + ]
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。",
  3 + "chunks": [
  4 + "## category_classifier → impl_bug (SDD 明确,code 实现错)\n"
  5 + ],
  6 + "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]",
  7 + "files": [
  8 + {
  9 + "path": "integration/runs/run-mock-l2-1/triage.json",
  10 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be code/server.js 抛 ReferenceError; SDD 明确写 POST /todos 入参为 text 字段,代码没遵守\",\n \"category\": \"impl_bug\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.78,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: ReferenceError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 290\n}\n"
  11 + }
  12 + ]
  13 +}
... ...
fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/infra_flake/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).",
  3 + "sequence": [
  4 + {
  5 + "_variant": "round1-crash",
  6 + "chunks": ["## EADDRINUSE on :18001\n"],
  7 + "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE",
  8 + "files": [
  9 + {
  10 + "path": "integration/runs/run-mock-l2-1/result.json",
  11 + "content": "{\n \"ok\": false,\n \"crashed\": true,\n \"run_id\": \"run-mock-l2-1\",\n \"crash_reason\": \"EADDRINUSE on :18001\",\n \"summary\": { \"total\": 0, \"passed\": 0, \"failed\": 0 },\n \"failures\": [],\n \"stderr_excerpt\": \"Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\"\n}\n"
  12 + },
  13 + {
  14 + "path": "integration/runs/run-mock-l2-1/process.log",
  15 + "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n"
  16 + }
  17 + ]
  18 + },
  19 + {
  20 + "_variant": "round2-pass",
  21 + "chunks": ["## retry: new port, pass\n"],
  22 + "result": "mock-ok devloop-integrator[round2]: pass",
  23 + "files": [
  24 + {
  25 + "path": "integration/runs/run-mock-l2-2/result.json",
  26 + "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 920\n}\n"
  27 + }
  28 + ]
  29 + }
  30 + ]
  31 +}
... ...
fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.",
  3 + "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"],
  4 + "result": "mock-ok devloop-triager: infra_flake confidence=0.95",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/triage.json",
  8 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"端口 18001 已被占用 (EADDRINUSE),进程未起;与 code/SDD 无关\",\n \"category\": \"infra_flake\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.95,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: EADDRINUSE\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 180\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json 0 → 100644
  1 +{
  2 + "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
  3 + "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
  4 + "result": "mock-ok devloop-architect[init]: SDD rev 1",
  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</p><table><tr><td>m</td></tr></table><pre>m</pre><svg></svg><div class=\"card\">x</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 (explicit)</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-scenarios/spec_ambiguous/devloop-integrator.json 0 → 100644
  1 +{
  2 + "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.",
  3 + "chunks": ["## case fail (spec_ambiguous)\n"],
  4 + "result": "mock-ok devloop-integrator: failure due to ambiguous SDD",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/result.json",
  8 + "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 200, \"body\": { \"id\": 1 } },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"behavior undefined in SDD\"\n }],\n \"duration_ms\": 1000\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json 0 → 100644
  1 +{
  2 + "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.",
  3 + "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"],
  4 + "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)",
  5 + "files": [
  6 + {
  7 + "path": "integration/runs/run-mock-l2-1/triage.json",
  8 + "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"SDD 没明确规定 POST /todos 应返 200 还是 201; 两种解读都说得通\",\n \"category\": \"spec_ambiguous\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.4,\n \"evidence\": [],\n \"will_auto_apply\": false,\n \"evidence_verified\": true,\n \"duration_ms\": 250\n}\n"
  9 + }
  10 + ]
  11 +}
... ...
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 }
... ...
packages/acp-core/protocol/ClaudeCliProtocol.js
... ... @@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter {
49 49 this._mockMode = MOCK_MODE || options.mock === true;
50 50 this._mockFixtureDir = options.mockFixtureDir
51 51 || path.resolve(__dirname, '../../../fixtures/devloop-mock');
  52 + // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录
  53 + this._mockFallbackDir = options.mockFallbackDir
  54 + || path.resolve(__dirname, '../../../fixtures/devloop-mock');
52 55 }
53 56  
54 57 /**
... ... @@ -73,6 +76,19 @@ class ClaudeCliProtocol extends EventEmitter {
73 76 '--dangerously-skip-permissions',
74 77 ];
75 78  
  79 + // DevLoop 真模式: 注入 agent 专属 system prompt (来自 system/templates/{agent}/prompt.md)
  80 + // 不用 --bare — 我们仍想要 Read/Write/Edit/Bash 自动可用 + 工具权限提示等正常默认。
  81 + // 用 --append-system-prompt 而不是 --system-prompt: 保留 Claude 默认提示
  82 + // (含工具使用约定),再追加 agent 的 ROLE / SANDBOX / WORKFLOW 段。
  83 + if (this.options.systemPrompt && typeof this.options.systemPrompt === 'string') {
  84 + args.push('--append-system-prompt', this.options.systemPrompt);
  85 + }
  86 +
  87 + // 单次会话预算上限 (USD); 防 Agent 跑飞烧钱
  88 + if (typeof this.options.maxBudgetUsd === 'number' && this.options.maxBudgetUsd > 0) {
  89 + args.push('--max-budget-usd', String(this.options.maxBudgetUsd));
  90 + }
  91 +
76 92 // 支持禁用特定原生工具 (用于编排型 Agent,强制纯文本输出)
77 93 if (this.options.disallowedTools && this.options.disallowedTools.length > 0) {
78 94 args.push('--disallowedTools', ...this.options.disallowedTools);
... ... @@ -536,21 +552,25 @@ class ClaudeCliProtocol extends EventEmitter {
536 552 async _sendMockPrompt(text) {
537 553 const agentName = this.agentInfo?.name || this.options.agentName || 'default';
538 554 const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`);
539   - const defaultPath = path.join(this._mockFixtureDir, 'default.json');
  555 + const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir
  556 + ? path.join(this._mockFallbackDir, `${agentName}.json`)
  557 + : null;
  558 + const defaultPath = path.join(this._mockFallbackDir, 'default.json');
540 559  
541 560 let raw;
542   - if (fs.existsSync(fixturePath)) {
  561 + const tryPaths = [fixturePath];
  562 + if (fallbackPath) tryPaths.push(fallbackPath);
  563 + tryPaths.push(defaultPath);
  564 +
  565 + for (const p of tryPaths) {
  566 + if (!fs.existsSync(p)) continue;
543 567 try {
544   - raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
  568 + raw = JSON.parse(fs.readFileSync(p, 'utf8'));
  569 + break;
545 570 } catch (e) {
546   - console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`);
  571 + console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`);
547 572 }
548 573 }
549   - if (!raw && fs.existsSync(defaultPath)) {
550   - try {
551   - raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
552   - } catch { /* ignore */ }
553   - }
554 574 if (!raw) {
555 575 // echo fallback: 把 prompt 头 80 字符回显
556 576 raw = {
... ...
scripts/phase3-verify.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/phase3-verify.js
  4 + *
  5 + * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支):
  6 + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条
  7 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit
  8 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
  9 + * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset
  10 + * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay
  11 + *
  12 + * 用法: node scripts/phase3-verify.js
  13 + */
  14 +
  15 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  16 +
  17 +const fs = require('fs');
  18 +const path = require('path');
  19 +
  20 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  21 +require('module').globalPaths.push(serverNodeModules);
  22 +const express = (() => {
  23 + try { return require('express'); }
  24 + catch {
  25 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  26 + }
  27 +})();
  28 +
  29 +const pathResolver = require('../server/services/devloop/path-resolver');
  30 +const sseBroker = require('../server/services/devloop/sse-broker');
  31 +const bugTracker = require('../server/services/devloop/bug-tracker');
  32 +const lc = require('../server/src/core/devloop/loop-controller');
  33 +
  34 +const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios');
  35 +
  36 +const results = [];
  37 +function step(name, fn) {
  38 + return (async () => {
  39 + try {
  40 + const r = await fn();
  41 + results.push({ name, ok: true, detail: r?.detail || '' });
  42 + return r;
  43 + } catch (e) {
  44 + results.push({ name, ok: false, reason: e.message });
  45 + throw e;
  46 + }
  47 + })();
  48 +}
  49 +
  50 +function makeFakeRes(events) {
  51 + return {
  52 + setHeader() {}, flushHeaders() {}, end() {},
  53 + on() {},
  54 + write(line) {
  55 + const m = line.match(/^event: (.+)$/m);
  56 + if (m) events.push(m[1]);
  57 + },
  58 + };
  59 +}
  60 +
  61 +async function runScenario(scenario) {
  62 + pathResolver._resetForTesting();
  63 + sseBroker._resetForTesting();
  64 + const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36);
  65 + const dir = path.join(SCENARIO_BASE, scenario);
  66 + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario });
  67 + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
  68 +
  69 + const events = [];
  70 + sseBroker.subscribe(proj.pid, makeFakeRes(events), {});
  71 +
  72 + const result = await lc.runProject(proj.pid, { mockFixtureDir: dir });
  73 + sseBroker.close(proj.pid);
  74 + return { pid: proj.pid, cid, result, events };
  75 +}
  76 +
  77 +function cleanup(cid) {
  78 + try {
  79 + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
  80 + } catch { /* ignore */ }
  81 +}
  82 +
  83 +async function main() {
  84 + const cidsToClean = [];
  85 +
  86 + // ① contract_mismatch
  87 + let cm;
  88 + try {
  89 + await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => {
  90 + cm = await runScenario('contract_mismatch');
  91 + cidsToClean.push(cm.cid);
  92 + if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state);
  93 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8'));
  94 + if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds);
  95 + if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev);
  96 + return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` };
  97 + });
  98 +
  99 + await step(' sdd.updated SSE event', () => {
  100 + if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(','));
  101 + return { detail: 'sdd.updated emitted' };
  102 + });
  103 +
  104 + await step(' changelog has sdd_rev=2 entry', () => {
  105 + const cl = pathResolver.resolveChangelog(cm.pid);
  106 + if (!fs.existsSync(cl)) throw new Error('changelog missing');
  107 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  108 + if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog');
  109 + return { detail: `${lines.length} changelog entries` };
  110 + });
  111 +
  112 + await step(' bug opened → fixed', () => {
  113 + const bugs = bugTracker.list(cm.pid, {});
  114 + if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length);
  115 + if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status);
  116 + return { detail: `${bugs[0].id} (${bugs[0].status})` };
  117 + });
  118 + } catch { /* errors recorded by step */ }
  119 +
  120 + // ② impl_bug
  121 + let ib;
  122 + try {
  123 + await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => {
  124 + ib = await runScenario('impl_bug');
  125 + cidsToClean.push(ib.cid);
  126 + if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state);
  127 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8'));
  128 + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)');
  129 + if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)');
  130 + return { detail: `sdd_rev=1 l2_rounds=0` };
  131 + });
  132 + } catch {}
  133 +
  134 + // ③ spec_ambiguous
  135 + let sa;
  136 + try {
  137 + await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => {
  138 + sa = await runScenario('spec_ambiguous');
  139 + cidsToClean.push(sa.cid);
  140 + if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state);
  141 + if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE');
  142 + return { detail: 'l2_blocked · SSE fired' };
  143 + });
  144 + } catch {}
  145 +
  146 + // ④ infra_flake
  147 + let inf;
  148 + try {
  149 + await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => {
  150 + inf = await runScenario('infra_flake');
  151 + cidsToClean.push(inf.cid);
  152 + if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state);
  153 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8'));
  154 + if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds);
  155 + const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  156 + if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action');
  157 + return { detail: 'shipped without budget burn' };
  158 + });
  159 + } catch {}
  160 +
  161 + // ⑤ Observability cross-check
  162 + try {
  163 + await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => {
  164 + // 用 contract_mismatch 项目验
  165 + if (!cm) throw new Error('cm scenario did not run');
  166 + const tl = pathResolver.resolveTimelineLog(cm.pid);
  167 + const bg = pathResolver.resolveBugsLog(cm.pid);
  168 + const cl = pathResolver.resolveChangelog(cm.pid);
  169 + const sl = pathResolver.resolveStreamLog(cm.pid);
  170 + for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) {
  171 + if (!fs.existsSync(p)) throw new Error(`${name} missing`);
  172 + }
  173 + const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean)
  174 + .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
  175 + for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) {
  176 + // unit.state is via sseBroker.emit not timeline; but we should at least have integration.result + sdd.updated as timeline kinds — actually those go via sseBroker.emit too. Check timeline has agent.call entries.
  177 + }
  178 + // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped
  179 + const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped'];
  180 + const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k));
  181 + if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(','));
  182 + return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` };
  183 + });
  184 +
  185 + await step('⑥ SSE replay via Last-Event-ID', async () => {
  186 + if (!cm) throw new Error('cm scenario did not run');
  187 + const replayed = [];
  188 + sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 });
  189 + sseBroker.close(cm.pid);
  190 + // 至少应回放出 sdd.updated / project.shipped 之类
  191 + if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length);
  192 + return { detail: `replayed ${replayed.length} events from id=3` };
  193 + });
  194 + } catch {}
  195 +
  196 + for (const c of cidsToClean) cleanup(c);
  197 +
  198 + let pass = 0, fail = 0;
  199 + console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────');
  200 + for (const r of results) {
  201 + const tag = r.ok ? '✓' : '✗';
  202 + console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`);
  203 + r.ok ? pass++ : fail++;
  204 + }
  205 + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  206 + process.exit(fail ? 1 : 0);
  207 +}
  208 +
  209 +main().catch(e => {
  210 + console.error('\n[phase3-verify] fatal:', e);
  211 + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  212 + process.exit(1);
  213 +});
... ...
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 +});
... ...
scripts/phase5-verify.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/phase5-verify.js
  4 + *
  5 + * P5 Vue Dashboard 验收 (后端 + 静态产物):
  6 + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在
  7 + * ② GET /:pid/state 返带 status + units 字段
  8 + * ③ GET /:pid/timeline 返事件列表
  9 + * ④ GET /:pid/changelog 返条目列表
  10 + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail
  11 + * ⑥ GET /:pid/bugs 返 bug 列表
  12 + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件
  13 + *
  14 + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。
  15 + */
  16 +
  17 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  18 +
  19 +const fs = require('fs');
  20 +const path = require('path');
  21 +const http = require('http');
  22 +
  23 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  24 +require('module').globalPaths.push(serverNodeModules);
  25 +const express = (() => {
  26 + try { return require('express'); }
  27 + catch {
  28 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  29 + }
  30 +})();
  31 +
  32 +const pathResolver = require('../server/services/devloop/path-resolver');
  33 +const lc = require('../server/src/core/devloop/loop-controller');
  34 +const devloopRouter = require('../server/routes/devloop');
  35 +
  36 +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
  37 +const WEB_DIST = path.resolve(__dirname, '../web/dist');
  38 +
  39 +const results = [];
  40 +function step(name, fn) {
  41 + return (async () => {
  42 + try {
  43 + const r = await fn();
  44 + results.push({ name, ok: true, detail: r?.detail || '' });
  45 + return r;
  46 + } catch (e) {
  47 + results.push({ name, ok: false, reason: e.message });
  48 + throw e;
  49 + }
  50 + })();
  51 +}
  52 +
  53 +async function startApp() {
  54 + const app = express();
  55 + app.use(express.json());
  56 + app.use('/api/devloop', devloopRouter);
  57 + return new Promise((resolve) => {
  58 + const srv = app.listen(0, '127.0.0.1', () => {
  59 + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
  60 + });
  61 + });
  62 +}
  63 +
  64 +async function main() {
  65 + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => {
  66 + const required = [
  67 + 'index.html', 'assets',
  68 + ];
  69 + for (const f of required) {
  70 + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`);
  71 + }
  72 + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets'));
  73 + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built');
  74 + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built');
  75 + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` };
  76 + });
  77 +
  78 + const { srv, base } = await startApp();
  79 + const cid = 'c-phase5-' + Date.now().toString(36);
  80 + let pid;
  81 +
  82 + try {
  83 + await step('② 准备项目: POST /projects → state', async () => {
  84 + const r = await fetch(`${base}/api/devloop/projects`, {
  85 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  86 + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }),
  87 + });
  88 + pid = (await r.json()).pid;
  89 + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json();
  90 + if (!s.status) throw new Error('state.status missing');
  91 + if (!Array.isArray(s.units)) throw new Error('state.units missing');
  92 + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` };
  93 + });
  94 +
  95 + await step('③ GET /:pid/timeline returns events', async () => {
  96 + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`);
  97 + const j = await r.json();
  98 + if (!Array.isArray(j.events)) throw new Error('events not array');
  99 + if (j.events.length === 0) throw new Error('events empty');
  100 + return { detail: `${j.events.length} events` };
  101 + });
  102 +
  103 + await step('④ GET /:pid/changelog returns entries', async () => {
  104 + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
  105 + const j = await r.json();
  106 + if (!Array.isArray(j.entries)) throw new Error('entries not array');
  107 + return { detail: `${j.entries.length} entries` };
  108 + });
  109 +
  110 + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => {
  111 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
  112 + if (r.status !== 200) throw new Error(`status ${r.status}`);
  113 + const j = await r.json();
  114 + if (!j.unit) throw new Error('unit missing');
  115 + if (!Array.isArray(j.runs)) throw new Error('runs not array');
  116 + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` };
  117 + });
  118 +
  119 + await step('⑥ GET /:pid/bugs returns bug list', async () => {
  120 + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`);
  121 + const j = await r.json();
  122 + if (!Array.isArray(j.bugs)) throw new Error('bugs not array');
  123 + return { detail: `${j.bugs.length} bugs` };
  124 + });
  125 +
  126 + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => {
  127 + // 用 http.get 拿原生 stream
  128 + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`);
  129 + const events = await new Promise((resolve) => {
  130 + const out = [];
  131 + const req = http.get({
  132 + hostname: url.hostname, port: url.port, path: url.pathname,
  133 + headers: { 'Accept': 'text/event-stream' },
  134 + }, (res) => {
  135 + let buf = '';
  136 + res.on('data', (chunk) => {
  137 + buf += chunk.toString();
  138 + const matches = buf.matchAll(/^event: (.+)$/gm);
  139 + for (const m of matches) out.push(m[1]);
  140 + buf = buf.slice(buf.lastIndexOf('\n\n') + 2);
  141 + });
  142 + res.on('error', () => resolve(out));
  143 + });
  144 + // Trigger an event so we have something to receive
  145 + setTimeout(async () => {
  146 + try {
  147 + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
  148 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  149 + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }),
  150 + });
  151 + } catch { /* ignore */ }
  152 + }, 100);
  153 + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500);
  154 + });
  155 + if (events.length === 0) throw new Error('no SSE events received');
  156 + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` };
  157 + });
  158 +
  159 + } finally {
  160 + srv.close();
  161 + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
  162 + catch { /* ignore */ }
  163 + }
  164 +
  165 + let pass = 0, fail = 0;
  166 + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────');
  167 + for (const r of results) {
  168 + const tag = r.ok ? '✓' : '✗';
  169 + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`);
  170 + r.ok ? pass++ : fail++;
  171 + }
  172 + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  173 + process.exit(fail ? 1 : 0);
  174 +}
  175 +
  176 +main().catch(e => {
  177 + console.error('\n[phase5-verify] fatal:', e);
  178 + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  179 + process.exit(1);
  180 +});
... ...
scripts/real-pm-smoke.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/real-pm-smoke.js
  4 + *
  5 + * 最小验证: 真 claude CLI 跑一次 PM Agent,看 ClaudeCliProtocol +
  6 + * --append-system-prompt + 工作树 mtime diff 这条 real 链路是否
  7 + * 端到端能用。预算 $0.50 cap。
  8 + *
  9 + * 期望:
  10 + * ① claude 进程 spawn 成功
  11 + * ② Agent 输出非空 (result_text 有内容)
  12 + * ③ files_written 至少 ≥ 1 (Agent 调用 Write 工具写了文件)
  13 + * ④ git 有新 commit
  14 + *
  15 + * NOTE: 真 LLM 输出不可控;PM 可能不完全照 prompt.md 跑全套 11 章节。
  16 + * 只要它写了任意 prd/* 文件就算 wire-up 通。
  17 + */
  18 +
  19 +delete process.env.LEMONBOSS_CLAUDE_MODE; // 显式关闭 mock
  20 +
  21 +// 加载根 .env (含 CLAUDE_CODE_OAUTH_TOKEN); dotenv 装在 server/node_modules
  22 +const path = require('path');
  23 +const dotenv = require(require.resolve('dotenv', { paths: [path.resolve(__dirname, '../server')] }));
  24 +dotenv.config({ path: path.join(__dirname, '../.env') });
  25 +
  26 +const fs = require('fs');
  27 +
  28 +const lc = require('../server/src/core/devloop/loop-controller');
  29 +const pr = require('../server/services/devloop/path-resolver');
  30 +
  31 +// --max-budget-usd 仅在 --print 模式可用,与我们的 stream-json 长连接互斥
  32 +// → 用进程超时 (5min) + 一次性单 agent 调用兜底
  33 +const HARD_TIMEOUT_MS = 5 * 60 * 1000;
  34 +const cleanup = [];
  35 +
  36 +async function main() {
  37 + console.log('\n┌─ Real Claude CLI · PM Smoke ────────────────────');
  38 + console.log(`│ Hard timeout: 5min (no maxBudgetUsd — only --print mode)`);
  39 + console.log(`│ Model: claude default`);
  40 + console.log('└──────────────────────────────────────────────────\n');
  41 +
  42 + const cid = 'c-real-pm-' + Date.now().toString(36);
  43 + cleanup.push(cid);
  44 + const proj = await lc.createProject({
  45 + company_id: cid,
  46 + mode: 'sandbox',
  47 + idea: 'real PM smoke: minimal TODO API + Vue UI',
  48 + });
  49 + console.log(`pid = ${proj.pid}`);
  50 + console.log(`root = ${pr.resolveProjectRoot(proj.pid)}\n`);
  51 +
  52 + const userPrompt = [
  53 + 'Mode: init',
  54 + 'Requirement: A minimal TODO list — POST /todos (add) + GET /todos (list).',
  55 + '',
  56 + '约束 (please obey for this smoke test):',
  57 + ' • cwd 已经是项目根,直接相对路径写文件',
  58 + ' • 至少写 prd/index.html 一个最小 HTML stub (含 <article data-doc="prd" data-rev="1"><section data-rev="1"><h3>...</h3><p>...</p></section></article>)',
  59 + ' • 不要尝试调 hash_contract / bootstrap_prd_site 等 LemonBoss 内部工具 — 它们在本 session 不可用',
  60 + ' • 不要执行 npm install / git / curl 等 Bash 命令',
  61 + ' • 完成后直接 stop, 不需要总结',
  62 + '',
  63 + '回 "DONE" 一词即可结束。',
  64 + ].join('\n');
  65 +
  66 + console.log('▸ calling devloop-pm via real claude CLI...');
  67 + console.log(` (system prompt: system/templates/devloop-pm/prompt.md, ~3KB)\n`);
  68 +
  69 + const startMs = Date.now();
  70 + let chunkCount = 0;
  71 + const r = await Promise.race([
  72 + lc.callAgent(proj.pid, 'devloop-pm', userPrompt, {
  73 + mock: false,
  74 + onChunk: () => { chunkCount++; },
  75 + }),
  76 + new Promise((_, rej) =>
  77 + setTimeout(() => rej(new Error('hard timeout 5min reached')), HARD_TIMEOUT_MS),
  78 + ),
  79 + ]);
  80 + const elapsedSec = Math.round((Date.now() - startMs) / 1000);
  81 +
  82 + console.log('\n▸ result:');
  83 + console.log(` elapsed: ${elapsedSec}s`);
  84 + console.log(` stream chunks: ${chunkCount}`);
  85 + console.log(` result text: ${r.result_text.slice(0, 120).replace(/\n/g, ' ')}${r.result_text.length > 120 ? '…' : ''}`);
  86 + console.log(` files_written: ${r.files_written.length}`);
  87 + for (const f of r.files_written.slice(0, 10)) console.log(` · ${f}`);
  88 + if (r.files_written.length > 10) console.log(` · (+${r.files_written.length - 10} more)`);
  89 + console.log(` commit: ${r.commit?.sha?.slice(0, 7) || '(none)'}`);
  90 +
  91 + console.log('\n┌─ Wire-up status ────────────────────────────────');
  92 + const checks = [
  93 + ['process spawn', true],
  94 + ['stream chunks', chunkCount > 0],
  95 + ['result text', r.result_text.length > 0],
  96 + ['files written', r.files_written.length > 0],
  97 + ['git commit', !!r.commit?.sha],
  98 + ];
  99 + let pass = 0;
  100 + for (const [k, ok] of checks) {
  101 + console.log(`│ ${ok ? '✓' : '✗'} ${k}`);
  102 + if (ok) pass++;
  103 + }
  104 + console.log(`└─ ${pass}/${checks.length} checks passed\n`);
  105 +
  106 + console.log('Artifacts retained for inspection:');
  107 + console.log(` ${pr.resolveProjectRoot(proj.pid)}`);
  108 + console.log(` cleanup: rm -rf data/companies/${cid}\n`);
  109 +
  110 + process.exit(pass === checks.length ? 0 : 1);
  111 +}
  112 +
  113 +main().catch(e => {
  114 + console.error('\n[real-pm-smoke] FATAL:', e?.message || e);
  115 + console.error(e?.stack);
  116 + process.exit(1);
  117 +});
... ...
scripts/self-bootstrap.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/self-bootstrap.js
  4 + *
  5 + * "自举乐趣" — 让 DevLoop 系统去开发 LemonBoss DevLoop 自己的下一个 feature。
  6 + *
  7 + * 流程模拟一个人在 5173 端口前端发起的完整工作流:
  8 + * 1. POST /projects — 新需求:给 DevLoop 自己加 /restart 端点
  9 + * 2. GET /state — 看 PRD/SDD 出来了 (status=running)
  10 + * 3. POST /run — 自动 L1 → L2 → ship (status=shipped)
  11 + * 4. GET /timeline / bugs / changelog / units — 检查所有产物
  12 + * 5. POST /feedback — 反馈追加:支持 force=true 参数 (触发 PM incremental)
  13 + * 6. POST /run — 再 ship 一次 (PRD rev 1→2 后)
  14 + * 7. POST /sign-off — 锁定 shipped-final
  15 + * 8. POST /feedback — 验证锁定后 400
  16 + *
  17 + * 当前 Agent 全部 mock 模式,产出是 fixture-driven 的演示数据。整个流程跑通即
  18 + * 证明三层 Loop 协议 + 持久化 + 状态机 + UI 端点完整 wire-up。真切换到 real
  19 + * Claude CLI 时只需 LEMONBOSS_CLAUDE_MODE=real (或不设),Mock 路径关闭即可。
  20 + */
  21 +
  22 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  23 +
  24 +const fs = require('fs');
  25 +const path = require('path');
  26 +
  27 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  28 +require('module').globalPaths.push(serverNodeModules);
  29 +const express = (() => {
  30 + try { return require('express'); }
  31 + catch {
  32 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  33 + }
  34 +})();
  35 +
  36 +const pathResolver = require('../server/services/devloop/path-resolver');
  37 +const devloopRouter = require('../server/routes/devloop');
  38 +
  39 +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
  40 +const BOOTSTRAP_IDEA = `Add POST /api/devloop/projects/:pid/restart endpoint:
  41 + - resets a 'shipped-final' or 'abandoned' project back to 'draft'
  42 + - re-runs runDocsPhase from scratch with the original requirement
  43 + - returns { ok, pid, status, idea }
  44 + - rejects non-terminal states with 400`;
  45 +
  46 +const PIPELINE_LOG = [];
  47 +function logStep(name, info = '') {
  48 + const ts = new Date().toTimeString().slice(0, 8);
  49 + console.log(`[${ts}] ▸ ${name}${info ? ' ' + info : ''}`);
  50 + PIPELINE_LOG.push({ ts, name, info });
  51 +}
  52 +
  53 +function logArtifact(name, value) {
  54 + console.log(` · ${name.padEnd(20)} ${value}`);
  55 +}
  56 +
  57 +async function startApp() {
  58 + const app = express();
  59 + app.use(express.json());
  60 + app.use('/api/devloop', devloopRouter);
  61 + return new Promise(r => {
  62 + const s = app.listen(0, '127.0.0.1', () => {
  63 + r({ srv: s, base: `http://127.0.0.1:${s.address().port}` });
  64 + });
  65 + });
  66 +}
  67 +
  68 +async function J(url, opts = {}) {
  69 + const r = await fetch(url, opts);
  70 + let j; try { j = await r.json(); } catch { j = null; }
  71 + return { status: r.status, body: j };
  72 +}
  73 +
  74 +async function main() {
  75 + console.log('\n╔══════════════════════════════════════════════════════════════════╗');
  76 + console.log('║ DevLoop · 自举测试 (Self-Bootstrap) ║');
  77 + console.log('║ 让系统去开发自己的下一个 feature ║');
  78 + console.log('╚══════════════════════════════════════════════════════════════════╝\n');
  79 +
  80 + console.log('Meta-Requirement:');
  81 + for (const line of BOOTSTRAP_IDEA.split('\n')) console.log(' ' + line);
  82 + console.log('');
  83 +
  84 + const { srv, base } = await startApp();
  85 + const cid = 'c-self-' + Date.now().toString(36);
  86 + let pid;
  87 +
  88 + try {
  89 + // ─── Step 1: 提交新需求 ───
  90 + logStep('Step 1 / POST /projects (new requirement)');
  91 + {
  92 + const r = await J(`${base}/api/devloop/projects`, {
  93 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  94 + body: JSON.stringify({
  95 + company_id: cid,
  96 + name: 'devloop-self-restart',
  97 + requirement: BOOTSTRAP_IDEA,
  98 + mockFixtureDir: HAPPY_DIR,
  99 + }),
  100 + });
  101 + if (r.status !== 200) throw new Error('POST /projects: ' + r.status + ' / ' + JSON.stringify(r.body));
  102 + pid = r.body.pid;
  103 + logArtifact('pid', pid);
  104 + logArtifact('pm files', r.body.pm.files_written);
  105 + logArtifact('architect files', r.body.architect.files_written);
  106 + logArtifact('PM commit', r.body.pm.commit?.slice(0, 7));
  107 + logArtifact('Arch commit', r.body.architect.commit?.slice(0, 7));
  108 + }
  109 +
  110 + // ─── Step 2: 读 state ───
  111 + logStep('Step 2 / GET /state');
  112 + {
  113 + const r = await J(`${base}/api/devloop/projects/${pid}/state`);
  114 + logArtifact('status', r.body.status);
  115 + logArtifact('prd_rev', r.body.prd_rev);
  116 + logArtifact('sdd_rev', r.body.sdd_rev);
  117 + logArtifact('units', r.body.units?.map(u => `${u.id}:${u.status}`).join(', '));
  118 + logArtifact('idea', r.body.project?.idea_summary?.slice(0, 60));
  119 + }
  120 +
  121 + // ─── Step 3: 跑 L1 → L2 → ship ───
  122 + logStep('Step 3 / POST /run (runProject — drives L1 + L2 + ship)');
  123 + {
  124 + const r = await J(`${base}/api/devloop/projects/${pid}/run`, {
  125 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  126 + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
  127 + });
  128 + if (r.status !== 200) throw new Error('POST /run: ' + r.status);
  129 + logArtifact('final_state', r.body.final_state);
  130 + logArtifact('l2_rounds', r.body.l2_rounds);
  131 + logArtifact('run_id', r.body.run_id);
  132 + logArtifact('L1+L2 phases', r.body.history?.length);
  133 + }
  134 +
  135 + // ─── Step 4: 4 个 Ops 子端点全部检查 ───
  136 + logStep('Step 4 / GET /bugs · /timeline · /changelog · /units/todo-be');
  137 + {
  138 + const bugs = await J(`${base}/api/devloop/projects/${pid}/bugs`);
  139 + const tl = await J(`${base}/api/devloop/projects/${pid}/timeline?limit=20`);
  140 + const cl = await J(`${base}/api/devloop/projects/${pid}/changelog`);
  141 + const unit = await J(`${base}/api/devloop/projects/${pid}/units/todo-be`);
  142 + logArtifact('bugs total', `${bugs.body.total} (statuses: ${bugs.body.bugs.map(b => b.status).join(', ')})`);
  143 + logArtifact('timeline events', tl.body.total);
  144 + logArtifact('changelog entries', cl.body.total);
  145 + logArtifact('todo-be runs', unit.body.runs?.length);
  146 + logArtifact('todo-be status', unit.body.unit?.status);
  147 + }
  148 +
  149 + // ─── Step 5: human feedback ───
  150 + logStep('Step 5 / POST /feedback (iterate the spec)');
  151 + {
  152 + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, {
  153 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  154 + body: JSON.stringify({
  155 + text: 'Also add ?from=<rev> query param so /restart can rewind to a specific PRD rev. Default current rev.',
  156 + by: 'yibo',
  157 + mockFixtureDir: HAPPY_DIR,
  158 + }),
  159 + });
  160 + if (r.status !== 200) throw new Error('POST /feedback: ' + r.status);
  161 + logArtifact('prd_rev', `${r.body.prd_rev_before} → ${r.body.prd_rev_after}`);
  162 + logArtifact('status', r.body.status);
  163 + logArtifact('files', r.body.files_written?.length);
  164 + }
  165 +
  166 + // ─── Step 6: 重新跑一遍 ───
  167 + logStep('Step 6 / POST /run (re-ship after feedback)');
  168 + {
  169 + const r = await J(`${base}/api/devloop/projects/${pid}/run`, {
  170 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  171 + body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
  172 + });
  173 + logArtifact('final_state', r.body.final_state);
  174 + }
  175 +
  176 + // ─── Step 7: sign off ───
  177 + logStep('Step 7 / POST /sign-off (human approves)');
  178 + {
  179 + const r = await J(`${base}/api/devloop/projects/${pid}/sign-off`, {
  180 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  181 + body: JSON.stringify({ by: 'yibo' }),
  182 + });
  183 + logArtifact('status', r.body.status);
  184 + }
  185 +
  186 + // ─── Step 8: lock-out check ───
  187 + logStep('Step 8 / POST /feedback after sign-off (should reject)');
  188 + {
  189 + const r = await J(`${base}/api/devloop/projects/${pid}/feedback`, {
  190 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  191 + body: JSON.stringify({ text: 'too late' }),
  192 + });
  193 + logArtifact('status code', r.status);
  194 + logArtifact('error', r.body.error);
  195 + logArtifact('from', r.body.from);
  196 + }
  197 +
  198 + // ─── 总结 ───
  199 + console.log('\n╭─ Self-Bootstrap Summary ─────────────────────────────────────────╮');
  200 + const root = pathResolver.resolveProjectRoot(pid);
  201 + console.log(`│ project root: ${root}`);
  202 + const inventory = [
  203 + 'project.json',
  204 + 'prd/index.html',
  205 + 'prd/feature/feature-tree.html',
  206 + 'sdd/index.html',
  207 + 'sdd/mvcp/control.html',
  208 + 'units.json',
  209 + 'integration/spec.json',
  210 + 'integration/runs/run-mock-baseline/result.json',
  211 + 'timeline.ndjson',
  212 + 'changelog.ndjson',
  213 + 'bugs.ndjson',
  214 + 'stream.log',
  215 + 'history/prd-rev-001.html',
  216 + 'history/prd-rev-002.html',
  217 + 'history/sdd-rev-001.html',
  218 + '.git',
  219 + ];
  220 + for (const f of inventory) {
  221 + const p = path.join(root, f);
  222 + const exists = fs.existsSync(p);
  223 + console.log(`│ ${exists ? '✓' : '✗'} ${f}`);
  224 + }
  225 +
  226 + // git log
  227 + console.log('│');
  228 + console.log(`│ git commits (auto-generated by LoopController.commitAfterAgent):`);
  229 + const { execSync } = require('child_process');
  230 + try {
  231 + const log = execSync(`git -C "${root}" log --oneline -20`, { encoding: 'utf8' }).trim().split('\n');
  232 + for (const line of log) console.log(`│ ${line}`);
  233 + } catch (e) { console.log(`│ (git log failed: ${e.message})`); }
  234 +
  235 + console.log('╰──────────────────────────────────────────────────────────────────╯\n');
  236 +
  237 + console.log('Result: DevLoop 成功地为自己"开发"了一个新 feature, 通过完整 L1+L2+L3 pipeline');
  238 + console.log('完成 → ship → human iterate (PRD rev 2) → ship → sign-off → 拒绝后续修改。\n');
  239 + console.log('生产模式接 real Claude CLI: 删 LEMONBOSS_CLAUDE_MODE=mock 即可,流程不动。\n');
  240 +
  241 + } finally {
  242 + srv.close();
  243 + // 保留产物供查阅,不清理
  244 + if (pid) {
  245 + console.log(`(artifacts retained at: data/companies/${cid}/projects/${pid}/)`);
  246 + console.log(`若要清理: rm -rf data/companies/${cid}`);
  247 + }
  248 + }
  249 +}
  250 +
  251 +main().catch(e => {
  252 + console.error('\n[self-bootstrap] FATAL:', e);
  253 + process.exit(1);
  254 +});
... ...
server/agent-server.js
... ... @@ -67,6 +67,7 @@ app.use(&#39;/api/company&#39;, require(&#39;./routes/company&#39;));
67 67 app.use('/api/goals', require('./routes/goals'));
68 68 app.use('/api/tasks', require('./routes/tasks'));
69 69 app.use('/api/devloop', require('./routes/devloop'));
  70 +app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
70 71  
71 72 // Health
72 73 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
... ...
server/index.js
... ... @@ -94,6 +94,7 @@ app.use(&#39;/api/goals&#39;, require(&#39;./routes/goals&#39;));
94 94 app.use('/api/company', require('./routes/company'));
95 95 app.use('/api/tasks', require('./routes/tasks'));
96 96 app.use('/api/devloop', require('./routes/devloop'));
  97 +app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
97 98  
98 99 // ─── 系统端点 ───
99 100 app.get('/api/health', (req, res) => res.json({
... ...
server/public/devloop-status.html 0 → 100644
  1 +<!doctype html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="utf-8" />
  5 +<title>DevLoop · Project Status</title>
  6 +<style>
  7 + body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; }
  8 + header { display:flex; justify-content:space-between; align-items:baseline; border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:16px; }
  9 + h1 { font-size: 18px; margin: 0; }
  10 + .pid { color:#888; font-family:monospace; font-size:13px; }
  11 + .row { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:16px; }
  12 + .card { background:#f7f7f9; border:1px solid #e3e3e8; border-radius:6px; padding:12px 16px; min-width:160px; }
  13 + .card label { font-size:11px; text-transform:uppercase; color:#888; display:block; margin-bottom:4px; }
  14 + .card .v { font-size:20px; font-weight:600; }
  15 + .units { margin-top:8px; }
  16 + .unit { display:flex; align-items:center; padding:6px 10px; background:#fff; border:1px solid #e3e3e8; border-radius:4px; margin-bottom:4px; gap:12px; }
  17 + .unit .id { flex:1; font-family:monospace; }
  18 + .unit .deps { color:#888; font-size:12px; }
  19 + .status { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; text-transform:uppercase; }
  20 + .status.pending { background:#eee; color:#666; }
  21 + .status.coding { background:#fff3cd; color:#856404; }
  22 + .status.testing { background:#cce5ff; color:#004085; }
  23 + .status.passed { background:#d4edda; color:#155724; }
  24 + .status.failed { background:#f8d7da; color:#721c24; }
  25 + .status.blocked { background:#d6d8db; color:#383d41; }
  26 + .timeline { font-family:monospace; font-size:12px; max-height:240px; overflow:auto; background:#0f1115; color:#e6e6e6; padding:12px; border-radius:6px; }
  27 + .timeline .ts { color:#7da; margin-right:8px; }
  28 + .timeline .kind { color:#ffce6a; margin-right:8px; }
  29 + input { font-family:monospace; padding:4px 8px; border:1px solid #ccc; border-radius:4px; }
  30 + button { padding:4px 12px; background:#222; color:white; border:none; border-radius:4px; cursor:pointer; }
  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; }
  47 +</style>
  48 +</head>
  49 +<body>
  50 +<header>
  51 + <h1>DevLoop · Project Status</h1>
  52 + <div>
  53 + <input id="pidInput" placeholder="p-xxxxxx" />
  54 + <button id="connectBtn">Connect</button>
  55 + </div>
  56 +</header>
  57 +
  58 +<div id="err" class="err"></div>
  59 +
  60 +<div id="content" style="display:none">
  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">
  75 + <div class="card"><label>PRD rev</label><div class="v" id="prdRev">-</div></div>
  76 + <div class="card"><label>SDD rev</label><div class="v" id="sddRev">-</div></div>
  77 + <div class="card"><label>Units</label><div class="v" id="unitsCount">0</div></div>
  78 + </div>
  79 + <h2 style="font-size:14px;margin:16px 0 8px">Units (DAG)</h2>
  80 + <div class="units" id="units"></div>
  81 + <h2 style="font-size:14px;margin:16px 0 8px">Recent timeline events</h2>
  82 + <div class="timeline" id="timeline"></div>
  83 +</div>
  84 +
  85 +<script>
  86 +const $ = id => document.getElementById(id);
  87 +let pollTimer = null;
  88 +
  89 +async function fetchState(pid) {
  90 + const r = await fetch(`/api/devloop/projects/${pid}/state`);
  91 + if (!r.ok) throw new Error(`state: ${r.status}`);
  92 + return r.json();
  93 +}
  94 +
  95 +function render(s) {
  96 + $('pidShow').textContent = s.pid;
  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;
  102 + $('prdRev').textContent = s.prd_rev ?? '-';
  103 + $('sddRev').textContent = s.sdd_rev ?? '-';
  104 + const units = s.units || [];
  105 + $('unitsCount').textContent = units.length;
  106 + $('units').innerHTML = units.map(u => `
  107 + <div class="unit">
  108 + <span class="id">${u.id}</span>
  109 + <span class="deps">deps: ${(u.depends_on || []).join(', ') || '∅'}</span>
  110 + <span class="status ${u.status}">${u.status}</span>
  111 + </div>`).join('');
  112 + $('timeline').innerHTML = (s.timeline_tail || []).map(e => `
  113 + <div><span class="ts">${e.ts || ''}</span><span class="kind">[${e.kind}]</span>${e.summary || ''}</div>
  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';
  120 +}
  121 +
  122 +async function connect() {
  123 + const pid = $('pidInput').value.trim();
  124 + if (!pid) return;
  125 + $('err').textContent = '';
  126 + if (pollTimer) clearInterval(pollTimer);
  127 + async function poll() {
  128 + try {
  129 + const s = await fetchState(pid);
  130 + render(s);
  131 + $('content').style.display = '';
  132 + } catch (e) {
  133 + $('err').textContent = e.message;
  134 + $('content').style.display = 'none';
  135 + }
  136 + }
  137 + await poll();
  138 + pollTimer = setInterval(poll, 2000);
  139 +}
  140 +
  141 +$('connectBtn').addEventListener('click', connect);
  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 +
  182 +const fromQuery = new URLSearchParams(location.search).get('pid');
  183 +if (fromQuery) { $('pidInput').value = fromQuery; connect(); }
  184 +</script>
  185 +</body>
  186 +</html>
... ...
server/routes/devloop.js
... ... @@ -20,6 +20,18 @@ const router = express.Router();
20 20  
21 21 const loopController = require('../src/core/devloop/loop-controller');
22 22 const pathResolver = require('../services/devloop/path-resolver');
  23 +const sseBroker = require('../services/devloop/sse-broker');
  24 +const bugTracker = require('../services/devloop/bug-tracker');
  25 +const fs = require('fs');
  26 +const os = require('os');
  27 +const path = require('path');
  28 +const multer = require('multer');
  29 +
  30 +// 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。
  31 +const intakeUpload = multer({
  32 + storage: multer.memoryStorage(),
  33 + limits: { fileSize: 10 * 1024 * 1024, files: 10 },
  34 +});
23 35  
24 36 const h = (fn) => async (req, res) => {
25 37 try { await fn(req, res); }
... ... @@ -31,25 +43,49 @@ const h = (fn) =&gt; async (req, res) =&gt; {
31 43  
32 44 // POST /api/devloop/projects
33 45 router.post('/projects', h(async (req, res) => {
34   - const { company_id, requirement, mode, target_path, mock, name } = req.body || {};
  46 + const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {};
35 47 if (!company_id) {
36 48 return res.status(400).json({ error: 'missing field: company_id' });
37 49 }
38 50 if (!requirement || typeof requirement !== 'string') {
39 51 return res.status(400).json({ error: 'missing field: requirement' });
40 52 }
  53 + if ((mode === 'linked-new' || mode === 'linked-existing') && !target_path) {
  54 + return res.status(400).json({ error: `missing field: target_path (required for ${mode})` });
  55 + }
  56 + if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) {
  57 + return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` });
  58 + }
41 59  
42   - // 1. 创建项目 (sandbox 默认)
43   - const proj = await loopController.createProject({
44   - company_id,
45   - mode: mode || 'sandbox',
46   - target_path,
47   - idea: name ? `${name}: ${requirement}` : requirement,
48   - });
  60 + // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path)
  61 + let proj;
  62 + try {
  63 + proj = await loopController.createProject({
  64 + company_id,
  65 + mode: mode || 'sandbox',
  66 + target_path,
  67 + idea: name ? `${name}: ${requirement}` : requirement,
  68 + });
  69 + } catch (e) {
  70 + // 用户输入级错误返 400;其他保留 500
  71 + if (e.code === 'SENSITIVE_PATH'
  72 + || e.code === 'DIRTY_WORKTREE'
  73 + || /target_path|parent dir|not empty|must be absolute|does not exist|not a directory|already contains|uncommitted changes/i.test(e.message)) {
  74 + return res.status(400).json({ error: e.message });
  75 + }
  76 + throw e;
  77 + }
  78 +
  79 + // 2. 把 name 作为一等字段写入 project.json (供列表 / 改名共用)
  80 + if (name) {
  81 + try { pathResolver.patchProjectMeta(proj.pid, { name }); }
  82 + catch (e) { console.warn('[DevLoop] failed to set name:', e.message); }
  83 + }
49 84  
50   - // 2. PM → Architect (mock 模式)
  85 + // 3. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用
51 86 const phase = await loopController.runDocsPhase(proj.pid, requirement, {
52 87 mock: mock !== false,
  88 + mockFixtureDir,
53 89 });
54 90  
55 91 res.json({
... ... @@ -70,44 +106,306 @@ router.post(&#39;/projects&#39;, h(async (req, res) =&gt; {
70 106 });
71 107 }));
72 108  
73   -// GET /api/devloop/projects
74   -router.get('/projects', h(async (_req, res) => {
75   - const projects = pathResolver.listProjects();
  109 +// GET /api/devloop/projects?include_archived=true
  110 +router.get('/projects', h(async (req, res) => {
  111 + const includeArchived = String(req.query.include_archived || '') === 'true';
  112 + const projects = pathResolver.listProjects({ includeArchived });
76 113 res.json({ projects });
77 114 }));
78 115  
79   -// POST /api/devloop/projects/:pid/run?only=<unitId>
80   -// P2: only=<unitId> 跑单 unit;不带 only → 501 (P3 才补全 runProject)
  116 +// GET /api/devloop/fs/browse?path=/abs/path
  117 +// 列出指定绝对路径下的子目录(供新建项目时的"指定本机目录"选择用)。
  118 +// - 不传 path → 用 $HOME
  119 +// - 拒绝 sensitive 路径
  120 +// - 仅返回目录,不返回文件
  121 +router.get('/fs/browse', h(async (req, res) => {
  122 + let p = req.query.path ? String(req.query.path) : os.homedir();
  123 + if (!path.isAbsolute(p)) {
  124 + return res.status(400).json({ error: 'path must be absolute' });
  125 + }
  126 + p = path.resolve(p);
  127 + if (pathResolver.isSensitivePath(p)) {
  128 + return res.status(403).json({ error: `sensitive_path: ${p} is blacklisted` });
  129 + }
  130 + if (!fs.existsSync(p)) {
  131 + return res.status(404).json({ error: `not found: ${p}` });
  132 + }
  133 + const stat = fs.statSync(p);
  134 + if (!stat.isDirectory()) {
  135 + return res.status(400).json({ error: `not a directory: ${p}` });
  136 + }
  137 + let entries = [];
  138 + try {
  139 + entries = fs.readdirSync(p, { withFileTypes: true })
  140 + .filter(d => d.isDirectory() && !d.name.startsWith('.'))
  141 + .map(d => {
  142 + const abs = path.join(p, d.name);
  143 + let is_empty = false;
  144 + try { is_empty = fs.readdirSync(abs).length === 0; } catch { /* permission denied 当作非空 */ }
  145 + return { name: d.name, abs_path: abs, is_empty };
  146 + })
  147 + .sort((a, b) => a.name.localeCompare(b.name));
  148 + } catch (e) {
  149 + return res.status(403).json({ error: `cannot read: ${e.message}` });
  150 + }
  151 + const parent = path.dirname(p);
  152 + res.json({
  153 + path: p,
  154 + parent: parent === p ? null : parent, // 根目录 parent === p
  155 + home: os.homedir(),
  156 + entries,
  157 + });
  158 +}));
  159 +
  160 +// PATCH /api/devloop/projects/:pid
  161 +// body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback)
  162 +router.patch('/projects/:pid', h(async (req, res) => {
  163 + const { pid } = req.params;
  164 + try { pathResolver.resolveProjectRoot(pid); }
  165 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  166 + const allowed = ['name', 'idea_summary'];
  167 + const patch = {};
  168 + for (const k of allowed) {
  169 + if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) {
  170 + const v = req.body[k];
  171 + if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` });
  172 + patch[k] = v.slice(0, 500);
  173 + }
  174 + }
  175 + if (Object.keys(patch).length === 0) {
  176 + return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' });
  177 + }
  178 + const meta = pathResolver.patchProjectMeta(pid, patch);
  179 + res.json({ ok: true, pid, project: meta });
  180 +}));
  181 +
  182 +// DELETE /api/devloop/projects/:pid?hard=true
  183 +// 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked)
  184 +router.delete('/projects/:pid', h(async (req, res) => {
  185 + const { pid } = req.params;
  186 + const hard = String(req.query.hard || '') === 'true';
  187 + try { pathResolver.resolveProjectRoot(pid); }
  188 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  189 + if (hard) {
  190 + const r = pathResolver.hardDeleteProject(pid);
  191 + return res.json({ ok: true, pid, hard: true, ...r });
  192 + }
  193 + const meta = pathResolver.patchProjectMeta(pid, {
  194 + archived: true,
  195 + archived_at: new Date().toISOString(),
  196 + });
  197 + res.json({ ok: true, pid, hard: false, archived: true, project: meta });
  198 +}));
  199 +
  200 +// POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复
  201 +router.post('/projects/:pid/restore', h(async (req, res) => {
  202 + const { pid } = req.params;
  203 + try { pathResolver.resolveProjectRoot(pid); }
  204 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  205 + const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null });
  206 + res.json({ ok: true, pid, archived: false, project: meta });
  207 +}));
  208 +
  209 +// POST /api/devloop/projects/:pid/run[?only=<unitId>] [body: { mockFixtureDir? }]
  210 +// - 不带 only: 跑完整 runProject (P3)
  211 +// - only=<unitId>: 单 unit drill-down (P2 兼容)
81 212 router.post('/projects/:pid/run', h(async (req, res) => {
82 213 const { pid } = req.params;
83 214 const { only } = req.query;
84   - if (!only) {
85   - return res.status(501).json({ error: 'P2 only supports ?only=<unitId>; full project run lands in P3' });
86   - }
  215 + const { mockFixtureDir } = req.body || {};
87 216 try {
88   - const result = await loopController.runUnit(pid, only, { mock: true });
89   - res.json({
90   - ok: result.ok,
91   - pid, unit_id: only,
92   - attempts: result.attempts,
  217 + if (only) {
  218 + const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir });
  219 + return res.json({
  220 + ok: result.ok, pid, unit_id: only,
  221 + attempts: result.attempts, final_state: result.final_state,
  222 + history: result.history.map(h => ({
  223 + agent: h.agent, attempt: h.attempt,
  224 + ok: h.ok, files: h.files?.length || 0,
  225 + commit: h.commit || null,
  226 + })),
  227 + });
  228 + }
  229 + // 完整 runProject
  230 + const result = await loopController.runProject(pid, { mock: true, mockFixtureDir });
  231 + return res.json({
  232 + ok: result.ok, pid,
93 233 final_state: result.final_state,
94   - history: result.history.map(h => ({
95   - agent: h.agent, attempt: h.attempt,
96   - ok: h.ok, files: h.files?.length || 0,
97   - commit: h.commit || null,
98   - })),
  234 + l2_rounds: result.l2_rounds,
  235 + run_id: result.run_id || null,
  236 + reason: result.reason || null,
  237 + history: result.history,
99 238 });
100 239 } catch (e) {
101 240 if (/Unknown project/.test(e.message)) {
102 241 return res.status(404).json({ error: 'unknown project: ' + pid });
103 242 }
104 243 if (/not found in project/.test(e.message)) {
105   - return res.status(404).json({ error: 'unknown unit: ' + only });
  244 + return res.status(404).json({ error: 'unknown unit: ' + (only || '?') });
106 245 }
107 246 throw e;
108 247 }
109 248 }));
110 249  
  250 +// GET /api/devloop/projects/:pid/stream — SSE
  251 +// client may send Last-Event-ID header → broker replays from there
  252 +router.get('/projects/:pid/stream', (req, res) => {
  253 + const { pid } = req.params;
  254 + try { pathResolver.resolveProjectRoot(pid); }
  255 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  256 +
  257 + res.setHeader('Content-Type', 'text/event-stream');
  258 + res.setHeader('Cache-Control', 'no-cache');
  259 + res.setHeader('Connection', 'keep-alive');
  260 + res.flushHeaders?.();
  261 +
  262 + const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null;
  263 + sseBroker.subscribe(pid, res, {
  264 + replayFromOffset: lastEventId != null ? lastEventId : undefined,
  265 + });
  266 +});
  267 +
  268 +// P4: POST /api/devloop/projects/:pid/sign-off
  269 +// body: { by?, note? }
  270 +// 400 if status !== 'shipped'
  271 +router.post('/projects/:pid/sign-off', h(async (req, res) => {
  272 + const { pid } = req.params;
  273 + try { pathResolver.resolveProjectRoot(pid); }
  274 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  275 + try {
  276 + const meta = await loopController.signOff(pid, req.body || {});
  277 + res.json({ ok: true, pid, status: meta.status });
  278 + } catch (e) {
  279 + if (e.name === 'InvalidProjectStatusTransition') {
  280 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  281 + }
  282 + throw e;
  283 + }
  284 +}));
  285 +
  286 +// P4: POST /api/devloop/projects/:pid/abandon
  287 +// body: { by?, reason?, note? }
  288 +router.post('/projects/:pid/abandon', h(async (req, res) => {
  289 + const { pid } = req.params;
  290 + try { pathResolver.resolveProjectRoot(pid); }
  291 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  292 + try {
  293 + const meta = await loopController.abandon(pid, req.body || {});
  294 + res.json({ ok: true, pid, status: meta.status });
  295 + } catch (e) {
  296 + if (e.name === 'InvalidProjectStatusTransition') {
  297 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  298 + }
  299 + throw e;
  300 + }
  301 +}));
  302 +
  303 +// P4: POST /api/devloop/projects/:pid/feedback
  304 +// body: { text (required), by?, mockFixtureDir? }
  305 +// 400 if status ∈ {shipped-final, abandoned}
  306 +router.post('/projects/:pid/feedback', h(async (req, res) => {
  307 + const { pid } = req.params;
  308 + const { text, by, mockFixtureDir } = req.body || {};
  309 + if (!text) return res.status(400).json({ error: 'missing field: text' });
  310 + try { pathResolver.resolveProjectRoot(pid); }
  311 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  312 + try {
  313 + const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir });
  314 + res.json(r);
  315 + } catch (e) {
  316 + if (e.name === 'InvalidProjectStatusTransition') {
  317 + return res.status(400).json({ error: e.message, from: e.from, to: e.to });
  318 + }
  319 + throw e;
  320 + }
  321 +}));
  322 +
  323 +// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
  324 +router.get('/projects/:pid/bugs', h(async (req, res) => {
  325 + const { pid } = req.params;
  326 + try { pathResolver.resolveProjectRoot(pid); }
  327 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  328 + const filter = {};
  329 + if (req.query.status) filter.status = String(req.query.status).split(',');
  330 + if (req.query.unit_id) filter.unit_id = req.query.unit_id;
  331 + if (req.query.severity) filter.severity = String(req.query.severity).split(',');
  332 + const bugs = bugTracker.list(pid, filter);
  333 + res.json({ bugs, total: bugs.length });
  334 +}));
  335 +
  336 +// P5: GET /api/devloop/projects/:pid/timeline?limit=200
  337 +// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用
  338 +router.get('/projects/:pid/timeline', h(async (req, res) => {
  339 + const fs = require('fs');
  340 + const { pid } = req.params;
  341 + try { pathResolver.resolveProjectRoot(pid); }
  342 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  343 + const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200));
  344 + const p = pathResolver.resolveTimelineLog(pid);
  345 + if (!fs.existsSync(p)) return res.json({ events: [], total: 0 });
  346 + const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
  347 + const events = lines.slice(-limit).reverse().map(l => {
  348 + try { return JSON.parse(l); } catch { return null; }
  349 + }).filter(Boolean);
  350 + res.json({ events, total: events.length });
  351 +}));
  352 +
  353 +// P5: GET /api/devloop/projects/:pid/changelog
  354 +// 读 changelog.ndjson 全部 (按时间倒序)
  355 +router.get('/projects/:pid/changelog', h(async (req, res) => {
  356 + const fs = require('fs');
  357 + const { pid } = req.params;
  358 + try { pathResolver.resolveProjectRoot(pid); }
  359 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  360 + const p = pathResolver.resolveChangelog(pid);
  361 + if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 });
  362 + const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
  363 + .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean)
  364 + .reverse();
  365 + res.json({ entries, total: entries.length });
  366 +}));
  367 +
  368 +// P5: GET /api/devloop/projects/:pid/units/:unitId
  369 +// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json)
  370 +router.get('/projects/:pid/units/:unitId', h(async (req, res) => {
  371 + const fs = require('fs'); const path = require('path');
  372 + const { pid, unitId } = req.params;
  373 + try { pathResolver.resolveProjectRoot(pid); }
  374 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  375 + const unitDir = pathResolver.resolveUnitDir(pid, unitId);
  376 + if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId });
  377 + // 从 units.json 找 unit 元数据
  378 + let unitMeta = null;
  379 + try {
  380 + const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  381 + unitMeta = (uj.units || []).find(u => u.id === unitId) || null;
  382 + } catch { /* ignore */ }
  383 + // runs
  384 + const runsDir = path.join(unitDir, 'runs');
  385 + let runs = [];
  386 + if (fs.existsSync(runsDir)) {
  387 + const ids = fs.readdirSync(runsDir).filter(n => {
  388 + try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; }
  389 + }).sort().reverse().slice(0, 3);
  390 + for (const id of ids) {
  391 + const rp = path.join(runsDir, id, 'result.json');
  392 + let r = null;
  393 + if (fs.existsSync(rp)) {
  394 + try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {}
  395 + }
  396 + runs.push({ run_id: id, result: r });
  397 + }
  398 + }
  399 + // coder.log 最后 1KB 节选
  400 + let coderLog = null;
  401 + const clp = path.join(unitDir, 'coder.log');
  402 + if (fs.existsSync(clp)) {
  403 + const raw = fs.readFileSync(clp, 'utf8');
  404 + coderLog = raw.length > 1024 ? raw.slice(-1024) : raw;
  405 + }
  406 + res.json({ unit: unitMeta, runs, coder_log_tail: coderLog });
  407 +}));
  408 +
111 409 // GET /api/devloop/projects/:pid/state
112 410 router.get('/projects/:pid/state', h(async (req, res) => {
113 411 try {
... ... @@ -121,4 +419,178 @@ router.get(&#39;/projects/:pid/state&#39;, h(async (req, res) =&gt; {
121 419 }
122 420 }));
123 421  
  422 +// ─────────────────────────────────────────────────────────────────────────────
  423 +// P6 · Requirements 列表 + 新增需求 intake (file upload / dir scan / chat draft)
  424 +// ─────────────────────────────────────────────────────────────────────────────
  425 +
  426 +// GET /api/devloop/projects/:pid/requirements
  427 +// 返回需求列表:rev=1 为初始需求(取自 project.json.idea_summary),
  428 +// rev>=2 来自 changelog.ndjson 中 kind=prd_rev 的条目。
  429 +router.get('/projects/:pid/requirements', h(async (req, res) => {
  430 + const { pid } = req.params;
  431 + let projectMeta;
  432 + try {
  433 + projectMeta = JSON.parse(fs.readFileSync(pathResolver.resolveProjectJson(pid), 'utf8'));
  434 + } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  435 +
  436 + const items = [];
  437 + // rev 1: 初始需求
  438 + items.push({
  439 + rev: 1,
  440 + ts: projectMeta.created_at,
  441 + by: 'init',
  442 + trigger: 'project_create',
  443 + text: projectMeta.idea_summary || '',
  444 + });
  445 +
  446 + // rev>=2: from changelog
  447 + const clp = pathResolver.resolveChangelog(pid);
  448 + if (fs.existsSync(clp)) {
  449 + const lines = fs.readFileSync(clp, 'utf8').trim().split('\n').filter(Boolean);
  450 + for (const l of lines) {
  451 + let e; try { e = JSON.parse(l); } catch { continue; }
  452 + if (e.kind !== 'prd_rev') continue;
  453 + items.push({
  454 + rev: e.rev,
  455 + ts: e.ts,
  456 + by: e.by || 'human',
  457 + trigger: e.trigger || 'human_feedback',
  458 + text: e.diff?.feedback_excerpt || e.summary || '',
  459 + });
  460 + }
  461 + }
  462 +
  463 + // 派生 status: 最新一条 = current(用项目状态),其余 = older
  464 + items.sort((a, b) => a.rev - b.rev);
  465 + const latestRev = items.length ? items[items.length - 1].rev : 0;
  466 + const projectStatus = projectMeta.status || 'draft';
  467 + for (const it of items) {
  468 + it.status = it.rev === latestRev ? projectStatus : 'older';
  469 + it.is_current = it.rev === latestRev;
  470 + }
  471 +
  472 + res.json({
  473 + pid,
  474 + project_status: projectStatus,
  475 + current_rev: latestRev,
  476 + items: items.reverse(), // 倒序:最新在前
  477 + });
  478 +}));
  479 +
  480 +// POST /api/devloop/projects/:pid/intake/files
  481 +// multipart/form-data: files=<file>...; (.md / .txt / .html 才被采纳,其它跳过)
  482 +// 保存到 projects/{pid}/intake/{ts}/,返回每个文件的提取文本。
  483 +router.post('/projects/:pid/intake/files',
  484 + intakeUpload.array('files', 10),
  485 + h(async (req, res) => {
  486 + const { pid } = req.params;
  487 + let root;
  488 + try { root = pathResolver.resolveProjectRoot(pid); }
  489 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  490 +
  491 + const ts = new Date().toISOString().replace(/[:.]/g, '-');
  492 + const intakeDir = path.join(root, 'intake', ts);
  493 + fs.mkdirSync(intakeDir, { recursive: true });
  494 +
  495 + const files = req.files || [];
  496 + const results = [];
  497 + for (const f of files) {
  498 + const ext = path.extname(f.originalname).toLowerCase();
  499 + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) {
  500 + results.push({ name: f.originalname, skipped: true, reason: `unsupported ext: ${ext}` });
  501 + continue;
  502 + }
  503 + const safeName = f.originalname.replace(/[/\\]/g, '_').slice(0, 200);
  504 + const abs = path.join(intakeDir, safeName);
  505 + fs.writeFileSync(abs, f.buffer);
  506 + let text = f.buffer.toString('utf8');
  507 + if (ext === '.html' || ext === '.htm') {
  508 + // 极简 strip:去脚本/样式块 + 拆 tag
  509 + text = text
  510 + .replace(/<script[\s\S]*?<\/script>/gi, '')
  511 + .replace(/<style[\s\S]*?<\/style>/gi, '')
  512 + .replace(/<[^>]+>/g, ' ')
  513 + .replace(/\s+/g, ' ')
  514 + .trim();
  515 + }
  516 + results.push({
  517 + name: f.originalname,
  518 + size: f.size,
  519 + saved_to: path.relative(root, abs),
  520 + text,
  521 + });
  522 + }
  523 + res.json({ ok: true, intake_dir: path.relative(root, intakeDir), files: results });
  524 + })
  525 +);
  526 +
  527 +// POST /api/devloop/projects/:pid/intake/scan-dir
  528 +// body: { path: '/abs/dir', max_files?: 50, max_bytes?: 1MB }
  529 +// 递归找 md/txt/html,读文本。受 sensitive 黑名单拦截。
  530 +router.post('/projects/:pid/intake/scan-dir', h(async (req, res) => {
  531 + const { pid } = req.params;
  532 + try { pathResolver.resolveProjectRoot(pid); }
  533 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  534 +
  535 + const target = req.body?.path && String(req.body.path);
  536 + const maxFiles = Math.min(200, parseInt(req.body?.max_files, 10) || 50);
  537 + const maxBytes = Math.min(5 * 1024 * 1024, parseInt(req.body?.max_bytes, 10) || 1024 * 1024);
  538 + if (!target || !path.isAbsolute(target)) {
  539 + return res.status(400).json({ error: 'path required (absolute)' });
  540 + }
  541 + if (pathResolver.isSensitivePath(target)) {
  542 + return res.status(403).json({ error: `sensitive_path: ${target}` });
  543 + }
  544 + if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
  545 + return res.status(404).json({ error: `not a directory: ${target}` });
  546 + }
  547 +
  548 + const found = [];
  549 + let totalBytes = 0;
  550 + let truncated = false;
  551 + function walk(dir, depth) {
  552 + if (depth > 6 || found.length >= maxFiles || truncated) return;
  553 + let entries;
  554 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  555 + catch { return; }
  556 + for (const e of entries) {
  557 + if (e.name.startsWith('.') || e.name === 'node_modules') continue;
  558 + const full = path.join(dir, e.name);
  559 + if (e.isDirectory()) { walk(full, depth + 1); continue; }
  560 + if (!e.isFile()) continue;
  561 + const ext = path.extname(e.name).toLowerCase();
  562 + if (!['.md', '.txt', '.html', '.htm'].includes(ext)) continue;
  563 + const stat = fs.statSync(full);
  564 + if (totalBytes + stat.size > maxBytes) { truncated = true; return; }
  565 + let text;
  566 + try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
  567 + if (ext === '.html' || ext === '.htm') {
  568 + text = text.replace(/<script[\s\S]*?<\/script>/gi, '')
  569 + .replace(/<style[\s\S]*?<\/style>/gi, '')
  570 + .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
  571 + }
  572 + totalBytes += stat.size;
  573 + found.push({ path: full, rel: path.relative(target, full), size: stat.size, text });
  574 + if (found.length >= maxFiles) { truncated = true; return; }
  575 + }
  576 + }
  577 + walk(target, 0);
  578 + res.json({ ok: true, scanned_root: target, total_bytes: totalBytes, truncated, files: found });
  579 +}));
  580 +
  581 +// POST /api/devloop/projects/:pid/draft-chat
  582 +// body: { messages: [{role:'user'|'assistant', content:string}, ...] }
  583 +// 返回 { role:'assistant', content:string }
  584 +// 占位实现:原样回显最后一条 user 消息 + 提示。LLM 接入后此函数体内换实现即可。
  585 +router.post('/projects/:pid/draft-chat', h(async (req, res) => {
  586 + const { pid } = req.params;
  587 + try { pathResolver.resolveProjectRoot(pid); }
  588 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  589 + const messages = Array.isArray(req.body?.messages) ? req.body.messages : [];
  590 + const lastUser = [...messages].reverse().find(m => m.role === 'user');
  591 + const echoText = lastUser?.content?.toString().trim() || '(空消息)';
  592 + const reply = `已收到你的描述(共 ${echoText.length} 字符):\n\n> ${echoText.slice(0, 200)}${echoText.length > 200 ? '…' : ''}\n\n(LLM 多轮调试暂未接入。请继续追加细节、上传文档、或扫描代码目录,然后点"提交需求"一次性发给 PM Agent。)`;
  593 + res.json({ role: 'assistant', content: reply, llm_stub: true });
  594 +}));
  595 +
124 596 module.exports = router;
... ...
server/services/devloop/git-vault.js
... ... @@ -142,12 +142,11 @@ async function commit(pid, spec) {
142 142 }
143 143 if (!spec.summary) throw new Error('commit: summary required');
144 144  
145   - // 1. 加 files(默认 -A,但受 .gitignore 控制)
146   - const addArgs = ['add', '--'];
  145 + // 1. 加 files(显式列表则限定路径;否则 -A 全量。-A 受 .gitignore 控制)
147 146 if (Array.isArray(spec.files) && spec.files.length) {
148   - addArgs.push(...spec.files);
  147 + const rAdd = await _run(root, ['add', '--', ...spec.files]);
  148 + if (!rAdd.ok) throw new Error(`git add files failed: ${rAdd.stderr}`);
149 149 } else {
150   - // -A:把所有变更(包括删除)加进 index
151 150 const rA = await _run(root, ['add', '-A']);
152 151 if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`);
153 152 }
... ...
server/services/devloop/path-resolver.js
... ... @@ -259,9 +259,55 @@ function allocateProjectRoot({ company_id, mode = &#39;sandbox&#39;, target_path }) {
259 259 };
260 260 saveLinkedRegistry(reg);
261 261 } else if (mode === 'linked-existing') {
262   - const e = new Error('linked-existing mode is v2; not supported in v1');
263   - e.code = 'NOT_IMPLEMENTED';
264   - throw e;
  262 + if (!target_path) throw new Error('target_path required for linked-existing');
  263 + if (!path.isAbsolute(target_path)) {
  264 + throw new Error('target_path must be absolute');
  265 + }
  266 + if (isSensitivePath(target_path)) {
  267 + const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
  268 + e.code = 'SENSITIVE_PATH';
  269 + throw e;
  270 + }
  271 + if (!fs.existsSync(target_path)) {
  272 + throw new Error(`target_path does not exist: ${target_path} (use linked-new for empty/new dir)`);
  273 + }
  274 + const stat = fs.statSync(target_path);
  275 + if (!stat.isDirectory()) {
  276 + throw new Error(`target_path is not a directory: ${target_path}`);
  277 + }
  278 + // 已有 DevLoop 项目(目录里就有 project.json)→ 拒绝重复挂载
  279 + if (fs.existsSync(path.join(target_path, 'project.json'))) {
  280 + throw new Error(`target_path already contains a DevLoop project.json: ${target_path}`);
  281 + }
  282 + // 已有 .git → 必须 working tree 干净(避免后续 Agent 的 git add -A 把用户脏改动扫进 DevLoop commit)
  283 + if (fs.existsSync(path.join(target_path, '.git'))) {
  284 + const { execFileSync } = require('child_process');
  285 + let porcelain;
  286 + try {
  287 + porcelain = execFileSync('git', ['status', '--porcelain'], { cwd: target_path, encoding: 'utf8' });
  288 + } catch (e) {
  289 + throw new Error(`failed to check git status at ${target_path}: ${e.message}`);
  290 + }
  291 + if (porcelain.trim().length > 0) {
  292 + const lines = porcelain.trim().split('\n').slice(0, 8).join('\n');
  293 + const err = new Error(
  294 + `target_path has uncommitted changes; please commit or stash first.\n` +
  295 + `Reason: DevLoop agents auto-commit with git add -A and would sweep your dirty files into agent commits.\n` +
  296 + `--- git status --porcelain (truncated) ---\n${lines}`
  297 + );
  298 + err.code = 'DIRTY_WORKTREE';
  299 + throw err;
  300 + }
  301 + }
  302 + root_path = path.resolve(target_path);
  303 + const reg = loadLinkedRegistry();
  304 + reg[pid] = {
  305 + root_path,
  306 + mode: 'linked-existing',
  307 + company_id,
  308 + registered_at: new Date().toISOString(),
  309 + };
  310 + saveLinkedRegistry(reg);
265 311 } else {
266 312 throw new Error(`unknown mode: ${mode}`);
267 313 }
... ... @@ -282,9 +328,76 @@ function unregisterLinkedProject(pid) {
282 328 if (cache) cache.delete(pid);
283 329 }
284 330  
285   -function listProjects() {
  331 +/**
  332 + * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。
  333 + * 默认过滤已 archived 的项目;includeArchived=true 时全返。
  334 + */
  335 +function listProjects({ includeArchived = false } = {}) {
  336 + ensureInit();
  337 + const out = [];
  338 + for (const [pid, entry] of cache.entries()) {
  339 + const meta = _readMetaSafe(entry.root_path);
  340 + if (!includeArchived && meta && meta.archived) continue;
  341 + out.push({
  342 + pid,
  343 + ...entry,
  344 + name: meta?.name || meta?.idea_summary || pid,
  345 + idea_summary: meta?.idea_summary || '',
  346 + status: meta?.status || 'draft',
  347 + created_at: meta?.created_at || null,
  348 + archived: !!meta?.archived,
  349 + archived_at: meta?.archived_at || null,
  350 + });
  351 + }
  352 + // 倒序:最近创建的在前
  353 + out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
  354 + return out;
  355 +}
  356 +
  357 +function _readMetaSafe(rootPath) {
  358 + try {
  359 + const p = path.join(rootPath, 'project.json');
  360 + if (!fs.existsSync(p)) return null;
  361 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  362 + } catch { return null; }
  363 +}
  364 +
  365 +/**
  366 + * 修改 project.json 的字段(name / idea_summary / archived 等)。
  367 + * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。
  368 + */
  369 +function patchProjectMeta(pid, patch = {}) {
  370 + ensureInit();
  371 + const root = resolveProjectRoot(pid);
  372 + const metaPath = path.join(root, 'project.json');
  373 + if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`);
  374 + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
  375 + Object.assign(meta, patch);
  376 + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
  377 + return meta;
  378 +}
  379 +
  380 +/**
  381 + * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。
  382 + * 返回 { mode, removed_disk: boolean }
  383 + */
  384 +function hardDeleteProject(pid) {
286 385 ensureInit();
287   - return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry }));
  386 + const entry = cache.get(pid);
  387 + if (!entry) throw new Error(`Unknown project: ${pid}`);
  388 + let removed_disk = false;
  389 + if (entry.mode === 'sandbox') {
  390 + if (fs.existsSync(entry.root_path)) {
  391 + fs.rmSync(entry.root_path, { recursive: true, force: true });
  392 + removed_disk = true;
  393 + }
  394 + } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') {
  395 + // 只解注册,不动用户自己挑的目录
  396 + const reg = loadLinkedRegistry();
  397 + if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); }
  398 + }
  399 + cache.delete(pid);
  400 + return { mode: entry.mode, removed_disk };
288 401 }
289 402  
290 403 /** 测试用:重置缓存,强制下次重新扫描 */
... ... @@ -316,6 +429,8 @@ module.exports = {
316 429 allocateProjectRoot,
317 430 unregisterLinkedProject,
318 431 listProjects,
  432 + patchProjectMeta,
  433 + hardDeleteProject,
319 434 isSensitivePath,
320 435 _resetForTesting,
321 436 // 常量
... ...
server/src/core/devloop/loop-controller.js
... ... @@ -24,8 +24,17 @@ const pathResolver = require(&#39;../../../services/devloop/path-resolver&#39;);
24 24 const gitVault = require('../../../services/devloop/git-vault');
25 25 const timelineLogger = require('../../../services/devloop/timeline-logger');
26 26 const unitRegistry = require('../../../services/devloop/unit-registry');
  27 +const bugTracker = require('../../../services/devloop/bug-tracker');
  28 +const changelogWriter = require('../../../services/devloop/changelog-writer');
  29 +const sseBroker = require('../../../services/devloop/sse-broker');
  30 +const docStore = require('../../../services/devloop/doc-store');
27 31 const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');
28 32  
  33 +// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地
  34 +unitRegistry.attachSseBroker(sseBroker);
  35 +bugTracker.attachSseBroker(sseBroker);
  36 +bugTracker.attachTimelineLogger(timelineLogger);
  37 +
29 38 const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');
30 39  
31 40 /**
... ... @@ -42,13 +51,15 @@ async function createProject({ company_id, mode = &#39;sandbox&#39;, target_path, idea =
42 51 company_id, mode, target_path,
43 52 });
44 53  
45   - // project.json
  54 + // project.json (含 P4 status FSM 字段)
46 55 const meta = {
47 56 pid,
48 57 company_id,
49 58 mode,
50 59 created_at: new Date().toISOString(),
51 60 idea_summary: (idea || '').slice(0, 200),
  61 + status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned
  62 + status_updated_at: new Date().toISOString(),
52 63 };
53 64 fs.writeFileSync(
54 65 pathResolver.resolveProjectJson(pid),
... ... @@ -74,10 +85,12 @@ async function createProject({ company_id, mode = &#39;sandbox&#39;, target_path, idea =
74 85 }
75 86  
76 87 // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净)
  88 + // 显式 files 列表:避免 linked-existing 模式把用户已有未提交文件拖进我们的 chore commit
77 89 const firstCommit = await gitVault.commit(pid, {
78 90 kind: 'chore',
79 91 summary: `chore: project metadata + first timeline entry for ${pid}`,
80 92 author: { name: 'devloop-system', email: 'devloop@local' },
  93 + files: ['project.json', 'timeline.ndjson'],
81 94 });
82 95  
83 96 return { pid, root_path, mode, first_commit: firstCommit };
... ... @@ -147,19 +160,32 @@ async function commitAfterAgent(pid, agentName, summary = &#39;&#39;) {
147 160 async function callAgent(pid, agentName, userPrompt, opts = {}) {
148 161 const projectRoot = pathResolver.resolveProjectRoot(pid);
149 162  
  163 + const useMock = opts.mock !== false;
  164 +
150 165 // 1. 系统提示 (可选 — 没文件就空串)
151 166 const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md');
152 167 const systemPrompt = fs.existsSync(promptPath)
153 168 ? fs.readFileSync(promptPath, 'utf8')
154 169 : '';
155   - const fullPrompt = systemPrompt
156   - ? `${systemPrompt}\n\n## User Task\n${userPrompt}`
  170 + // mock 模式: 把 system + user 拼一起喂 fixture (fixture 不在意);
  171 + // real 模式: systemPrompt 走 --append-system-prompt, userPrompt 走 stdin。
  172 + const fullPrompt = useMock
  173 + ? (systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt)
157 174 : userPrompt;
158 175  
159   - // 2. 启动 protocol
  176 + // 2. real 模式: 先快照工作树 mtime,Agent 跑完后做 diff
  177 + const preSnapshot = useMock ? null : _snapshotMtimes(projectRoot);
  178 +
  179 + // 3. 启动 protocol
160 180 const protocol = new ClaudeCliProtocol(
161 181 { name: agentName },
162   - { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段)
  182 + {
  183 + mock: useMock,
  184 + mockFixtureDir: opts.mockFixtureDir,
  185 + // real 模式专属:
  186 + systemPrompt: useMock ? undefined : systemPrompt,
  187 + maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限
  188 + },
163 189 );
164 190  
165 191 let resultParams = null;
... ... @@ -183,36 +209,44 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) {
183 209 protocol.disconnect();
184 210 }
185 211  
186   - // 3. 落盘 fixture.files (path 相对 projectRoot)
  212 + // 4. 落盘 — mock 用 fixture.files, real 用 fs diff
187 213 const filesWritten = [];
188   - for (const f of filesFromMock) {
189   - if (!f?.path || typeof f.content !== 'string') continue;
190   - if (path.isAbsolute(f.path) || f.path.includes('..')) {
191   - throw new Error(`unsafe mock file path: ${f.path}`);
  214 + if (useMock) {
  215 + for (const f of filesFromMock) {
  216 + if (!f?.path || typeof f.content !== 'string') continue;
  217 + if (path.isAbsolute(f.path) || f.path.includes('..')) {
  218 + throw new Error(`unsafe mock file path: ${f.path}`);
  219 + }
  220 + const abs = path.join(projectRoot, f.path);
  221 + fs.mkdirSync(path.dirname(abs), { recursive: true });
  222 + let content = f.content;
  223 + if (f.path === 'units.json' || f.path.endsWith('/units.json')) {
  224 + try {
  225 + const u = JSON.parse(content);
  226 + u.project_id = pid;
  227 + content = JSON.stringify(u, null, 2);
  228 + } catch { /* leave as-is, downstream will fail validation */ }
  229 + }
  230 + fs.writeFileSync(abs, content);
  231 + filesWritten.push(f.path);
192 232 }
193   - const abs = path.join(projectRoot, f.path);
194   - fs.mkdirSync(path.dirname(abs), { recursive: true });
195   - let content = f.content;
196   - // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_")
197   - if (f.path === 'units.json' || f.path.endsWith('/units.json')) {
198   - try {
199   - const u = JSON.parse(content);
200   - u.project_id = pid;
201   - content = JSON.stringify(u, null, 2);
202   - } catch { /* leave as-is, downstream will fail validation */ }
  233 + } else {
  234 + // real 模式: 扫工作树找新增/修改文件 (相对项目根)
  235 + const postSnapshot = _snapshotMtimes(projectRoot);
  236 + for (const [rel, mtime] of postSnapshot) {
  237 + const before = preSnapshot.get(rel);
  238 + if (before === undefined || before < mtime) filesWritten.push(rel);
203 239 }
204   - fs.writeFileSync(abs, content);
205   - filesWritten.push(f.path);
206 240 }
207 241  
208   - // 4. timeline + commit
  242 + // 5. timeline + commit
209 243 try {
210 244 timelineLogger.append(pid, {
211 245 source: agentName,
212 246 kind: 'agent.call',
213   - ref: resultParams?.session_id || 'mock',
214   - summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`,
215   - payload: { files_written: filesWritten.length, mock: !!resultParams?.mock },
  247 + ref: resultParams?.session_id || (useMock ? 'mock' : 'real'),
  248 + summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (${useMock ? 'mock' : 'real'})`,
  249 + payload: { files_written: filesWritten.length, mock: !!resultParams?.mock, real: !useMock },
216 250 });
217 251 } catch { /* swallow */ }
218 252  
... ... @@ -246,17 +280,26 @@ async function runDocsPhase(pid, requirement, opts = {}) {
246 280 throw new Error('requirement (string) required');
247 281 }
248 282  
  283 + // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始
  284 + if (opts.resetMockSequence !== false) {
  285 + ClaudeCliProtocol.resetMockState();
  286 + }
  287 +
249 288 const onChunk = opts.onChunk
250 289 ? (agent) => (text) => opts.onChunk(agent, text)
251 290 : null;
  291 + const passthrough = {
  292 + mock: opts.mock !== false,
  293 + mockFixtureDir: opts.mockFixtureDir,
  294 + };
252 295  
253 296 const pm = await callAgent(pid, 'devloop-pm',
254 297 `Requirement:\n${requirement}\n\nmode: init`,
255   - { onChunk: onChunk ? onChunk('devloop-pm') : undefined });
  298 + { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined });
256 299  
257 300 const architect = await callAgent(pid, 'devloop-architect',
258 301 `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`,
259   - { onChunk: onChunk ? onChunk('devloop-architect') : undefined });
  302 + { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined });
260 303  
261 304 // timeline 总结
262 305 try {
... ... @@ -272,6 +315,9 @@ async function runDocsPhase(pid, requirement, opts = {}) {
272 315 });
273 316 } catch { /* swallow */ }
274 317  
  318 + // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2)
  319 + _setProjectStatus(pid, 'running', { reason: 'docs_complete' });
  320 +
275 321 return { ok: true, pm, architect };
276 322 }
277 323  
... ... @@ -290,7 +336,10 @@ function getProjectState(pid) {
290 336  
291 337 const projectJson = pathResolver.resolveProjectJson(pid);
292 338 if (fs.existsSync(projectJson)) {
293   - try { out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); } catch {}
  339 + try {
  340 + out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8'));
  341 + out.status = out.project.status || 'unknown';
  342 + } catch {}
294 343 }
295 344  
296 345 const unitsJson = pathResolver.resolveUnitsJson(pid);
... ... @@ -464,11 +513,487 @@ function _findLatestTesterResult(pid, unitId) {
464 513 }
465 514 }
466 515  
  516 +// ──────────────────────────────────────────────────────────────
  517 +// P3 · L2 Integration Loop · runProject
  518 +// ──────────────────────────────────────────────────────────────
  519 +
  520 +/**
  521 + * Topological wave grouping (Kahn). Returns string[][] — units per wave.
  522 + */
  523 +function _groupUnitsByWave(units) {
  524 + const groups = [];
  525 + const remaining = new Set(units.map(u => u.id));
  526 + const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()]));
  527 + while (remaining.size > 0) {
  528 + const wave = [];
  529 + for (const id of remaining) {
  530 + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
  531 + }
  532 + if (wave.length === 0) {
  533 + throw new Error('cycle in units.json (no unit has all dependencies cleared)');
  534 + }
  535 + for (const id of wave) remaining.delete(id);
  536 + groups.push(wave);
  537 + }
  538 + return groups;
  539 +}
  540 +
  541 +/** find latest run dir under integration/runs/ */
  542 +function _findLatestIntegrationRunDir(pid) {
  543 + const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs');
  544 + if (!fs.existsSync(dir)) return null;
  545 + const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory());
  546 + if (!ids.length) return null;
  547 + ids.sort();
  548 + return ids[ids.length - 1];
  549 +}
  550 +
  551 +function _readJsonSafe(p) {
  552 + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
  553 +}
  554 +
  555 +/**
  556 + * runProject — P3 主循环 (mock 模式)。
  557 + *
  558 + * 流程:
  559 + * while not done and not blocked:
  560 + * 1. 按 DAG wave 跑 L1 (per-unit runUnit)
  561 + * 2. 若 l2GateOpen → 跑 Integrator
  562 + * 3. result.ok=true → 项目完成
  563 + * 4. result.ok=false → Triager → dispatch:
  564 + * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json)
  565 + * → l2_rounds++; 超 l2_budget → l2_exhausted
  566 + * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算
  567 + * - infra_flake → 直接重跑 (不 reset, 不烧预算)
  568 + * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed
  569 + *
  570 + * @param {string} pid
  571 + * @param {object} opts
  572 + * - mockFixtureDir?: string (P3 scenario 测试用)
  573 + * - maxRetries?: number (safety net 防 mock 死循环;默认 10)
  574 + * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history }
  575 + */
  576 +async function runProject(pid, opts = {}) {
  577 + // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续,
  578 + // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。
  579 + // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。
  580 + const projectRoot = pathResolver.resolveProjectRoot(pid);
  581 + const history = [];
  582 + const callOpts = {
  583 + mock: opts.mock !== false,
  584 + mockFixtureDir: opts.mockFixtureDir,
  585 + resetMockSequence: false, // 一次 runProject 只 reset 一次
  586 + };
  587 + const safety = opts.maxRetries || 10;
  588 + let iterations = 0;
  589 +
  590 + // SSE start
  591 + sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() });
  592 +
  593 + while (iterations < safety) {
  594 + iterations++;
  595 +
  596 + // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ────────
  597 + const allUnits = unitRegistry.list(pid);
  598 + const pendingUnits = allUnits.filter(u => u.status === 'pending');
  599 + if (pendingUnits.length > 0) {
  600 + const waves = _groupUnitsByWave(pendingUnits);
  601 + for (let w = 0; w < waves.length; w++) {
  602 + const wave = waves[w];
  603 + sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave });
  604 + // 串行跑(mock 同步快,不需并行)
  605 + for (const uid of wave) {
  606 + const r = await runUnit(pid, uid, { ...callOpts });
  607 + history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts });
  608 + if (!r.ok) {
  609 + sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts });
  610 + _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` });
  611 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` };
  612 + }
  613 + }
  614 + }
  615 + }
  616 +
  617 + // ── (2) L2 gate ────────────────────────────────────────────
  618 + if (!unitRegistry.l2GateOpen(pid)) {
  619 + sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' });
  620 + _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' });
  621 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' };
  622 + }
  623 +
  624 + // ── (3) 跑 Integrator ──────────────────────────────────────
  625 + sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) });
  626 + const integ = await callAgent(pid, 'devloop-integrator',
  627 + `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`,
  628 + callOpts);
  629 + const runId = _findLatestIntegrationRunDir(pid);
  630 + const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null;
  631 + history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed });
  632 + sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed });
  633 +
  634 + // ── (4) 成功 → 项目完成 ────────────────────────────────────
  635 + if (intResult?.ok === true) {
  636 + // 若有未关 bug → 转 fixed
  637 + const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] });
  638 + for (const b of openBugs) {
  639 + try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); }
  640 + catch { /* skip invalid transitions */ }
  641 + }
  642 + sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId });
  643 + try {
  644 + timelineLogger.append(pid, {
  645 + source: 'system', kind: 'project.shipped', ref: runId,
  646 + summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`,
  647 + });
  648 + } catch {}
  649 + _setProjectStatus(pid, 'shipped', { run_id: runId });
  650 + return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId };
  651 + }
  652 +
  653 + // ── (5) 失败 → 开 bug + 调 Triager ──────────────────────────
  654 + const bug = bugTracker.open(pid, {
  655 + source: 'integration_test',
  656 + run_id: runId,
  657 + title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`,
  658 + severity: intResult?.crashed ? 'high' : 'medium',
  659 + category: 'unknown',
  660 + detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200),
  661 + affected_units: intResult?.failures?.[0]?.involves || [],
  662 + by: 'devloop-integrator',
  663 + });
  664 + bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' });
  665 +
  666 + await callAgent(pid, 'devloop-triager',
  667 + `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`,
  668 + callOpts);
  669 + const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null;
  670 + history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence });
  671 + sseBroker.emit(pid, 'triage.result', {
  672 + run_id: runId,
  673 + category: triage?.category,
  674 + confidence: triage?.confidence,
  675 + affected_units: triage?.affected_units || [],
  676 + });
  677 +
  678 + if (!triage) {
  679 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId });
  680 + _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' });
  681 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' };
  682 + }
  683 +
  684 + // ── (6) 分支调度 ────────────────────────────────────────────
  685 + const lowConfidence = (triage.confidence ?? 0) < 0.5;
  686 + const category = triage.category;
  687 +
  688 + if (lowConfidence || category === 'spec_ambiguous') {
  689 + sseBroker.emit(pid, 'human_intervention_needed', {
  690 + reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous',
  691 + run_id: runId, category, confidence: triage.confidence,
  692 + });
  693 + _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' });
  694 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` };
  695 + }
  696 +
  697 + if (category === 'infra_flake') {
  698 + // 不 reset 不烧预算,直接下一轮重跑 Integrator
  699 + history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' });
  700 + continue;
  701 + }
  702 +
  703 + if (category === 'impl_bug') {
  704 + // reset affected units (不烧 l2_budget)
  705 + const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u));
  706 + if (affected.length === 0) {
  707 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId });
  708 + _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' });
  709 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history };
  710 + }
  711 + unitRegistry.reset(pid, affected);
  712 + history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected });
  713 + sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' });
  714 + continue;
  715 + }
  716 +
  717 + if (category === 'contract_mismatch') {
  718 + // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++)
  719 + // 预检 l2_budget
  720 + const currentRounds = _l2Rounds(pid);
  721 + const l2Budget = _l2Budget(pid);
  722 + if (currentRounds + 1 > l2Budget) {
  723 + sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget });
  724 + _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' });
  725 + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history };
  726 + }
  727 + const oldSddRev = _readSddRev(pid);
  728 + await callAgent(pid, 'devloop-architect',
  729 + `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`,
  730 + callOpts);
  731 + const newSddRev = _readSddRev(pid);
  732 + if (newSddRev > oldSddRev) {
  733 + try {
  734 + changelogWriter.append(pid, {
  735 + kind: 'sdd_rev',
  736 + rev: newSddRev,
  737 + by: 'devloop-architect',
  738 + trigger: 'l2_triage',
  739 + summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`,
  740 + diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] },
  741 + });
  742 + } catch { /* swallow */ }
  743 + sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` });
  744 + }
  745 + history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev });
  746 + continue;
  747 + }
  748 +
  749 + // Unknown category
  750 + sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category });
  751 + _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' });
  752 + return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` };
  753 + }
  754 +
  755 + // safety net 超过
  756 + sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations });
  757 + _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' });
  758 + return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history };
  759 +}
  760 +
  761 +/** Status set that swallows invalid-transition errors (already in target state etc.) */
  762 +function _safeSetStatus(pid, to, extra) {
  763 + try { return _setProjectStatus(pid, to, extra); }
  764 + catch (e) {
  765 + if (e.name === 'InvalidProjectStatusTransition') return null;
  766 + throw e;
  767 + }
  768 +}
  769 +
  770 +function _l2Rounds(pid) {
  771 + const u = docStore.readUnits(pid);
  772 + return u?.l2_rounds || 0;
  773 +}
  774 +function _l2Budget(pid) {
  775 + const u = docStore.readUnits(pid);
  776 + return u?.l2_budget || 3;
  777 +}
  778 +function _readSddRev(pid) {
  779 + const root = pathResolver.resolveProjectRoot(pid);
  780 + const idx = path.join(root, 'sdd', 'index.html');
  781 + if (!fs.existsSync(idx)) return 0;
  782 + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
  783 + if (m) return parseInt(m[1], 10);
  784 + // fallback: try mvcp/control.html (where patch goes)
  785 + const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html');
  786 + if (fs.existsSync(ctrl)) {
  787 + const m2 = fs.readFileSync(ctrl, 'utf8').match(/<article[^>]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
  788 + if (m2) return parseInt(m2[1], 10);
  789 + }
  790 + return 1;
  791 +}
  792 +
  793 +// ──────────────────────────────────────────────────────────────
  794 +// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback
  795 +// ──────────────────────────────────────────────────────────────
  796 +
  797 +const STATUS_TRANSITIONS = {
  798 + draft: ['running', 'abandoned'],
  799 + running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'],
  800 + l2_blocked: ['running', 'abandoned'],
  801 + l2_exhausted: ['running', 'abandoned'],
  802 + shipped: ['running', 'shipped-final', 'abandoned'],
  803 + 'shipped-final': [], // 终态
  804 + abandoned: [], // 终态
  805 +};
  806 +
  807 +class InvalidProjectStatusTransition extends Error {
  808 + constructor(from, to) {
  809 + super(`Invalid project status transition: ${from} → ${to}`);
  810 + this.name = 'InvalidProjectStatusTransition';
  811 + this.from = from;
  812 + this.to = to;
  813 + }
  814 +}
  815 +
  816 +function _readProjectMeta(pid) {
  817 + const p = pathResolver.resolveProjectJson(pid);
  818 + if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`);
  819 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  820 +}
  821 +
  822 +function _writeProjectMeta(pid, meta) {
  823 + fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2));
  824 +}
  825 +
  826 +/**
  827 + * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。
  828 + * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。
  829 + */
  830 +function _setProjectStatus(pid, to, extra = {}) {
  831 + const meta = _readProjectMeta(pid);
  832 + const from = meta.status || 'draft';
  833 + if (from === to) return meta; // idempotent
  834 + const allowed = STATUS_TRANSITIONS[from] || [];
  835 + if (!allowed.includes(to)) {
  836 + throw new InvalidProjectStatusTransition(from, to);
  837 + }
  838 + meta.status = to;
  839 + meta.status_updated_at = new Date().toISOString();
  840 + if (extra.note) meta.status_note = extra.note;
  841 + _writeProjectMeta(pid, meta);
  842 +
  843 + try {
  844 + sseBroker.emit(pid, 'project.status', { from, to, ...extra });
  845 + timelineLogger.append(pid, {
  846 + source: extra.by || 'system',
  847 + kind: 'project.status',
  848 + ref: pid,
  849 + summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`,
  850 + payload: { from, to, ...extra },
  851 + });
  852 + } catch { /* swallow */ }
  853 + return meta;
  854 +}
  855 +
  856 +/**
  857 + * Sign Off — 把已 shipped 的项目锁为 shipped-final。
  858 + * 之后再发 feedback 会被拒绝。
  859 + */
  860 +async function signOff(pid, opts = {}) {
  861 + const meta = _readProjectMeta(pid);
  862 + if (meta.status !== 'shipped') {
  863 + throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final');
  864 + }
  865 + _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' });
  866 + // sign-off commit (留 git 痕)
  867 + try {
  868 + await gitVault.commit(pid, {
  869 + kind: 'chore',
  870 + summary: `chore: sign-off project ${pid}`,
  871 + author: { name: opts.by || 'human', email: 'human@devloop.local' },
  872 + });
  873 + } catch { /* swallow if nothing to commit */ }
  874 + return _readProjectMeta(pid);
  875 +}
  876 +
  877 +/**
  878 + * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。
  879 + */
  880 +async function abandon(pid, opts = {}) {
  881 + const meta = _readProjectMeta(pid);
  882 + if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
  883 + throw new InvalidProjectStatusTransition(meta.status, 'abandoned');
  884 + }
  885 + _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note });
  886 + try {
  887 + await gitVault.commit(pid, {
  888 + kind: 'chore',
  889 + summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`,
  890 + author: { name: opts.by || 'human', email: 'human@devloop.local' },
  891 + });
  892 + } catch { /* swallow */ }
  893 + // 关闭所有 SSE 订阅(close 帧)
  894 + try { sseBroker.close(pid); } catch { /* ignore */ }
  895 + return _readProjectMeta(pid);
  896 +}
  897 +
  898 +/**
  899 + * Submit human feedback — 触发 PM incremental 模式。
  900 + * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。
  901 + * shipped-final / abandoned → 拒绝。
  902 + */
  903 +async function submitFeedback(pid, text, opts = {}) {
  904 + if (!text || typeof text !== 'string') {
  905 + throw new Error('feedback text required');
  906 + }
  907 + const meta = _readProjectMeta(pid);
  908 + if (meta.status === 'shipped-final' || meta.status === 'abandoned') {
  909 + throw new InvalidProjectStatusTransition(meta.status, 'feedback');
  910 + }
  911 +
  912 + const prdRevBefore = _readPrdRev(pid);
  913 + const pm = await callAgent(pid, 'devloop-pm',
  914 + `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.`,
  915 + { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir },
  916 + );
  917 + const prdRevAfter = _readPrdRev(pid);
  918 +
  919 + // 若 PRD rev bumped → 写 changelog
  920 + if (prdRevAfter > prdRevBefore) {
  921 + try {
  922 + changelogWriter.append(pid, {
  923 + kind: 'prd_rev',
  924 + rev: prdRevAfter,
  925 + by: opts.by || 'human',
  926 + trigger: 'human_feedback',
  927 + summary: `PRD bumped to rev ${prdRevAfter} via human feedback`,
  928 + diff: { feedback_excerpt: text.slice(0, 120) },
  929 + });
  930 + sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' });
  931 + } catch { /* swallow */ }
  932 + }
  933 +
  934 + // 若当前是 shipped, 改回 running (人类反馈触发新一轮)
  935 + if (meta.status === 'shipped') {
  936 + _setProjectStatus(pid, 'running', { reason: 'human_feedback' });
  937 + }
  938 +
  939 + return {
  940 + ok: true,
  941 + pid,
  942 + files_written: pm.files_written,
  943 + prd_rev_before: prdRevBefore,
  944 + prd_rev_after: prdRevAfter,
  945 + status: _readProjectMeta(pid).status,
  946 + };
  947 +}
  948 +
  949 +/**
  950 + * 扫项目根递归收集 (relPath → mtimeMs) Map。
  951 + * 忽略 .git / node_modules / agent-state/snapshots (高频写) 子树。
  952 + * real 模式 callAgent 用这个做 pre/post 快照,自动识别 Agent 写出来的新文件。
  953 + */
  954 +function _snapshotMtimes(root) {
  955 + const map = new Map();
  956 + const IGNORE_DIRS = new Set(['.git', 'node_modules', '.cache', '.tmp']);
  957 + function walk(dir, prefix) {
  958 + let entries;
  959 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  960 + catch { return; }
  961 + for (const e of entries) {
  962 + if (IGNORE_DIRS.has(e.name)) continue;
  963 + const rel = prefix ? `${prefix}/${e.name}` : e.name;
  964 + const full = path.join(dir, e.name);
  965 + if (e.isDirectory()) {
  966 + walk(full, rel);
  967 + } else if (e.isFile()) {
  968 + try {
  969 + map.set(rel, fs.statSync(full).mtimeMs);
  970 + } catch { /* ignore */ }
  971 + }
  972 + }
  973 + }
  974 + walk(root, '');
  975 + return map;
  976 +}
  977 +
  978 +function _readPrdRev(pid) {
  979 + const root = pathResolver.resolveProjectRoot(pid);
  980 + const idx = path.join(root, 'prd', 'index.html');
  981 + if (!fs.existsSync(idx)) return 0;
  982 + const m = fs.readFileSync(idx, 'utf8').match(/<article[^>]*data-doc="prd"[^>]*data-rev="(\d+)"/);
  983 + return m ? parseInt(m[1], 10) : 1;
  984 +}
  985 +
467 986 module.exports = {
468 987 createProject,
469 988 commitAfterAgent,
470 989 callAgent,
471 990 runDocsPhase,
472 991 runUnit,
  992 + runProject,
473 993 getProjectState,
  994 + // P4
  995 + signOff,
  996 + abandon,
  997 + submitFeedback,
  998 + InvalidProjectStatusTransition,
474 999 };
... ...
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 +});
... ...
server/tests/devloop/path-resolver.test.js
... ... @@ -109,13 +109,54 @@ describe(&#39;pathResolver&#39;, () =&gt; {
109 109 });
110 110 });
111 111  
112   - describe('allocateProjectRoot · linked-existing rejected in v1', () => {
113   - it('throws NOT_IMPLEMENTED', () => {
  112 + describe('allocateProjectRoot · linked-existing', () => {
  113 + it('rejects nonexistent target_path', () => {
  114 + const fake = path.join(os.tmpdir(), 'devloop-le-nope-' + Math.random().toString(36).slice(2,8));
114 115 expect(() =>
115 116 pathResolver.allocateProjectRoot({
116   - company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp',
  117 + company_id: 'c-test', mode: 'linked-existing', target_path: fake,
117 118 })
118   - ).toThrow(/v2/);
  119 + ).toThrow(/does not exist/);
  120 + });
  121 +
  122 + it('rejects target_path that already contains a DevLoop project.json', () => {
  123 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-'));
  124 + fs.writeFileSync(path.join(dir, 'project.json'), '{}');
  125 + expect(() =>
  126 + pathResolver.allocateProjectRoot({
  127 + company_id: 'c-test', mode: 'linked-existing', target_path: dir,
  128 + })
  129 + ).toThrow(/already contains/);
  130 + });
  131 +
  132 + it('accepts existing non-empty plain dir (no .git)', () => {
  133 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-'));
  134 + fs.writeFileSync(path.join(dir, 'README.md'), '# user');
  135 + const r = pathResolver.allocateProjectRoot({
  136 + company_id: 'c-test', mode: 'linked-existing', target_path: dir,
  137 + });
  138 + expect(r.pid).toMatch(/^p-/);
  139 + expect(r.root_path).toBe(dir);
  140 + expect(r.mode).toBe('linked-existing');
  141 + pathResolver.unregisterLinkedProject(r.pid);
  142 + });
  143 +
  144 + it('rejects existing git repo with dirty worktree', () => {
  145 + const { execFileSync } = require('child_process');
  146 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-'));
  147 + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
  148 + execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir });
  149 + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
  150 + fs.writeFileSync(path.join(dir, 'a.txt'), 'one');
  151 + execFileSync('git', ['add', '-A'], { cwd: dir });
  152 + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
  153 + // dirty: untracked
  154 + fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked');
  155 + expect(() =>
  156 + pathResolver.allocateProjectRoot({
  157 + company_id: 'c-test', mode: 'linked-existing', target_path: dir,
  158 + })
  159 + ).toThrow(/uncommitted changes/);
119 160 });
120 161 });
121 162  
... ...
server/tests/devloop/routes-crud.test.js 0 → 100644
  1 +/**
  2 + * routes/devloop.js · 项目 CRUD 路由测试
  3 + *
  4 + * 覆盖 (新增的) PATCH / DELETE (soft + hard) / restore + listProjects 过滤。
  5 + */
  6 +
  7 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  8 +
  9 +const fs = require('fs');
  10 +const path = require('path');
  11 +const os = require('os');
  12 +const express = require('express');
  13 +
  14 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-crud-'));
  15 +process.env.HOME = TEST_HOME;
  16 +
  17 +const pathResolver = require('../../services/devloop/path-resolver');
  18 +const devloopRouter = require('../../routes/devloop');
  19 +
  20 +function startApp() {
  21 + const app = express();
  22 + app.use(express.json());
  23 + app.use('/api/devloop', devloopRouter);
  24 + return new Promise((resolve) => {
  25 + const srv = app.listen(0, '127.0.0.1', () => {
  26 + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
  27 + });
  28 + });
  29 +}
  30 +
  31 +async function createProject(base, cid, requirement = 'crud test') {
  32 + const r = await fetch(`${base}/api/devloop/projects`, {
  33 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  34 + body: JSON.stringify({ company_id: cid, requirement }),
  35 + });
  36 + const j = await r.json();
  37 + return j.pid;
  38 +}
  39 +
  40 +describe('routes/devloop · CRUD', () => {
  41 + let app, base;
  42 + const cids = [];
  43 +
  44 + beforeAll(async () => {
  45 + pathResolver._resetForTesting();
  46 + ({ srv: app, base } = await startApp());
  47 + });
  48 +
  49 + afterAll(() => {
  50 + app.close();
  51 + for (const c of cids) {
  52 + try {
  53 + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
  54 + } catch { /* ignore */ }
  55 + }
  56 + });
  57 +
  58 + describe('PATCH /projects/:pid', () => {
  59 + let pid, cid;
  60 + beforeAll(async () => {
  61 + cid = 'c-patch-' + Math.random().toString(36).slice(2, 7);
  62 + cids.push(cid);
  63 + pid = await createProject(base, cid);
  64 + });
  65 +
  66 + it('updates name + idea_summary', async () => {
  67 + const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
  68 + method: 'PATCH', headers: { 'Content-Type': 'application/json' },
  69 + body: JSON.stringify({ name: 'renamed project', idea_summary: 'new summary' }),
  70 + });
  71 + expect(r.status).toBe(200);
  72 + const j = await r.json();
  73 + expect(j.project.name).toBe('renamed project');
  74 + expect(j.project.idea_summary).toBe('new summary');
  75 + });
  76 +
  77 + it('rejects empty body (no allowed fields)', async () => {
  78 + const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
  79 + method: 'PATCH', headers: { 'Content-Type': 'application/json' },
  80 + body: JSON.stringify({}),
  81 + });
  82 + expect(r.status).toBe(400);
  83 + });
  84 +
  85 + it('rejects non-string fields', async () => {
  86 + const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
  87 + method: 'PATCH', headers: { 'Content-Type': 'application/json' },
  88 + body: JSON.stringify({ name: 123 }),
  89 + });
  90 + expect(r.status).toBe(400);
  91 + });
  92 +
  93 + it('ignores fields not in allowlist (status / archived not changeable here)', async () => {
  94 + const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
  95 + method: 'PATCH', headers: { 'Content-Type': 'application/json' },
  96 + body: JSON.stringify({ status: 'shipped-final', archived: true }),
  97 + });
  98 + expect(r.status).toBe(400); // 没有任何 allowed 字段 → 400
  99 + });
  100 +
  101 + it('404 on unknown pid', async () => {
  102 + const r = await fetch(`${base}/api/devloop/projects/p-nope`, {
  103 + method: 'PATCH', headers: { 'Content-Type': 'application/json' },
  104 + body: JSON.stringify({ name: 'x' }),
  105 + });
  106 + expect(r.status).toBe(404);
  107 + });
  108 + });
  109 +
  110 + describe('DELETE soft (archive) + GET list', () => {
  111 + let pidA, pidB, cid;
  112 + beforeAll(async () => {
  113 + cid = 'c-del-' + Math.random().toString(36).slice(2, 7);
  114 + cids.push(cid);
  115 + pidA = await createProject(base, cid, 'kept');
  116 + pidB = await createProject(base, cid, 'will be archived');
  117 + });
  118 +
  119 + it('soft delete sets archived=true (default no hard query)', async () => {
  120 + const r = await fetch(`${base}/api/devloop/projects/${pidB}`, { method: 'DELETE' });
  121 + expect(r.status).toBe(200);
  122 + const j = await r.json();
  123 + expect(j.hard).toBe(false);
  124 + expect(j.archived).toBe(true);
  125 + expect(j.project.archived).toBe(true);
  126 + });
  127 +
  128 + it('default listProjects excludes archived', async () => {
  129 + const r = await fetch(`${base}/api/devloop/projects`);
  130 + const j = await r.json();
  131 + const ids = j.projects.map(p => p.pid);
  132 + expect(ids).toContain(pidA);
  133 + expect(ids).not.toContain(pidB);
  134 + });
  135 +
  136 + it('?include_archived=true returns archived too', async () => {
  137 + const r = await fetch(`${base}/api/devloop/projects?include_archived=true`);
  138 + const j = await r.json();
  139 + const ids = j.projects.map(p => p.pid);
  140 + expect(ids).toContain(pidA);
  141 + expect(ids).toContain(pidB);
  142 + const arch = j.projects.find(p => p.pid === pidB);
  143 + expect(arch.archived).toBe(true);
  144 + });
  145 +
  146 + it('POST /restore un-archives', async () => {
  147 + const r = await fetch(`${base}/api/devloop/projects/${pidB}/restore`, { method: 'POST' });
  148 + expect(r.status).toBe(200);
  149 + const j = await r.json();
  150 + expect(j.archived).toBe(false);
  151 + // 现在默认 list 应能见到
  152 + const list = await (await fetch(`${base}/api/devloop/projects`)).json();
  153 + expect(list.projects.map(p => p.pid)).toContain(pidB);
  154 + });
  155 + });
  156 +
  157 + describe('DELETE hard', () => {
  158 + it('?hard=true rm -rf 项目目录 (sandbox)', async () => {
  159 + const cid = 'c-hard-' + Math.random().toString(36).slice(2, 7);
  160 + cids.push(cid);
  161 + const pid = await createProject(base, cid, 'hard delete me');
  162 + const rootBefore = pathResolver.resolveProjectRoot(pid);
  163 + expect(fs.existsSync(rootBefore)).toBe(true);
  164 +
  165 + const r = await fetch(`${base}/api/devloop/projects/${pid}?hard=true`, { method: 'DELETE' });
  166 + expect(r.status).toBe(200);
  167 + const j = await r.json();
  168 + expect(j.hard).toBe(true);
  169 + expect(j.removed_disk).toBe(true);
  170 + expect(fs.existsSync(rootBefore)).toBe(false);
  171 +
  172 + // 后续读 state 应 404 (pid 已从 cache 移除)
  173 + const s = await fetch(`${base}/api/devloop/projects/${pid}/state`);
  174 + expect(s.status).toBe(404);
  175 + });
  176 +
  177 + it('hard delete unknown pid → 404', async () => {
  178 + const r = await fetch(`${base}/api/devloop/projects/p-nope?hard=true`, { method: 'DELETE' });
  179 + expect(r.status).toBe(404);
  180 + });
  181 + });
  182 +
  183 + describe('GET /projects?include_archived=false (default)', () => {
  184 + it('returns projects array (could be empty)', async () => {
  185 + const r = await fetch(`${base}/api/devloop/projects`);
  186 + expect(r.status).toBe(200);
  187 + const j = await r.json();
  188 + expect(Array.isArray(j.projects)).toBe(true);
  189 + });
  190 + });
  191 +});
... ...
server/tests/devloop/routes.test.js
... ... @@ -106,9 +106,13 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
106 106 expect(s.units).toHaveLength(3);
107 107 });
108 108  
109   - it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => {
110   - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' });
111   - expect(r.status).toBe(501);
  109 + it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => {
  110 + const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, {
  111 + method: 'POST',
  112 + headers: { 'Content-Type': 'application/json' },
  113 + body: JSON.stringify({}),
  114 + });
  115 + expect(r.status).toBe(404);
112 116 });
113 117  
114 118 it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
... ... @@ -133,6 +137,32 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
133 137 expect(j2.history).toHaveLength(4);
134 138 });
135 139  
  140 + it('POST /run (full project, no ?only) drives runProject', async () => {
  141 + const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7);
  142 + created.push(cid);
  143 + const r1 = await fetch(`${base}/api/devloop/projects`, {
  144 + method: 'POST',
  145 + headers: { 'Content-Type': 'application/json' },
  146 + body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }),
  147 + });
  148 + const pid = (await r1.json()).pid;
  149 + // ship via full project run (baseline fixtures = happy path)
  150 + const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, {
  151 + method: 'POST',
  152 + headers: { 'Content-Type': 'application/json' },
  153 + body: JSON.stringify({}),
  154 + });
  155 + expect(r2.status).toBe(200);
  156 + const j = await r2.json();
  157 + expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state);
  158 + expect(typeof j.l2_rounds).toBe('number');
  159 + });
  160 +
  161 + it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => {
  162 + const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`);
  163 + expect(r.status).toBe(404);
  164 + });
  165 +
136 166 it('POST /run?only on unknown unit returns 404', async () => {
137 167 const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7);
138 168 created.push(cid);
... ... @@ -159,4 +189,52 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
159 189 const j = await r.json();
160 190 expect(Array.isArray(j.projects)).toBe(true);
161 191 });
  192 +
  193 + // P5 — UI-supporting endpoints
  194 + describe('P5 UI endpoints', () => {
  195 + let pid;
  196 + beforeAll(async () => {
  197 + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7);
  198 + created.push(cid);
  199 + const r = await fetch(`${base}/api/devloop/projects`, {
  200 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  201 + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }),
  202 + });
  203 + pid = (await r.json()).pid;
  204 + });
  205 +
  206 + it('GET /timeline returns events array', async () => {
  207 + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`);
  208 + expect(r.status).toBe(200);
  209 + const j = await r.json();
  210 + expect(Array.isArray(j.events)).toBe(true);
  211 + expect(j.events.length).toBeGreaterThan(0);
  212 + });
  213 +
  214 + it('GET /changelog returns entries array', async () => {
  215 + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
  216 + expect(r.status).toBe(200);
  217 + const j = await r.json();
  218 + expect(Array.isArray(j.entries)).toBe(true);
  219 + });
  220 +
  221 + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => {
  222 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
  223 + expect(r.status).toBe(200);
  224 + const j = await r.json();
  225 + expect(j).toHaveProperty('unit');
  226 + expect(j).toHaveProperty('runs');
  227 + expect(Array.isArray(j.runs)).toBe(true);
  228 + });
  229 +
  230 + it('GET /units/:unitId 404 on unknown unit', async () => {
  231 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`);
  232 + expect(r.status).toBe(404);
  233 + });
  234 +
  235 + it('GET /timeline 404 on unknown pid', async () => {
  236 + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`);
  237 + expect(r.status).toBe(404);
  238 + });
  239 + });
162 240 });
... ...
server/tests/devloop/run-project.test.js 0 → 100644
  1 +/**
  2 + * loop-controller.runProject (P3 L2 联动) 单测
  3 + *
  4 + * 跑 4 个 L2 分支:
  5 + * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1
  6 + * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0
  7 + * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
  8 + * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0
  9 + */
  10 +
  11 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  12 +
  13 +const fs = require('fs');
  14 +const path = require('path');
  15 +const os = require('os');
  16 +
  17 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-'));
  18 +process.env.HOME = TEST_HOME;
  19 +
  20 +const pathResolver = require('../../services/devloop/path-resolver');
  21 +const sseBroker = require('../../services/devloop/sse-broker');
  22 +const bugTracker = require('../../services/devloop/bug-tracker');
  23 +const lc = require('../../src/core/devloop/loop-controller');
  24 +
  25 +const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios');
  26 +
  27 +async function setupScenario(name) {
  28 + pathResolver._resetForTesting();
  29 + sseBroker._resetForTesting();
  30 + const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7);
  31 + const dir = path.join(SCENARIO_BASE, name);
  32 + const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name });
  33 + await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
  34 + return { pid: proj.pid, cid, dir };
  35 +}
  36 +
  37 +function cleanup(cid) {
  38 + try {
  39 + fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
  40 + } catch { /* ignore */ }
  41 +}
  42 +
  43 +describe('runProject · L2 branches', () => {
  44 + describe('① contract_mismatch', () => {
  45 + let pid, cid, dir, result;
  46 + const sseEvents = [];
  47 +
  48 + beforeAll(async () => {
  49 + ({ pid, cid, dir } = await setupScenario('contract_mismatch'));
  50 + const fakeRes = {
  51 + setHeader() {}, flushHeaders() {}, end() {},
  52 + on() {}, // sse-broker subscribes res.on('close', ...)
  53 + write(line) {
  54 + const m = line.match(/^event: (.+)$/m);
  55 + if (m) sseEvents.push(m[1]);
  56 + },
  57 + };
  58 + sseBroker.subscribe(pid, fakeRes, {});
  59 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  60 + });
  61 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  62 +
  63 + it('ships after 1 L2 round', () => {
  64 + expect(result.ok).toBe(true);
  65 + expect(result.final_state).toBe('shipped');
  66 + });
  67 +
  68 + it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => {
  69 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  70 + expect(u.l2_rounds).toBe(1);
  71 + expect(u.sdd_rev).toBe('2');
  72 + });
  73 +
  74 + it('emits sdd.updated event', () => {
  75 + expect(sseEvents).toContain('sdd.updated');
  76 + });
  77 +
  78 + it('changelog has sdd_rev entry', () => {
  79 + const cl = pathResolver.resolveChangelog(pid);
  80 + expect(fs.existsSync(cl)).toBe(true);
  81 + const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
  82 + expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true);
  83 + });
  84 +
  85 + it('bug opened then transitioned to fixed', () => {
  86 + const bugs = bugTracker.list(pid, {});
  87 + expect(bugs).toHaveLength(1);
  88 + expect(bugs[0].status).toBe('fixed');
  89 + });
  90 + });
  91 +
  92 + describe('② impl_bug', () => {
  93 + let pid, cid, dir, result;
  94 +
  95 + beforeAll(async () => {
  96 + ({ pid, cid, dir } = await setupScenario('impl_bug'));
  97 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  98 + });
  99 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  100 +
  101 + it('ships without SDD change', () => {
  102 + expect(result.ok).toBe(true);
  103 + expect(result.final_state).toBe('shipped');
  104 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  105 + expect(u.l2_rounds).toBe(0);
  106 + expect(u.sdd_rev).toBe('1');
  107 + });
  108 +
  109 + it('history shows impl_bug reset_units (no sdd_patch)', () => {
  110 + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  111 + expect(actions.some(a => /impl_bug/.test(a))).toBe(true);
  112 + expect(actions.some(a => /sdd_patch/.test(a))).toBe(false);
  113 + });
  114 + });
  115 +
  116 + describe('③ spec_ambiguous', () => {
  117 + let pid, cid, dir, result;
  118 + const sseEvents = [];
  119 +
  120 + beforeAll(async () => {
  121 + ({ pid, cid, dir } = await setupScenario('spec_ambiguous'));
  122 + const fakeRes = {
  123 + setHeader() {}, flushHeaders() {}, end() {},
  124 + on() {}, // sse-broker subscribes res.on('close', ...)
  125 + write(line) {
  126 + const m = line.match(/^event: (.+)$/m);
  127 + if (m) sseEvents.push(m[1]);
  128 + },
  129 + };
  130 + sseBroker.subscribe(pid, fakeRes, {});
  131 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  132 + });
  133 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  134 +
  135 + it('returns l2_blocked', () => {
  136 + expect(result.ok).toBe(false);
  137 + expect(result.final_state).toBe('l2_blocked');
  138 + });
  139 +
  140 + it('emits human_intervention_needed SSE', () => {
  141 + expect(sseEvents).toContain('human_intervention_needed');
  142 + });
  143 + });
  144 +
  145 + describe('④ infra_flake', () => {
  146 + let pid, cid, dir, result;
  147 +
  148 + beforeAll(async () => {
  149 + ({ pid, cid, dir } = await setupScenario('infra_flake'));
  150 + result = await lc.runProject(pid, { mockFixtureDir: dir });
  151 + });
  152 + afterAll(() => { sseBroker.close(pid); cleanup(cid); });
  153 +
  154 + it('ships after re-run without budget burn', () => {
  155 + expect(result.ok).toBe(true);
  156 + expect(result.final_state).toBe('shipped');
  157 + const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  158 + expect(u.l2_rounds).toBe(0);
  159 + });
  160 +
  161 + it('history records rerun_integrator', () => {
  162 + const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
  163 + expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true);
  164 + });
  165 + });
  166 +});
... ...
start.sh
... ... @@ -37,9 +37,9 @@ for PORT in 3010 5173; do
37 37 fi
38 38 done
39 39  
40   -# 启动后端 Agent Chat Server
41   -echo "🔌 启动 Agent Server (端口 3010)..."
42   -PORT=3010 node server/agent-server.js &
  40 +# 启动后端主服务 (全量: Agent + DevLoop + Master + Skills + Arena + Auth + WS)
  41 +echo "🔌 启动 LemonBoss Server (端口 3010)..."
  42 +PORT=3010 node server/index.js &
43 43 SERVER_PID=$!
44 44  
45 45 # 等待后端启动
... ...
web/src/components/devloop/DashboardHeader.vue 0 → 100644
  1 +<template>
  2 + <header class="dl-header">
  3 + <div class="left">
  4 + <router-link to="/devloop" class="back">← 项目列表</router-link>
  5 + <h1>DevLoop · {{ pid }}</h1>
  6 + <span class="status-badge" :class="status">{{ status }}</span>
  7 + </div>
  8 + <div class="right">
  9 + <button class="btn primary" @click="$emit('new-requirement')" :disabled="terminal">+ 新增需求</button>
  10 + <button class="btn signoff" @click="$emit('signoff')" :disabled="status !== 'shipped'">Sign Off</button>
  11 + <button class="btn abandon" @click="$emit('abandon')" :disabled="terminal">Abandon</button>
  12 + <span class="sep">|</span>
  13 + <button class="btn link" @click="$emit('ops', 'bugs')">Ops →</button>
  14 + </div>
  15 + </header>
  16 +</template>
  17 +
  18 +<script setup>
  19 +import { computed } from 'vue';
  20 +const props = defineProps({
  21 + pid: { type: String, required: true },
  22 + state: { type: Object, default: null },
  23 +});
  24 +defineEmits(['new-requirement', 'signoff', 'abandon', 'ops']);
  25 +const status = computed(() => props.state?.status || props.state?.project?.status || 'unknown');
  26 +const terminal = computed(() => status.value === 'shipped-final' || status.value === 'abandoned');
  27 +</script>
  28 +
  29 +<style scoped>
  30 +.dl-header {
  31 + display:flex; align-items:center; justify-content:space-between;
  32 + padding:12px 0; border-bottom:1px solid #eee;
  33 +}
  34 +.left { display:flex; align-items:center; gap:14px; }
  35 +.right { display:flex; align-items:center; gap:8px; }
  36 +.back { color:#888; text-decoration:none; font-size:13px; }
  37 +.back:hover { color:#222; }
  38 +h1 { font-size:18px; font-weight:600; margin:0; font-family: ui-monospace, monospace; }
  39 +.status-badge {
  40 + font-size:11px; padding:3px 10px; border-radius:12px; text-transform:uppercase; font-weight:600;
  41 +}
  42 +.status-badge.draft { background:#eee; color:#666; }
  43 +.status-badge.running { background:#cce5ff; color:#004085; }
  44 +.status-badge.shipped { background:#d4edda; color:#155724; }
  45 +.status-badge.shipped-final { background:#0a6; color:#fff; }
  46 +.status-badge.abandoned { background:#222; color:#fff; }
  47 +.status-badge.l2_blocked { background:#fff3cd; color:#856404; }
  48 +.status-badge.l2_exhausted { background:#f8d7da; color:#721c24; }
  49 +.status-badge.unknown { background:#fafafa; color:#999; border:1px solid #eee; }
  50 +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; }
  51 +.btn:disabled { background:#ddd; color:#999; cursor:not-allowed; }
  52 +.btn.primary { background:#222; color:#fff; }
  53 +.btn.signoff { background:#0a6; color:#fff; }
  54 +.btn.abandon { background:#822; color:#fff; }
  55 +.btn.link { background:transparent; color:#0a6; padding:6px 0; }
  56 +.sep { color:#ccc; }
  57 +</style>
... ...
web/src/components/devloop/FeedbackModal.vue 0 → 100644
  1 +<template>
  2 + <div class="modal-backdrop" @click.self="$emit('close')">
  3 + <div class="modal">
  4 + <header>
  5 + <h2>+ NEW REQUIREMENT</h2>
  6 + <button class="x" @click="$emit('close')">×</button>
  7 + </header>
  8 + <p class="hint">提交一段新的需求 / 反馈 → PM 进 incremental 模式,追加 PRD 章节并 bump rev。</p>
  9 + <textarea
  10 + v-model="text"
  11 + :disabled="submitting"
  12 + placeholder="例如:增加 toggle done 功能;或:把 POST /todos 改成支持 text+priority"
  13 + />
  14 + <div v-if="err" class="err">{{ err }}</div>
  15 + <div v-if="ok" class="ok">{{ ok }}</div>
  16 + <footer>
  17 + <button class="btn cancel" @click="$emit('close')" :disabled="submitting">Cancel</button>
  18 + <button class="btn submit" @click="submit" :disabled="!text.trim() || submitting">{{ submitting ? '...' : 'Submit' }}</button>
  19 + </footer>
  20 + </div>
  21 + </div>
  22 +</template>
  23 +
  24 +<script setup>
  25 +import { ref } from 'vue';
  26 +const props = defineProps({ pid: { type: String, required: true } });
  27 +const emit = defineEmits(['close', 'submitted']);
  28 +
  29 +const text = ref('');
  30 +const submitting = ref(false);
  31 +const err = ref('');
  32 +const ok = ref('');
  33 +
  34 +async function submit() {
  35 + if (!text.value.trim()) return;
  36 + submitting.value = true;
  37 + err.value = ''; ok.value = '';
  38 + try {
  39 + const r = await fetch(`/api/devloop/projects/${props.pid}/feedback`, {
  40 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  41 + body: JSON.stringify({ text: text.value.trim(), by: 'human' }),
  42 + });
  43 + const j = await r.json();
  44 + if (!r.ok) {
  45 + err.value = j.error || `HTTP ${r.status}`;
  46 + } else {
  47 + ok.value = `PRD bumped: rev ${j.prd_rev_before} → ${j.prd_rev_after} · status=${j.status}`;
  48 + setTimeout(() => emit('submitted'), 800);
  49 + }
  50 + } catch (e) {
  51 + err.value = e.message;
  52 + } finally {
  53 + submitting.value = false;
  54 + }
  55 +}
  56 +</script>
  57 +
  58 +<style scoped>
  59 +.modal-backdrop {
  60 + position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:1000;
  61 + display:flex; align-items:center; justify-content:center;
  62 +}
  63 +.modal {
  64 + background:#fff; border-radius:8px; width:560px; max-width:90vw;
  65 + padding:20px 24px; box-shadow:0 8px 32px rgba(0,0,0,.2);
  66 +}
  67 +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
  68 +h2 { margin:0; font-size:16px; }
  69 +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; line-height:1; }
  70 +.hint { color:#888; font-size:12px; margin:0 0 12px 0; }
  71 +textarea {
  72 + width:100%; min-height:120px; padding:10px; box-sizing:border-box;
  73 + border:1px solid #ccc; border-radius:4px; font-family:inherit; resize:vertical;
  74 +}
  75 +.err { color:#c00; font-size:12px; margin-top:8px; }
  76 +.ok { color:#070; font-size:12px; margin-top:8px; }
  77 +footer { margin-top:12px; display:flex; justify-content:flex-end; gap:8px; }
  78 +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; }
  79 +.btn:disabled { background:#ddd !important; color:#999 !important; cursor:not-allowed; }
  80 +.btn.cancel { background:#eee; color:#222; }
  81 +.btn.submit { background:#222; color:#fff; }
  82 +</style>
... ...
web/src/components/devloop/LiveAgentStream.vue 0 → 100644
  1 +<template>
  2 + <div class="stream">
  3 + <div class="bar">
  4 + <span class="conn" :class="status">● {{ status }}</span>
  5 + <span class="lei" v-if="lastEventId">last-event-id: {{ lastEventId }}</span>
  6 + <span class="grow" />
  7 + <button class="btn" @click="clear">clear</button>
  8 + </div>
  9 + <div ref="logEl" class="log">
  10 + <div v-for="(e, i) in events" :key="i" class="evt">
  11 + <span class="ts">{{ formatTs(e.ts) }}</span>
  12 + <span class="kind">[{{ e.kind }}]</span>
  13 + <span class="payload">{{ e.payload }}</span>
  14 + </div>
  15 + <div v-if="!events.length" class="empty">waiting for events…</div>
  16 + </div>
  17 + </div>
  18 +</template>
  19 +
  20 +<script setup>
  21 +import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
  22 +const props = defineProps({ pid: { type: String, required: true } });
  23 +
  24 +const events = ref([]);
  25 +const status = ref('connecting');
  26 +const lastEventId = ref(null);
  27 +const logEl = ref(null);
  28 +let es = null;
  29 +let reconnectTimer = null;
  30 +const MAX_EVENTS = 250;
  31 +
  32 +function connect() {
  33 + if (es) try { es.close(); } catch {}
  34 + status.value = 'connecting';
  35 + const url = `/api/devloop/projects/${props.pid}/stream`;
  36 + // EventSource 自带 Last-Event-ID; 用户网络断开自动重连。
  37 + // (本应用浏览器会自动从 lastEventId 续传)
  38 + try {
  39 + es = new EventSource(url);
  40 + } catch (e) {
  41 + status.value = 'error';
  42 + return;
  43 + }
  44 + es.onopen = () => { status.value = 'connected'; };
  45 + es.onerror = () => {
  46 + status.value = 'reconnecting';
  47 + // EventSource 内置 reconnect, 但浏览器不一定保证 5s 内重连;
  48 + // 手动 5s 重试兜底:断后 5s 关掉重新打开,带 Last-Event-ID 由浏览器自动注入。
  49 + try { es.close(); } catch {}
  50 + es = null;
  51 + if (reconnectTimer) clearTimeout(reconnectTimer);
  52 + reconnectTimer = setTimeout(connect, 5000);
  53 + };
  54 + // 监听几个核心事件类型(SSE event name)
  55 + const listenTo = [
  56 + 'project.status', 'project.shipped', 'project.run_started',
  57 + 'l1.wave_start', 'unit.state',
  58 + 'integration.run_start', 'integration.result',
  59 + 'triage.result', 'sdd.updated', 'prd.updated',
  60 + 'human_intervention_needed', 'project.l2_blocked', 'project.l2_exhausted',
  61 + 'units.reset', 'bug.opened', 'bug.transition',
  62 + ];
  63 + for (const k of listenTo) {
  64 + es.addEventListener(k, (ev) => addEvent(k, ev));
  65 + }
  66 + // generic message handler 兜底
  67 + es.onmessage = (ev) => addEvent(ev.type || 'message', ev);
  68 +}
  69 +
  70 +function addEvent(kind, ev) {
  71 + let payload;
  72 + try { payload = JSON.stringify(JSON.parse(ev.data)); } catch { payload = ev.data; }
  73 + if (ev.lastEventId) lastEventId.value = ev.lastEventId;
  74 + events.value.push({ ts: Date.now(), kind, payload });
  75 + if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
  76 + nextTick(() => {
  77 + if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
  78 + });
  79 +}
  80 +
  81 +function formatTs(ts) {
  82 + const d = new Date(ts);
  83 + return d.toTimeString().slice(0, 8);
  84 +}
  85 +
  86 +function clear() { events.value = []; }
  87 +
  88 +onMounted(connect);
  89 +onBeforeUnmount(() => {
  90 + if (reconnectTimer) clearTimeout(reconnectTimer);
  91 + if (es) try { es.close(); } catch {}
  92 +});
  93 +</script>
  94 +
  95 +<style scoped>
  96 +.stream { display:flex; flex-direction:column; height:240px; }
  97 +.bar { display:flex; gap:10px; align-items:center; font-size:12px; color:#666; margin-bottom:6px; }
  98 +.bar .grow { flex:1; }
  99 +.conn { font-weight:600; }
  100 +.conn.connecting { color:#888; }
  101 +.conn.connected { color:#0a6; }
  102 +.conn.reconnecting { color:#c70; }
  103 +.conn.error { color:#c00; }
  104 +.lei { font-family: ui-monospace, monospace; color:#aaa; }
  105 +.log {
  106 + flex:1; overflow-y:auto;
  107 + background:#0f1115; color:#e6e6e6;
  108 + font-family: ui-monospace, monospace; font-size:12px;
  109 + padding:10px; border-radius:6px;
  110 +}
  111 +.evt { display:flex; gap:8px; margin-bottom:4px; }
  112 +.ts { color:#7da; flex:0 0 70px; }
  113 +.kind { color:#ffce6a; flex:0 0 auto; }
  114 +.payload { color:#bbd; overflow-wrap:anywhere; flex:1; }
  115 +.empty { color:#666; }
  116 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; }
  117 +</style>
... ...
web/src/components/devloop/LoopStatusBar.vue 0 → 100644
  1 +<template>
  2 + <div class="loop-bar">
  3 + <div class="phase" :class="{active: phaseAt('docs')}">
  4 + <span class="dot" />Docs<small>PRD rev {{ state.prd_rev ?? '-' }} · SDD rev {{ state.sdd_rev ?? '-' }}</small>
  5 + </div>
  6 + <div class="arrow">→</div>
  7 + <div class="phase" :class="{active: phaseAt('l1')}">
  8 + <span class="dot" />L1 Units<small>{{ l1Summary }}</small>
  9 + </div>
  10 + <div class="arrow">→</div>
  11 + <div class="phase" :class="{active: phaseAt('l2')}">
  12 + <span class="dot" />L2 Integration<small>l2_rounds {{ l2Rounds }}/{{ l2Budget }}</small>
  13 + </div>
  14 + <div class="arrow">→</div>
  15 + <div class="phase" :class="{active: phaseAt('ship')}">
  16 + <span class="dot" />Ship<small>{{ statusLabel }}</small>
  17 + </div>
  18 + </div>
  19 +</template>
  20 +
  21 +<script setup>
  22 +import { computed } from 'vue';
  23 +const props = defineProps({ state: { type: Object, required: true } });
  24 +
  25 +const status = computed(() => props.state.status || props.state.project?.status || 'unknown');
  26 +const units = computed(() => props.state.units || []);
  27 +const l1Summary = computed(() => {
  28 + const u = units.value;
  29 + if (!u.length) return 'no units';
  30 + const passed = u.filter(x => x.status === 'passed').length;
  31 + return `${passed}/${u.length} passed`;
  32 +});
  33 +const l2Rounds = computed(() => props.state.project?.status === 'shipped' ? props.state.units_rev || 0 : props.state.units_rev || 0);
  34 +const l2Budget = computed(() => 3);
  35 +const statusLabel = computed(() => status.value);
  36 +
  37 +function phaseAt(p) {
  38 + const s = status.value;
  39 + if (p === 'docs') return s !== 'unknown' && s !== 'draft';
  40 + if (p === 'l1') return ['running', 'shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s);
  41 + if (p === 'l2') return ['shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s);
  42 + if (p === 'ship') return ['shipped', 'shipped-final'].includes(s);
  43 + return false;
  44 +}
  45 +</script>
  46 +
  47 +<style scoped>
  48 +.loop-bar { display:flex; align-items:stretch; gap:8px; }
  49 +.phase {
  50 + flex:1; padding:12px 16px; border:1px solid #e3e3e8; border-radius:6px;
  51 + display:flex; flex-direction:column; gap:4px; background:#f9f9fb;
  52 + font-weight:500; color:#999;
  53 +}
  54 +.phase.active { background:#fff; border-color:#0a6; color:#222; }
  55 +.phase small { font-weight:400; font-size:11px; color:#888; }
  56 +.phase .dot {
  57 + width:8px; height:8px; border-radius:50%; background:#ccc; display:inline-block; margin-right:6px;
  58 +}
  59 +.phase.active .dot { background:#0a6; }
  60 +.arrow { display:flex; align-items:center; color:#aaa; font-size:18px; }
  61 +</style>
... ...
web/src/components/devloop/PrdDiffPanel.vue 0 → 100644
  1 +<template>
  2 + <div class="prd-diff">
  3 + <div class="meta">
  4 + <span class="rev">PRD rev <b>{{ state?.prd_rev ?? '-' }}</b></span>
  5 + <span class="rev">SDD rev <b>{{ state?.sdd_rev ?? '-' }}</b></span>
  6 + <span class="grow" />
  7 + <button class="btn" @click="reload" :disabled="loading">{{ loading ? '...' : 'refresh' }}</button>
  8 + </div>
  9 +
  10 + <div v-if="entries.length === 0" class="empty">no changelog yet</div>
  11 +
  12 + <ul v-else class="entries">
  13 + <li v-for="e in entries.slice(0, 12)" :key="e.ts + ':' + e.kind" :class="['entry', e.kind]">
  14 + <span class="kind">{{ e.kind }}</span>
  15 + <span class="rev-num">rev {{ e.rev }}</span>
  16 + <span class="trigger">[{{ e.trigger || '?' }}]</span>
  17 + <span class="summary">{{ e.summary }}</span>
  18 + </li>
  19 + </ul>
  20 + </div>
  21 +</template>
  22 +
  23 +<script setup>
  24 +import { ref, onMounted, watch } from 'vue';
  25 +const props = defineProps({
  26 + pid: { type: String, required: true },
  27 + state: { type: Object, default: null },
  28 +});
  29 +
  30 +const entries = ref([]);
  31 +const loading = ref(false);
  32 +
  33 +async function reload() {
  34 + loading.value = true;
  35 + try {
  36 + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`);
  37 + const j = await r.json();
  38 + entries.value = j.entries || [];
  39 + } catch (e) {
  40 + /* swallow */
  41 + } finally {
  42 + loading.value = false;
  43 + }
  44 +}
  45 +
  46 +onMounted(reload);
  47 +// Refresh changelog when prd_rev or sdd_rev changes
  48 +watch(() => props.state?.prd_rev, reload);
  49 +watch(() => props.state?.sdd_rev, reload);
  50 +</script>
  51 +
  52 +<style scoped>
  53 +.prd-diff { display:flex; flex-direction:column; gap:8px; }
  54 +.meta { display:flex; align-items:center; gap:14px; font-size:12px; color:#666; }
  55 +.meta .grow { flex:1; }
  56 +.meta .rev b { color:#222; font-size:14px; }
  57 +.empty { padding:20px; color:#888; text-align:center; }
  58 +.entries { list-style:none; padding:0; margin:0; max-height:200px; overflow-y:auto; }
  59 +.entry {
  60 + display:flex; gap:10px; padding:6px 8px;
  61 + border-bottom:1px solid #f0f0f3; font-size:13px;
  62 +}
  63 +.entry:last-child { border-bottom:none; }
  64 +.entry .kind {
  65 + font-family: ui-monospace, monospace; font-size:11px; padding:2px 8px; border-radius:10px;
  66 + background:#eef; color:#446; flex:0 0 auto;
  67 +}
  68 +.entry.sdd_rev .kind { background:#ffeede; color:#a64a00; }
  69 +.entry.prd_rev .kind { background:#deecff; color:#1d4d99; }
  70 +.entry .rev-num { font-weight:600; color:#222; flex:0 0 50px; }
  71 +.entry .trigger { color:#888; font-size:11px; flex:0 0 auto; }
  72 +.entry .summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; }
  73 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; }
  74 +.btn:disabled { background:#bbb; }
  75 +</style>
... ...
web/src/components/devloop/RequirementListSection.vue 0 → 100644
  1 +<template>
  2 + <section class="req-section">
  3 + <header class="req-hdr">
  4 + <h2>需求列表</h2>
  5 + <div class="hdr-meta">
  6 + <span v-if="data" class="meta">
  7 + 共 {{ data.items.length }} 项 · 当前 rev {{ data.current_rev }} · 项目状态
  8 + <span :class="['pill', `s-${data.project_status}`]">{{ data.project_status }}</span>
  9 + </span>
  10 + <button class="primary" @click="goNew">+ 新增需求</button>
  11 + </div>
  12 + </header>
  13 +
  14 + <div v-if="error" class="err">{{ error }}</div>
  15 +
  16 + <div v-if="loading && !data" class="empty-mini">加载中…</div>
  17 + <table v-else-if="data && data.items.length" class="rt">
  18 + <thead>
  19 + <tr>
  20 + <th>rev</th><th>时间</th><th>来源</th><th>触发</th><th>需求摘要</th><th>状态</th>
  21 + </tr>
  22 + </thead>
  23 + <tbody>
  24 + <tr v-for="it in data.items" :key="it.rev" :class="{ current: it.is_current }">
  25 + <td><code>r{{ it.rev }}</code></td>
  26 + <td class="ts">{{ formatTime(it.ts) }}</td>
  27 + <td><code>{{ it.by }}</code></td>
  28 + <td>{{ humanTrigger(it.trigger) }}</td>
  29 + <td class="text-cell" :title="it.text">{{ truncate(it.text, 100) }}</td>
  30 + <td>
  31 + <span v-if="it.is_current" :class="['pill', `s-${it.status}`]">{{ it.status }}</span>
  32 + <span v-else class="pill older">已被新需求覆盖</span>
  33 + </td>
  34 + </tr>
  35 + </tbody>
  36 + </table>
  37 + <div v-else class="empty-mini">尚无需求记录。</div>
  38 + </section>
  39 +</template>
  40 +
  41 +<script setup>
  42 +import { ref, onMounted, onBeforeUnmount } from 'vue';
  43 +import { useRouter } from 'vue-router';
  44 +
  45 +const props = defineProps({ pid: { type: String, required: true } });
  46 +const router = useRouter();
  47 +
  48 +const data = ref(null);
  49 +const error = ref('');
  50 +const loading = ref(false);
  51 +let pollTimer = null;
  52 +
  53 +async function fetchList() {
  54 + loading.value = true;
  55 + try {
  56 + const r = await fetch(`/api/devloop/projects/${props.pid}/requirements`);
  57 + if (!r.ok) throw new Error(`HTTP ${r.status}`);
  58 + data.value = await r.json();
  59 + error.value = '';
  60 + } catch (e) {
  61 + error.value = e.message;
  62 + } finally {
  63 + loading.value = false;
  64 + }
  65 +}
  66 +
  67 +function goNew() {
  68 + router.push(`/devloop/${props.pid}/new-requirement`);
  69 +}
  70 +
  71 +function formatTime(iso) {
  72 + if (!iso) return '—';
  73 + try {
  74 + const d = new Date(iso);
  75 + return `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
  76 + } catch { return iso; }
  77 +}
  78 +function truncate(s, n) {
  79 + if (!s) return '—';
  80 + return s.length > n ? s.slice(0, n) + '…' : s;
  81 +}
  82 +function humanTrigger(t) {
  83 + return ({ project_create: '初始', human_feedback: '人类反馈' }[t]) || t;
  84 +}
  85 +
  86 +onMounted(() => {
  87 + fetchList();
  88 + pollTimer = setInterval(fetchList, 5000);
  89 +});
  90 +onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer); });
  91 +</script>
  92 +
  93 +<style scoped>
  94 +.req-section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; }
  95 +.req-hdr { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
  96 +.req-hdr h2 { font-size:14px; font-weight:600; margin:0; color:#555; }
  97 +.hdr-meta { display:flex; align-items:center; gap:14px; }
  98 +.meta { font-size:12px; color:#888; }
  99 +.err { color:#c00; font-size:12px; padding:8px; background:#fee; border-radius:4px; margin-bottom:8px; }
  100 +.empty-mini { color:#888; padding:24px; text-align:center; font-size:13px; }
  101 +
  102 +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:5px 14px; border-radius:5px; cursor:pointer; font-size:12px; }
  103 +button.primary:hover { background:#085; }
  104 +
  105 +.rt { width:100%; border-collapse:collapse; font-size:13px; }
  106 +.rt th, .rt td { padding:8px 10px; border-bottom:1px solid #f0f0f0; text-align:left; vertical-align:top; }
  107 +.rt th { background:#fafafa; color:#666; font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.04em; }
  108 +.rt tr.current { background:#f5fbf7; }
  109 +.rt code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-family: ui-monospace, monospace; font-size:11px; }
  110 +.rt .ts { white-space:nowrap; color:#666; font-size:12px; }
  111 +.rt .text-cell { max-width:480px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  112 +.pill { font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; background:#eee; color:#555; }
  113 +.pill.older { background:#fafafa; color:#999; font-weight:400; }
  114 +.pill.s-draft { background:#eef; color:#446; }
  115 +.pill.s-running { background:#fff3cd; color:#7a5; }
  116 +.pill.s-shipped { background:#d4f4dd; color:#0a6; }
  117 +.pill.s-shipped-final { background:#0a6; color:#fff; }
  118 +.pill.s-abandoned { background:#fbe; color:#922; }
  119 +.pill.s-l2_blocked { background:#fdd; color:#a44; }
  120 +</style>
... ...
web/src/components/devloop/UnitDrawer.vue 0 → 100644
  1 +<template>
  2 + <div class="drawer-backdrop" @click.self="$emit('close')">
  3 + <aside class="drawer">
  4 + <header>
  5 + <h2>{{ unitId }}</h2>
  6 + <button class="x" @click="$emit('close')">×</button>
  7 + </header>
  8 +
  9 + <div v-if="loading" class="loading">loading…</div>
  10 + <div v-else-if="err" class="err">{{ err }}</div>
  11 + <div v-else class="body">
  12 + <section>
  13 + <h3>Metadata</h3>
  14 + <div class="meta">
  15 + <div><label>status</label><span :class="['badge', unit?.status]">{{ unit?.status || '?' }}</span></div>
  16 + <div><label>type</label><span>{{ unit?.type || unit?.kind || '?' }}</span></div>
  17 + <div><label>retries</label><span>{{ unit?.retries ?? 0 }}/{{ unit?.retry_budget ?? '?' }}</span></div>
  18 + <div><label>contract</label><span class="hash">{{ unit?.contract_hash || '?' }}</span></div>
  19 + <div><label>depends_on</label><span>{{ (unit?.depends_on || []).join(', ') || '∅' }}</span></div>
  20 + </div>
  21 + </section>
  22 +
  23 + <section>
  24 + <h3>Recent runs ({{ runs.length }})</h3>
  25 + <ul class="runs">
  26 + <li v-for="r in runs" :key="r.run_id" :class="['run', r.result?.ok ? 'ok' : 'fail']">
  27 + <div class="row">
  28 + <span class="rid">{{ r.run_id }}</span>
  29 + <span class="dot" :class="{ok: r.result?.ok, fail: r.result?.ok === false, crashed: r.result?.crashed }" />
  30 + <span class="sum">
  31 + {{ r.result ? `${r.result.summary?.passed || 0}/${r.result.summary?.total || 0}` : 'no result' }}
  32 + </span>
  33 + </div>
  34 + <div v-for="(f, i) in (r.result?.failures || [])" :key="i" class="failure">
  35 + <code>{{ f.test || f.case_id }}</code>: {{ f.hint || f.stack?.slice(0, 100) || 'no hint' }}
  36 + </div>
  37 + </li>
  38 + <li v-if="!runs.length" class="empty">no runs yet</li>
  39 + </ul>
  40 + </section>
  41 +
  42 + <section v-if="coderLog">
  43 + <h3>coder.log (tail)</h3>
  44 + <pre class="coder-log">{{ coderLog }}</pre>
  45 + </section>
  46 + </div>
  47 + </aside>
  48 + </div>
  49 +</template>
  50 +
  51 +<script setup>
  52 +import { ref, onMounted } from 'vue';
  53 +const props = defineProps({
  54 + pid: { type: String, required: true },
  55 + unitId: { type: String, required: true },
  56 +});
  57 +defineEmits(['close']);
  58 +
  59 +const loading = ref(true);
  60 +const err = ref('');
  61 +const unit = ref(null);
  62 +const runs = ref([]);
  63 +const coderLog = ref('');
  64 +
  65 +onMounted(async () => {
  66 + try {
  67 + const r = await fetch(`/api/devloop/projects/${props.pid}/units/${props.unitId}`);
  68 + if (!r.ok) { err.value = `HTTP ${r.status}`; return; }
  69 + const j = await r.json();
  70 + unit.value = j.unit;
  71 + runs.value = j.runs || [];
  72 + coderLog.value = j.coder_log_tail || '';
  73 + } catch (e) {
  74 + err.value = e.message;
  75 + } finally {
  76 + loading.value = false;
  77 + }
  78 +});
  79 +</script>
  80 +
  81 +<style scoped>
  82 +.drawer-backdrop {
  83 + position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:900;
  84 + display:flex; justify-content:flex-end;
  85 +}
  86 +.drawer {
  87 + background:#fff; width:520px; max-width:95vw; height:100vh; overflow-y:auto;
  88 + padding:20px 24px; box-shadow:-8px 0 32px rgba(0,0,0,.15);
  89 +}
  90 +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
  91 +h2 { margin:0; font-family: ui-monospace, monospace; }
  92 +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; }
  93 +section { margin:18px 0; }
  94 +h3 { font-size:13px; color:#666; margin:0 0 8px; text-transform:uppercase; }
  95 +.loading, .err { padding:20px; text-align:center; color:#888; }
  96 +.err { color:#c00; }
  97 +.meta { display:grid; grid-template-columns: 110px 1fr; gap:6px 12px; font-size:13px; }
  98 +.meta label { color:#888; }
  99 +.meta .hash { font-family: ui-monospace, monospace; font-size:11px; color:#555; }
  100 +.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; background:#eee; }
  101 +.badge.passed { background:#d4edda; color:#155724; }
  102 +.badge.failed { background:#f8d7da; color:#721c24; }
  103 +.badge.coding { background:#fff3cd; color:#856404; }
  104 +.badge.testing { background:#cce5ff; color:#004085; }
  105 +.badge.pending { background:#eee; color:#666; }
  106 +.badge.blocked { background:#d6d8db; color:#383d41; }
  107 +.runs { list-style:none; padding:0; margin:0; }
  108 +.run { border:1px solid #eee; border-radius:4px; padding:8px 10px; margin-bottom:8px; }
  109 +.run.ok { background:#fafefa; }
  110 +.run.fail { background:#fef9f9; }
  111 +.run .row { display:flex; align-items:center; gap:10px; }
  112 +.rid { font-family: ui-monospace, monospace; font-size:12px; color:#555; }
  113 +.dot { width:8px; height:8px; border-radius:50%; background:#ccc; }
  114 +.dot.ok { background:#0a6; }
  115 +.dot.fail { background:#c00; }
  116 +.dot.crashed { background:#822; }
  117 +.sum { color:#666; font-size:12px; }
  118 +.failure { margin-top:4px; padding:4px 8px; background:#fff8f8; border-left:2px solid #c00; font-size:11px; color:#444; }
  119 +.failure code { background:#eee; padding:1px 4px; border-radius:2px; font-size:11px; }
  120 +.coder-log { background:#0f1115; color:#e6e6e6; padding:10px; border-radius:4px; font-size:11px; max-height:240px; overflow-y:auto; }
  121 +.empty { color:#888; padding:10px; }
  122 +</style>
... ...
web/src/components/devloop/UnitsDAG.vue 0 → 100644
  1 +<template>
  2 + <div class="dag-wrap">
  3 + <svg v-if="units.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H">
  4 + <defs>
  5 + <marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
  6 + <path d="M0,0 L10,5 L0,10 z" fill="#999" />
  7 + </marker>
  8 + </defs>
  9 + <line
  10 + v-for="(e, i) in edges"
  11 + :key="'e'+i"
  12 + :x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2"
  13 + stroke="#999" stroke-width="1.5" marker-end="url(#arr)"
  14 + />
  15 + <g
  16 + v-for="(n, i) in nodes"
  17 + :key="'n'+n.id"
  18 + :transform="`translate(${n.x},${n.y})`"
  19 + class="node-g"
  20 + @click="$emit('select', n.id)"
  21 + >
  22 + <rect :width="NW" :height="NH" rx="6" :class="['node', n.status]" />
  23 + <text :x="NW/2" :y="20" text-anchor="middle" class="id-label">{{ n.id }}</text>
  24 + <text :x="NW/2" :y="38" text-anchor="middle" class="status-label">{{ n.status }}</text>
  25 + </g>
  26 + </svg>
  27 + <div v-else class="empty">no units yet</div>
  28 + <p class="hint">click a unit node to inspect recent runs</p>
  29 + </div>
  30 +</template>
  31 +
  32 +<script setup>
  33 +import { computed } from 'vue';
  34 +const props = defineProps({ units: { type: Array, default: () => [] } });
  35 +defineEmits(['select']);
  36 +
  37 +const NW = 130, NH = 52, GAP_X = 40, GAP_Y = 80, PAD = 20;
  38 +
  39 +const waves = computed(() => {
  40 + const remaining = new Set(props.units.map(u => u.id));
  41 + const deps = new Map(props.units.map(u => [u.id, (u.depends_on || []).slice()]));
  42 + const out = [];
  43 + let safety = 100;
  44 + while (remaining.size > 0 && safety-- > 0) {
  45 + const wave = [];
  46 + for (const id of remaining) {
  47 + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
  48 + }
  49 + if (!wave.length) break;
  50 + for (const id of wave) remaining.delete(id);
  51 + out.push(wave);
  52 + }
  53 + return out;
  54 +});
  55 +
  56 +const nodes = computed(() => {
  57 + const out = [];
  58 + waves.value.forEach((wave, wi) => {
  59 + wave.forEach((id, ni) => {
  60 + const u = props.units.find(x => x.id === id);
  61 + out.push({
  62 + id, status: u?.status || 'unknown',
  63 + x: PAD + wi * (NW + GAP_X),
  64 + y: PAD + ni * (NH + 20),
  65 + wave: wi,
  66 + });
  67 + });
  68 + });
  69 + return out;
  70 +});
  71 +
  72 +const edges = computed(() => {
  73 + const out = [];
  74 + for (const u of props.units) {
  75 + for (const dep of (u.depends_on || [])) {
  76 + const a = nodes.value.find(n => n.id === dep);
  77 + const b = nodes.value.find(n => n.id === u.id);
  78 + if (a && b) out.push({ x1: a.x + NW, y1: a.y + NH/2, x2: b.x, y2: b.y + NH/2 });
  79 + }
  80 + }
  81 + return out;
  82 +});
  83 +
  84 +const W = computed(() => Math.max(400, PAD * 2 + waves.value.length * (NW + GAP_X) - GAP_X));
  85 +const H = computed(() => {
  86 + const maxRow = Math.max(...waves.value.map(w => w.length), 1);
  87 + return PAD * 2 + maxRow * (NH + 20) - 20;
  88 +});
  89 +</script>
  90 +
  91 +<style scoped>
  92 +.dag-wrap svg { display:block; max-width:100%; background:#fafafb; border:1px dashed #eee; border-radius:6px; }
  93 +.node-g { cursor: pointer; }
  94 +.node { fill:#fff; stroke:#888; stroke-width:1.5; }
  95 +.node:hover { stroke:#222; }
  96 +.node.pending { fill:#f5f5f5; stroke:#888; }
  97 +.node.coding { fill:#fff3cd; stroke:#856404; }
  98 +.node.testing { fill:#cce5ff; stroke:#004085; }
  99 +.node.passed { fill:#d4edda; stroke:#155724; }
  100 +.node.failed { fill:#f8d7da; stroke:#721c24; }
  101 +.node.blocked { fill:#d6d8db; stroke:#383d41; }
  102 +.node.unknown { fill:#fff; stroke:#aaa; }
  103 +.id-label { font-size:13px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; }
  104 +.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; }
  105 +.empty { padding:24px; color:#888; text-align:center; }
  106 +.hint { font-size:11px; color:#aaa; margin-top:6px; }
  107 +</style>
... ...
web/src/components/devloop/ops/BuglistView.vue 0 → 100644
  1 +<template>
  2 + <div class="bug-view">
  3 + <div class="filters">
  4 + <label>Status:
  5 + <select v-model="filterStatus" @change="reload">
  6 + <option value="">all</option>
  7 + <option>open</option><option>fixing</option>
  8 + <option>fixed</option><option>verified</option>
  9 + <option>wontfix</option><option>duplicate</option>
  10 + </select>
  11 + </label>
  12 + <span class="total">Total: {{ bugs.length }}</span>
  13 + <button class="btn" @click="reload">Refresh</button>
  14 + </div>
  15 +
  16 + <table v-if="bugs.length">
  17 + <thead>
  18 + <tr>
  19 + <th>ID</th>
  20 + <th>Status</th>
  21 + <th>Unit</th>
  22 + <th>Severity</th>
  23 + <th>Category</th>
  24 + <th>Title</th>
  25 + <th>Updated</th>
  26 + </tr>
  27 + </thead>
  28 + <tbody>
  29 + <tr v-for="b in bugs" :key="b.id" :class="b.status">
  30 + <td class="id">{{ b.id }}</td>
  31 + <td><span :class="['badge', b.status]">{{ b.status }}</span></td>
  32 + <td>{{ b.unit_id || '-' }}</td>
  33 + <td>{{ b.severity || '-' }}</td>
  34 + <td>{{ b.category || 'unknown' }}</td>
  35 + <td class="title">{{ b.title }}</td>
  36 + <td class="ts">{{ formatTs(b.last_updated_at || b.discovered_at) }}</td>
  37 + </tr>
  38 + </tbody>
  39 + </table>
  40 + <p v-else class="empty">no bugs (or none match filter)</p>
  41 + </div>
  42 +</template>
  43 +
  44 +<script setup>
  45 +import { ref, onMounted } from 'vue';
  46 +const props = defineProps({ pid: { type: String, required: true } });
  47 +
  48 +const bugs = ref([]);
  49 +const filterStatus = ref('');
  50 +
  51 +async function reload() {
  52 + const qs = filterStatus.value ? `?status=${filterStatus.value}` : '';
  53 + const r = await fetch(`/api/devloop/projects/${props.pid}/bugs${qs}`);
  54 + if (!r.ok) { bugs.value = []; return; }
  55 + const j = await r.json();
  56 + bugs.value = j.bugs || [];
  57 +}
  58 +
  59 +function formatTs(s) {
  60 + if (!s) return '-';
  61 + try { return new Date(s).toLocaleString(); } catch { return s; }
  62 +}
  63 +
  64 +onMounted(reload);
  65 +</script>
  66 +
  67 +<style scoped>
  68 +.bug-view {}
  69 +.filters { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  70 +.filters select { padding:4px 8px; }
  71 +.filters .total { color:#666; }
  72 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  73 +table { width:100%; border-collapse:collapse; font-size:13px; }
  74 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  75 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  76 +tr:hover { background:#fafafa; }
  77 +.id { font-family: ui-monospace, monospace; color:#555; font-size:11px; }
  78 +.title { color:#222; }
  79 +.ts { color:#888; font-size:11px; }
  80 +.badge {
  81 + display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase;
  82 +}
  83 +.badge.open { background:#fef0c8; color:#8d6708; }
  84 +.badge.fixing { background:#cce5ff; color:#004085; }
  85 +.badge.fixed { background:#d4edda; color:#155724; }
  86 +.badge.verified { background:#a3e0b8; color:#0a4a1d; }
  87 +.badge.wontfix { background:#ddd; color:#444; }
  88 +.badge.duplicate { background:#eee; color:#666; }
  89 +.empty { padding:30px; text-align:center; color:#888; }
  90 +</style>
... ...
web/src/components/devloop/ops/ChangelogView.vue 0 → 100644
  1 +<template>
  2 + <div class="changelog-view">
  3 + <div class="bar">
  4 + <span>Total: {{ entries.length }} changes</span>
  5 + <span class="grow" />
  6 + <button class="btn" @click="reload">Refresh</button>
  7 + </div>
  8 +
  9 + <table v-if="entries.length">
  10 + <thead>
  11 + <tr>
  12 + <th>When</th>
  13 + <th>Kind</th>
  14 + <th>Rev</th>
  15 + <th>By</th>
  16 + <th>Trigger</th>
  17 + <th>Summary</th>
  18 + </tr>
  19 + </thead>
  20 + <tbody>
  21 + <tr v-for="(e, i) in entries" :key="i">
  22 + <td class="ts">{{ formatTs(e.ts) }}</td>
  23 + <td><span :class="['k', e.kind]">{{ e.kind }}</span></td>
  24 + <td>{{ e.rev }}</td>
  25 + <td>{{ e.by || '-' }}</td>
  26 + <td class="trigger">[{{ e.trigger || '-' }}]</td>
  27 + <td class="summary">{{ e.summary }}</td>
  28 + </tr>
  29 + </tbody>
  30 + </table>
  31 + <p v-else class="empty">no changelog entries</p>
  32 + </div>
  33 +</template>
  34 +
  35 +<script setup>
  36 +import { ref, onMounted } from 'vue';
  37 +const props = defineProps({ pid: { type: String, required: true } });
  38 +const entries = ref([]);
  39 +
  40 +async function reload() {
  41 + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`);
  42 + if (!r.ok) return;
  43 + const j = await r.json();
  44 + entries.value = j.entries || [];
  45 +}
  46 +
  47 +function formatTs(s) {
  48 + if (!s) return '-';
  49 + try { return new Date(s).toLocaleString(); } catch { return s; }
  50 +}
  51 +
  52 +onMounted(reload);
  53 +</script>
  54 +
  55 +<style scoped>
  56 +.changelog-view {}
  57 +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  58 +.bar .grow { flex:1; }
  59 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  60 +table { width:100%; border-collapse:collapse; font-size:13px; }
  61 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  62 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  63 +.ts { color:#888; font-size:11px; }
  64 +.k {
  65 + display:inline-block; padding:1px 8px; border-radius:10px;
  66 + font-family: ui-monospace, monospace; font-size:11px;
  67 +}
  68 +.k.prd_rev { background:#deecff; color:#1d4d99; }
  69 +.k.sdd_rev { background:#ffeede; color:#a64a00; }
  70 +.trigger { color:#888; font-size:11px; }
  71 +.summary { color:#444; }
  72 +.empty { padding:30px; text-align:center; color:#888; }
  73 +</style>
... ...
web/src/components/devloop/ops/LoopLogView.vue 0 → 100644
  1 +<template>
  2 + <div class="loops-view">
  3 + <div class="bar">
  4 + <span>Total: {{ events.length }} events</span>
  5 + <span class="grow" />
  6 + <label>Filter:
  7 + <input v-model="filterText" placeholder="kind / summary substring" />
  8 + </label>
  9 + <button class="btn" @click="reload">Refresh</button>
  10 + </div>
  11 +
  12 + <section v-for="(grp, key) in groups" :key="key" class="group">
  13 + <h3>{{ key }} <span class="count">({{ grp.length }})</span></h3>
  14 + <ul>
  15 + <li v-for="e in grp" :key="e.ts + e.kind">
  16 + <span class="ts">{{ formatTs(e.ts) }}</span>
  17 + <span :class="['kind', e.kind.replace(/\./g, '-')]">{{ e.kind }}</span>
  18 + <span class="src">[{{ e.source || 'system' }}]</span>
  19 + <span class="summary">{{ e.summary }}</span>
  20 + </li>
  21 + </ul>
  22 + </section>
  23 +
  24 + <p v-if="!events.length" class="empty">no timeline events</p>
  25 + </div>
  26 +</template>
  27 +
  28 +<script setup>
  29 +import { ref, computed, onMounted } from 'vue';
  30 +const props = defineProps({ pid: { type: String, required: true } });
  31 +const events = ref([]);
  32 +const filterText = ref('');
  33 +
  34 +async function reload() {
  35 + const r = await fetch(`/api/devloop/projects/${props.pid}/timeline?limit=500`);
  36 + if (!r.ok) return;
  37 + const j = await r.json();
  38 + events.value = j.events || [];
  39 +}
  40 +
  41 +const filtered = computed(() => {
  42 + const q = filterText.value.toLowerCase().trim();
  43 + if (!q) return events.value;
  44 + return events.value.filter(e =>
  45 + (e.kind || '').toLowerCase().includes(q) ||
  46 + (e.summary || '').toLowerCase().includes(q));
  47 +});
  48 +
  49 +const groups = computed(() => {
  50 + const out = { 'L1 (unit)': [], 'L2 (integration / triage)': [], 'L3 (human / status)': [], 'System / Docs / Git': [] };
  51 + for (const e of filtered.value) {
  52 + const k = e.kind || '';
  53 + if (/^unit\.|^L1/.test(k)) out['L1 (unit)'].push(e);
  54 + else if (/integration|triage|sdd\.|units\.reset|l2_/.test(k)) out['L2 (integration / triage)'].push(e);
  55 + else if (/^project\.status|^project\.shipped|^project\.abandoned|human_intervention|prd\.updated/.test(k)) out['L3 (human / status)'].push(e);
  56 + else out['System / Docs / Git'].push(e);
  57 + }
  58 + return out;
  59 +});
  60 +
  61 +function formatTs(ts) {
  62 + if (!ts) return '-';
  63 + try { return new Date(ts).toLocaleTimeString(); } catch { return String(ts); }
  64 +}
  65 +
  66 +onMounted(reload);
  67 +</script>
  68 +
  69 +<style scoped>
  70 +.loops-view {}
  71 +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  72 +.bar .grow { flex:1; }
  73 +.bar input { padding:4px 8px; border:1px solid #ccc; border-radius:4px; width:200px; }
  74 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  75 +.group { margin-bottom:18px; }
  76 +.group h3 { font-size:12px; color:#888; margin:0 0 6px; text-transform:uppercase; letter-spacing:0.5px; }
  77 +.group h3 .count { color:#bbb; font-weight:400; }
  78 +ul { list-style:none; padding:0; margin:0; }
  79 +li {
  80 + display:flex; gap:8px; padding:5px 8px; font-size:12px;
  81 + border-bottom:1px solid #f5f5f7;
  82 +}
  83 +li:hover { background:#fafafa; }
  84 +.ts { color:#888; flex:0 0 100px; font-family: ui-monospace, monospace; }
  85 +.kind {
  86 + flex:0 0 auto; font-family: ui-monospace, monospace; font-size:11px;
  87 + padding:1px 6px; border-radius:4px; background:#eef; color:#446;
  88 +}
  89 +.kind.unit-state { background:#cce5ff; color:#004085; }
  90 +.kind.integration-result { background:#ffeede; color:#a64a00; }
  91 +.kind.triage-result { background:#fde6f5; color:#8b1d72; }
  92 +.kind.sdd-updated, .kind.prd-updated { background:#ffd; color:#7a6b00; }
  93 +.kind.project-shipped { background:#d4edda; color:#155724; }
  94 +.kind.project-status { background:#eee; color:#444; }
  95 +.src { color:#999; font-size:11px; flex:0 0 auto; }
  96 +.summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; }
  97 +.empty { padding:30px; text-align:center; color:#888; }
  98 +</style>
... ...
web/src/components/devloop/ops/ProgressView.vue 0 → 100644
  1 +<template>
  2 + <div class="progress-view">
  3 + <div v-if="!state" class="empty">loading…</div>
  4 + <div v-else class="cards">
  5 + <div class="card big">
  6 + <label>Project Status</label>
  7 + <div class="value">
  8 + <span :class="['badge', currentStatus]">{{ currentStatus }}</span>
  9 + </div>
  10 + <div class="meta">created {{ formatTs(state.project?.created_at) }}</div>
  11 + </div>
  12 +
  13 + <div class="card">
  14 + <label>PRD rev</label>
  15 + <div class="value mono">{{ state.prd_rev ?? '-' }}</div>
  16 + </div>
  17 +
  18 + <div class="card">
  19 + <label>SDD rev</label>
  20 + <div class="value mono">{{ state.sdd_rev ?? '-' }}</div>
  21 + </div>
  22 +
  23 + <div class="card">
  24 + <label>Units progress</label>
  25 + <div class="value">{{ unitsPassed }}/{{ units.length }} passed</div>
  26 + <div class="bar"><div :style="{ width: pctPassed + '%' }" class="fill" /></div>
  27 + </div>
  28 +
  29 + <div class="card">
  30 + <label>L2 rounds</label>
  31 + <div class="value">{{ l2Rounds }} / {{ l2Budget }}</div>
  32 + <div class="bar"><div :style="{ width: pctL2 + '%' }" class="fill l2" /></div>
  33 + </div>
  34 +
  35 + <div class="card">
  36 + <label>Bugs (open)</label>
  37 + <div class="value">{{ bugsOpen }} <small>/ {{ bugsTotal }} total</small></div>
  38 + </div>
  39 + </div>
  40 +
  41 + <h3>Per-unit breakdown</h3>
  42 + <table v-if="units.length">
  43 + <thead>
  44 + <tr><th>Unit</th><th>Status</th><th>Retries</th><th>Contract hash</th></tr>
  45 + </thead>
  46 + <tbody>
  47 + <tr v-for="u in units" :key="u.id">
  48 + <td class="mono">{{ u.id }}</td>
  49 + <td><span :class="['badge', u.status]">{{ u.status }}</span></td>
  50 + <td>{{ u.retries ?? 0 }} / {{ u.retry_budget ?? '-' }}</td>
  51 + <td class="mono hash">{{ u.contract_hash || '-' }}</td>
  52 + </tr>
  53 + </tbody>
  54 + </table>
  55 + </div>
  56 +</template>
  57 +
  58 +<script setup>
  59 +import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
  60 +const props = defineProps({ pid: { type: String, required: true } });
  61 +
  62 +const state = ref(null);
  63 +const bugs = ref([]);
  64 +let timer = null;
  65 +
  66 +async function load() {
  67 + try {
  68 + const [sR, bR] = await Promise.all([
  69 + fetch(`/api/devloop/projects/${props.pid}/state`),
  70 + fetch(`/api/devloop/projects/${props.pid}/bugs`),
  71 + ]);
  72 + if (sR.ok) state.value = await sR.json();
  73 + if (bR.ok) bugs.value = (await bR.json()).bugs || [];
  74 + } catch { /* ignore */ }
  75 +}
  76 +
  77 +const units = computed(() => state.value?.units || []);
  78 +const unitsPassed = computed(() => units.value.filter(u => u.status === 'passed').length);
  79 +const pctPassed = computed(() => units.value.length ? Math.round(unitsPassed.value / units.value.length * 100) : 0);
  80 +const currentStatus = computed(() => state.value?.status || state.value?.project?.status || 'unknown');
  81 +const l2Rounds = computed(() => {
  82 + // pull from units.json via the existing /state endpoint structure
  83 + // state.units_rev exists but rounds field needs separate fetch; for now read project.json if surfaced
  84 + return state.value?.project?.l2_rounds ?? 0;
  85 +});
  86 +const l2Budget = computed(() => 3);
  87 +const pctL2 = computed(() => Math.min(100, (l2Rounds.value / l2Budget.value) * 100));
  88 +const bugsOpen = computed(() => bugs.value.filter(b => b.status === 'open' || b.status === 'fixing').length);
  89 +const bugsTotal = computed(() => bugs.value.length);
  90 +
  91 +function formatTs(s) { if (!s) return '-'; try { return new Date(s).toLocaleString(); } catch { return s; } }
  92 +
  93 +onMounted(() => {
  94 + load();
  95 + timer = setInterval(load, 3000);
  96 +});
  97 +onBeforeUnmount(() => { if (timer) clearInterval(timer); });
  98 +</script>
  99 +
  100 +<style scoped>
  101 +.progress-view {}
  102 +.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; }
  103 +.card { background:#fafafb; border:1px solid #e7e7eb; border-radius:8px; padding:14px 16px; }
  104 +.card.big { grid-column: 1 / -1; }
  105 +.card label { font-size:11px; text-transform:uppercase; color:#888; }
  106 +.card .value { font-size:22px; font-weight:600; margin-top:4px; }
  107 +.card .value small { font-size:13px; color:#888; font-weight:400; }
  108 +.card .meta { font-size:11px; color:#aaa; }
  109 +.mono { font-family: ui-monospace, monospace; }
  110 +.bar { width:100%; height:6px; background:#eee; border-radius:3px; margin-top:6px; overflow:hidden; }
  111 +.fill { height:6px; background:#0a6; border-radius:3px; transition: width .3s; }
  112 +.fill.l2 { background:#a64a00; }
  113 +.empty { padding:30px; text-align:center; color:#888; }
  114 +h3 { font-size:13px; color:#666; margin:24px 0 8px; text-transform:uppercase; }
  115 +table { width:100%; border-collapse:collapse; font-size:13px; }
  116 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  117 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  118 +.hash { font-size:11px; color:#666; }
  119 +.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; text-transform:uppercase; font-weight:600; }
  120 +.badge.draft { background:#eee; color:#666; }
  121 +.badge.running { background:#cce5ff; color:#004085; }
  122 +.badge.shipped { background:#d4edda; color:#155724; }
  123 +.badge.shipped-final { background:#0a6; color:#fff; }
  124 +.badge.abandoned { background:#222; color:#fff; }
  125 +.badge.l2_blocked { background:#fff3cd; color:#856404; }
  126 +.badge.l2_exhausted { background:#f8d7da; color:#721c24; }
  127 +.badge.pending { background:#eee; color:#666; }
  128 +.badge.coding { background:#fff3cd; color:#856404; }
  129 +.badge.testing { background:#cce5ff; color:#004085; }
  130 +.badge.passed { background:#d4edda; color:#155724; }
  131 +.badge.failed { background:#f8d7da; color:#721c24; }
  132 +.badge.blocked { background:#d6d8db; color:#383d41; }
  133 +.badge.unknown { background:#fafafa; color:#888; }
  134 +</style>
... ...
web/src/router/index.js
... ... @@ -69,6 +69,29 @@ const routes = [
69 69 name: 'QuotaMonitor',
70 70 component: () => import('@/views/QuotaMonitor.vue'),
71 71 },
  72 + // P5 · DevLoop Projects 列表 + Dashboard + Ops
  73 + {
  74 + path: '/devloop',
  75 + name: 'DevLoopProjects',
  76 + component: () => import('@/views/DevLoopProjects.vue'),
  77 + },
  78 + {
  79 + path: '/devloop/:pid',
  80 + name: 'DevLoopDashboard',
  81 + component: () => import('@/views/DevLoopDashboard.vue'),
  82 + props: true,
  83 + },
  84 + {
  85 + path: '/devloop/:pid/ops/:tab(bugs|loops|changelog|progress)',
  86 + name: 'DevLoopOps',
  87 + component: () => import('@/views/DevLoopOps.vue'),
  88 + props: true,
  89 + },
  90 + {
  91 + path: '/devloop/:pid/new-requirement',
  92 + name: 'DevLoopNewRequirement',
  93 + component: () => import('@/views/DevLoopNewRequirement.vue'),
  94 + },
72 95 ]
73 96  
74 97 const router = createRouter({
... ...
web/src/views/DevLoopDashboard.vue 0 → 100644
  1 +<template>
  2 + <div class="devloop-dashboard">
  3 + <DashboardHeader
  4 + :pid="pid"
  5 + :state="state"
  6 + @ops="goOps"
  7 + @new-requirement="goNewRequirement"
  8 + @signoff="onSignOff"
  9 + @abandon="onAbandon"
  10 + />
  11 +
  12 + <div v-if="error" class="error-banner">{{ error }}</div>
  13 +
  14 + <div v-if="state" class="grid">
  15 + <section class="loop-status">
  16 + <LoopStatusBar :state="state" />
  17 + </section>
  18 +
  19 + <div class="reqs">
  20 + <RequirementListSection :pid="pid" />
  21 + </div>
  22 +
  23 + <section class="dag">
  24 + <h2>Units DAG</h2>
  25 + <UnitsDAG
  26 + :units="state.units || []"
  27 + @select="onUnitClick"
  28 + />
  29 + </section>
  30 +
  31 + <section class="live">
  32 + <h2>Live agent stream</h2>
  33 + <LiveAgentStream :pid="pid" />
  34 + </section>
  35 +
  36 + <section class="diff">
  37 + <h2>PRD changes</h2>
  38 + <PrdDiffPanel :pid="pid" :state="state" />
  39 + </section>
  40 + </div>
  41 +
  42 + <UnitDrawer
  43 + v-if="drawerUnit"
  44 + :pid="pid"
  45 + :unit-id="drawerUnit"
  46 + @close="drawerUnit = null"
  47 + />
  48 +
  49 + <FeedbackModal
  50 + v-if="showFeedback"
  51 + :pid="pid"
  52 + @close="showFeedback = false"
  53 + @submitted="onFeedbackSubmitted"
  54 + />
  55 + </div>
  56 +</template>
  57 +
  58 +<script setup>
  59 +import { ref, onMounted, onBeforeUnmount } from 'vue';
  60 +import { useRouter } from 'vue-router';
  61 +import DashboardHeader from '@/components/devloop/DashboardHeader.vue';
  62 +import LoopStatusBar from '@/components/devloop/LoopStatusBar.vue';
  63 +import UnitsDAG from '@/components/devloop/UnitsDAG.vue';
  64 +import LiveAgentStream from '@/components/devloop/LiveAgentStream.vue';
  65 +import PrdDiffPanel from '@/components/devloop/PrdDiffPanel.vue';
  66 +import FeedbackModal from '@/components/devloop/FeedbackModal.vue';
  67 +import UnitDrawer from '@/components/devloop/UnitDrawer.vue';
  68 +import RequirementListSection from '@/components/devloop/RequirementListSection.vue';
  69 +
  70 +const props = defineProps({ pid: { type: String, required: true } });
  71 +const router = useRouter();
  72 +
  73 +const state = ref(null);
  74 +const error = ref('');
  75 +const showFeedback = ref(false);
  76 +const drawerUnit = ref(null);
  77 +let pollTimer = null;
  78 +
  79 +async function fetchState() {
  80 + try {
  81 + const r = await fetch(`/api/devloop/projects/${props.pid}/state`);
  82 + if (!r.ok) {
  83 + error.value = r.status === 404 ? `项目不存在: ${props.pid}` : `读 state 失败: ${r.status}`;
  84 + state.value = null;
  85 + return;
  86 + }
  87 + error.value = '';
  88 + state.value = await r.json();
  89 + } catch (e) {
  90 + error.value = e.message;
  91 + }
  92 +}
  93 +
  94 +function goOps(tab = 'bugs') {
  95 + router.push(`/devloop/${props.pid}/ops/${tab}`);
  96 +}
  97 +
  98 +function openFeedback() { showFeedback.value = true; }
  99 +function goNewRequirement() { router.push(`/devloop/${props.pid}/new-requirement`); }
  100 +
  101 +function onUnitClick(unitId) { drawerUnit.value = unitId; }
  102 +
  103 +async function onSignOff() {
  104 + if (!confirm('确认 Sign Off? 此后将无法再提交反馈。')) return;
  105 + const r = await fetch(`/api/devloop/projects/${props.pid}/sign-off`, {
  106 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  107 + body: JSON.stringify({ by: 'human' }),
  108 + });
  109 + const j = await r.json();
  110 + if (!r.ok) alert('Sign off failed: ' + (j.error || r.status));
  111 + await fetchState();
  112 +}
  113 +
  114 +async function onAbandon() {
  115 + const reason = prompt('Abandon 原因 (可空):', 'no longer needed');
  116 + if (reason === null) return;
  117 + const r = await fetch(`/api/devloop/projects/${props.pid}/abandon`, {
  118 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  119 + body: JSON.stringify({ by: 'human', reason }),
  120 + });
  121 + const j = await r.json();
  122 + if (!r.ok) alert('Abandon failed: ' + (j.error || r.status));
  123 + await fetchState();
  124 +}
  125 +
  126 +function onFeedbackSubmitted() { showFeedback.value = false; fetchState(); }
  127 +
  128 +onMounted(async () => {
  129 + await fetchState();
  130 + pollTimer = setInterval(fetchState, 2500);
  131 +});
  132 +
  133 +onBeforeUnmount(() => {
  134 + if (pollTimer) clearInterval(pollTimer);
  135 +});
  136 +</script>
  137 +
  138 +<style scoped>
  139 +.devloop-dashboard { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  140 +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:8px 0; }
  141 +.grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; }
  142 +.grid section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; min-height: 200px; }
  143 +.grid section.live, .grid section.diff { min-height: 280px; }
  144 +.grid h2 { font-size:14px; font-weight:600; margin:0 0 8px 0; color:#555; }
  145 +.loop-status, .dag, .reqs { grid-column: 1 / -1; }
  146 +@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
  147 +</style>
... ...
web/src/views/DevLoopNewRequirement.vue 0 → 100644
  1 +<template>
  2 + <div class="new-req-page">
  3 + <header class="hdr">
  4 + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link>
  5 + <h1>新增需求 · <code>{{ pid }}</code></h1>
  6 + </header>
  7 +
  8 + <div class="layout">
  9 + <!-- LEFT: chat -->
  10 + <section class="chat-col">
  11 + <div class="chat-hdr">
  12 + <h2>对话草稿</h2>
  13 + <span class="hint-mini">写多条消息、上传文档、扫描代码 → 一次性提交给 PM Agent</span>
  14 + </div>
  15 + <div class="chat-log" ref="chatLogEl">
  16 + <div v-if="messages.length === 0" class="empty-mini">
  17 + 写下第一句需求描述,或左下角添加附件 / 代码目录后再继续。
  18 + </div>
  19 + <div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
  20 + <div class="bubble-meta">{{ m.role === 'user' ? '你' : 'Assistant' }}<span v-if="m.llm_stub" class="stub-tag">LLM 占位</span></div>
  21 + <div class="bubble-body">{{ m.content }}</div>
  22 + </div>
  23 + <div v-if="sending" class="bubble assistant pending">Assistant 正在思考…</div>
  24 + </div>
  25 + <div class="chat-input">
  26 + <textarea v-model="draft" rows="3" placeholder="写一段需求 / 追加细节… Ctrl+Enter 发送"
  27 + @keydown.ctrl.enter="sendChat" @keydown.meta.enter="sendChat" />
  28 + <div class="chat-actions">
  29 + <button class="ghost" @click="sendChat" :disabled="!draft.trim() || sending">发送一句 (Ctrl+Enter)</button>
  30 + </div>
  31 + </div>
  32 + </section>
  33 +
  34 + <!-- RIGHT: attachments + scan + submit -->
  35 + <aside class="side-col">
  36 + <div class="card">
  37 + <h3>附件</h3>
  38 + <div class="upload-row">
  39 + <input ref="fileInputEl" type="file" multiple accept=".md,.txt,.html,.htm" @change="onFilesPicked" />
  40 + <button class="ghost" @click="showScanBrowser = true">扫描代码目录…</button>
  41 + </div>
  42 + <div v-if="attachments.length === 0" class="empty-mini">尚未添加任何附件 / 扫描结果。</div>
  43 + <ul v-else class="att-list">
  44 + <li v-for="(a, i) in attachments" :key="i">
  45 + <span class="att-icon">{{ a.kind === 'scan' ? '📂' : '📄' }}</span>
  46 + <span class="att-name" :title="a.name">{{ a.name }}</span>
  47 + <span class="att-size">{{ a.text.length }} 字符</span>
  48 + <button class="x-btn" @click="attachments.splice(i, 1)">×</button>
  49 + </li>
  50 + </ul>
  51 + </div>
  52 +
  53 + <div class="card submit-card">
  54 + <h3>预览 + 提交</h3>
  55 + <div class="preview-stat">
  56 + 消息 {{ userMsgCount }} 条 · 附件 {{ attachments.length }} 项 · 总长 {{ totalChars }} 字符
  57 + </div>
  58 + <button class="primary big" :disabled="!canSubmit || submitting" @click="submitFinal">
  59 + {{ submitting ? '提交中…' : '提交需求 → PM Agent' }}
  60 + </button>
  61 + <div v-if="submitError" class="err">{{ submitError }}</div>
  62 + <div v-if="submitOk" class="ok">{{ submitOk }}</div>
  63 + <details class="preview-details">
  64 + <summary>查看将发送的 feedback 文本</summary>
  65 + <pre class="preview-text">{{ assembledText }}</pre>
  66 + </details>
  67 + </div>
  68 + </aside>
  69 + </div>
  70 +
  71 + <!-- 扫描目录浏览器(复用 fs/browse) -->
  72 + <div v-if="showScanBrowser" class="modal-mask" @click.self="showScanBrowser = false">
  73 + <div class="modal browser">
  74 + <h2>选择要扫描的目录</h2>
  75 + <p class="hint">递归读取该目录下的 <code>.md</code> / <code>.txt</code> / <code>.html</code> 文件,拼成一段文本作为附件。</p>
  76 + <div class="bcrumb">
  77 + <button class="ghost" :disabled="!browser.parent" @click="browseTo(browser.parent)">↑ 上级</button>
  78 + <button class="ghost" @click="browseTo(browser.home)">🏠 主目录</button>
  79 + <input class="path-input" v-model="browser.pathInput"
  80 + @keydown.enter="browseTo(browser.pathInput)"
  81 + placeholder="输入绝对路径回车跳转" />
  82 + </div>
  83 + <div class="cur-path">当前: <code>{{ browser.path || '—' }}</code></div>
  84 + <div v-if="browser.error" class="err">{{ browser.error }}</div>
  85 + <div class="entries">
  86 + <div v-if="browser.loading" class="empty-mini">加载中…</div>
  87 + <div v-else-if="!browser.entries.length" class="empty-mini">(此目录下没有可见的子目录)</div>
  88 + <div v-else
  89 + v-for="e in browser.entries"
  90 + :key="e.abs_path"
  91 + class="entry"
  92 + @dblclick="browseTo(e.abs_path)">
  93 + <span class="ico">📁</span>
  94 + <span>{{ e.name }}</span>
  95 + </div>
  96 + </div>
  97 + <div class="modal-actions">
  98 + <button @click="showScanBrowser = false" :disabled="scanning">取消</button>
  99 + <button class="primary" :disabled="!browser.path || scanning" @click="doScan">
  100 + {{ scanning ? '扫描中…' : '扫描当前目录' }}
  101 + </button>
  102 + </div>
  103 + </div>
  104 + </div>
  105 + </div>
  106 +</template>
  107 +
  108 +<script setup>
  109 +import { ref, computed, nextTick, onMounted } from 'vue';
  110 +import { useRoute, useRouter } from 'vue-router';
  111 +
  112 +const route = useRoute();
  113 +const router = useRouter();
  114 +const pid = route.params.pid;
  115 +
  116 +const messages = ref([]); // [{role, content, llm_stub?}]
  117 +const draft = ref('');
  118 +const sending = ref(false);
  119 +const chatLogEl = ref(null);
  120 +
  121 +const fileInputEl = ref(null);
  122 +const attachments = ref([]); // [{kind:'file'|'scan', name, text}]
  123 +
  124 +const showScanBrowser = ref(false);
  125 +const scanning = ref(false);
  126 +const browser = ref({ path:'', parent:null, home:'', entries:[], pathInput:'', loading:false, error:'' });
  127 +
  128 +const submitting = ref(false);
  129 +const submitError = ref('');
  130 +const submitOk = ref('');
  131 +
  132 +const userMsgCount = computed(() => messages.value.filter(m => m.role === 'user').length);
  133 +const totalChars = computed(() =>
  134 + messages.value.reduce((s, m) => s + (m.content?.length || 0), 0)
  135 + + attachments.value.reduce((s, a) => s + (a.text?.length || 0), 0)
  136 +);
  137 +const assembledText = computed(() => {
  138 + const userTurns = messages.value.filter(m => m.role === 'user').map((m, i) => `[消息 ${i+1}] ${m.content}`).join('\n\n');
  139 + const att = attachments.value.length
  140 + ? '\n\n--- 附件内容 ---\n\n' + attachments.value.map(a => `### ${a.name}\n\n${a.text}`).join('\n\n---\n\n')
  141 + : '';
  142 + return userTurns + att;
  143 +});
  144 +const canSubmit = computed(() => userMsgCount.value > 0 || attachments.value.length > 0);
  145 +
  146 +async function sendChat() {
  147 + if (!draft.value.trim() || sending.value) return;
  148 + const text = draft.value.trim();
  149 + draft.value = '';
  150 + messages.value.push({ role: 'user', content: text });
  151 + await nextTick(); scrollChatToBottom();
  152 + sending.value = true;
  153 + try {
  154 + const r = await fetch(`/api/devloop/projects/${pid}/draft-chat`, {
  155 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  156 + body: JSON.stringify({ messages: messages.value.map(m => ({ role: m.role, content: m.content })) }),
  157 + });
  158 + const j = await r.json();
  159 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  160 + messages.value.push({ role: 'assistant', content: j.content || '', llm_stub: !!j.llm_stub });
  161 + } catch (e) {
  162 + messages.value.push({ role: 'assistant', content: `(后端错误: ${e.message})`, llm_stub: true });
  163 + } finally {
  164 + sending.value = false;
  165 + await nextTick(); scrollChatToBottom();
  166 + }
  167 +}
  168 +
  169 +function scrollChatToBottom() {
  170 + if (chatLogEl.value) chatLogEl.value.scrollTop = chatLogEl.value.scrollHeight;
  171 +}
  172 +
  173 +async function onFilesPicked(ev) {
  174 + const files = Array.from(ev.target.files || []);
  175 + if (!files.length) return;
  176 + const form = new FormData();
  177 + for (const f of files) form.append('files', f);
  178 + try {
  179 + const r = await fetch(`/api/devloop/projects/${pid}/intake/files`, { method: 'POST', body: form });
  180 + const j = await r.json();
  181 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  182 + for (const f of (j.files || [])) {
  183 + if (f.skipped) {
  184 + alert(`跳过 ${f.name}: ${f.reason}`);
  185 + continue;
  186 + }
  187 + attachments.value.push({ kind: 'file', name: f.name, text: f.text || '' });
  188 + }
  189 + } catch (e) {
  190 + alert('上传失败: ' + e.message);
  191 + } finally {
  192 + if (fileInputEl.value) fileInputEl.value.value = '';
  193 + }
  194 +}
  195 +
  196 +async function browseTo(p) {
  197 + browser.value.loading = true; browser.value.error = '';
  198 + try {
  199 + const url = p ? `/api/devloop/fs/browse?path=${encodeURIComponent(p)}` : '/api/devloop/fs/browse';
  200 + const r = await fetch(url);
  201 + const j = await r.json();
  202 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  203 + browser.value.path = j.path;
  204 + browser.value.parent = j.parent;
  205 + browser.value.home = j.home;
  206 + browser.value.entries = j.entries || [];
  207 + browser.value.pathInput = j.path;
  208 + } catch (e) {
  209 + browser.value.error = e.message;
  210 + } finally {
  211 + browser.value.loading = false;
  212 + }
  213 +}
  214 +
  215 +async function doScan() {
  216 + if (!browser.value.path) return;
  217 + scanning.value = true;
  218 + try {
  219 + const r = await fetch(`/api/devloop/projects/${pid}/intake/scan-dir`, {
  220 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  221 + body: JSON.stringify({ path: browser.value.path, max_files: 30, max_bytes: 500000 }),
  222 + });
  223 + const j = await r.json();
  224 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  225 + if (!j.files.length) {
  226 + alert('该目录下未找到 md/txt/html 文件。');
  227 + return;
  228 + }
  229 + const text = j.files.map(f => `## ${f.rel}\n\n${f.text}`).join('\n\n---\n\n');
  230 + const name = `${j.files.length} 个文件@${shortPath(browser.value.path)}`
  231 + + (j.truncated ? ' (已截断)' : '');
  232 + attachments.value.push({ kind: 'scan', name, text });
  233 + showScanBrowser.value = false;
  234 + } catch (e) {
  235 + alert('扫描失败: ' + e.message);
  236 + } finally {
  237 + scanning.value = false;
  238 + }
  239 +}
  240 +
  241 +function shortPath(p) {
  242 + if (!p) return '';
  243 + if (browser.value.home && p.startsWith(browser.value.home)) return '~' + p.slice(browser.value.home.length);
  244 + return p.length > 36 ? '…' + p.slice(-33) : p;
  245 +}
  246 +
  247 +async function submitFinal() {
  248 + if (!canSubmit.value || submitting.value) return;
  249 + submitting.value = true; submitError.value = ''; submitOk.value = '';
  250 + try {
  251 + const r = await fetch(`/api/devloop/projects/${pid}/feedback`, {
  252 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  253 + body: JSON.stringify({ text: assembledText.value, by: 'human' }),
  254 + });
  255 + const j = await r.json();
  256 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  257 + submitOk.value = `已提交 → PRD bumped to rev ${j.prd_rev_after} · status=${j.status}`;
  258 + setTimeout(() => router.push(`/devloop/${pid}`), 1200);
  259 + } catch (e) {
  260 + submitError.value = e.message;
  261 + } finally {
  262 + submitting.value = false;
  263 + }
  264 +}
  265 +
  266 +onMounted(() => { /* nothing pre-loaded */ });
  267 +</script>
  268 +
  269 +<style scoped>
  270 +.new-req-page { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  271 +.hdr { display:flex; gap:14px; align-items:center; padding-bottom:8px; border-bottom:1px solid #eee; margin-bottom:16px; }
  272 +.back { color:#888; text-decoration:none; font-size:13px; }
  273 +.back:hover { color:#222; }
  274 +h1 { font-size:18px; margin:0; }
  275 +h1 code { font-family: ui-monospace, monospace; font-size:14px; background:#f4f4f6; padding:2px 8px; border-radius:4px; }
  276 +
  277 +.layout { display:grid; grid-template-columns: 1fr 380px; gap:16px; }
  278 +@media (max-width: 1000px) { .layout { grid-template-columns: 1fr; } }
  279 +
  280 +.chat-col, .side-col { display:flex; flex-direction:column; gap:12px; }
  281 +.chat-col { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; min-height: 500px; }
  282 +.chat-hdr { display:flex; justify-content:space-between; align-items:baseline; }
  283 +.chat-hdr h2 { font-size:14px; font-weight:600; margin:0; color:#555; }
  284 +.hint-mini { font-size:11px; color:#888; }
  285 +.chat-log { flex:1; overflow-y:auto; max-height: 420px; padding:8px; background:#fafafa; border-radius:6px; min-height:200px; }
  286 +.empty-mini { color:#888; font-size:13px; text-align:center; padding:24px; }
  287 +
  288 +.bubble { margin-bottom:10px; padding:10px 12px; border-radius:8px; max-width:85%; word-break:break-word; white-space:pre-wrap; font-size:13px; line-height:1.5; }
  289 +.bubble.user { background:#dff; margin-left:auto; }
  290 +.bubble.assistant { background:#fff; border:1px solid #eee; }
  291 +.bubble.pending { color:#888; font-style:italic; }
  292 +.bubble-meta { font-size:10px; color:#888; margin-bottom:4px; font-weight:600; }
  293 +.stub-tag { background:#fa3; color:#fff; font-size:10px; padding:1px 6px; border-radius:6px; margin-left:6px; font-weight:400; }
  294 +
  295 +.chat-input { display:flex; flex-direction:column; gap:8px; }
  296 +.chat-input textarea { width:100%; padding:8px; border:1px solid #ccc; border-radius:5px; font-family:inherit; font-size:13px; box-sizing:border-box; resize:vertical; }
  297 +.chat-actions { display:flex; justify-content:flex-end; }
  298 +
  299 +.card { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; }
  300 +.card h3 { font-size:13px; font-weight:600; margin:0 0 10px 0; color:#555; }
  301 +.upload-row { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
  302 +.upload-row input[type=file] { font-size:12px; }
  303 +.att-list { list-style:none; padding:0; margin:0; }
  304 +.att-list li { display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid #f3f3f3; font-size:12px; }
  305 +.att-icon { font-size:14px; }
  306 +.att-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  307 +.att-size { color:#888; font-size:11px; }
  308 +.x-btn { background:none; border:none; color:#c66; cursor:pointer; font-size:16px; padding:0 4px; }
  309 +.x-btn:hover { color:#c00; }
  310 +
  311 +.submit-card .preview-stat { font-size:12px; color:#666; margin-bottom:10px; }
  312 +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; border-radius:5px; padding:6px 14px; cursor:pointer; font-size:13px; }
  313 +button.primary:hover:not(:disabled) { background:#085; }
  314 +button.primary:disabled { background:#bbb; border-color:#bbb; cursor:not-allowed; }
  315 +button.primary.big { width:100%; padding:10px; font-size:14px; font-weight:600; }
  316 +button.ghost { background:#fff; border:1px solid #ccc; padding:5px 12px; border-radius:5px; cursor:pointer; font-size:12px; color:#444; }
  317 +button.ghost:hover:not(:disabled) { background:#f4f4f6; }
  318 +button.ghost:disabled { opacity:0.5; cursor:not-allowed; }
  319 +.err { color:#c00; font-size:12px; margin-top:8px; padding:6px; background:#fee; border-radius:4px; }
  320 +.ok { color:#070; font-size:12px; margin-top:8px; padding:6px; background:#dfd; border-radius:4px; }
  321 +.preview-details { margin-top:10px; font-size:12px; }
  322 +.preview-details summary { cursor:pointer; color:#0a6; }
  323 +.preview-text { background:#fafafa; padding:10px; border-radius:5px; font-size:11px; line-height:1.4; max-height:200px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; }
  324 +
  325 +/* Browser modal */
  326 +.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; }
  327 +.modal { background:#fff; border-radius:8px; padding:20px 24px; min-width:480px; max-width:680px; box-shadow:0 8px 32px rgba(0,0,0,0.25); }
  328 +.modal.browser { min-width:560px; }
  329 +.modal h2 { font-size:16px; margin:0 0 8px 0; }
  330 +.modal .hint { color:#666; font-size:12px; margin:0 0 12px 0; }
  331 +.modal code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-size:11px; font-family: ui-monospace, monospace; }
  332 +.bcrumb { display:flex; gap:6px; align-items:center; margin-bottom:8px; }
  333 +.bcrumb .path-input { flex:1; padding:5px 10px; border:1px solid #ccc; border-radius:4px; font-size:12px; font-family: ui-monospace, monospace; }
  334 +.cur-path { font-size:12px; color:#666; margin:6px 0; }
  335 +.entries { border:1px solid #eee; border-radius:6px; padding:6px 0; max-height:280px; min-height:160px; overflow-y:auto; background:#fafafa; }
  336 +.entry { display:flex; gap:10px; padding:6px 14px; cursor:pointer; }
  337 +.entry:hover { background:#eef; }
  338 +.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:14px; }
  339 +.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; }
  340 +.modal-actions button:hover:not(:disabled) { background:#f4f4f6; }
  341 +.modal-actions button:disabled { opacity:0.5; cursor:not-allowed; }
  342 +</style>
... ...
web/src/views/DevLoopOps.vue 0 → 100644
  1 +<template>
  2 + <div class="devloop-ops">
  3 + <header class="hdr">
  4 + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link>
  5 + <h1>Ops · {{ pid }}</h1>
  6 + </header>
  7 +
  8 + <nav class="tabs">
  9 + <router-link
  10 + v-for="t in tabs"
  11 + :key="t.id"
  12 + :to="`/devloop/${pid}/ops/${t.id}`"
  13 + :class="['tab', { active: t.id === tab }]"
  14 + >{{ t.label }}</router-link>
  15 + </nav>
  16 +
  17 + <component :is="activeView" :pid="pid" />
  18 + </div>
  19 +</template>
  20 +
  21 +<script setup>
  22 +import { computed } from 'vue';
  23 +import BuglistView from '@/components/devloop/ops/BuglistView.vue';
  24 +import LoopLogView from '@/components/devloop/ops/LoopLogView.vue';
  25 +import ChangelogView from '@/components/devloop/ops/ChangelogView.vue';
  26 +import ProgressView from '@/components/devloop/ops/ProgressView.vue';
  27 +
  28 +const props = defineProps({
  29 + pid: { type: String, required: true },
  30 + tab: { type: String, required: true },
  31 +});
  32 +
  33 +const tabs = [
  34 + { id: 'bugs', label: 'Bugs' },
  35 + { id: 'loops', label: 'Loops (Timeline)' },
  36 + { id: 'changelog', label: 'Changelog' },
  37 + { id: 'progress', label: 'Progress' },
  38 +];
  39 +
  40 +const viewMap = {
  41 + bugs: BuglistView,
  42 + loops: LoopLogView,
  43 + changelog: ChangelogView,
  44 + progress: ProgressView,
  45 +};
  46 +const activeView = computed(() => viewMap[props.tab] || BuglistView);
  47 +</script>
  48 +
  49 +<style scoped>
  50 +.devloop-ops { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  51 +.hdr { display:flex; align-items:center; gap:14px; padding-bottom:8px; border-bottom:1px solid #eee; }
  52 +.back { color:#888; text-decoration:none; font-size:13px; }
  53 +.back:hover { color:#222; }
  54 +h1 { font-size:18px; margin:0; font-family: ui-monospace, monospace; }
  55 +.tabs { display:flex; gap:0; margin:16px 0; border-bottom:1px solid #e3e3e8; }
  56 +.tab {
  57 + padding:8px 16px; text-decoration:none; color:#666; border-bottom:2px solid transparent;
  58 + font-size:13px; font-weight:500;
  59 +}
  60 +.tab:hover { color:#222; }
  61 +.tab.active { color:#0a6; border-bottom-color:#0a6; }
  62 +</style>
... ...
web/src/views/DevLoopProjects.vue 0 → 100644
  1 +<template>
  2 + <div class="devloop-projects">
  3 + <header class="hdr">
  4 + <h1>DevLoop · 项目</h1>
  5 + <div class="hdr-actions">
  6 + <label class="archived-toggle">
  7 + <input type="checkbox" v-model="includeArchived" @change="fetchProjects" />
  8 + 显示已归档
  9 + </label>
  10 + <button class="primary" @click="openCreate">+ 新建项目</button>
  11 + </div>
  12 + </header>
  13 +
  14 + <div v-if="error" class="error-banner">{{ error }}</div>
  15 +
  16 + <div v-if="loading && !projects.length" class="empty">加载中…</div>
  17 + <div v-else-if="!projects.length" class="empty">
  18 + 暂无项目。点击右上角 <b>新建项目</b> 开始一个 DevLoop 流程。
  19 + </div>
  20 +
  21 + <table v-else class="proj-table">
  22 + <thead>
  23 + <tr>
  24 + <th>名称</th>
  25 + <th>状态</th>
  26 + <th>Mode</th>
  27 + <th>代码目录</th>
  28 + <th>Company</th>
  29 + <th>创建时间</th>
  30 + <th>PID</th>
  31 + <th class="actions-col">操作</th>
  32 + </tr>
  33 + </thead>
  34 + <tbody>
  35 + <tr v-for="p in projects" :key="p.pid" :class="{ archived: p.archived }">
  36 + <td class="name-cell">
  37 + <router-link :to="`/devloop/${p.pid}`" class="name-link">
  38 + {{ p.name || p.pid }}
  39 + </router-link>
  40 + <div v-if="p.idea_summary && p.idea_summary !== p.name" class="idea">{{ p.idea_summary }}</div>
  41 + </td>
  42 + <td><span :class="['status-pill', `s-${p.status}`]">{{ p.status }}</span></td>
  43 + <td><code>{{ p.mode }}</code></td>
  44 + <td class="path-cell">
  45 + <code class="path" :title="p.root_path">{{ p.root_path }}</code>
  46 + </td>
  47 + <td><code>{{ p.company_id }}</code></td>
  48 + <td>{{ formatTime(p.created_at) }}</td>
  49 + <td><code class="pid">{{ p.pid }}</code></td>
  50 + <td class="actions-cell">
  51 + <button @click="openRename(p)">改名</button>
  52 + <button v-if="!p.archived" class="warn" @click="archive(p)">归档</button>
  53 + <button v-else @click="restore(p)">恢复</button>
  54 + <button class="danger" @click="hardDelete(p)">永删</button>
  55 + </td>
  56 + </tr>
  57 + </tbody>
  58 + </table>
  59 +
  60 + <!-- 新建项目弹窗 -->
  61 + <div v-if="showCreate" class="modal-mask" @click.self="closeCreate">
  62 + <div class="modal">
  63 + <h2>新建 DevLoop 项目</h2>
  64 + <label>
  65 + 公司
  66 + <select v-model="newProj.company_id">
  67 + <option value="">— 选择公司 —</option>
  68 + <option v-for="c in companies" :key="c.id" :value="c.id">
  69 + {{ c.name }} ({{ c.id }})
  70 + </option>
  71 + </select>
  72 + </label>
  73 + <label>
  74 + 项目名 (可选)
  75 + <input v-model="newProj.name" placeholder="如:购物车结算重构" />
  76 + </label>
  77 + <label>
  78 + 需求描述 *
  79 + <textarea v-model="newProj.requirement" rows="5"
  80 + placeholder="一句话或一段话描述这个项目要做什么。Agent 会按此生成 PRD/SDD。" />
  81 + </label>
  82 +
  83 + <fieldset class="storage">
  84 + <legend>代码目录</legend>
  85 + <label class="inline">
  86 + <input type="radio" value="sandbox" v-model="newProj.mode" />
  87 + <span><b>沙盒</b>(自动创建在 <code>data/companies/{cid}/projects/{pid}/</code>)</span>
  88 + </label>
  89 + <label class="inline">
  90 + <input type="radio" value="linked-new" v-model="newProj.mode" />
  91 + <span><b>新建到本机目录</b>(linked-new:必须空目录或不存在)</span>
  92 + </label>
  93 + <label class="inline">
  94 + <input type="radio" value="linked-existing" v-model="newProj.mode" />
  95 + <span><b>挂到已有项目</b>(linked-existing:在你已有的代码库上启动 DevLoop)</span>
  96 + </label>
  97 + <div v-if="isLinkedMode" class="linked-row">
  98 + <input v-model="newProj.target_path"
  99 + :placeholder="newProj.mode === 'linked-new'
  100 + ? '/Users/yibo/projects/my-new-project(绝对路径,必须空目录或不存在)'
  101 + : '/Users/yibo/projects/my-existing-repo(绝对路径,必须已存在)'" />
  102 + <button type="button" class="ghost" @click="openBrowser">浏览…</button>
  103 + </div>
  104 + <div v-if="newProj.mode === 'linked-new'" class="hint">
  105 + 目标目录必须是<b>空</b>目录(或不存在,会自动创建)。系统目录如 <code>/etc</code>、<code>~/.ssh</code> 已加入黑名单。
  106 + </div>
  107 + <div v-else-if="newProj.mode === 'linked-existing'" class="hint">
  108 + DevLoop 会把元数据(<code>project.json</code> / <code>prd/</code> / <code>sdd/</code> / <code>units/</code> / <code>timeline.ndjson</code> 等)写到此目录根。
  109 + 如已有 <code>.git</code>,复用既有 repo 不重新 init;不会覆盖你的 <code>.gitignore</code>。
  110 + <b>要求 working tree 干净</b>:先 <code>git commit</code> 或 <code>git stash</code> 你的未提交改动,否则会被 Agent 的 <code>git add -A</code> 误扫进 DevLoop 的 commit。
  111 + </div>
  112 + </fieldset>
  113 +
  114 + <label class="inline">
  115 + <input type="checkbox" v-model="newProj.mock" />
  116 + 使用 mock 模式(不调真实 Claude,用 fixture 走通流程)
  117 + </label>
  118 + <div v-if="createError" class="err">{{ createError }}</div>
  119 + <div class="modal-actions">
  120 + <button @click="closeCreate" :disabled="creating">取消</button>
  121 + <button class="primary" :disabled="creating || !canCreate" @click="submitCreate">
  122 + {{ creating ? '创建中…' : '创建' }}
  123 + </button>
  124 + </div>
  125 + </div>
  126 + </div>
  127 +
  128 + <!-- 改名弹窗 -->
  129 + <div v-if="renameTarget" class="modal-mask" @click.self="renameTarget = null">
  130 + <div class="modal sm">
  131 + <h2>改名 — {{ renameTarget.pid }}</h2>
  132 + <label>
  133 + 名称
  134 + <input v-model="renameValue" />
  135 + </label>
  136 + <div v-if="renameError" class="err">{{ renameError }}</div>
  137 + <div class="modal-actions">
  138 + <button @click="renameTarget = null" :disabled="renaming">取消</button>
  139 + <button class="primary" :disabled="renaming || !renameValue" @click="submitRename">
  140 + {{ renaming ? '保存中…' : '保存' }}
  141 + </button>
  142 + </div>
  143 + </div>
  144 + </div>
  145 +
  146 + <!-- 目录浏览器 -->
  147 + <div v-if="showBrowser" class="modal-mask" @click.self="showBrowser = false" style="z-index:1100">
  148 + <div class="modal browser">
  149 + <h2>选择代码目录</h2>
  150 + <div class="bcrumb">
  151 + <button class="ghost" :disabled="!browser.parent" @click="browseTo(browser.parent)">↑ 上级</button>
  152 + <button class="ghost" @click="browseTo(browser.home)">🏠 主目录</button>
  153 + <input class="path-input" v-model="browser.pathInput"
  154 + @keydown.enter="browseTo(browser.pathInput)"
  155 + placeholder="输入绝对路径回车跳转" />
  156 + </div>
  157 + <div class="cur-path">当前: <code>{{ browser.path || '—' }}</code></div>
  158 + <div v-if="browser.error" class="err">{{ browser.error }}</div>
  159 + <div class="entries">
  160 + <div v-if="browser.loading" class="empty-mini">加载中…</div>
  161 + <div v-else-if="!browser.entries.length" class="empty-mini">(此目录下没有可见的子目录)</div>
  162 + <div v-else
  163 + v-for="e in browser.entries"
  164 + :key="e.abs_path"
  165 + class="entry"
  166 + @dblclick="browseTo(e.abs_path)"
  167 + @click="browser.selectedChild = e.abs_path">
  168 + <span class="ico">📁</span>
  169 + <span class="ename" :class="{ active: browser.selectedChild === e.abs_path }">{{ e.name }}</span>
  170 + <span class="empty-tag" v-if="e.is_empty">空</span>
  171 + </div>
  172 + </div>
  173 + <div class="hint">
  174 + 单击选中 / 双击进入。<b>新建项目目录</b>建议在某个父目录下手填一个不存在的子目录名(系统会创建)。
  175 + </div>
  176 + <div class="modal-actions">
  177 + <button @click="showBrowser = false">取消</button>
  178 + <button class="ghost" :disabled="!browser.selectedChild" @click="useThisChild">
  179 + 使用选中目录
  180 + </button>
  181 + <button class="primary" :disabled="!browser.path" @click="useThisDir">
  182 + 使用当前目录: <code>{{ shortPath(browser.path) }}</code>
  183 + </button>
  184 + </div>
  185 + </div>
  186 + </div>
  187 + </div>
  188 +</template>
  189 +
  190 +<script setup>
  191 +import { ref, computed, onMounted } from 'vue';
  192 +
  193 +const projects = ref([]);
  194 +const companies = ref([]);
  195 +const error = ref('');
  196 +const loading = ref(false);
  197 +const includeArchived = ref(false);
  198 +
  199 +const showCreate = ref(false);
  200 +const creating = ref(false);
  201 +const createError = ref('');
  202 +const newProj = ref({ company_id: '', name: '', requirement: '', mock: true, mode: 'sandbox', target_path: '' });
  203 +const isLinkedMode = computed(() =>
  204 + newProj.value.mode === 'linked-new' || newProj.value.mode === 'linked-existing'
  205 +);
  206 +const canCreate = computed(() => {
  207 + if (!newProj.value.company_id || !newProj.value.requirement.trim()) return false;
  208 + if (isLinkedMode.value && !newProj.value.target_path.trim()) return false;
  209 + return true;
  210 +});
  211 +
  212 +const showBrowser = ref(false);
  213 +const browser = ref({
  214 + path: '', parent: null, home: '', entries: [], pathInput: '',
  215 + loading: false, error: '', selectedChild: '',
  216 +});
  217 +
  218 +const renameTarget = ref(null);
  219 +const renameValue = ref('');
  220 +const renaming = ref(false);
  221 +const renameError = ref('');
  222 +
  223 +async function fetchProjects() {
  224 + loading.value = true;
  225 + try {
  226 + const url = `/api/devloop/projects${includeArchived.value ? '?include_archived=true' : ''}`;
  227 + const r = await fetch(url);
  228 + if (!r.ok) throw new Error(`HTTP ${r.status}`);
  229 + const j = await r.json();
  230 + projects.value = j.projects || [];
  231 + error.value = '';
  232 + } catch (e) {
  233 + error.value = '加载项目失败: ' + e.message;
  234 + } finally {
  235 + loading.value = false;
  236 + }
  237 +}
  238 +
  239 +async function fetchCompanies() {
  240 + try {
  241 + const r = await fetch('/api/company/list');
  242 + if (!r.ok) return;
  243 + const j = await r.json();
  244 + companies.value = Array.isArray(j) ? j : (j.companies || []);
  245 + } catch { /* 忽略,用户可手填 pid */ }
  246 +}
  247 +
  248 +function openCreate() {
  249 + newProj.value = { company_id: '', name: '', requirement: '', mock: true, mode: 'sandbox', target_path: '' };
  250 + createError.value = '';
  251 + showCreate.value = true;
  252 +}
  253 +function closeCreate() {
  254 + if (creating.value) return;
  255 + showCreate.value = false;
  256 +}
  257 +
  258 +async function submitCreate() {
  259 + creating.value = true;
  260 + createError.value = '';
  261 + try {
  262 + const body = {
  263 + company_id: newProj.value.company_id,
  264 + name: newProj.value.name || undefined,
  265 + requirement: newProj.value.requirement.trim(),
  266 + mock: newProj.value.mock,
  267 + mode: newProj.value.mode,
  268 + };
  269 + if (isLinkedMode.value) {
  270 + body.target_path = newProj.value.target_path.trim();
  271 + }
  272 + const r = await fetch('/api/devloop/projects', {
  273 + method: 'POST',
  274 + headers: { 'Content-Type': 'application/json' },
  275 + body: JSON.stringify(body),
  276 + });
  277 + const j = await r.json();
  278 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  279 + showCreate.value = false;
  280 + await fetchProjects();
  281 + } catch (e) {
  282 + createError.value = e.message;
  283 + } finally {
  284 + creating.value = false;
  285 + }
  286 +}
  287 +
  288 +async function openBrowser() {
  289 + showBrowser.value = true;
  290 + // 起点:用户已填的路径目录 / 已填值本身(若是目录) / $HOME
  291 + let start = newProj.value.target_path?.trim() || '';
  292 + if (!start) start = ''; // 让后端默认 $HOME
  293 + browser.value.selectedChild = '';
  294 + await browseTo(start);
  295 +}
  296 +
  297 +async function browseTo(p) {
  298 + browser.value.loading = true;
  299 + browser.value.error = '';
  300 + try {
  301 + const url = p
  302 + ? `/api/devloop/fs/browse?path=${encodeURIComponent(p)}`
  303 + : '/api/devloop/fs/browse';
  304 + const r = await fetch(url);
  305 + const j = await r.json();
  306 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  307 + browser.value.path = j.path;
  308 + browser.value.parent = j.parent;
  309 + browser.value.home = j.home;
  310 + browser.value.entries = j.entries || [];
  311 + browser.value.pathInput = j.path;
  312 + browser.value.selectedChild = '';
  313 + } catch (e) {
  314 + browser.value.error = e.message;
  315 + } finally {
  316 + browser.value.loading = false;
  317 + }
  318 +}
  319 +
  320 +function useThisDir() {
  321 + if (!browser.value.path) return;
  322 + newProj.value.target_path = browser.value.path;
  323 + showBrowser.value = false;
  324 +}
  325 +
  326 +function useThisChild() {
  327 + if (!browser.value.selectedChild) return;
  328 + newProj.value.target_path = browser.value.selectedChild;
  329 + showBrowser.value = false;
  330 +}
  331 +
  332 +function shortPath(p) {
  333 + if (!p) return '';
  334 + if (browser.value.home && p.startsWith(browser.value.home)) {
  335 + return '~' + p.slice(browser.value.home.length);
  336 + }
  337 + return p.length > 48 ? '…' + p.slice(-45) : p;
  338 +}
  339 +
  340 +function openRename(p) {
  341 + renameTarget.value = p;
  342 + renameValue.value = p.name || '';
  343 + renameError.value = '';
  344 +}
  345 +
  346 +async function submitRename() {
  347 + if (!renameTarget.value) return;
  348 + renaming.value = true;
  349 + renameError.value = '';
  350 + try {
  351 + const r = await fetch(`/api/devloop/projects/${renameTarget.value.pid}`, {
  352 + method: 'PATCH',
  353 + headers: { 'Content-Type': 'application/json' },
  354 + body: JSON.stringify({ name: renameValue.value }),
  355 + });
  356 + const j = await r.json();
  357 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  358 + renameTarget.value = null;
  359 + await fetchProjects();
  360 + } catch (e) {
  361 + renameError.value = e.message;
  362 + } finally {
  363 + renaming.value = false;
  364 + }
  365 +}
  366 +
  367 +async function archive(p) {
  368 + if (!confirm(`归档项目 "${p.name || p.pid}"?项目数据保留,可在"显示已归档"中恢复。`)) return;
  369 + const r = await fetch(`/api/devloop/projects/${p.pid}`, { method: 'DELETE' });
  370 + if (!r.ok) {
  371 + const j = await r.json().catch(() => ({}));
  372 + return alert('归档失败: ' + (j.error || r.status));
  373 + }
  374 + await fetchProjects();
  375 +}
  376 +
  377 +async function restore(p) {
  378 + const r = await fetch(`/api/devloop/projects/${p.pid}/restore`, { method: 'POST' });
  379 + if (!r.ok) {
  380 + const j = await r.json().catch(() => ({}));
  381 + return alert('恢复失败: ' + (j.error || r.status));
  382 + }
  383 + await fetchProjects();
  384 +}
  385 +
  386 +async function hardDelete(p) {
  387 + const msg = p.mode === 'sandbox'
  388 + ? `永久删除 "${p.name || p.pid}"?\n\n会递归删除目录:\n${p.root_path}\n\n此操作不可撤销!`
  389 + : `从 DevLoop 注册表中移除 "${p.name || p.pid}"?\n\n(linked 模式:你的目录 ${p.root_path} 会保留)`;
  390 + if (!confirm(msg)) return;
  391 + const r = await fetch(`/api/devloop/projects/${p.pid}?hard=true`, { method: 'DELETE' });
  392 + if (!r.ok) {
  393 + const j = await r.json().catch(() => ({}));
  394 + return alert('删除失败: ' + (j.error || r.status));
  395 + }
  396 + await fetchProjects();
  397 +}
  398 +
  399 +function formatTime(iso) {
  400 + if (!iso) return '—';
  401 + try {
  402 + const d = new Date(iso);
  403 + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
  404 + } catch { return iso; }
  405 +}
  406 +
  407 +onMounted(() => {
  408 + fetchProjects();
  409 + fetchCompanies();
  410 +});
  411 +</script>
  412 +
  413 +<style scoped>
  414 +.devloop-projects { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  415 +.hdr { display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; border-bottom:1px solid #eee; }
  416 +.hdr h1 { font-size:20px; margin:0; }
  417 +.hdr-actions { display:flex; align-items:center; gap:14px; }
  418 +.archived-toggle { font-size:13px; color:#666; display:flex; align-items:center; gap:6px; cursor:pointer; }
  419 +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:12px 0; }
  420 +
  421 +.empty { text-align:center; color:#888; padding:48px 0; font-size:14px; }
  422 +
  423 +.proj-table { width:100%; border-collapse:collapse; margin-top:16px; background:#fff; }
  424 +.proj-table th, .proj-table td { padding:10px 12px; border-bottom:1px solid #eee; text-align:left; font-size:13px; vertical-align:middle; }
  425 +.proj-table th { background:#fafafa; color:#555; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; }
  426 +.proj-table tr.archived { opacity:0.55; background:#fafafa; }
  427 +.proj-table tr:hover:not(.archived) { background:#fafbff; }
  428 +
  429 +.name-cell { min-width:240px; }
  430 +.name-link { color:#0a6; text-decoration:none; font-weight:500; }
  431 +.name-link:hover { text-decoration:underline; }
  432 +.idea { color:#888; font-size:12px; margin-top:2px; max-width:340px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  433 +
  434 +.pid, code { font-family: ui-monospace, SFMono-Regular, monospace; font-size:12px; background:#f4f4f6; padding:1px 6px; border-radius:3px; }
  435 +
  436 +.status-pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; background:#eee; color:#444; }
  437 +.s-draft { background:#eef; color:#446; }
  438 +.s-running { background:#fff3cd; color:#7a5; }
  439 +.s-shipped { background:#d4f4dd; color:#0a6; }
  440 +.s-shipped-final { background:#0a6; color:#fff; }
  441 +.s-abandoned { background:#fbe; color:#922; }
  442 +.s-l2_blocked { background:#fdd; color:#a44; }
  443 +
  444 +.actions-col { width:240px; }
  445 +.actions-cell { display:flex; gap:6px; }
  446 +.actions-cell button { padding:4px 10px; font-size:12px; border:1px solid #ddd; background:#fff; border-radius:4px; cursor:pointer; color:#444; }
  447 +.actions-cell button:hover { background:#f4f4f6; border-color:#bbb; }
  448 +.actions-cell button.warn { color:#a60; border-color:#fc8; }
  449 +.actions-cell button.danger { color:#c00; border-color:#f99; }
  450 +
  451 +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:6px 14px; border-radius:5px; cursor:pointer; font-size:13px; }
  452 +button.primary:hover:not(:disabled) { background:#085; }
  453 +button.primary:disabled { background:#bbb; border-color:#bbb; color:#fff; opacity:0.7; cursor:not-allowed; }
  454 +
  455 +/* Modal */
  456 +.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; }
  457 +.modal { background:#fff; border-radius:8px; padding:20px 24px; min-width:480px; max-width:600px; box-shadow:0 8px 32px rgba(0,0,0,0.25); }
  458 +.modal.sm { min-width:360px; }
  459 +.modal h2 { font-size:16px; margin:0 0 16px 0; }
  460 +.modal label { display:block; margin-bottom:12px; font-size:13px; color:#444; }
  461 +.modal label.inline { display:flex; align-items:center; gap:8px; }
  462 +.modal input[type="text"], .modal input:not([type]), .modal textarea, .modal select { display:block; width:100%; margin-top:4px; padding:6px 10px; border:1px solid #ccc; border-radius:4px; font-size:13px; box-sizing:border-box; }
  463 +.modal textarea { resize:vertical; font-family:inherit; }
  464 +.modal .err { color:#c00; font-size:12px; margin:8px 0; padding:6px 10px; background:#fee; border-radius:4px; }
  465 +.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:18px; }
  466 +.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; }
  467 +.modal-actions button:hover:not(:disabled) { background:#f4f4f6; }
  468 +.modal-actions button:disabled { opacity:0.5; cursor:not-allowed; }
  469 +.modal-actions button.ghost { color:#0a6; border-color:#9d6; }
  470 +.modal-actions button.ghost:hover:not(:disabled) { background:#f0fff5; }
  471 +
  472 +/* path cell */
  473 +.path-cell { max-width:300px; }
  474 +.path { display:inline-block; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle; font-size:11px; }
  475 +
  476 +/* storage fieldset */
  477 +.modal fieldset.storage { border:1px solid #e5e5e8; border-radius:6px; padding:12px 14px; margin-bottom:14px; }
  478 +.modal fieldset.storage legend { padding:0 6px; color:#555; font-size:12px; font-weight:600; }
  479 +.modal fieldset.storage label.inline { margin-bottom:8px; }
  480 +.modal fieldset.storage code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-size:11px; }
  481 +.modal .linked-row { display:flex; gap:8px; margin-top:4px; }
  482 +.modal .linked-row input { flex:1; padding:6px 10px; border:1px solid #ccc; border-radius:4px; font-size:13px; font-family: ui-monospace, monospace; }
  483 +.modal .linked-row button.ghost { padding:6px 12px; border:1px solid #9d6; color:#0a6; background:#fff; border-radius:4px; cursor:pointer; font-size:13px; }
  484 +.modal .linked-row button.ghost:hover { background:#f0fff5; }
  485 +.modal .hint { color:#888; font-size:11px; margin-top:6px; line-height:1.5; }
  486 +
  487 +/* directory browser */
  488 +.modal.browser { min-width:560px; max-width:720px; }
  489 +.modal.browser .bcrumb { display:flex; gap:6px; align-items:center; margin-bottom:8px; }
  490 +.modal.browser .bcrumb button.ghost { padding:4px 10px; border:1px solid #ccc; background:#fff; border-radius:4px; font-size:12px; cursor:pointer; }
  491 +.modal.browser .bcrumb button.ghost:hover:not(:disabled) { background:#f4f4f6; }
  492 +.modal.browser .bcrumb .path-input { flex:1; padding:5px 10px; border:1px solid #ccc; border-radius:4px; font-size:12px; font-family: ui-monospace, monospace; }
  493 +.modal.browser .cur-path { font-size:12px; color:#666; margin:6px 0; }
  494 +.modal.browser .entries { border:1px solid #eee; border-radius:6px; padding:6px 0; max-height:320px; min-height:180px; overflow-y:auto; background:#fafafa; }
  495 +.modal.browser .entry { display:flex; align-items:center; gap:10px; padding:6px 14px; cursor:pointer; user-select:none; }
  496 +.modal.browser .entry:hover { background:#eef; }
  497 +.modal.browser .entry .ico { font-size:13px; }
  498 +.modal.browser .entry .ename { font-size:13px; }
  499 +.modal.browser .entry .ename.active { color:#0a6; font-weight:600; }
  500 +.modal.browser .entry .empty-tag { font-size:10px; color:#0a6; background:#dfd; padding:1px 6px; border-radius:8px; margin-left:auto; }
  501 +.modal.browser .empty-mini { color:#888; font-size:12px; padding:24px; text-align:center; }
  502 +</style>
... ...