# Project Root · 项目根目录是什么 · 在哪建 > DevLoop 的**所有产物**(PRD 站点 · SDD 站点 · units/code/ · tests · runs · timeline · bugs · changelog · .git)都围绕一个 **project root** 组织。本页定义这个根是什么、如何创建、三种模式(sandbox / linked-new / linked-existing)、三种入口(REST / CLI / UI),以及各组件如何引用它而不硬编码路径。 ## ① 「项目」的定义 一个 **DevLoop 项目** = 一个目录 + 一个 `project.json` + 一棵 git 历史。这个目录就是 **project root**,是整个 DevLoop 流程的**唯一锚点**。 所有路径都从 root 派生: ``` {project_root}/ # 这就是「根」 ├── .git/ # GitVault init ├── .gitignore ├── project.json # M-1 元数据 + root_path 自身 ├── prd/ # PM 写 ├── sdd/ # Architect 写 ├── units.json ├── units/{unitId}/ # Coder/Tester 写 ├── integration/ # Integrator/Triager 写 ├── agent-state/ # P-8 持久化 ├── timeline.ndjson · bugs.ndjson · changelog.ndjson ├── stream.log └── history/ # 归档 (.gitignore) ``` 所有 Agent、所有工具、LoopController、GitVault 都按 `{project_root}/...` 找文件。**没有任何代码硬编码 `data/companies/...`**。这是关键设计。 ## ② 三种项目模式 | mode | root_path | git repo | v1 支持 | 典型场景 | | --- | --- | --- | --- | --- | | **sandbox**
(默认) | 自动生成:`{server_data_dir}/companies/{cid}/projects/{pid}/` | 自动 git init | ✅ v1 | 不确定代码归宿 / 内部 demo / 学习 / 隔离实验 | | **linked-new** | 用户指定空目录或不存在的目录,比如 `~/code/auto-todo` | 自动 git init 到该路径 | ✅ v1 | 已知代码最终归宿;想生成就直接落盘那里;之后可 git push | | **linked-existing** | 用户指定已有 git repo 的子目录,比如 `~/code/my-app/services/auth/` | 复用上级 git repo | ⚠️ v2 | 给已存在的项目**增量**加新模块;冲突风险高,需要 git stash 等保护 | **v1 推荐:sandbox 是默认。**linked-new 当作 power user 选项 — 它给你「自动 push 到 GitHub」的可能性,但也要承担「DevLoop 误覆盖了你 ~/code/ 下别的东西」的风险(沙箱机制阻止越界,但 user-chosen path 越界相对沙箱更难定义)。 ## ③ project.json 加 root_path 字段 [M-1 ProjectMeta](sdd-model.html) 现在含 root_path(绝对路径)和 mode: ```ts const ProjectMeta = Type.Object({ id: Type.String({ pattern: '^p-[a-z0-9]{6}$' }), company_id: Type.String(), name: Type.String(), // 新增字段 mode: Type.Union([ Type.Literal('sandbox'), Type.Literal('linked-new'), Type.Literal('linked-existing'), ]), root_path: Type.String(), // 绝对路径,所有派生路径的源 status: Type.Union([ Type.Literal('prd_drafting'), Type.Literal('running'), Type.Literal('blocked'), Type.Literal('shipped'), Type.Literal('shipped-final'), Type.Literal('abandoned'), ]), ... }); ``` 所有组件读 path 都走 `resolveProjectPath(pid) → project.root_path`,而不是拼 `data/companies/.../`。 ## ④ 路径解析服务(新组件) · `server/services/path-resolver.js · 新增,~80 行` ```ts module.exports.pathResolver = { // 单例索引: pid → root_path // 在 LoopController 启动时 walk(data/companies/) 建立 // 加: linked 项目通过 ~/.devloop/linked-projects.json 维护索引 init(): Promise, resolveProjectRoot(pid: string): string, // 绝对路径,unknown pid 抛错 resolveUnitDir(pid, unitId): string, resolvePrdSite(pid): string, resolveSddSite(pid): string, resolveAgentState(pid, agent): string, resolveMemoryDir(pid, agent): string, // 注意:memory 总在 company 级,不在 root 下 resolveTimelineLog(pid): string, ... // 反向 findProjectByPath(abs: string): { pid, root } | null, // linked 项目注册 registerLinkedProject({ pid, root_path, mode }): Promise, unregisterLinkedProject(pid): Promise, }; ``` **关键:**所有用 `projectsDir(pid)` 的地方改成 `pathResolver.resolveProjectRoot(pid)`。这样切换 mode 是**路径透明**的。Agent 的 `ctx.projectDir` 注入也走这个。 linked 项目索引 `~/.devloop/linked-projects.json` 形如: ```js { "p-731042": { "root_path": "/Users/yibo/code/auto-todo", "mode": "linked-new", "company_id": "c-lemonv", "registered_at": "2026-05-13T11:31:00Z" } } ``` sandbox 项目**不需要**注册(按惯例从 `data/companies/.../projects/` 扫描即可)。 ## ⑤ 三个创建入口 ### 5.1 REST · 主入口 ```ts POST /api/devloop/projects { "company_id": "c-lemonv", "name": "Auto TODO", "requirement": "做一个 TODO API + Vue 前端", // 新增 - 可选,默认 mode=sandbox "mode": "sandbox" | "linked-new" | "linked-existing", "target_path": "/Users/yibo/code/auto-todo" // linked-* 时必填 } // 响应 { "project_id": "p-731042", "mode": "linked-new", "root_path": "/Users/yibo/code/auto-todo", "status": "prd_drafting", "stream_url": "/api/devloop/projects/p-731042/stream" } ``` ### 5.2 CLI · 命令行 ```js # 文件: scripts/devloop-new.js (NEW, ~80 行) # sandbox 模式 (默认) node scripts/devloop-new.js \ --name "Auto TODO" \ --requirement "做一个 TODO API + Vue 前端" # 等价于 mode=sandbox, root 自动到 data/.../projects/p-XXXXXX/ # 输出: 🍋 Project p-731042 created (sandbox) # Root: /Users/yibo/Documents/lemonAI/lemonboss/data/companies/c-default/projects/p-731042 # linked-new 模式 node scripts/devloop-new.js \ --path ~/code/auto-todo \ --name "Auto TODO" \ --requirement "做一个 TODO API + Vue 前端" # 输出: 🍋 Project p-731042 created (linked-new) # Root: /Users/yibo/code/auto-todo # git init done # PM Agent dispatched, follow at http://localhost:3010/devloop/p-731042 # 也支持 stdin 多行需求 (long requirement) cat my-spec.md | node scripts/devloop-new.js --path ~/code/x --name "X" --requirement - # 列出现有项目 node scripts/devloop-new.js --list # 输出: # p-731042 Auto TODO linked-new ~/code/auto-todo running # p-845001 Auth System sandbox data/.../p-845001 shipped ``` ### 5.3 UI · Dashboard 的「+ NEW PROJECT」 localhost:5173/devloop · "+ NEW PROJECT" clicked 选 linked-new 时弹出 file picker 让用户选目录(或手填路径)。选 sandbox 时不显示 path 输入。 ## ⑥ 创建流程 · 三模式各自 step-by-step ```ts // LoopController.createProject({ company_id, name, requirement }) step 1 · 生成 pid = "p-" + randomBase36(6) step 2 · root_path = `${dataDir}/companies/${company_id}/projects/${pid}/` step 3 · fs.mkdirSync(root_path, { recursive: true }) step 4 · 写 project.json (含 root_path · mode='sandbox') step 5 · GitVault.init(pid) → 在 root_path 起 .git/ step 6 · 写 .gitignore 模板 → 首个 commit "init: project ${pid} · ${name}" step 7 · pathResolver 不需要 register (按 data/ 路径约定可扫到) step 8 · LoopController.onHumanFeedback(pid, { text: requirement }) → PM Agent 异步启动 // 全程在 server 进程内,无文件系统外部依赖 ``` ```ts // 同上,但 root_path 来自用户 step 1 · 校验 target_path: - 必须是绝对路径 - 必须不存在,或者存在但是空目录 - 必须不在系统敏感目录 (e.g. /, /etc, /System) - 必须可写 (fs.accessSync check) - 如果父目录不存在 → 拒绝 (不递归创建,避免误操作) step 2 · 生成 pid (同上) step 3 · root_path = target_path (绝对路径,resolve('~') if any) step 4 · fs.mkdirSync(root_path, { recursive: false }) // 必须显式空 step 5 · 写 project.json step 6 · GitVault.init(pid) → git init 在用户路径 step 7 · 写 .gitignore 模板 → 首个 commit step 8 · pathResolver.registerLinkedProject({ pid, root_path, mode }) → 写 ~/.devloop/linked-projects.json step 9 · onHumanFeedback → PM 启动 // 与 sandbox 区别仅在 root_path 来源和 register 步骤 ``` ```ts // v2 (v1 拒绝接受 linked-existing) step 1 · 校验 target_path 已存在 + 是 git repo (含 .git) step 2 · git status 必须 clean (否则要求用户先 commit/stash) step 3 · 在 target_path 下创建子目录 (如 services/{pid}/ 或用户指定) step 4 · 写 project.json 到该子目录 step 5 · GitVault 不 init (复用上级 repo) step 6 · 追加 .gitignore 条目 (sandbox-specific exclusions 到上级 .gitignore) step 7 · pathResolver.registerLinkedProject(mode='linked-existing') step 8 · 创建 feature branch: git checkout -b devloop/${pid} step 9 · 后续 commit 都在该 branch step 10 · shipped 时不自动合 main,等人 review + merge // 风险点:用户其他工具(IDE/lint) 同时改文件 → 冲突 # v2 才做,因为对边界 case 处理复杂 ``` ## ⑦ 各组件如何引用 root_path 关键改造:**没有任何地方**硬编码 `data/companies/...`。一切走 pathResolver。 | 原来(硬编码) | 改成(path-resolver) | | --- | --- | | `path.join(dataDir, 'companies', cid, 'projects', pid)` | `pathResolver.resolveProjectRoot(pid)` | | `projectsDir(pid)` helper | delegate 到 pathResolver | | Agent ctx.projectDir 注入 | 由 callAgentWithPersistence 用 pathResolver 计算 | | data-layout.html 的目录示意 | 明确写 `{project_root}/` 而非 `data/companies/.../projects/{pid}/` | | GitVault 内部 cwd | 每次操作前 `pathResolver.resolveProjectRoot(pid)` | | Bash 工具的沙箱 cwd 锁 | 同上 | > _这是 P0 / P1 阶段必须的**抽象化重构**,否则 linked 模式没法实现。代价不大(~30 行改动散在 5-6 处)。_ ## ⑧ memory · 始终在 company 级 注意:**memory 不跟 project root 移动**。memory 是 company 范围长期记忆,所以它永远在: ``` {server_data_dir}/companies/{cid}/memory/devloop/ ├── pm/*.md ├── architect/*.md └── ... ``` 哪怕 project 在 ~/code/auto-todo 下,memory 仍在 server 的 data/ 内。这样 linked 项目也能享受跨项目记忆,且 memory 不会跟着 git push 出去(隐私)。 ## ⑨ 跨主机 / 跨机器场景 linked-new 模式启用后,**项目根可以跨机器**: - 主机 A 上 DevLoop 创建项目,root = `~/code/x` - `git push origin main` 推到 GitHub - 主机 B `git clone` 到任意位置 - 主机 B 上重启 DevLoop server,**不会识别这个项目**(pathResolver 索引在 A 上) - 主机 B 用 `node scripts/devloop-import.js --path ~/work/x` 重新注册(NEW,v2 工具) > _memory 不跨机器(属 company 数据);timeline / bugs / changelog 跟着 git 走(tracked);agent-state 不跟(.gitignore)— 这意味着**对端续跑不能恢复 paused agents**,要从干净状态重启。可接受。_ ## ⑩ 创建后 LoopController 接管的统一接口 不管哪种模式,创建后 LoopController 的视角**无差异**: ```ts // 任何 mode 都走这个接口 loopController.onHumanFeedback(pid, { text, origin }); // LoopController 内部: // 1. pathResolver.resolveProjectRoot(pid) → 拿到根路径(不关心是 sandbox 还是 linked) // 2. 注入 ctx 给 Agent: ctx.projectDir = root // 3. Agent 通过 Write 工具落盘,沙箱白名单基于 root + 子路径(写 prd/** 等) // 4. GitVault.commit({ pid, ... }) → 自动在 root 上 git 操作 ``` 这就是**路径透明**的好处:业务代码无 mode 判断分支。 ## ⑪ project.json 完整示例 ```js // sandbox 项目 { "id": "p-731042", "company_id": "c-lemonv", "name": "Auto TODO", "mode": "sandbox", "root_path": "/Users/yibo/Documents/lemonAI/lemonboss/data/companies/c-lemonv/projects/p-731042", "status": "running", "created_at": "2026-05-13T10:31:00Z", "updated_at": "2026-05-13T11:02:00Z", "owner": "yibo", "token_budget": 5000000, "token_used": 42018, "compressOldRuns": false } // linked-new 项目 { "id": "p-845001", "company_id": "c-lemonv", "name": "Auto Auth System", "mode": "linked-new", "root_path": "/Users/yibo/code/auto-auth", // 用户选的 "status": "running", ... } ``` ## ⑫ 路径白名单(沙箱安全) linked 模式给了用户灵活性,但也带来风险。沙箱必须严格: | 规则 | 实现 | | --- | --- | | 拒绝 root_path = 系统敏感路径 | 黑名单:/, /etc, /System, /usr, /var, /Library, /Applications, $HOME/.ssh, $HOME/.aws, $HOME/Downloads(防误操作) | | 拒绝 root_path 不可写 | fs.accessSync(W_OK) 检查 | | 拒绝 root_path 已存在且非空(linked-new) | fs.readdirSync 检查 | | Agent 写白名单走 root 相对路径 | writeAllowList: ["prd/**", "sdd/**", "units/**", ...] · resolve 后必须以 root_path 开头 | | 跨项目读写 | 禁止:任何 ../{别的项目} 路径直接拒绝 | | memory 跨项目读 | 白名单例外:memory/devloop/ 在 company 级允许只读 | ## ⑬ 实战命令 ```js # 启动 server PORT=3010 node server/index.js # 方式 A · REST sandbox echo '{"company_id":"c-lemonv","name":"todo-demo","requirement":"做一个 TODO API"}' > /tmp/req.json curl -X POST http://localhost:3010/api/devloop/projects -d @/tmp/req.json # → { "project_id": "p-731042", "mode": "sandbox", "root_path": "/.../data/companies/c-lemonv/projects/p-731042" } # 方式 B · REST linked-new echo '{"company_id":"c-lemonv","name":"Auto TODO","requirement":"做一个 TODO API","mode":"linked-new","target_path":"/Users/yibo/code/auto-todo"}' > /tmp/req.json curl -X POST http://localhost:3010/api/devloop/projects -d @/tmp/req.json # → { "project_id": "p-845001", "mode": "linked-new", "root_path": "/Users/yibo/code/auto-todo" } # 在 ~/code/auto-todo 下立即看到 .git · .gitignore · project.json · prd/ 等开始生成 # 方式 C · CLI node scripts/devloop-new.js \ --path ~/code/auto-todo \ --name "Auto TODO" \ --requirement "做一个 TODO API" # 方式 D · UI (假设 frontend 启动) # 浏览器: http://localhost:5173/devloop · 点 "+ NEW PROJECT" · 填表 · 提交 # 列出现有项目 (跨 sandbox + linked) node scripts/devloop-new.js --list curl http://localhost:3010/api/devloop/projects # → [{pid, name, mode, root_path, status}, ...] # 删除项目 (linked 模式不删用户目录,只 unregister + 移除 sandbox 索引) curl -X DELETE http://localhost:3010/api/devloop/projects/p-731042 # sandbox: 整 data/.../projects/p-XXXXXX/ 删除 # linked: 只从 ~/.devloop/linked-projects.json unregister,用户目录保留 ``` ## ⑭ 工时增量 | 任务 | 工时 | 归属阶段 | | --- | --- | --- | | pathResolver 实现 + 写硬编码迁移 | 1.5 天 | P0 (前置,后续 GitVault/工具都依赖) | | project.json schema 加 mode + root_path | 0.5 天 | P0 | | POST /projects 加 mode + target_path 参数 | 0.5 天 | P1 | | scripts/devloop-new.js CLI | 1 天 | P1 | | scripts/devloop-import.js (v2) | — | v2 | | UI 新建项目 modal | 1 天 | P4 | | **合计 v1** | **4.5 天** | 分摊到 P0/P1/P4 | > _P0 时间预算从 13.5 天 → 15.5 天(+2 天 path-resolver + schema),P1 从 5 天 → 6.5 天(+1.5 天 mode 参数 + CLI)。总工时 10 周 → 10.5 周。_ ## ⑮ 关联 - [M-1 ProjectMeta](sdd-model.html) · 加 mode + root_path 字段 - [C-1 REST.createProject](sdd-control.html) · 接受 mode + target_path - [Git Versioning](arch-git-versioning.html) · GitVault 用 root_path 当 cwd - [data-layout](data-layout.html) · 用 {project_root}/ 而非硬编码 - [代码归宿](plan-code-lifecycle.html) · linked-new 让代码直接落到目标位置