Commit bb371e032c014f9c963926affd1d9fd48c3c73be

Authored by yibo
1 parent 85fe344b

feat(devloop): real-CLI mode wiring + project CRUD routes + Projects list view

Two parallel pieces of plumbing — neither has been exercised end-to-end yet
(real CLI gated on `claude /login`; Projects list view served alongside
existing pages):

──── real-CLI wire-up (REAL.1 + REAL.2) ────

LemonBoss now has a clean toggle between mock & real Claude CLI:

- packages/acp-core/protocol/ClaudeCliProtocol.js
  * `options.systemPrompt` → `--append-system-prompt <prompt>` so each
    devloop agent receives its role-specific prompt.md without losing
    Claude's default tool-use conventions.
  * `options.maxBudgetUsd` → `--max-budget-usd` (note: only effective in
    `--print` mode; documented inline).

- server/src/core/devloop/loop-controller.js
  * callAgent now branches on `opts.mock`:
    - mock (P1-P5 path, untouched): fixture sequence + `mock/files` event
    - real: read system/templates/{agent}/prompt.md, pass as systemPrompt,
      snapshot project file mtimes pre-call, diff post-call to compute
      `files_written` — no fixture, no mock/files needed.
  * `_snapshotMtimes(root)` helper walks the project tree (skipping
    .git / node_modules / .cache / .tmp) and returns Map<relPath,mtime>.
  * timeline event payload now carries `real: true` flag.

- scripts/real-pm-smoke.js — minimal real-CLI sanity:
  spawns claude, injects PM prompt, prints which files were written +
  git commit sha. Hard timeout 5min. Single agent → expected spend
  $0.10-0.30 per run when authenticated.

Current status: process spawn + system-prompt injection + stream-json
flow all verified up to the `Not logged in · Please run /login` gate.
Auth is the only remaining blocker for executing the real smoke.

──── project CRUD routes ────

server/routes/devloop.js + server/services/devloop/path-resolver.js
add the lifecycle pieces the Projects list view needs:

- GET    /projects?include_archived=true   list w/ archive filter
- PATCH  /projects/:pid                    edit name + idea_summary
- DELETE /projects/:pid                    soft delete (archive)
- DELETE /projects/:pid?hard=true          hard delete (rm -rf sandbox;
                                            unregister linked)
- POST   /projects/:pid/restore            un-archive

path-resolver exposes matching helpers: listProjects({includeArchived}),
patchProjectMeta(pid,patch), hardDeleteProject(pid).

──── UI ────

- web/src/views/DevLoopProjects.vue (320 lines, new)  — Projects list
- web/src/router/index.js — adds `/devloop` → DevLoopProjects
- web/src/components/devloop/DashboardHeader.vue — back-link now points
  to `/devloop` (project list) instead of `/company`.

──── housekeeping ────

- CLAUDE.md / start.sh — start.sh now boots server/index.js (full route
  surface incl. /api/devloop) instead of the historical agent-server.js;
  docs updated to reflect this.

──── tests (+12) ────

- server/tests/devloop/routes-crud.test.js — 12 cases:
  PATCH validation (4) · soft DELETE + list filter (4) · POST /restore
  (1) · hard DELETE (2) · GET /projects default behavior (1).

Results:
- phase0-verify .. phase5-verify: 8+7+7+6+9+9+7 all pass
- vitest devloop+acp-core: 230/230 (was 218 → +12)

Specs:
- docs/prd/newfunction/_spec/arch-acp-bridge.md · Mock Mode
- docs/prd/newfunction/_spec/plan-migration.md · "自举乐趣" (real path
  prep)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 的进程。
... ... @@ -58,17 +61,17 @@ auto-dev &quot;&lt;task&gt;&quot; # bin 入口 → server/src/cli/auto-dev.js
58 61  
59 62 | 入口 | 端口 | 启动方式 | 挂载路由 |
60 63 |------|------|----------|----------|
61   -| `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/` 静态文件 |
62   -| `server/agent-server.js` | 3010 | `bash start.sh` | 仅 LemonBoss 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` |
  64 +| `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/` 静态文件 |
  65 +| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/goals` `/api/tasks` `/api/devloop` |
63 66  
64   -两者**不要同时启动**(端口冲突)。需要 Skill / Arena / Master Agent 时跑 `index.js`;只调通 Agent Chat / Company / OKR 时跑 `agent-server.js`。
  67 +两者**不要同时启动**(端口冲突)。默认走 `index.js`(`start.sh` / `npm run dev` 都指向它);`agent-server.js` 只在你想跑无依赖最小子集时手动启动。
