05 · ARCHITECTURE/Project Root

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/...。这是关键设计。

② 三种项目模式

moderoot_pathgit repov1 支持典型场景
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 现在含 root_path(绝对路径)和 mode:

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 行
module.exports.pathResolver = {
  // 单例索引: pid → root_path
  // 在 LoopController 启动时 walk(data/companies/) 建立
  // 加: linked 项目通过 ~/.devloop/linked-projects.json 维护索引

  init(): Promise<void>,

  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<void>,
  unregisterLinkedProject(pid): Promise<void>,
};

关键:所有用 projectsDir(pid) 的地方改成 pathResolver.resolveProjectRoot(pid)。这样切换 mode 是路径透明的。Agent 的 ctx.projectDir 注入也走这个。

linked 项目索引 ~/.devloop/linked-projects.json 形如:

{
  "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 · 主入口

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 · 命令行

# 文件: 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
新建项目 L3 · HUMAN · INIT 项目名 Auto TODO 创建模式 ● sandbox (默认) ○ linked-new ○ linked-existing (v2) target path: 自动分配到 data/.../projects/p-XXXXXX/ 需求描述 做一个 TODO API + 简单 Vue 前端 能新增、列出。 25 / 4000 字 · Ctrl+Enter 提交 取消 创建项目

选 linked-new 时弹出 file picker 让用户选目录(或手填路径)。选 sandbox 时不显示 path 输入。

⑥ 创建流程 · 三模式各自 step-by-step

// 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 进程内,无文件系统外部依赖

⑦ 各组件如何引用 root_path

关键改造:没有任何地方硬编码 data/companies/...。一切走 pathResolver。

原来(硬编码)改成(path-resolver)
path.join(dataDir, 'companies', cid, 'projects', pid)pathResolver.resolveProjectRoot(pid)
projectsDir(pid) helperdelegate 到 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 的视角无差异:

// 任何 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 完整示例

// 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 级允许只读

⑬ 实战命令

# 启动 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_path0.5 天P0
POST /projects 加 mode + target_path 参数0.5 天P1
scripts/devloop-new.js CLI1 天P1
scripts/devloop-import.js (v2)—v2
UI 新建项目 modal1 天P4
合计 v14.5 天分摊到 P0/P1/P4

P0 时间预算从 13.5 天 → 15.5 天(+2 天 path-resolver + schema),P1 从 5 天 → 6.5 天(+1.5 天 mode 参数 + CLI)。总工时 10 周 → 10.5 周。

⑮ 关联