# 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 让代码直接落到目标位置