65 68  
66 69 ### 目录速查
67 70  
68 71 | 目录 | 说明 |
69 72 |------|------|
70   -| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管) |
71   -| `server/agent-server.js` | 独立 Agent Chat 服务(LemonBoss 子集) |
  73 +| `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管),`start.sh` / `npm run dev` 默认入口 |
  74 +| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks / DevLoop),仅手动启动 |
72 75 | `server/routes/` | 15 个 API 路由 |
73 76 | `server/services/` | 26 个业务服务(skill-db, benchmark-runner, company-service, heartbeat, pipeline-service, retrain-pipeline, …) |
74 77 | `server/src/core/` | Agent 引擎核心(orchestrator, master-session, agent-manager, scheduler, session-pool, prompt-builder, runtime-resolver, …) |
... ... @@ -115,7 +118,7 @@ auto-dev &quot;&lt;task&gt;&quot; # bin 入口 → server/src/cli/auto-dev.js
115 118 ## 注意事项
116 119  
117 120 - 生产环境只跑 `server/index.js`:它会同时托管 API + `web/dist/` 静态文件(`NODE_ENV=production`)。
118   -- `start.sh` **只**做:kill 端口 → 起 `agent-server.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。
  121 +- `start.sh` **只**做:kill 占用 3010/5173 的进程 → 起 `server/index.js` → 起 `vite`。**不**会自动 `skills:sync` / `web:build` / 起 bot —— 那些要单独跑。
