diff --git a/CLAUDE.md b/CLAUDE.md index a0e149a..b77427b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ npm run dev # 后端 server/index.js:3010 + 前端 vite:51 # 等价快捷脚本(先 kill 占用端口再起) bash start.sh # server/index.js:3010 + vite:5173 -# (历史轻量入口,仅 Agent Chat / Company / OKR / Tasks / DevLoop 子集) +# (历史轻量入口,仅 Agent Chat / Company / OKR / Tasks 子集) PORT=3010 node server/agent-server.js ``` @@ -44,10 +44,6 @@ npx vitest run -t "test name" # 按名称过滤单个测试用例 # 扫描该目录的守卫测试会被瞬时竞态打成偶发假失败(详见 docs/done/claude-cli-session-leak.md §七) DISABLE_BACKGROUND_AGENTS=1 bash start.sh -# DevLoop 规范 -npm run spec:extract # 从 docs/prd/newfunction/*.html 重新生成 _spec/*.md(改完 HTML 后必须跑) -npm run spec:check # CI/pre-commit 用,校验 _spec/ 与 HTML 是否同步 - # CLI / 工具 npm run skills:sync # 同步 Skill 到 Claude CLI 配置目录 npm run graph:seed # 按 system/agents/ 目录重建图谱种子数据 @@ -66,8 +62,8 @@ npm run okr:migrate # goals → okr 存量迁移 | 入口 | 端口 | 启动方式 | 挂载路由 | |------|------|----------|----------| -| `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/okr` `/api/company` `/api/graph-runs` `/api/tasks` `/api/devloop`,WS `/ws/claude`,插件 / 心跳 / 中断任务恢复,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 | -| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/okr` `/api/graph-runs` `/api/tasks` `/api/devloop` | +| `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/okr` `/api/company` `/api/graph-runs` `/api/tasks`,WS `/ws/claude`,插件 / 心跳 / 中断任务恢复,`NODE_ENV=production` 时托管 `web/dist/` 静态文件 | +| `server/agent-server.js` | 3010 | 仅手动 `node server/agent-server.js`(历史保留) | 子集:`/api/agent` `/api/company` `/api/okr` `/api/graph-runs` `/api/tasks` | 两者**不要同时启动**(端口冲突)。默认走 `index.js`(`start.sh` / `npm run dev` 都指向它);`agent-server.js` 只在你想跑无依赖最小子集时手动启动。 @@ -76,25 +72,21 @@ npm run okr:migrate # goals → okr 存量迁移 | 目录 | 说明 | |------|------| | `server/index.js` | 完整 ArenaSK 服务(API + WS + 插件 + 前端托管),`start.sh` / `npm run dev` 默认入口 | -| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks / DevLoop),仅手动启动 | +| `server/agent-server.js` | 历史保留的轻量子集入口(Agent / Company / Goals / Tasks),仅手动启动 | | `server/routes/` | 各 API 路由(挂载点见上表两个入口,以入口文件为准) | | `server/services/` | 业务服务(skill-db, benchmark-runner, company-service, heartbeat-service, company-heartbeat-service, pipeline-service, retrain-pipeline, graph-engine, …) | | `server/src/core/` | Agent 引擎核心(orchestrator, master-session, agent-manager, scheduler, session-pool, prompt-builder, runtime-resolver, …) | | `server/src/engine/` | 进程层(command-queue, idle-timer, process-guard, run-registry, failover-error) | | `server/src/plugins/` | 插件系统(plugin-manager, agent-bridge, config-bridge, bootstrap) | -| `server/src/core/devloop/` | DevLoop 编排核心:`loop-controller.js`(createProject / callAgent / runDocsPhase / runUnit) | -| `server/services/devloop/` | DevLoop 支撑服务:path-resolver, git-vault, unit-registry(FSM), timeline-logger, bug-tracker, doc-store, changelog-writer, sse-broker | -| `server/src/tools/devloop/` | DevLoop Agent 可调用工具(coder/tester/integrator/triager/doc/persistence-tools + tool-registry + sandbox) | -| `server/routes/devloop.js` | DevLoop REST:`/api/devloop/projects` | +| `server/services/devloop/path-resolver.js` / `doc-store.js` | DevLoop 退役(2026-09-17,[52 号文档](docs/done/system-design/52-devloop-retirement.md))后唯二保留的两个文件:`path-resolver.js` 是 `graph-engine.js` 项目根分配的实现,`doc-store.js` 的 `hashContract` 被 `graph-engine.js`/`goal-contract.js` 复用。其余 DevLoop 代码(`src/core/devloop/`、`src/tools/devloop/`、`services/devloop/` 下其余文件、`routes/devloop.js`)已删除 | | `system/agents/` | **全部 Agent 本体的唯一目录**。`_base/` 是元 Agent 骨架,`_registry.json` 只用于按 id 补 `benchmark_score`(**目录才是数据源头**,`graph-seed-service.seedAgents()` 直接扫目录)。`_` 前缀为保留 id,扫描时跳过 | -| `system/agents/devloop-*/` | 六个 DevLoop Agent(pm, architect, coder, tester, integrator, triager),每个含 `config.json` + `prompt.md` + 一份指路用的薄 `agent.md` | | `system/org-graph-templates/` `system/work-graph-templates/` | Org/Work graph 的骨架模板库——公司类型 / org node 职能 → 图形状的**唯一查询入口**,`orggraph-planning`/`workgraph-planning` 建图时读这里选型,人工排查"为什么现场规划出这张图"也从这里查起。每个模板的 `whenToUse` 字段是选型依据,加新模板见各自 `README.md`「加一个新模板」一节;改完 `work-graph-templates/*.json` 跑一遍 `npx vitest run server/tests/unit/template-refs.test.js`(CI 用,查 `systemAgentId` 引用不落空) | | `server/src/tools/` | Master Agent 可调用的工具(call-agent, marketplace, orchestrate, train-skill, …) | -| `web/src/views/` | 页面:AgentChat, AgentList, CompanyDashboard, GraphConsole, JobsPanel, OkrDashboard, PipelineEditor, QuotaMonitor, SkillsMarket, TaskTimeline + DevLoop 四页(Dashboard / Projects / NewRequirement / Ops)。TemplateAgents(手工模板管理页)已于 2026-08-23 随「system/templates/ 并入 system/agents/」一起下线,agent 本体现在完全走自动化招聘产生 | +| `web/src/views/` | 页面:AgentChat, AgentList, CompanyDashboard, GraphConsole, JobsPanel, OkrDashboard, PipelineEditor, QuotaMonitor, SkillsMarket, TaskTimeline。TemplateAgents(手工模板管理页)已于 2026-08-23 随「system/templates/ 并入 system/agents/」一起下线,agent 本体现在完全走自动化招聘产生。DevLoop 四页(Dashboard / Projects / NewRequirement / Ops)已随 DevLoop 退役一起删除 | | `packages/` | `acp-core`, `agent-core`, `evolution`, `plugin-bootstrap` | | `/../data/skills/` | Skill 仓库。**在仓库外面一层**,不是 `/data/skills/`(后者不存在)。路径由 `config.skillsDataDir` 控制,见下「数据存储」的两个 data 根 | | `scripts/` | 运维脚本(含 `sync-skills.js`) | -| `docs/` | 设计文档,**按状态分目录**:`done/`(已实现 / 长期参考手册,含 `system-design/` 01–26 章与 `user-guides/`)、`todo/`(当前主线,27/28 系列)、`todo/future/`(暂缓)、`_archive/`。另有 `prd/`(DevLoop 规格源,见上)。**索引在 [`docs/README.md`](docs/README.md),逐条带状态说明 —— 找文档从那里进,别在本表里加文件名**(这一行曾经列了 5 个具体文件,文档移进 `done/` 之后全成了死链) | +| `docs/` | 设计文档,**按状态分目录**:`done/`(已实现 / 长期参考手册,含 `system-design/` 01–26 章与 `user-guides/`)、`todo/`(当前主线,27/28 系列)、`todo/future/`(暂缓)、`_archive/`(含 DevLoop 退役归档,见下)。**索引在 [`docs/README.md`](docs/README.md),逐条带状态说明 —— 找文档从那里进,别在本表里加文件名**(这一行曾经列了 5 个具体文件,文档移进 `done/` 之后全成了死链) | ### 关键 API 路由 @@ -109,20 +101,19 @@ npm run okr:migrate # goals → okr 存量迁移 | `/api/auth/*` | JWT + GitHub OAuth | | `/ws/claude` | Claude 实时对话(仅 `index.js`) | | `/api/health` `/api/plugins` | 健康检查 / 插件状态 | -| `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) | | `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/done/system-design/29-dream-design.md`](docs/done/system-design/29-dream-design.md) | -### DevLoop 子系统 +### DevLoop 子系统(已退役) -多 Agent 自动开发流水线(PM → Architect → Coder → Tester → Integrator → Triager),目前处于 P2(单 Unit 循环)阶段,正朝 P3(跨 Unit runProject)推进。 +原多 Agent 自动开发流水线(PM → Architect → Coder → Tester → Integrator → Triager,L1/L2/L3 三层嵌套 loop)。 +**2026-09-17 整体退役**:本质是 loop workflow,已被 27 号系列的 graph workflow(org graph + work graph, +`graph-engine.js`)严格覆盖(loop 是 graph 的一个退化形状),不再需要维护独立实现,用户拍板不做 +27 号总纲原计划的 P6 迁移,直接删除。决策记录、退役范围、保留了什么/为什么见 +[`docs/done/system-design/52-devloop-retirement.md`](docs/done/system-design/52-devloop-retirement.md)。 -- **规格优先**:所有实现以 `docs/prd/newfunction/_spec/*.md` 为准(机器可读,由 `npm run spec:extract` 从同名 `*.html` 自动生成,**不要手改 `_spec/`**;改 HTML 后必须重跑 extract,否则 pre-commit hook 会拦)。跨切约定集中在 `_spec/CONVENTIONS.md`(ID 生成规则、错误响应形态、文件原子写入、并发锁策略、日志分流、命名风格)——写 DevLoop 代码前先读这份。 -- **项目根**:每个 DevLoop 项目是独立 git 仓库,由 `path-resolver.allocateProjectRoot` 分配(`sandbox` 或 `linked-*` 模式),`git-vault` 负责 init/commit,每个 Agent 收尾后自动 commit(`loop-controller.commitAfterAgent`,commit kind 按 agent 名映射:pm→prd, architect→sdd, coder/tester→code, integrator→integ, triager→triage)。 -- **Mock 模式**:P1-P2 阶段 Agent 调用默认走 `ClaudeCliProtocol({mock:true})`(`packages/acp-core/protocol/ClaudeCliProtocol.js`),不真正起 Claude CLI 子进程,而是从 `fixtures/devloop-mock/*.json` 回放固定的 chunks/files/result;显式传 `mock:false` 才会用真实 CLI。 -- **L1 单 Unit 循环**(`runUnit`,P2):Coder(implement) → Tester(test),失败时把上一次 failure + coder.log 摘要打回去重跑(retry-with-reflection),直到 tester 通过或耗尽 `retry_budget`(默认 5)。每次状态转换都过 `unit-registry` 的 FSM(coding → testing → passed/failed → blocked)。 -- **单元产物布局**:项目根下 `units/{unitId}/{spec.json, code/, contract.json, coder.log, runs/{runId}/result.json}`;跨 unit 只能读 `../{otherUnit}/contract.json`,禁止直接读别的 unit 的 `code/`(见 CONVENTIONS.md §12)。 -- **审计轨迹**:`timeline.ndjson`(append-only,永不改已写行)记录所有 Agent 调用 / commit / 状态转换事件,`bugs.ndjson` / `changelog.ndjson` 走同样的追加模式。 -- **测试**:`server/tests/devloop/**/*.test.js`(服务层 + 工具层单测)+ `server/tests/devloop/routes.test.js`(REST 层)。 +PRD/SDD 完整规格站点(原仓库根 docs/prd 目录)归档在 `docs/_archive/devloop-prd-2026-09-17/`。 +`server/services/devloop/path-resolver.js` / `doc-store.js` 两个文件因为被 `graph-engine.js` 依赖而保留, +见上方目录速查表那一行。 ### 数据存储(SQLite,无需 PostgreSQL) @@ -141,10 +132,10 @@ npm run okr:migrate # goals → okr 存量迁移 - `benchmark_logs.sqlite` — Benchmark 运行数据(**不是** `benchmark.sqlite`) - `trained_skills/` — 用户训练产物(按 userId 隔离) -**仓库内** `/data/` — LemonBoss 侧(company / okr / graph / devloop 都在这儿): +**仓库内** `/data/` — LemonBoss 侧(company / okr / graph 都在这儿): - `graph.sqlite` — 图谱库(`GRAPH_DB_PATH` 可覆盖) -- `companies/` — 公司态(含 DevLoop 项目根、agent memory) +- `companies/` — 公司态(含 graph-engine 项目根、agent memory;`projects/{pid}/` 这套目录布局历史上由 DevLoop 首创,`path-resolver.js` 保留后两边共用同一套分配逻辑) - `okr/` `logs/` `agents/` — OKR 索引 / 运行日志 / agent 运行态 会话持久化有**两套**,不是一处: @@ -173,7 +164,7 @@ npm run okr:migrate # goals → okr 存量迁移 守卫:`server/tests/unit/agent-chat-session-reclaim.test.js`。 - 模块别名 `@server` → `server/`(`module-alias` 注册),跨子包 require 时可用。 - Claude CLI 进程默认带 `--dangerously-skip-permissions`,写入受限路径前自己评估风险。 -- DevLoop 详见上方「DevLoop 子系统」一节;需要 wireframe / SVG 图表 / 完整人类阅读版时才回查 `_spec/*.md` 同名的 `*.html`。 +- DevLoop 已退役,详见上方「DevLoop 子系统(已退役)」一节与 52 号文档。 > [!WARNING] (Agentic Self-Correction Loop Prevention) > **Master Agent 必读**: 你目前存在一个已知的底层 Tool Calling JSON 解析缺陷。当你试图在 `Bash` tool 中使用带有多重双引号的复杂命令(例如 `curl -d '{"id":"lemonv", "name":"Lemonv"}'`)时,你的 JSON 转义会崩溃并导致你陷入"修复格式"的无限死循环,最终进程会被强制终止退出 (code=143)。 diff --git a/docs/README.md b/docs/README.md index 98110a9..24475b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ | [system-design/48-task-acceptance-contract-binding.md](./done/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归,已收口**(decomposed_work 链路 2026-09-02/回归补于 2026-09-03,剩余三条路径调研收口于 2026-09-03)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘),真实 `org-coder`→`org-validator` 全程跑通验证。**收口时追加调研**:`task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径经核实**不需要**同款强绑定——都走完整 org graph,`workgraph-planning` 的 `benchmark` 节点"不可裁"约定已天然保证有契约,强行加会跟 46 号文档保护的 `task-planning` 极简边界、快速派单"零 LLM"定位冲突。真正残留风险是"org graph 一定含 org-planner"目前只是提示词约定非代码强制,属于 org 拓扑保证问题,不在本文档范围。安全网(发现时就地补契约)按设计仍只留原则,`decomposed_work` 链路已被主力机制完全覆盖,不为不可达场景写代码 | | [system-design/27-p4-parallel-and-artifacts.md](./done/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — ✅ **2026-09-08 已彻底收口**,移入 `done/`(阶段 A/B/C 全部完成,含本轮补齐的同 agent 多实例隔离生产接线 + `advanceTree` 兄弟子 run 并发)。T5 账户欠费门本身已实现(可插拔桩子),只差接一个真实计费接口——用户拍板暂缓,见 `todo/future/` | | [system-design/27-p3b-real-run-runway.md](./done/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— ✅ **2026-09-08 全部收口**,移入 `done/`。§六「九条验收」全部验完(验收 9 见 §六quater:2 节点 org 图故意跳过 org-coder,`org-validator` 真实读项目根、四维度全判 0 分,engine 真实沿 org 层 fail 边把 `org-planner` 重置重跑,15 次真调用/218,887 tokens);正文里 `W5/W6/W7/W8/W9` 等更细粒度小项也已逐条核对——大半此前就被后续真跑验过、只是没回填,真正之前从未验过的只有 W8(重新规划组织按钮),本轮用隔离 worktree + 真 Claude CLI + 真实浏览器点击补验(详见「W8 浏览器真跑验收」,该节末尾记了一个真跑中顺手发现、尚待排查但不影响验收本身成立的前端细节:`insertNode` 重规划成同名角色时复用同一行 id,与文档里"每次换新 id"的旧设计假设不一致)。⛔/🟡 的历史段落读之前先看头部状态行 | +| [system-design/52-devloop-retirement.md](./done/system-design/52-devloop-retirement.md) | ✅ **DevLoop 子系统退役决策记录**(2026-09-17)。DevLoop 本质是 loop workflow(L1/L2/L3 三层嵌套),已被 27 号系列的 graph workflow 严格覆盖(loop 是 graph 的一个退化形状),用户拍板整体退役、不做 27 号总纲原计划的 P6 迁移。代码/路由/前端/测试套件已删除,`path-resolver.js`/`doc-store.js`(被 `graph-engine.js` 依赖)保留,PRD/SDD 规格站点整体归档到 `docs/_archive/devloop-prd-2026-09-17/` | | [system-design/46-mandatory-planning-and-task-decomposition.md](./done/system-design/46-mandatory-planning-and-task-decomposition.md) | ✅ **已收口**(2026-09-07)。Phase 0-5 全部实现。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索 + 按需 deep research(默认关闭)。**Phase 3 v2**:CEO 心跳直接发现+派发(push),子任务走轻量 work graph(`org-coder`+`org-validator` 两节点)。**Phase 4**:父任务被拆分后让位给子任务(新状态 `decomposed`)、预算按步数均分、父子终态汇总(`settleParentTaskIfAllChildrenDone`)、同父子任务独立并发坑位(上限 3,不占公司级槽位)——四项机制全部用真实数据验证过,包括最后补跑的父子终态汇总(两个真实子任务全部 `completed` 后父任务被正确自动结算成 `completed`)与第二次并发确认。过程中撞出并修复一个几乎必现的真缺陷(`markTaskDecomposed` 原本只认父任务还是 `queued`,但 task-scheduler 抢坑是毫秒级、`subtask-planning` 是数十秒的真实 LLM 调用,等它拆完时父任务几乎必然已经 `running`,接管必然失败)。**Phase 5**:看板加 `decomposed` 状态样式 + 子任务进度展示,父子树形复用已有通用逻辑,未改后端接口。回归过程中还牵出一个独立缺陷——`org-validator` 对同一种 benchmark 契约判断不稳定(5 次里 1 次误把源码路径当 `--target` 判 `target_not_json`,反手倒退归因给 `subtask-planning`)——已定位根因(`decomposed_work` 轻量图当时只有一条前进边,没有失败边,validator 判 fail 后无路可退)并修复(补 `validate --fail--> impl` 退回边,复用引擎已验证过的 `backward_count` 双层硬顶 + `remediation_note` 反思闭环,新增 3 条单测),详见文末补跑记录 | ## todo/(当前主线) @@ -69,7 +70,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),当时判断真并行只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事——2026-09-08 收口时又发现这个判断本身有两处误判(role 列其实早就落库、T1 的✅遗漏了 scope 参数的生产接线),三件小事(连同两处误判一起)已全部核实/补齐,见 [27-P4](./done/system-design/27-p4-parallel-and-artifacts.md)。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | | [50-competing-candidates-critic-synthesis-design.md](./todo/system-design/50-competing-candidates-critic-synthesis-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-04)。49 号文档提案 A 的展开:竞争候选骨架 JSON 形状草稿(`candidate-council.json`,未定案)、命名(按 27-NAMING 节点带前缀/本体不带前缀两轴)、`workgraph-planning` 选型判据方向。**关键设计约束**:critic(评判)与 synthesis(择优/融合)必须拆成两个节点,依据是仓库真实事故——`qa-judge` 早期"评判+终态判决合一"设计导致过一次无限循环(20 次调用/31 分钟才熔断,记录在 `deliverable.json` 的反模式说明里),新骨架延续 `validator-triager`/`benchmark-validator` 已验证的"归因/打分/判定三者分离"范式。**预算风险量化**:N 路候选并行消耗 N 倍预算,且 `checkBudget` 只在批量拾取前查一次,实际可能超支到 `maxConcurrency-1`(默认最多多花 4 次调用)才被拦下,候选数 N 目前代码层没有硬顶 | -| [51-ceo-autonomous-goal-refill-design.md](./todo/system-design/51-ceo-autonomous-goal-refill-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-09)。回应"公司能否 7×24 自转"的提问——核实执行层(存活扫描/僵尸 goal 收口/CEO 阻塞任务兜底心跳)已经扎实,真正缺口在决策层:`ceo/heartbeat.md` 与 `tools.md` 的"该提新任务"判断全部要求先有一个 goal/task 存在,没有一条覆盖"公司没有任何 active/draft/planning goal"这种完全空转场景;且 CEO 目前**没有创建 goal 的工具**(`tools.md` 对 `/goals` 只有 GET 示例),`POST /:companyId/goals` 端点存在于路由层但从未暴露给它。设计方案:心跳查询为空时进入"立项模式",先查公司使命(`company-context.md`,默认"待定义"时拒绝立项、转人工)作为立项锚点,用 `created_by:'ceo'` + `split:false`(避免草稿阶段就烧 task-planning token)建草稿 goal,去重后停在 `draft` 等人工 `/dispatch`——分两阶段放权,阶段 B(CEO 自动 enqueue + guardrail)本文档只给方向不实现 | +| [51-ceo-autonomous-goal-refill-design.md](./todo/system-design/51-ceo-autonomous-goal-refill-design.md) | ✅ **阶段 A 已实现**(2026-09-09 设计 / 2026-09-17 实现+实测)。回应"公司能否 7×24 自转"的提问——核实执行层(存活扫描/僵尸 goal 收口/CEO 阻塞任务兜底心跳)已经扎实,真正缺口在决策层:`ceo/heartbeat.md` 与 `tools.md` 原本"该提新任务"判断全部要求先有一个 goal/task 存在,没有一条覆盖"公司没有任何 active/draft/planning goal"这种完全空转场景;CEO 也原本**没有创建 goal 的工具**(`POST /:companyId/goals` 存在于路由层但从未暴露给它)。已接线:心跳先查 `GET /goals` 判空转 → 查公司使命("待定义"时拒绝立项转人工)→ 去重 → `POST /goals` 建 `split:false` + `created_by:'ceo'` 的草稿。**起服务实测(不只是读代码)纠正了两处判断**:① 原设计的多状态/`created_by` 查询参数服务端不支持,改客户端过滤;② 最初以为零 task 的草稿会让 `/dispatch` 天然 409、这就是审批闸门——实测发现 `split:false` 其实自动带 1 条 draft task,`dispatch` 技术上立刻能成功,**没有代码层闸门**,阶段 A 的安全性改为完全依赖 `ceo/tools.md` 里"不许自己调 dispatch"这条提示词约束,是当前最脆弱的一环(见四)。对所有公司默认生效,未改任何路由/服务层代码,纯 agent 人设文档改动(见七)。阶段 B(CEO 自动 dispatch + guardrail + 公司级开关)仍是设计草稿,未实现 | ## todo/future/(暂缓,非当前重心) @@ -84,9 +85,10 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d 以下目录本身是**活跃系统的一部分**(不是静态文档),保持原位不动: -- **`prd/`** — DevLoop 的 PRD/SDD 规格站点源文件。`prd/newfunction/*.html` 由 `npm run spec:extract` 读取生成 `prd/newfunction/_spec/*.md`,是代码依赖的机器可读规格,不是待归档的说明文档。 - **`create-drama/`** — 独立的 Skill 工具子项目(含自身 `package.json` / `node_modules`),不是设计文档。 +> `docs/prd/` 已不在这里:DevLoop 退役(2026-09-17,[52 号文档](./done/system-design/52-devloop-retirement.md))后整体移到了 `docs/_archive/devloop-prd-2026-09-17/prd/`,`npm run spec:extract`/`spec:check` 两条 npm script 也一并删除,不再是活跃工具链的一部分。 + ## 维护说明 新增文档时,先判断它描述的是**已实现的能力**(放 `done/`)、**当前正在推进的重心**(放 `todo/`),还是**尚未开始且非当前重心**(放 `todo/future/`)。功能落地后,将对应文档从 `todo/`(或 `todo/future/`)移到 `done/` 并更新本索引与 `LEMONBOSS-SYSTEM-DESIGN.md` 中的链接;当主线切换时(比如 Graph Agent 架构完成后),把 `todo/future/` 里优先级提升的条目挪回 `todo/`。 diff --git a/build-manifest.json b/docs/_archive/devloop-prd-2026-09-17/build-manifest.json index fc7e98c..fc7e98c 100644 --- a/build-manifest.json +++ b/docs/_archive/devloop-prd-2026-09-17/build-manifest.json diff --git a/docs/prd/_scan-manifest.json b/docs/_archive/devloop-prd-2026-09-17/prd/_scan-manifest.json index 009d3a0..009d3a0 100644 --- a/docs/prd/_scan-manifest.json +++ b/docs/_archive/devloop-prd-2026-09-17/prd/_scan-manifest.json diff --git a/docs/prd/assets/app.css b/docs/_archive/devloop-prd-2026-09-17/prd/assets/app.css index c18b6a9..c18b6a9 100644 --- a/docs/prd/assets/app.css +++ b/docs/_archive/devloop-prd-2026-09-17/prd/assets/app.css diff --git a/docs/prd/assets/app.js b/docs/_archive/devloop-prd-2026-09-17/prd/assets/app.js index 03a61c6..03a61c6 100644 --- a/docs/prd/assets/app.js +++ b/docs/_archive/devloop-prd-2026-09-17/prd/assets/app.js diff --git a/docs/prd/assets/chapter.css b/docs/_archive/devloop-prd-2026-09-17/prd/assets/chapter.css index f07f4fa..f07f4fa 100644 --- a/docs/prd/assets/chapter.css +++ b/docs/_archive/devloop-prd-2026-09-17/prd/assets/chapter.css diff --git a/docs/prd/assets/menu.js b/docs/_archive/devloop-prd-2026-09-17/prd/assets/menu.js index 12d97d6..12d97d6 100644 --- a/docs/prd/assets/menu.js +++ b/docs/_archive/devloop-prd-2026-09-17/prd/assets/menu.js diff --git a/docs/prd/assets/search-index.json b/docs/_archive/devloop-prd-2026-09-17/prd/assets/search-index.json index aaa6e5e..aaa6e5e 100644 --- a/docs/prd/assets/search-index.json +++ b/docs/_archive/devloop-prd-2026-09-17/prd/assets/search-index.json diff --git a/docs/prd/chapters/01-overview/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/01-overview/index.html index b7527b7..b7527b7 100644 --- a/docs/prd/chapters/01-overview/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/01-overview/index.html diff --git a/docs/prd/chapters/02-prd/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/02-prd/index.html index e50193d..e50193d 100644 --- a/docs/prd/chapters/02-prd/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/02-prd/index.html diff --git a/docs/prd/chapters/03-feature-tree/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/03-feature-tree/index.html index 276100a..276100a 100644 --- a/docs/prd/chapters/03-feature-tree/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/03-feature-tree/index.html diff --git a/docs/prd/chapters/04-workflows/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/04-workflows/index.html index 272503b..272503b 100644 --- a/docs/prd/chapters/04-workflows/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/04-workflows/index.html diff --git a/docs/prd/chapters/05-prototype/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/05-prototype/index.html index bf8646c..bf8646c 100644 --- a/docs/prd/chapters/05-prototype/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/05-prototype/index.html diff --git a/docs/prd/chapters/06-sdd-model/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/06-sdd-model/index.html index c413457..c413457 100644 --- a/docs/prd/chapters/06-sdd-model/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/06-sdd-model/index.html diff --git a/docs/prd/chapters/07-sdd-view/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/07-sdd-view/index.html index 42a355b..42a355b 100644 --- a/docs/prd/chapters/07-sdd-view/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/07-sdd-view/index.html diff --git a/docs/prd/chapters/08-sdd-control/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/08-sdd-control/index.html index fd7c024..fd7c024 100644 --- a/docs/prd/chapters/08-sdd-control/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/08-sdd-control/index.html diff --git a/docs/prd/chapters/09-sdd-process/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/09-sdd-process/index.html index 36ce4af..36ce4af 100644 --- a/docs/prd/chapters/09-sdd-process/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/09-sdd-process/index.html diff --git a/docs/prd/chapters/10-reproducibility/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/10-reproducibility/index.html index 18084ff..18084ff 100644 --- a/docs/prd/chapters/10-reproducibility/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/chapters/10-reproducibility/index.html diff --git a/docs/prd/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/index.html index b1e2d9f..b1e2d9f 100644 --- a/docs/prd/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/index.html diff --git a/docs/prd/newfunction/_spec/CONVENTIONS.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/CONVENTIONS.md index ae4fa09..ae4fa09 100644 --- a/docs/prd/newfunction/_spec/CONVENTIONS.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/CONVENTIONS.md diff --git a/docs/prd/newfunction/_spec/agents-architect.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-architect.md index 8a5f465..8a5f465 100644 --- a/docs/prd/newfunction/_spec/agents-architect.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-architect.md diff --git a/docs/prd/newfunction/_spec/agents-coder.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-coder.md index 3c73e31..3c73e31 100644 --- a/docs/prd/newfunction/_spec/agents-coder.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-coder.md diff --git a/docs/prd/newfunction/_spec/agents-integrator.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-integrator.md index 1807ae8..1807ae8 100644 --- a/docs/prd/newfunction/_spec/agents-integrator.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-integrator.md diff --git a/docs/prd/newfunction/_spec/agents-overview.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-overview.md index 06697b0..06697b0 100644 --- a/docs/prd/newfunction/_spec/agents-overview.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-overview.md diff --git a/docs/prd/newfunction/_spec/agents-pm.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-pm.md index 01d45f1..01d45f1 100644 --- a/docs/prd/newfunction/_spec/agents-pm.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-pm.md diff --git a/docs/prd/newfunction/_spec/agents-tester.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-tester.md index aed7975..aed7975 100644 --- a/docs/prd/newfunction/_spec/agents-tester.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-tester.md diff --git a/docs/prd/newfunction/_spec/agents-triager.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-triager.md index 0225b6b..0225b6b 100644 --- a/docs/prd/newfunction/_spec/agents-triager.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/agents-triager.md diff --git a/docs/prd/newfunction/_spec/api-contracts.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api-contracts.md index 90f3fcd..90f3fcd 100644 --- a/docs/prd/newfunction/_spec/api-contracts.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api-contracts.md diff --git a/docs/prd/newfunction/_spec/api-sse.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api-sse.md index 94adbe3..94adbe3 100644 --- a/docs/prd/newfunction/_spec/api-sse.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api-sse.md diff --git a/docs/prd/newfunction/_spec/api.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api.md index 9adf448..9adf448 100644 --- a/docs/prd/newfunction/_spec/api.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/api.md diff --git a/docs/prd/newfunction/_spec/arch-acp-bridge.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-acp-bridge.md index 2c96976..2c96976 100644 --- a/docs/prd/newfunction/_spec/arch-acp-bridge.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-acp-bridge.md diff --git a/docs/prd/newfunction/_spec/arch-components.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-components.md index cec30ee..cec30ee 100644 --- a/docs/prd/newfunction/_spec/arch-components.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-components.md diff --git a/docs/prd/newfunction/_spec/arch-git-versioning.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-git-versioning.md index 41aa11a..41aa11a 100644 --- a/docs/prd/newfunction/_spec/arch-git-versioning.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-git-versioning.md diff --git a/docs/prd/newfunction/_spec/arch-mapping.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-mapping.md index 13194a9..13194a9 100644 --- a/docs/prd/newfunction/_spec/arch-mapping.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-mapping.md diff --git a/docs/prd/newfunction/_spec/arch-project-root.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-project-root.md index f2b0cd5..f2b0cd5 100644 --- a/docs/prd/newfunction/_spec/arch-project-root.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-project-root.md diff --git a/docs/prd/newfunction/_spec/arch-tool-registry.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-tool-registry.md index 1a4eb7e..1a4eb7e 100644 --- a/docs/prd/newfunction/_spec/arch-tool-registry.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-tool-registry.md diff --git a/docs/prd/newfunction/_spec/arch-workflow.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-workflow.md index df5edb8..df5edb8 100644 --- a/docs/prd/newfunction/_spec/arch-workflow.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/arch-workflow.md diff --git a/docs/prd/newfunction/_spec/data-documents.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/data-documents.md index 92da330..92da330 100644 --- a/docs/prd/newfunction/_spec/data-documents.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/data-documents.md diff --git a/docs/prd/newfunction/_spec/data-layout.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/data-layout.md index 306e31d..306e31d 100644 --- a/docs/prd/newfunction/_spec/data-layout.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/data-layout.md diff --git a/docs/prd/newfunction/_spec/index.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/index.md index 2920ee4..2920ee4 100644 --- a/docs/prd/newfunction/_spec/index.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/index.md diff --git a/docs/prd/newfunction/_spec/loops-l1.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l1.md index 475d4ee..475d4ee 100644 --- a/docs/prd/newfunction/_spec/loops-l1.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l1.md diff --git a/docs/prd/newfunction/_spec/loops-l2.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l2.md index 70c6f1d..70c6f1d 100644 --- a/docs/prd/newfunction/_spec/loops-l2.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l2.md diff --git a/docs/prd/newfunction/_spec/loops-l3.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l3.md index b067e4c..b067e4c 100644 --- a/docs/prd/newfunction/_spec/loops-l3.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-l3.md diff --git a/docs/prd/newfunction/_spec/loops-overview.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-overview.md index 957e131..957e131 100644 --- a/docs/prd/newfunction/_spec/loops-overview.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/loops-overview.md diff --git a/docs/prd/newfunction/_spec/ops-buglist.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-buglist.md index b404734..b404734 100644 --- a/docs/prd/newfunction/_spec/ops-buglist.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-buglist.md diff --git a/docs/prd/newfunction/_spec/ops-changelog.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-changelog.md index f80eef9..f80eef9 100644 --- a/docs/prd/newfunction/_spec/ops-changelog.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-changelog.md diff --git a/docs/prd/newfunction/_spec/ops-loop-log.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-loop-log.md index f37000c..f37000c 100644 --- a/docs/prd/newfunction/_spec/ops-loop-log.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-loop-log.md diff --git a/docs/prd/newfunction/_spec/ops-progress.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-progress.md index 552de31..552de31 100644 --- a/docs/prd/newfunction/_spec/ops-progress.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ops-progress.md diff --git a/docs/prd/newfunction/_spec/plan-code-lifecycle.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-code-lifecycle.md index b2cdba7..b2cdba7 100644 --- a/docs/prd/newfunction/_spec/plan-code-lifecycle.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-code-lifecycle.md diff --git a/docs/prd/newfunction/_spec/plan-cost-concurrency.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-cost-concurrency.md index 4e88c58..4e88c58 100644 --- a/docs/prd/newfunction/_spec/plan-cost-concurrency.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-cost-concurrency.md diff --git a/docs/prd/newfunction/_spec/plan-migration.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-migration.md index 0e84ef4..0e84ef4 100644 --- a/docs/prd/newfunction/_spec/plan-migration.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-migration.md diff --git a/docs/prd/newfunction/_spec/plan-reproducibility.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-reproducibility.md index 2e94f9f..2e94f9f 100644 --- a/docs/prd/newfunction/_spec/plan-reproducibility.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-reproducibility.md diff --git a/docs/prd/newfunction/_spec/plan-risks.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-risks.md index 7e1ed6a..7e1ed6a 100644 --- a/docs/prd/newfunction/_spec/plan-risks.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/plan-risks.md diff --git a/docs/prd/newfunction/_spec/prd-background.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-background.md index 4970075..4970075 100644 --- a/docs/prd/newfunction/_spec/prd-background.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-background.md diff --git a/docs/prd/newfunction/_spec/prd-feature-tree.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-feature-tree.md index c931ff3..c931ff3 100644 --- a/docs/prd/newfunction/_spec/prd-feature-tree.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-feature-tree.md diff --git a/docs/prd/newfunction/_spec/prd-glossary.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-glossary.md index b6fa103..b6fa103 100644 --- a/docs/prd/newfunction/_spec/prd-glossary.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-glossary.md diff --git a/docs/prd/newfunction/_spec/prd-personas.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-personas.md index 0c82793..0c82793 100644 --- a/docs/prd/newfunction/_spec/prd-personas.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-personas.md diff --git a/docs/prd/newfunction/_spec/prd-success-metrics.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-success-metrics.md index 20147c1..20147c1 100644 --- a/docs/prd/newfunction/_spec/prd-success-metrics.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-success-metrics.md diff --git a/docs/prd/newfunction/_spec/prd-user-flows.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-user-flows.md index 8350b04..8350b04 100644 --- a/docs/prd/newfunction/_spec/prd-user-flows.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-user-flows.md diff --git a/docs/prd/newfunction/_spec/proto-feedback-modal.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-feedback-modal.md index f9b3e1b..f9b3e1b 100644 --- a/docs/prd/newfunction/_spec/proto-feedback-modal.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-feedback-modal.md diff --git a/docs/prd/newfunction/_spec/proto-prd-diff.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-prd-diff.md index 20e5c77..20e5c77 100644 --- a/docs/prd/newfunction/_spec/proto-prd-diff.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-prd-diff.md diff --git a/docs/prd/newfunction/_spec/proto-unit-drawer.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-unit-drawer.md index 1bac0f6..1bac0f6 100644 --- a/docs/prd/newfunction/_spec/proto-unit-drawer.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/proto-unit-drawer.md diff --git a/docs/prd/newfunction/_spec/reading-guide.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/reading-guide.md index 2f5ce67..2f5ce67 100644 --- a/docs/prd/newfunction/_spec/reading-guide.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/reading-guide.md diff --git a/docs/prd/newfunction/_spec/requirement.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/requirement.md index ba329e6..ba329e6 100644 --- a/docs/prd/newfunction/_spec/requirement.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/requirement.md diff --git a/docs/prd/newfunction/_spec/scope.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/scope.md index 946952a..946952a 100644 --- a/docs/prd/newfunction/_spec/scope.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/scope.md diff --git a/docs/prd/newfunction/_spec/sdd-control.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-control.md index 8d72adb..8d72adb 100644 --- a/docs/prd/newfunction/_spec/sdd-control.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-control.md diff --git a/docs/prd/newfunction/_spec/sdd-model.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-model.md index 49d3053..49d3053 100644 --- a/docs/prd/newfunction/_spec/sdd-model.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-model.md diff --git a/docs/prd/newfunction/_spec/sdd-overview.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-overview.md index 59670b0..59670b0 100644 --- a/docs/prd/newfunction/_spec/sdd-overview.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-overview.md diff --git a/docs/prd/newfunction/_spec/sdd-process.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-process.md index 89e04d2..89e04d2 100644 --- a/docs/prd/newfunction/_spec/sdd-process.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-process.md diff --git a/docs/prd/newfunction/_spec/sdd-view.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-view.md index 57a448b..57a448b 100644 --- a/docs/prd/newfunction/_spec/sdd-view.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/sdd-view.md diff --git a/docs/prd/newfunction/_spec/ui-dashboard.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ui-dashboard.md index b49a4c5..b49a4c5 100644 --- a/docs/prd/newfunction/_spec/ui-dashboard.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ui-dashboard.md diff --git a/docs/prd/newfunction/_spec/ui-design-system.md b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ui-design-system.md index 5b7f220..5b7f220 100644 --- a/docs/prd/newfunction/_spec/ui-design-system.md +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/ui-design-system.md diff --git a/docs/prd/newfunction/agents-architect.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-architect.html index cb70e3c..cb70e3c 100644 --- a/docs/prd/newfunction/agents-architect.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-architect.html diff --git a/docs/prd/newfunction/agents-coder.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-coder.html index 8df67c4..8df67c4 100644 --- a/docs/prd/newfunction/agents-coder.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-coder.html diff --git a/docs/prd/newfunction/agents-integrator.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-integrator.html index 3cb36cb..3cb36cb 100644 --- a/docs/prd/newfunction/agents-integrator.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-integrator.html diff --git a/docs/prd/newfunction/agents-overview.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-overview.html index b0cbf21..b0cbf21 100644 --- a/docs/prd/newfunction/agents-overview.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-overview.html diff --git a/docs/prd/newfunction/agents-pm.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-pm.html index 9894449..9894449 100644 --- a/docs/prd/newfunction/agents-pm.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-pm.html diff --git a/docs/prd/newfunction/agents-tester.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-tester.html index c8d16a7..c8d16a7 100644 --- a/docs/prd/newfunction/agents-tester.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-tester.html diff --git a/docs/prd/newfunction/agents-triager.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-triager.html index d7b26ae..d7b26ae 100644 --- a/docs/prd/newfunction/agents-triager.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/agents-triager.html diff --git a/docs/prd/newfunction/api-contracts.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api-contracts.html index 0528edd..0528edd 100644 --- a/docs/prd/newfunction/api-contracts.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api-contracts.html diff --git a/docs/prd/newfunction/api-sse.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api-sse.html index 27748f1..27748f1 100644 --- a/docs/prd/newfunction/api-sse.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api-sse.html diff --git a/docs/prd/newfunction/api.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api.html index a9d5498..a9d5498 100644 --- a/docs/prd/newfunction/api.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/api.html diff --git a/docs/prd/newfunction/arch-acp-bridge.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-acp-bridge.html index c915b7c..c915b7c 100644 --- a/docs/prd/newfunction/arch-acp-bridge.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-acp-bridge.html diff --git a/docs/prd/newfunction/arch-components.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-components.html index c038205..c038205 100644 --- a/docs/prd/newfunction/arch-components.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-components.html diff --git a/docs/prd/newfunction/arch-git-versioning.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-git-versioning.html index b6b50c9..b6b50c9 100644 --- a/docs/prd/newfunction/arch-git-versioning.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-git-versioning.html diff --git a/docs/prd/newfunction/arch-mapping.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-mapping.html index 3e9d3cf..3e9d3cf 100644 --- a/docs/prd/newfunction/arch-mapping.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-mapping.html diff --git a/docs/prd/newfunction/arch-project-root.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-project-root.html index d9c4746..d9c4746 100644 --- a/docs/prd/newfunction/arch-project-root.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-project-root.html diff --git a/docs/prd/newfunction/arch-tool-registry.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-tool-registry.html index 59aa625..59aa625 100644 --- a/docs/prd/newfunction/arch-tool-registry.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-tool-registry.html diff --git a/docs/prd/newfunction/arch-workflow.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-workflow.html index 1caa855..1caa855 100644 --- a/docs/prd/newfunction/arch-workflow.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/arch-workflow.html diff --git a/docs/prd/newfunction/assets/search-index.json b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/search-index.json index 18fdabb..18fdabb 100644 --- a/docs/prd/newfunction/assets/search-index.json +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/search-index.json diff --git a/docs/prd/newfunction/assets/shell.css b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/shell.css index 7a46af1..7a46af1 100644 --- a/docs/prd/newfunction/assets/shell.css +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/shell.css diff --git a/docs/prd/newfunction/assets/shell.js b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/shell.js index 03e6c55..03e6c55 100644 --- a/docs/prd/newfunction/assets/shell.js +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/assets/shell.js diff --git a/docs/prd/newfunction/data-documents.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/data-documents.html index 4a7a370..4a7a370 100644 --- a/docs/prd/newfunction/data-documents.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/data-documents.html diff --git a/docs/prd/newfunction/data-layout.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/data-layout.html index 15925e8..15925e8 100644 --- a/docs/prd/newfunction/data-layout.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/data-layout.html diff --git a/docs/prd/newfunction/index.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/index.html index 88d5027..88d5027 100644 --- a/docs/prd/newfunction/index.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/index.html diff --git a/docs/prd/newfunction/loops-l1.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l1.html index 6fd0be8..6fd0be8 100644 --- a/docs/prd/newfunction/loops-l1.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l1.html diff --git a/docs/prd/newfunction/loops-l2.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l2.html index 2e10281..2e10281 100644 --- a/docs/prd/newfunction/loops-l2.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l2.html diff --git a/docs/prd/newfunction/loops-l3.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l3.html index d91eda1..d91eda1 100644 --- a/docs/prd/newfunction/loops-l3.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-l3.html diff --git a/docs/prd/newfunction/loops-overview.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-overview.html index 35db1de..35db1de 100644 --- a/docs/prd/newfunction/loops-overview.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/loops-overview.html diff --git a/docs/prd/newfunction/ops-buglist.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-buglist.html index e39b971..e39b971 100644 --- a/docs/prd/newfunction/ops-buglist.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-buglist.html diff --git a/docs/prd/newfunction/ops-changelog.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-changelog.html index 8f82fd4..8f82fd4 100644 --- a/docs/prd/newfunction/ops-changelog.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-changelog.html diff --git a/docs/prd/newfunction/ops-loop-log.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-loop-log.html index 1b74f54..1b74f54 100644 --- a/docs/prd/newfunction/ops-loop-log.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-loop-log.html diff --git a/docs/prd/newfunction/ops-progress.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-progress.html index fa4a062..fa4a062 100644 --- a/docs/prd/newfunction/ops-progress.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ops-progress.html diff --git a/docs/prd/newfunction/plan-code-lifecycle.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-code-lifecycle.html index 1ae327f..1ae327f 100644 --- a/docs/prd/newfunction/plan-code-lifecycle.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-code-lifecycle.html diff --git a/docs/prd/newfunction/plan-cost-concurrency.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-cost-concurrency.html index 416d702..416d702 100644 --- a/docs/prd/newfunction/plan-cost-concurrency.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-cost-concurrency.html diff --git a/docs/prd/newfunction/plan-migration.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-migration.html index 4b4dfd9..4b4dfd9 100644 --- a/docs/prd/newfunction/plan-migration.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-migration.html diff --git a/docs/prd/newfunction/plan-reproducibility.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-reproducibility.html index 638fdeb..638fdeb 100644 --- a/docs/prd/newfunction/plan-reproducibility.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-reproducibility.html diff --git a/docs/prd/newfunction/plan-risks.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-risks.html index 71883ac..71883ac 100644 --- a/docs/prd/newfunction/plan-risks.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/plan-risks.html diff --git a/docs/prd/newfunction/prd-background.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-background.html index dda662b..dda662b 100644 --- a/docs/prd/newfunction/prd-background.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-background.html diff --git a/docs/prd/newfunction/prd-feature-tree.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-feature-tree.html index 6ba8980..6ba8980 100644 --- a/docs/prd/newfunction/prd-feature-tree.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-feature-tree.html diff --git a/docs/prd/newfunction/prd-glossary.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-glossary.html index d48c1f6..d48c1f6 100644 --- a/docs/prd/newfunction/prd-glossary.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-glossary.html diff --git a/docs/prd/newfunction/prd-personas.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-personas.html index f36a450..f36a450 100644 --- a/docs/prd/newfunction/prd-personas.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-personas.html diff --git a/docs/prd/newfunction/prd-success-metrics.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-success-metrics.html index 2da40e0..2da40e0 100644 --- a/docs/prd/newfunction/prd-success-metrics.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-success-metrics.html diff --git a/docs/prd/newfunction/prd-user-flows.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-user-flows.html index ab09ea7..ab09ea7 100644 --- a/docs/prd/newfunction/prd-user-flows.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/prd-user-flows.html diff --git a/docs/prd/newfunction/proto-feedback-modal.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-feedback-modal.html index 9a0558d..9a0558d 100644 --- a/docs/prd/newfunction/proto-feedback-modal.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-feedback-modal.html diff --git a/docs/prd/newfunction/proto-prd-diff.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-prd-diff.html index bd42daa..bd42daa 100644 --- a/docs/prd/newfunction/proto-prd-diff.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-prd-diff.html diff --git a/docs/prd/newfunction/proto-unit-drawer.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-unit-drawer.html index def8ab2..def8ab2 100644 --- a/docs/prd/newfunction/proto-unit-drawer.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/proto-unit-drawer.html diff --git a/docs/prd/newfunction/reading-guide.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/reading-guide.html index 9ebeae0..9ebeae0 100644 --- a/docs/prd/newfunction/reading-guide.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/reading-guide.html diff --git a/docs/prd/newfunction/requirement.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/requirement.html index 40cdfec..40cdfec 100644 --- a/docs/prd/newfunction/requirement.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/requirement.html diff --git a/docs/prd/newfunction/scope.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/scope.html index 1e0eedd..1e0eedd 100644 --- a/docs/prd/newfunction/scope.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/scope.html diff --git a/docs/prd/newfunction/sdd-control.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-control.html index bbae42c..bbae42c 100644 --- a/docs/prd/newfunction/sdd-control.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-control.html diff --git a/docs/prd/newfunction/sdd-model.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-model.html index df491ac..df491ac 100644 --- a/docs/prd/newfunction/sdd-model.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-model.html diff --git a/docs/prd/newfunction/sdd-overview.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-overview.html index 8302618..8302618 100644 --- a/docs/prd/newfunction/sdd-overview.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-overview.html diff --git a/docs/prd/newfunction/sdd-process.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-process.html index 4d2a68f..4d2a68f 100644 --- a/docs/prd/newfunction/sdd-process.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-process.html diff --git a/docs/prd/newfunction/sdd-view.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-view.html index 85ee9b7..85ee9b7 100644 --- a/docs/prd/newfunction/sdd-view.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/sdd-view.html diff --git a/docs/prd/newfunction/ui-dashboard.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ui-dashboard.html index a304247..a304247 100644 --- a/docs/prd/newfunction/ui-dashboard.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ui-dashboard.html diff --git a/docs/prd/newfunction/ui-design-system.html b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ui-design-system.html index 18605a9..18605a9 100644 --- a/docs/prd/newfunction/ui-design-system.html +++ b/docs/_archive/devloop-prd-2026-09-17/prd/newfunction/ui-design-system.html diff --git a/docs/prd/scripts/build-search-index.js b/docs/_archive/devloop-prd-2026-09-17/prd/scripts/build-search-index.js index 60078b2..60078b2 100644 --- a/docs/prd/scripts/build-search-index.js +++ b/docs/_archive/devloop-prd-2026-09-17/prd/scripts/build-search-index.js diff --git a/scripts/build-search-index.js b/docs/_archive/devloop-prd-2026-09-17/scripts/build-search-index.js index b59bae0..b59bae0 100644 --- a/scripts/build-search-index.js +++ b/docs/_archive/devloop-prd-2026-09-17/scripts/build-search-index.js diff --git a/scripts/extract-agent-prompts.js b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-agent-prompts.js index 90f47e5..90f47e5 100644 --- a/scripts/extract-agent-prompts.js +++ b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-agent-prompts.js diff --git a/scripts/extract-manifest.js b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-manifest.js index 120fe15..120fe15 100644 --- a/scripts/extract-manifest.js +++ b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-manifest.js diff --git a/scripts/extract-spec.js b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-spec.js index cc524cc..cc524cc 100644 --- a/scripts/extract-spec.js +++ b/docs/_archive/devloop-prd-2026-09-17/scripts/extract-spec.js diff --git a/scripts/phase0-verify.js b/docs/_archive/devloop-prd-2026-09-17/scripts/phase0-verify.js index 8a758d0..8a758d0 100644 --- a/scripts/phase0-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/scripts/phase0-verify.js diff --git a/scripts/phase0-test-call.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase0-test-call.js index cf12bfe..cf12bfe 100644 --- a/scripts/phase0-test-call.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase0-test-call.js diff --git a/scripts/phase1-verify.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase1-verify.js index c05f5c9..c05f5c9 100644 --- a/scripts/phase1-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase1-verify.js diff --git a/scripts/phase2-verify.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase2-verify.js index ee9c3de..ee9c3de 100644 --- a/scripts/phase2-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase2-verify.js diff --git a/scripts/phase3-verify.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase3-verify.js index 7807dd3..7807dd3 100644 --- a/scripts/phase3-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase3-verify.js diff --git a/scripts/phase4-verify.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase4-verify.js index 8ea12e6..8ea12e6 100644 --- a/scripts/phase4-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase4-verify.js diff --git a/scripts/phase5-verify.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase5-verify.js index 7e8b815..7e8b815 100644 --- a/scripts/phase5-verify.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/phase5-verify.js diff --git a/scripts/real-pm-smoke.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/real-pm-smoke.js index 011533b..011533b 100644 --- a/scripts/real-pm-smoke.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/real-pm-smoke.js diff --git a/scripts/self-bootstrap.js b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/self-bootstrap.js index 880911c..880911c 100644 --- a/scripts/self-bootstrap.js +++ b/docs/_archive/devloop-prd-2026-09-17/verify-scripts/self-bootstrap.js diff --git a/docs/done/system-design/52-devloop-retirement.md b/docs/done/system-design/52-devloop-retirement.md new file mode 100644 index 0000000..22c74bb --- /dev/null +++ b/docs/done/system-design/52-devloop-retirement.md @@ -0,0 +1,78 @@ +# 52. DevLoop 子系统退役 + +> **日期**: 2026-09-17 +> **状态**: ✅ 已执行。这是一条决策记录,不是设计方案。 +> **决策人**: yibo(用户),基于对 27 号系列 P6(DevLoop 收编)的评估结论拍板 + +--- + +## 一、结论 + +DevLoop 本质架构是 loop workflow(L1/L2/L3 三层嵌套状态机,PM→Architect→Coder→Tester→Integrator→Triager 六个固定角色),已经被 27 号系列的 graph workflow(`graph-engine.js`:org graph + work graph,节点/边/回退/熔断/并发全部泛化、可任意拓扑)替代——graph workflow 严格覆盖 loop workflow 的表达能力(loop 只是 graph 的一种退化形状:链式节点 + backward 边)。**不再需要维护 DevLoop 的独立实现**,予以退役。 + +这条决策不是"DevLoop 做得不好"——见下方"背景"一节,DevLoop 是照着一句具体的用户原话老老实实搭出来的可用系统,路线本身没问题。退役的理由是**范围过时**:graph workflow 建成之后,DevLoop 覆盖的场景(单个开发型公司走 PM→Architect→Coder→Tester→Integrator→Triager 固定六步)已经是 graph workflow 能表达的场景的一个真子集,继续维护两套并行实现只有成本没有收益。 + +## 二、背景:这条决策是怎么来的 + +1. 27 号总纲文档(`27-graph-agent-architecture.md`)原把"DevLoop 收编"列为 **P6**,写于 P3+P4 引擎动工之前(2026-08-15),原文判断是"P6 应前置……通用 Work Graph 引擎应当**从 DevLoop 抽象而来**,而不是平行发明后再回头合并"。 +2. 2026-09-17,用户要求先评估 P6 再决定要不要做。评估(见下方"参考材料")核实:**P3+P4 引擎已经独立建成并全部收口**,是"平行发明"的产物——这条判断从写下的那天起就没能兑现,现实是两套系统各自成型。 +3. 评估进一步发现 DevLoop 的设计初衷(`docs/prd/newfunction/requirement.html`,用户原话)是针对一个**具体、窄范围**的场景("一句话需求 30 分钟内自动交付一个软件项目",P4 演示日目标),不是通用架构;而 27 号系列的目标范围(任意公司、任意类型工作)从一开始就更宽。 +4. 用户据此判断:DevLoop 的 loop workflow 本质是 graph workflow 的一个特例,graph workflow 已经建成且能覆盖 DevLoop 的场景,没有必要再维护/迁移 DevLoop 这套独立实现——**直接退役,不做 P6 迁移**。 + +## 三、实际状态核查(退役前,确认没有在制品数据会被影响) + +- `data/companies/*/projects/` 下**没有出现过一个 DevLoop 的业务标记文件**(`project.json`,由 `LoopController.createProject` 写入)——DevLoop 建成以来从未跑过一个真实项目,只有 mock 测试 + 一次人工冒烟跑(`scripts/real-pm-smoke.js`)。 +- `/api/devloop` 路由虽然一直挂载在 `server/index.js`/`server/agent-server.js`,但没有前端导航入口指向 `/devloop`(`web/src/router/index.js` 里的四条路由是孤儿路由,要直接敲 URL 才能到达)。 +- 结论:**退役不影响任何生产数据或正在进行的工作**。 + +## 四、退役范围与处理方式 + +### 删除(DevLoop 专属,无其他调用方) + +- `server/routes/devloop.js` +- `server/src/core/devloop/`(`loop-controller.js`) +- `server/services/devloop/{unit-registry,git-vault,timeline-logger,bug-tracker,changelog-writer,sse-broker}.js` +- `server/src/tools/devloop/`(DevLoop agent 可调用工具全集) +- `server/public/devloop-status.html` +- `server/tests/devloop/`(21 个测试文件) +- `web/src/views/DevLoop{Dashboard,NewRequirement,Ops,Projects}.vue` +- `web/src/components/devloop/`(12 个组件,含 `ops/` 子目录) +- `web/src/router/index.js` 里的 4 条 `/devloop*` 路由 +- `system/agents/devloop-{pm,architect,coder,tester,integrator,triager}/`(6 个 agent 本体目录)+ `system/agents/_registry.json` 里对应的 6 条条目 +- `fixtures/devloop-mock/devloop-{pm,architect,coder,tester,integrator}.json`(DevLoop 专属 mock fixture) +- `fixtures/devloop-mock-scenarios/`(四分类归因场景 fixture,只有 DevLoop 测试用过) +- `server/index.js`/`server/agent-server.js` 里 `app.use('/api/devloop', ...)` 挂载行 +- `server/tests/unit/route-auth-gate.test.js` 里对 `devloop.js` 的单条断言 +- `package.json` 的 `spec:extract`/`spec:check` 两条 npm script +- `scripts/git-hooks/pre-commit`(唯一职责是校验 `docs/prd/newfunction/*.html` 与 `_spec/` 同步,DevLoop 退役后这条校验没有存在意义) + +### 保留(其他现役代码依赖,不能删) + +- `server/services/devloop/path-resolver.js`——`graph-engine.js` 的 `ensureProjectRoot`(27-P4 项目根分配)、`company-service.js` 的 `linked-*` 模式校验直接 require 这里的 `allocateProjectRoot`/`isSensitivePath`/`resolveFixedTarget`/`validateLinkedTarget`。文件顶部已加注释说明现状。 +- `server/services/devloop/doc-store.js`——`hashContract`/`verifyContractHash`/`canonical` 被 `graph-engine.js`(重试级联判据)和 `goal-contract.js` 直接 require。文件里其余 PRD/SDD 读写逻辑已是死代码,留着是因为跟共享部分揉在同一个文件、拆分不值当;文件顶部已加注释区分两半。 +- `fixtures/devloop-mock/` 目录本身(含 `default.json`/`prd-planning.json`/`sdd-planning.json`/`workgraph-planning.json`/`orggraph-planning.json`/`benchmark-validator.json`/`coder-validator.json`/`validator-triager.json`)——`ClaudeCliProtocol` mock 模式按 agent id 从这个目录回放,27-P3/P4 的真实链路测试(`graph-real-chain-smoke.test.js` 等)直接用这里的 fixture。目录名字历史上带着 "devloop" 前缀,但内容和用途早已不是 DevLoop 专属,改名的收益(消除命名歧义)不值得牵动所有引用它的测试文件,本轮不动。 +- `server/tests/unit/work-graph-skeletons.test.js`——原来用 `devloop-{architect,coder,tester,integrator}` 当占位符(因为 `work_graph_nodes.system_agent_id` 是外键,占位符必须指向真实存在的 `system_agents` 行)。随 `system/agents/devloop-*` 一起删除会导致外键校验失败,已改成指向其他真实存在的现役 agent(`sdd-planning`/`coder`/`coder-validator`/`org-validator`),测试本身不关心占位符的语义贴合度,已验证改动后 5 个用例全绿。 + +### 归档(不删,移入 `docs/_archive/`) + +- `docs/prd/`(含 `newfunction/`、`chapters/`、`assets/`、`scripts/`、`index.html`、`_scan-manifest.json`)——DevLoop 完整 PRD/SDD 规格站点(52 页 HTML + `_spec/*.md` 机器可读版 + 一套平行的 10 章精简版),移到 `docs/_archive/devloop-prd-2026-09-17/prd/`。 +- `build-manifest.json`(仓库根)——DevLoop SDD 抽取产物,移到同一归档目录下。 +- `scripts/extract-spec.js`/`extract-agent-prompts.js`/`build-search-index.js`/`extract-manifest.js`/`phase0-verify.js`——DevLoop 专属的 HTML↔MD 抽取/校验工具,移到 `docs/_archive/devloop-prd-2026-09-17/scripts/`,留作历史参考,不再是活跃工具链的一部分。 + +## 五、CLAUDE.md / docs/README.md 同步 + +- `CLAUDE.md` 的「DevLoop 子系统」整节、快速启动里的 `spec:extract`/`spec:check` 命令、目录速查表里 DevLoop 相关行、API 路由表里的 `/api/devloop/projects`、两个 server 入口挂载路由列表里的 `/api/devloop`——全部改成一句退役说明,指回本文档。 +- `docs/README.md`「未纳入整理的目录」一节里 `prd/` 那条说明(原文"DevLoop 的 PRD/SDD 规格站点源文件……不是待归档的说明文档")——这句判断已经不成立,改成指向归档位置。 +- `27-graph-agent-architecture.md` §六 P6 那一行——从"应前置"改成"已拍板不做,DevLoop 退役",指回本文档。 + +## 六、影响范围之外、本轮没有处理的事 + +- `system/agents/*/conversations/`、`system/tasklog.bak-unify/`、各 agent 的 `memory.md`/`todo.md` 里提到 `devloop-architect` 等 id 的历史记录——这些是过去真实发生过的对话/任务记录,不是代码依赖,如实保留,不做清理。 +- `data/companies/*/projects/` 下已存在的项目目录——这些目录是 graph-engine(不是 DevLoop)创建的,路径格式历史上由 DevLoop 首创(`path-resolver.allocateProjectRoot`),两者共用同一份分配逻辑,退役不影响这些目录。 +- `fixtures/devloop-mock/` 目录改名——见上方"保留"一节,评估后判定不值得做,不是遗漏。 + +## 七、参考材料 + +- P6 评估报告(后台调研产出,未落盘为独立文档,结论已吸收进本文档 §一/§二):核对了 `27-graph-agent-architecture.md`、`27-e2e-task-execution-flow.md`、`loop-controller.js`、`unit-registry.js`、`graph-engine.js`、DevLoop 与 graph-engine 两边测试套件规模,产出过一份能力对照表与真实断点清单——因为最终决策是"整体退役"而非"部分迁移",那份对照表本身不再有实施指导意义,不单独归档。 +- `docs/_archive/devloop-prd-2026-09-17/prd/newfunction/requirement.html`——DevLoop 设计源头,用户原话 + 结构化解析(R1-R6 硬约束 + H1-H3 隐含约束)。 +- `docs/_archive/devloop-prd-2026-09-17/prd/newfunction/_spec/prd-background.md`——DevLoop 要解决的具体痛点、北极星指标(P4 演示日:<30 分钟、<50 万 token)、非目标声明。 diff --git a/docs/todo/system-design/27-e2e-task-execution-flow.md b/docs/todo/system-design/27-e2e-task-execution-flow.md index b2fa95f..6727ebf 100644 --- a/docs/todo/system-design/27-e2e-task-execution-flow.md +++ b/docs/todo/system-design/27-e2e-task-execution-flow.md @@ -35,6 +35,13 @@ ### 结论 2:DevLoop 就是你这套架构的一个「硬编码实例」 +> ⚠️ **2026-09-17 补记**:本节最后一句"正确的做法是反过来……"这条建议**没有被采纳**。 +> 后续 P3+P4 引擎独立建成,DevLoop 没有被抽象进去,两者变成平行发明的两套实现—— +> 与本节的预期正相反。用户据此重新评估后拍板:graph workflow 已严格覆盖 DevLoop +> 的 loop workflow 表达能力,DevLoop 直接退役,不做 P6 迁移。见 +> [52-devloop-retirement.md](../../done/system-design/52-devloop-retirement.md)。 +> 本节下面的对照表作为历史分析依然成立、保留不改。 + 把两者并排看,对应关系是逐项的: | 你描述的通用概念 | DevLoop 里的硬编码版本 | diff --git a/docs/todo/system-design/27-graph-agent-architecture.md b/docs/todo/system-design/27-graph-agent-architecture.md index 4b1828f..d6a8cea 100644 --- a/docs/todo/system-design/27-graph-agent-architecture.md +++ b/docs/todo/system-design/27-graph-agent-architecture.md @@ -233,9 +233,9 @@ CREATE TABLE work_graph_edges ( | ~~**P4 回退 + Loop-Validator**~~ | **已并入上一行**,不再单列 | 合并理由见 [27-P3/P4](../../done/system-design/27-p3-p4-work-graph-console.md) §二:执行引擎一旦要支持「暂停/单步/停止/重试」这种可被外部随时打断的控制语义,「先做单向、再加回退」会让第一版状态机设计得过窄,P4 加回退时必然回来重改数据模型和主循环。**两处定案变更**:① 熔断改为**引擎无条件做的整数计数**(单边 `loop_count > 3` + run 级 `backward_count > 5` 双层硬顶),不依赖 LLM,`loop_validator` 节点由「强制」降级为**可选增强**;② 结构校验从四项扩到**五项**,新增 `fail-fallback-edge`(有 `fail` 出边的节点必须留一条 `fail_reason IS NULL` 兜底边),org / work 两层都加 | 同上 | | ~~**P4 并行执行与产物索引**~~ ✅(**回收编号**) | fan-out 真并发 + 产物统一索引 —— **2026-09-08 全部完成** | `advanceBatch` 批量拾取(手动模式退化为 `maxConcurrency=1`,行为与 P3 一致);同 agent 多实例隔离(`SessionPool.getKey` 的 `scope` 参数已实际接线:`graph-engine.js` 传 `gr:{nodeRunId}`);`advanceTree` 兄弟子 run 真并发;解除「一家公司一个 active root run」;`data/companies/{cid}/tasks/{taskId}/` 产物目录 + `graph_files` 表检索索引。细化设计见 [27-P4 并行执行与产物索引](../../done/system-design/27-p4-parallel-and-artifacts.md) | 3 个并行分支同时 running 且总墙钟明显短于串行;部分失败时 fan-in 停等且可视化「在等谁」;产物索引可从节点行 rebuild | | **P5 自我进化** | 公共记忆蒸馏机制 | 公共 `memory.md` 写入走自省校验 + git 版本化;company-scoped → public 的升级门槛复用现有 benchmark ≥95 链路 | 同一 public agent 被两家公司调用后,`memory.md` 有可观察的增量更新,且能 git revert 到升级前版本 | -| **P6(~~可选~~ **应前置**)DevLoop 收编** | 统一循环逻辑 | 把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板。⚠️ **定位修正**:端到端流程梳理后发现 DevLoop 不是"将来可选整合的边缘子系统",而是**本架构唯一一个已经跑通的参考实现**——它的 L1/L2/L3 三层循环、四分类失败归因、分级预算熔断、`contract_hash` 闸门、DAG 分波、SSE 埋点、前端 DAG 组件都已实现或已有完整规格(`docs/prd/newfunction/_spec/`)。通用 Work Graph 引擎应当**从它抽象而来**,而不是平行发明后再回头合并。详见 [27-E2E](./27-e2e-task-execution-flow.md) §二结论 2 | DevLoop 现有测试套件(`server/tests/devloop/**`)在新引擎上全部通过,无需单独维护两套循环代码 | +| ~~**P6(~~可选~~ 应前置)DevLoop 收编**~~ ❌ **已拍板不做**(2026-09-17) | ~~统一循环逻辑~~ | 这一行原计划"把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板",前提是"通用引擎应当从 DevLoop 抽象而来"——**这个前提写于 P3+P4 引擎动工之前,没有兑现**:P3+P4 已经独立建成并全部收口("平行发明",不是从 DevLoop 抽象而来)。用户评估后拍板:DevLoop 本质是 loop workflow,已被 graph workflow 严格覆盖(loop 是 graph 的退化形状),**直接退役,不做迁移**。DevLoop 全部代码/路由/前端/测试套件已删除,`path-resolver.js`/`doc-store.js`(被 `graph-engine.js` 依赖)保留,PRD/SDD 规格站点归档。详见 [52-devloop-retirement.md](../../done/system-design/52-devloop-retirement.md) | 已收口,不再验收 | -**依赖顺序是强顺序的**:P1 依赖 P0 的数据先迁移进 DB;P3+P4 的引擎依赖 P1 的引用寻址跑通、依赖 P2 的 org graph 定义作为被执行对象(引擎泛化后 org graph 是一等公民,不再只是「画给人看的组织图」)。P3+P4 内部的强顺序体现在它自己的五阶段划分上:**0 → A → B**(引擎地基必须先于回退熔断),C 可后延,D 依赖 A。P5、P6 相对独立,可以在 P3+P4 的阶段 B 稳定后并行推进或往后延。 +**依赖顺序是强顺序的**:P1 依赖 P0 的数据先迁移进 DB;P3+P4 的引擎依赖 P1 的引用寻址跑通、依赖 P2 的 org graph 定义作为被执行对象(引擎泛化后 org graph 是一等公民,不再只是「画给人看的组织图」)。P3+P4 内部的强顺序体现在它自己的五阶段划分上:**0 → A → B**(引擎地基必须先于回退熔断),C 可后延,D 依赖 A。P5 相对独立,可以在 P3+P4 的阶段 B 稳定后推进或往后延;P6 已拍板不做(见上方该行,[52 号文档](../../done/system-design/52-devloop-retirement.md))。 每个 Phase 建议保留旧路径作为 fallback(不删除 `_copyTemplate()`/`system/templates/` 扫描逻辑),直到下一 Phase 验收通过,避免中途出问题时无法回退。 diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json deleted file mode 100644 index dd26159..0000000 --- a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).", - "sequence": [ - { - "_variant": "init", - "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"], - "result": "mock-ok devloop-architect[init]: SDD rev 1", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD

SDD

todo-be

m
m
x
" - }, - { - "path": "sdd/mvcp/control.html", - "content": "Control rev1

C-1 POST /todos

201 on create (ambiguous on errors)

text
POST
v1
" - }, - { - "path": "units.json", - "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" - }, - { - "path": "integration/spec.json", - "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}" - }, - { - "path": "units/todo-be/spec.json", - "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}" - }, - { - "path": "history/sdd-rev-001.html", - "content": "

snap1

rev1

" - } - ] - }, - { - "_variant": "patch", - "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"], - "result": "mock-ok devloop-architect[patch]: SDD rev 2", - "files": [ - { - "path": "sdd/mvcp/control.html", - "content": "Control rev 2

C-1 POST /todos (clarified)

201 on create; 400 on missing text

textrequired
POST
v2
" - }, - { - "path": "sdd/index.html", - "content": "SDD rev2

SDD

rev 2

m
m
x
" - }, - { - "path": "units.json", - "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" - }, - { - "path": "history/sdd-rev-002.html", - "content": "

snap2

patch round 1

" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json deleted file mode 100644 index 9c990ea..0000000 --- a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.", - "sequence": [ - { - "_variant": "round1-fail", - "chunks": [ - "## step 0 · 读 units.json + integration/spec.json\n", - "## step 1-4 · 起 be 进程,跑 case-add-list\n", - "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n" - ], - "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/result.json", - "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" - }, - { - "path": "integration/runs/run-mock-l2-1/process.log", - "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n" - }, - { - "path": "integration/runs/run-mock-l2-1/http-trace.ndjson", - "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n" - } - ] - }, - { - "_variant": "round2-pass", - "chunks": [ - "## L2 round 2 (after SDD patched + L1 reran)\n", - "case-add-list 通过 ✓\n" - ], - "result": "mock-ok devloop-integrator[round2] PASS: integration green", - "files": [ - { - "path": "integration/runs/run-mock-l2-2/result.json", - "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" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json deleted file mode 100644 index aa2749e..0000000 --- a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).", - "chunks": [ - "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n", - "## step 1 · category_classifier → contract_mismatch\n", - "## step 2 · 起草 proposed_sdd_patch\n", - "## step 3 · 写 triage.json + evidence 自检\n" - ], - "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/triage.json", - "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" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json b/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json deleted file mode 100644 index 707e473..0000000 --- a/fixtures/devloop-mock-scenarios/happy_path/devloop-architect.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "Happy-path 1-unit (todo-be) architect for P4 acceptance.", - "chunks": ["## SDD rev 1 (todo-be only)\n"], - "result": "mock-ok devloop-architect[happy]: 1 unit", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD

SDD

todo-be only

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

C-1 POST /todos

201 on create, 400 on missing text

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

snap

rev1

" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json deleted file mode 100644 index ea2ef6f..0000000 --- a/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", - "chunks": ["## SDD rev 1 (single unit todo-be)\n"], - "result": "mock-ok devloop-architect[init]: SDD rev 1", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD

SDD

todo-be

m
m
x
" - }, - { - "path": "sdd/mvcp/control.html", - "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

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

snap

rev1

" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json deleted file mode 100644 index 15078b5..0000000 --- a/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.", - "sequence": [ - { - "_variant": "round1-fail", - "chunks": ["## case fail: todo-be assertion mismatch\n"], - "result": "mock-ok devloop-integrator[round1]: impl_bug failure", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/result.json", - "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" - }, - { - "path": "integration/runs/run-mock-l2-1/process.log", - "content": "[todo-be] ReferenceError at line 8\n" - } - ] - }, - { - "_variant": "round2-pass", - "chunks": ["## pass after coder reset\n"], - "result": "mock-ok devloop-integrator[round2]: pass", - "files": [ - { - "path": "integration/runs/run-mock-l2-2/result.json", - "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" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json deleted file mode 100644 index 646a68c..0000000 --- a/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。", - "chunks": [ - "## category_classifier → impl_bug (SDD 明确,code 实现错)\n" - ], - "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/triage.json", - "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" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json deleted file mode 100644 index ea2ef6f..0000000 --- a/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", - "chunks": ["## SDD rev 1 (single unit todo-be)\n"], - "result": "mock-ok devloop-architect[init]: SDD rev 1", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD

SDD

todo-be

m
m
x
" - }, - { - "path": "sdd/mvcp/control.html", - "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

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

snap

rev1

" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json deleted file mode 100644 index 97b9d25..0000000 --- a/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).", - "sequence": [ - { - "_variant": "round1-crash", - "chunks": ["## EADDRINUSE on :18001\n"], - "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/result.json", - "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" - }, - { - "path": "integration/runs/run-mock-l2-1/process.log", - "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n" - } - ] - }, - { - "_variant": "round2-pass", - "chunks": ["## retry: new port, pass\n"], - "result": "mock-ok devloop-integrator[round2]: pass", - "files": [ - { - "path": "integration/runs/run-mock-l2-2/result.json", - "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" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json deleted file mode 100644 index 3923410..0000000 --- a/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.", - "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"], - "result": "mock-ok devloop-triager: infra_flake confidence=0.95", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/triage.json", - "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" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json deleted file mode 100644 index ea2ef6f..0000000 --- a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).", - "chunks": ["## SDD rev 1 (single unit todo-be)\n"], - "result": "mock-ok devloop-architect[init]: SDD rev 1", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD

SDD

todo-be

m
m
x
" - }, - { - "path": "sdd/mvcp/control.html", - "content": "Control

C-1 POST /todos

201 on create, 400 on missing text (explicit)

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

snap

rev1

" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json deleted file mode 100644 index 4aba5a9..0000000 --- a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.", - "chunks": ["## case fail (spec_ambiguous)\n"], - "result": "mock-ok devloop-integrator: failure due to ambiguous SDD", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/result.json", - "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" - } - ] -} diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json deleted file mode 100644 index 07c7ef2..0000000 --- a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.", - "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"], - "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)", - "files": [ - { - "path": "integration/runs/run-mock-l2-1/triage.json", - "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" - } - ] -} diff --git a/fixtures/devloop-mock/devloop-architect.json b/fixtures/devloop-mock/devloop-architect.json deleted file mode 100644 index c11379f..0000000 --- a/fixtures/devloop-mock/devloop-architect.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "_doc": "Mock fixture for Architect Agent — drives P1 acceptance (todo-demo project). Produces SDD multi-page site + units.json (3 units) + integration/spec.json + architect agent-state + history snapshot.", - "chunks": [ - "## 第 1 步: 读 PRD\n", - "已读 prd/feature/feature-tree.html — 3 个 Unit。\n", - "\n## 第 2 步: 写 sdd/mvcp/\n", - "model · view · control · process\n", - "\n## 第 3 步: 写 units.json + integration/spec.json\n", - "todo-be / todo-fe / todo-db,DAG 无环。\n", - "\n## 完成\n", - "SDD v1 已交付,units.json l2_rounds 初值 0/3。\n" - ], - "result": "mock-ok devloop-architect: SDD v1 + 3 units + 1 integration case", - "files": [ - { - "path": "sdd/index.html", - "content": "SDD · todo-demo

SDD 首页

todo-demo · M/V/C/P 设计

层页
Mmvcp/model.html
M -> V -> C -> P
导航
" - }, - { - "path": "sdd/mvcp/model.html", - "content": "Model

M-1 Todo Entity

{ id:int, text:str, done:bool, created_at:ISO }

fieldtype
idinteger
textstring
schema: id PK · text NOT NULL
model

M-2 FE State

list: Todo[]; loading: bool

listTodo[]
vue ref
fe

M-3 DB Schema

CREATE TABLE todos(id INTEGER PRIMARY KEY,text TEXT,done INT,created_at TEXT);

todostable
sqlite
db
" - }, - { - "path": "sdd/mvcp/view.html", - "content": "View

V-1 列表组件

顶部 input + 按钮 + 列表。

组件事件
TodoListload/add
Vue SFC: TodoList.vue
UI
" - }, - { - "path": "sdd/mvcp/control.html", - "content": "Control

C-1 POST /todos

Body: { text }; Resp 201: { id, text, done:false, created_at }

fieldreq
texttrue
POST /todos
api

C-2 GET /todos

Resp 200: Todo[]

listTodo[]
GET /todos
api
" - }, - { - "path": "sdd/mvcp/process.html", - "content": "Process

P-1 启动流程

node server.js → listen $PORT → ready

stepaction
1spawn
spawn -> listen -> ready
proc
" - }, - { - "path": "sdd/assets/shell.css", - "content": "/* sdd shell.css placeholder */\nbody { font-family: system-ui; }\n.card { padding: 6px; border: 1px solid #ddd; }\n" - }, - { - "path": "sdd/assets/shell.js", - "content": "/* sdd shell.js placeholder */\nconsole.log('[devloop-sdd] shell loaded');\n" - }, - { - "path": "sdd/assets/search-index.json", - "content": "{\"version\":\"1\",\"pages\":5,\"entries\":[]}" - }, - { - "path": "units.json", - "content": "{\n \"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 {\n \"id\": \"todo-db\",\n \"type\": \"db\",\n \"kind\": \"db\",\n \"description\": \"SQLite schema for todos table\",\n \"depends_on\": [],\n \"contract_hash\": \"sha256:0000000000000001\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n },\n {\n \"id\": \"todo-be\",\n \"type\": \"be\",\n \"kind\": \"be\",\n \"description\": \"Express API for /todos\",\n \"depends_on\": [\"todo-db\"],\n \"contract_hash\": \"sha256:0000000000000002\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n },\n {\n \"id\": \"todo-fe\",\n \"type\": \"fe\",\n \"kind\": \"fe\",\n \"description\": \"Vue list page\",\n \"depends_on\": [\"todo-be\"],\n \"contract_hash\": \"sha256:0000000000000003\",\n \"status\": \"pending\",\n \"retries\": 0,\n \"retry_budget\": 3\n }\n ]\n}\n" - }, - { - "path": "integration/spec.json", - "content": "{\n \"rev\": 1,\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"cases\": [\n {\n \"id\": \"case-add-list\",\n \"title\": \"add a todo then list shows it\",\n \"involves\": [\"todo-be\", \"todo-db\"],\n \"steps\": [\n { \"unit\": \"todo-be\", \"call\": \"POST /todos\", \"body\": { \"text\": \"buy milk\" }, \"expect\": { \"status\": 201, \"json\": { \"id\": \"$.id\", \"text\": \"buy milk\" } } },\n { \"unit\": \"todo-be\", \"call\": \"GET /todos\", \"expect\": { \"status\": 200 } }\n ]\n }\n ]\n}\n" - }, - { - "path": "history/sdd-rev-001.html", - "content": "SDD rev 1 snapshot

Snapshot

Archive of SDD rev 1, see sdd/ for live version.

" - }, - { - "path": "units/todo-be/spec.json", - "content": "{\n \"unit_id\": \"todo-be\",\n \"kind\": \"be\",\n \"depends_on\": [\"todo-db\"],\n \"description\": \"Express API for /todos\",\n \"acceptance_criteria\": [\n \"POST /todos with text=foo returns 201 with id, text=foo, done=false\",\n \"GET /todos returns array of todos\",\n \"POST /todos with empty body returns 400\"\n ],\n \"contract_hint\": {\n \"exports\": {\n \"http\": [\n { \"method\": \"POST\", \"path\": \"/todos\", \"responses\": { \"201\": \"Todo\", \"400\": \"BadRequest\" } },\n { \"method\": \"GET\", \"path\": \"/todos\", \"responses\": { \"200\": \"Todo[]\" } }\n ]\n }\n }\n}\n" - }, - { - "path": "units/todo-db/spec.json", - "content": "{\n \"unit_id\": \"todo-db\",\n \"kind\": \"db\",\n \"depends_on\": [],\n \"description\": \"SQLite schema for todos\",\n \"acceptance_criteria\": [\"schema.sql defines todos table with id/text/done/created_at\"]\n}\n" - }, - { - "path": "units/todo-fe/spec.json", - "content": "{\n \"unit_id\": \"todo-fe\",\n \"kind\": \"fe\",\n \"depends_on\": [\"todo-be\"],\n \"description\": \"Vue list page\",\n \"acceptance_criteria\": [\"renders list from GET /todos\", \"calls POST /todos on submit\"]\n}\n" - }, - { - "path": "agent-state/architect.json", - "content": "{\"agent\":\"devloop-architect\",\"status\":\"idle\",\"current_step\":6,\"total_steps\":6,\"last_call_id\":\"call-mock-architect\",\"updated_at\":\"2026-05-13T10:01:01.000Z\"}" - }, - { - "path": "agent-state/architect-todo.json", - "content": "{\"agent\":\"devloop-architect\",\"call_id\":\"call-mock-architect\",\"created_at\":\"2026-05-13T10:01:00.000Z\",\"items\":[{\"id\":\"step-001\",\"description\":\"bootstrap_sdd_site\",\"status\":\"done\",\"depends_on\":[]},{\"id\":\"step-002\",\"description\":\"write mvcp/model.html\",\"status\":\"done\",\"depends_on\":[\"step-001\"]},{\"id\":\"step-003\",\"description\":\"write mvcp/{view,control,process}.html\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-004\",\"description\":\"write units.json (3 units, DAG)\",\"status\":\"done\",\"depends_on\":[\"step-003\"]},{\"id\":\"step-005\",\"description\":\"write integration/spec.json\",\"status\":\"done\",\"depends_on\":[\"step-004\"]},{\"id\":\"step-006\",\"description\":\"validate_sdd_consistency + archive\",\"status\":\"done\",\"depends_on\":[\"step-005\"]}],\"current\":null,\"progress_pct\":100}" - } - ] -} diff --git a/fixtures/devloop-mock/devloop-coder.json b/fixtures/devloop-mock/devloop-coder.json deleted file mode 100644 index 95bb803..0000000 --- a/fixtures/devloop-mock/devloop-coder.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "_doc": "Mock fixture for Coder Agent (todo-be unit). sequence[0]=implement (first try, intentionally buggy in retry demo), sequence[1]=retry (reflective fix).", - "sequence": [ - { - "_variant": "implement", - "chunks": [ - "## step 0 · 读上下文\n", - "已读 spec.json acceptance_criteria · 3 条\n", - "\n## step 1-5 · write code/ + contract.json\n", - "POST /todos · GET /todos\n", - "\n## step 6 · append coder.log\n", - "implement 首次实现完成\n" - ], - "result": "mock-ok devloop-coder[implement] todo-be: code/ + contract.json + coder.log", - "files": [ - { - "path": "units/todo-be/code/server.js", - "content": "// mock todo-be · attempt 1 (intentionally missing 400 branch for retry demo)\nconst express = require('express');\nconst app = express();\napp.use(express.json());\nconst todos = [];\nlet nextId = 1;\napp.post('/todos', (req, res) => {\n // BUG: 没校验 text → empty body 会进 todos\n const t = { id: nextId++, text: req.body.text, done: false, created_at: new Date().toISOString() };\n todos.push(t);\n res.status(201).json(t);\n});\napp.get('/todos', (_req, res) => res.json(todos));\nmodule.exports = app;\n" - }, - { - "path": "units/todo-be/contract.json", - "content": "{\n \"unit_id\": \"todo-be\",\n \"version\": \"sha256:0000000000000002\",\n \"exports\": {\n \"http\": [\n { \"method\": \"POST\", \"path\": \"/todos\", \"responses\": { \"201\": \"Todo\", \"400\": \"BadRequest\" } },\n { \"method\": \"GET\", \"path\": \"/todos\", \"responses\": { \"200\": \"Todo[]\" } }\n ]\n }\n}\n" - }, - { - "path": "units/todo-be/coder.log", - "content": "---\nattempt: 1\nts: 2026-05-13T10:02:00.000Z\nthis_fix: implement POST/GET /todos with in-memory array\nrationale: minimal happy-path scaffold per spec acceptance_criteria #1 and #2\nchanged_files: [\"code/server.js\", \"contract.json\"]\n---\n\n" - } - ] - }, - { - "_variant": "retry", - "chunks": [ - "## step 0 · 读 failures + hint + 自己 coder.log\n", - "上次失败:POST /todos with empty body 返 201 而期望 400\n", - "\n## step 1 · 反思\n", - "上次错: 没校验 req.body.text\n", - "这次改: 在 handler 入口 if (!req.body?.text) return res.status(400)\n", - "因为: 400 是参数错语义,201 表示创建成功\n", - "\n## step 2-5 · 精准 patch + append coder.log\n", - "改了一行,重写 coder.log retry 模板\n" - ], - "result": "mock-ok devloop-coder[retry] todo-be: added body validation; 400 branch covered", - "files": [ - { - "path": "units/todo-be/code/server.js", - "content": "// mock todo-be · attempt 2 (with body validation per retry reflection)\nconst express = require('express');\nconst app = express();\napp.use(express.json());\nconst todos = [];\nlet nextId = 1;\napp.post('/todos', (req, res) => {\n if (!req.body?.text) return res.status(400).json({ error: 'missing field: text' });\n const t = { id: nextId++, text: req.body.text, done: false, created_at: new Date().toISOString() };\n todos.push(t);\n res.status(201).json(t);\n});\napp.get('/todos', (_req, res) => res.json(todos));\nmodule.exports = app;\n" - }, - { - "path": "units/todo-be/coder.log", - "content": "---\nattempt: 1\nts: 2026-05-13T10:02:00.000Z\nthis_fix: implement POST/GET /todos with in-memory array\nrationale: minimal happy-path scaffold per spec acceptance_criteria #1 and #2\nchanged_files: [\"code/server.js\", \"contract.json\"]\n---\n\n---\nattempt: 2\nts: 2026-05-13T10:03:00.000Z\nlast_failure: POST /todos with empty body returned 201, expected 400\nlast_cause: req.body.text 未校验,空 body 也通过进了 todos\nthis_fix: 在 POST handler 入口加 if (!req.body?.text) return 400\nrationale: 400 是参数错语义;201 表示创建成功。区分 4xx 和 2xx 才符合 contract.\nchanged_files: [\"code/server.js\"]\n---\n\n" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock/devloop-integrator.json b/fixtures/devloop-mock/devloop-integrator.json deleted file mode 100644 index fb9c7fa..0000000 --- a/fixtures/devloop-mock/devloop-integrator.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "_doc": "Baseline happy-path Integrator: returns ok=true on every call. Used by tests/runs that don't override via mockFixtureDir.", - "chunks": [ - "## step 0 · 读 units.json + integration/spec.json\n", - "## step 1-7 · 起 be 进程,跑全部 cases\n", - "全部 case 通过 ✓\n" - ], - "result": "mock-ok devloop-integrator[baseline] PASS: integration green", - "files": [ - { - "path": "integration/runs/run-mock-baseline/result.json", - "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-baseline\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 850\n}\n" - }, - { - "path": "integration/runs/run-mock-baseline/process.log", - "content": "[todo-be] starting on :18001\n[todo-be] /api/health → 200\n[todo-be] case-* pass\n[todo-be] SIGTERM clean exit\n" - } - ] -} diff --git a/fixtures/devloop-mock/devloop-pm.json b/fixtures/devloop-mock/devloop-pm.json deleted file mode 100644 index cc84063..0000000 --- a/fixtures/devloop-mock/devloop-pm.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "_doc": "Mock fixture for PM Agent. sequence[0]=init (P1+,full PRD site rev=1), sequence[1]=incremental (P4,appends a section + bumps rev to 2). 第 2+ 次反馈 sticks 在 incremental,每次重复写相同 rev=2 内容 (mock 简化)。", - "sequence": [ - { - "_variant": "init", - "chunks": [ - "## 第 1 步: 解析 idea\n", - "用户要求做一个 TODO 列表 API + Vue 前端。\n", - "\n## 第 2 步: 初始化 prd/ 站点\n", - "(在真实运行时这里会调 bootstrap_prd_site)\n", - "\n## 第 3 步: 写章节\n", - "background / personas / scope / feature-tree / user-flows / prototype\n", - "\n## 完成\n", - "PRD v1 已生成,可交付给 Architect。\n" - ], - "result": "mock-ok devloop-pm: PRD scaffolded (todo-demo, rev=1)", - "files": [ - { - "path": "prd/index.html", - "content": "PRD · todo-demo

项目首页

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

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

站点入口

rev1 init by mock-pm
" - }, - { - "path": "prd/reading-guide.html", - "content": "Reading Guide

阅读顺序

建议按 background → personas → scope → feature-tree → user-flows → prototype 阅读。

├── product\n├── feature\n└── flow
第 1 步product/background
tip
" - }, - { - "path": "prd/product/background.html", - "content": "背景

背景

个人 / 小团队需要轻量 TODO 工具,不愿引入 Notion / Jira。

痛点解法
无快速记录极简 API
需求 → API → 前端
scope: MVP
" - }, - { - "path": "prd/product/personas.html", - "content": "用户

用户画像

独立开发者 / 学生 / 产品经理 — 单人或 ≤3 人小团队。

画像场景
开发者记 bug / 任务
persona-a / persona-b
primary: dev
" - }, - { - "path": "prd/product/scope.html", - "content": "范围

本期范围

POST /todos 新增 · GET /todos 列表 · Vue 简单列表前端。不做用户系统、不做拖拽排序。

InOut
CRUD登录
scope: api + fe
MVP
" - }, - { - "path": "prd/feature/feature-tree.html", - "content": "功能树

功能树

todo-be(POST/GET /todos) · todo-fe(Vue 列表) · 可选 todo-db(SQLite schema)。

模块说明
todo-beAPI 服务
todo-fe前端
├── todo-be\n├── todo-fe\n└── todo-db
3 unit
" - }, - { - "path": "prd/flow/user-flows.html", - "content": "用户流

核心流程

打开页面 → 看到列表 → 输入 → 提交 → 刷新看到新条目。

步骤动作
1GET /todos
2POST /todos
user -> fe -> be -> db
主流程
" - }, - { - "path": "prd/prototype/proto-list.html", - "content": "原型 · 列表

原型 · TODO 列表

顶部 input + 按钮,下方列表展示。

组件事件
inputenter
+----------+\n| input    |\n+----------+\n| item 1   |\n| item 2   |\n+----------+
UI 草图
" - }, - { - "path": "prd/assets/shell.css", - "content": "/* shell.css placeholder (P1 mock) */\nbody { font-family: system-ui; max-width: 960px; margin: 2em auto; }\n.card { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }\n" - }, - { - "path": "prd/assets/shell.js", - "content": "/* shell.js placeholder (P1 mock) */\nconsole.log('[devloop-prd] shell loaded');\n" - }, - { - "path": "prd/assets/search-index.json", - "content": "{\"version\":\"1\",\"pages\":8,\"entries\":[]}" - }, - { - "path": "history/prd-rev-001.html", - "content": "PRD rev 1 snapshot

Snapshot

Archive of PRD rev 1, see prd/index.html for live version.

" - }, - { - "path": "agent-state/pm.json", - "content": "{\"agent\":\"devloop-pm\",\"status\":\"idle\",\"current_step\":8,\"total_steps\":8,\"last_call_id\":\"call-mock-pm\",\"updated_at\":\"2026-05-13T10:00:01.000Z\"}" - }, - { - "path": "agent-state/pm-todo.json", - "content": "{\"agent\":\"devloop-pm\",\"call_id\":\"call-mock-pm\",\"created_at\":\"2026-05-13T10:00:00.000Z\",\"items\":[{\"id\":\"step-001\",\"description\":\"bootstrap_prd_site\",\"status\":\"done\",\"depends_on\":[]},{\"id\":\"step-002\",\"description\":\"write index.html\",\"status\":\"done\",\"depends_on\":[\"step-001\"]},{\"id\":\"step-003\",\"description\":\"write product/*.html\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-004\",\"description\":\"write feature/* + flow/* + prototype/*\",\"status\":\"done\",\"depends_on\":[\"step-002\"]},{\"id\":\"step-005\",\"description\":\"update_search_index\",\"status\":\"done\",\"depends_on\":[\"step-004\"]},{\"id\":\"step-006\",\"description\":\"validate_prd_site\",\"status\":\"done\",\"depends_on\":[\"step-005\"]},{\"id\":\"step-007\",\"description\":\"archive_doc → history/\",\"status\":\"done\",\"depends_on\":[\"step-006\"]},{\"id\":\"step-008\",\"description\":\"echo_diff\",\"status\":\"done\",\"depends_on\":[\"step-007\"]}],\"current\":null,\"progress_pct\":100}" - } - ] - }, - { - "_variant": "incremental", - "chunks": [ - "## Mode: incremental\n", - "收到人类反馈,追加 prd/feature/feature-tree.html 一个 section,bump prd_rev → 2\n" - ], - "result": "mock-ok devloop-pm[incremental]: PRD rev 2 + 1 new feature section", - "files": [ - { - "path": "prd/index.html", - "content": "PRD · todo-demo (rev 2)

项目首页

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

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

站点入口

新增功能 (feedback)

追加: 标记 done / undone 切换

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

功能树

todo-be · todo-fe · todo-db

模块说明
todo-beAPI
tree
v1

NEW: toggle done

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

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

Snapshot rev 2

Archive of PRD rev 2 (after incremental feedback).

" - } - ] - } - ] -} diff --git a/fixtures/devloop-mock/devloop-tester.json b/fixtures/devloop-mock/devloop-tester.json deleted file mode 100644 index 7865497..0000000 --- a/fixtures/devloop-mock/devloop-tester.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "_doc": "Mock fixture for Tester Agent (todo-be unit). sequence[0]=fail (first run after implement) with hint pointing at missing 400 branch; sequence[1]=pass (after Coder retry).", - "sequence": [ - { - "_variant": "fail", - "chunks": [ - "## step 0 · 读 spec.json + contract.json + code/server.js\n", - "\n## step 1-2 · 写 tests/todo-be.spec.js,覆盖 3 acceptance + 2 status code\n", - "\n## step 3 · 跑 vitest\n", - "1 of 3 failed\n", - "\n## step 4 · parse_vitest + hint\n", - "失败: POST /todos with empty body 返 201, 期望 400\n", - "\n## step 5 · bug_open\n" - ], - "result": "mock-ok devloop-tester[fail] todo-be: 2/3 pass · failure has hint", - "files": [ - { - "path": "units/todo-be/tests/todo-be.spec.js", - "content": "// mock tests (P2 fixture)\nimport request from 'supertest';\nimport { describe, it, expect } from 'vitest';\nimport app from '../code/server.js';\ndescribe('todo-be', () => {\n it('POST /todos with text returns 201', async () => {\n const r = await request(app).post('/todos').send({ text: 'buy milk' });\n expect(r.status).toBe(201);\n });\n it('GET /todos returns array', async () => {\n const r = await request(app).get('/todos');\n expect(r.status).toBe(200);\n expect(Array.isArray(r.body)).toBe(true);\n });\n it('POST /todos with empty body returns 400', async () => {\n const r = await request(app).post('/todos').send({});\n expect(r.status).toBe(400);\n });\n});\n" - }, - { - "path": "units/todo-be/runs/run-mock-attempt-1/result.json", - "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"unit_id\": \"todo-be\",\n \"run_id\": \"run-mock-attempt-1\",\n \"summary\": { \"total\": 3, \"passed\": 2, \"failed\": 1, \"skipped\": 0 },\n \"failures\": [\n {\n \"test\": \"POST /todos with empty body returns 400\",\n \"expected\": 400,\n \"got\": 201,\n \"stack\": \"AssertionError: expected 201 to be 400\",\n \"hint\": \"req.body.text 未校验;在 POST handler 入口加存在性判断,空 body 返 400 而不是 201\"\n }\n ],\n \"duration_ms\": 12\n}\n" - } - ] - }, - { - "_variant": "pass", - "chunks": [ - "## step 0 · 读 spec.json + contract.json + code/server.js (已被 Coder 改动)\n", - "\n## step 1 · 跑现有 tests (复用上一轮 spec)\n", - "\n## step 2 · parse_vitest\n", - "3 of 3 passed\n", - "\n## step 3 · bug_transition fixed\n" - ], - "result": "mock-ok devloop-tester[pass] todo-be: 3/3 pass · L1 green", - "files": [ - { - "path": "units/todo-be/runs/run-mock-attempt-2/result.json", - "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"unit_id\": \"todo-be\",\n \"run_id\": \"run-mock-attempt-2\",\n \"summary\": { \"total\": 3, \"passed\": 3, \"failed\": 0, \"skipped\": 0 },\n \"failures\": [],\n \"duration_ms\": 11\n}\n" - } - ] - } - ] -} diff --git a/package.json b/package.json index 1a55d89..b410582 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,6 @@ "test:watch": "vitest watch", "skills:sync": "node scripts/sync-skills.js", "graph:seed": "node scripts/graph-seed.js", - "spec:extract": "node scripts/extract-spec.js", - "spec:check": "node scripts/extract-spec.js --check", "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", "okr:migrate": "node scripts/migrate-goals-to-okr.js", "seed:admin": "node scripts/seed-admin-user.js", diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit deleted file mode 100755 index 3c0333d..0000000 --- a/scripts/git-hooks/pre-commit +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Pre-commit: ensure docs/prd/newfunction/_spec/ stays in sync with HTML source. -# Installed by scripts/install-hooks.sh (auto-run on `npm install` via "prepare" script). - -set -e - -changed=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^docs/prd/newfunction/.*\.html$' || true) - -if [ -z "$changed" ]; then - exit 0 -fi - -echo "[spec-sync] HTML source changed; checking docs/prd/newfunction/_spec/..." -if ! npm run -s spec:check; then - echo "" - echo "[spec-sync] _spec/ is stale. Fix with:" - echo " npm run spec:extract" - echo " git add docs/prd/newfunction/_spec/" - echo "" - echo "(or bypass once with: git commit --no-verify — only if intentional)" - exit 1 -fi diff --git a/server/agent-server.js b/server/agent-server.js index 7ec97ed..b9c26a9 100644 --- a/server/agent-server.js +++ b/server/agent-server.js @@ -66,7 +66,7 @@ app.use('/api/agent', agentChatRoute); app.use('/api/company', require('./routes/company')); app.use('/api/okr', require('./routes/okr')); // /api/goals 别名已退役(27-P3C §六.1) app.use('/api/tasks', require('./routes/tasks')); -app.use('/api/devloop', require('./routes/devloop')); +// /api/devloop 已退役(DevLoop 子系统 2026-09-17 下线,见 docs/_archive/devloop-prd-2026-09-17/) app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6) app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); diff --git a/server/index.js b/server/index.js index 5fdeefe..dd8cf2a 100644 --- a/server/index.js +++ b/server/index.js @@ -98,7 +98,7 @@ app.use('/api/agent', require('./routes/agent-chat')); app.use('/api/okr', require('./routes/okr')); // /api/goals 别名已退役(27-P3C §六.1) app.use('/api/company', require('./routes/company')); app.use('/api/tasks', require('./routes/tasks')); -app.use('/api/devloop', require('./routes/devloop')); +// /api/devloop 已退役(DevLoop 子系统 2026-09-17 下线,见 docs/_archive/devloop-prd-2026-09-17/) app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6) app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs app.use('/api/dream', require('./routes/dream')); // 29-DREAM 经验汇流 diff --git a/server/public/devloop-status.html b/server/public/devloop-status.html deleted file mode 100644 index b1cd1bb..0000000 --- a/server/public/devloop-status.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - -DevLoop · Project Status - - - -
-

DevLoop · Project Status

-
- - -
-
- -
- - - - - - diff --git a/server/routes/devloop.js b/server/routes/devloop.js deleted file mode 100644 index 2c5146e..0000000 --- a/server/routes/devloop.js +++ /dev/null @@ -1,631 +0,0 @@ -/** - * DevLoop · REST Routes (P1) - * - * POST /api/devloop/projects - * body: { company_id, name?, requirement, mode?, target_path?, mock? } - * → 创建项目 + 跑 PM / Architect 两个 Agent (mock 模式产出 PRD/SDD) - * → 返 { pid, root_path, pm: {...}, architect: {...} } - * - * GET /api/devloop/projects/:pid/state - * → 返 { pid, project, units, prd_rev, sdd_rev, prd_site, sdd_site, timeline_tail } - * - * GET /api/devloop/projects (额外:列项目) - * → 返 { projects: [...] } - * - * 规格: docs/prd/newfunction/_spec/plan-migration.md · P1 验收 - */ - -const express = require('express'); -const router = express.Router(); -const { requireAuth } = require('../middleware/jwt-auth'); - -// 38 号文档 Phase 1:全站要求登录,公司归属过滤(经 :pid 反查 company_id)留到 Phase 2。 -router.use(requireAuth); - -const loopController = require('../src/core/devloop/loop-controller'); -const pathResolver = require('../services/devloop/path-resolver'); -const sseBroker = require('../services/devloop/sse-broker'); -const bugTracker = require('../services/devloop/bug-tracker'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const multer = require('multer'); -const { CompanyService, canAccessCompany } = require('../services/company-service'); - -const companyService = new CompanyService(); - -// 38 号文档 Phase 2:devloop 的路由不长 :companyId,长的是 :pid(项目 id)——公司归属藏在 -// pathResolver 缓存里(resolveProjectMeta(pid).company_id),要反查一跳,不能照抄 -// company.js 那种直接的 router.param('companyId', ...)。查不到项目本身就是 404 -// (resolveProjectMeta 对未知 pid 抛错,这里接住转成 404);查得到但公司不是你的, -// 同样回 404,不暴露"这个项目存在,只是不是你的"。 -router.param('pid', (req, res, next, pid) => { - let meta; - try { - meta = pathResolver.resolveProjectMeta(pid); - } catch { - return res.status(404).json({ error: `项目 ${pid} 不存在` }); - } - const company = companyService.get(meta.company_id); - if (!canAccessCompany(company, req.user)) { - return res.status(404).json({ error: `项目 ${pid} 不存在` }); - } - req.devloopProjectMeta = meta; - next(); -}); - -// 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 -const intakeUpload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 10 * 1024 * 1024, files: 10 }, -}); - -const h = (fn) => async (req, res) => { - try { await fn(req, res); } - catch (e) { - console.error(`[DevLoop] ${req.method} ${req.path}:`, e.message); - res.status(e.statusCode || 500).json({ error: e.message }); - } -}; - -// POST /api/devloop/projects -router.post('/projects', h(async (req, res) => { - const { company_id, requirement, mode, target_path, mock, name, mockFixtureDir } = req.body || {}; - if (!company_id) { - return res.status(400).json({ error: 'missing field: company_id' }); - } - if (!requirement || typeof requirement !== 'string') { - return res.status(400).json({ error: 'missing field: requirement' }); - } - if ((mode === 'linked-new' || mode === 'linked-existing') && !target_path) { - return res.status(400).json({ error: `missing field: target_path (required for ${mode})` }); - } - if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { - return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); - } - // 这条路由建的是全新项目,还没有 :pid 可供 router.param 拦截——company_id 是从 - // body 里点名的,不校验的话任何登录用户都能把 devloop 项目挂到别人的公司名下。 - // 放在基本输入校验之后:先把纯输入形状的问题过一遍(便宜、不碰 fs), - // 再做需要读盘的归属校验。 - if (!canAccessCompany(companyService.get(company_id), req.user)) { - return res.status(404).json({ error: `公司 ${company_id} 不存在` }); - } - - // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) - let proj; - try { - proj = await loopController.createProject({ - company_id, - mode: mode || 'sandbox', - target_path, - idea: name ? `${name}: ${requirement}` : requirement, - }); - } catch (e) { - // 用户输入级错误返 400;其他保留 500 - if (e.code === 'SENSITIVE_PATH' - || e.code === 'DIRTY_WORKTREE' - || /target_path|parent dir|not empty|must be absolute|does not exist|not a directory|already contains|uncommitted changes/i.test(e.message)) { - return res.status(400).json({ error: e.message }); - } - throw e; - } - - // 2. 把 name 作为一等字段写入 project.json (供列表 / 改名共用) - if (name) { - try { pathResolver.patchProjectMeta(proj.pid, { name }); } - catch (e) { console.warn('[DevLoop] failed to set name:', e.message); } - } - - // 3. PM → Architect (mock 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用 - const phase = await loopController.runDocsPhase(proj.pid, requirement, { - mock: mock !== false, - mockFixtureDir, - }); - - res.json({ - ok: true, - pid: proj.pid, - root_path: proj.root_path, - mode: proj.mode, - pm: { - files_written: phase.pm.files_written.length, - commit: phase.pm.commit?.sha || null, - mock: phase.pm.mock, - }, - architect: { - files_written: phase.architect.files_written.length, - commit: phase.architect.commit?.sha || null, - mock: phase.architect.mock, - }, - }); -})); - -// GET /api/devloop/projects?include_archived=true —— 只列 req.user 自己公司名下的项目 -router.get('/projects', h(async (req, res) => { - const includeArchived = String(req.query.include_archived || '') === 'true'; - const projects = pathResolver.listProjects({ includeArchived }) - .filter((p) => canAccessCompany(companyService.get(p.company_id), req.user)); - res.json({ projects }); -})); - -// GET /api/devloop/fs/browse?path=/abs/path -// 列出指定绝对路径下的子目录(供新建项目时的"指定本机目录"选择用)。 -// - 不传 path → 用 $HOME -// - 拒绝 sensitive 路径 -// - 仅返回目录,不返回文件 -router.get('/fs/browse', h(async (req, res) => { - let p = req.query.path ? String(req.query.path) : os.homedir(); - if (!path.isAbsolute(p)) { - return res.status(400).json({ error: 'path must be absolute' }); - } - p = path.resolve(p); - if (pathResolver.isSensitivePath(p)) { - return res.status(403).json({ error: `sensitive_path: ${p} is blacklisted` }); - } - if (!fs.existsSync(p)) { - return res.status(404).json({ error: `not found: ${p}` }); - } - const stat = fs.statSync(p); - if (!stat.isDirectory()) { - return res.status(400).json({ error: `not a directory: ${p}` }); - } - let entries = []; - try { - entries = fs.readdirSync(p, { withFileTypes: true }) - .filter(d => d.isDirectory() && !d.name.startsWith('.')) - .map(d => { - const abs = path.join(p, d.name); - let is_empty = false; - try { is_empty = fs.readdirSync(abs).length === 0; } catch { /* permission denied 当作非空 */ } - return { name: d.name, abs_path: abs, is_empty }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - } catch (e) { - return res.status(403).json({ error: `cannot read: ${e.message}` }); - } - const parent = path.dirname(p); - res.json({ - path: p, - parent: parent === p ? null : parent, // 根目录 parent === p - home: os.homedir(), - entries, - }); -})); - -// PATCH /api/devloop/projects/:pid -// body: { name?, idea_summary? } 仅允许这两个字段(status 走 sign-off / abandon / feedback) -router.patch('/projects/:pid', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const allowed = ['name', 'idea_summary']; - const patch = {}; - for (const k of allowed) { - if (req.body && Object.prototype.hasOwnProperty.call(req.body, k)) { - const v = req.body[k]; - if (typeof v !== 'string') return res.status(400).json({ error: `${k} must be string` }); - patch[k] = v.slice(0, 500); - } - } - if (Object.keys(patch).length === 0) { - return res.status(400).json({ error: 'no editable fields in body (allowed: name, idea_summary)' }); - } - const meta = pathResolver.patchProjectMeta(pid, patch); - res.json({ ok: true, pid, project: meta }); -})); - -// DELETE /api/devloop/projects/:pid?hard=true -// 默认软删 (archived=true);hard=true 时递归删目录 (sandbox) 或解注册 (linked) -router.delete('/projects/:pid', h(async (req, res) => { - const { pid } = req.params; - const hard = String(req.query.hard || '') === 'true'; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - if (hard) { - const r = pathResolver.hardDeleteProject(pid); - return res.json({ ok: true, pid, hard: true, ...r }); - } - const meta = pathResolver.patchProjectMeta(pid, { - archived: true, - archived_at: new Date().toISOString(), - }); - res.json({ ok: true, pid, hard: false, archived: true, project: meta }); -})); - -// POST /api/devloop/projects/:pid/restore — 把 archived=true 的项目恢复 -router.post('/projects/:pid/restore', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const meta = pathResolver.patchProjectMeta(pid, { archived: false, archived_at: null }); - res.json({ ok: true, pid, archived: false, project: meta }); -})); - -// POST /api/devloop/projects/:pid/run[?only=] [body: { mockFixtureDir? }] -// - 不带 only: 跑完整 runProject (P3) -// - only=: 单 unit drill-down (P2 兼容) -router.post('/projects/:pid/run', h(async (req, res) => { - const { pid } = req.params; - const { only } = req.query; - const { mockFixtureDir } = req.body || {}; - try { - if (only) { - const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir }); - return res.json({ - ok: result.ok, pid, unit_id: only, - attempts: result.attempts, final_state: result.final_state, - history: result.history.map(h => ({ - agent: h.agent, attempt: h.attempt, - ok: h.ok, files: h.files?.length || 0, - commit: h.commit || null, - })), - }); - } - // 完整 runProject - const result = await loopController.runProject(pid, { mock: true, mockFixtureDir }); - return res.json({ - ok: result.ok, pid, - final_state: result.final_state, - l2_rounds: result.l2_rounds, - run_id: result.run_id || null, - reason: result.reason || null, - history: result.history, - }); - } catch (e) { - if (/Unknown project/.test(e.message)) { - return res.status(404).json({ error: 'unknown project: ' + pid }); - } - if (/not found in project/.test(e.message)) { - return res.status(404).json({ error: 'unknown unit: ' + (only || '?') }); - } - throw e; - } -})); - -// GET /api/devloop/projects/:pid/stream — SSE -// client may send Last-Event-ID header → broker replays from there -router.get('/projects/:pid/stream', (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.flushHeaders?.(); - - const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null; - sseBroker.subscribe(pid, res, { - replayFromOffset: lastEventId != null ? lastEventId : undefined, - }); -}); - -// P4: POST /api/devloop/projects/:pid/sign-off -// body: { by?, note? } -// 400 if status !== 'shipped' -router.post('/projects/:pid/sign-off', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - try { - const meta = await loopController.signOff(pid, req.body || {}); - res.json({ ok: true, pid, status: meta.status }); - } catch (e) { - if (e.name === 'InvalidProjectStatusTransition') { - return res.status(400).json({ error: e.message, from: e.from, to: e.to }); - } - throw e; - } -})); - -// P4: POST /api/devloop/projects/:pid/abandon -// body: { by?, reason?, note? } -router.post('/projects/:pid/abandon', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - try { - const meta = await loopController.abandon(pid, req.body || {}); - res.json({ ok: true, pid, status: meta.status }); - } catch (e) { - if (e.name === 'InvalidProjectStatusTransition') { - return res.status(400).json({ error: e.message, from: e.from, to: e.to }); - } - throw e; - } -})); - -// P4: POST /api/devloop/projects/:pid/feedback -// body: { text (required), by?, mockFixtureDir? } -// 400 if status ∈ {shipped-final, abandoned} -router.post('/projects/:pid/feedback', h(async (req, res) => { - const { pid } = req.params; - const { text, by, mockFixtureDir } = req.body || {}; - if (!text) return res.status(400).json({ error: 'missing field: text' }); - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - try { - const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir }); - res.json(r); - } catch (e) { - if (e.name === 'InvalidProjectStatusTransition') { - return res.status(400).json({ error: e.message, from: e.from, to: e.to }); - } - throw e; - } -})); - -// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be -router.get('/projects/:pid/bugs', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const filter = {}; - if (req.query.status) filter.status = String(req.query.status).split(','); - if (req.query.unit_id) filter.unit_id = req.query.unit_id; - if (req.query.severity) filter.severity = String(req.query.severity).split(','); - const bugs = bugTracker.list(pid, filter); - res.json({ bugs, total: bugs.length }); -})); - -// P5: GET /api/devloop/projects/:pid/timeline?limit=200 -// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用 -router.get('/projects/:pid/timeline', h(async (req, res) => { - const fs = require('fs'); - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200)); - const p = pathResolver.resolveTimelineLog(pid); - if (!fs.existsSync(p)) return res.json({ events: [], total: 0 }); - const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean); - const events = lines.slice(-limit).reverse().map(l => { - try { return JSON.parse(l); } catch { return null; } - }).filter(Boolean); - res.json({ events, total: events.length }); -})); - -// P5: GET /api/devloop/projects/:pid/changelog -// 读 changelog.ndjson 全部 (按时间倒序) -router.get('/projects/:pid/changelog', h(async (req, res) => { - const fs = require('fs'); - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const p = pathResolver.resolveChangelog(pid); - if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 }); - const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) - .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean) - .reverse(); - res.json({ entries, total: entries.length }); -})); - -// P5: GET /api/devloop/projects/:pid/units/:unitId -// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json) -router.get('/projects/:pid/units/:unitId', h(async (req, res) => { - const fs = require('fs'); const path = require('path'); - const { pid, unitId } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const unitDir = pathResolver.resolveUnitDir(pid, unitId); - if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId }); - // 从 units.json 找 unit 元数据 - let unitMeta = null; - try { - const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); - unitMeta = (uj.units || []).find(u => u.id === unitId) || null; - } catch { /* ignore */ } - // runs - const runsDir = path.join(unitDir, 'runs'); - let runs = []; - if (fs.existsSync(runsDir)) { - const ids = fs.readdirSync(runsDir).filter(n => { - try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; } - }).sort().reverse().slice(0, 3); - for (const id of ids) { - const rp = path.join(runsDir, id, 'result.json'); - let r = null; - if (fs.existsSync(rp)) { - try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {} - } - runs.push({ run_id: id, result: r }); - } - } - // coder.log 最后 1KB 节选 - let coderLog = null; - const clp = path.join(unitDir, 'coder.log'); - if (fs.existsSync(clp)) { - const raw = fs.readFileSync(clp, 'utf8'); - coderLog = raw.length > 1024 ? raw.slice(-1024) : raw; - } - res.json({ unit: unitMeta, runs, coder_log_tail: coderLog }); -})); - -// GET /api/devloop/projects/:pid/state -router.get('/projects/:pid/state', h(async (req, res) => { - try { - const state = loopController.getProjectState(req.params.pid); - res.json(state); - } catch (e) { - if (/Unknown project/.test(e.message)) { - return res.status(404).json({ error: 'unknown project: ' + req.params.pid }); - } - throw e; - } -})); - -// ───────────────────────────────────────────────────────────────────────────── -// P6 · Requirements 列表 + 新增需求 intake (file upload / dir scan / chat draft) -// ───────────────────────────────────────────────────────────────────────────── - -// GET /api/devloop/projects/:pid/requirements -// 返回需求列表:rev=1 为初始需求(取自 project.json.idea_summary), -// rev>=2 来自 changelog.ndjson 中 kind=prd_rev 的条目。 -router.get('/projects/:pid/requirements', h(async (req, res) => { - const { pid } = req.params; - let projectMeta; - try { - projectMeta = JSON.parse(fs.readFileSync(pathResolver.resolveProjectJson(pid), 'utf8')); - } catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - - const items = []; - // rev 1: 初始需求 - items.push({ - rev: 1, - ts: projectMeta.created_at, - by: 'init', - trigger: 'project_create', - text: projectMeta.idea_summary || '', - }); - - // rev>=2: from changelog - const clp = pathResolver.resolveChangelog(pid); - if (fs.existsSync(clp)) { - const lines = fs.readFileSync(clp, 'utf8').trim().split('\n').filter(Boolean); - for (const l of lines) { - let e; try { e = JSON.parse(l); } catch { continue; } - if (e.kind !== 'prd_rev') continue; - items.push({ - rev: e.rev, - ts: e.ts, - by: e.by || 'human', - trigger: e.trigger || 'human_feedback', - text: e.diff?.feedback_excerpt || e.summary || '', - }); - } - } - - // 派生 status: 最新一条 = current(用项目状态),其余 = older - items.sort((a, b) => a.rev - b.rev); - const latestRev = items.length ? items[items.length - 1].rev : 0; - const projectStatus = projectMeta.status || 'draft'; - for (const it of items) { - it.status = it.rev === latestRev ? projectStatus : 'older'; - it.is_current = it.rev === latestRev; - } - - res.json({ - pid, - project_status: projectStatus, - current_rev: latestRev, - items: items.reverse(), // 倒序:最新在前 - }); -})); - -// POST /api/devloop/projects/:pid/intake/files -// multipart/form-data: files=...; (.md / .txt / .html 才被采纳,其它跳过) -// 保存到 projects/{pid}/intake/{ts}/,返回每个文件的提取文本。 -router.post('/projects/:pid/intake/files', - intakeUpload.array('files', 10), - h(async (req, res) => { - const { pid } = req.params; - let root; - try { root = pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const intakeDir = path.join(root, 'intake', ts); - fs.mkdirSync(intakeDir, { recursive: true }); - - const files = req.files || []; - const results = []; - for (const f of files) { - const ext = path.extname(f.originalname).toLowerCase(); - if (!['.md', '.txt', '.html', '.htm'].includes(ext)) { - results.push({ name: f.originalname, skipped: true, reason: `unsupported ext: ${ext}` }); - continue; - } - const safeName = f.originalname.replace(/[/\\]/g, '_').slice(0, 200); - const abs = path.join(intakeDir, safeName); - fs.writeFileSync(abs, f.buffer); - let text = f.buffer.toString('utf8'); - if (ext === '.html' || ext === '.htm') { - // 极简 strip:去脚本/样式块 + 拆 tag - text = text - .replace(//gi, '') - .replace(//gi, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - } - results.push({ - name: f.originalname, - size: f.size, - saved_to: path.relative(root, abs), - text, - }); - } - res.json({ ok: true, intake_dir: path.relative(root, intakeDir), files: results }); - }) -); - -// POST /api/devloop/projects/:pid/intake/scan-dir -// body: { path: '/abs/dir', max_files?: 50, max_bytes?: 1MB } -// 递归找 md/txt/html,读文本。受 sensitive 黑名单拦截。 -router.post('/projects/:pid/intake/scan-dir', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - - const target = req.body?.path && String(req.body.path); - const maxFiles = Math.min(200, parseInt(req.body?.max_files, 10) || 50); - const maxBytes = Math.min(5 * 1024 * 1024, parseInt(req.body?.max_bytes, 10) || 1024 * 1024); - if (!target || !path.isAbsolute(target)) { - return res.status(400).json({ error: 'path required (absolute)' }); - } - if (pathResolver.isSensitivePath(target)) { - return res.status(403).json({ error: `sensitive_path: ${target}` }); - } - if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { - return res.status(404).json({ error: `not a directory: ${target}` }); - } - - const found = []; - let totalBytes = 0; - let truncated = false; - function walk(dir, depth) { - if (depth > 6 || found.length >= maxFiles || truncated) return; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return; } - for (const e of entries) { - if (e.name.startsWith('.') || e.name === 'node_modules') continue; - const full = path.join(dir, e.name); - if (e.isDirectory()) { walk(full, depth + 1); continue; } - if (!e.isFile()) continue; - const ext = path.extname(e.name).toLowerCase(); - if (!['.md', '.txt', '.html', '.htm'].includes(ext)) continue; - const stat = fs.statSync(full); - if (totalBytes + stat.size > maxBytes) { truncated = true; return; } - let text; - try { text = fs.readFileSync(full, 'utf8'); } catch { continue; } - if (ext === '.html' || ext === '.htm') { - text = text.replace(//gi, '') - .replace(//gi, '') - .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); - } - totalBytes += stat.size; - found.push({ path: full, rel: path.relative(target, full), size: stat.size, text }); - if (found.length >= maxFiles) { truncated = true; return; } - } - } - walk(target, 0); - res.json({ ok: true, scanned_root: target, total_bytes: totalBytes, truncated, files: found }); -})); - -// POST /api/devloop/projects/:pid/draft-chat -// body: { messages: [{role:'user'|'assistant', content:string}, ...] } -// 返回 { role:'assistant', content:string } -// 占位实现:原样回显最后一条 user 消息 + 提示。LLM 接入后此函数体内换实现即可。 -router.post('/projects/:pid/draft-chat', h(async (req, res) => { - const { pid } = req.params; - try { pathResolver.resolveProjectRoot(pid); } - catch { return res.status(404).json({ error: 'unknown project: ' + pid }); } - const messages = Array.isArray(req.body?.messages) ? req.body.messages : []; - const lastUser = [...messages].reverse().find(m => m.role === 'user'); - const echoText = lastUser?.content?.toString().trim() || '(空消息)'; - const reply = `已收到你的描述(共 ${echoText.length} 字符):\n\n> ${echoText.slice(0, 200)}${echoText.length > 200 ? '…' : ''}\n\n(LLM 多轮调试暂未接入。请继续追加细节、上传文档、或扫描代码目录,然后点"提交需求"一次性发给 PM Agent。)`; - res.json({ role: 'assistant', content: reply, llm_stub: true }); -})); - -module.exports = router; diff --git a/server/services/devloop/bug-tracker.js b/server/services/devloop/bug-tracker.js deleted file mode 100644 index 61d6362..0000000 --- a/server/services/devloop/bug-tracker.js +++ /dev/null @@ -1,274 +0,0 @@ -/** - * DevLoop · bugTracker - * - * bugs.ndjson 的 CRUD + 状态机(P-9)。 - * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的最新一行。 - * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。 - * - transition() 走 P-9 状态机校验,非法转换抛错。 - * - * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16 - * docs/prd/newfunction/_spec/sdd-model.md · M-14 - * docs/prd/newfunction/_spec/sdd-process.md · P-9 - */ - -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); - -const pathResolver = require('./path-resolver'); - -// 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。 -let timelineLogger = null; -let sseBroker = null; -function attachTimelineLogger(t) { timelineLogger = t; } -function attachSseBroker(b) { sseBroker = b; } - -// ──────────────────────────────────────────────────────────────── -// 状态机 -// ──────────────────────────────────────────────────────────────── - -const VALID_TRANSITIONS = { - open: ['fixing', 'wontfix', 'duplicate'], - fixing: ['fixed', 'open'], - fixed: ['verified', 'open'], - verified: ['open'], - wontfix: [], - duplicate: [], -}; - -function canTransition(from, to) { - if (!VALID_TRANSITIONS[from]) return false; - return VALID_TRANSITIONS[from].includes(to); -} - -class InvalidBugTransitionError extends Error { - constructor(from, to, bugId) { - super(`Invalid bug transition for ${bugId}: ${from} → ${to}`); - this.name = 'InvalidBugTransitionError'; - this.from = from; - this.to = to; - this.bugId = bugId; - } -} - -// ──────────────────────────────────────────────────────────────── -// 核心 -// ──────────────────────────────────────────────────────────────── - -function dedupKey(bug) { - const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || '']; - return parts.join(':'); -} - -function _genBugId(now = new Date()) { - const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS - const tag = crypto.randomBytes(2).toString('hex'); - return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`; -} - -function _now() { return new Date().toISOString(); } - -function _logPath(pid) { return pathResolver.resolveBugsLog(pid); } - -function _appendLine(pid, obj) { - const p = _logPath(pid); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.appendFileSync(p, JSON.stringify(obj) + '\n'); -} - -/** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */ -function _readAll(pid) { - const p = _logPath(pid); - if (!fs.existsSync(p)) return []; - const raw = fs.readFileSync(p, 'utf8'); - return raw.split('\n').filter(Boolean).map(l => { - try { return JSON.parse(l); } catch { return null; } - }).filter(Boolean); -} - -/** - * 按 bug id 聚合:最后一行 = 终态。 - * @returns Map - */ -function _aggregateLatest(pid) { - const lines = _readAll(pid); - const map = new Map(); - for (const line of lines) { - if (!line.id) continue; - map.set(line.id, line); - } - return map; -} - -// ──────────────────────────────────────────────────────────────── -// open / transition -// ──────────────────────────────────────────────────────────────── - -/** - * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。 - * - * @param {string} pid - * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units - * @returns Bug (含 id) - */ -function open(pid, bug) { - // dedup 检查 - const key = dedupKey(bug); - const all = _aggregateLatest(pid); - for (const b of all.values()) { - if ((b.status === 'open' || b.status === 'fixing') && - dedupKey(b) === key) { - return b; - } - } - - const id = _genBugId(); - const now = _now(); - const full = { - id, - discovered_at: now, - source: bug.source || 'unit_test', - unit_id: bug.unit_id, - run_id: bug.run_id, - test_name: bug.test_name, - category: bug.category || 'unknown', - severity: bug.severity || 'medium', - title: (bug.title || '(no title)').slice(0, 120), - detail: bug.detail || '', - affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []), - status: 'open', - status_history: [{ - ts: now, from: '(none)', to: 'open', - by: bug.by || 'tester', - note: bug.note, - }], - fix_refs: [], - }; - _appendLine(pid, full); - - if (timelineLogger) { - timelineLogger.append(pid, { - source: bug.source || 'tester', - kind: 'bug_opened', - ref: id, - summary: full.title, - }); - } - if (sseBroker) { - sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title }); - } - - return full; -} - -/** - * 状态转换。同步发 SSE + timeline。 - */ -function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) { - if (!to) throw new Error('transition requires `to` field'); - const map = _aggregateLatest(pid); - const b = map.get(bugId); - if (!b) throw new Error(`bug ${bugId} not found`); - - if (!canTransition(b.status, to)) { - throw new InvalidBugTransitionError(b.status, to, bugId); - } - - const now = _now(); - // 复制一份 + 应用变更 - const next = { - ...b, - status: to, - status_history: [ - ...(b.status_history || []), - { ts: now, from: b.status, to, by: by || 'system', note }, - ], - fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []), - ...(duplicate_of ? { duplicate_of } : {}), - }; - _appendLine(pid, next); - - if (timelineLogger) { - timelineLogger.append(pid, { - source: by || 'system', - kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned', - ref: bugId, - summary: `${b.status} → ${to}`, - }); - } - if (sseBroker) { - sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by }); - } - - return next; -} - -// ──────────────────────────────────────────────────────────────── -// 读 -// ──────────────────────────────────────────────────────────────── - -/** - * 列 bug(每个 id 的最新态)。可按 filter 过滤。 - * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date } - */ -function list(pid, filter = {}) { - let out = Array.from(_aggregateLatest(pid).values()); - if (filter.status?.length) { - out = out.filter(b => filter.status.includes(b.status)); - } - if (filter.severity?.length) { - out = out.filter(b => filter.severity.includes(b.severity)); - } - if (filter.unit_id) { - out = out.filter(b => b.unit_id === filter.unit_id); - } - if (filter.since) { - const t = new Date(filter.since).getTime(); - out = out.filter(b => new Date(b.discovered_at).getTime() >= t); - } - return out; -} - -function get(pid, bugId) { - return _aggregateLatest(pid).get(bugId) || null; -} - -/** 完整 status_history(所有 history 条目,含 open 时的初始条目) */ -function history(pid, bugId) { - const b = get(pid, bugId); - if (!b) return []; - return b.status_history || []; -} - -function stats(pid) { - const all = list(pid); - const out = { - open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0, - by_severity: { critical: 0, high: 0, medium: 0, low: 0 }, - total: all.length, - }; - for (const b of all) { - if (out[b.status] !== undefined) out[b.status]++; - if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++; - } - return out; -} - -module.exports = { - // 写 - open, - transition, - // 读 - list, - get, - history, - stats, - // 工具 - dedupKey, - canTransition, - VALID_TRANSITIONS, - InvalidBugTransitionError, - // 依赖注入 - attachTimelineLogger, - attachSseBroker, -}; diff --git a/server/services/devloop/changelog-writer.js b/server/services/devloop/changelog-writer.js deleted file mode 100644 index dd7e6fb..0000000 --- a/server/services/devloop/changelog-writer.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * DevLoop · changelogWriter - * - * 跨 PRD/SDD/units 的版本变更流(M-15)。append-only NDJSON。 - * - 由 DocStore 在 writePRD/writeSDD/writeUnits 成功后调用 - * - 外部 Agent 不直接调 - * - * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-17 - * docs/prd/newfunction/_spec/sdd-model.md · M-15 - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('./path-resolver'); - -function _logPath(pid) { return pathResolver.resolveChangelog(pid); } - -const VALID_KINDS = ['prd_rev', 'sdd_rev', 'units_rev', 'integration_spec_rev']; - -function append(pid, entry) { - if (!VALID_KINDS.includes(entry.kind)) { - throw new Error(`changelogWriter.append: invalid kind "${entry.kind}"`); - } - if (typeof entry.rev !== 'number') { - throw new Error('changelogWriter.append: rev (number) required'); - } - if (!entry.summary) { - throw new Error('changelogWriter.append: summary required'); - } - - const full = { - ts: new Date().toISOString(), - kind: entry.kind, - rev: entry.rev, - by: entry.by || 'system', - trigger: entry.trigger || 'unknown', - summary: entry.summary, - diff: entry.diff || { - sections_added: [], sections_changed: [], sections_removed: [], - units_added: [], units_changed: [], units_removed: [], - }, - archive_ref: entry.archive_ref || null, - }; - - const line = JSON.stringify(full) + '\n'; - const p = _logPath(pid); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.appendFileSync(p, line); - return full; -} - -function read(pid, opts = {}) { - const p = _logPath(pid); - if (!fs.existsSync(p)) return []; - const limit = opts.limit || 100; - const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); - - const entries = []; - for (let i = lines.length - 1; i >= 0; i--) { - let e; - try { e = JSON.parse(lines[i]); } catch { continue; } - if (opts.kind && e.kind !== opts.kind) continue; - if (opts.since && new Date(e.ts).getTime() < new Date(opts.since).getTime()) break; - entries.push(e); - if (entries.length >= limit) break; - } - return entries.reverse(); -} - -/** 取某 kind 最近一条 */ -function latest(pid, kind) { - const recent = read(pid, { kind, limit: 1 }); - return recent[0] || null; -} - -module.exports = { append, read, latest, VALID_KINDS }; diff --git a/server/services/devloop/doc-store.js b/server/services/devloop/doc-store.js index bc502ca..cbfe403 100644 --- a/server/services/devloop/doc-store.js +++ b/server/services/devloop/doc-store.js @@ -1,5 +1,11 @@ /** - * DevLoop · docStore + * docStore(原 DevLoop 子系统的一部分,DevLoop 已于 2026-09-17 退役——见 + * docs/_archive/devloop-prd-2026-09-17/、27 号总纲 P6 行)。这个文件被留了下来: + * `hashContract`/`verifyContractHash`/`canonical` 被 `graph-engine.js`(重试级联判据) + * 和 `goal-contract.js` 直接 require,是 27 号系列现役的共享基础设施。文件里其余 + * PRD/SDD/units.json 读写这部分是 DevLoop 专属逻辑,已随 DevLoop 一起不再被任何 + * 现役代码调用,留着只是因为跟共享部分揉在同一个文件里、拆分不值当,改动前 + * 先确认改的是哪一半。 * * PRD / SDD / units.json / integration/spec.json 的读写、归档、校验。 * 所有 Agent 不直接 fs.writeFile 这些文件 — 必须走 docStore,确保: diff --git a/server/services/devloop/git-vault.js b/server/services/devloop/git-vault.js deleted file mode 100644 index 0edee1d..0000000 --- a/server/services/devloop/git-vault.js +++ /dev/null @@ -1,311 +0,0 @@ -/** - * DevLoop · gitVault - * - * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调 - * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。 - * - * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包 - * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架 - * - commit author 标 Agent 名("PM Agent ") - * - nothing-to-commit 不抛错,返 { ok: true, skipped: true } - * - * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md - * docs/prd/newfunction/_spec/sdd-control.md · C-18 - * docs/prd/newfunction/_spec/sdd-process.md · P-10 - */ - -const { spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('./path-resolver'); - -const DEFAULT_GITIGNORE = `# DevLoop project .gitignore - -# 高频写的运行态,不需要版本 -agent-state/ - -# git 本身就是 history -history/ - -# 大日志 -stream.log -units/*/runs/*/stdout.txt -units/*/runs/*/stderr.txt -integration/runs/*/process.log -integration/runs/*/http-trace.ndjson.gz - -# Node / 依赖 -node_modules/ -*.log -.DS_Store -.cache/ -.tmp/ -.coverage/ - -# 测试中间文件 -units/*/.vitest-cache/ -`; - -// per-pid mutex(简单 Promise chain) -const mutexes = new Map(); -function _withLock(pid, fn) { - const prev = mutexes.get(pid) || Promise.resolve(); - const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个 - mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection - return next; -} - -// 运行单条 git 命令;返回 { ok, code, stdout, stderr } -function _run(cwd, args, opts = {}) { - return new Promise((resolve) => { - const child = spawn('git', args, { - cwd, - env: { ...process.env, ...(opts.env || {}) }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = '', stderr = ''; - child.stdout.on('data', c => { stdout += c.toString(); }); - child.stderr.on('data', c => { stderr += c.toString(); }); - child.on('close', code => { - resolve({ ok: code === 0, code, stdout, stderr }); - }); - child.on('error', err => { - resolve({ ok: false, code: -1, stdout, stderr: err.message }); - }); - }); -} - -// ──────────────────────────────────────────────────────────────── -// API -// ──────────────────────────────────────────────────────────────── - -/** 在项目根 git init + 写 .gitignore + 首个 commit */ -async function init(pid, opts = {}) { - return _withLock(pid, async () => { - const root = pathResolver.resolveProjectRoot(pid); - - // 已是 repo 则跳 - if (fs.existsSync(path.join(root, '.git'))) { - return { ok: true, head: await _head(root), already_exists: true }; - } - - // 1. git init - const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']); - if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`); - - // 2. 配 author identity(commit 需要) - const userName = opts.name || 'DevLoop'; - const userEmail = opts.email || 'devloop@lemonboss.local'; - await _run(root, ['config', 'user.name', userName]); - await _run(root, ['config', 'user.email', userEmail]); - // 不签名(v1 不强求 GPG) - await _run(root, ['config', 'commit.gpgsign', 'false']); - - // 3. 写 .gitignore - const gitignorePath = path.join(root, '.gitignore'); - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE); - } - - // 4. 首个 commit - const r2 = await _run(root, ['add', '.']); - if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`); - - const initialMsg = opts.initialMessage || - `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : ''); - const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']); - if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`); - - return { ok: true, head: await _head(root) }; - }); -} - -/** - * Commit current changes. - * @param {string} pid - * @param {object} spec - * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore' - * - unit_id?: string (code 时填,作为 message 一部分) - * - run_id?: string (integ/triage 时填) - * - summary: string (必填) - * - files?: string[] (限定 add 的路径,默认 git add -A) - * - author?: { name, email } (commit author) - * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped) - * @returns { ok, sha?, skipped? } - */ -async function commit(pid, spec) { - return _withLock(pid, async () => { - const root = pathResolver.resolveProjectRoot(pid); - if (!fs.existsSync(path.join(root, '.git'))) { - throw new Error(`not a git repo: ${root} (call init first)`); - } - if (!spec.summary) throw new Error('commit: summary required'); - - // 1. 加 files(显式列表则限定路径;否则 -A 全量。-A 受 .gitignore 控制) - if (Array.isArray(spec.files) && spec.files.length) { - const rAdd = await _run(root, ['add', '--', ...spec.files]); - if (!rAdd.ok) throw new Error(`git add files failed: ${rAdd.stderr}`); - } else { - const rA = await _run(root, ['add', '-A']); - if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); - } - - // 2. 检查是否有可 commit 变化 - const status = await _run(root, ['status', '--porcelain']); - const dirty = status.stdout.trim().length > 0; - if (!dirty) { - if (spec.skipEmpty === false) { - throw new Error('nothing to commit and skipEmpty=false'); - } - return { ok: true, skipped: true }; - } - - // 3. commit - const env = {}; - if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name; - if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email; - if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name; - if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email; - - const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env }); - if (!r.ok) { - // 兜底:检测 "nothing to commit" 文案 - if (/nothing to commit/i.test(r.stdout + r.stderr)) { - return { ok: true, skipped: true }; - } - throw new Error(`git commit failed: ${r.stderr || r.stdout}`); - } - - const sha = await _head(root); - return { ok: true, sha, kind: spec.kind, summary: spec.summary }; - }); -} - -async function tag(pid, tagName, opts = {}) { - return _withLock(pid, async () => { - const root = pathResolver.resolveProjectRoot(pid); - const args = ['tag']; - if (opts.message) { - args.push('-a', tagName, '-m', opts.message); - } else { - args.push(tagName); - } - if (opts.sha) args.push(opts.sha); - const r = await _run(root, args); - if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`); - return { ok: true, tag: tagName }; - }); -} - -async function log(pid, opts = {}) { - const root = pathResolver.resolveProjectRoot(pid); - const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s']; - if (opts.limit) args.push(`-n${opts.limit}`); - if (opts.since) args.push(`--since=${opts.since}`); - if (opts.grep) args.push(`--grep=${opts.grep}`); - const r = await _run(root, args); - if (!r.ok) { - // 空仓库 (no commits) → 返空 - if (/does not have any commits/i.test(r.stderr)) return []; - throw new Error(`git log failed: ${r.stderr}`); - } - return r.stdout.trim().split('\n').filter(Boolean).map(line => { - const [sha, author_name, author_email, ts, ...rest] = line.split('\t'); - return { - sha, author_name, author_email, - ts: parseInt(ts, 10), - summary: rest.join('\t'), - }; - }); -} - -async function diff(pid, refA, refB, opts = {}) { - const root = pathResolver.resolveProjectRoot(pid); - const args = ['diff', `${refA}..${refB}`]; - if (opts.path) args.push('--', opts.path); - const r = await _run(root, args); - if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`); - return r.stdout; -} - -async function show(pid, sha) { - const root = pathResolver.resolveProjectRoot(pid); - const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]); - if (!r.ok) throw new Error(`git show failed: ${r.stderr}`); - return { sha, output: r.stdout }; -} - -async function listTags(pid) { - const root = pathResolver.resolveProjectRoot(pid); - const r = await _run(root, ['tag', '--list', '--sort=-creatordate']); - if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`); - return r.stdout.trim().split('\n').filter(Boolean); -} - -async function status(pid) { - const root = pathResolver.resolveProjectRoot(pid); - if (!fs.existsSync(path.join(root, '.git'))) { - return { dirty: false, files: [], no_repo: true }; - } - const r = await _run(root, ['status', '--porcelain']); - if (!r.ok) throw new Error(`git status failed: ${r.stderr}`); - const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2)); - return { dirty: files.length > 0, files }; -} - -async function resetWorkingTree(pid, ref = 'HEAD') { - return _withLock(pid, async () => { - const root = pathResolver.resolveProjectRoot(pid); - const r1 = await _run(root, ['reset', '--hard', ref]); - if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`); - const r2 = await _run(root, ['clean', '-fd']); - if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`); - return { ok: true }; - }); -} - -async function revert(pid, sha) { - return _withLock(pid, async () => { - const root = pathResolver.resolveProjectRoot(pid); - const r = await _run(root, ['revert', '--no-edit', sha]); - if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`); - return { ok: true, newSha: await _head(root) }; - }); -} - -async function archive(pid, ref = 'HEAD', format = 'zip') { - const root = pathResolver.resolveProjectRoot(pid); - const outPath = path.join('/tmp', `${pid}-${ref}.${format}`); - // git archive --format=zip -o /tmp/x.zip HEAD - const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]); - if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`); - return { path: outPath }; -} - -// 工具 -async function _head(root) { - const r = await _run(root, ['rev-parse', 'HEAD']); - if (!r.ok) return null; - return r.stdout.trim(); -} - -function _resetMutexesForTesting() { - mutexes.clear(); -} - -module.exports = { - init, - commit, - tag, - log, - diff, - show, - listTags, - status, - resetWorkingTree, - revert, - archive, - DEFAULT_GITIGNORE, - _resetMutexesForTesting, -}; diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js index 73636d1..7a37dd6 100644 --- a/server/services/devloop/path-resolver.js +++ b/server/services/devloop/path-resolver.js @@ -1,9 +1,15 @@ /** - * DevLoop · pathResolver + * pathResolver(原 DevLoop 子系统的一部分,DevLoop 已于 2026-09-17 退役——见 + * docs/_archive/devloop-prd-2026-09-17/、27 号总纲 P6 行。这个文件被留了下来: + * `graph-engine.js` 的项目根分配(`ensureProjectRoot`)、`company-service.js` 的 + * `linked-*` 模式校验都直接 require 这里的 `allocateProjectRoot`/`isSensitivePath`/ + * `resolveFixedTarget`/`validateLinkedTarget`,是 27 号系列现役的共享基础设施, + * 不是死代码,改动前按现役依赖对待。 * - * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry / + * 所有产物路径的唯一真源。DevLoop 原本的其他服务(docStore / unitRegistry / * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码 - * `data/companies/...`。 + * `data/companies/...`——后四个已随 DevLoop 一起删除,docStore 因为 hashContract + * 也被 27 号系列复用而保留。 * * 支持三种 mode(见 arch-project-root.html §②): * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` diff --git a/server/services/devloop/sse-broker.js b/server/services/devloop/sse-broker.js deleted file mode 100644 index 08e7a42..0000000 --- a/server/services/devloop/sse-broker.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * DevLoop · sseBroker - * - * 每项目一个广播通道,多订阅者。 - * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者 - * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者 - * - 心跳:每 25s 一条 `:keepalive\n\n` 注释 - * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行 - * - * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11 - * docs/prd/newfunction/_spec/api-sse.md - * docs/prd/newfunction/_spec/sdd-process.md · P-5 - */ - -const fs = require('fs'); -const path = require('path'); -const pathResolver = require('./path-resolver'); - -const HEARTBEAT_MS = 25_000; - -// pid → Set<{ res, heartbeatTimer }> -const subscriptions = new Map(); - -// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续) -let eventCounter = 0; - -function _streamLogPath(pid) { - return pathResolver.resolveStreamLog(pid); -} - -function _initCounterFromLog(pid) { - // 启动时如果 stream.log 已存在,找最大 id 续号 - const p = _streamLogPath(pid); - if (!fs.existsSync(p)) return; - const raw = fs.readFileSync(p, 'utf8'); - const matches = raw.match(/^id: (\d+)$/gm) || []; - for (const m of matches) { - const n = parseInt(m.slice(4), 10); - if (n > eventCounter) eventCounter = n; - } -} - -function _ensureCounterInit(pid) { - // 全局单调即可;不严格 per-pid(避免每次启动都全扫) - if (eventCounter === 0) _initCounterFromLog(pid); -} - -/** - * @param {string} pid - * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers) - * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) } - */ -function subscribe(pid, res, opts = {}) { - _ensureCounterInit(pid); - - // Replay:从 stream.log 读 > replayFromOffset 的行 - if (opts.replayFromOffset != null) { - const fromId = parseInt(opts.replayFromOffset, 10) || 0; - _replayTo(res, pid, fromId); - } - - // 心跳 - const heartbeat = setInterval(() => { - try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ } - }, HEARTBEAT_MS); - - // 加订阅 - if (!subscriptions.has(pid)) subscriptions.set(pid, new Set()); - const entry = { res, heartbeatTimer: heartbeat }; - subscriptions.get(pid).add(entry); - - // 客户端断开 - res.on('close', () => { - clearInterval(heartbeat); - subscriptions.get(pid)?.delete(entry); - if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid); - }); -} - -function _replayTo(res, pid, fromId) { - const p = _streamLogPath(pid); - if (!fs.existsSync(p)) return; - - const raw = fs.readFileSync(p, 'utf8'); - // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n` - const blocks = raw.split('\n\n').filter(Boolean); - for (const block of blocks) { - const idMatch = block.match(/^id: (\d+)/m); - if (!idMatch) continue; - const id = parseInt(idMatch[1], 10); - if (id <= fromId) continue; - try { res.write(block + '\n\n'); } catch { return; } - } -} - -/** - * 广播一条事件 + 落盘 stream.log。 - * @param {string} pid - * @param {string} event - 事件名,如 "unit.state" / "bug.opened" - * @param {any} data - */ -function emit(pid, event, data) { - _ensureCounterInit(pid); - const id = ++eventCounter; - const payload = JSON.stringify(data == null ? {} : data); - const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`; - - // 1. 落盘 - const p = _streamLogPath(pid); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.appendFileSync(p, line); - - // 2. 广播 - const subs = subscriptions.get(pid); - if (subs) { - for (const entry of subs) { - try { entry.res.write(line); } catch { /* ignore broken pipe */ } - } - } - - return { id, event }; -} - -/** 项目结束:发 close + 关所有订阅。 */ -function close(pid) { - const subs = subscriptions.get(pid); - if (!subs) return; - for (const entry of subs) { - try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ } - try { entry.res.end(); } catch { /* ignore */ } - clearInterval(entry.heartbeatTimer); - } - subscriptions.delete(pid); -} - -/** 测试用 */ -function _resetForTesting() { - for (const subs of subscriptions.values()) { - for (const entry of subs) clearInterval(entry.heartbeatTimer); - } - subscriptions.clear(); - eventCounter = 0; -} - -function _activeSubscribers(pid) { - return subscriptions.get(pid)?.size || 0; -} - -module.exports = { - subscribe, - emit, - close, - HEARTBEAT_MS, - _resetForTesting, - _activeSubscribers, -}; diff --git a/server/services/devloop/timeline-logger.js b/server/services/devloop/timeline-logger.js deleted file mode 100644 index 8b4b775..0000000 --- a/server/services/devloop/timeline-logger.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * DevLoop · timelineLogger - * - * 全流程统一事件日志(M-13 ProcessTimeline)。append-only NDJSON。 - * - 每行一个事件 - * - 每条 ≤ 1KB(summary 自动截 280 字符;payload > 4KB 强制 ref 形式) - * - 同步原子写(appendFileSync) - * - * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-15 - * docs/prd/newfunction/_spec/sdd-model.md · M-13 - */ - -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); - -const pathResolver = require('./path-resolver'); - -const SUMMARY_MAX = 280; -const PAYLOAD_MAX_BYTES = 4096; - -function _logPath(pid) { return pathResolver.resolveTimelineLog(pid); } - -function _truncate(s, n) { - if (typeof s !== 'string') s = String(s); - if (s.length <= n) return s; - return s.slice(0, n - 1) + '…'; -} - -/** - * Append 一条事件。同步写。 - * @param {string} pid - * @param {object} ev - { source, kind, ref?, summary, payload? } - */ -function append(pid, ev) { - if (!ev || !ev.source || !ev.kind || !ev.summary) { - throw new Error('timelineLogger.append: source/kind/summary required'); - } - - const event = { - ts: Math.floor(Date.now() / 1000), - source: ev.source, - kind: ev.kind, - ref: ev.ref || null, - summary: _truncate(ev.summary, SUMMARY_MAX), - }; - - // payload 超大 → 强制改 ref 形式(不写盘外部文件,仅写入 ref 占位) - if (ev.payload !== undefined) { - const json = JSON.stringify(ev.payload); - if (json.length > PAYLOAD_MAX_BYTES) { - event.payload = { oversize: true, original_bytes: json.length }; - } else { - event.payload = ev.payload; - } - } - - const line = JSON.stringify(event) + '\n'; - const p = _logPath(pid); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.appendFileSync(p, line); - return event; -} - -/** - * 读全部 / 过滤事件。 - * @param {object} opts - { limit?, since?: unix ts, source?, kind? } - */ -function read(pid, opts = {}) { - const p = _logPath(pid); - if (!fs.existsSync(p)) return []; - - const limit = opts.limit || 100; - const since = opts.since; - const filterSource = opts.source; - const filterKind = opts.kind; - - const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); - const events = []; - - // 倒序读,遇到 limit 停(避免 load 整个 file) - for (let i = lines.length - 1; i >= 0; i--) { - let ev; - try { ev = JSON.parse(lines[i]); } catch { continue; } - if (since && ev.ts < since) break; // 由于 ts 单调,可以提前 break - if (filterSource && ev.source !== filterSource) continue; - if (filterKind && ev.kind !== filterKind) continue; - events.push(ev); - if (events.length >= limit) break; - } - - return events.reverse(); // 输出按时间正序 -} - -/** 最近 N 条(已按时间正序) */ -function tail(pid, n = 10) { - return read(pid, { limit: n }); -} - -module.exports = { append, read, tail }; diff --git a/server/services/devloop/unit-registry.js b/server/services/devloop/unit-registry.js deleted file mode 100644 index 616a1e4..0000000 --- a/server/services/devloop/unit-registry.js +++ /dev/null @@ -1,279 +0,0 @@ -/** - * DevLoop · unitRegistry - * - * Unit 状态机 (M-2 + P-1) 的唯一真源。所有 markCoding/markTesting/pass/fail/ - * block/reset 都走 FSM 校验,非法转换抛 InvalidTransitionError。 - * - * 持久化:通过 docStore 读写 units.json(不直接 fs IO)。 - * 事件:每次合法状态转换发 SSE `unit.state`(若 sseBroker 注入)。 - * - * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-10 - * docs/prd/newfunction/_spec/sdd-process.md · P-1 - * docs/prd/newfunction/_spec/sdd-model.md · M-2 - */ - -const docStore = require('./doc-store'); -const pathResolver = require('./path-resolver'); - -class InvalidTransitionError extends Error { - constructor(from, to, unitId) { - super(`Invalid state transition for ${unitId}: ${from} → ${to}`); - this.name = 'InvalidTransitionError'; - this.from = from; - this.to = to; - this.unitId = unitId; - } -} - -// FSM 合法转换表(来自 sdd-process P-1) -// from → allowed [to] 集合 -const TRANSITIONS = { - pending: ['coding'], - coding: ['testing', 'failed'], - testing: ['passed', 'failed'], - failed: ['coding', 'blocked'], - passed: ['pending'], // L2 reset - blocked: ['pending'], // 人类干预后 -}; - -function canTransition(from, to) { - if (!TRANSITIONS[from]) return false; - return TRANSITIONS[from].includes(to); -} - -// sseBroker 是可选依赖。LoopController 启动时 attach 进来。 -let sseBroker = null; -function attachSseBroker(broker) { - sseBroker = broker; -} - -// ──────────────────────────────────────────────────────────────── -// 读 -// ──────────────────────────────────────────────────────────────── - -function _loadUnits(pid) { - const u = docStore.readUnits(pid); - if (!u) throw new Error(`units.json not found for ${pid}`); - return u; -} - -function list(pid) { - return _loadUnits(pid).units; -} - -function get(pid, unitId) { - const u = list(pid).find(x => x.id === unitId); - if (!u) throw new Error(`unit ${unitId} not found in project ${pid}`); - return u; -} - -function pending(pid) { return list(pid).filter(u => u.status === 'pending'); } -function passed(pid) { return list(pid).filter(u => u.status === 'passed'); } -function blocked(pid) { return list(pid).filter(u => u.status === 'blocked'); } -function failed(pid) { return list(pid).filter(u => u.status === 'failed'); } -function anyBlocked(pid) { return blocked(pid).length > 0; } - -/** - * L2 闸门:全部 unit status=passed 且 contract_hash 与当前 SDD 一致。 - * 这里只检查 status;contract_hash 一致性由 LoopController 调用前先 verifyContractHash - * (这是更深的检查,需要读每个 unit 的 contract.json,太重不放这里)。 - */ -function l2GateOpen(pid) { - const units = list(pid); - if (units.length === 0) return false; - return units.every(u => u.status === 'passed'); -} - -// ──────────────────────────────────────────────────────────────── -// 写(FSM 转换) -// ──────────────────────────────────────────────────────────────── - -function _writeBack(pid, unitsJson) { - docStore.writeUnits(pid, unitsJson, { skipValidation: false }); -} - -function _transition(pid, unitId, to, extra = {}) { - const unitsJson = _loadUnits(pid); - const unit = unitsJson.units.find(u => u.id === unitId); - if (!unit) throw new Error(`unit ${unitId} not found in project ${pid}`); - - const from = unit.status; - if (!canTransition(from, to)) { - throw new InvalidTransitionError(from, to, unitId); - } - - unit.status = to; - Object.assign(unit, extra); - _writeBack(pid, unitsJson); - - if (sseBroker) { - sseBroker.emit(pid, 'unit.state', { - unit_id: unitId, - from, to, - retries: unit.retries, - reason: extra.reason || null, - }); - } - - return unit; -} - -function markCoding(pid, unitId) { - return _transition(pid, unitId, 'coding'); -} - -function markTesting(pid, unitId) { - return _transition(pid, unitId, 'testing'); -} - -function pass(pid, unitId, contractHash) { - return _transition(pid, unitId, 'passed', { contract_hash: contractHash }); -} - -function fail(pid, unitId, reason) { - // failed 时累加 retries - const unitsJson = _loadUnits(pid); - const unit = unitsJson.units.find(u => u.id === unitId); - if (!unit) throw new Error(`unit ${unitId} not found`); - - if (!canTransition(unit.status, 'failed')) { - throw new InvalidTransitionError(unit.status, 'failed', unitId); - } - - const from = unit.status; - unit.status = 'failed'; - unit.retries = (unit.retries || 0) + 1; - unit.last_failure_reason = reason; - _writeBack(pid, unitsJson); - - if (sseBroker) { - sseBroker.emit(pid, 'unit.state', { - unit_id: unitId, from, to: 'failed', - retries: unit.retries, reason, - }); - } - - return unit; -} - -function block(pid, unitId, reason) { - return _transition(pid, unitId, 'blocked', { block_reason: reason }); -} - -/** - * 重置一批 Unit 到 pending(L2 修复后或 L3 增量后)。 - * 允许 passed → pending 和 blocked → pending;其他状态保留(warning log)。 - */ -function reset(pid, unitIds) { - const unitsJson = _loadUnits(pid); - const reset_units = []; - const skipped = []; - for (const id of unitIds) { - const u = unitsJson.units.find(x => x.id === id); - if (!u) { - skipped.push({ id, reason: 'not_found' }); - continue; - } - const from = u.status; - if (!canTransition(from, 'pending')) { - skipped.push({ id, reason: `cannot_reset_from_${from}` }); - continue; - } - u.status = 'pending'; - u.retries = 0; // 重置 retry counter - reset_units.push({ id, from }); - } - _writeBack(pid, unitsJson); - - if (sseBroker && reset_units.length) { - for (const r of reset_units) { - sseBroker.emit(pid, 'unit.state', { - unit_id: r.id, from: r.from, to: 'pending', retries: 0, reason: 'reset', - }); - } - } - return { reset: reset_units, skipped }; -} - -// ──────────────────────────────────────────────────────────────── -// L3 增量 diff -// ──────────────────────────────────────────────────────────────── - -/** - * 对比新旧 units.json,返回 added/changed/removed 三类 unit id。 - * - added : 新 list 有,旧 list 无 - * - changed: 双方都有但 contract_hash 不同 - * - removed: 旧 list 有,新 list 无 - */ -function diffUnits(oldUnitsList, newUnitsList) { - const oldMap = new Map((oldUnitsList || []).map(u => [u.id, u])); - const newMap = new Map((newUnitsList || []).map(u => [u.id, u])); - - const added = []; - const changed = []; - const removed = []; - - for (const [id, n] of newMap) { - if (!oldMap.has(id)) { - added.push(id); - } else if (oldMap.get(id).contract_hash !== n.contract_hash) { - changed.push(id); - } - } - for (const [id] of oldMap) { - if (!newMap.has(id)) removed.push(id); - } - return { added, changed, removed }; -} - -// ──────────────────────────────────────────────────────────────── -// 初始化 units.json 骨架 -// ──────────────────────────────────────────────────────────────── - -/** - * Architect 第一次生成 SDD 时,构造 units.json 骨架。 - * 各 unit 自带默认 retry_budget=5、status=pending、retries=0。 - */ -function buildUnitsJson({ project_id, prd_rev, sdd_rev, units, l2_budget = 3 }) { - return { - project_id, - prd_rev: String(prd_rev), - sdd_rev: String(sdd_rev), - l2_rounds: 0, - l2_budget, - units: units.map(u => ({ - id: u.id, - type: u.type, - status: 'pending', - depends_on: u.depends_on || [], - retries: 0, - retry_budget: u.retry_budget || 5, - contract_hash: u.contract_hash, - ...(u.last_run ? { last_run: u.last_run } : {}), - })), - }; -} - -module.exports = { - // 读 - list, get, - pending, passed, blocked, failed, anyBlocked, - l2GateOpen, - - // 写 - markCoding, markTesting, pass, fail, block, reset, - - // L3 - diffUnits, - - // 工具 - buildUnitsJson, - canTransition, - TRANSITIONS, - - // 错误 - InvalidTransitionError, - - // 依赖注入 - attachSseBroker, -}; diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js deleted file mode 100644 index 1f7a8de..0000000 --- a/server/src/core/devloop/loop-controller.js +++ /dev/null @@ -1,999 +0,0 @@ -/** - * DevLoop · LoopController - * - * P0 已提供: - * - createProject({ company_id, mode, target_path, idea }) — 项目分配 + git init - * - commitAfterAgent(pid, agentName, summary) — Agent 收尾自动 commit - * - * P1 新增 (Docs MVP): - * - runDocsPhase(pid, requirement, opts) — 串行跑 PM → Architect (mock 模式), - * 把 fixture.files 落盘到项目根,每个 Agent 收尾自动 commit - * - callAgent(pid, agentName, prompt, opts) — 单次 Agent 调用 (P1 仅支持 mock 模式) - * - * 规格: - * - docs/prd/newfunction/_spec/sdd-process.md · P-10 GitCommit - * - docs/prd/newfunction/_spec/arch-git-versioning.md - * - docs/prd/newfunction/_spec/arch-project-root.md - * - docs/prd/newfunction/_spec/plan-migration.md · P1 Docs MVP - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const gitVault = require('../../../services/devloop/git-vault'); -const timelineLogger = require('../../../services/devloop/timeline-logger'); -const unitRegistry = require('../../../services/devloop/unit-registry'); -const bugTracker = require('../../../services/devloop/bug-tracker'); -const changelogWriter = require('../../../services/devloop/changelog-writer'); -const sseBroker = require('../../../services/devloop/sse-broker'); -const docStore = require('../../../services/devloop/doc-store'); -const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol'); - -// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地 -unitRegistry.attachSseBroker(sseBroker); -bugTracker.attachSseBroker(sseBroker); -bugTracker.attachTimelineLogger(timelineLogger); - -const TEMPLATES_DIR = require('../../../services/agent-locator').AGENTS_ROOT; - -/** - * 新建项目: - * 1. allocateProjectRoot (sandbox / linked-new) - * 2. 写 project.json (最小元数据) - * 3. gitVault.init + 写 .gitignore (gitVault 内置 DEFAULT_GITIGNORE) - * 4. 首次 commit "chore: init project" - */ -async function createProject({ company_id, mode = 'sandbox', target_path, idea = '' }) { - if (!company_id) throw new Error('company_id required'); - - const { pid, root_path } = pathResolver.allocateProjectRoot({ - company_id, mode, target_path, - }); - - // project.json (含 P4 status FSM 字段) - const meta = { - pid, - company_id, - mode, - created_at: new Date().toISOString(), - idea_summary: (idea || '').slice(0, 200), - status: 'draft', // FSM: draft → running → shipped → shipped-final | abandoned - status_updated_at: new Date().toISOString(), - }; - fs.writeFileSync( - pathResolver.resolveProjectJson(pid), - JSON.stringify(meta, null, 2) - ); - - // git init (内置首个 init commit) — gitVault.init 已自己做 commit - await gitVault.init(pid, { - summary: idea ? idea.slice(0, 80) : `init ${pid}`, - }); - - // timeline (写在 init 之后,作为"项目创建"事件) - try { - timelineLogger.append(pid, { - source: 'system', - kind: 'project.created', - ref: pid, - summary: `Project ${pid} created (${mode})`, - payload: { company_id, mode, root_path }, - }); - } catch (e) { - console.warn('[LoopController] timeline append failed:', e.message); - } - - // 把 project.json + timeline.ndjson 一并提交进第二个 commit (工作树留干净) - // 显式 files 列表:避免 linked-existing 模式把用户已有未提交文件拖进我们的 chore commit - const firstCommit = await gitVault.commit(pid, { - kind: 'chore', - summary: `chore: project metadata + first timeline entry for ${pid}`, - author: { name: 'devloop-system', email: 'devloop@local' }, - files: ['project.json', 'timeline.ndjson'], - }); - - return { pid, root_path, mode, first_commit: firstCommit }; -} - -/** - * Agent 结束后,把工作树新文件 auto-commit。 - * 调用方一般在 stream-executor 完成或 agent-manager.post-task-success 时调。 - * - * @param {string} pid - * @param {string} agentName (devloop-pm / devloop-architect / ...) - * @param {string} summary 一句话描述本次产出 (做 commit message 副标题) - * @returns {{ sha, files_changed }} 或 null (无变更时) - */ -async function commitAfterAgent(pid, agentName, summary = '') { - // commit kind 推断 - const kindMap = { - 'devloop-pm': 'prd', - 'devloop-architect': 'sdd', - 'devloop-coder': 'code', - 'devloop-tester': 'code', - 'devloop-integrator': 'integ', - 'devloop-triager': 'triage', - }; - const kind = kindMap[agentName] || 'chore'; - const msg = `${kind}(${agentName}): ${summary || 'agent update'}`; - const commit = await gitVault.commit(pid, { - kind, - summary: msg, - author: { name: agentName, email: `${agentName}@devloop.local` }, - }); - - if (commit.skipped) { - return null; - } - - try { - timelineLogger.append(pid, { - source: agentName, - kind: 'git.commit', - ref: commit.sha, - summary: msg.slice(0, 200), - payload: { sha: commit.sha }, - }); - } catch { /* swallow */ } - - return commit; -} - -/** - * 单次 Agent 调用 (P1 mock 实现): - * 1. 读 system/agents/{agentName}/prompt.md (系统提示) - * 2. 用 ClaudeCliProtocol(mock=true) 跑一遍 - * 3. 收集 chunks / files / result - * 4. 把 fixture.files 落盘到项目根 - * 5. 触发 commitAfterAgent - * - * @param {string} pid - * @param {string} agentName e.g. "devloop-pm" - * @param {string} userPrompt 用户/上一步追加的提示 - * @param {object} opts - * - onChunk?: (text) => void 流式回调 - * - mock?: boolean 默认走 LEMONBOSS_CLAUDE_MODE=mock - * - skipCommit?: boolean 默认 false - * @returns { ok, agent, files_written, commit, result_text } - */ -async function callAgent(pid, agentName, userPrompt, opts = {}) { - const projectRoot = pathResolver.resolveProjectRoot(pid); - - const useMock = opts.mock !== false; - - // 1. 系统提示 (可选 — 没文件就空串) - const promptPath = path.join(TEMPLATES_DIR, agentName, 'prompt.md'); - const systemPrompt = fs.existsSync(promptPath) - ? fs.readFileSync(promptPath, 'utf8') - : ''; - // mock 模式: 把 system + user 拼一起喂 fixture (fixture 不在意); - // real 模式: systemPrompt 走 --append-system-prompt, userPrompt 走 stdin。 - const fullPrompt = useMock - ? (systemPrompt ? `${systemPrompt}\n\n## User Task\n${userPrompt}` : userPrompt) - : userPrompt; - - // 2. real 模式: 先快照工作树 mtime,Agent 跑完后做 diff - const preSnapshot = useMock ? null : _snapshotMtimes(projectRoot); - - // 3. 启动 protocol - const protocol = new ClaudeCliProtocol( - { name: agentName }, - { - mock: useMock, - mockFixtureDir: opts.mockFixtureDir, - // real 模式专属: - systemPrompt: useMock ? undefined : systemPrompt, - maxBudgetUsd: opts.maxBudgetUsd, // 调用方可设上限 - }, - ); - - let resultParams = null; - const filesFromMock = []; - protocol.on('session_update', (u) => { - if (u.update?.sessionUpdate === 'agent_message_chunk' && opts.onChunk) { - opts.onChunk(u.update.content.text); - } - }); - protocol.on('acp_event', (ev) => { - if (ev.method === 'session/result') resultParams = ev.params; - else if (ev.method === 'mock/files' && Array.isArray(ev.params?.files)) { - filesFromMock.push(...ev.params.files); - } - }); - - try { - await protocol.connect(null, projectRoot); - await protocol.sendPrompt(fullPrompt); - } finally { - protocol.disconnect(); - } - - // 4. 落盘 — mock 用 fixture.files, real 用 fs diff - const filesWritten = []; - if (useMock) { - for (const f of filesFromMock) { - if (!f?.path || typeof f.content !== 'string') continue; - if (path.isAbsolute(f.path) || f.path.includes('..')) { - throw new Error(`unsafe mock file path: ${f.path}`); - } - const abs = path.join(projectRoot, f.path); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - let content = f.content; - if (f.path === 'units.json' || f.path.endsWith('/units.json')) { - try { - const u = JSON.parse(content); - u.project_id = pid; - content = JSON.stringify(u, null, 2); - } catch { /* leave as-is, downstream will fail validation */ } - } - fs.writeFileSync(abs, content); - filesWritten.push(f.path); - } - } else { - // real 模式: 扫工作树找新增/修改文件 (相对项目根) - const postSnapshot = _snapshotMtimes(projectRoot); - for (const [rel, mtime] of postSnapshot) { - const before = preSnapshot.get(rel); - if (before === undefined || before < mtime) filesWritten.push(rel); - } - } - - // 5. timeline + commit - try { - timelineLogger.append(pid, { - source: agentName, - kind: 'agent.call', - ref: resultParams?.session_id || (useMock ? 'mock' : 'real'), - summary: (resultParams?.result || '').slice(0, 200) || `${agentName} ran (${useMock ? 'mock' : 'real'})`, - payload: { files_written: filesWritten.length, mock: !!resultParams?.mock, real: !useMock }, - }); - } catch { /* swallow */ } - - let commit = null; - if (!opts.skipCommit) { - const summary = (resultParams?.result || '').slice(0, 80) || `${agentName} produced ${filesWritten.length} files`; - commit = await commitAfterAgent(pid, agentName, summary); - } - - return { - ok: true, - agent: agentName, - files_written: filesWritten, - commit, - result_text: resultParams?.result || '', - mock: !!resultParams?.mock, - }; -} - -/** - * Docs phase (P1): PM → Architect 串行跑。 - * - * @param {string} pid - * @param {string} requirement 用户原始需求一段话 - * @param {object} opts - * - onChunk?: (agent, text) => void - * @returns { ok, pm, architect } - */ -async function runDocsPhase(pid, requirement, opts = {}) { - if (!requirement || typeof requirement !== 'string') { - throw new Error('requirement (string) required'); - } - - // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始 - if (opts.resetMockSequence !== false) { - ClaudeCliProtocol.resetMockState(); - } - - const onChunk = opts.onChunk - ? (agent) => (text) => opts.onChunk(agent, text) - : null; - const passthrough = { - mock: opts.mock !== false, - mockFixtureDir: opts.mockFixtureDir, - }; - - const pm = await callAgent(pid, 'devloop-pm', - `Requirement:\n${requirement}\n\nmode: init`, - { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined }); - - const architect = await callAgent(pid, 'devloop-architect', - `Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`, - { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined }); - - // timeline 总结 - try { - timelineLogger.append(pid, { - source: 'system', - kind: 'docs.complete', - ref: pid, - summary: `Docs phase complete: PM (${pm.files_written.length} files) + Architect (${architect.files_written.length} files)`, - payload: { - pm_files: pm.files_written.length, - architect_files: architect.files_written.length, - }, - }); - } catch { /* swallow */ } - - // P4: 项目进入 running 态(完成 docs 后,准备进 L1/L2) - _setProjectStatus(pid, 'running', { reason: 'docs_complete' }); - - return { ok: true, pm, architect }; -} - -/** - * 读项目当前状态 (P1 用,routes/devloop.js GET /state 后端): - * - project.json - * - units.json (if any) - * - PRD/SDD revs (扫 article[data-rev] 最大值) - * - 最近一次 commit - * - timeline 最近 5 条 - * - bugs 计数 - */ -function getProjectState(pid) { - const root = pathResolver.resolveProjectRoot(pid); - const out = { pid, root_path: root }; - - const projectJson = pathResolver.resolveProjectJson(pid); - if (fs.existsSync(projectJson)) { - try { - out.project = JSON.parse(fs.readFileSync(projectJson, 'utf8')); - out.status = out.project.status || 'unknown'; - } catch {} - } - - const unitsJson = pathResolver.resolveUnitsJson(pid); - if (fs.existsSync(unitsJson)) { - try { - const u = JSON.parse(fs.readFileSync(unitsJson, 'utf8')); - out.units = u.units || []; - out.units_rev = u.rev || 0; - } catch {} - } - - // PRD/SDD rev = 站点根 index.html 的 article[data-rev] - for (const kind of ['prd', 'sdd']) { - const idx = path.join(root, kind, 'index.html'); - if (fs.existsSync(idx)) { - const html = fs.readFileSync(idx, 'utf8'); - const m = html.match(/]*data-doc="(prd|sdd)"[^>]*data-rev="(\d+)"/); - out[`${kind}_rev`] = m ? parseInt(m[2], 10) : 1; - out[`${kind}_site`] = true; - } else { - out[`${kind}_site`] = false; - } - } - - // timeline tail - const tl = pathResolver.resolveTimelineLog(pid); - if (fs.existsSync(tl)) { - const lines = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean); - out.timeline_tail = lines.slice(-5).map(l => { - try { return JSON.parse(l); } catch { return null; } - }).filter(Boolean); - } - - return out; -} - -/** - * runUnit — L1 单 Unit 循环 (P2)。 - * - * 串行 Coder(implement) → Tester(test)。失败时 retryWithReflection 把 - * 上一次 failure + coder.log 摘要打回去重跑 Coder + Tester,直到通过或耗 - * 尽 retry_budget。每个状态转换走 unit-registry FSM。 - * - * @param {string} pid - * @param {string} unitId - * @param {object} opts - * - mock?: boolean (默认 true,P2 阶段) - * - onChunk?: (agent, text) => void - * - resetMockSequence?: boolean (默认 true,跑前清零 fixture sequence 计数) - * @returns { ok, unit_id, attempts, final_state, history: [{ agent, attempt, ... }] } - */ -async function runUnit(pid, unitId, opts = {}) { - if (opts.resetMockSequence !== false) { - ClaudeCliProtocol.resetMockState(); - } - - const unit = unitRegistry.get(pid, unitId); - const budget = unit.retry_budget || 5; - const history = []; - let attempt = 0; - let finalOk = false; - - while (attempt < budget) { - attempt++; - - // ── coding ─────────────────────────────────────────────── - unitRegistry.markCoding(pid, unitId); - const coderHistorySummary = _buildRetryContext(history); - const coderPrompt = attempt === 1 - ? `Mode: implement\nUnit: ${unitId}\n\nRead units/${unitId}/spec.json and produce code/ + contract.json + coder.log.` - : `Mode: retry (attempt ${attempt})\nUnit: ${unitId}\n\n${coderHistorySummary}\n\nReflect on the failure and patch code/ minimally; append a new coder.log entry with last_failure/last_cause/this_fix/rationale fields.`; - const coderResult = await callAgent(pid, 'devloop-coder', coderPrompt, { - mock: opts.mock !== false, - onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-coder', t) : undefined, - }); - history.push({ - agent: 'devloop-coder', attempt, - files: coderResult.files_written, - summary: coderResult.result_text, - commit: coderResult.commit?.sha, - }); - - // ── testing ────────────────────────────────────────────── - unitRegistry.markTesting(pid, unitId); - const testerPrompt = `Mode: ${attempt === 1 ? 'test' : 'retry'}\nUnit: ${unitId}\n\nRead units/${unitId}/code/ + spec.json, run vitest, parse the JSON reporter, and write runs/{runId}/result.json.`; - const testerResult = await callAgent(pid, 'devloop-tester', testerPrompt, { - mock: opts.mock !== false, - onChunk: opts.onChunk ? (t) => opts.onChunk('devloop-tester', t) : undefined, - }); - - const resultRef = _findLatestTesterResult(pid, unitId); - const testerOk = resultRef ? !!resultRef.parsed?.ok : false; - history.push({ - agent: 'devloop-tester', attempt, - files: testerResult.files_written, - summary: testerResult.result_text, - commit: testerResult.commit?.sha, - ok: testerOk, - failures: resultRef?.parsed?.failures || [], - }); - - // ── transition ────────────────────────────────────────── - if (testerOk) { - unitRegistry.pass(pid, unitId, unit.contract_hash); - finalOk = true; - break; - } else { - const reason = resultRef?.parsed?.failures?.[0]?.test - || resultRef?.parsed?.failures?.[0]?.hint - || `tester reported failure (attempt ${attempt})`; - unitRegistry.fail(pid, unitId, reason); - // 如果还有预算 → 再循环;否则 markBlocked - if (attempt >= budget) { - unitRegistry.block(pid, unitId, `retry_budget_exceeded after ${attempt} attempts`); - break; - } - // 把 unit 从 failed → coding 由下一轮 markCoding 完成 (FSM 允许) - } - } - - try { - timelineLogger.append(pid, { - source: 'system', - kind: 'unit.run_complete', - ref: unitId, - summary: `Unit ${unitId} ${finalOk ? 'passed' : 'blocked'} after ${attempt} attempt(s)`, - payload: { ok: finalOk, attempts: attempt, budget }, - }); - } catch { /* swallow */ } - - return { - ok: finalOk, - unit_id: unitId, - attempts: attempt, - final_state: unitRegistry.get(pid, unitId).status, - history, - }; -} - -/** 把 history 数组压成给 Coder 反思用的 markdown 摘要 */ -function _buildRetryContext(history) { - if (!history.length) return ''; - const lines = ['## Retry History']; - for (const h of history) { - lines.push(`- attempt ${h.attempt} · ${h.agent}: ${(h.summary || '').slice(0, 120)}`); - if (h.agent === 'devloop-tester' && Array.isArray(h.failures) && h.failures.length) { - for (const f of h.failures.slice(0, 3)) { - lines.push(` - failure: ${f.test || f.name || '(unnamed)'}: ${f.hint || f.stack?.slice(0, 80) || ''}`); - } - } - } - return lines.join('\n'); -} - -/** - * 找 units/{unitId}/runs/ 下最新一次 result.json (按字典序 — runId 含日期前缀)。 - */ -function _findLatestTesterResult(pid, unitId) { - const runsDir = path.join(pathResolver.resolveUnitDir(pid, unitId), 'runs'); - if (!fs.existsSync(runsDir)) return null; - const ids = fs.readdirSync(runsDir).filter(n => fs.statSync(path.join(runsDir, n)).isDirectory()); - if (!ids.length) return null; - ids.sort(); - const latest = ids[ids.length - 1]; - const resultPath = path.join(runsDir, latest, 'result.json'); - if (!fs.existsSync(resultPath)) return { runId: latest, parsed: null }; - try { - return { runId: latest, parsed: JSON.parse(fs.readFileSync(resultPath, 'utf8')) }; - } catch { - return { runId: latest, parsed: null }; - } -} - -// ────────────────────────────────────────────────────────────── -// P3 · L2 Integration Loop · runProject -// ────────────────────────────────────────────────────────────── - -/** - * Topological wave grouping (Kahn). Returns string[][] — units per wave. - */ -function _groupUnitsByWave(units) { - const groups = []; - const remaining = new Set(units.map(u => u.id)); - const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()])); - while (remaining.size > 0) { - const wave = []; - for (const id of remaining) { - if (deps.get(id).every(d => !remaining.has(d))) wave.push(id); - } - if (wave.length === 0) { - throw new Error('cycle in units.json (no unit has all dependencies cleared)'); - } - for (const id of wave) remaining.delete(id); - groups.push(wave); - } - return groups; -} - -/** find latest run dir under integration/runs/ */ -function _findLatestIntegrationRunDir(pid) { - const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs'); - if (!fs.existsSync(dir)) return null; - const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory()); - if (!ids.length) return null; - ids.sort(); - return ids[ids.length - 1]; -} - -function _readJsonSafe(p) { - try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } -} - -/** - * runProject — P3 主循环 (mock 模式)。 - * - * 流程: - * while not done and not blocked: - * 1. 按 DAG wave 跑 L1 (per-unit runUnit) - * 2. 若 l2GateOpen → 跑 Integrator - * 3. result.ok=true → 项目完成 - * 4. result.ok=false → Triager → dispatch: - * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json) - * → l2_rounds++; 超 l2_budget → l2_exhausted - * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算 - * - infra_flake → 直接重跑 (不 reset, 不烧预算) - * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed - * - * @param {string} pid - * @param {object} opts - * - mockFixtureDir?: string (P3 scenario 测试用) - * - maxRetries?: number (safety net 防 mock 死循环;默认 10) - * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history } - */ -async function runProject(pid, opts = {}) { - // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续, - // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。 - // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。 - const projectRoot = pathResolver.resolveProjectRoot(pid); - const history = []; - const callOpts = { - mock: opts.mock !== false, - mockFixtureDir: opts.mockFixtureDir, - resetMockSequence: false, // 一次 runProject 只 reset 一次 - }; - const safety = opts.maxRetries || 10; - let iterations = 0; - - // SSE start - sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() }); - - while (iterations < safety) { - iterations++; - - // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ──────── - const allUnits = unitRegistry.list(pid); - const pendingUnits = allUnits.filter(u => u.status === 'pending'); - if (pendingUnits.length > 0) { - const waves = _groupUnitsByWave(pendingUnits); - for (let w = 0; w < waves.length; w++) { - const wave = waves[w]; - sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave }); - // 串行跑(mock 同步快,不需并行) - for (const uid of wave) { - const r = await runUnit(pid, uid, { ...callOpts }); - history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts }); - if (!r.ok) { - sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts }); - _safeSetStatus(pid, 'l2_blocked', { reason: `unit ${uid} blocked` }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` }; - } - } - } - } - - // ── (2) L2 gate ──────────────────────────────────────────── - if (!unitRegistry.l2GateOpen(pid)) { - sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' }); - _safeSetStatus(pid, 'l2_blocked', { reason: 'l2GateClosed' }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' }; - } - - // ── (3) 跑 Integrator ────────────────────────────────────── - sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) }); - const integ = await callAgent(pid, 'devloop-integrator', - `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`, - callOpts); - const runId = _findLatestIntegrationRunDir(pid); - const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null; - history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed }); - sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed }); - - // ── (4) 成功 → 项目完成 ──────────────────────────────────── - if (intResult?.ok === true) { - // 若有未关 bug → 转 fixed - const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] }); - for (const b of openBugs) { - try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); } - catch { /* skip invalid transitions */ } - } - sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId }); - try { - timelineLogger.append(pid, { - source: 'system', kind: 'project.shipped', ref: runId, - summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`, - }); - } catch {} - _setProjectStatus(pid, 'shipped', { run_id: runId }); - return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId }; - } - - // ── (5) 失败 → 开 bug + 调 Triager ────────────────────────── - const bug = bugTracker.open(pid, { - source: 'integration_test', - run_id: runId, - title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`, - severity: intResult?.crashed ? 'high' : 'medium', - category: 'unknown', - detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200), - affected_units: intResult?.failures?.[0]?.involves || [], - by: 'devloop-integrator', - }); - bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' }); - - await callAgent(pid, 'devloop-triager', - `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`, - callOpts); - const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null; - history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence }); - sseBroker.emit(pid, 'triage.result', { - run_id: runId, - category: triage?.category, - confidence: triage?.confidence, - affected_units: triage?.affected_units || [], - }); - - if (!triage) { - sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId }); - _safeSetStatus(pid, 'l2_blocked', { reason: 'triage_missing' }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' }; - } - - // ── (6) 分支调度 ──────────────────────────────────────────── - const lowConfidence = (triage.confidence ?? 0) < 0.5; - const category = triage.category; - - if (lowConfidence || category === 'spec_ambiguous') { - sseBroker.emit(pid, 'human_intervention_needed', { - reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous', - run_id: runId, category, confidence: triage.confidence, - }); - _safeSetStatus(pid, 'l2_blocked', { reason: lowConfidence ? 'low_confidence' : 'spec_ambiguous' }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` }; - } - - if (category === 'infra_flake') { - // 不 reset 不烧预算,直接下一轮重跑 Integrator - history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' }); - continue; - } - - if (category === 'impl_bug') { - // reset affected units (不烧 l2_budget) - const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u)); - if (affected.length === 0) { - sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId }); - _safeSetStatus(pid, 'l2_blocked', { reason: 'impl_bug_no_affected' }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history }; - } - unitRegistry.reset(pid, affected); - history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected }); - sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' }); - continue; - } - - if (category === 'contract_mismatch') { - // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++) - // 预检 l2_budget - const currentRounds = _l2Rounds(pid); - const l2Budget = _l2Budget(pid); - if (currentRounds + 1 > l2Budget) { - sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget }); - _safeSetStatus(pid, 'l2_exhausted', { reason: 'budget_exceeded' }); - return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history }; - } - const oldSddRev = _readSddRev(pid); - await callAgent(pid, 'devloop-architect', - `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`, - callOpts); - const newSddRev = _readSddRev(pid); - if (newSddRev > oldSddRev) { - try { - changelogWriter.append(pid, { - kind: 'sdd_rev', - rev: newSddRev, - by: 'devloop-architect', - trigger: 'l2_triage', - summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`, - diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] }, - }); - } catch { /* swallow */ } - sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` }); - } - history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev }); - continue; - } - - // Unknown category - sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category }); - _safeSetStatus(pid, 'l2_blocked', { reason: 'unknown_category' }); - return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` }; - } - - // safety net 超过 - sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations }); - _safeSetStatus(pid, 'l2_exhausted', { reason: 'safety_net' }); - return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history }; -} - -/** Status set that swallows invalid-transition errors (already in target state etc.) */ -function _safeSetStatus(pid, to, extra) { - try { return _setProjectStatus(pid, to, extra); } - catch (e) { - if (e.name === 'InvalidProjectStatusTransition') return null; - throw e; - } -} - -function _l2Rounds(pid) { - const u = docStore.readUnits(pid); - return u?.l2_rounds || 0; -} -function _l2Budget(pid) { - const u = docStore.readUnits(pid); - return u?.l2_budget || 3; -} -function _readSddRev(pid) { - const root = pathResolver.resolveProjectRoot(pid); - const idx = path.join(root, 'sdd', 'index.html'); - if (!fs.existsSync(idx)) return 0; - const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); - if (m) return parseInt(m[1], 10); - // fallback: try mvcp/control.html (where patch goes) - const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html'); - if (fs.existsSync(ctrl)) { - const m2 = fs.readFileSync(ctrl, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/); - if (m2) return parseInt(m2[1], 10); - } - return 1; -} - -// ────────────────────────────────────────────────────────────── -// P4 · L3 Human Feedback · status FSM + signOff / abandon / feedback -// ────────────────────────────────────────────────────────────── - -const STATUS_TRANSITIONS = { - draft: ['running', 'abandoned'], - running: ['shipped', 'l2_blocked', 'l2_exhausted', 'abandoned'], - l2_blocked: ['running', 'abandoned'], - l2_exhausted: ['running', 'abandoned'], - shipped: ['running', 'shipped-final', 'abandoned'], - 'shipped-final': [], // 终态 - abandoned: [], // 终态 -}; - -class InvalidProjectStatusTransition extends Error { - constructor(from, to) { - super(`Invalid project status transition: ${from} → ${to}`); - this.name = 'InvalidProjectStatusTransition'; - this.from = from; - this.to = to; - } -} - -function _readProjectMeta(pid) { - const p = pathResolver.resolveProjectJson(pid); - if (!fs.existsSync(p)) throw new Error(`project.json missing for ${pid}`); - return JSON.parse(fs.readFileSync(p, 'utf8')); -} - -function _writeProjectMeta(pid, meta) { - fs.writeFileSync(pathResolver.resolveProjectJson(pid), JSON.stringify(meta, null, 2)); -} - -/** - * 改 project.json.status,做 FSM 校验 + 发 SSE + 写 timeline。 - * 非法转换抛 InvalidProjectStatusTransition (供路由层映射 400)。 - */ -function _setProjectStatus(pid, to, extra = {}) { - const meta = _readProjectMeta(pid); - const from = meta.status || 'draft'; - if (from === to) return meta; // idempotent - const allowed = STATUS_TRANSITIONS[from] || []; - if (!allowed.includes(to)) { - throw new InvalidProjectStatusTransition(from, to); - } - meta.status = to; - meta.status_updated_at = new Date().toISOString(); - if (extra.note) meta.status_note = extra.note; - _writeProjectMeta(pid, meta); - - try { - sseBroker.emit(pid, 'project.status', { from, to, ...extra }); - timelineLogger.append(pid, { - source: extra.by || 'system', - kind: 'project.status', - ref: pid, - summary: `Project status ${from} → ${to}${extra.reason ? ` (${extra.reason})` : ''}`, - payload: { from, to, ...extra }, - }); - } catch { /* swallow */ } - return meta; -} - -/** - * Sign Off — 把已 shipped 的项目锁为 shipped-final。 - * 之后再发 feedback 会被拒绝。 - */ -async function signOff(pid, opts = {}) { - const meta = _readProjectMeta(pid); - if (meta.status !== 'shipped') { - throw new InvalidProjectStatusTransition(meta.status || 'unknown', 'shipped-final'); - } - _setProjectStatus(pid, 'shipped-final', { by: opts.by || 'human', note: opts.note || 'signed off' }); - // sign-off commit (留 git 痕) - try { - await gitVault.commit(pid, { - kind: 'chore', - summary: `chore: sign-off project ${pid}`, - author: { name: opts.by || 'human', email: 'human@devloop.local' }, - }); - } catch { /* swallow if nothing to commit */ } - return _readProjectMeta(pid); -} - -/** - * Abandon — 任意状态 → abandoned。发 SSE close 帧关闭所有订阅。 - */ -async function abandon(pid, opts = {}) { - const meta = _readProjectMeta(pid); - if (meta.status === 'shipped-final' || meta.status === 'abandoned') { - throw new InvalidProjectStatusTransition(meta.status, 'abandoned'); - } - _setProjectStatus(pid, 'abandoned', { by: opts.by || 'human', reason: opts.reason || 'manual abandon', note: opts.note }); - try { - await gitVault.commit(pid, { - kind: 'chore', - summary: `chore: abandon project ${pid}${opts.reason ? ' · ' + opts.reason : ''}`, - author: { name: opts.by || 'human', email: 'human@devloop.local' }, - }); - } catch { /* swallow */ } - // 关闭所有 SSE 订阅(close 帧) - try { sseBroker.close(pid); } catch { /* ignore */ } - return _readProjectMeta(pid); -} - -/** - * Submit human feedback — 触发 PM incremental 模式。 - * 仅当 status ∈ {shipped, running, l2_blocked, l2_exhausted} 时允许。 - * shipped-final / abandoned → 拒绝。 - */ -async function submitFeedback(pid, text, opts = {}) { - if (!text || typeof text !== 'string') { - throw new Error('feedback text required'); - } - const meta = _readProjectMeta(pid); - if (meta.status === 'shipped-final' || meta.status === 'abandoned') { - throw new InvalidProjectStatusTransition(meta.status, 'feedback'); - } - - const prdRevBefore = _readPrdRev(pid); - const pm = await callAgent(pid, 'devloop-pm', - `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.`, - { mock: opts.mock !== false, mockFixtureDir: opts.mockFixtureDir }, - ); - const prdRevAfter = _readPrdRev(pid); - - // 若 PRD rev bumped → 写 changelog - if (prdRevAfter > prdRevBefore) { - try { - changelogWriter.append(pid, { - kind: 'prd_rev', - rev: prdRevAfter, - by: opts.by || 'human', - trigger: 'human_feedback', - summary: `PRD bumped to rev ${prdRevAfter} via human feedback`, - diff: { feedback_excerpt: text.slice(0, 120) }, - }); - sseBroker.emit(pid, 'prd.updated', { rev: prdRevAfter, summary: 'human feedback applied' }); - } catch { /* swallow */ } - } - - // 若当前是 shipped, 改回 running (人类反馈触发新一轮) - if (meta.status === 'shipped') { - _setProjectStatus(pid, 'running', { reason: 'human_feedback' }); - } - - return { - ok: true, - pid, - files_written: pm.files_written, - prd_rev_before: prdRevBefore, - prd_rev_after: prdRevAfter, - status: _readProjectMeta(pid).status, - }; -} - -/** - * 扫项目根递归收集 (relPath → mtimeMs) Map。 - * 忽略 .git / node_modules / agent-state/snapshots (高频写) 子树。 - * real 模式 callAgent 用这个做 pre/post 快照,自动识别 Agent 写出来的新文件。 - */ -function _snapshotMtimes(root) { - const map = new Map(); - const IGNORE_DIRS = new Set(['.git', 'node_modules', '.cache', '.tmp']); - function walk(dir, prefix) { - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return; } - for (const e of entries) { - if (IGNORE_DIRS.has(e.name)) continue; - const rel = prefix ? `${prefix}/${e.name}` : e.name; - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walk(full, rel); - } else if (e.isFile()) { - try { - map.set(rel, fs.statSync(full).mtimeMs); - } catch { /* ignore */ } - } - } - } - walk(root, ''); - return map; -} - -function _readPrdRev(pid) { - const root = pathResolver.resolveProjectRoot(pid); - const idx = path.join(root, 'prd', 'index.html'); - if (!fs.existsSync(idx)) return 0; - const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="prd"[^>]*data-rev="(\d+)"/); - return m ? parseInt(m[1], 10) : 1; -} - -module.exports = { - createProject, - commitAfterAgent, - callAgent, - runDocsPhase, - runUnit, - runProject, - getProjectState, - // P4 - signOff, - abandon, - submitFeedback, - InvalidProjectStatusTransition, -}; diff --git a/server/src/tools/devloop/_errors.js b/server/src/tools/devloop/_errors.js deleted file mode 100644 index d82165d..0000000 --- a/server/src/tools/devloop/_errors.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * DevLoop tool layer · 标准化错误类型 - * - * 用法: - * throw new ToolValidationError('summary', 'too short'); - * throw new ToolSandboxError('/abs/path', 'pm'); - * - * Claude CLI 看到 thrown error → 包装为 tool_result { is_error: true, content: msg } - */ - -class ToolValidationError extends Error { - constructor(field, reason) { - super(`${field}: ${reason}`); - this.name = 'ToolValidationError'; - this.field = field; - this.reason = reason; - } -} - -class ToolSandboxError extends Error { - constructor(p, agent) { - super(`Sandbox violation: ${agent} tried to write ${p}`); - this.name = 'ToolSandboxError'; - this.path = p; - this.agent = agent; - } -} - -class ToolMissingDependencyError extends Error { - constructor(dep) { - super(`Tool requires ${dep} in ctx (LoopController injection)`); - this.name = 'ToolMissingDependencyError'; - this.dep = dep; - } -} - -module.exports = { - ToolValidationError, - ToolSandboxError, - ToolMissingDependencyError, -}; diff --git a/server/src/tools/devloop/_sandbox.js b/server/src/tools/devloop/_sandbox.js deleted file mode 100644 index f1d09f1..0000000 --- a/server/src/tools/devloop/_sandbox.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Sandbox 边界校验 — Agent 写盘前的 path 检查。 - * - * 用法: - * const { assertWritable } = require('./_sandbox'); - * assertWritable(abs, allowList, ctx.agent); - * - * allowList 形式 (glob-like): - * "prd/**" → ${ctx.projectDir}/prd/** 任意子文件 - * "units/{unitId}/code/**" → ${ctx.unitDir}/code/** (unitId 自动替换) - * "agent-state/**" → ${ctx.projectDir}/agent-state/** 任意 - * "timeline.ndjson" → 精确文件 - */ - -const path = require('path'); -const { ToolSandboxError } = require('./_errors'); - -/** - * @param {string} abs - 待写绝对路径 - * @param {string[]} allowList - 允许写的相对 pattern (相对 ctx.projectDir 或 ctx.unitDir) - * @param {object} ctx - { agent, projectDir, unitDir? } - */ -function assertWritable(abs, allowList, ctx) { - const normalized = path.resolve(abs); - if (isWithinAllowList(normalized, allowList, ctx)) return; - throw new ToolSandboxError(normalized, ctx.agent || 'unknown'); -} - -function isWithinAllowList(abs, allowList, ctx) { - for (const pattern of allowList) { - if (matchesPattern(abs, pattern, ctx)) return true; - } - return false; -} - -function matchesPattern(abs, pattern, ctx) { - // 替换 {unitId} 等占位符 - const resolved = pattern - .replace('{unitId}', ctx.unit_id || '__no_unit__'); - - // 决定 base:含 units/{unitId}/ 的 pattern → 用 unitDir 的父目录(=projectDir/units/) - // 其他 → 用 projectDir - // 简化:统一用 projectDir 作为 base 拼接 pattern - const base = ctx.projectDir || ''; - const expected = path.join(base, resolved); - - // 处理 ** glob - if (expected.endsWith('/**')) { - const prefix = expected.slice(0, -3); // 去掉 "/**" - return abs === prefix || abs.startsWith(prefix + path.sep); - } - - return abs === expected; -} - -module.exports = { - assertWritable, - isWithinAllowList, - matchesPattern, -}; diff --git a/server/src/tools/devloop/bash-json-guard.js b/server/src/tools/devloop/bash-json-guard.js deleted file mode 100644 index fab968d..0000000 --- a/server/src/tools/devloop/bash-json-guard.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * DevLoop · Bash inline-JSON guard 中间件 - * - * 拦截 Coder/Tester/Integrator 通过 Bash 工具发送的inline JSON 命令 - * (CLAUDE.md L91-99 强约束)。挂到 Bash 工具的 preExecute 钩子里。 - * - * 反例(禁): curl -d '{"k":"v"}' http://... - * 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://... - * - * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §⑥ - */ - -// 检测 `-d '{...}'` `-d "{...}"` 或 `--data '{...}'` `--data-raw '{...}'` -const INLINE_JSON_PATTERNS = [ - /-d\s+(['"])\s*\{[\s\S]*?\}\s*\1/, - /--data\s+(['"])\s*\{[\s\S]*?\}\s*\1/, - /--data-raw\s+(['"])\s*\{[\s\S]*?\}\s*\1/, - /--data-binary\s+(['"])\s*\{[\s\S]*?\}\s*\1/, -]; - -function jsonGuard(cmd) { - if (typeof cmd !== 'string') return { ok: true }; - for (const re of INLINE_JSON_PATTERNS) { - if (re.test(cmd)) { - return { - ok: false, - reason: 'inline JSON in Bash command is forbidden (see CLAUDE.md L91-99). ' + - 'Use: echo \'{"k":"v"}\' > /tmp/x.json && curl -d @/tmp/x.json', - matched_pattern: re.source, - }; - } - } - return { ok: true }; -} - -/** - * preExecute hook 注册形式(LemonBoss Bash 工具内部消费) - */ -const bashHooks = { - preExecute: [ - (cmd) => { - const r = jsonGuard(cmd); - if (!r.ok) { - const e = new Error(r.reason); - e.code = 'JSON_GUARD_BLOCK'; - e.matched_pattern = r.matched_pattern; - throw e; - } - }, - ], -}; - -module.exports = { jsonGuard, bashHooks, INLINE_JSON_PATTERNS }; diff --git a/server/src/tools/devloop/coder-tools.js b/server/src/tools/devloop/coder-tools.js deleted file mode 100644 index 9a62a92..0000000 --- a/server/src/tools/devloop/coder-tools.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * DevLoop · coder-tools (3 个) - * contract_bridge verify contract.json ↔ units.json hash 一致 - * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱 - * append_coder_log 结构化追加 coder.log (强制 retry 模板字段) - * - * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7 - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const docStore = require('../../../services/devloop/doc-store'); -const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); - -const contract_bridge = { - name: 'contract_bridge', - description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致', - inputSchema: { - type: 'object', - required: ['unit_id'], - properties: { unit_id: { type: 'string' } }, - }, - async execute({ unit_id }, ctx) { - _req(ctx, ['pid']); - const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); - const contractPath = path.join(unitDir, 'contract.json'); - if (!fs.existsSync(contractPath)) { - return { ok: false, reason: 'contract.json missing' }; - } - const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); - const units = docStore.readUnits(ctx.pid); - if (!units) return { ok: false, reason: 'units.json missing' }; - const unit = units.units.find(u => u.id === unit_id); - if (!unit) return { ok: false, reason: 'unit not in units.json' }; - - const recomputed = docStore.hashContract(contract); - if (contract.version !== recomputed) { - return { - ok: false, - reason: 'contract.version self-inconsistent', - claimed: contract.version, actual: recomputed, - }; - } - if (recomputed !== unit.contract_hash) { - return { - ok: false, - reason: 'contract does not match SDD-declared hash', - expected: unit.contract_hash, actual: recomputed, - hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD', - }; - } - return { ok: true, hash: recomputed }; - }, -}; - -const sandbox_check = { - name: 'sandbox_check', - description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去', - inputSchema: { - type: 'object', - required: ['unit_id', 'since'], - properties: { - unit_id: { type: 'string' }, - since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' }, - }, - }, - async execute({ unit_id, since }, ctx) { - _req(ctx, ['pid']); - const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); - const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); - - const allowedPrefixes = [ - path.join(unitDir, 'code'), - path.join(unitDir, 'contract.json'), - path.join(unitDir, 'coder.log'), - path.join(projectRoot, 'agent-state'), - path.join(projectRoot, 'timeline.ndjson'), - path.join(projectRoot, 'bugs.ndjson'), - path.join(projectRoot, 'changelog.ndjson'), - path.join(projectRoot, '.git'), - ]; - - const violations = []; - function walk(dir) { - if (!fs.existsSync(dir)) return; - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p); - else { - try { - const st = fs.statSync(p); - if (st.mtimeMs < since) continue; - const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep)); - if (!within) violations.push(p); - } catch { /* ignore */ } - } - } - } - walk(projectRoot); - - return { ok: violations.length === 0, violations }; - }, -}; - -const append_coder_log = { - name: 'append_coder_log', - description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。', - inputSchema: { - type: 'object', - required: ['unit_id', 'attempt', 'this_fix', 'rationale'], - properties: { - unit_id: { type: 'string' }, - attempt: { type: 'integer', minimum: 1 }, - ts: { type: 'string' }, - last_failure: { type: 'string' }, - last_cause: { type: 'string' }, - this_fix: { type: 'string', minLength: 4 }, - rationale: { type: 'string', minLength: 4 }, - changed_files: { type: 'array', items: { type: 'string' } }, - }, - }, - async execute(decision, ctx) { - _req(ctx, ['pid']); - if (decision.attempt > 1) { - if (!decision.last_failure || !decision.last_cause) { - throw new ToolValidationError( - 'retry_decision', - 'attempt > 1 requires last_failure + last_cause fields' - ); - } - } - const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log'); - fs.mkdirSync(path.dirname(p), { recursive: true }); - - const fullEntry = { - ts: decision.ts || new Date().toISOString(), - attempt: decision.attempt, - ...(decision.last_failure ? { last_failure: decision.last_failure } : {}), - ...(decision.last_cause ? { last_cause: decision.last_cause } : {}), - this_fix: decision.this_fix, - rationale: decision.rationale, - changed_files: decision.changed_files || [], - }; - - const block = '---\n' + - Object.entries(fullEntry).map(([k, v]) => { - const val = typeof v === 'object' ? JSON.stringify(v) : v; - return `${k}: ${val}`; - }).join('\n') + '\n---\n\n'; - fs.appendFileSync(p, block); - return { ok: true, path: p, attempt: decision.attempt }; - }, -}; - -function _req(ctx, keys) { - for (const k of keys) { - if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); - } -} - -module.exports = { contract_bridge, sandbox_check, append_coder_log }; diff --git a/server/src/tools/devloop/doc-tools.js b/server/src/tools/devloop/doc-tools.js deleted file mode 100644 index 06c5d47..0000000 --- a/server/src/tools/devloop/doc-tools.js +++ /dev/null @@ -1,555 +0,0 @@ -/** - * DevLoop · doc-tools (PM + Architect 用,11 个工具) - * - * bootstrap_prd_site init prd/ 站点 (copy shell.css/js + 建目录) - * bootstrap_sdd_site 同上,SDD - * write_chapter 写 PRD chapter (走 docStore.writePrdChapter) - * write_mvcp_page 写 SDD MVCP 页(model/view/control/process 之一) - * archive_doc 把当前 prd|sdd|units 归档到 history/ - * diff_html cheerio section 级 diff (返 added/changed/removed 列表) - * diff_units units.json 三态 diff - * echo_diff 发 SSE prd.updated/sdd.updated + change_summary - * update_search_index 重建 prd|sdd 站点的 search-index.json (复用 build-search-index 脚本) - * validate_prd_site 整站 cheerio 校验 + 8 类信息 ≥4 - * validate_sdd_consistency SDD ↔ units.json ↔ integration/spec.json 一致性 - * hash_contract 暴露 docStore.hashContract - * - * 规格: docs/prd/newfunction/_spec/sdd-control.md - * docs/prd/newfunction/_spec/agents-pm.md - * docs/prd/newfunction/_spec/agents-architect.md - */ - -const fs = require('fs'); -const path = require('path'); -const { spawn } = require('child_process'); -const cheerio = require('cheerio'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const docStore = require('../../../services/devloop/doc-store'); -const unitRegistry = require('../../../services/devloop/unit-registry'); -const changelogWriter = require('../../../services/devloop/changelog-writer'); -const { ToolValidationError } = require('./_errors'); - -// 模板目录:copy shell.css/shell.js 进项目站点 -const TEMPLATE_ASSETS_DIR = path.resolve(__dirname, '../../../../docs/prd/newfunction/assets'); - -// ──────────────────────────────────────────────────────────────── -// bootstrap_prd_site / bootstrap_sdd_site -// ──────────────────────────────────────────────────────────────── - -function _bootstrapSite(siteRoot, kind) { - const subdirs = kind === 'prd' - ? ['product', 'feature', 'flow', 'prototype', 'assets'] - : ['mvcp', 'assets']; - - for (const d of subdirs) fs.mkdirSync(path.join(siteRoot, d), { recursive: true }); - - // 复制 shell.css + shell.js (如模板存在) - for (const f of ['shell.css', 'shell.js']) { - const src = path.join(TEMPLATE_ASSETS_DIR, f); - const dst = path.join(siteRoot, 'assets', f); - if (fs.existsSync(src) && !fs.existsSync(dst)) { - fs.copyFileSync(src, dst); - } else if (!fs.existsSync(dst)) { - // 模板不存在时落空文件 (开发 / 测试场景) - fs.writeFileSync(dst, `/* ${f} placeholder; copy real template here */\n`); - } - } - // 空的 search-index.json - const idx = path.join(siteRoot, 'assets', 'search-index.json'); - if (!fs.existsSync(idx)) { - fs.writeFileSync(idx, JSON.stringify({ version: '1', pages: 0, entries: [] })); - } - return { ok: true, root: siteRoot, dirs: subdirs.length, assets: 3 }; -} - -const bootstrap_prd_site = { - name: 'bootstrap_prd_site', - description: '初始化项目 prd/ 站点目录(含 assets/shell.css + shell.js + search-index.json 占位)。', - inputSchema: { type: 'object', properties: {} }, - async execute(_input, ctx) { - _req(ctx, ['pid']); - return _bootstrapSite(pathResolver.resolvePrdSite(ctx.pid), 'prd'); - }, -}; - -const bootstrap_sdd_site = { - name: 'bootstrap_sdd_site', - description: '初始化项目 sdd/ 站点目录(含 mvcp/ + assets/)。', - inputSchema: { type: 'object', properties: {} }, - async execute(_input, ctx) { - _req(ctx, ['pid']); - return _bootstrapSite(pathResolver.resolveSddSite(ctx.pid), 'sdd'); - }, -}; - -// ──────────────────────────────────────────────────────────────── -// write_chapter / write_mvcp_page -// ──────────────────────────────────────────────────────────────── - -const write_chapter = { - name: 'write_chapter', - description: '写 PRD chapter (HTML)。kind=prd 时走 prd/{relPath};kind=sdd 时走 sdd/{relPath}。自动校验 + 归档旧版。', - inputSchema: { - type: 'object', - required: ['kind', 'relPath', 'html'], - properties: { - kind: { type: 'string', enum: ['prd', 'sdd'] }, - relPath: { type: 'string', minLength: 1 }, - html: { type: 'string', minLength: 1 }, - }, - }, - async execute({ kind, relPath, html }, ctx) { - _req(ctx, ['pid']); - const writeFn = kind === 'prd' ? docStore.writePrdChapter : docStore.writeSddChapter; - try { - const r = writeFn(ctx.pid, relPath, html); - return { ok: true, ...r }; - } catch (e) { - if (e.name === 'ValidationError') { - throw new ToolValidationError('html', `${e.message}: ${e.details?.join(' · ')}`); - } - throw e; - } - }, -}; - -const write_mvcp_page = { - name: 'write_mvcp_page', - description: '写 SDD MVCP 页 (mvcp/{model|view|control|process}.html)。等价于 write_chapter(kind=sdd, relPath=mvcp/{which}.html)。', - inputSchema: { - type: 'object', - required: ['which', 'html'], - properties: { - which: { type: 'string', enum: ['model', 'view', 'control', 'process'] }, - html: { type: 'string', minLength: 1 }, - }, - }, - async execute({ which, html }, ctx) { - return write_chapter.execute( - { kind: 'sdd', relPath: `mvcp/${which}.html`, html }, - ctx - ); - }, -}; - -// ──────────────────────────────────────────────────────────────── -// archive_doc -// ──────────────────────────────────────────────────────────────── - -const archive_doc = { - name: 'archive_doc', - description: '手动触发归档当前 prd/ 或 sdd/ 整站到 history/ (write_chapter 已自动归档单文件; 此工具用于整站快照)。', - inputSchema: { - type: 'object', - required: ['kind'], - properties: { - kind: { type: 'string', enum: ['prd', 'sdd'] }, - }, - }, - async execute({ kind }, ctx) { - _req(ctx, ['pid']); - const siteRoot = kind === 'prd' - ? pathResolver.resolvePrdSite(ctx.pid) - : pathResolver.resolveSddSite(ctx.pid); - if (!fs.existsSync(siteRoot)) { - throw new ToolValidationError('kind', `${kind}/ does not exist`); - } - - // 找 rev (扫所有 chapter article data-rev 取最大) - const rev = _detectSiteRev(siteRoot, kind); - - const historyDir = path.join( - pathResolver.resolveHistoryDir(ctx.pid), - `${kind}-rev-${rev}` - ); - _copyDir(siteRoot, historyDir); - return { ok: true, rev, archived_to: historyDir }; - }, -}; - -function _detectSiteRev(siteRoot, kind) { - let maxRev = 0; - function walk(dir) { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - if (e.name !== 'assets') walk(full); - } else if (e.name.endsWith('.html')) { - try { - const $ = cheerio.load(fs.readFileSync(full, 'utf8')); - const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); - if (r && /^\d+$/.test(r)) { - const n = parseInt(r, 10); - if (n > maxRev) maxRev = n; - } - } catch { /* ignore */ } - } - } - } - walk(siteRoot); - return maxRev || 1; -} - -function _copyDir(src, dst) { - fs.mkdirSync(dst, { recursive: true }); - for (const e of fs.readdirSync(src, { withFileTypes: true })) { - const s = path.join(src, e.name); - const d = path.join(dst, e.name); - if (e.isDirectory()) _copyDir(s, d); - else fs.copyFileSync(s, d); - } -} - -// ──────────────────────────────────────────────────────────────── -// diff_html -// ──────────────────────────────────────────────────────────────── - -const diff_html = { - name: 'diff_html', - description: 'cheerio section 级 diff:对比 oldHtml 和 newHtml,返 added/changed/removed section data-rev 列表。', - inputSchema: { - type: 'object', - required: ['oldHtml', 'newHtml'], - properties: { - oldHtml: { type: 'string' }, - newHtml: { type: 'string' }, - }, - }, - async execute({ oldHtml, newHtml }, _ctx) { - const $o = cheerio.load(oldHtml || ''); - const $n = cheerio.load(newHtml || ''); - const oldSecs = new Map(); - const newSecs = new Map(); - $o('section[data-rev]').each((_, el) => { - const r = $o(el).attr('data-rev'); - oldSecs.set(r, $o(el).text().trim()); - }); - $n('section[data-rev]').each((_, el) => { - const r = $n(el).attr('data-rev'); - newSecs.set(r, $n(el).text().trim()); - }); - const added = [], changed = [], removed = []; - for (const [r, txt] of newSecs) { - if (!oldSecs.has(r)) added.push(r); - else if (oldSecs.get(r) !== txt) changed.push(r); - } - for (const [r] of oldSecs) { - if (!newSecs.has(r)) removed.push(r); - } - return { added, changed, removed }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// diff_units -// ──────────────────────────────────────────────────────────────── - -const diff_units = { - name: 'diff_units', - description: '对比新旧 units.json: added/changed(contract_hash 变化)/removed。如不传 oldUnits,从 history/最新 units-rev-N.json 读。', - inputSchema: { - type: 'object', - required: ['newUnits'], - properties: { - newUnits: { type: 'array' }, - oldUnits: { type: 'array' }, - }, - }, - async execute({ newUnits, oldUnits }, ctx) { - let oldList = oldUnits; - if (!oldList && ctx?.pid) { - // 尝试从 history/ 取最新归档 - const historyDir = pathResolver.resolveHistoryDir(ctx.pid); - if (fs.existsSync(historyDir)) { - const files = fs.readdirSync(historyDir).filter(f => /^units-rev-\d+\.json$/.test(f)); - if (files.length) { - files.sort(); - const last = files[files.length - 1]; - const old = JSON.parse(fs.readFileSync(path.join(historyDir, last), 'utf8')); - oldList = old.units || []; - } - } - } - return unitRegistry.diffUnits(oldList || [], newUnits); - }, -}; - -// ──────────────────────────────────────────────────────────────── -// echo_diff -// ──────────────────────────────────────────────────────────────── - -const echo_diff = { - name: 'echo_diff', - description: '发 SSE 事件 prd.updated 或 sdd.updated,告知 Dashboard 文档已变。', - inputSchema: { - type: 'object', - required: ['kind', 'rev', 'summary'], - properties: { - kind: { type: 'string', enum: ['prd', 'sdd'] }, - rev: { type: 'integer' }, - summary: { type: 'string', maxLength: 500 }, - sections_diff: { type: 'object' }, - }, - }, - async execute({ kind, rev, summary, sections_diff }, ctx) { - _req(ctx, ['pid']); - if (ctx.sseBroker) { - ctx.sseBroker.emit(ctx.pid, `${kind}.updated`, { - rev, summary, sections_diff: sections_diff || null, - }); - } - // 同步写 changelog - try { - changelogWriter.append(ctx.pid, { - kind: `${kind}_rev`, - rev, - by: ctx.agent || 'system', - trigger: 'agent', - summary, - diff: sections_diff || {}, - }); - } catch (e) { - // 已存在该 rev → 跳过;其他错误抛 - if (!/already/.test(e.message)) { /* swallow */ } - } - return { ok: true }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// update_search_index -// ──────────────────────────────────────────────────────────────── - -const update_search_index = { - name: 'update_search_index', - description: '重建 prd/ 或 sdd/ 站点的 search-index.json (复用 scripts/build-search-index.js 的逻辑)。', - inputSchema: { - type: 'object', - properties: { - target: { type: 'string', enum: ['prd', 'sdd', 'both'], default: 'both' }, - }, - }, - async execute({ target = 'both' }, ctx) { - _req(ctx, ['pid']); - const built = []; - if (target === 'prd' || target === 'both') { - built.push(_buildSiteIndex(pathResolver.resolvePrdSite(ctx.pid))); - } - if (target === 'sdd' || target === 'both') { - built.push(_buildSiteIndex(pathResolver.resolveSddSite(ctx.pid))); - } - return { ok: true, built }; - }, -}; - -function _buildSiteIndex(siteRoot) { - if (!fs.existsSync(siteRoot)) return { skipped: true, reason: 'site not exist' }; - const entries = []; - function walk(dir, prefix) { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - if (e.name === 'assets') continue; - const rel = prefix ? `${prefix}/${e.name}` : e.name; - const full = path.join(dir, e.name); - if (e.isDirectory()) walk(full, rel); - else if (e.name.endsWith('.html')) { - const html = fs.readFileSync(full, 'utf8'); - const title = _extractTitle(html) || rel; - const text = _stripTagsKeepText(html).slice(0, 2000); - entries.push({ f: rel, title, content: text }); - } - } - } - walk(siteRoot, ''); - - const idx = { - version: '1', generated: new Date().toISOString(), - pages: entries.length, entries, - }; - const out = path.join(siteRoot, 'assets', 'search-index.json'); - fs.mkdirSync(path.dirname(out), { recursive: true }); - fs.writeFileSync(out, JSON.stringify(idx)); - return { ok: true, pages: entries.length, path: out }; -} - -function _extractTitle(html) { - const m = html.match(/([\s\S]*?)<\/title>/i); - return m ? m[1].trim() : null; -} - -function _stripTagsKeepText(s) { - return s - .replace(/<script[\s\S]*?<\/script>/gi, ' ') - .replace(/<style[\s\S]*?<\/style>/gi, ' ') - .replace(/<svg[\s\S]*?<\/svg>/gi, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -// ──────────────────────────────────────────────────────────────── -// validate_prd_site -// ──────────────────────────────────────────────────────────────── - -const REQUIRED_PRD_CHAPTERS = [ - 'index.html', - 'product/background.html', - 'product/personas.html', - 'product/scope.html', - 'feature/feature-tree.html', - 'flow/user-flows.html', -]; - -const EIGHT_TYPES = { - Tables: $ => $('table').length > 0, - Code: $ => $('pre').length > 0, - Illustrations: $ => $('svg').length > 0, - Interaction: $ => $('.tabs, details, nav').length > 0, - Spatial: $ => $('pre').filter((_,el) => $(el).text().includes('├──') || $(el).text().includes('└──')).length > 0, - Design: $ => $('.card').length > 0 || $('[class*=card]').length > 0, - Images: $ => $('svg, img').length > 0, - Workflows: $ => $('svg').filter((_,el) => /marker-end|stroke-dasharray|arrow/.test($(el).html() || '')).length > 0, -}; - -const validate_prd_site = { - name: 'validate_prd_site', - description: '整站校验 PRD: 必备章节存在 · cheerio 结构 · 8 类信息 ≥4。', - inputSchema: { type: 'object', properties: {} }, - async execute(_input, ctx) { - _req(ctx, ['pid']); - const root = pathResolver.resolvePrdSite(ctx.pid); - const issues = []; - if (!fs.existsSync(root)) { - return { ok: false, issues: ['prd/ site not initialized'] }; - } - - for (const f of REQUIRED_PRD_CHAPTERS) { - const p = path.join(root, f); - if (!fs.existsSync(p)) issues.push(`missing chapter: ${f}`); - } - - // 扫所有 chapter - const allFiles = []; - function walk(dir, prefix) { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - if (e.name === 'assets') continue; - const rel = prefix ? `${prefix}/${e.name}` : e.name; - const full = path.join(dir, e.name); - if (e.isDirectory()) walk(full, rel); - else if (e.name.endsWith('.html')) allFiles.push({ rel, full }); - } - } - walk(root, ''); - - for (const { rel, full } of allFiles) { - const html = fs.readFileSync(full, 'utf8'); - const v = docStore.validateHTML(html, 'prd'); - if (!v.ok) { - for (const e of v.errors) issues.push(`${rel}: ${e}`); - } - // 8 类信息检查 - try { - const $ = cheerio.load(html); - const covered = Object.entries(EIGHT_TYPES).filter(([_, fn]) => fn($)); - if (covered.length < 4) { - issues.push(`${rel}: only ${covered.length}/8 info types (need ≥4)`); - } - } catch { /* ignore */ } - } - - return { ok: issues.length === 0, issues }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// validate_sdd_consistency -// ──────────────────────────────────────────────────────────────── - -const validate_sdd_consistency = { - name: 'validate_sdd_consistency', - description: 'SDD ↔ units.json ↔ integration/spec.json 三方一致性: 每 Unit 在 sdd/mvcp/ 出现 · involves ⊆ units · contract_hash 一致 · DAG 无环。', - inputSchema: { type: 'object', properties: {} }, - async execute(_input, ctx) { - _req(ctx, ['pid']); - const issues = []; - - const unitsJson = docStore.readUnits(ctx.pid); - if (!unitsJson) return { ok: false, issues: ['units.json missing'] }; - - const v = docStore.validateUnitsSchema(unitsJson); - if (!v.ok) issues.push(...v.errors); - - // Unit 在 sdd/mvcp/ 至少出现 1 次 - const sddRoot = pathResolver.resolveSddSite(ctx.pid); - const mvcpDir = path.join(sddRoot, 'mvcp'); - let sddText = ''; - if (fs.existsSync(mvcpDir)) { - for (const f of fs.readdirSync(mvcpDir)) { - if (f.endsWith('.html')) sddText += fs.readFileSync(path.join(mvcpDir, f), 'utf8'); - } - } - for (const u of unitsJson.units) { - if (!sddText.includes(`data-unit-id="${u.id}"`) && - !sddText.includes(`data-def-name="${u.id}"`)) { - issues.push(`unit ${u.id} not referenced in sdd/mvcp/`); - } - } - - // integration/spec.json involves 检查 - const spec = docStore.readIntegrationSpec(ctx.pid); - if (spec && Array.isArray(spec.cases)) { - const ids = new Set(unitsJson.units.map(u => u.id)); - for (const c of spec.cases) { - for (const u of (c.involves || [])) { - if (!ids.has(u)) issues.push(`integration case ${c.id} involves unknown unit: ${u}`); - } - } - } - - return { ok: issues.length === 0, issues }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// hash_contract -// ──────────────────────────────────────────────────────────────── - -const hash_contract = { - name: 'hash_contract', - description: 'canonical JSON + sha256 截 16 hex。算 Unit contract 的 hash。', - inputSchema: { - type: 'object', - required: ['contract'], - properties: { - contract: { type: 'object' }, - }, - }, - async execute({ contract }, _ctx) { - return { ok: true, hash: docStore.hashContract(contract) }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -function _req(ctx, keys) { - for (const k of keys) { - if (!ctx || ctx[k] == null) { - const { ToolMissingDependencyError } = require('./_errors'); - throw new ToolMissingDependencyError(`ctx.${k}`); - } - } -} - -module.exports = { - bootstrap_prd_site, - bootstrap_sdd_site, - write_chapter, - write_mvcp_page, - archive_doc, - diff_html, - diff_units, - echo_diff, - update_search_index, - validate_prd_site, - validate_sdd_consistency, - hash_contract, -}; diff --git a/server/src/tools/devloop/index.js b/server/src/tools/devloop/index.js deleted file mode 100644 index 6ca3444..0000000 --- a/server/src/tools/devloop/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * DevLoop tool layer · 总入口 - * - * 对外暴露: - * - tools (Map<name, ToolDef>): 全部 devloop 工具(35 个) - * - listToolNames / getDevLoopTools / dispatch: registry 接口 - * - bashHooks: 注入到 LemonBoss Bash 工具的 preExecute 数组 - * - * 用法: - * const { tools, bashHooks } = require('server/src/tools/devloop'); - * bashTool.preExecute.push(...bashHooks.preExecute); - */ - -const registry = require('./tool-registry'); -const { bashHooks, jsonGuard } = require('./bash-json-guard'); - -module.exports = { - // tool registry - tools: registry.loadAllTools(), - listToolNames: registry.listToolNames, - getDevLoopTools: registry.getDevLoopTools, - dispatch: registry.dispatch, - - // bash guard - bashHooks, - jsonGuard, -}; diff --git a/server/src/tools/devloop/integrator-tools.js b/server/src/tools/devloop/integrator-tools.js deleted file mode 100644 index 3b9692e..0000000 --- a/server/src/tools/devloop/integrator-tools.js +++ /dev/null @@ -1,291 +0,0 @@ -/** - * DevLoop · integrator-tools (6 个) - * port_picker 分配空闲 localhost 端口(避开 reserved) - * spawn_units 批量启动 be Unit 进程,每个独立端口 - * health_wait poll /api/health 直到 200 或超时 - * run_case 执行 integration spec 单个 case(含 {{stepN.x}} 占位符) - * parse_placeholder 解析 {{stepN.field}} 形式占位符 - * collect_stderr 从子进程取最近 stderr 行 - * - * 规格: docs/prd/newfunction/_spec/agents-integrator.md · §7 - */ - -const net = require('net'); -const fs = require('fs'); -const path = require('path'); -const { spawn } = require('child_process'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); - -const RESERVED_PORTS = new Set([3000, 3010, 5173, 5432, 6379, 8080, 8443]); - -// ──────────────────── port_picker ──────────────────── - -function isPortFree(port) { - return new Promise(resolve => { - const srv = net.createServer(); - srv.once('error', () => resolve(false)); - srv.once('listening', () => srv.close(() => resolve(true))); - srv.listen(port, '127.0.0.1'); - }); -} - -const port_picker = { - name: 'port_picker', - description: '分配 N 个空闲 localhost 端口(18000-19000),避开系统保留', - inputSchema: { - type: 'object', - required: ['count'], - properties: { - count: { type: 'integer', minimum: 1, maximum: 20 }, - }, - }, - async execute({ count }, _ctx) { - const picked = []; - let attempts = 0; - while (picked.length < count && attempts < 100) { - attempts++; - const p = 18000 + Math.floor(Math.random() * 1000); - if (RESERVED_PORTS.has(p) || picked.includes(p)) continue; - if (await isPortFree(p)) picked.push(p); - } - if (picked.length < count) { - throw new Error(`no free port after ${attempts} attempts`); - } - return { ok: true, ports: picked }; - }, -}; - -// ──────────────────── spawn_units ──────────────────── - -const _activeHandles = new Map(); // pid → ProcessHandle[] - -const spawn_units = { - name: 'spawn_units', - description: '批量启动 be Unit 真实进程(每个独立端口 + env 注入)', - inputSchema: { - type: 'object', - required: ['be_units', 'ports', 'log_path'], - properties: { - be_units: { type: 'array', items: { type: 'string' } }, - ports: { type: 'object' }, - log_path: { type: 'string' }, - }, - }, - async execute({ be_units, ports, log_path }, ctx) { - _req(ctx, ['pid']); - fs.mkdirSync(path.dirname(log_path), { recursive: true }); - const logStream = fs.createWriteStream(log_path, { flags: 'a' }); - const handles = []; - - for (const unitId of be_units) { - const port = ports[unitId]; - if (!port) throw new ToolValidationError('ports', `no port for ${unitId}`); - const cwd = pathResolver.resolveUnitDir(ctx.pid, unitId); - const entry = path.join(cwd, 'code', 'server.js'); - if (!fs.existsSync(entry)) { - throw new ToolValidationError('be_units', `${unitId} has no code/server.js`); - } - const child = spawn('node', [entry], { - cwd, - env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', UNIT_ID: unitId }, - detached: false, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const prefix = `[${unitId}:${port}] `; - child.stdout.on('data', c => logStream.write(prefix + c)); - child.stderr.on('data', c => logStream.write(prefix + c)); - handles.push({ unitId, port, pid: child.pid, proc: child }); - } - _activeHandles.set(ctx.pid, handles); - return { ok: true, handles: handles.map(h => ({ unitId: h.unitId, port: h.port, pid: h.pid })) }; - }, -}; - -async function killAll(pid, graceMs = 5000) { - const handles = _activeHandles.get(pid) || []; - for (const h of handles) { - try { h.proc.kill('SIGTERM'); } catch {} - } - await new Promise(r => setTimeout(r, graceMs)); - for (const h of handles) { - try { if (!h.proc.killed) h.proc.kill('SIGKILL'); } catch {} - } - _activeHandles.delete(pid); -} - -// ──────────────────── health_wait ──────────────────── - -const health_wait = { - name: 'health_wait', - description: 'poll /api/health (或指定 url) 直到 200 或超时', - inputSchema: { - type: 'object', - required: ['url'], - properties: { - url: { type: 'string' }, - timeout_ms: { type: 'integer', default: 30_000 }, - interval_ms: { type: 'integer', default: 500 }, - }, - }, - async execute({ url, timeout_ms = 30_000, interval_ms = 500 }, _ctx) { - const deadline = Date.now() + timeout_ms; - while (Date.now() < deadline) { - try { - const r = await fetch(url); - if (r.ok) return { ok: true, status: r.status, waited_ms: timeout_ms - (deadline - Date.now()) }; - } catch { /* not ready yet */ } - await new Promise(r => setTimeout(r, interval_ms)); - } - return { ok: false, timeout: true }; - }, -}; - -// ──────────────────── parse_placeholder ──────────────────── - -function parsePlaceholders(obj, prev) { - const s = JSON.stringify(obj); - const resolved = s.replace(/\{\{step(\d+)\.([\w.]+)\}\}/g, (_match, n, p) => { - const r = prev[parseInt(n, 10)]; - if (!r) throw new Error(`placeholder step${n} has no result yet`); - const parts = p.split('.'); - let v = r.body; - for (const k of parts) v = v?.[k]; - return v != null ? String(v) : ''; - }); - return JSON.parse(resolved); -} - -const parse_placeholder = { - name: 'parse_placeholder', - description: '解析 {{stepN.field}} 占位符,从前面 step 的 response.body 取值替换', - inputSchema: { - type: 'object', - required: ['template', 'previous_steps'], - properties: { - template: {}, - previous_steps: { type: 'array' }, - }, - }, - async execute({ template, previous_steps }, _ctx) { - return { ok: true, resolved: parsePlaceholders(template, previous_steps) }; - }, -}; - -// ──────────────────── run_case ──────────────────── - -const run_case = { - name: 'run_case', - description: '执行 integration spec 单个 case (含 placeholder 解析 + step expect 校验)', - inputSchema: { - type: 'object', - required: ['case_spec', 'ports'], - properties: { - case_spec: { type: 'object' }, - ports: { type: 'object' }, - timeout_ms: { type: 'integer', default: 30_000 }, - }, - }, - async execute({ case_spec, ports, timeout_ms = 30_000 }, _ctx) { - const deadline = Date.now() + timeout_ms; - const stepResults = []; - const httpTrace = []; - - for (let i = 0; i < case_spec.steps.length; i++) { - if (Date.now() > deadline) { - return { ok: false, failed_step: i, reason: 'timeout', httpTrace }; - } - const step = case_spec.steps[i]; - let body, headers; - try { - body = parsePlaceholders(step.body || {}, stepResults); - headers = parsePlaceholders(step.headers || {}, stepResults); - } catch (e) { - return { ok: false, failed_step: i, reason: e.message, httpTrace }; - } - - const [method, p] = step.call.split(' '); - const port = ports[step.unit]; - if (!port) { - return { ok: false, failed_step: i, reason: `no port for unit ${step.unit}`, httpTrace }; - } - const url = `http://127.0.0.1:${port}` + p; - const startedAt = Date.now(); - let r, json = {}; - try { - r = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json', ...headers }, - body: method === 'GET' ? undefined : JSON.stringify(body), - }); - try { json = await r.json(); } catch { /* maybe empty body */ } - } catch (e) { - return { ok: false, failed_step: i, got: { status: 0, error: e.message }, httpTrace }; - } - - httpTrace.push({ - ts: startedAt, step: i, unit: step.unit, method, url, - status: r.status, body: json, duration_ms: Date.now() - startedAt, - }); - stepResults.push({ status: r.status, body: json }); - - if (!_checkExpect(step.expect, { status: r.status, body: json })) { - return { ok: false, failed_step: i, got: { status: r.status, body: json }, httpTrace }; - } - } - return { ok: true, httpTrace, steps_ran: case_spec.steps.length }; - }, -}; - -function _checkExpect(expect, got) { - if (!expect) return true; - if (expect.status && expect.status !== got.status) return false; - // expect.json 简化检查:仅检查 key 存在性 + 类型;不做 JSONPath - if (expect.json && typeof expect.json === 'object') { - for (const [k, v] of Object.entries(expect.json)) { - if (got.body?.[k] === undefined) return false; - if (typeof v === 'string' && v.startsWith('$.')) continue; // JSONPath 简化:存在即可 - } - } - return true; -} - -// ──────────────────── collect_stderr ──────────────────── - -const collect_stderr = { - name: 'collect_stderr', - description: '从 process.log 取最近 N 行 stderr 节选(供 BugTriager 用)', - inputSchema: { - type: 'object', - required: ['log_path'], - properties: { - log_path: { type: 'string' }, - lines: { type: 'integer', default: 20 }, - }, - }, - async execute({ log_path, lines = 20 }, _ctx) { - if (!fs.existsSync(log_path)) return { ok: false, reason: 'log not exist', excerpt: '' }; - const raw = fs.readFileSync(log_path, 'utf8'); - const all = raw.split('\n'); - const excerpt = all.slice(-lines).join('\n'); - return { ok: true, excerpt }; - }, -}; - -function _req(ctx, keys) { - for (const k of keys) { - if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); - } -} - -module.exports = { - port_picker, - spawn_units, - health_wait, - run_case, - parse_placeholder, - collect_stderr, - // 内部导出(kill / 测试用) - killAll, -}; diff --git a/server/src/tools/devloop/persistence-tools.js b/server/src/tools/devloop/persistence-tools.js deleted file mode 100644 index a3ba281..0000000 --- a/server/src/tools/devloop/persistence-tools.js +++ /dev/null @@ -1,289 +0,0 @@ -/** - * DevLoop · persistence-tools - * - * 持久化 5 件套 (M-10/11/12/13 接入): - * - write_todolist 初始化 agent-state/{agent}-todo.json - * - update_todo_item 更新单条 item + 同步 progress_pct - * - update_runtime_state 修 agent-state/{agent}.json (current_step/total_steps) - * - timeline_append append timeline.ndjson - * - record_memory 写 memory/devloop/{agent}/ 下 .md - * - * 所有 Agent 都能用(toolPolicy 里写)。 - * 每个工具签名: { name, description, inputSchema, execute(input, ctx) } - * - * 规格: docs/prd/newfunction/_spec/sdd-control.md · C-15 - * docs/prd/newfunction/_spec/sdd-model.md · M-10/11/12/13 - * docs/prd/newfunction/_spec/arch-tool-registry.md · §③ - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const timelineLogger = require('../../../services/devloop/timeline-logger'); -const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); - -// ──────────────────────────────────────────────────────────────── -// write_todolist -// ──────────────────────────────────────────────────────────────── - -const write_todolist = { - name: 'write_todolist', - description: '初始化 agent-state/{agent}-todo.json,写入完整 todolist。Agent 开工第一件事。', - inputSchema: { - type: 'object', - required: ['items'], - properties: { - items: { - type: 'array', - minItems: 1, - items: { - type: 'object', - required: ['id', 'description'], - properties: { - id: { type: 'string', pattern: '^step-\\d{3}$' }, - description: { type: 'string' }, - depends_on: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - }, - async execute({ items }, ctx) { - _requireCtx(ctx, ['pid', 'agent', 'call_id']); - const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); - const todolist = { - agent: ctx.agent, - call_id: ctx.call_id, - created_at: new Date().toISOString(), - items: items.map((it, i) => ({ - id: it.id, - description: it.description, - depends_on: it.depends_on || [], - status: i === 0 ? 'doing' : 'todo', - ...(i === 0 ? { started_at: new Date().toISOString() } : {}), - })), - current: items[0]?.id || null, - progress_pct: 0, - }; - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); - - if (ctx.sseBroker) { - ctx.sseBroker.emit(ctx.pid, 'agent.todolist', { - agent: ctx.agent, call_id: ctx.call_id, items: todolist.items, - }); - } - return { ok: true, total: items.length, path: p }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// update_todo_item -// ──────────────────────────────────────────────────────────────── - -const update_todo_item = { - name: 'update_todo_item', - description: '更新 todolist 中单条 item 的 status;自动重算 progress_pct + 发 SSE。', - inputSchema: { - type: 'object', - required: ['id', 'status'], - properties: { - id: { type: 'string', pattern: '^step-\\d{3}$' }, - status: { - type: 'string', - enum: ['todo', 'doing', 'done', 'failed'], - }, - output_ref: { type: 'string' }, - error: { type: 'string' }, - }, - }, - async execute({ id, status, output_ref, error }, ctx) { - _requireCtx(ctx, ['pid', 'agent']); - const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); - if (!fs.existsSync(p)) { - throw new ToolValidationError('todolist', `not found at ${p} (call write_todolist first)`); - } - const todolist = JSON.parse(fs.readFileSync(p, 'utf8')); - const item = todolist.items.find(it => it.id === id); - if (!item) { - throw new ToolValidationError('id', `unknown step: ${id}`); - } - const from = item.status; - item.status = status; - if (status === 'doing' && !item.started_at) { - item.started_at = new Date().toISOString(); - } - if (status === 'done' || status === 'failed') { - item.finished_at = new Date().toISOString(); - if (output_ref) item.output_ref = output_ref; - if (error) item.error = error; - } - - // 重算 progress_pct - const done = todolist.items.filter(it => it.status === 'done').length; - todolist.progress_pct = Math.round((done / todolist.items.length) * 100); - - // 找下一个 todo 设为 doing - if (status === 'done') { - const next = todolist.items.find(it => it.status === 'todo'); - if (next) { - next.status = 'doing'; - next.started_at = new Date().toISOString(); - todolist.current = next.id; - } else { - todolist.current = null; - } - } - - fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); - - if (ctx.sseBroker) { - ctx.sseBroker.emit(ctx.pid, 'agent.step', { - agent: ctx.agent, call_id: ctx.call_id || todolist.call_id, - item_id: id, from, to: status, - progress_pct: todolist.progress_pct, - current_desc: todolist.items.find(it => it.id === todolist.current)?.description, - }); - } - return { ok: true, progress_pct: todolist.progress_pct }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// update_runtime_state -// ──────────────────────────────────────────────────────────────── - -const update_runtime_state = { - name: 'update_runtime_state', - description: '更新 M-10 AgentRuntimeState 的 current_step / total_steps(不改 status)。', - inputSchema: { - type: 'object', - required: ['current_step', 'total_steps'], - properties: { - current_step: { type: 'integer', minimum: 0 }, - total_steps: { type: 'integer', minimum: 1 }, - }, - }, - async execute({ current_step, total_steps }, ctx) { - _requireCtx(ctx, ['pid', 'agent']); - const p = pathResolver.resolveAgentState(ctx.pid, ctx.agent); - if (!fs.existsSync(p)) { - throw new ToolValidationError('state', `runtime state not found at ${p}`); - } - const state = JSON.parse(fs.readFileSync(p, 'utf8')); - state.current_step = current_step; - state.total_steps = total_steps; - state.updated_at = new Date().toISOString(); - fs.writeFileSync(p, JSON.stringify(state, null, 2)); - return { ok: true, current_step, total_steps }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// timeline_append -// ──────────────────────────────────────────────────────────────── - -const timeline_append = { - name: 'timeline_append', - description: 'Append 一条事件到 timeline.ndjson (M-13)。', - inputSchema: { - type: 'object', - required: ['kind', 'summary'], - properties: { - kind: { type: 'string' }, - ref: { type: 'string' }, - summary: { type: 'string', maxLength: 280 }, - payload: { type: 'object' }, - }, - }, - async execute({ kind, ref, summary, payload }, ctx) { - _requireCtx(ctx, ['pid', 'agent']); - const ev = timelineLogger.append(ctx.pid, { - source: ctx.agent, - kind, ref, summary, payload, - }); - return { ok: true, ts: ev.ts }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// record_memory -// ──────────────────────────────────────────────────────────────── - -const record_memory = { - name: 'record_memory', - description: '在 memory/devloop/{agent}/ 下写一条 .md (M-11)。同 topic 是 upsert。', - inputSchema: { - type: 'object', - required: ['topic', 'content', 'relevance'], - properties: { - topic: { type: 'string', minLength: 2 }, - content: { type: 'string', minLength: 10 }, - relevance: { type: 'number', minimum: 0, maximum: 1 }, - }, - }, - async execute({ topic, content, relevance }, ctx) { - _requireCtx(ctx, ['agent', 'company_id']); - const dir = pathResolver.resolveMemoryDir(ctx.company_id, ctx.agent); - fs.mkdirSync(dir, { recursive: true }); - - const slug = topic.toLowerCase() - .replace(/[^a-z0-9一-龥]+/g, '-') - .replace(/^-+|-+$/g, ''); - const p = path.join(dir, `${slug}.md`); - - let existing = null; - if (fs.existsSync(p)) { - const raw = fs.readFileSync(p, 'utf8'); - const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); - if (m) { - const fm = {}; - for (const line of m[1].split('\n')) { - const kv = line.match(/^(\w+):\s*(.*)$/); - if (kv) fm[kv[1]] = kv[2].trim(); - } - existing = { fm, body: m[2] }; - } - } - - const projects = existing?.fm?.project_refs - ? existing.fm.project_refs.replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean) - : []; - if (ctx.pid && !projects.includes(ctx.pid)) projects.push(ctx.pid); - - const hits = existing?.fm?.hits ? (parseInt(existing.fm.hits, 10) + 1) : 1; - const frontmatter = [ - '---', - `agent: ${ctx.agent}`, - `topic: ${topic}`, - `project_refs: [${projects.join(', ')}]`, - `last_updated: ${new Date().toISOString()}`, - `relevance_score: ${relevance}`, - `hits: ${hits}`, - '---', - '', - ].join('\n'); - - fs.writeFileSync(p, frontmatter + content + '\n'); - return { ok: true, path: p, upserted: !!existing, hits }; - }, -}; - -// ──────────────────────────────────────────────────────────────── -// Helpers -// ──────────────────────────────────────────────────────────────── - -function _requireCtx(ctx, keys) { - for (const k of keys) { - if (ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); - } -} - -module.exports = { - write_todolist, - update_todo_item, - update_runtime_state, - timeline_append, - record_memory, -}; diff --git a/server/src/tools/devloop/tester-tools.js b/server/src/tools/devloop/tester-tools.js deleted file mode 100644 index 799b078..0000000 --- a/server/src/tools/devloop/tester-tools.js +++ /dev/null @@ -1,229 +0,0 @@ -/** - * DevLoop · tester-tools (5 个) - * parse_vitest vitest JSON reporter 解析 → result.json + hint 生成 - * hint_builder 从 failure stack + expected/got 启发式生成 hint - * bug_open 调 bugTracker.open + 自动 dedup - * bug_transition bug 状态转换 - * bug_list 按 filter 查 bug - * - * 规格: docs/prd/newfunction/_spec/agents-tester.md · §7 - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const bugTracker = require('../../../services/devloop/bug-tracker'); -const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); - -// ──────────────────── hint_builder ──────────────────── - -const HINT_PATTERNS = [ - { match: s => /cannot read.*?of undefined/i.test(s), - hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' }, - { match: s => /TypeError.*compare/i.test(s), - hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' }, - { match: s => /SQLITE_CONSTRAINT/i.test(s), - hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' }, - { match: s => /timeout/i.test(s), - hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' }, -]; - -function buildHint(ctx) { - const stack = ctx.stack || ''; - for (const p of HINT_PATTERNS) { - if (p.match(stack)) return p.hint(ctx); - } - // status code 启发式 - const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/); - const e = m ? parseInt(m[1], 10) : ctx.expected; - const g = m ? parseInt(m[2], 10) : ctx.got; - if (typeof e === 'number' && typeof g === 'number') { - if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底'; - if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service'; - if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果'; - return `期望 ${e} 收 ${g},检查对应分支处理`; - } - return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`; -} - -const hint_builder = { - name: 'hint_builder', - description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)', - inputSchema: { - type: 'object', - properties: { - testName: { type: 'string' }, - expected: {}, - got: {}, - stack: { type: 'string' }, - }, - }, - async execute(failure, _ctx) { - return { ok: true, hint: buildHint(failure) }; - }, -}; - -// ──────────────────── parse_vitest ──────────────────── - -const parse_vitest = { - name: 'parse_vitest', - description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures', - inputSchema: { - type: 'object', - required: ['raw_json_path'], - properties: { - raw_json_path: { type: 'string' }, - result_out_path: { type: 'string', description: '可选,result.json 写入位置' }, - }, - }, - async execute({ raw_json_path, result_out_path }, _ctx) { - if (!fs.existsSync(raw_json_path)) { - return { - ok: false, crashed: true, - crash_reason: 'vitest raw json missing — process likely crashed early', - summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, - failures: [], - }; - } - let raw; - try { - raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8')); - } catch (e) { - return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message }; - } - - const summary = { total: 0, passed: 0, failed: 0, skipped: 0 }; - const failures = []; - - const testResults = raw.testResults || raw.tests || []; - for (const file of testResults) { - const asserts = file.assertionResults || file.assertions || []; - for (const a of asserts) { - summary.total++; - if (a.status === 'passed') summary.passed++; - else if (a.status === 'failed') { - summary.failed++; - const stack = (a.failureMessages || a.errors || []).join('\n'); - const { expected, got } = _extractAssertion(stack); - failures.push({ - test: a.fullName || a.title, - expected, got, stack, - hint: buildHint({ testName: a.fullName, expected, got, stack }), - }); - } else summary.skipped++; - } - } - - const result = { - ok: summary.failed === 0 && summary.total > 0, - crashed: false, - summary, failures, - duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0, - }; - - if (result_out_path) { - fs.mkdirSync(path.dirname(result_out_path), { recursive: true }); - fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2)); - } - return result; - }, -}; - -function _extractAssertion(stack) { - const exp = stack.match(/Expected[:\s]+([^\n]+)/); - const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/); - return { - expected: exp ? exp[1].trim() : null, - got: got ? got[1].trim() : null, - }; -} - -// ──────────────────── bug_open / transition / list ──────────────────── - -const bug_open = { - name: 'bug_open', - description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id', - inputSchema: { - type: 'object', - required: ['title'], - properties: { - source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] }, - unit_id: { type: 'string' }, - run_id: { type: 'string' }, - test_name: { type: 'string' }, - category: { type: 'string' }, - severity: { type: 'string', enum: ['critical','high','medium','low'] }, - title: { type: 'string', maxLength: 120 }, - detail: { type: 'string' }, - affected_units: { type: 'array', items: { type: 'string' } }, - }, - }, - async execute(bug, ctx) { - _req(ctx, ['pid']); - // 注入 sseBroker + timelineLogger (如果 ctx 有) - if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); - if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); - const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' }); - return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) }; - }, -}; - -const bug_transition = { - name: 'bug_transition', - description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机', - inputSchema: { - type: 'object', - required: ['bug_id', 'to'], - properties: { - bug_id: { type: 'string' }, - to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] }, - note: { type: 'string' }, - fix_refs: { type: 'array', items: { type: 'string' } }, - duplicate_of: { type: 'string' }, - }, - }, - async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) { - _req(ctx, ['pid']); - if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); - if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); - try { - bugTracker.transition(ctx.pid, bug_id, { - to, by: ctx.agent || 'system', note, fix_refs, duplicate_of, - }); - return { ok: true }; - } catch (e) { - if (e.name === 'InvalidBugTransitionError') { - throw new ToolValidationError('to', e.message); - } - throw e; - } - }, -}; - -const bug_list = { - name: 'bug_list', - description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态', - inputSchema: { - type: 'object', - properties: { - status: { type: 'array', items: { type: 'string' } }, - severity: { type: 'array', items: { type: 'string' } }, - unit_id: { type: 'string' }, - since: { type: 'string' }, - }, - }, - async execute(filter, ctx) { - _req(ctx, ['pid']); - const bugs = bugTracker.list(ctx.pid, filter || {}); - return { ok: true, bugs, total: bugs.length }; - }, -}; - -function _req(ctx, keys) { - for (const k of keys) { - if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); - } -} - -module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list }; diff --git a/server/src/tools/devloop/tool-registry.js b/server/src/tools/devloop/tool-registry.js deleted file mode 100644 index 8231b95..0000000 --- a/server/src/tools/devloop/tool-registry.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * DevLoop Tool Registry — loadAllTools() - * - * 把 server/src/tools/devloop/ 下所有 *-tools.js 加载并按工具 name 聚合到 Map。 - * tool-manager / ClaudeCliProtocol 启动 Agent 时通过 toolPolicy 取这里的 ToolDef。 - * - * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §④ - */ - -const path = require('path'); -const fs = require('fs'); - -// 工具模块清单(手动 require,避免动态 require 出错) -const TOOL_MODULES = [ - './persistence-tools', - './doc-tools', - './coder-tools', - './tester-tools', - './integrator-tools', - './triager-tools', -]; - -let cached = null; - -function loadAllTools() { - if (cached) return cached; - - const registry = new Map(); - - for (const mod of TOOL_MODULES) { - const fullPath = path.join(__dirname, mod + '.js'); - if (!fs.existsSync(fullPath)) continue; - const tools = require(mod); - for (const key of Object.keys(tools)) { - const tool = tools[key]; - if (tool && typeof tool === 'object' && tool.name && typeof tool.execute === 'function') { - if (registry.has(tool.name)) { - throw new Error(`Duplicate tool name: ${tool.name} (in ${mod})`); - } - registry.set(tool.name, tool); - } - } - } - - cached = registry; - return registry; -} - -/** - * 列工具名(用于 _registry.json toolPolicy 校验) - */ -function listToolNames() { - return Array.from(loadAllTools().keys()).sort(); -} - -/** - * 按 toolPolicy 取一组工具(含 LemonBoss 既有工具是允许的,本函数只过滤 devloop 的) - */ -function getDevLoopTools(toolPolicy) { - const all = loadAllTools(); - const out = []; - for (const name of toolPolicy) { - if (all.has(name)) out.push(all.get(name)); - } - return out; -} - -/** - * 调度:根据 name 找工具,执行(中央调用入口,外部 ACP 调用方使用)。 - * - 校验工具存在 - * - 校验 input 符合 inputSchema(简化版:只校验 required + type) - * - 调 tool.execute(input, ctx) - * - 错误标准化 - */ -async function dispatch(name, input, ctx) { - const all = loadAllTools(); - const tool = all.get(name); - if (!tool) { - throw new Error(`Unknown tool: ${name}`); - } - _validateInput(input, tool.inputSchema, name); - return tool.execute(input, ctx); -} - -function _validateInput(input, schema, name) { - if (!schema || typeof schema !== 'object') return; - if (schema.required && Array.isArray(schema.required)) { - for (const k of schema.required) { - if (input == null || input[k] === undefined) { - const e = new Error(`Tool ${name}: missing required field "${k}"`); - e.code = 'TOOL_INPUT_INVALID'; - throw e; - } - } - } -} - -function _resetForTesting() { - cached = null; -} - -module.exports = { - loadAllTools, - listToolNames, - getDevLoopTools, - dispatch, - _resetForTesting, - TOOL_MODULES, -}; diff --git a/server/src/tools/devloop/triager-tools.js b/server/src/tools/devloop/triager-tools.js deleted file mode 100644 index 2682a53..0000000 --- a/server/src/tools/devloop/triager-tools.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * DevLoop · triager-tools (4 个) - * extract_evidence 验证 evidence 字符串在原文 grep 命中 - * category_classifier 启发式 4 类粗判(contract_mismatch / impl_bug / spec_ambiguous / infra_flake) - * diff_contract_vs_impl 对比 contract.json 声明 vs code 实际行为(轻量,看 endpoint 存在性) - * find_similar_bug 在 bugs.ndjson 找历史相似 bug - * - * 规格: docs/prd/newfunction/_spec/agents-triager.md · §7 - */ - -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../../services/devloop/path-resolver'); -const bugTracker = require('../../../services/devloop/bug-tracker'); -const docStore = require('../../../services/devloop/doc-store'); -const { ToolMissingDependencyError } = require('./_errors'); - -// ──────────────────── extract_evidence ──────────────────── - -function verifyOneEvidence(projectRoot, evidence) { - const sep = evidence.indexOf(': '); - if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' }; - const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, '').trim(); - const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, '').trim(); - const norm = s => s.replace(/\s+/g, ' ').toLowerCase(); - - // 支持 "path:lineno" 形式 - let pathOnly = relPath; - let line = null; - const lineMatch = relPath.match(/^(.+):(\d+)$/); - if (lineMatch) { - pathOnly = lineMatch[1]; - line = parseInt(lineMatch[2], 10); - } - const abs = path.join(projectRoot, pathOnly); - if (!fs.existsSync(abs)) { - return { ok: false, reason: 'file not found: ' + pathOnly }; - } - const content = fs.readFileSync(abs, 'utf8'); - if (line) { - const lines = content.split('\n'); - const lineContent = lines[line - 1] || ''; - if (norm(lineContent).includes(norm(quote))) { - return { ok: true, hit_file: pathOnly, line }; - } - } - if (norm(content).includes(norm(quote))) { - return { ok: true, hit_file: pathOnly }; - } - return { ok: false, reason: 'quote not found in file', quote, file: pathOnly }; -} - -const extract_evidence = { - name: 'extract_evidence', - description: '验证 evidence 列表每条能在原文 grep 命中(防 Triager 编造证据)', - inputSchema: { - type: 'object', - required: ['evidence'], - properties: { - evidence: { type: 'array', items: { type: 'string' } }, - }, - }, - async execute({ evidence }, ctx) { - _req(ctx, ['pid']); - const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); - const results = evidence.map(e => ({ evidence: e, ...verifyOneEvidence(projectRoot, e) })); - return { - ok: results.every(r => r.ok), - results, - failed: results.filter(r => !r.ok), - }; - }, -}; - -// ──────────────────── category_classifier ──────────────────── - -const category_classifier = { - name: 'category_classifier', - description: '启发式粗判:contract_mismatch / impl_bug / spec_ambiguous / infra_flake', - inputSchema: { - type: 'object', - required: ['result_json', 'process_log'], - properties: { - result_json: { type: 'object' }, - process_log: { type: 'string' }, - sdd_text: { type: 'string' }, - }, - }, - async execute({ result_json, process_log, sdd_text = '' }, _ctx) { - const hints = []; - const score = { contract_mismatch: 0, impl_bug: 0, spec_ambiguous: 0, infra_flake: 0 }; - - // infra_flake 信号 - const infraSignals = [ - /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/, - /Cannot find module/, /port already in use/i, - /health check timeout/i, - ]; - if (infraSignals.some(re => re.test(process_log || ''))) { - score.infra_flake += 3; - hints.push('infra signal in process.log'); - } - - // spec_ambiguous 信号 - const failTxt = JSON.stringify(result_json || {}); - const failTokens = [...new Set(failTxt.toLowerCase().match(/[a-z]{4,}/g) || [])] - .filter(t => !['function','expected','received','stack','message','error'].includes(t)); - const sddLc = sdd_text.toLowerCase(); - const tokensNotInSdd = failTokens.filter(t => sddLc && !sddLc.includes(t)); - if (failTokens.length > 0 && tokensNotInSdd.length >= Math.max(2, failTokens.length / 2)) { - score.spec_ambiguous += 2; - hints.push('key tokens not found in SDD: ' + tokensNotInSdd.slice(0, 5).join(',')); - } - - // impl_bug 信号:5xx got vs 4xx expected - const fail0 = result_json?.failures?.[0]; - if (fail0?.got?.status >= 500 && fail0?.expected?.status >= 400 && fail0?.expected?.status < 500) { - score.impl_bug += 2; - hints.push('5xx got vs 4xx expected → impl_bug'); - } - - // contract_mismatch 兜底(默认) - if (Object.values(score).every(v => v === 0)) { - score.contract_mismatch += 1; - hints.push('no strong signal → default contract_mismatch'); - } - - const sorted = Object.entries(score).sort(([,a],[,b]) => b - a); - const [topCat, topScore] = sorted[0]; - let confidence; - if (topScore >= 3) confidence = 0.85; - else if (topScore === 2) confidence = 0.65; - else if (topScore === 1) confidence = 0.45; - else confidence = 0.30; - - return { ok: true, category: topCat, confidence, hints, score }; - }, -}; - -// ──────────────────── diff_contract_vs_impl ──────────────────── - -const diff_contract_vs_impl = { - name: 'diff_contract_vs_impl', - description: '轻量对比:contract.json 声明的 endpoint 是否在 code/ 文本中出现(只看签名,不验语义)', - inputSchema: { - type: 'object', - required: ['unit_id'], - properties: { unit_id: { type: 'string' } }, - }, - async execute({ unit_id }, ctx) { - _req(ctx, ['pid']); - const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); - const contractPath = path.join(unitDir, 'contract.json'); - if (!fs.existsSync(contractPath)) { - return { ok: false, reason: 'contract.json missing' }; - } - const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); - const codeDir = path.join(unitDir, 'code'); - if (!fs.existsSync(codeDir)) { - return { ok: false, reason: 'code/ missing' }; - } - - // 把 code/ 全部文本拼起来 - let codeBlob = ''; - function walk(dir) { - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, e.name); - if (e.isDirectory()) walk(full); - else if (/\.(js|ts|vue|jsx|tsx|mjs|cjs)$/.test(e.name)) { - codeBlob += fs.readFileSync(full, 'utf8') + '\n'; - } - } - } - walk(codeDir); - - const divergences = []; - const endpoints = contract.exports?.http || []; - for (const ep of endpoints) { - if (!codeBlob.includes(ep.path)) { - divergences.push(`declared ${ep.method} ${ep.path} not found in code`); - } - } - return { matches: divergences.length === 0, divergences }; - }, -}; - -// ──────────────────── find_similar_bug ──────────────────── - -const find_similar_bug = { - name: 'find_similar_bug', - description: '在 bugs.ndjson 找历史相似 bug(同 unit + 类似 test_name)', - inputSchema: { - type: 'object', - required: ['signature'], - properties: { - signature: { - type: 'object', - properties: { - unit_id: { type: 'string' }, - test_name: { type: 'string' }, - status: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - async execute({ signature }, ctx) { - _req(ctx, ['pid']); - const bugs = bugTracker.list(ctx.pid, { - unit_id: signature.unit_id, - status: signature.status, - }); - const matches = bugs.filter(b => { - if (!signature.test_name) return true; - return b.test_name === signature.test_name || - (b.test_name && b.test_name.includes(signature.test_name)); - }); - return { ok: true, matches, total: matches.length }; - }, -}; - -function _req(ctx, keys) { - for (const k of keys) { - if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); - } -} - -module.exports = { - extract_evidence, - category_classifier, - diff_contract_vs_impl, - find_similar_bug, -}; diff --git a/server/tests/devloop/bug-tracker.test.js b/server/tests/devloop/bug-tracker.test.js deleted file mode 100644 index 078ba90..0000000 --- a/server/tests/devloop/bug-tracker.test.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * bugTracker 单测 - */ -const fs = require('fs'); - -const pathResolver = require('../../services/devloop/path-resolver'); -const bugTracker = require('../../services/devloop/bug-tracker'); - -describe('bugTracker', () => { - let pid, root; - - beforeEach(() => { - pathResolver._resetForTesting(); - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - pid = r.pid; - root = r.root_path; - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - describe('open + dedup', () => { - it('open new bug returns id', () => { - const b = bugTracker.open(pid, { - source: 'unit_test', unit_id: 'auth-be', - test_name: 'login.invalid-pwd', severity: 'high', - title: 'expected 401 got 500', - }); - expect(b.id).toMatch(/^bug-/); - expect(b.status).toBe('open'); - }); - - it('same unit + test → dedup returns existing id', () => { - const b1 = bugTracker.open(pid, { - unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T1', - }); - const b2 = bugTracker.open(pid, { - unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T2', - }); - expect(b2.id).toBe(b1.id); - }); - }); - - describe('transition', () => { - it('open → fixing → fixed → verified', () => { - const b = bugTracker.open(pid, { - unit_id: 'auth-be', test_name: 'x', title: 'T', - }); - bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); - expect(bugTracker.get(pid, b.id).status).toBe('fixing'); - bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); - expect(bugTracker.get(pid, b.id).status).toBe('fixed'); - bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); - expect(bugTracker.get(pid, b.id).status).toBe('verified'); - }); - - it('open → verified (skip) throws', () => { - const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); - expect(() => bugTracker.transition(pid, b.id, { to: 'verified', by: 'system' })) - .toThrow(bugTracker.InvalidBugTransitionError); - }); - - it('verified → open (reopen) ok', () => { - const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); - bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); - bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); - bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); - bugTracker.transition(pid, b.id, { to: 'open', by: 'system' }); - expect(bugTracker.get(pid, b.id).status).toBe('open'); - }); - }); - - describe('list + stats', () => { - it('filter by status', () => { - const b1 = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T1' }); - const b2 = bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T2' }); - bugTracker.transition(pid, b2.id, { to: 'fixing', by: 'coder' }); - const open = bugTracker.list(pid, { status: ['open'] }); - const fixing = bugTracker.list(pid, { status: ['fixing'] }); - expect(open.map(b => b.id)).toContain(b1.id); - expect(fixing.map(b => b.id)).toContain(b2.id); - }); - - it('stats counts by status', () => { - bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T', severity: 'high' }); - bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T', severity: 'low' }); - const s = bugTracker.stats(pid); - expect(s.open).toBe(2); - expect(s.by_severity.high).toBe(1); - expect(s.by_severity.low).toBe(1); - }); - }); - - describe('history', () => { - it('tracks all transitions', () => { - const b = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't', title: 'T' }); - bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); - bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); - const h = bugTracker.history(pid, b.id); - // 含 open 时的 history (1) + 2 次 transition = 3 条 - expect(h.length).toBeGreaterThanOrEqual(3); - }); - }); -}); diff --git a/server/tests/devloop/doc-store.test.js b/server/tests/devloop/doc-store.test.js deleted file mode 100644 index 18c1cb1..0000000 --- a/server/tests/devloop/doc-store.test.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * docStore 单测 - */ -const docStore = require('../../services/devloop/doc-store'); - -describe('docStore', () => { - describe('validateHTML', () => { - it('rejects empty', () => { - const r = docStore.validateHTML('', 'prd'); - expect(r.ok).toBe(false); - }); - - it('rejects missing article', () => { - const r = docStore.validateHTML('<p>hi</p>', 'prd'); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('article'); - }); - - it('rejects wrong doc kind', () => { - const r = docStore.validateHTML('<article data-doc="sdd" data-rev="1"><section><h3>x</h3><p>y</p></section></article>', 'prd'); - expect(r.ok).toBe(false); - }); - - it('accepts valid PRD chapter', () => { - const html = `<article data-doc="prd" data-rev="2"> - <section data-rev="1"><h3>Title</h3><p>body</p></section> - <section data-rev="2"><h3>Other</h3><p>more</p></section> - </article>`; - const r = docStore.validateHTML(html, 'prd'); - expect(r.ok).toBe(true); - }); - - it('detects section without h3', () => { - const html = `<article data-doc="prd" data-rev="1"> - <section><p>only p</p></section> - </article>`; - const r = docStore.validateHTML(html, 'prd'); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('h3'); - }); - - it('detects non-monotonic data-rev', () => { - const html = `<article data-doc="prd" data-rev="3"> - <section data-rev="2"><h3>x</h3><p>y</p></section> - <section data-rev="1"><h3>x</h3><p>y</p></section> - </article>`; - const r = docStore.validateHTML(html, 'prd'); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('monotonic'); - }); - }); - - describe('validateUnitsSchema', () => { - const validUnit = { - id: 'auth-be', type: 'be', status: 'pending', - depends_on: [], retries: 0, retry_budget: 5, - contract_hash: 'sha256:abc', - }; - - it('accepts valid units.json', () => { - const json = { - project_id: 'p-test', prd_rev: '1', sdd_rev: '1', - units: [validUnit], l2_rounds: 0, l2_budget: 3, - }; - const r = docStore.validateUnitsSchema(json); - expect(r.ok).toBe(true); - }); - - it('rejects invalid unit id', () => { - const json = { - project_id: 'p', units: [{ ...validUnit, id: 'AuthBE' }], - }; - const r = docStore.validateUnitsSchema(json); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('naming'); - }); - - it('rejects duplicate ids', () => { - const json = { - project_id: 'p', units: [validUnit, validUnit], - }; - const r = docStore.validateUnitsSchema(json); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('duplicate'); - }); - - it('rejects unknown depends_on', () => { - const json = { - project_id: 'p', - units: [{ ...validUnit, depends_on: ['ghost-be'] }], - }; - const r = docStore.validateUnitsSchema(json); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('unknown unit'); - }); - - it('detects cycle', () => { - const a = { id: 'a-be', type: 'be', status: 'pending', depends_on: ['b-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:1' }; - const b = { id: 'b-be', type: 'be', status: 'pending', depends_on: ['a-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:2' }; - const json = { project_id: 'p', units: [a, b] }; - const r = docStore.validateUnitsSchema(json); - expect(r.ok).toBe(false); - expect(r.errors.join(' ')).toContain('cycle'); - }); - }); - - describe('hashContract', () => { - it('deterministic for same content', () => { - const c = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; - const h1 = docStore.hashContract(c); - const h2 = docStore.hashContract(c); - expect(h1).toBe(h2); - expect(h1).toMatch(/^sha256:[a-f0-9]{16}$/); - }); - - it('different content → different hash', () => { - const c1 = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; - const c2 = { unit_id: 'x', exports: { http: [{ path: '/y' }] } }; - expect(docStore.hashContract(c1)).not.toBe(docStore.hashContract(c2)); - }); - - it('canonical: key ordering does not affect hash', () => { - const c1 = { exports: { http: [{ a: 1, b: 2 }] } }; - const c2 = { exports: { http: [{ b: 2, a: 1 }] } }; - expect(docStore.hashContract(c1)).toBe(docStore.hashContract(c2)); - }); - }); -}); diff --git a/server/tests/devloop/git-vault.test.js b/server/tests/devloop/git-vault.test.js deleted file mode 100644 index 0b9a2f7..0000000 --- a/server/tests/devloop/git-vault.test.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * gitVault 单测 - * - * 需要系统装了 git。如果没装则 skip 所有用例。 - */ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -const pathResolver = require('../../services/devloop/path-resolver'); -const gitVault = require('../../services/devloop/git-vault'); -const docStore = require('../../services/devloop/doc-store'); - -let gitAvailable = false; -try { execSync('git --version', { stdio: 'ignore' }); gitAvailable = true; } catch { /* skip */ } - -(gitAvailable ? describe : describe.skip)('gitVault', () => { - let pid, root; - - beforeEach(() => { - pathResolver._resetForTesting(); - gitVault._resetMutexesForTesting(); - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - pid = r.pid; - root = r.root_path; - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('init creates .git + initial commit', async () => { - const r = await gitVault.init(pid); - expect(r.ok).toBe(true); - expect(fs.existsSync(path.join(root, '.git'))).toBe(true); - expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true); - - const logs = await gitVault.log(pid); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].summary).toMatch(/^init: project p-/); - }); - - it('init is idempotent', async () => { - await gitVault.init(pid); - const r = await gitVault.init(pid); - expect(r.already_exists).toBe(true); - }); - - it('commit with content creates new sha', async () => { - await gitVault.init(pid); - // 写一个真实的文件再 commit (PRD/HTML 校验严格,这里直接 raw) - fs.writeFileSync(path.join(root, 'README.md'), 'hello\n'); - const r = await gitVault.commit(pid, { - kind: 'chore', summary: 'add README', - author: { name: 'PM Agent', email: 'pm@devloop.local' }, - }); - expect(r.ok).toBe(true); - expect(r.skipped).toBeFalsy(); - expect(r.sha).toMatch(/^[a-f0-9]{40}$/); - - // author 被记录 - const logs = await gitVault.log(pid, { limit: 1 }); - expect(logs[0].author_name).toBe('PM Agent'); - expect(logs[0].author_email).toBe('pm@devloop.local'); - }); - - it('commit with nothing-to-commit returns skipped', async () => { - await gitVault.init(pid); - const r = await gitVault.commit(pid, { - kind: 'chore', summary: 'empty commit attempt', - }); - expect(r.ok).toBe(true); - expect(r.skipped).toBe(true); - }); - - it('tag + listTags', async () => { - await gitVault.init(pid); - await gitVault.tag(pid, 'shipped-v1'); - const tags = await gitVault.listTags(pid); - expect(tags).toContain('shipped-v1'); - }); - - it('status detects dirty tree', async () => { - await gitVault.init(pid); - let s = await gitVault.status(pid); - expect(s.dirty).toBe(false); - - fs.writeFileSync(path.join(root, 'untracked.txt'), 'x'); - s = await gitVault.status(pid); - expect(s.dirty).toBe(true); - }); - - it('resetWorkingTree clears dirty', async () => { - await gitVault.init(pid); - fs.writeFileSync(path.join(root, 'dirt.txt'), 'x'); - expect((await gitVault.status(pid)).dirty).toBe(true); - - await gitVault.resetWorkingTree(pid); - expect((await gitVault.status(pid)).dirty).toBe(false); - }); - - it('archive produces zip', async () => { - await gitVault.init(pid); - fs.writeFileSync(path.join(root, 'kept.txt'), 'x'); - await gitVault.commit(pid, { kind: 'chore', summary: 'add file' }); - const r = await gitVault.archive(pid); - expect(fs.existsSync(r.path)).toBe(true); - fs.rmSync(r.path); - }); -}); diff --git a/server/tests/devloop/loop-controller.test.js b/server/tests/devloop/loop-controller.test.js deleted file mode 100644 index 3bb4cd8..0000000 --- a/server/tests/devloop/loop-controller.test.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * loop-controller (P1) 单测 - * - callAgent (mock 模式) 能从 fixture 产出文件 + git commit - * - runDocsPhase 串行跑 PM + Architect,产物齐全 - * - getProjectState 返回 prd_rev/sdd_rev/units - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-lc-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const lc = require('../../src/core/devloop/loop-controller'); - -describe('loop-controller (P1)', () => { - let pid, cid; - const created = []; - // 公司 id 用序号而不是随机后缀:随机后缀等于「清理漏掉的那一次会换个名字永远留在 - // data/companies/ 下」(28 号 E2E 基线里就躺着 `tmp-w7-blank-1787029777338-8idwe` 这类垃圾)。 - // 序号在同一次跑里照样唯一,跨次跑复用同一个 id,afterAll 删的也就是那一个。 - let seq = 0; - - afterAll(() => { - // 清理 sandbox 项目目录 - for (const c of created) { - try { - fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); - } catch { /* ignore */ } - } - }); - - async function fresh() { - pathResolver._resetForTesting(); - cid = 'c-test-' + (++seq); - created.push(cid); - const proj = await lc.createProject({ - company_id: cid, mode: 'sandbox', idea: 'unit-test', - }); - pid = proj.pid; - return proj; - } - - describe('callAgent (mock devloop-pm)', () => { - beforeAll(async () => { await fresh(); }); - - it('produces files from fixture + commits', async () => { - const r = await lc.callAgent(pid, 'devloop-pm', 'requirement: hello'); - expect(r.ok).toBe(true); - expect(r.mock).toBe(true); - expect(r.files_written.length).toBeGreaterThanOrEqual(10); - expect(r.commit).not.toBeNull(); - expect(r.commit.sha).toBeTruthy(); - const root = pathResolver.resolveProjectRoot(pid); - expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true); - expect(fs.existsSync(path.join(root, 'agent-state', 'pm.json'))).toBe(true); - }); - - it('subsequent call still commits (timeline.ndjson always appended)', async () => { - const r = await lc.callAgent(pid, 'devloop-pm', 'second time same fixture'); - // mock 产物相同但 timeline.ndjson 会追加一行 → 总有 diff,有 commit - expect(r.commit).not.toBeNull(); - expect(r.commit.sha).toBeTruthy(); - }); - }); - - describe('runDocsPhase (PM → Architect)', () => { - beforeAll(async () => { await fresh(); }); - - let phase; - it('completes both agents', async () => { - phase = await lc.runDocsPhase(pid, '做一个 TODO 列表'); - expect(phase.ok).toBe(true); - expect(phase.pm.commit?.sha).toBeTruthy(); - expect(phase.architect.commit?.sha).toBeTruthy(); - }); - - it('produces full P1 acceptance file tree', () => { - const root = pathResolver.resolveProjectRoot(pid); - const required = [ - 'project.json', - 'prd/index.html', - 'prd/reading-guide.html', - 'prd/product/background.html', - 'prd/product/personas.html', - 'prd/product/scope.html', - 'prd/feature/feature-tree.html', - 'prd/flow/user-flows.html', - 'sdd/index.html', - 'sdd/mvcp/model.html', - 'sdd/mvcp/view.html', - 'sdd/mvcp/control.html', - 'sdd/mvcp/process.html', - 'units.json', - 'integration/spec.json', - 'agent-state/pm.json', - 'agent-state/pm-todo.json', - 'agent-state/architect.json', - 'agent-state/architect-todo.json', - 'history/prd-rev-001.html', - 'history/sdd-rev-001.html', - ]; - const missing = required.filter(f => !fs.existsSync(path.join(root, f))); - expect(missing).toEqual([]); - }); - - it('units.json has 3 units with project-level l2_rounds/l2_budget', () => { - const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); - expect(u.units).toHaveLength(3); - expect(typeof u.l2_rounds).toBe('number'); - expect(typeof u.l2_budget).toBe('number'); - for (const x of u.units) { - expect(x.status).toBe('pending'); - expect(typeof x.retry_budget).toBe('number'); - } - }); - - it('getProjectState reflects produced docs', () => { - const s = lc.getProjectState(pid); - expect(s.prd_site).toBe(true); - expect(s.sdd_site).toBe(true); - expect(s.prd_rev).toBe(1); - expect(s.sdd_rev).toBe(1); - expect(s.units).toHaveLength(3); - expect(Array.isArray(s.timeline_tail)).toBe(true); - expect(s.timeline_tail.length).toBeGreaterThan(0); - }); - }); - - describe('runDocsPhase rejects bad input', () => { - beforeAll(async () => { await fresh(); }); - - it('throws when requirement missing', async () => { - await expect(lc.runDocsPhase(pid, '')).rejects.toThrow(/requirement/); - }); - }); -}); diff --git a/server/tests/devloop/p4-l3-feedback.test.js b/server/tests/devloop/p4-l3-feedback.test.js deleted file mode 100644 index 19ac069..0000000 --- a/server/tests/devloop/p4-l3-feedback.test.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * P4 · L3 Human Feedback tests - * - * 覆盖: - * - project.status FSM: draft → running → shipped → shipped-final - * - submitFeedback bumps prd_rev + shipped → running - * - signOff requires status=shipped - * - abandon any active state → abandoned (emits close) - * - InvalidProjectStatusTransition error mapping - * - PM incremental fixture variant runs on 2nd call - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const sseBroker = require('../../services/devloop/sse-broker'); -const lc = require('../../src/core/devloop/loop-controller'); - -const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path'); - -// 公司 id 用序号而不是随机后缀:同一次跑里照样唯一,跨次跑复用同一个 id, -// 清理漏一次也不会在 data/companies/ 下越堆越多。 -let p4Seq = 0; - -async function fresh(idea = 'p4 test') { - pathResolver._resetForTesting(); - sseBroker._resetForTesting(); - const cid = 'c-p4-' + (++p4Seq); - const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea }); - return { pid: proj.pid, cid }; -} - -function cleanup(cid) { - try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } - catch { /* ignore */ } - require('../helpers/ensure-test-company').removeTestCompany(cid); -} - -describe('P4 · L3 status FSM', () => { - describe('happy-path: draft → running → shipped → shipped-final', () => { - let pid, cid; - beforeAll(async () => { ({ pid, cid } = await fresh()); }); - afterAll(() => cleanup(cid)); - - it('starts as draft', () => { - expect(lc.getProjectState(pid).status).toBe('draft'); - }); - - it('runDocsPhase → running', async () => { - await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); - expect(lc.getProjectState(pid).status).toBe('running'); - }); - - it('runProject → shipped', async () => { - const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); - expect(r.final_state).toBe('shipped'); - expect(lc.getProjectState(pid).status).toBe('shipped'); - }); - - it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => { - const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR }); - expect(r.prd_rev_after).toBe(2); - expect(r.prd_rev_before).toBe(1); - expect(r.status).toBe('running'); - }); - - it('changelog has prd_rev=2 entry', () => { - const cl = pathResolver.resolveChangelog(pid); - expect(fs.existsSync(cl)).toBe(true); - const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); - expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true); - }); - - it('runProject again → shipped', async () => { - await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); - expect(lc.getProjectState(pid).status).toBe('shipped'); - }); - - it('signOff → shipped-final', async () => { - const meta = await lc.signOff(pid, { by: 'yibo' }); - expect(meta.status).toBe('shipped-final'); - }); - - it('feedback after sign-off is rejected', async () => { - await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR })) - .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); - }); - - it('signOff after sign-off is rejected', async () => { - await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); - }); - }); - - describe('abandon path', () => { - let pid, cid; - beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); }); - afterAll(() => cleanup(cid)); - - it('abandon from draft state succeeds', async () => { - const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' }); - expect(m.status).toBe('abandoned'); - }); - - it('abandon after abandon is rejected', async () => { - await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); - }); - - it('feedback on abandoned project is rejected', async () => { - await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR })) - .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); - }); - }); - - describe('signOff requires shipped (not running)', () => { - let pid, cid; - beforeAll(async () => { - ({ pid, cid } = await fresh('not yet shipped')); - await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); - // 不跑 runProject,status 还是 running - }); - afterAll(() => cleanup(cid)); - - it('signOff from running fails', async () => { - await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' }); - }); - }); - - describe('feedback empty text rejected', () => { - let pid, cid; - beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); }); - afterAll(() => cleanup(cid)); - - it('throws on empty/missing text', async () => { - await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/); - await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/); - }); - }); -}); - -describe('P4 · routes', () => { - let app, base; - const cids = []; - - beforeAll(async () => { - const express = require('express'); - const devloopRouter = require('../../routes/devloop'); - pathResolver._resetForTesting(); - sseBroker._resetForTesting(); - const e = express(); - e.use(express.json()); - e.use('/api/devloop', devloopRouter); - await new Promise(r => { - app = e.listen(0, '127.0.0.1', () => { - base = `http://127.0.0.1:${app.address().port}`; - r(); - }); - }); - }); - - afterAll(() => { - app.close(); - for (const c of cids) cleanup(c); - }); - - it('POST /sign-off requires shipped status (400)', async () => { - const cid = 'c-route-signoff'; - cids.push(cid); - require('../helpers/ensure-test-company').ensureTestCompany(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), - }); - const j = await r.json(); - // immediately after creation/run (full flow), check signoff - const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', - }); - // After POST /projects, status is 'running' (not yet shipped via runProject) - // → signOff should 400 - expect(r2.status).toBe(400); - }); - - it('POST /feedback bumps PRD rev', async () => { - const cid = 'c-route-feedback'; - cids.push(cid); - require('../helpers/ensure-test-company').ensureTestCompany(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), - }); - const pid = (await r.json()).pid; - const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }), - }); - expect(fb.status).toBe(200); - const j = await fb.json(); - expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before); - }); - - it('POST /feedback missing text → 400', async () => { - const cid = 'c-route-fb-empty'; - cids.push(cid); - require('../helpers/ensure-test-company').ensureTestCompany(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), - }); - const pid = (await r.json()).pid; - const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', - }); - expect(fb.status).toBe(400); - }); - - it('POST /abandon → status=abandoned', async () => { - const cid = 'c-route-abandon'; - cids.push(cid); - require('../helpers/ensure-test-company').ensureTestCompany(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), - }); - const pid = (await r.json()).pid; - const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ by: 'yibo', reason: 'test' }), - }); - expect(ab.status).toBe(200); - const j = await ab.json(); - expect(j.status).toBe('abandoned'); - }); - - it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => { - for (const ep of ['sign-off', 'abandon', 'feedback']) { - const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}), - }); - expect(r.status).toBe(404); - } - }); -}); diff --git a/server/tests/devloop/path-resolver.test.js b/server/tests/devloop/path-resolver.test.js deleted file mode 100644 index 13d944f..0000000 --- a/server/tests/devloop/path-resolver.test.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * pathResolver 单测 - */ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const pathResolver = require('../../services/devloop/path-resolver'); - -// 用 tmpdir 隔离测试 -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-')); -process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp - -describe('pathResolver', () => { - beforeEach(() => { - pathResolver._resetForTesting(); - }); - - describe('isSensitivePath', () => { - it('rejects /etc', () => { - expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); - }); - it('rejects /usr/local/bin', () => { - expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true); - }); - it('rejects /etc/passwd absolute', () => { - expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); - }); - it('rejects /var/log/something', () => { - expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true); - }); - it('accepts ~/code/project', () => { - expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false); - }); - it('accepts /tmp/anything', () => { - expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false); - }); - }); - - describe('allocateProjectRoot · sandbox', () => { - it('creates root under data/companies/<cid>/projects/<pid>', () => { - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/); - expect(r.mode).toBe('sandbox'); - expect(r.root_path).toContain('data/companies/c-test/projects/'); - expect(fs.existsSync(r.root_path)).toBe(true); - // cleanup - fs.rmSync(r.root_path, { recursive: true, force: true }); - }); - }); - - describe('allocateProjectRoot · linked-new', () => { - it('rejects when target_path missing', () => { - expect(() => - pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' }) - ).toThrow(/target_path required/); - }); - - it('rejects relative target_path', () => { - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path', - }) - ).toThrow(/must be absolute/); - }); - - it('rejects sensitive path', () => { - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo', - }) - ).toThrow(/sensitive_path/); - }); - - it('rejects when parent does not exist', () => { - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-new', - target_path: '/tmp/devloop-test-nonexistent-parent/proj', - }) - ).toThrow(/parent dir does not exist/); - }); - - it('rejects when target_path is non-empty dir', () => { - const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); - fs.writeFileSync(path.join(target, 'existing.txt'), 'x'); - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-new', target_path: target, - }) - ).toThrow(/not empty/); - fs.rmSync(target, { recursive: true, force: true }); - }); - - it('accepts empty existing dir', () => { - const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); - const r = pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-new', target_path: target, - }); - expect(r.mode).toBe('linked-new'); - expect(r.root_path).toBe(target); - // 检查 linked-registry - const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8')); - expect(reg[r.pid]).toBeDefined(); - expect(reg[r.pid].mode).toBe('linked-new'); - - fs.rmSync(target, { recursive: true, force: true }); - pathResolver.unregisterLinkedProject(r.pid); - }); - }); - - describe('allocateProjectRoot · linked-existing', () => { - it('rejects nonexistent target_path', () => { - // 固定路径 + 先删一遍:随机名只是「大概率不存在」,删一遍是「一定不存在」 - const fake = path.join(os.tmpdir(), 'devloop-le-nope'); - fs.rmSync(fake, { recursive: true, force: true }); - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-existing', target_path: fake, - }) - ).toThrow(/does not exist/); - }); - - it('rejects target_path that already contains a DevLoop project.json', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-')); - fs.writeFileSync(path.join(dir, 'project.json'), '{}'); - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-existing', target_path: dir, - }) - ).toThrow(/already contains/); - }); - - it('accepts existing non-empty plain dir (no .git)', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-')); - fs.writeFileSync(path.join(dir, 'README.md'), '# user'); - const r = pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-existing', target_path: dir, - }); - expect(r.pid).toMatch(/^p-/); - expect(r.root_path).toBe(dir); - expect(r.mode).toBe('linked-existing'); - pathResolver.unregisterLinkedProject(r.pid); - }); - - it('rejects existing git repo with dirty worktree', () => { - const { execFileSync } = require('child_process'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-')); - execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); - execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); - execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); - fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); - execFileSync('git', ['add', '-A'], { cwd: dir }); - execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); - // dirty: untracked - fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); - expect(() => - pathResolver.allocateProjectRoot({ - company_id: 'c-test', mode: 'linked-existing', target_path: dir, - }) - ).toThrow(/uncommitted changes/); - }); - }); - - describe('resolveFixedTarget(39 号文档:公司级目标代码目录,不进 registry)', () => { - it('linked-existing:校验通过时返回 root_path,不写 linked-projects registry', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-le-')); - fs.writeFileSync(path.join(dir, 'README.md'), '# user'); - const before = fs.existsSync(pathResolver.LINKED_REGISTRY) - ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; - const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); - expect(root).toBe(dir); - const after = fs.existsSync(pathResolver.LINKED_REGISTRY) - ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null; - expect(after).toBe(before); // registry 文件内容没变——没有 pid 被登记进去 - expect(pathResolver.findProjectByPath(dir)).toBe(null); // 也没有进内存 cache - }); - - it('linked-existing:git 树不干净照样拒绝(复用同一套校验,报同一个 error.code)', () => { - const { execFileSync } = require('child_process'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-dirty-')); - execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); - execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir }); - execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }); - fs.writeFileSync(path.join(dir, 'a.txt'), 'one'); - execFileSync('git', ['add', '-A'], { cwd: dir }); - execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); - fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked'); - let caught = null; - try { - pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' }); - } catch (e) { caught = e; } - expect(caught).not.toBe(null); - expect(caught.message).toMatch(/uncommitted changes/); - expect(caught.code).toBe('DIRTY_WORKTREE'); - }); - - it('linked-existing:路径不存在时报错,不静默创建/降级', () => { - const fake = path.join(os.tmpdir(), 'devloop-fixed-le-nope'); - fs.rmSync(fake, { recursive: true, force: true }); - expect(() => - pathResolver.resolveFixedTarget({ target_path: fake, mode: 'linked-existing' }) - ).toThrow(/does not exist/); - expect(fs.existsSync(fake)).toBe(false); // 没有被顺手 mkdir 出来 - }); - - it('linked-new:空目录直接接受,同样不进 registry', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-new-')); - fs.rmSync(dir, { recursive: true, force: true }); // 留空壳测试「不存在则新建」 - const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-new' }); - expect(root).toBe(dir); - expect(fs.existsSync(dir)).toBe(true); - expect(pathResolver.findProjectByPath(dir)).toBe(null); - }); - - it('拒绝未知 mode', () => { - expect(() => - pathResolver.resolveFixedTarget({ target_path: '/tmp/x', mode: 'sandbox' }) - ).toThrow(/unknown mode/); - }); - - it('敏感路径黑名单同样生效', () => { - expect(() => - pathResolver.resolveFixedTarget({ target_path: '/etc/whatever', mode: 'linked-existing' }) - ).toThrow(/sensitive_path/); - }); - }); - - describe('resolveProjectRoot + 派生路径', () => { - it('throws on unknown pid', () => { - expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); - }); - - it('after allocate, all resolvers work', () => { - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path); - expect(pathResolver.resolveUnitDir(r.pid, 'auth-be')) - .toBe(path.join(r.root_path, 'units', 'auth-be')); - expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd')); - expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd')); - expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson')); - expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson')); - expect(pathResolver.resolveAgentState(r.pid, 'pm')) - .toBe(path.join(r.root_path, 'agent-state', 'pm.json')); - fs.rmSync(r.root_path, { recursive: true, force: true }); - }); - }); - - describe('memory dir 在 company 级', () => { - it('does not depend on pid', () => { - const m1 = pathResolver.resolveMemoryDir('c-test', 'pm'); - expect(m1).toContain('companies/c-test/memory/devloop/pm'); - expect(m1).not.toContain('projects/'); - }); - }); -}); diff --git a/server/tests/devloop/routes-crud.test.js b/server/tests/devloop/routes-crud.test.js deleted file mode 100644 index 9f7dae0..0000000 --- a/server/tests/devloop/routes-crud.test.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * routes/devloop.js · 项目 CRUD 路由测试 - * - * 覆盖 (新增的) PATCH / DELETE (soft + hard) / restore + listProjects 过滤。 - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const express = require('express'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-crud-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const devloopRouter = require('../../routes/devloop'); -const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); - -function startApp() { - const app = express(); - app.use(express.json()); - app.use('/api/devloop', devloopRouter); - return new Promise((resolve) => { - const srv = app.listen(0, '127.0.0.1', () => { - resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` }); - }); - }); -} - -// 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、当前用户 -// 碰得到的公司——这个唯一的建项目入口顺手建一下,不用在每个调用点各补一次。 -async function createProject(base, cid, requirement = 'crud test') { - ensureTestCompany(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement }), - }); - const j = await r.json(); - return j.pid; -} - -describe('routes/devloop · CRUD', () => { - let app, base; - const cids = []; - - beforeAll(async () => { - pathResolver._resetForTesting(); - ({ srv: app, base } = await startApp()); - }); - - afterAll(() => { - app.close(); - for (const c of cids) { - try { - fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); - } catch { /* ignore */ } - removeTestCompany(c); - } - }); - - describe('PATCH /projects/:pid', () => { - let pid, cid; - beforeAll(async () => { - cid = 'c-patch'; - cids.push(cid); - pid = await createProject(base, cid); - }); - - it('updates name + idea_summary', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}`, { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'renamed project', idea_summary: 'new summary' }), - }); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j.project.name).toBe('renamed project'); - expect(j.project.idea_summary).toBe('new summary'); - }); - - it('rejects empty body (no allowed fields)', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}`, { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(r.status).toBe(400); - }); - - it('rejects non-string fields', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}`, { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 123 }), - }); - expect(r.status).toBe(400); - }); - - it('ignores fields not in allowlist (status / archived not changeable here)', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}`, { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'shipped-final', archived: true }), - }); - expect(r.status).toBe(400); // 没有任何 allowed 字段 → 400 - }); - - it('404 on unknown pid', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-nope`, { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'x' }), - }); - expect(r.status).toBe(404); - }); - }); - - describe('DELETE soft (archive) + GET list', () => { - let pidA, pidB, cid; - beforeAll(async () => { - cid = 'c-del'; - cids.push(cid); - pidA = await createProject(base, cid, 'kept'); - pidB = await createProject(base, cid, 'will be archived'); - }); - - it('soft delete sets archived=true (default no hard query)', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pidB}`, { method: 'DELETE' }); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j.hard).toBe(false); - expect(j.archived).toBe(true); - expect(j.project.archived).toBe(true); - }); - - it('default listProjects excludes archived', async () => { - const r = await fetch(`${base}/api/devloop/projects`); - const j = await r.json(); - const ids = j.projects.map(p => p.pid); - expect(ids).toContain(pidA); - expect(ids).not.toContain(pidB); - }); - - it('?include_archived=true returns archived too', async () => { - const r = await fetch(`${base}/api/devloop/projects?include_archived=true`); - const j = await r.json(); - const ids = j.projects.map(p => p.pid); - expect(ids).toContain(pidA); - expect(ids).toContain(pidB); - const arch = j.projects.find(p => p.pid === pidB); - expect(arch.archived).toBe(true); - }); - - it('POST /restore un-archives', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pidB}/restore`, { method: 'POST' }); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j.archived).toBe(false); - // 现在默认 list 应能见到 - const list = await (await fetch(`${base}/api/devloop/projects`)).json(); - expect(list.projects.map(p => p.pid)).toContain(pidB); - }); - }); - - describe('DELETE hard', () => { - it('?hard=true rm -rf 项目目录 (sandbox)', async () => { - const cid = 'c-hard'; - cids.push(cid); - const pid = await createProject(base, cid, 'hard delete me'); - const rootBefore = pathResolver.resolveProjectRoot(pid); - expect(fs.existsSync(rootBefore)).toBe(true); - - const r = await fetch(`${base}/api/devloop/projects/${pid}?hard=true`, { method: 'DELETE' }); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j.hard).toBe(true); - expect(j.removed_disk).toBe(true); - expect(fs.existsSync(rootBefore)).toBe(false); - - // 后续读 state 应 404 (pid 已从 cache 移除) - const s = await fetch(`${base}/api/devloop/projects/${pid}/state`); - expect(s.status).toBe(404); - }); - - it('hard delete unknown pid → 404', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-nope?hard=true`, { method: 'DELETE' }); - expect(r.status).toBe(404); - }); - }); - - describe('GET /projects?include_archived=false (default)', () => { - it('returns projects array (could be empty)', async () => { - const r = await fetch(`${base}/api/devloop/projects`); - expect(r.status).toBe(200); - const j = await r.json(); - expect(Array.isArray(j.projects)).toBe(true); - }); - }); -}); diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js deleted file mode 100644 index 45c6468..0000000 --- a/server/tests/devloop/routes.test.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * routes/devloop.js 集成测试 - * - * 不启动完整 server,只挂 devloop router 到独立 express app + fetch。 - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const express = require('express'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rt-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const devloopRouter = require('../../routes/devloop'); -const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); - -function startApp() { - const app = express(); - app.use(express.json()); - app.use('/api/devloop', devloopRouter); - return new Promise((resolve) => { - const srv = app.listen(0, '127.0.0.1', () => { - const port = srv.address().port; - resolve({ srv, base: `http://127.0.0.1:${port}` }); - }); - }); -} - -describe('routes/devloop', () => { - let app, base; - const created = []; - - // 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、 - // 当前用户碰得到的公司——这份测试文件里各用例用的 cid 都是硬编码字面量,从没真的建过公司。 - const TEST_CIDS = ['c-route-test', 'c-route-p2', 'c-route-p3', 'c-route-p2u', 'c-p5-routes']; - - beforeAll(async () => { - pathResolver._resetForTesting(); - for (const cid of TEST_CIDS) ensureTestCompany(cid); - ({ srv: app, base } = await startApp()); - }); - - afterAll(() => { - app.close(); - for (const c of created) { - try { - fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); - } catch { /* ignore */ } - } - for (const cid of TEST_CIDS) removeTestCompany(cid); - }); - - it('POST /api/devloop/projects requires company_id', async () => { - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requirement: 'x' }), - }); - expect(r.status).toBe(400); - const j = await r.json(); - expect(j.error).toMatch(/company_id/); - }); - - it('POST /api/devloop/projects requires requirement', async () => { - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: 'c-x' }), - }); - expect(r.status).toBe(400); - const j = await r.json(); - expect(j.error).toMatch(/requirement/); - }); - - it('POST /api/devloop/projects → creates project and runs PM + Architect', async () => { - const cid = 'c-route-test'; - created.push(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - company_id: cid, - name: 'todo-demo', - requirement: '做一个 TODO 列表:能新增、列表、Vue 前端', - }), - }); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j.ok).toBe(true); - expect(j.pid).toMatch(/^p-/); - expect(j.pm.files_written).toBeGreaterThanOrEqual(10); - expect(j.architect.files_written).toBeGreaterThanOrEqual(10); - expect(j.pm.commit).toBeTruthy(); - expect(j.architect.commit).toBeTruthy(); - - // 验证文件确实落盘 - const root = pathResolver.resolveProjectRoot(j.pid); - expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true); - expect(fs.existsSync(path.join(root, 'sdd', 'mvcp', 'model.html'))).toBe(true); - expect(fs.existsSync(path.join(root, 'units.json'))).toBe(true); - - // GET /state - const sR = await fetch(`${base}/api/devloop/projects/${j.pid}/state`); - expect(sR.status).toBe(200); - const s = await sR.json(); - expect(s.pid).toBe(j.pid); - expect(s.prd_site).toBe(true); - expect(s.sdd_site).toBe(true); - expect(s.units).toHaveLength(3); - }); - - it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(r.status).toBe(404); - }); - - it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { - const cid = 'c-route-p2'; - created.push(cid); - // 准备项目 - const r1 = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: '做一个 TODO API' }), - }); - const j1 = await r1.json(); - const pid = j1.pid; - // 跑 todo-be - const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' }); - expect(r2.status).toBe(200); - const j2 = await r2.json(); - expect(j2.ok).toBe(true); - expect(j2.unit_id).toBe('todo-be'); - expect(j2.attempts).toBe(2); - expect(j2.final_state).toBe('passed'); - expect(j2.history).toHaveLength(4); - }); - - it('POST /run (full project, no ?only) drives runProject', async () => { - const cid = 'c-route-p3'; - created.push(cid); - const r1 = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }), - }); - const pid = (await r1.json()).pid; - // ship via full project run (baseline fixtures = happy path) - const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(r2.status).toBe(200); - const j = await r2.json(); - expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state); - expect(typeof j.l2_rounds).toBe('number'); - }); - - it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`); - expect(r.status).toBe(404); - }); - - it('POST /run?only on unknown unit returns 404', async () => { - const cid = 'c-route-p2u'; - created.push(cid); - const r1 = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'x' }), - }); - const pid = (await r1.json()).pid; - const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=no-such-unit`, { method: 'POST' }); - expect(r2.status).toBe(404); - }); - - it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); - expect(r.status).toBe(404); - // 38 号文档 Phase 2:router.param('pid', ...) 现在抢在具体路由前面判"项目存不存在", - // 消息文案统一成"项目 X 不存在"(跟 company/run 那两处同款措辞),不再是各路由各写一套。 - const j = await r.json(); - expect(j.error).toMatch(/不存在/); - }); - - it('GET /api/devloop/projects lists projects', async () => { - const r = await fetch(`${base}/api/devloop/projects`); - expect(r.status).toBe(200); - const j = await r.json(); - expect(Array.isArray(j.projects)).toBe(true); - }); - - // P5 — UI-supporting endpoints - describe('P5 UI endpoints', () => { - let pid; - beforeAll(async () => { - const cid = 'c-p5-routes'; - created.push(cid); - const r = await fetch(`${base}/api/devloop/projects`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), - }); - pid = (await r.json()).pid; - }); - - it('GET /timeline returns events array', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); - expect(r.status).toBe(200); - const j = await r.json(); - expect(Array.isArray(j.events)).toBe(true); - expect(j.events.length).toBeGreaterThan(0); - }); - - it('GET /changelog returns entries array', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); - expect(r.status).toBe(200); - const j = await r.json(); - expect(Array.isArray(j.entries)).toBe(true); - }); - - it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); - expect(r.status).toBe(200); - const j = await r.json(); - expect(j).toHaveProperty('unit'); - expect(j).toHaveProperty('runs'); - expect(Array.isArray(j.runs)).toBe(true); - }); - - it('GET /units/:unitId 404 on unknown unit', async () => { - const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); - expect(r.status).toBe(404); - }); - - it('GET /timeline 404 on unknown pid', async () => { - const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); - expect(r.status).toBe(404); - }); - }); -}); diff --git a/server/tests/devloop/run-project.test.js b/server/tests/devloop/run-project.test.js deleted file mode 100644 index c867e1b..0000000 --- a/server/tests/devloop/run-project.test.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * loop-controller.runProject (P3 L2 联动) 单测 - * - * 跑 4 个 L2 分支: - * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 - * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 - * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE - * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0 - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const sseBroker = require('../../services/devloop/sse-broker'); -const bugTracker = require('../../services/devloop/bug-tracker'); -const lc = require('../../src/core/devloop/loop-controller'); - -const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios'); - -async function setupScenario(name) { - pathResolver._resetForTesting(); - sseBroker._resetForTesting(); - const cid = 'c-p3-' + name; // 场景名本身唯一,不再加随机后缀(清理漏一次也只留这一个) - const dir = path.join(SCENARIO_BASE, name); - const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name }); - await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); - return { pid: proj.pid, cid, dir }; -} - -function cleanup(cid) { - try { - fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); - } catch { /* ignore */ } -} - -describe('runProject · L2 branches', () => { - describe('① contract_mismatch', () => { - let pid, cid, dir, result; - const sseEvents = []; - - beforeAll(async () => { - ({ pid, cid, dir } = await setupScenario('contract_mismatch')); - const fakeRes = { - setHeader() {}, flushHeaders() {}, end() {}, - on() {}, // sse-broker subscribes res.on('close', ...) - write(line) { - const m = line.match(/^event: (.+)$/m); - if (m) sseEvents.push(m[1]); - }, - }; - sseBroker.subscribe(pid, fakeRes, {}); - result = await lc.runProject(pid, { mockFixtureDir: dir }); - }); - afterAll(() => { sseBroker.close(pid); cleanup(cid); }); - - it('ships after 1 L2 round', () => { - expect(result.ok).toBe(true); - expect(result.final_state).toBe('shipped'); - }); - - it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => { - const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); - expect(u.l2_rounds).toBe(1); - expect(u.sdd_rev).toBe('2'); - }); - - it('emits sdd.updated event', () => { - expect(sseEvents).toContain('sdd.updated'); - }); - - it('changelog has sdd_rev entry', () => { - const cl = pathResolver.resolveChangelog(pid); - expect(fs.existsSync(cl)).toBe(true); - const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); - expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true); - }); - - it('bug opened then transitioned to fixed', () => { - const bugs = bugTracker.list(pid, {}); - expect(bugs).toHaveLength(1); - expect(bugs[0].status).toBe('fixed'); - }); - }); - - describe('② impl_bug', () => { - let pid, cid, dir, result; - - beforeAll(async () => { - ({ pid, cid, dir } = await setupScenario('impl_bug')); - result = await lc.runProject(pid, { mockFixtureDir: dir }); - }); - afterAll(() => { sseBroker.close(pid); cleanup(cid); }); - - it('ships without SDD change', () => { - expect(result.ok).toBe(true); - expect(result.final_state).toBe('shipped'); - const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); - expect(u.l2_rounds).toBe(0); - expect(u.sdd_rev).toBe('1'); - }); - - it('history shows impl_bug reset_units (no sdd_patch)', () => { - const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); - expect(actions.some(a => /impl_bug/.test(a))).toBe(true); - expect(actions.some(a => /sdd_patch/.test(a))).toBe(false); - }); - }); - - describe('③ spec_ambiguous', () => { - let pid, cid, dir, result; - const sseEvents = []; - - beforeAll(async () => { - ({ pid, cid, dir } = await setupScenario('spec_ambiguous')); - const fakeRes = { - setHeader() {}, flushHeaders() {}, end() {}, - on() {}, // sse-broker subscribes res.on('close', ...) - write(line) { - const m = line.match(/^event: (.+)$/m); - if (m) sseEvents.push(m[1]); - }, - }; - sseBroker.subscribe(pid, fakeRes, {}); - result = await lc.runProject(pid, { mockFixtureDir: dir }); - }); - afterAll(() => { sseBroker.close(pid); cleanup(cid); }); - - it('returns l2_blocked', () => { - expect(result.ok).toBe(false); - expect(result.final_state).toBe('l2_blocked'); - }); - - it('emits human_intervention_needed SSE', () => { - expect(sseEvents).toContain('human_intervention_needed'); - }); - }); - - describe('④ infra_flake', () => { - let pid, cid, dir, result; - - beforeAll(async () => { - ({ pid, cid, dir } = await setupScenario('infra_flake')); - result = await lc.runProject(pid, { mockFixtureDir: dir }); - }); - afterAll(() => { sseBroker.close(pid); cleanup(cid); }); - - it('ships after re-run without budget burn', () => { - expect(result.ok).toBe(true); - expect(result.final_state).toBe('shipped'); - const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); - expect(u.l2_rounds).toBe(0); - }); - - it('history records rerun_integrator', () => { - const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); - expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true); - }); - }); -}); diff --git a/server/tests/devloop/run-unit.test.js b/server/tests/devloop/run-unit.test.js deleted file mode 100644 index 08457b1..0000000 --- a/server/tests/devloop/run-unit.test.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * loop-controller.runUnit (P2 L1 单 Unit Loop) 单测 - * - * 验: - * - state 链路 pending → coding → testing → failed → coding → testing → passed - * - coder.log 第二条决策含 last_failure (反思生效) - * - 沙箱违例被工具层拒绝 - */ - -process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ru-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../services/devloop/path-resolver'); -const unitRegistry = require('../../services/devloop/unit-registry'); -const lc = require('../../src/core/devloop/loop-controller'); -const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol'); -const { assertWritable } = require('../../src/tools/devloop/_sandbox'); -const { ToolSandboxError } = require('../../src/tools/devloop/_errors'); - -describe('runUnit (P2 L1)', () => { - let pid, cid; - const created = []; - - beforeAll(async () => { - pathResolver._resetForTesting(); - ClaudeCliProtocol.resetMockState(); - cid = 'c-rununit'; - created.push(cid); - const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p2 test' }); - pid = proj.pid; - await lc.runDocsPhase(pid, 'TODO list'); - }); - - afterAll(() => { - for (const c of created) { - try { - fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); - } catch { /* ignore */ } - } - }); - - it('todo-be: fail → retry → pass in 2 attempts', async () => { - // 验证起始 status=pending - expect(unitRegistry.get(pid, 'todo-be').status).toBe('pending'); - - // 监听 unit-registry 的 SSE 事件来捕获完整 FSM 链路 - const fsmTrail = []; - unitRegistry.attachSseBroker({ - emit: (_pid, kind, ev) => { - if (kind === 'unit.state' && ev.unit_id === 'todo-be') { - fsmTrail.push(`${ev.from}→${ev.to}`); - } - }, - }); - - const r = await lc.runUnit(pid, 'todo-be'); - expect(r.ok).toBe(true); - expect(r.attempts).toBe(2); - expect(r.final_state).toBe('passed'); - - // FSM 链路: pending→coding (att 1) · coding→testing · testing→failed · failed→coding (att 2) · coding→testing · testing→passed - expect(fsmTrail).toEqual([ - 'pending→coding', - 'coding→testing', - 'testing→failed', - 'failed→coding', - 'coding→testing', - 'testing→passed', - ]); - }); - - it('coder.log second decision references previous failure', () => { - const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log'); - const log = fs.readFileSync(logPath, 'utf8'); - // 必须至少有两条 attempt - expect(log).toMatch(/attempt: 1/); - expect(log).toMatch(/attempt: 2/); - // 第二条 entry 必须包含反思字段 - const att2Section = log.split('attempt: 2')[1] || ''; - expect(att2Section).toMatch(/last_failure/); - expect(att2Section).toMatch(/last_cause/); - expect(att2Section).toMatch(/this_fix/); - }); - - it('produced runs/{runId}/result.json for both attempts', () => { - const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs'); - const ids = fs.readdirSync(runsDir).sort(); - expect(ids.length).toBeGreaterThanOrEqual(2); - // 第一个 result.ok=false, 最后 result.ok=true - const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8')); - const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8')); - expect(first.ok).toBe(false); - expect(last.ok).toBe(true); - }); - - it('sandbox enforcement: writing outside unit dir is rejected by _sandbox.assertWritable', () => { - const root = pathResolver.resolveProjectRoot(pid); - const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' }; - // 合法 - expect(() => assertWritable( - path.join(root, 'units/todo-be/code/server.js'), - ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'], - ctx, - )).not.toThrow(); - // 越权: 写别的 unit - expect(() => assertWritable( - path.join(root, 'units/todo-fe/code/foo.js'), - ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'], - ctx, - )).toThrow(ToolSandboxError); - // 越权: 写 prd - expect(() => assertWritable( - path.join(root, 'prd/anything.html'), - ['units/{unitId}/code/**'], - ctx, - )).toThrow(ToolSandboxError); - }); -}); diff --git a/server/tests/devloop/timeline-logger.test.js b/server/tests/devloop/timeline-logger.test.js deleted file mode 100644 index ce34483..0000000 --- a/server/tests/devloop/timeline-logger.test.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * timelineLogger + changelogWriter 单测 - */ -const fs = require('fs'); - -const pathResolver = require('../../services/devloop/path-resolver'); -const timelineLogger = require('../../services/devloop/timeline-logger'); -const changelogWriter = require('../../services/devloop/changelog-writer'); - -describe('timelineLogger', () => { - let pid, root; - - beforeEach(() => { - pathResolver._resetForTesting(); - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - pid = r.pid; - root = r.root_path; - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('append writes NDJSON line', () => { - timelineLogger.append(pid, { - source: 'system', kind: 'project_created', ref: pid, - summary: 'test project', - }); - const events = timelineLogger.read(pid); - expect(events).toHaveLength(1); - expect(events[0].kind).toBe('project_created'); - }); - - it('append rejects missing fields', () => { - expect(() => timelineLogger.append(pid, {})).toThrow(); - expect(() => timelineLogger.append(pid, { source: 'x' })).toThrow(); - }); - - it('summary truncated to 280 chars', () => { - const longSummary = 'x'.repeat(500); - timelineLogger.append(pid, { - source: 'system', kind: 'test', summary: longSummary, - }); - const events = timelineLogger.read(pid); - expect(events[0].summary.length).toBeLessThanOrEqual(280); - expect(events[0].summary).toMatch(/…$/); - }); - - it('oversized payload reduced to ref marker', () => { - const big = { huge: 'x'.repeat(5000) }; - timelineLogger.append(pid, { - source: 'system', kind: 'test', summary: 's', payload: big, - }); - const events = timelineLogger.read(pid); - expect(events[0].payload.oversize).toBe(true); - }); - - it('tail returns last N', () => { - for (let i = 0; i < 15; i++) { - timelineLogger.append(pid, { source: 'system', kind: 'k', summary: `${i}` }); - } - const last5 = timelineLogger.tail(pid, 5); - expect(last5).toHaveLength(5); - // 时间正序,最后一条是最新 - expect(last5[4].summary).toBe('14'); - }); - - it('filter by kind', () => { - timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'a' }); - timelineLogger.append(pid, { source: 'arch', kind: 'sdd_generated', summary: 'b' }); - timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'c' }); - const r = timelineLogger.read(pid, { kind: 'prd_drafted' }); - expect(r).toHaveLength(2); - }); -}); - -describe('changelogWriter', () => { - let pid, root; - - beforeEach(() => { - pathResolver._resetForTesting(); - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - pid = r.pid; - root = r.root_path; - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('append writes entry', () => { - changelogWriter.append(pid, { - kind: 'prd_rev', rev: 1, by: 'pm-agent', - trigger: 'init', summary: 'first PRD', - }); - const all = changelogWriter.read(pid); - expect(all).toHaveLength(1); - expect(all[0].kind).toBe('prd_rev'); - expect(all[0].rev).toBe(1); - }); - - it('rejects invalid kind', () => { - expect(() => changelogWriter.append(pid, { - kind: 'wrong-kind', rev: 1, summary: 's', - })).toThrow(); - }); - - it('latest returns most recent of kind', () => { - changelogWriter.append(pid, { kind: 'prd_rev', rev: 1, summary: 's1' }); - changelogWriter.append(pid, { kind: 'sdd_rev', rev: 1, summary: 's2' }); - changelogWriter.append(pid, { kind: 'prd_rev', rev: 2, summary: 's3' }); - const r = changelogWriter.latest(pid, 'prd_rev'); - expect(r.rev).toBe(2); - }); -}); diff --git a/server/tests/devloop/tools/bash-json-guard.test.js b/server/tests/devloop/tools/bash-json-guard.test.js deleted file mode 100644 index b9b7d1c..0000000 --- a/server/tests/devloop/tools/bash-json-guard.test.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * bash-json-guard 单测 - */ - -const { jsonGuard, bashHooks, INLINE_JSON_PATTERNS } = require('../../../src/tools/devloop/bash-json-guard'); - -describe('bash-json-guard', () => { - describe('jsonGuard', () => { - it('passes plain commands', () => { - expect(jsonGuard('ls -la').ok).toBe(true); - expect(jsonGuard('node server.js').ok).toBe(true); - }); - - it('passes curl with @file', () => { - expect(jsonGuard('curl -X POST -d @/tmp/payload.json http://x').ok).toBe(true); - }); - - it('blocks curl -d single-quoted inline JSON', () => { - const r = jsonGuard(`curl -d '{"k":"v"}' http://x`); - expect(r.ok).toBe(false); - expect(r.reason).toMatch(/inline JSON/i); - }); - - it('blocks curl --data inline JSON', () => { - const r = jsonGuard(`curl --data '{"k":"v"}' http://x`); - expect(r.ok).toBe(false); - }); - - it('blocks curl --data-raw inline JSON', () => { - const r = jsonGuard(`curl --data-raw '{"a":1}' http://x`); - expect(r.ok).toBe(false); - }); - - it('blocks curl --data-binary inline JSON', () => { - const r = jsonGuard(`curl --data-binary '{"big":true}' http://x`); - expect(r.ok).toBe(false); - }); - - it('blocks double-quoted form too', () => { - const r = jsonGuard(`curl -d "{\\"k\\":\\"v\\"}" http://x`); - expect(r.ok).toBe(false); - }); - - it('returns matched_pattern on block', () => { - const r = jsonGuard(`curl -d '{}' http://x`); - expect(r.ok).toBe(false); - expect(typeof r.matched_pattern).toBe('string'); - }); - - it('passes non-string input safely', () => { - expect(jsonGuard(null).ok).toBe(true); - expect(jsonGuard(undefined).ok).toBe(true); - expect(jsonGuard(123).ok).toBe(true); - }); - }); - - describe('bashHooks.preExecute', () => { - it('throws JSON_GUARD_BLOCK on offending command', () => { - const hook = bashHooks.preExecute[0]; - let err = null; - try { hook(`curl -d '{"x":1}' http://x`); } catch (e) { err = e; } - expect(err).not.toBeNull(); - expect(err.code).toBe('JSON_GUARD_BLOCK'); - }); - - it('returns silently on safe command', () => { - const hook = bashHooks.preExecute[0]; - expect(() => hook('echo hello')).not.toThrow(); - }); - }); - - it('exports 4 INLINE_JSON_PATTERNS', () => { - expect(INLINE_JSON_PATTERNS.length).toBe(4); - }); -}); diff --git a/server/tests/devloop/tools/coder-tools.test.js b/server/tests/devloop/tools/coder-tools.test.js deleted file mode 100644 index 5822c77..0000000 --- a/server/tests/devloop/tools/coder-tools.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * coder-tools 单测 - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ct-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const docStore = require('../../../services/devloop/doc-store'); -const tools = require('../../../src/tools/devloop/coder-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-proj-')); - const pid = 'p-codetest'; - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root }; -} - -describe('coder-tools', () => { - let env, ctx; - beforeEach(() => { - env = setupProject(); - ctx = { pid: env.pid, agent: 'coder' }; - }); - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('contract_bridge', () => { - function buildContract(exports) { - // hashContract uses contract.exports || contract, so version field stays stable - const c = { exports }; - c.version = docStore.hashContract(c); - return c; - } - - it('returns ok when hash matches', async () => { - const unitId = 'u-be-auth'; - const unitDir = path.join(env.root, 'units', unitId); - fs.mkdirSync(unitDir, { recursive: true }); - const contract = buildContract({ http: [{ method: 'POST', path: '/api/login' }] }); - fs.writeFileSync( - path.join(unitDir, 'contract.json'), - JSON.stringify(contract, null, 2) - ); - fs.writeFileSync( - path.join(env.root, 'units.json'), - JSON.stringify({ - units: [{ id: unitId, kind: 'be', contract_hash: contract.version, depends_on: [] }], - }) - ); - const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); - expect(r.ok).toBe(true); - expect(r.hash).toBe(contract.version); - }); - - it('flags hash mismatch (SDD-declared hash differs)', async () => { - const unitId = 'u-be-mis'; - const unitDir = path.join(env.root, 'units', unitId); - fs.mkdirSync(unitDir, { recursive: true }); - const contract = buildContract({ http: [{ method: 'GET', path: '/api/x' }] }); - fs.writeFileSync( - path.join(unitDir, 'contract.json'), - JSON.stringify(contract) - ); - fs.writeFileSync( - path.join(env.root, 'units.json'), - JSON.stringify({ - units: [{ id: unitId, kind: 'be', contract_hash: 'sha256:deadbeefdeadbeef', depends_on: [] }], - }) - ); - const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); - expect(r.ok).toBe(false); - expect(r.reason).toMatch(/does not match SDD/); - }); - - it('returns ok=false when contract missing', async () => { - const r = await tools.contract_bridge.execute({ unit_id: 'u-nope' }, ctx); - expect(r.ok).toBe(false); - expect(r.reason).toMatch(/contract\.json missing/); - }); - }); - - describe('append_coder_log', () => { - it('writes attempt=1 entry without retry fields', async () => { - const r = await tools.append_coder_log.execute({ - unit_id: 'u-foo', attempt: 1, - this_fix: 'init', rationale: 'first', - }, ctx); - expect(r.ok).toBe(true); - const content = fs.readFileSync(r.path, 'utf8'); - expect(content).toMatch(/attempt: 1/); - expect(content).toMatch(/this_fix: init/); - }); - - it('rejects retry without last_failure/last_cause', async () => { - await expect(tools.append_coder_log.execute({ - unit_id: 'u-foo', attempt: 2, - this_fix: 'try again', rationale: 'hmm', - }, ctx)).rejects.toThrow(/last_failure.*last_cause/); - }); - - it('accepts retry with required fields', async () => { - const r = await tools.append_coder_log.execute({ - unit_id: 'u-foo', attempt: 2, - last_failure: 'auth returns 500', - last_cause: 'user undefined before bcrypt.compare', - this_fix: 'add user null check', - rationale: 'guard against missing user', - }, ctx); - expect(r.ok).toBe(true); - expect(r.attempt).toBe(2); - }); - }); - - describe('sandbox_check', () => { - it('returns ok when no out-of-sandbox writes', async () => { - const unitId = 'u-x'; - const unitDir = path.join(env.root, 'units', unitId, 'code'); - fs.mkdirSync(unitDir, { recursive: true }); - const since = Date.now() - 10_000; - fs.writeFileSync(path.join(unitDir, 'a.js'), 'console.log(1);'); - const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); - expect(r.ok).toBe(true); - expect(r.violations).toEqual([]); - }); - - it('flags writes outside sandbox', async () => { - const unitId = 'u-y'; - fs.mkdirSync(path.join(env.root, 'units', unitId, 'code'), { recursive: true }); - const since = Date.now() - 1_000; - // 写一个非沙箱目录文件 - const badPath = path.join(env.root, 'rogue', 'evil.js'); - fs.mkdirSync(path.dirname(badPath), { recursive: true }); - fs.writeFileSync(badPath, 'oops'); - const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); - expect(r.ok).toBe(false); - expect(r.violations.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/server/tests/devloop/tools/doc-tools.test.js b/server/tests/devloop/tools/doc-tools.test.js deleted file mode 100644 index 33b78aa..0000000 --- a/server/tests/devloop/tools/doc-tools.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * doc-tools 单测 (PM + Architect) - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-dt-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const docStore = require('../../../services/devloop/doc-store'); -const tools = require('../../../src/tools/devloop/doc-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-proj-')); - const pid = 'p-doctest'; - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root }; -} - -const VALID_PRD_HTML = ` -<!doctype html> -<html><head><title>Scope -
-
-

S1

desc

-
T
- -
code block
-
card
-
-
-`; - -const VALID_SDD_HTML = ` - -Model -
-

M-1

x

-
-`; - -describe('doc-tools', () => { - let env; - let ctx; - beforeEach(() => { - env = setupProject(); - ctx = { pid: env.pid, agent: 'pm' }; - }); - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('bootstrap_prd_site / bootstrap_sdd_site', () => { - it('creates prd site skeleton', async () => { - const r = await tools.bootstrap_prd_site.execute({}, ctx); - expect(r.ok).toBe(true); - expect(fs.existsSync(path.join(env.root, 'prd', 'product'))).toBe(true); - expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'shell.css'))).toBe(true); - expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'search-index.json'))).toBe(true); - }); - - it('creates sdd site skeleton with mvcp/', async () => { - const r = await tools.bootstrap_sdd_site.execute({}, ctx); - expect(r.ok).toBe(true); - expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp'))).toBe(true); - }); - }); - - describe('write_chapter', () => { - it('writes valid PRD chapter', async () => { - await tools.bootstrap_prd_site.execute({}, ctx); - const r = await tools.write_chapter.execute({ - kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, - }, ctx); - expect(r.ok).toBe(true); - expect(fs.existsSync(path.join(env.root, 'prd', 'product', 'scope.html'))).toBe(true); - }); - - it('rejects invalid HTML', async () => { - await tools.bootstrap_prd_site.execute({}, ctx); - await expect(tools.write_chapter.execute({ - kind: 'prd', relPath: 'product/scope.html', html: '

no article tag

', - }, ctx)).rejects.toThrow(); - }); - }); - - describe('write_mvcp_page', () => { - it('writes sdd/mvcp/model.html', async () => { - await tools.bootstrap_sdd_site.execute({}, ctx); - const r = await tools.write_mvcp_page.execute({ - which: 'model', html: VALID_SDD_HTML, - }, ctx); - expect(r.ok).toBe(true); - expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp', 'model.html'))).toBe(true); - }); - }); - - describe('diff_html', () => { - it('detects added/changed/removed sections', async () => { - const oldHtml = `
A
B
`; - const newHtml = `
A
B-changed
C
`; - const r = await tools.diff_html.execute({ oldHtml, newHtml }, ctx); - expect(r.added).toEqual(['3']); - expect(r.changed).toEqual(['2']); - expect(r.removed).toEqual([]); - }); - }); - - describe('diff_units', () => { - it('detects added unit', async () => { - const r = await tools.diff_units.execute({ - newUnits: [ - { id: 'u-a', contract_hash: 'h1' }, - { id: 'u-b', contract_hash: 'h2' }, - ], - oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], - }, ctx); - expect(r.added).toContain('u-b'); - }); - - it('detects changed unit (contract_hash diff)', async () => { - const r = await tools.diff_units.execute({ - newUnits: [{ id: 'u-a', contract_hash: 'h2' }], - oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], - }, ctx); - expect(r.changed).toContain('u-a'); - }); - }); - - describe('echo_diff', () => { - it('emits SSE prd.updated', async () => { - const events = []; - ctx.sseBroker = { emit: (pid, k, p) => events.push({ pid, k, p }) }; - const r = await tools.echo_diff.execute({ - kind: 'prd', rev: 2, summary: 'scope rev 2', sections_diff: { added: ['s2'] }, - }, ctx); - expect(r.ok).toBe(true); - expect(events).toHaveLength(1); - expect(events[0].k).toBe('prd.updated'); - }); - }); - - describe('update_search_index', () => { - it('builds prd index after writes', async () => { - await tools.bootstrap_prd_site.execute({}, ctx); - await tools.write_chapter.execute({ - kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, - }, ctx); - const r = await tools.update_search_index.execute({ target: 'prd' }, ctx); - expect(r.ok).toBe(true); - expect(r.built[0].pages).toBeGreaterThan(0); - }); - }); - - describe('hash_contract', () => { - it('returns sha256-prefixed 16-hex hash', async () => { - const r = await tools.hash_contract.execute({ - contract: { name: 'login', method: 'POST', path: '/api/login' }, - }, ctx); - expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); - }); - - it('is deterministic for canonical-equivalent contracts', async () => { - const a = await tools.hash_contract.execute({ - contract: { a: 1, b: 2 }, - }, ctx); - const b = await tools.hash_contract.execute({ - contract: { b: 2, a: 1 }, - }, ctx); - expect(a.hash).toBe(b.hash); - }); - }); - - describe('validate_prd_site', () => { - it('flags missing required chapters', async () => { - await tools.bootstrap_prd_site.execute({}, ctx); - const r = await tools.validate_prd_site.execute({}, ctx); - expect(r.ok).toBe(false); - expect(r.issues.some(i => i.includes('missing chapter'))).toBe(true); - }); - }); -}); diff --git a/server/tests/devloop/tools/integrator-tools.test.js b/server/tests/devloop/tools/integrator-tools.test.js deleted file mode 100644 index 0eeeaf4..0000000 --- a/server/tests/devloop/tools/integrator-tools.test.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * integrator-tools 单测 - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const http = require('http'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-it-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const tools = require('../../../src/tools/devloop/integrator-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'it-proj-')); - const pid = 'p-integtest'; - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root }; -} - -describe('integrator-tools', () => { - let env, ctx; - beforeEach(() => { - env = setupProject(); - ctx = { pid: env.pid, agent: 'integrator' }; - }); - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('port_picker', () => { - it('allocates the requested number of free ports', async () => { - const r = await tools.port_picker.execute({ count: 3 }, ctx); - expect(r.ok).toBe(true); - expect(r.ports).toHaveLength(3); - for (const p of r.ports) { - expect(p).toBeGreaterThanOrEqual(18000); - expect(p).toBeLessThan(19000); - } - expect(new Set(r.ports).size).toBe(3); // unique - }); - }); - - describe('parse_placeholder', () => { - it('resolves {{step0.body.field}} from prev results', async () => { - const r = await tools.parse_placeholder.execute({ - template: { token: '{{step0.token}}' }, - previous_steps: [{ body: { token: 'abc123' } }], - }, ctx); - expect(r.resolved.token).toBe('abc123'); - }); - - it('throws when step ref missing', async () => { - await expect(tools.parse_placeholder.execute({ - template: { x: '{{step5.foo}}' }, - previous_steps: [], - }, ctx)).rejects.toThrow(/placeholder step5/); - }); - }); - - describe('health_wait', () => { - it('returns ok=true when server responds 200', async () => { - const srv = http.createServer((_req, res) => { - res.statusCode = 200; res.end('ok'); - }); - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - const r = await tools.health_wait.execute({ - url: `http://127.0.0.1:${port}/api/health`, - timeout_ms: 2000, interval_ms: 50, - }, ctx); - srv.close(); - expect(r.ok).toBe(true); - expect(r.status).toBe(200); - }); - - it('returns ok=false on timeout', async () => { - const r = await tools.health_wait.execute({ - url: 'http://127.0.0.1:1/never', - timeout_ms: 300, interval_ms: 100, - }, ctx); - expect(r.ok).toBe(false); - expect(r.timeout).toBe(true); - }); - }); - - describe('collect_stderr', () => { - it('returns last N lines from log', async () => { - const p = path.join(env.root, 'process.log'); - fs.writeFileSync(p, ['l1','l2','l3','l4','l5'].join('\n')); - const r = await tools.collect_stderr.execute({ log_path: p, lines: 2 }, ctx); - expect(r.ok).toBe(true); - expect(r.excerpt).toBe('l4\nl5'); - }); - - it('returns ok=false when log missing', async () => { - const r = await tools.collect_stderr.execute({ - log_path: path.join(env.root, 'nope.log'), - }, ctx); - expect(r.ok).toBe(false); - }); - }); - - describe('run_case', () => { - it('executes a single GET case against a test server', async () => { - const srv = http.createServer((req, res) => { - if (req.url === '/api/ping') { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ pong: true })); - return; - } - res.statusCode = 404; res.end(); - }); - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - const r = await tools.run_case.execute({ - case_spec: { - steps: [ - { unit: 'u-x', call: 'GET /api/ping', expect: { status: 200 } }, - ], - }, - ports: { 'u-x': port }, - }, ctx); - srv.close(); - expect(r.ok).toBe(true); - expect(r.steps_ran).toBe(1); - }); - - it('reports failed step when status mismatches', async () => { - const srv = http.createServer((_req, res) => { - res.statusCode = 500; res.end('{}'); - }); - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - const r = await tools.run_case.execute({ - case_spec: { - steps: [{ unit: 'u-x', call: 'GET /api/x', expect: { status: 200 } }], - }, - ports: { 'u-x': port }, - }, ctx); - srv.close(); - expect(r.ok).toBe(false); - expect(r.failed_step).toBe(0); - expect(r.got.status).toBe(500); - }); - }); -}); diff --git a/server/tests/devloop/tools/persistence-tools.test.js b/server/tests/devloop/tools/persistence-tools.test.js deleted file mode 100644 index ae552b0..0000000 --- a/server/tests/devloop/tools/persistence-tools.test.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * persistence-tools 单测 - * - * 覆盖 5 个工具: - * write_todolist / update_todo_item / update_runtime_state / - * timeline_append / record_memory - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-pt-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const tools = require('../../../src/tools/devloop/persistence-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-proj-')); - const pid = 'p-test01'; - // 注入 cache (绕过 register) - pathResolver.init(); - // hack: 直接写 project.json + 让 init 重扫不可行,改为构造 linked 注册表 - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root, company_id: 'c-test' }; -} - -describe('persistence-tools', () => { - let ctx; - let env; - - beforeEach(() => { - env = setupProject(); - ctx = { - pid: env.pid, - agent: 'pm', - call_id: 'call-1', - company_id: env.company_id, - }; - }); - - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('write_todolist', () => { - it('writes initial todolist with first item doing', async () => { - const r = await tools.write_todolist.execute({ - items: [ - { id: 'step-001', description: 'first step' }, - { id: 'step-002', description: 'second step' }, - ], - }, ctx); - expect(r.ok).toBe(true); - expect(r.total).toBe(2); - const written = JSON.parse(fs.readFileSync(r.path, 'utf8')); - expect(written.items[0].status).toBe('doing'); - expect(written.items[1].status).toBe('todo'); - expect(written.current).toBe('step-001'); - expect(written.progress_pct).toBe(0); - }); - - it('requires call_id in ctx', async () => { - await expect(tools.write_todolist.execute({ - items: [{ id: 'step-001', description: 'x' }], - }, { pid: env.pid, agent: 'pm' })).rejects.toThrow(/call_id/); - }); - - it('emits SSE when broker present', async () => { - const emitted = []; - ctx.sseBroker = { emit: (pid, kind, p) => emitted.push({ pid, kind, p }) }; - await tools.write_todolist.execute({ - items: [{ id: 'step-001', description: 'x' }], - }, ctx); - expect(emitted).toHaveLength(1); - expect(emitted[0].kind).toBe('agent.todolist'); - }); - }); - - describe('update_todo_item', () => { - beforeEach(async () => { - await tools.write_todolist.execute({ - items: [ - { id: 'step-001', description: 'a' }, - { id: 'step-002', description: 'b' }, - ], - }, ctx); - }); - - it('transitions doing → done and advances current to next', async () => { - const r = await tools.update_todo_item.execute({ - id: 'step-001', status: 'done', output_ref: 'units.json', - }, ctx); - expect(r.ok).toBe(true); - expect(r.progress_pct).toBe(50); - const p = pathResolver.resolveAgentTodolist(env.pid, 'pm'); - const tl = JSON.parse(fs.readFileSync(p, 'utf8')); - expect(tl.items[0].status).toBe('done'); - expect(tl.items[0].output_ref).toBe('units.json'); - expect(tl.items[1].status).toBe('doing'); - expect(tl.current).toBe('step-002'); - }); - - it('rejects unknown step id', async () => { - await expect(tools.update_todo_item.execute({ - id: 'step-999', status: 'done', - }, ctx)).rejects.toThrow(/unknown step/); - }); - }); - - describe('timeline_append', () => { - it('appends an event to timeline.ndjson', async () => { - const r = await tools.timeline_append.execute({ - kind: 'doc.updated', - summary: 'PRD scope rev 2', - ref: 'prd/scope.html', - }, ctx); - expect(r.ok).toBe(true); - expect(r.ts).toBeTruthy(); - const p = pathResolver.resolveTimelineLog(env.pid); - const content = fs.readFileSync(p, 'utf8').trim().split('\n'); - const lastEvent = JSON.parse(content[content.length - 1]); - expect(lastEvent.kind).toBe('doc.updated'); - expect(lastEvent.source).toBe('pm'); - }); - }); - - describe('record_memory', () => { - let uniqueAgent; - beforeEach(() => { - // memory dir is under DATA_DIR/companies//memory — not isolated by env.root. - // 固定 id + 每条用例前后清目录:随机后缀只会让漏掉的那次清理永远留在盘上。 - uniqueAgent = 'test-mem-agent'; - fs.rmSync(pathResolver.resolveMemoryDir(env.company_id, uniqueAgent), { recursive: true, force: true }); - ctx.agent = uniqueAgent; - }); - afterEach(() => { - const dir = pathResolver.resolveMemoryDir(env.company_id, uniqueAgent); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('writes memory .md with frontmatter (new)', async () => { - const r = await tools.record_memory.execute({ - topic: 'jwt-auth-points', - content: 'use jose lib with ES256 signing alg', - relevance: 0.9, - }, ctx); - expect(r.ok).toBe(true); - expect(r.upserted).toBe(false); - expect(r.hits).toBe(1); - const content = fs.readFileSync(r.path, 'utf8'); - expect(content).toMatch(/relevance_score: 0\.9/); - expect(content).toMatch(/jose lib/); - }); - - it('upserts on same topic with hits++', async () => { - await tools.record_memory.execute({ - topic: 'jwt-auth', content: 'first version content here', relevance: 0.5, - }, ctx); - const r2 = await tools.record_memory.execute({ - topic: 'jwt-auth', content: 'updated version content here', relevance: 0.7, - }, ctx); - expect(r2.upserted).toBe(true); - expect(r2.hits).toBe(2); - }); - }); -}); diff --git a/server/tests/devloop/tools/sandbox-and-errors.test.js b/server/tests/devloop/tools/sandbox-and-errors.test.js deleted file mode 100644 index 7808b4e..0000000 --- a/server/tests/devloop/tools/sandbox-and-errors.test.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * _sandbox + _errors 单测 - */ - -const path = require('path'); -const { - ToolValidationError, - ToolSandboxError, - ToolMissingDependencyError, -} = require('../../../src/tools/devloop/_errors'); -const sandbox = require('../../../src/tools/devloop/_sandbox'); - -describe('errors', () => { - it('ToolValidationError carries field + reason', () => { - const e = new ToolValidationError('html', 'too short'); - expect(e.name).toBe('ToolValidationError'); - expect(e.field).toBe('html'); - expect(e.reason).toBe('too short'); - expect(e.message).toContain('too short'); - }); - - it('ToolSandboxError reports agent + path', () => { - const e = new ToolSandboxError('/etc/passwd', 'coder'); - expect(e.name).toBe('ToolSandboxError'); - expect(e.path).toBe('/etc/passwd'); - expect(e.agent).toBe('coder'); - }); - - it('ToolMissingDependencyError names missing dep', () => { - const e = new ToolMissingDependencyError('ctx.pid'); - expect(e.message).toMatch(/ctx\.pid/); - }); -}); - -describe('_sandbox', () => { - const ctx = { - agent: 'coder', - projectDir: '/tmp/my-proj', - unit_id: 'u-be-auth', - }; - - it('matches exact file pattern', () => { - expect(sandbox.matchesPattern( - '/tmp/my-proj/timeline.ndjson', 'timeline.ndjson', ctx - )).toBe(true); - }); - - it('matches /** glob prefix', () => { - expect(sandbox.matchesPattern( - '/tmp/my-proj/prd/feature/login.html', 'prd/**', ctx - )).toBe(true); - }); - - it('matches /** root itself', () => { - expect(sandbox.matchesPattern( - '/tmp/my-proj/prd', 'prd/**', ctx - )).toBe(true); - }); - - it('substitutes {unitId} placeholder', () => { - expect(sandbox.matchesPattern( - '/tmp/my-proj/units/u-be-auth/code/server.js', - 'units/{unitId}/code/**', - ctx - )).toBe(true); - }); - - it('rejects paths outside projectDir', () => { - expect(sandbox.matchesPattern( - '/etc/passwd', 'prd/**', ctx - )).toBe(false); - }); - - it('isWithinAllowList allows when any pattern matches', () => { - expect(sandbox.isWithinAllowList( - '/tmp/my-proj/units/u-be-auth/contract.json', - ['prd/**', 'units/{unitId}/contract.json'], - ctx - )).toBe(true); - }); - - it('assertWritable throws ToolSandboxError on violation', () => { - expect(() => sandbox.assertWritable( - '/etc/passwd', ['prd/**'], ctx - )).toThrow(ToolSandboxError); - }); - - it('assertWritable is silent on allowed path', () => { - expect(() => sandbox.assertWritable( - '/tmp/my-proj/prd/index.html', ['prd/**'], ctx - )).not.toThrow(); - }); -}); diff --git a/server/tests/devloop/tools/tester-tools.test.js b/server/tests/devloop/tools/tester-tools.test.js deleted file mode 100644 index 5dd2b36..0000000 --- a/server/tests/devloop/tools/tester-tools.test.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * tester-tools 单测 - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tt-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const tools = require('../../../src/tools/devloop/tester-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-proj-')); - const pid = 'p-testertest'; - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root }; -} - -describe('tester-tools', () => { - let env, ctx; - beforeEach(() => { - env = setupProject(); - ctx = { pid: env.pid, agent: 'tester' }; - }); - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('hint_builder', () => { - it('hints on TypeError compare (bcrypt-like)', async () => { - const r = await tools.hint_builder.execute({ - stack: 'TypeError: Illegal arguments: undefined,string compare', - }, ctx); - expect(r.hint).toMatch(/bcrypt\.compare/); - }); - - it('hints on 401 expected vs 500 got', async () => { - const r = await tools.hint_builder.execute({ - expected: 401, got: 500, stack: '', - }, ctx); - expect(r.hint).toMatch(/401/); - }); - - it('parses status code from stack', async () => { - const r = await tools.hint_builder.execute({ - stack: 'Expected: 200\nReceived: 404', - }, ctx); - expect(r.hint).toMatch(/200/); - }); - }); - - describe('parse_vitest', () => { - it('returns crashed when raw json missing', async () => { - const r = await tools.parse_vitest.execute({ - raw_json_path: path.join(env.root, 'nope.json'), - }, ctx); - expect(r.ok).toBe(false); - expect(r.crashed).toBe(true); - }); - - it('parses passing vitest output', async () => { - const rawPath = path.join(env.root, 'raw.json'); - fs.writeFileSync(rawPath, JSON.stringify({ - testResults: [{ - assertionResults: [ - { status: 'passed', fullName: 'a' }, - { status: 'passed', fullName: 'b' }, - ], - }], - })); - const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); - expect(r.ok).toBe(true); - expect(r.summary.passed).toBe(2); - expect(r.failures).toHaveLength(0); - }); - - it('extracts failure with hint', async () => { - const rawPath = path.join(env.root, 'raw2.json'); - fs.writeFileSync(rawPath, JSON.stringify({ - testResults: [{ - assertionResults: [{ - status: 'failed', fullName: 'login returns 401', - failureMessages: ['Expected: 401\nReceived: 500\nat handler'], - }], - }], - })); - const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); - expect(r.ok).toBe(false); - expect(r.summary.failed).toBe(1); - expect(r.failures[0].hint).toMatch(/401/); - }); - - it('writes result.json when result_out_path given', async () => { - const rawPath = path.join(env.root, 'raw3.json'); - fs.writeFileSync(rawPath, JSON.stringify({ - testResults: [{ assertionResults: [{ status: 'passed', fullName: 't' }] }], - })); - const out = path.join(env.root, 'result.json'); - await tools.parse_vitest.execute({ - raw_json_path: rawPath, result_out_path: out, - }, ctx); - expect(fs.existsSync(out)).toBe(true); - }); - }); - - describe('bug_open / bug_list / bug_transition', () => { - it('opens a bug then lists it', async () => { - const r = await tools.bug_open.execute({ - title: 'login 500 instead of 401', - unit_id: 'u-be-auth', - test_name: 'login wrong password', - severity: 'high', - }, ctx); - expect(r.ok).toBe(true); - expect(r.bug_id).toBeTruthy(); - - const ls = await tools.bug_list.execute({ unit_id: 'u-be-auth' }, ctx); - expect(ls.total).toBeGreaterThanOrEqual(1); - }); - - it('transitions bug open → fixing', async () => { - const opened = await tools.bug_open.execute({ - title: 'x', unit_id: 'u-a', test_name: 't', - }, ctx); - const r = await tools.bug_transition.execute({ - bug_id: opened.bug_id, to: 'fixing', note: 'coder picked up', - }, ctx); - expect(r.ok).toBe(true); - }); - - it('rejects invalid bug transition', async () => { - const opened = await tools.bug_open.execute({ - title: 'y', unit_id: 'u-b', test_name: 't', - }, ctx); - await expect(tools.bug_transition.execute({ - bug_id: opened.bug_id, to: 'verified', - }, ctx)).rejects.toThrow(); - }); - }); -}); diff --git a/server/tests/devloop/tools/tool-registry.test.js b/server/tests/devloop/tools/tool-registry.test.js deleted file mode 100644 index e1ca716..0000000 --- a/server/tests/devloop/tools/tool-registry.test.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * tool-registry 单测 - */ - -const registry = require('../../../src/tools/devloop/tool-registry'); - -describe('tool-registry', () => { - beforeEach(() => registry._resetForTesting()); - - it('loadAllTools returns a Map with unique tool names', () => { - const all = registry.loadAllTools(); - expect(all instanceof Map).toBe(true); - expect(all.size).toBeGreaterThanOrEqual(30); - for (const [name, tool] of all) { - expect(typeof name).toBe('string'); - expect(tool.name).toBe(name); - expect(typeof tool.execute).toBe('function'); - } - }); - - it('listToolNames includes core 5 persistence tools', () => { - const names = registry.listToolNames(); - expect(names).toContain('write_todolist'); - expect(names).toContain('update_todo_item'); - expect(names).toContain('timeline_append'); - expect(names).toContain('record_memory'); - expect(names).toContain('update_runtime_state'); - }); - - it('listToolNames includes PM/Architect doc tools', () => { - const names = registry.listToolNames(); - expect(names).toContain('write_chapter'); - expect(names).toContain('write_mvcp_page'); - expect(names).toContain('hash_contract'); - expect(names).toContain('validate_prd_site'); - }); - - it('listToolNames includes Coder/Tester/Integrator/Triager tools', () => { - const names = registry.listToolNames(); - expect(names).toContain('contract_bridge'); // coder - expect(names).toContain('parse_vitest'); // tester - expect(names).toContain('port_picker'); // integrator - expect(names).toContain('extract_evidence'); // triager - }); - - it('getDevLoopTools filters by toolPolicy', () => { - const subset = registry.getDevLoopTools(['hash_contract', 'parse_vitest', 'nope']); - expect(subset).toHaveLength(2); - expect(subset.map(t => t.name).sort()).toEqual(['hash_contract', 'parse_vitest']); - }); - - it('dispatch invokes a known tool', async () => { - const r = await registry.dispatch('hash_contract', { - contract: { name: 'login', method: 'POST' }, - }, {}); - expect(r.ok).toBe(true); - expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); - }); - - it('dispatch rejects unknown tool', async () => { - await expect(registry.dispatch('does_not_exist', {}, {})).rejects.toThrow(/Unknown tool/); - }); - - it('dispatch validates required fields', async () => { - await expect(registry.dispatch('hash_contract', {}, {})).rejects.toThrow(/missing required field "contract"/); - }); -}); diff --git a/server/tests/devloop/tools/triager-tools.test.js b/server/tests/devloop/tools/triager-tools.test.js deleted file mode 100644 index 20a7c26..0000000 --- a/server/tests/devloop/tools/triager-tools.test.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * triager-tools 单测 - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tr-')); -process.env.HOME = TEST_HOME; - -const pathResolver = require('../../../services/devloop/path-resolver'); -const docStore = require('../../../services/devloop/doc-store'); -const tools = require('../../../src/tools/devloop/triager-tools'); - -function setupProject() { - pathResolver._resetForTesting(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-proj-')); - const pid = 'p-triagertest'; - const reg = { - [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, - }; - fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); - fs.writeFileSync( - path.join(TEST_HOME, '.devloop', 'linked-projects.json'), - JSON.stringify(reg) - ); - pathResolver._resetForTesting(); - return { pid, root }; -} - -describe('triager-tools', () => { - let env, ctx; - beforeEach(() => { - env = setupProject(); - ctx = { pid: env.pid, agent: 'triager' }; - }); - afterEach(() => { - fs.rmSync(env.root, { recursive: true, force: true }); - }); - - describe('extract_evidence', () => { - it('verifies a quote present in a file', async () => { - const target = path.join(env.root, 'unit-x.js'); - fs.writeFileSync(target, 'const VAL = 42;\nfunction foo() { return VAL; }\n'); - const r = await tools.extract_evidence.execute({ - evidence: ['unit-x.js: function foo() { return VAL'], - }, ctx); - expect(r.ok).toBe(true); - expect(r.results[0].ok).toBe(true); - }); - - it('rejects malformed evidence', async () => { - const r = await tools.extract_evidence.execute({ - evidence: ['no-colon-no-quote'], - }, ctx); - expect(r.ok).toBe(false); - expect(r.failed[0].reason).toMatch(/malformed/); - }); - - it('reports quote not found', async () => { - const target = path.join(env.root, 'file.js'); - fs.writeFileSync(target, 'function bar() {}'); - const r = await tools.extract_evidence.execute({ - evidence: ['file.js: never-going-to-find-this'], - }, ctx); - expect(r.ok).toBe(false); - expect(r.failed[0].reason).toMatch(/not found/); - }); - }); - - describe('category_classifier', () => { - it('flags infra_flake on EADDRINUSE in process log', async () => { - const r = await tools.category_classifier.execute({ - result_json: { failures: [] }, - process_log: 'Error: listen EADDRINUSE 0.0.0.0:3000', - }, ctx); - expect(r.category).toBe('infra_flake'); - }); - - it('flags impl_bug on 500 vs 4xx', async () => { - const r = await tools.category_classifier.execute({ - result_json: { failures: [{ got: { status: 500 }, expected: { status: 401 } }] }, - process_log: '', - }, ctx); - expect(r.category).toBe('impl_bug'); - }); - - it('falls back to contract_mismatch with low confidence', async () => { - const r = await tools.category_classifier.execute({ - result_json: { failures: [] }, - process_log: '', - sdd_text: 'this sdd describes everything', - }, ctx); - expect(r.category).toBe('contract_mismatch'); - expect(r.confidence).toBeLessThanOrEqual(0.5); - }); - }); - - describe('diff_contract_vs_impl', () => { - it('reports divergence when endpoint not in code', async () => { - const unitId = 'u-x'; - const unitDir = path.join(env.root, 'units', unitId); - fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); - fs.writeFileSync( - path.join(unitDir, 'contract.json'), - JSON.stringify({ - exports: { http: [{ method: 'POST', path: '/api/login' }] }, - }) - ); - fs.writeFileSync( - path.join(unitDir, 'code', 'server.js'), - 'function nothing(){}' - ); - const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); - expect(r.matches).toBe(false); - expect(r.divergences.some(d => d.includes('/api/login'))).toBe(true); - }); - - it('matches when endpoint appears in code', async () => { - const unitId = 'u-y'; - const unitDir = path.join(env.root, 'units', unitId); - fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); - fs.writeFileSync( - path.join(unitDir, 'contract.json'), - JSON.stringify({ - exports: { http: [{ method: 'GET', path: '/api/ping' }] }, - }) - ); - fs.writeFileSync( - path.join(unitDir, 'code', 'server.js'), - `app.get('/api/ping', (q,r) => r.send('pong'))` - ); - const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); - expect(r.matches).toBe(true); - }); - }); - - describe('find_similar_bug', () => { - it('returns empty when no bugs.ndjson yet', async () => { - const r = await tools.find_similar_bug.execute({ - signature: { unit_id: 'u-none', test_name: 't' }, - }, ctx); - expect(r.ok).toBe(true); - expect(r.total).toBe(0); - }); - }); -}); diff --git a/server/tests/devloop/unit-registry.test.js b/server/tests/devloop/unit-registry.test.js deleted file mode 100644 index c69e0e7..0000000 --- a/server/tests/devloop/unit-registry.test.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * unitRegistry 单测 — 跑 FSM 转换 - */ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const pathResolver = require('../../services/devloop/path-resolver'); -const docStore = require('../../services/devloop/doc-store'); -const unitRegistry = require('../../services/devloop/unit-registry'); - -describe('unitRegistry', () => { - let pid; - - beforeEach(() => { - pathResolver._resetForTesting(); - const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); - pid = r.pid; - // 准备 units.json - const units = { - project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3, - units: [ - { - id: 'auth-be', type: 'be', status: 'pending', depends_on: [], - retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa', - }, - { - id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'], - retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb', - }, - ], - }; - docStore.writeUnits(pid, units); - }); - - afterEach(() => { - const root = pathResolver.resolveProjectRoot(pid); - fs.rmSync(root, { recursive: true, force: true }); - }); - - describe('list / pending / passed', () => { - it('lists all units', () => { - expect(unitRegistry.list(pid)).toHaveLength(2); - }); - it('all start pending', () => { - expect(unitRegistry.pending(pid)).toHaveLength(2); - expect(unitRegistry.passed(pid)).toHaveLength(0); - }); - }); - - describe('canTransition', () => { - it('pending → coding ok', () => { - expect(unitRegistry.canTransition('pending', 'coding')).toBe(true); - }); - it('coding → testing ok', () => { - expect(unitRegistry.canTransition('coding', 'testing')).toBe(true); - }); - it('pending → passed FORBIDDEN', () => { - expect(unitRegistry.canTransition('pending', 'passed')).toBe(false); - }); - it('passed → pending ok (L2 reset)', () => { - expect(unitRegistry.canTransition('passed', 'pending')).toBe(true); - }); - it('blocked → pending ok (人类干预)', () => { - expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true); - }); - }); - - describe('markCoding / markTesting / pass', () => { - it('full L1 happy path', () => { - unitRegistry.markCoding(pid, 'auth-be'); - expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding'); - - unitRegistry.markTesting(pid, 'auth-be'); - expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing'); - - unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000'); - const u = unitRegistry.get(pid, 'auth-be'); - expect(u.status).toBe('passed'); - expect(u.contract_hash).toBe('sha256:newhash00000000'); - }); - - it('pending → testing throws', () => { - expect(() => unitRegistry.markTesting(pid, 'auth-be')) - .toThrow(unitRegistry.InvalidTransitionError); - }); - }); - - describe('fail + retries', () => { - it('coding/testing → failed accumulates retries', () => { - unitRegistry.markCoding(pid, 'auth-be'); - unitRegistry.markTesting(pid, 'auth-be'); - unitRegistry.fail(pid, 'auth-be', 'test red'); - expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1); - - unitRegistry.markCoding(pid, 'auth-be'); - unitRegistry.markTesting(pid, 'auth-be'); - unitRegistry.fail(pid, 'auth-be', 'test red 2'); - expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2); - }); - }); - - describe('block + reset', () => { - it('failed → blocked then reset', () => { - unitRegistry.markCoding(pid, 'auth-be'); - unitRegistry.markTesting(pid, 'auth-be'); - unitRegistry.fail(pid, 'auth-be', 'red'); - unitRegistry.block(pid, 'auth-be', 'budget exceeded'); - expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked'); - - const r = unitRegistry.reset(pid, ['auth-be']); - expect(r.reset).toHaveLength(1); - expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending'); - expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0); - }); - - it('reset skips coding/testing units with reason', () => { - unitRegistry.markCoding(pid, 'auth-be'); - const r = unitRegistry.reset(pid, ['auth-be', 'ghost']); - expect(r.reset).toHaveLength(0); - expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/); - expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found'); - }); - }); - - describe('l2GateOpen', () => { - it('false when any not passed', () => { - expect(unitRegistry.l2GateOpen(pid)).toBe(false); - }); - it('true when all passed', () => { - for (const id of ['auth-be', 'auth-fe']) { - unitRegistry.markCoding(pid, id); - unitRegistry.markTesting(pid, id); - unitRegistry.pass(pid, id, 'sha256:0000000000000000'); - } - expect(unitRegistry.l2GateOpen(pid)).toBe(true); - }); - }); - - describe('diffUnits', () => { - it('detects added/changed/removed', () => { - const oldList = [ - { id: 'a-be', contract_hash: 'sha256:1' }, - { id: 'b-be', contract_hash: 'sha256:2' }, - ]; - const newList = [ - { id: 'a-be', contract_hash: 'sha256:1' }, // same - { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed - { id: 'c-be', contract_hash: 'sha256:3' }, // added - ]; - const r = unitRegistry.diffUnits(oldList, newList); - expect(r.added).toEqual(['c-be']); - expect(r.changed).toEqual(['b-be']); - expect(r.removed).toEqual([]); - }); - }); -}); diff --git a/server/tests/unit/graduation-criteria-reality.test.js b/server/tests/unit/graduation-criteria-reality.test.js index f8c1caf..e456328 100644 --- a/server/tests/unit/graduation-criteria-reality.test.js +++ b/server/tests/unit/graduation-criteria-reality.test.js @@ -127,9 +127,10 @@ describe('毕业闸门 × 仓库实测现状', () => { const incompleteIds = incomplete.map((b) => b.id); expect(missingRequiredOnDisk.filter((id) => !incompleteIds.includes(id))).toEqual([]); - // 样本非空:实测有一批本体缺 soul.md(6 个 devloop-* + org-coder / org-writer) + // 样本非空:实测有一批本体缺 soul.md(org-coder / org-writer;DevLoop 退役前原本还有 + // 6 个 devloop-* 一起算在这个样本里,2026-09-17 随 DevLoop 一起删除,样本缩到 2 个) // 或仍是骨架(单测毕业出来的)。这条要是变成 0,说明判据被放成了「永远齐全」, - // 上面两条断言也就不作数了。这 8 个是没有任何测试会去改的常驻本体, + // 上面两条断言也就不作数了。这 2 个是没有任何测试会去改的常驻本体, // 所以它们绝不会被 churned 过滤掉 —— 样本非空这一条不依赖并发时序。 expect(missingRequiredOnDisk.length).toBeGreaterThan(0); }); diff --git a/server/tests/unit/route-auth-gate.test.js b/server/tests/unit/route-auth-gate.test.js index 2496964..7c6554e 100644 --- a/server/tests/unit/route-auth-gate.test.js +++ b/server/tests/unit/route-auth-gate.test.js @@ -52,10 +52,6 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { expectGated(require('../../routes/tasks.js'), requireAuth, 'tasks.js'); }); - it('devloop.js 挂了 requireAuth', () => { - expectGated(require('../../routes/devloop.js'), requireAuth, 'devloop.js'); - }); - it('graph-run.js 两个挂载点都挂了 requireAuthOrInternal(39 号文档:ceo 心跳要能读/停/重试 run,不能只有 company.js 一侧放行 agent 自环调用)', () => { const graphRun = require('../../routes/graph-run.js'); expectGated(graphRun, requireAuthOrInternal, 'graph-run.js router'); diff --git a/server/tests/unit/work-graph-skeletons.test.js b/server/tests/unit/work-graph-skeletons.test.js index 461d0a2..2360bba 100644 --- a/server/tests/unit/work-graph-skeletons.test.js +++ b/server/tests/unit/work-graph-skeletons.test.js @@ -32,11 +32,11 @@ const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates'); */ const PLACEHOLDERS = { '{{orgNode}}': 'org-coder', - '{{splitAgent}}': 'devloop-architect', - '{{implAgent}}': 'devloop-coder', - '{{testAgent}}': 'devloop-tester', - '{{mergeAgent}}': 'devloop-integrator', - '{{integrationAgent}}': 'devloop-tester', + '{{splitAgent}}': 'sdd-planning', + '{{implAgent}}': 'coder', + '{{testAgent}}': 'coder-validator', + '{{mergeAgent}}': 'org-validator', + '{{integrationAgent}}': 'coder-validator', '{{validatorAgent}}': 'coder-validator', }; diff --git a/system/agents/_registry.json b/system/agents/_registry.json index 96c9eeb..249a5a2 100644 --- a/system/agents/_registry.json +++ b/system/agents/_registry.json @@ -18,60 +18,6 @@ "usedCount": 82 }, { - "id": "devloop-pm", - "name": "DevLoop PM Agent", - "type": "devloop", - "description": "产出多页 HTML PRD 站点 (init / incremental / resume 三模式)。规格 docs/prd/newfunction/agents-pm.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 0 - }, - { - "id": "devloop-architect", - "name": "DevLoop Architect Agent", - "type": "devloop", - "description": "把 PRD 翻译成 SDD (MVCP) + units.json + integration spec。规格 docs/prd/newfunction/agents-architect.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 0 - }, - { - "id": "devloop-coder", - "name": "DevLoop Coder Agent", - "type": "devloop", - "description": "单 Unit 实现器 + retry-with-hint。规格 docs/prd/newfunction/agents-coder.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 6 - }, - { - "id": "devloop-tester", - "name": "DevLoop Tester Agent", - "type": "devloop", - "description": "运行 vitest + 生成 result.json + 开 bug。规格 docs/prd/newfunction/agents-tester.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 5 - }, - { - "id": "devloop-integrator", - "name": "DevLoop Integrator Agent", - "type": "devloop", - "description": "拉起多 Unit 进程 + 执行 integration spec。规格 docs/prd/newfunction/agents-integrator.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 0 - }, - { - "id": "devloop-triager", - "name": "DevLoop BugTriager Agent", - "type": "devloop", - "description": "分析 bug 根因 + 4 类粗判 + 路由给 Coder/Architect。规格 docs/prd/newfunction/agents-triager.html", - "benchmarkScore": null, - "trainedAt": "2026-05-13", - "usedCount": 0 - }, - { "id": "workgraph-planning", "name": "WorkGraph 规划 Agent", "type": "meta-planning", diff --git a/system/agents/devloop-architect/agent.md b/system/agents/devloop-architect/agent.md deleted file mode 100644 index 33a0986..0000000 --- a/system/agents/devloop-architect/agent.md +++ /dev/null @@ -1,21 +0,0 @@ -# DevLoop Architect Agent - -把 PRD 站点翻译成 SDD 站点 + units.json + integration/spec.json,让下游 Coder / Tester / Integrator / BugTriager 都能照单干活。 - -## ⚠️ 本体提示词不在这个文件里 —— 在同目录的 `prompt.md` - -DevLoop 的六个 Agent 与其余 agent 的装配路径不同,别照着别的目录改: - -- **运行时读的是 `prompt.md`**:`server/src/core/devloop/loop-controller.js` 的 `callAgent()` - 直接拼 `{agentDir}/prompt.md` 当 system prompt(mock 模式拼进 fullPrompt,real 模式走 - `--append-system-prompt`)。文件缺失只会静默降级成空 system prompt,不报错。 -- **`prompt.md` 是自动生成物**:由 `scripts/extract-agent-prompts.js` 从 - `docs/prd/newfunction/agents-architect.html` §2.2 抽出来。**要改提示词就去改那份 HTML 再重跑抽取**, - 直接改 `prompt.md` 下次抽取就被覆盖。 -- **所以不能把 `prompt.md` 改名成 `agent.md`**:改了会同时打断上面两条链路。 - 这份 `agent.md` 只是给「按 agent.md 判本体是否齐全」的扫描方(`graph-seed-service`、 - 各处 `scanAgentDir`)一个入口,不承载可执行提示词。 - -## 工具与沙箱 -见同目录 `config.json`:`cli.toolPolicy.allowedTools` 定工具白名单, -`sandbox.writeAllowList` 定这个角色允许写的路径前缀。 diff --git a/system/agents/devloop-architect/config.json b/system/agents/devloop-architect/config.json deleted file mode 100644 index 443441d..0000000 --- a/system/agents/devloop-architect/config.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "type": "devloop", - "status": "idle", - "promptFile": "prompt.md", - "cli": { - "toolPolicy": { - "mode": "restricted", - "allowedTools": [ - "Read", "Bash", - "write_todolist", "update_todo_item", "update_runtime_state", - "timeline_append", "record_memory", - "bootstrap_sdd_site", "write_chapter", "write_mvcp_page", - "diff_html", "diff_units", "echo_diff", "update_search_index", - "validate_sdd_consistency", "archive_doc", "hash_contract" - ] - } - }, - "sandbox": { - "writeAllowList": [ - "sdd/**", - "units.json", - "integration/**", - "agent-state/**", - "timeline.ndjson", - "changelog.ndjson", - "history/**" - ] - } -} diff --git a/system/agents/devloop-architect/prompt.md b/system/agents/devloop-architect/prompt.md deleted file mode 100644 index 70adad6..0000000 --- a/system/agents/devloop-architect/prompt.md +++ /dev/null @@ -1,131 +0,0 @@ - - -# ROLE -你是 LemonBoss DevLoop 的 Architect Agent。你把 PM Agent 产出的 PRD 站点 -转化为 SDD 站点 + units.json + integration/spec.json,让下游 Coder/Tester/ -Integrator/BugTriager 都能照单干活。 - -# MODES (输入参数 mode 决定行为) -- "generate" PRD 第一次出/重大变化 → 重新派生整套 SDD -- "patch" 接 triage.json,精准应用 proposed_sdd_patch -- "resume" 从 resume_token 恢复未完 todolist - -# OUTPUT FORMAT (硬约束 - DocStore 拒残) - -[A] 三件套必齐: - sdd/ 5 个 HTML + 3 个 asset - units.json 含 DAG · contract_hash · l2 预算 - integration/spec.json 端到端 cases - -[B] 每个 MVCP 定义五字段: -
-

X · {简短描述}

- {预期落盘路径} -
{TypeBox / TS / Vue props / SQL DDL}
-
  • 前置/后置/不变式
-
  • 验收用例(给 Tester 转 vitest)
-
- -[C] Unit id 严格命名: /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/ - 示例: auth-be · profile-fe · session-mw · todo-db - 禁止: AuthBE · auth_be · auth-backend · auth (无后缀) - -[D] 每个 Unit 必须有: - - contract (非空,OpenAPI/TS/schema) - - contract_hash (sha256:{16 chars},由 hash_contract 工具计算) - - depends_on (string[],可为空) - - 在 sdd/mvcp/*.html 中至少出现 1 次 - -[E] integration/spec.json 每 case 必须: - - 至少 2 步 - - involves 字段是步骤 unit 的并集 - - 引用的所有 unit 在 units.json 存在 - -# MANDATORY WORKFLOW - -## step 0 · 读取上下文 - ├─ 必读 prd/ 全部章节(尤其 prd/feature/feature-tree.html · prd/flow/user-flows.html) - ├─ 必读 changelog.ndjson 最后 5 条 - ├─ generate / patch 模式: 读现有 sdd/ + units.json + integration/spec.json (若存在) - ├─ patch 模式: 必读 integration/runs/{runId}/triage.json - └─ memory section 已在 prompt 顶部注入,不重复读 - -## step 1 · write_todolist - -### generate 模式典型 todolist (12 项): - step-001 解析 prd/feature/feature-tree.html → 列出 N 个 feature - step-002 对每个 feature 决定 be/fe/mw/db 拆分 - step-003 为每个 Unit 起草 contract (OpenAPI / TS interface / schema) - step-004 调 hash_contract 算 contract_hash - step-005 构造 DAG (depends_on) + 拓扑排序验环 - step-006 写 sdd/index.html + overview.html - step-007 写 sdd/mvcp/model.html (所有 data schema) - step-008 写 sdd/mvcp/view.html (所有 fe 组件) - step-009 写 sdd/mvcp/control.html (所有 be/mw 接口) - step-010 写 sdd/mvcp/process.html (长进程,可能空) - step-011 写 units.json (合并所有 Unit 元数据) - step-012 写 integration/spec.json (e2e cases) - step-013 update_search_index('sdd') + validate_sdd_consistency - step-014 timeline_append + record_memory (可选) - -### patch 模式典型 todolist (6 项): - step-001 读 triage.json + 定位影响的 sdd section - step-002 sdd-section-patcher 应用 proposed_sdd_patch - step-003 受影响 Unit 重算 contract_hash - step-004 更新 units.json (changed 数组标记) - step-005 (如 triage 暗示) 更新 integration/spec.json - step-006 validate + update_search_index + timeline - -## step N+1 · 收尾 - ├─ validate_sdd_consistency(pid) → 必须 ok=true - ├─ update_search_index(pid, 'sdd') - ├─ timeline_append('sdd_generated'|'sdd_patched', ref, summary) - └─ record_memory 如果发现可复用的架构模式(如 "项目偏好 better-sqlite3") - -# RULES (硬约束) - -1. 永远不读、不写 prd/。PRD 是 PM 的领地。 -2. 永远不写代码、不动 units/{id}/code/。代码是 Coder 的活。 -3. 同一 feature 涉及 BE + FE 必拆 2 个 Unit。不允许"全栈 Unit"。 -4. contract_hash 必须用 hash_contract 工具,不要手算/猜测。 -5. patch 模式只动 triage.affected_units 涉及的 section,其他原样保留。 -6. units.json 写入前必须 typebox 校验 + 拓扑排序验环。 -7. integration/spec.json 的 involves 必须是 cases.steps.unit 的精确并集。 -8. 任何带 JSON body 的 Bash 命令 → /tmp/*.json + -d @file (CLAUDE.md L91-99)。 -9. 沙箱: sdd/** · units.json · integration/spec.json · memory/devloop/architect/ -10. 不调 call_sub_agent。你不分派。 - -# DECISION HEURISTICS (写 SDD 时常用判断) - -### 何时拆 mw Unit - - 跨 ≥2 个 Unit 共享的能力(认证、限流、CORS) - - 引入新的请求/响应钩子 - - 否则不拆,内联在调用方的 Unit 里。 - -### 何时拆 db Unit - - 新表/列的 SQL DDL - - 跨多 Unit 共享的 schema (放 db 的好处:Coder 不必各自重复) - - 否则,schema 写在调用方 be Unit 内。 - -### 何时升 Unit 颗粒 - - 一个 Unit code 估算 > 400 行 → 拆 2 个 - - 单 Unit 测试用例 > 15 个 → 同上 - - 同 feature 内有显著独立的子能力(如 auth 含 login + register + token-refresh) - -# OUTPUT VOICE -- 中文写描述,英文写 token/code 标识符 -- 不写"完美""强大",数字 > 形容词 -- contract 段落要简洁但完整,不写无意义的注释 - -# SELF-CHECK -1. validate_sdd_consistency(pid) 返回 ok=true -2. units.json 通过 typebox 校验 -3. units.json DAG 无环 (topoSort 通过) -4. 每个 Unit 在 sdd/mvcp/ 至少出现 1 次 -5. 每个 Unit contract_hash 重算与文件中一致 -6. integration/spec.json involves 都在 units.json -7. agent-state/architect.json status=done -8. todolist 全 done -9. timeline 含 sdd_generated 或 sdd_patched -10. patch 模式: 仅 affected_units 涉及的 unit contract_hash 变化 -任一不过 → patch 后重跑 (≤2 次)。第 3 次仍不过 → 报 validation_failed 上报 L3。 diff --git a/system/agents/devloop-coder/agent.md b/system/agents/devloop-coder/agent.md deleted file mode 100644 index 6570ed8..0000000 --- a/system/agents/devloop-coder/agent.md +++ /dev/null @@ -1,21 +0,0 @@ -# DevLoop Coder Agent - -单 Unit 实现器:读 Unit spec + contract,写代码到 code/、写 contract.json、把决策记进 coder.log;失败时按 Tester 的 hint 反思重写。 - -## ⚠️ 本体提示词不在这个文件里 —— 在同目录的 `prompt.md` - -DevLoop 的六个 Agent 与其余 agent 的装配路径不同,别照着别的目录改: - -- **运行时读的是 `prompt.md`**:`server/src/core/devloop/loop-controller.js` 的 `callAgent()` - 直接拼 `{agentDir}/prompt.md` 当 system prompt(mock 模式拼进 fullPrompt,real 模式走 - `--append-system-prompt`)。文件缺失只会静默降级成空 system prompt,不报错。 -- **`prompt.md` 是自动生成物**:由 `scripts/extract-agent-prompts.js` 从 - `docs/prd/newfunction/agents-coder.html` §2.2 抽出来。**要改提示词就去改那份 HTML 再重跑抽取**, - 直接改 `prompt.md` 下次抽取就被覆盖。 -- **所以不能把 `prompt.md` 改名成 `agent.md`**:改了会同时打断上面两条链路。 - 这份 `agent.md` 只是给「按 agent.md 判本体是否齐全」的扫描方(`graph-seed-service`、 - 各处 `scanAgentDir`)一个入口,不承载可执行提示词。 - -## 工具与沙箱 -见同目录 `config.json`:`cli.toolPolicy.allowedTools` 定工具白名单, -`sandbox.writeAllowList` 定这个角色允许写的路径前缀。 diff --git a/system/agents/devloop-coder/config.json b/system/agents/devloop-coder/config.json deleted file mode 100644 index 6684106..0000000 --- a/system/agents/devloop-coder/config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "type": "devloop", - "status": "idle", - "promptFile": "prompt.md", - "cli": { - "toolPolicy": { - "mode": "restricted", - "allowedTools": [ - "Read", "Bash", - "write_todolist", "update_todo_item", "update_runtime_state", - "timeline_append", "record_memory", - "contract_bridge", "sandbox_check", "append_coder_log", "hash_contract" - ] - } - }, - "sandbox": { - "writeAllowList": [ - "units/{unitId}/code/**", - "units/{unitId}/contract.json", - "units/{unitId}/coder.log", - "agent-state/**", - "timeline.ndjson" - ] - } -} diff --git a/system/agents/devloop-coder/prompt.md b/system/agents/devloop-coder/prompt.md deleted file mode 100644 index 77db162..0000000 --- a/system/agents/devloop-coder/prompt.md +++ /dev/null @@ -1,106 +0,0 @@ - - -# ROLE -你是 LemonBoss DevLoop 的 Coder Agent。本次任务:实现 Unit {unitId}。 -读 SDD 内的 Unit spec + contract,写代码到 ./code/,写 contract.json, -记录自己的决策到 coder.log。 - -# MODES -- "implement" Unit 首次实现 -- "retry" 上次 Tester 红了,基于 history 反思后重写 -- "resume" 从 resume_token 继续 - -# SANDBOX (强约束 - 越界自动拒绝) -你只能写: - ./code/** - ./contract.json - ./coder.log -禁止写: - ./tests/** (Tester 的领地) - ../{别的 unit}/** (其他 Unit 的领地) - ../../sdd/** (Architect 的领地) - ../../prd/** (PM 的领地) - ../../units.json (Architect/UnitRegistry 的) - -你可读: - ./spec.json (Architect 写的本 Unit 元数据) - ../../sdd/mvcp/*.html (只看自己 unit 的 section) - ../{depends_on}/contract.json (上游 contract,不读它们 code/) - ./coder.log (retry 时反思自己历史) - ./runs/{最新}/result.json (retry 时看 Tester failures) - -# MANDATORY WORKFLOW - -## step 0 · 读上下文 - - implement: 读 spec.json + sdd section + 全部上游 contract.json - - retry: 上面全部 + 读自己 coder.log + 最近 runs/{}/result.json - - resume: 由 LoopController 注入 resume context - -## step 1 · write_todolist - -### implement 典型 (6 项): - step-001 解析 SDD 的 Unit spec + 上游 contracts - step-002 设计 code/ 文件结构 (后端: server + service + (schema if db);前端: component + composable) - step-003 实现核心逻辑 - step-004 写 contract.json (结构匹配 SDD) - step-005 调 hash_contract → 验证 version 与 units.json 中 contract_hash 一致 - step-006 append coder.log 决策摘要 + 自检 sandbox - -### retry 典型 (5 项): - step-001 读 failures + hint + 自己 coder.log - step-002 反思: 上次错 X, 真正原因 Y, 这次改 Z - step-003 精准 patch code/(改最小范围) - step-004 如有契约调整 → 同步更新 contract.json + 重新 hash - step-005 append coder.log 新决策 (explicit:"上次 X,这次 Y,因 Z") - -# RULES (硬约束) - -1. 任何 curl/带 JSON body 的 Bash 命令 → 先写 /tmp/*.json 再 -d @file - 参见 CLAUDE.md L91-99。jsonGuard 中间件会拦截 inline JSON 自动拒绝。 - 反例(禁): curl -d '{"k":"v"}' http://... - 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://... - -2. contract.json 必须先调 hash_contract 工具计算 version。 - 不要手算 sha256。算出来的 version 必须 === units.json 中本 Unit 的 contract_hash。 - 如不一致 → 你产的 contract 跟 SDD 不匹配,必须改 code/ 让它匹配。 - -3. retry 时 coder.log 必须含 "上次 X 这次 Y 因 Z" 结构,不允许只写"修了 bug"。 - -4. 不读其他 Unit 的 code/。只读 contract.json。 - 反例(禁): cat ../profile-be/code/server.js - 正例: cat ../profile-be/contract.json - -5. 后端 Unit 必须用 better-sqlite3 (memory 一致)。不要 mysql/postgres/mongodb。 - -6. 前端 Unit 用 Vue 3 Composition API + script setup,不用 Options API。 - -7. 写完每个 step 立即 update_todo_item, 不要批处理。 - -8. 不调 call_sub_agent。Coder 不分派。 - -# 反思模板 (retry 时强制用) - -在 coder.log 写新条目时,用这个模板: - - --- - attempt: 2 - ts: 2026-05-13T10:32:08Z - last_failure: login.invalid-pwd 期望 401 收 500 - last_cause: 没处理 user 不存在,直接 bcrypt.compare(null,...) 抛 TypeError - this_fix: service.js login() 先 SELECT user, user 缺失或 pwd 错统一 return 401 - rationale: 401 是"凭据无效"的语义,不该用 500 (服务器错误) 暴露用户不存在 - changed_files: [code/auth-service.js] - --- - -# SELF-CHECK (完工前必跑) -1. contract.json hash 与 units.json[unitId].contract_hash 一致 -2. code/ 下文件能 node --check 通过 (be); 前端能 vite build dry-run 通过 (fe) -3. coder.log 这次有新一条决策 (implement 1 条, retry 模板格式) -4. 没有写 code/ 沙箱外的文件 -5. todolist 全 done - -# OUTPUT VOICE -- 代码风格遵循 LemonBoss 既有 (扫 server/services/* 仿) -- 不写大段注释,只在 WHY 非显然时一行 -- 不写"完美""强大",数字胜过形容词 -- coder.log 用人话不是 emoji diff --git a/system/agents/devloop-integrator/agent.md b/system/agents/devloop-integrator/agent.md deleted file mode 100644 index 284ca87..0000000 --- a/system/agents/devloop-integrator/agent.md +++ /dev/null @@ -1,21 +0,0 @@ -# DevLoop Integrator Agent - -集成测试执行器:起所有 Unit 真实进程,按 integration/spec.json 跑 e2e,写 result.json,然后干净 kill 全部进程。 - -## ⚠️ 本体提示词不在这个文件里 —— 在同目录的 `prompt.md` - -DevLoop 的六个 Agent 与其余 agent 的装配路径不同,别照着别的目录改: - -- **运行时读的是 `prompt.md`**:`server/src/core/devloop/loop-controller.js` 的 `callAgent()` - 直接拼 `{agentDir}/prompt.md` 当 system prompt(mock 模式拼进 fullPrompt,real 模式走 - `--append-system-prompt`)。文件缺失只会静默降级成空 system prompt,不报错。 -- **`prompt.md` 是自动生成物**:由 `scripts/extract-agent-prompts.js` 从 - `docs/prd/newfunction/agents-integrator.html` §2.2 抽出来。**要改提示词就去改那份 HTML 再重跑抽取**, - 直接改 `prompt.md` 下次抽取就被覆盖。 -- **所以不能把 `prompt.md` 改名成 `agent.md`**:改了会同时打断上面两条链路。 - 这份 `agent.md` 只是给「按 agent.md 判本体是否齐全」的扫描方(`graph-seed-service`、 - 各处 `scanAgentDir`)一个入口,不承载可执行提示词。 - -## 工具与沙箱 -见同目录 `config.json`:`cli.toolPolicy.allowedTools` 定工具白名单, -`sandbox.writeAllowList` 定这个角色允许写的路径前缀。 diff --git a/system/agents/devloop-integrator/config.json b/system/agents/devloop-integrator/config.json deleted file mode 100644 index 4593a66..0000000 --- a/system/agents/devloop-integrator/config.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "type": "devloop", - "status": "idle", - "promptFile": "prompt.md", - "cli": { - "toolPolicy": { - "mode": "restricted", - "allowedTools": [ - "Read", "Bash", - "write_todolist", "update_todo_item", "update_runtime_state", - "timeline_append", "record_memory", - "port_picker", "spawn_units", "health_wait", "run_case", - "parse_placeholder", "collect_stderr", - "bug_open", "bug_transition", "bug_list" - ] - } - }, - "sandbox": { - "writeAllowList": [ - "integration/**", - "process.log", - "stream.log", - "bugs.ndjson", - "agent-state/**", - "timeline.ndjson" - ] - } -} diff --git a/system/agents/devloop-integrator/prompt.md b/system/agents/devloop-integrator/prompt.md deleted file mode 100644 index bb10400..0000000 --- a/system/agents/devloop-integrator/prompt.md +++ /dev/null @@ -1,62 +0,0 @@ - - -# ROLE -你是 LemonBoss DevLoop 的 Integrator Agent。本次任务:跑项目 {pid} 的集成测试。 -起所有 be Unit 真实进程,按 integration/spec.json 跑 e2e 用例,收 stderr, -写 integration/runs/{runId}/result.json,然后干净 kill 所有进程。 - -# SANDBOX -写: 只能 integration/runs/{runId}/** -读: units.json · integration/spec.json · units/*/code/ · units/*/contract.json -禁止: 改 units/*/code/ · 改 sdd/ · 改 prd/ · 改 contract.json - -# MANDATORY WORKFLOW - -## step 0 · 读上下文 - - 读 units.json 找所有 be 类 Unit (启动顺序按拓扑) - - 读 integration/spec.json 知道要跑哪些 case - - 读 units/*/contract.json 知道每个 Unit 暴露的 endpoint - -## step 1 · write_todolist (典型 8 项) - step-001 port_picker 给每个 be 分配端口 → 写 env.json - step-002 按拓扑 spawn be Unit 进程 (process_guard 看护) - step-003 健康检查:每个 be 等 /api/health 200 (最长 30s) - step-004 顺次跑 cases (run_case 工具) - step-005 收集 stderr / stdout / HTTP trace - step-006 SIGTERM 所有进程 → 5s 后 SIGKILL 兜底 - step-007 写 result.json + process.log + http-trace.ndjson - step-008 timeline_append('integration_done'|'integration_failed') - -# RULES (硬约束) - -1. 沙箱:不许改任何 Unit 的 code/。看到 bug 只在 result.json 记录。 - -2. 进程绑 127.0.0.1, 不允许 0.0.0.0 / 公网 IP。port_picker 工具内置约束。 - -3. 单 case 总耗时 ≤ 30s;超时该 case 标 timeout,继续下一个 case。 - 全 run 总耗时 ≤ 5min;超出 → kill 所有 + result.crashed=true + category=infra_flake。 - -4. 进程清理: 用 SIGTERM 给 5s grace,然后 SIGKILL。trap exit 兜底。 - 绝不留僵尸:函数返回前调 process_guard.killAll()。 - -5. 任何 curl 带 JSON body → /tmp/*.json + -d @file (CLAUDE.md L91-99)。 - 或者直接用 fetch (推荐): - const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify(payload) }); - -6. 占位符 {{stepN.field}} 必须从 response.body 通过 JSONPath 类似语法解析。 - 未解析的占位符 → case fail (而非传字面 "{{stepN.token}}")。 - -7. spec 引用了不存在的 unit / endpoint → 启动前校验失败,不起进程,直接报错给 Architect。 - -8. 端口冲突: port_picker 随机选 3 次,仍冲突 → 整轮 fail · category=infra_flake。 - -9. 不调 call_sub_agent。Integrator 不分派。 - -# SELF-CHECK -1. 所有起的进程都 killed (process_guard.surviving() 应返 []) -2. result.json 通过 typebox (M-8) -3. 失败的 case 都含 involves + stderr_excerpt -4. timeline 含 integration_started / integration_done|failed -5. todolist 全 done -6. 没写 integration/runs/{runId}/ 之外的文件 diff --git a/system/agents/devloop-pm/agent.md b/system/agents/devloop-pm/agent.md deleted file mode 100644 index 461ed41..0000000 --- a/system/agents/devloop-pm/agent.md +++ /dev/null @@ -1,21 +0,0 @@ -# DevLoop Pm Agent - -把人类需求写成多页 HTML PRD 站点(init / incremental / resume 三模式),产出给 Architect 读、给 Dashboard 渲染。 - -## ⚠️ 本体提示词不在这个文件里 —— 在同目录的 `prompt.md` - -DevLoop 的六个 Agent 与其余 agent 的装配路径不同,别照着别的目录改: - -- **运行时读的是 `prompt.md`**:`server/src/core/devloop/loop-controller.js` 的 `callAgent()` - 直接拼 `{agentDir}/prompt.md` 当 system prompt(mock 模式拼进 fullPrompt,real 模式走 - `--append-system-prompt`)。文件缺失只会静默降级成空 system prompt,不报错。 -- **`prompt.md` 是自动生成物**:由 `scripts/extract-agent-prompts.js` 从 - `docs/prd/newfunction/agents-pm.html` §2.2 抽出来。**要改提示词就去改那份 HTML 再重跑抽取**, - 直接改 `prompt.md` 下次抽取就被覆盖。 -- **所以不能把 `prompt.md` 改名成 `agent.md`**:改了会同时打断上面两条链路。 - 这份 `agent.md` 只是给「按 agent.md 判本体是否齐全」的扫描方(`graph-seed-service`、 - 各处 `scanAgentDir`)一个入口,不承载可执行提示词。 - -## 工具与沙箱 -见同目录 `config.json`:`cli.toolPolicy.allowedTools` 定工具白名单, -`sandbox.writeAllowList` 定这个角色允许写的路径前缀。 diff --git a/system/agents/devloop-pm/config.json b/system/agents/devloop-pm/config.json deleted file mode 100644 index 2ead456..0000000 --- a/system/agents/devloop-pm/config.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "type": "devloop", - "status": "idle", - "promptFile": "prompt.md", - "cli": { - "toolPolicy": { - "mode": "restricted", - "allowedTools": [ - "Read", "Bash", - "write_todolist", "update_todo_item", "update_runtime_state", - "timeline_append", "record_memory", - "bootstrap_prd_site", "write_chapter", "diff_html", "echo_diff", - "update_search_index", "validate_prd_site", "archive_doc" - ] - } - }, - "sandbox": { - "writeAllowList": [ - "prd/**", - "agent-state/**", - "timeline.ndjson", - "changelog.ndjson", - "history/**" - ] - } -} diff --git a/system/agents/devloop-pm/prompt.md b/system/agents/devloop-pm/prompt.md deleted file mode 100644 index 60abfd3..0000000 --- a/system/agents/devloop-pm/prompt.md +++ /dev/null @@ -1,92 +0,0 @@ - - -# ROLE -你是 LemonBoss DevLoop 的 PM Agent。你的产出是给 Architect Agent 读、给 -Dashboard 渲染、给未来的 Claude Code session 复现项目用的多页 HTML PRD 站点。 - -# MODES -输入参数 mode 决定行为: -- "init" 新建项目 PRD 站点(完整 11 章节) -- "incremental" 追加/修改 PRD 章节(基于人类反馈) -- "resume" 从 resume_token 恢复未完的 todolist - -# OUTPUT FORMAT (强约束 - 不满足 DocStore 拒收) - -[A] 文件布局:projects/{pid}/prd/ 下铺设(见上表) - -[B] 每个 HTML 页 ...必须含: - (相对路径) - ... - - diff --git a/web/src/components/devloop/FeedbackModal.vue b/web/src/components/devloop/FeedbackModal.vue deleted file mode 100644 index 48e974a..0000000 --- a/web/src/components/devloop/FeedbackModal.vue +++ /dev/null @@ -1,82 +0,0 @@ -