119 122 - `server/index.js` 与 `agent-server.js` 都内置了 **body-sanitizer 中间件**:在 `express.json()` 之前清掉 `\x00-\x1F` 控制字符,避免外部 Agent 通过 `curl` 传未转义换行/制表符导致的 `Bad control character` 报错。修改请求体解析链路前先看这两段。
120 123 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。
121 124 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。
... ...
packages/acp-core/protocol/ClaudeCliProtocol.js
... ... @@ -76,6 +76,19 @@ class ClaudeCliProtocol extends EventEmitter {
76 76 '--dangerously-skip-permissions',
77 77 ];
78 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 +
79 92 // 支持禁用特定原生工具 (用于编排型 Agent,强制纯文本输出)
80 93 if (this.options.disallowedTools && this.options.disallowedTools.length > 0) {
81 94 args.push('--disallowedTools', ...this.options.disallowedTools);
... ...
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 +const fs = require('fs');
  22 +const path = require('path');
  23 +
  24 +const lc = require('../server/src/core/devloop/loop-controller');
  25 +const pr = require('../server/services/devloop/path-resolver');
  26 +
  27 +// --max-budget-usd 仅在 --print 模式可用,与我们的 stream-json 长连接互斥
  28 +// → 用进程超时 (5min) + 一次性单 agent 调用兜底
  29 +const HARD_TIMEOUT_MS = 5 * 60 * 1000;
  30 +const cleanup = [];
  31 +
  32 +async function main() {
  33 + console.log('\n┌─ Real Claude CLI · PM Smoke ────────────────────');
  34 + console.log(`│ Hard timeout: 5min (no maxBudgetUsd — only --print mode)`);
  35 + console.log(`│ Model: claude default`);
  36 + console.log('└──────────────────────────────────────────────────\n');
  37 +
  38 + const cid = 'c-real-pm-' + Date.now().toString(36);
  39 + cleanup.push(cid);
  40 + const proj = await lc.createProject({
  41 + company_id: cid,
  42 + mode: 'sandbox',
  43 + idea: 'real PM smoke: minimal TODO API + Vue UI',
  44 + });
  45 + console.log(`pid = ${proj.pid}`);
  46 + console.log(`root = ${pr.resolveProjectRoot(proj.pid)}\n`);
  47 +
  48 + const userPrompt = [
  49 + 'Mode: init',
  50 + 'Requirement: A minimal TODO list — POST /todos (add) + GET /todos (list).',
  51 + '',
  52 + '约束 (please obey for this smoke test):',
  53 + ' • cwd 已经是项目根,直接相对路径写文件',
  54 + ' • 至少写 prd/index.html 一个最小 HTML stub (含 <article data-doc="prd" data-rev="1"><section data-rev="1"><h3>...</h3><p>...</p></section></article>)',
  55 + ' • 不要尝试调 hash_contract / bootstrap_prd_site 等 LemonBoss 内部工具 — 它们在本 session 不可用',
  56 + ' • 不要执行 npm install / git / curl 等 Bash 命令',
  57 + ' • 完成后直接 stop, 不需要总结',
  58 + '',
  59 + '回 "DONE" 一词即可结束。',
  60 + ].join('\n');
  61 +
  62 + console.log('▸ calling devloop-pm via real claude CLI...');
  63 + console.log(` (system prompt: system/templates/devloop-pm/prompt.md, ~3KB)\n`);
  64 +
  65 + const startMs = Date.now();
  66 + let chunkCount = 0;
  67 + const r = await Promise.race([
  68 + lc.callAgent(proj.pid, 'devloop-pm', userPrompt, {
  69 + mock: false,
  70 + onChunk: () => { chunkCount++; },
  71 + }),
  72 + new Promise((_, rej) =>
  73 + setTimeout(() => rej(new Error('hard timeout 5min reached')), HARD_TIMEOUT_MS),
  74 + ),
  75 + ]);
  76 + const elapsedSec = Math.round((Date.now() - startMs) / 1000);
  77 +
  78 + console.log('\n▸ result:');
  79 + console.log(` elapsed: ${elapsedSec}s`);
  80 + console.log(` stream chunks: ${chunkCount}`);
  81 + console.log(` result text: ${r.result_text.slice(0, 120).replace(/\n/g, ' ')}${r.result_text.length > 120 ? '…' : ''}`);
  82 + console.log(` files_written: ${r.files_written.length}`);
  83 + for (const f of r.files_written.slice(0, 10)) console.log(` · ${f}`);
  84 + if (r.files_written.length > 10) console.log(` · (+${r.files_written.length - 10} more)`);
  85 + console.log(` commit: ${r.commit?.sha?.slice(0, 7) || '(none)'}`);
  86 +
  87 + console.log('\n┌─ Wire-up status ────────────────────────────────');
  88 + const checks = [
  89 + ['process spawn', true],
  90 + ['stream chunks', chunkCount > 0],
  91 + ['result text', r.result_text.length > 0],
  92 + ['files written', r.files_written.length > 0],
  93 + ['git commit', !!r.commit?.sha],
  94 + ];
  95 + let pass = 0;
  96 + for (const [k, ok] of checks) {
  97 + console.log(`│ ${ok ? '✓' : '✗'} ${k}`);
  98 + if (ok) pass++;
  99 + }
  100 + console.log(`└─ ${pass}/${checks.length} checks passed\n`);
  101 +
  102 + console.log('Artifacts retained for inspection:');
  103 + console.log(` ${pr.resolveProjectRoot(proj.pid)}`);
  104 + console.log(` cleanup: rm -rf data/companies/${cid}\n`);
  105 +
  106 + process.exit(pass === checks.length ? 0 : 1);
  107 +}
  108 +
  109 +main().catch(e => {
  110 + console.error('\n[real-pm-smoke] FATAL:', e?.message || e);
  111 + console.error(e?.stack);
  112 + process.exit(1);
  113 +});
... ...
server/routes/devloop.js
... ... @@ -73,12 +73,62 @@ router.post(&#39;/projects&#39;, h(async (req, res) =&gt; {
73 73 });
74 74 }));
75 75  
76   -// GET /api/devloop/projects
77   -router.get('/projects', h(async (_req, res) => {
78   - const projects = pathResolver.listProjects();
  76 +// GET /api/devloop/projects?include_archived=true
  77 +router.get('/projects', h(async (req, res) => {
  78 + const includeArchived = String(req.query.include_archived || '') === 'true';
  79 + const projects = pathResolver.listProjects({ includeArchived });
79 80 res.json({ projects });
80 81 }));
81 82  
  83 +// PATCH /api/devloop/projects/:pid
  84 +// body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback)
  85 +router.patch('/projects/:pid', h(async (req, res) => {
  86 + const { pid } = req.params;
  87 + try { pathResolver.resolveProjectRoot(pid); }
  88 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  89 + const allowed = ['name', 'idea_summary'];
  90 + const patch = {};
  91 + for (const k of allowed) {
  92 + if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) {
  93 + const v = req.body[k];
  94 + if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` });
  95 + patch[k] = v.slice(0, 500);
  96 + }
  97 + }
  98 + if (Object.keys(patch).length === 0) {
  99 + return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' });
  100 + }
  101 + const meta = pathResolver.patchProjectMeta(pid, patch);
  102 + res.json({ ok: true, pid, project: meta });
  103 +}));
  104 +
  105 +// DELETE /api/devloop/projects/:pid?hard=true
  106 +// 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked)
  107 +router.delete('/projects/:pid', h(async (req, res) => {
  108 + const { pid } = req.params;
  109 + const hard = String(req.query.hard || '') === 'true';
  110 + try { pathResolver.resolveProjectRoot(pid); }
  111 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  112 + if (hard) {
  113 + const r = pathResolver.hardDeleteProject(pid);
  114 + return res.json({ ok: true, pid, hard: true, ...r });
  115 + }
  116 + const meta = pathResolver.patchProjectMeta(pid, {
  117 + archived: true,
  118 + archived_at: new Date().toISOString(),
  119 + });
  120 + res.json({ ok: true, pid, hard: false, archived: true, project: meta });
  121 +}));
  122 +
  123 +// POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复
  124 +router.post('/projects/:pid/restore', h(async (req, res) => {
  125 + const { pid } = req.params;
  126 + try { pathResolver.resolveProjectRoot(pid); }
  127 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  128 + const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null });
  129 + res.json({ ok: true, pid, archived: false, project: meta });
  130 +}));
  131 +
82 132 // POST /api/devloop/projects/:pid/run[?only=<unitId>] [body: { mockFixtureDir? }]
83 133 // - 不带 only: 跑完整 runProject (P3)
84 134 // - only=<unitId>: 单 unit drill-down (P2 兼容)
... ...
server/services/devloop/path-resolver.js
... ... @@ -282,9 +282,76 @@ function unregisterLinkedProject(pid) {
282 282 if (cache) cache.delete(pid);
283 283 }
284 284  
285   -function listProjects() {
  285 +/**
  286 + * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。
  287 + * 默认过滤已 archived 的项目;includeArchived=true 时全返。
  288 + */
  289 +function listProjects({ includeArchived = false } = {}) {
286 290 ensureInit();
287   - return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry }));
  291 + const out = [];
  292 + for (const [pid, entry] of cache.entries()) {
  293 + const meta = _readMetaSafe(entry.root_path);
  294 + if (!includeArchived && meta && meta.archived) continue;
  295 + out.push({
  296 + pid,
  297 + ...entry,
  298 + name: meta?.name || meta?.idea_summary || pid,
  299 + idea_summary: meta?.idea_summary || '',
  300 + status: meta?.status || 'draft',
  301 + created_at: meta?.created_at || null,
  302 + archived: !!meta?.archived,
  303 + archived_at: meta?.archived_at || null,
  304 + });
  305 + }
  306 + // 倒序:最近创建的在前
  307 + out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
  308 + return out;
  309 +}
  310 +
  311 +function _readMetaSafe(rootPath) {
  312 + try {
  313 + const p = path.join(rootPath, 'project.json');
  314 + if (!fs.existsSync(p)) return null;
  315 + return JSON.parse(fs.readFileSync(p, 'utf8'));
  316 + } catch { return null; }
  317 +}
  318 +
  319 +/**
  320 + * 修改 project.json 的字段(name / idea_summary / archived 等)。
  321 + * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。
  322 + */
  323 +function patchProjectMeta(pid, patch = {}) {
  324 + ensureInit();
  325 + const root = resolveProjectRoot(pid);
  326 + const metaPath = path.join(root, 'project.json');
  327 + if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`);
  328 + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
  329 + Object.assign(meta, patch);
  330 + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
  331 + return meta;
  332 +}
  333 +
  334 +/**
  335 + * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。
  336 + * 返回 { mode, removed_disk: boolean }
  337 + */
  338 +function hardDeleteProject(pid) {
  339 + ensureInit();
  340 + const entry = cache.get(pid);
  341 + if (!entry) throw new Error(`Unknown project: ${pid}`);
  342 + let removed_disk = false;
  343 + if (entry.mode === 'sandbox') {
  344 + if (fs.existsSync(entry.root_path)) {
  345 + fs.rmSync(entry.root_path, { recursive: true, force: true });
  346 + removed_disk = true;
  347 + }
  348 + } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') {
  349 + // 只解注册,不动用户自己挑的目录
  350 + const reg = loadLinkedRegistry();
  351 + if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); }
  352 + }
  353 + cache.delete(pid);
  354 + return { mode: entry.mode, removed_disk };
288 355 }
289 356  
290 357 /** 测试用:重置缓存,强制下次重新扫描 */
... ... @@ -316,6 +383,8 @@ module.exports = {
316 383 allocateProjectRoot,
317 384 unregisterLinkedProject,
318 385 listProjects,
  386 + patchProjectMeta,
  387 + hardDeleteProject,
319 388 isSensitivePath,
320 389 _resetForTesting,
321 390 // 常量
... ...
server/src/core/devloop/loop-controller.js
... ... @@ -158,21 +158,31 @@ async function commitAfterAgent(pid, agentName, summary = &#39;&#39;) {
158 158 async function callAgent(pid, agentName, userPrompt, opts = {}) {
159 159 const projectRoot = pathResolver.resolveProjectRoot(pid);
160 160  
  161 + const useMock = opts.mock !== false;
  162 +
161 163 // 1. 系统提示 (可选 — 没文件就空串)
162 164 const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md');
163 165 const systemPrompt = fs.existsSync(promptPath)
164 166 ? fs.readFileSync(promptPath, 'utf8')
165 167 : '';
166   - const fullPrompt = systemPrompt
167   - ? `${systemPrompt}\n\n## User Task\n${userPrompt}`
  168 + // mock 模式: 把 system + user 拼一起喂 fixture (fixture 不在意);
  169 + // real 模式: systemPrompt 走 --append-system-prompt, userPrompt 走 stdin。
  170 + const fullPrompt = useMock
  171 + ? (systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt)
168 172 : userPrompt;
169 173  
170   - // 2. 启动 protocol
  174 + // 2. real 模式: 先快照工作树 mtime,Agent 跑完后做 diff
  175 + const preSnapshot = useMock ? null : _snapshotMtimes(projectRoot);
  176 +
  177 + // 3. 启动 protocol
171 178 const protocol = new ClaudeCliProtocol(
172 179 { name: agentName },
173 180 {
174   - mock: opts.mock !== false, // 默认 mock=true (P1+ 阶段)
175   - mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用
  181 + mock: useMock,
  182 + mockFixtureDir: opts.mockFixtureDir,
  183 + // real 模式专属:
  184 + systemPrompt: useMock ? undefined : systemPrompt,
  185 + maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限
176 186 },
177 187 );
178 188  
... ... @@ -197,36 +207,44 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) {
197 207 protocol.disconnect();
198 208 }
199 209  
200   - // 3. 落盘 fixture.files (path 相对 projectRoot)
  210 + // 4. 落盘 — mock 用 fixture.files, real 用 fs diff
201 211 const filesWritten = [];
202   - for (const f of filesFromMock) {
203   - if (!f?.path || typeof f.content !== 'string') continue;
204   - if (path.isAbsolute(f.path) || f.path.includes('..')) {
205   - throw new Error(`unsafe mock file path: ${f.path}`);
  212 + if (useMock) {
  213 + for (const f of filesFromMock) {
  214 + if (!f?.path || typeof f.content !== 'string') continue;
  215 + if (path.isAbsolute(f.path) || f.path.includes('..')) {
  216 + throw new Error(`unsafe mock file path: ${f.path}`);
  217 + }
  218 + const abs = path.join(projectRoot, f.path);
  219 + fs.mkdirSync(path.dirname(abs), { recursive: true });
  220 + let content = f.content;
  221 + if (f.path === 'units.json' || f.path.endsWith('/units.json')) {
  222 + try {
  223 + const u = JSON.parse(content);
  224 + u.project_id = pid;
  225 + content = JSON.stringify(u, null, 2);
  226 + } catch { /* leave as-is, downstream will fail validation */ }
  227 + }
  228 + fs.writeFileSync(abs, content);
  229 + filesWritten.push(f.path);
206 230 }
207   - const abs = path.join(projectRoot, f.path);
208   - fs.mkdirSync(path.dirname(abs), { recursive: true });
209   - let content = f.content;
210   - // 把 fixture 里的占位符替换成实际值 ({{pid}} / "_placeholder_")
211   - if (f.path === 'units.json' || f.path.endsWith('/units.json')) {
212   - try {
213   - const u = JSON.parse(content);
214   - u.project_id = pid;
215   - content = JSON.stringify(u, null, 2);
216   - } catch { /* leave as-is, downstream will fail validation */ }
  231 + } else {
  232 + // real 模式: 扫工作树找新增/修改文件 (相对项目根)
  233 + const postSnapshot = _snapshotMtimes(projectRoot);
  234 + for (const [rel, mtime] of postSnapshot) {
  235 + const before = preSnapshot.get(rel);
  236 + if (before === undefined || before < mtime) filesWritten.push(rel);
217 237 }
218   - fs.writeFileSync(abs, content);
219   - filesWritten.push(f.path);
220 238 }
221 239  
222   - // 4. timeline + commit
  240 + // 5. timeline + commit
223 241 try {
224 242 timelineLogger.append(pid, {
225 243 source: agentName,
226 244 kind: 'agent.call',
227   - ref: resultParams?.session_id || 'mock',
228   - summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (mock)`,
229   - payload: { files_written: filesWritten.length, mock: !!resultParams?.mock },
  245 + ref: resultParams?.session_id || (useMock ? 'mock' : 'real'),
  246 + summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (${useMock ? 'mock' : 'real'})`,
  247 + payload: { files_written: filesWritten.length, mock: !!resultParams?.mock, real: !useMock },
230 248 });
231 249 } catch { /* swallow */ }
232 250  
... ... @@ -926,6 +944,35 @@ async function submitFeedback(pid, text, opts = {}) {
926 944 };
927 945 }
928 946  
  947 +/**
  948 + * 扫项目根递归收集 (relPath → mtimeMs) Map。
  949 + * 忽略 .git / node_modules / agent-state/snapshots (高频写) 子树。
  950 + * real 模式 callAgent 用这个做 pre/post 快照,自动识别 Agent 写出来的新文件。
  951 + */
  952 +function _snapshotMtimes(root) {
  953 + const map = new Map();
  954 + const IGNORE_DIRS = new Set(['.git', 'node_modules', '.cache', '.tmp']);
  955 + function walk(dir, prefix) {
  956 + let entries;
  957 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  958 + catch { return; }
  959 + for (const e of entries) {
  960 + if (IGNORE_DIRS.has(e.name)) continue;
  961 + const rel = prefix ? `${prefix}/${e.name}` : e.name;
  962 + const full = path.join(dir, e.name);
  963 + if (e.isDirectory()) {
  964 + walk(full, rel);
  965 + } else if (e.isFile()) {
  966 + try {
  967 + map.set(rel, fs.statSync(full).mtimeMs);
  968 + } catch { /* ignore */ }
  969 + }
  970 + }
  971 + }
  972 + walk(root, '');
  973 + return map;
  974 +}
  975 +
929 976 function _readPrdRev(pid) {
930 977 const root = pathResolver.resolveProjectRoot(pid);
931 978 const idx = path.join(root, 'prd', 'index.html');
... ...
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 +});
... ...
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
1 1 <template>
2 2 <header class="dl-header">
3 3 <div class="left">
4   - <router-link to="/company" class="back">← LemonBoss</router-link>
  4 + <router-link to="/devloop" class="back">← 项目列表</router-link>
5 5 <h1>DevLoop · {{ pid }}</h1>
6 6 <span class="status-badge" :class="status">{{ status }}</span>
7 7 </div>
... ...
web/src/router/index.js
... ... @@ -69,7 +69,12 @@ const routes = [
69 69 name: 'QuotaMonitor',
70 70 component: () => import('@/views/QuotaMonitor.vue'),
71 71 },
72   - // P5 · DevLoop Dashboard + Ops
  72 + // P5 · DevLoop Projects 列表 + Dashboard + Ops
  73 + {
  74 + path: '/devloop',
  75 + name: 'DevLoopProjects',
  76 + component: () => import('@/views/DevLoopProjects.vue'),
  77 + },
73 78 {
74 79 path: '/devloop/:pid',
75 80 name: 'DevLoopDashboard',
... ...
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>Company</th>
  28 + <th>创建时间</th>
  29 + <th>PID</th>
  30 + <th class="actions-col">操作</th>
  31 + </tr>
  32 + </thead>
  33 + <tbody>
  34 + <tr v-for="p in projects" :key="p.pid" :class="{ archived: p.archived }">
  35 + <td class="name-cell">
  36 + <router-link :to="`/devloop/${p.pid}`" class="name-link">
  37 + {{ p.name || p.pid }}
  38 + </router-link>
  39 + <div v-if="p.idea_summary && p.idea_summary !== p.name" class="idea">{{ p.idea_summary }}</div>
  40 + </td>
  41 + <td><span :class="['status-pill', `s-${p.status}`]">{{ p.status }}</span></td>
  42 + <td><code>{{ p.mode }}</code></td>
  43 + <td><code>{{ p.company_id }}</code></td>
  44 + <td>{{ formatTime(p.created_at) }}</td>
  45 + <td><code class="pid">{{ p.pid }}</code></td>
  46 + <td class="actions-cell">
  47 + <button @click="openRename(p)">改名</button>
  48 + <button v-if="!p.archived" class="warn" @click="archive(p)">归档</button>
  49 + <button v-else @click="restore(p)">恢复</button>
  50 + <button class="danger" @click="hardDelete(p)">永删</button>
  51 + </td>
  52 + </tr>
  53 + </tbody>
  54 + </table>
  55 +
  56 + <!-- 新建项目弹窗 -->
  57 + <div v-if="showCreate" class="modal-mask" @click.self="closeCreate">
  58 + <div class="modal">
  59 + <h2>新建 DevLoop 项目</h2>
  60 + <label>
  61 + 公司
  62 + <select v-model="newProj.company_id">
  63 + <option value="">— 选择公司 —</option>
  64 + <option v-for="c in companies" :key="c.id" :value="c.id">
  65 + {{ c.name }} ({{ c.id }})
  66 + </option>
  67 + </select>
  68 + </label>
  69 + <label>
  70 + 项目名 (可选)
  71 + <input v-model="newProj.name" placeholder="如:购物车结算重构" />
  72 + </label>
  73 + <label>
  74 + 需求描述 *
  75 + <textarea v-model="newProj.requirement" rows="5"
  76 + placeholder="一句话或一段话描述这个项目要做什么。Agent 会按此生成 PRD/SDD。" />
  77 + </label>
  78 + <label class="inline">
  79 + <input type="checkbox" v-model="newProj.mock" />
  80 + 使用 mock 模式(不调真实 Claude,用 fixture 走通流程)
  81 + </label>
  82 + <div v-if="createError" class="err">{{ createError }}</div>
  83 + <div class="modal-actions">
  84 + <button @click="closeCreate" :disabled="creating">取消</button>
  85 + <button class="primary" :disabled="creating || !canCreate" @click="submitCreate">
  86 + {{ creating ? '创建中…' : '创建' }}
  87 + </button>
  88 + </div>
  89 + </div>
  90 + </div>
  91 +
  92 + <!-- 改名弹窗 -->
  93 + <div v-if="renameTarget" class="modal-mask" @click.self="renameTarget = null">
  94 + <div class="modal sm">
  95 + <h2>改名 — {{ renameTarget.pid }}</h2>
  96 + <label>
  97 + 名称
  98 + <input v-model="renameValue" />
  99 + </label>
  100 + <div v-if="renameError" class="err">{{ renameError }}</div>
  101 + <div class="modal-actions">
  102 + <button @click="renameTarget = null" :disabled="renaming">取消</button>
  103 + <button class="primary" :disabled="renaming || !renameValue" @click="submitRename">
  104 + {{ renaming ? '保存中…' : '保存' }}
  105 + </button>
  106 + </div>
  107 + </div>
  108 + </div>
  109 + </div>
  110 +</template>
  111 +
  112 +<script setup>
  113 +import { ref, computed, onMounted } from 'vue';
  114 +
  115 +const projects = ref([]);
  116 +const companies = ref([]);
  117 +const error = ref('');
  118 +const loading = ref(false);
  119 +const includeArchived = ref(false);
  120 +
  121 +const showCreate = ref(false);
  122 +const creating = ref(false);
  123 +const createError = ref('');
  124 +const newProj = ref({ company_id: '', name: '', requirement: '', mock: true });
  125 +const canCreate = computed(() => newProj.value.company_id && newProj.value.requirement.trim());
  126 +
  127 +const renameTarget = ref(null);
  128 +const renameValue = ref('');
  129 +const renaming = ref(false);
  130 +const renameError = ref('');
  131 +
  132 +async function fetchProjects() {
  133 + loading.value = true;
  134 + try {
  135 + const url = `/api/devloop/projects${includeArchived.value ? '?include_archived=true' : ''}`;
  136 + const r = await fetch(url);
  137 + if (!r.ok) throw new Error(`HTTP ${r.status}`);
  138 + const j = await r.json();
  139 + projects.value = j.projects || [];
  140 + error.value = '';
  141 + } catch (e) {
  142 + error.value = '加载项目失败: ' + e.message;
  143 + } finally {
  144 + loading.value = false;
  145 + }
  146 +}
  147 +
  148 +async function fetchCompanies() {
  149 + try {
  150 + const r = await fetch('/api/company/list');
  151 + if (!r.ok) return;
  152 + const j = await r.json();
  153 + companies.value = Array.isArray(j) ? j : (j.companies || []);
  154 + } catch { /* 忽略,用户可手填 pid */ }
  155 +}
  156 +
  157 +function openCreate() {
  158 + newProj.value = { company_id: '', name: '', requirement: '', mock: true };
  159 + createError.value = '';
  160 + showCreate.value = true;
  161 +}
  162 +function closeCreate() {
  163 + if (creating.value) return;
  164 + showCreate.value = false;
  165 +}
  166 +
  167 +async function submitCreate() {
  168 + creating.value = true;
  169 + createError.value = '';
  170 + try {
  171 + const r = await fetch('/api/devloop/projects', {
  172 + method: 'POST',
  173 + headers: { 'Content-Type': 'application/json' },
  174 + body: JSON.stringify({
  175 + company_id: newProj.value.company_id,
  176 + name: newProj.value.name || undefined,
  177 + requirement: newProj.value.requirement.trim(),
  178 + mock: newProj.value.mock,
  179 + }),
  180 + });
  181 + const j = await r.json();
  182 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  183 + showCreate.value = false;
  184 + await fetchProjects();
  185 + } catch (e) {
  186 + createError.value = e.message;
  187 + } finally {
  188 + creating.value = false;
  189 + }
  190 +}
  191 +
  192 +function openRename(p) {
  193 + renameTarget.value = p;
  194 + renameValue.value = p.name || '';
  195 + renameError.value = '';
  196 +}
  197 +
  198 +async function submitRename() {
  199 + if (!renameTarget.value) return;
  200 + renaming.value = true;
  201 + renameError.value = '';
  202 + try {
  203 + const r = await fetch(`/api/devloop/projects/${renameTarget.value.pid}`, {
  204 + method: 'PATCH',
  205 + headers: { 'Content-Type': 'application/json' },
  206 + body: JSON.stringify({ name: renameValue.value }),
  207 + });
  208 + const j = await r.json();
  209 + if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
  210 + renameTarget.value = null;
  211 + await fetchProjects();
  212 + } catch (e) {
  213 + renameError.value = e.message;
  214 + } finally {
  215 + renaming.value = false;
  216 + }
  217 +}
  218 +
  219 +async function archive(p) {
  220 + if (!confirm(`归档项目 "${p.name || p.pid}"?项目数据保留,可在"显示已归档"中恢复。`)) return;
  221 + const r = await fetch(`/api/devloop/projects/${p.pid}`, { method: 'DELETE' });
  222 + if (!r.ok) {
  223 + const j = await r.json().catch(() => ({}));
  224 + return alert('归档失败: ' + (j.error || r.status));
  225 + }
  226 + await fetchProjects();
  227 +}
  228 +
  229 +async function restore(p) {
  230 + const r = await fetch(`/api/devloop/projects/${p.pid}/restore`, { method: 'POST' });
  231 + if (!r.ok) {
  232 + const j = await r.json().catch(() => ({}));
  233 + return alert('恢复失败: ' + (j.error || r.status));
  234 + }
  235 + await fetchProjects();
  236 +}
  237 +
  238 +async function hardDelete(p) {
  239 + const msg = p.mode === 'sandbox'
  240 + ? `永久删除 "${p.name || p.pid}"?\n\n会递归删除目录:\n${p.root_path}\n\n此操作不可撤销!`
  241 + : `从 DevLoop 注册表中移除 "${p.name || p.pid}"?\n\n(linked 模式:你的目录 ${p.root_path} 会保留)`;
  242 + if (!confirm(msg)) return;
  243 + const r = await fetch(`/api/devloop/projects/${p.pid}?hard=true`, { method: 'DELETE' });
  244 + if (!r.ok) {
  245 + const j = await r.json().catch(() => ({}));
  246 + return alert('删除失败: ' + (j.error || r.status));
  247 + }
  248 + await fetchProjects();
  249 +}
  250 +
  251 +function formatTime(iso) {
  252 + if (!iso) return '—';
  253 + try {
  254 + const d = new Date(iso);
  255 + 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')}`;
  256 + } catch { return iso; }
  257 +}
  258 +
  259 +onMounted(() => {
  260 + fetchProjects();
  261 + fetchCompanies();
  262 +});
  263 +</script>
  264 +
  265 +<style scoped>
  266 +.devloop-projects { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  267 +.hdr { display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; border-bottom:1px solid #eee; }
  268 +.hdr h1 { font-size:20px; margin:0; }
  269 +.hdr-actions { display:flex; align-items:center; gap:14px; }
  270 +.archived-toggle { font-size:13px; color:#666; display:flex; align-items:center; gap:6px; cursor:pointer; }
  271 +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:12px 0; }
  272 +
  273 +.empty { text-align:center; color:#888; padding:48px 0; font-size:14px; }
  274 +
  275 +.proj-table { width:100%; border-collapse:collapse; margin-top:16px; background:#fff; }
  276 +.proj-table th, .proj-table td { padding:10px 12px; border-bottom:1px solid #eee; text-align:left; font-size:13px; vertical-align:middle; }
  277 +.proj-table th { background:#fafafa; color:#555; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; }
  278 +.proj-table tr.archived { opacity:0.55; background:#fafafa; }
  279 +.proj-table tr:hover:not(.archived) { background:#fafbff; }
  280 +
  281 +.name-cell { min-width:240px; }
  282 +.name-link { color:#0a6; text-decoration:none; font-weight:500; }
  283 +.name-link:hover { text-decoration:underline; }
  284 +.idea { color:#888; font-size:12px; margin-top:2px; max-width:340px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  285 +
  286 +.pid, code { font-family: ui-monospace, SFMono-Regular, monospace; font-size:12px; background:#f4f4f6; padding:1px 6px; border-radius:3px; }
  287 +
  288 +.status-pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; background:#eee; color:#444; }
  289 +.s-draft { background:#eef; color:#446; }
  290 +.s-running { background:#fff3cd; color:#7a5; }
  291 +.s-shipped { background:#d4f4dd; color:#0a6; }
  292 +.s-shipped-final { background:#0a6; color:#fff; }
  293 +.s-abandoned { background:#fbe; color:#922; }
  294 +.s-l2_blocked { background:#fdd; color:#a44; }
  295 +
  296 +.actions-col { width:240px; }
  297 +.actions-cell { display:flex; gap:6px; }
  298 +.actions-cell button { padding:4px 10px; font-size:12px; border:1px solid #ddd; background:#fff; border-radius:4px; cursor:pointer; color:#444; }
  299 +.actions-cell button:hover { background:#f4f4f6; border-color:#bbb; }
  300 +.actions-cell button.warn { color:#a60; border-color:#fc8; }
  301 +.actions-cell button.danger { color:#c00; border-color:#f99; }
  302 +
  303 +button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:6px 14px; border-radius:5px; cursor:pointer; font-size:13px; }
  304 +button.primary:hover:not(:disabled) { background:#085; }
  305 +button.primary:disabled { opacity:0.5; cursor:not-allowed; }
  306 +
  307 +/* Modal */
  308 +.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; }
  309 +.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); }
  310 +.modal.sm { min-width:360px; }
  311 +.modal h2 { font-size:16px; margin:0 0 16px 0; }
  312 +.modal label { display:block; margin-bottom:12px; font-size:13px; color:#444; }
  313 +.modal label.inline { display:flex; align-items:center; gap:8px; }
  314 +.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; }
  315 +.modal textarea { resize:vertical; font-family:inherit; }
  316 +.modal .err { color:#c00; font-size:12px; margin:8px 0; padding:6px 10px; background:#fee; border-radius:4px; }
  317 +.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:18px; }
  318 +.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; }
  319 +.modal-actions button:hover:not(:disabled) { background:#f4f4f6; }
  320 +</style>
... ...