# Git Versioning · 每次 Agent 完工 = 一个 commit > 把每个 DevLoop 项目变成**独立 git repo**,每个 Agent 成功完成工作 → LoopController 自动 commit。
项目结束 → 打 tag。这一层补上文档之前缺的**真版本管理**——崩了能 reset,丢了能 archive,3 次 retry 能 diff。 ## ① 现状 vs 提议 #### 之前(无 git) - 文件 mtime 是唯一时间戳 - history/ 目录是 ad-hoc 归档 - 没有 atomic snapshot - 回滚靠 fs.rename + 手动拷贝 - retry 之间无法 diff(旧文件被覆盖) - 项目导出 = 拷整个目录 #### 提议(每项目一个 repo) - 每个 Agent 完工 → `git commit` - shipped → `git tag` - 整个 retry 链是 git log(可 diff 任意两次) - 崩 → `git reset` 干净恢复 - 导出 → `git archive` - 未来可加 `git push` 到 GitHub(v2) ## ② Repo 范围决策 | 方案 | 位置 | 优 | 劣 | | --- | --- | --- | --- | | **A · 每项目一个** ← **选这个** | `data/companies/{cid}/projects/{pid}/.git` | 隔离强 · 删项目就删 repo · 导出干净 · 可分别 push 不同 remote | repo 数量随项目增长(每个 ~10-50MB) | | B · 一个公司一个 | `data/companies/{cid}/.git` | 跨项目共享对象 | 跨项目混在 git log;不易导出单项目 | | C · 全局一个 | `data/.git` | 对象去重最大 | 所有 company 混;权限灾难 | | D · 主仓库 | lemonboss/.git | 不增 repo | 污染 LemonBoss 主提交历史;不可接受 | 选 A 的核心理由:DevLoop 项目本质是**临时工作空间**,shipped 后或归档或导出,独立 repo 让生命周期边界清晰。 ## ③ 落盘后的目录 ``` data/companies/{cid}/projects/{pid}/ ├── .git/ # NEW · 整个项目的 git 历史 ├── .gitignore # NEW · 项目创建时初始化 ├── project.json ├── prd/ → tracked ├── sdd/ → tracked ├── units.json → tracked ├── integration/spec.json → tracked ├── timeline.ndjson → tracked (append-only,git OK) ├── bugs.ndjson → tracked ├── changelog.ndjson → tracked ├── units/ → tracked (code + tests + contract) ├── integration/runs/ → tracked (result.json + triage.json) ├── agent-state/ → **.gitignore** (高频写,运行态) ├── history/ → **.gitignore** (git 本身就是 history) └── stream.log → **.gitignore** (SSE 流,7 天滚动) ``` ## ④ .gitignore 模板 ```js # LemonBoss DevLoop 项目 .gitignore (项目创建时落盘) # 高频写的运行态,不需要版本 agent-state/ # 真冗余 (git 本身就是 history) history/ # 大日志 stream.log units/*/runs/*/stdout.txt integration/runs/*/process.log integration/runs/*/http-trace.ndjson.gz # Node / 依赖 node_modules/ *.log .DS_Store .cache/ .tmp/ .coverage/ # 测试中间文件 units/*/.vitest-cache/ # 但保留: result.json triage.json contract.json 等小 JSON ``` > _由 GitVault.init 工具在 `git init` 后立即写入;首个 commit 就含 .gitignore。_ ## ⑤ Commit 时机表 | 事件 | commit msg 模板 | 触发位置 | files added | | --- | --- | --- | --- | | 项目创建 | `init: project {pid} · {requirement 摘要 30 字}` | LoopController.createProject 末尾 | project.json + .gitignore | | PM 写完 PRD | `prd: rev {N} · {change_summary}` | PM Agent 成功返回后 | prd/** + changelog.ndjson + timeline 增量 | | Architect 生成 SDD | `sdd: rev {N} · {units_count} units · {summary}` | Architect generate 成功后 | sdd/** + units.json + integration/spec.json + changelog/timeline | | Architect patch | `sdd: patch · {affected_units 列表}` | Architect patch 成功后 | 仅改的 sdd/mvcp/{x}.html + units.json (hash 更新) | | Coder 实现 | `code({unit_id}): implement · attempt {N}` | Coder 成功 + Tester 绿才 commit | units/{id}/code/** + contract.json + coder.log | | Coder retry 通过 | `code({unit_id}): retry → green · attempt {N}` | Tester 绿(attempt > 1) | 同上 | | Tester 写测试 | **合并到上一条 commit** | 同 Coder commit 一起 | tests/ + runs/{}/result.json 一起 | | BugTriager 归因 | `triage({run_id}): {category} · confidence {n}` | Triager 写完 triage.json 后 | integration/runs/{}/triage.json + bugs.ndjson 增量 | | Integrator 跑 | `integ({run_id}): {ok|fail} · {cases_passed}/{cases_total}` | Integrator 完工 | integration/runs/{}/result.json + process.log(如不在 ignore) | | Unit reset (L2 修复后) | **不 commit**,下一次 Coder commit 时一起 | — | — | | 项目 shipped | `ship: prd@{prd_rev} sdd@{sdd_rev}`
+ `git tag shipped-{rev}` | LoopController.shipProject | —(tag only) | | 项目 sign-off | `chore: sign-off by {user_id}`
+ `git tag final-{rev}` | POST /sign-off | project.json (status=shipped-final) | | 项目 abandon | `chore: abandon · {reason}`
+ `git tag abandoned` | POST /abandon | project.json (status=abandoned) | > _关键决策:**失败不 commit**(避免噪音 + 失败的代码不该留在 git 主线)。Tester 红 → Coder retry 时旧代码被工作区覆盖,反正没人需要回到失败态。_ ## ⑥ 谁调 git · 集中控制 关键原则:**Agent 不动 git**。LoopController 在 callAgentWithPersistence 的 success 分支统一调 GitVault.commit。 这样 Agent 沙箱继续严格(不允许 Bash 调 git),但项目状态**真实有版本**。 ## ⑦ GitVault · C-18 服务方法 · `server/services/git-vault.js` ```ts module.exports.gitVault = { // 初始化(项目创建时) init(pid: string, opts?: { name?: string, email?: string, // 默认 "DevLoop " initialBranch?: string, // 默认 "main" }): Promise<{ ok: boolean, head: string }>, // 主提交入口 (LoopController 调) commit(pid: string, { kind: 'init' | 'prd' | 'sdd' | 'code' | 'triage' | 'integ' | 'ship' | 'chore', unit_id?: string, // code 时填 run_id?: string, // triage / integ 时填 summary: string, // 简短描述 files?: string[], // 限定 add 的路径 (默认 git add -A 受 .gitignore) author?: { name, email }, // 哪个 Agent 干的,记到 commit author skipEmpty?: boolean, // nothing-to-commit 时是否抛错,默认 false }): Promise<{ ok, sha?: string, skipped?: boolean }>, // 打 tag tag(pid, tagName: string, opts?: { message?, sha? }): Promise<{ ok, tag }>, // 查询 log(pid, opts?: { limit?, since?, grep? }): Promise, diff(pid, refA: string, refB: string, opts?: { path? }): Promise, // unified diff text show(pid, sha: string): Promise, listTags(pid): Promise, // 写回滚 (P-8 recovery 用) resetWorkingTree(pid, ref = 'HEAD'): Promise<{ ok }>, // 抛弃未 commit 的脏文件 revert(pid, sha: string): Promise<{ ok, newSha }>, // 反向应用 // 导出 archive(pid, ref = 'HEAD', format = 'zip'): Promise<{ path: string }>, // 跑 git archive // v2 留口 addRemote?(pid, name, url), push?(pid, remote = 'origin'), }; ``` - 实现可走 simple-git 包 或 spawn git;推荐 **spawn git**(零依赖,CLAUDE.md 强约束的 JSON 也不涉及) - 所有方法**串行化 per-pid**(避免同项目并发 git 操作打架) - commit author = Agent 名("PM Agent ")便于 git log 看哪个 Agent 干的 - 失败处理:git 子命令非零退出 → 抛错;nothing-to-commit 默认 skipEmpty=false 返 skipped=true,不抛 - tests: init 后 .git 存在 + .gitignore 已 commit - tests: commit({kind:'prd'}) 后 git log 有该 commit + author 是 PM Agent - tests: nothing-to-commit 不抛错 - tests: tag('shipped-v1') 后 git tag --list 含 - tests: diff(sha1, sha2) 返回非空 unified diff ## ⑧ P-10 · 自动提交流程 · `server/src/core/loop-controller.js · callAgentWithPersistence 的 success 分支扩展` ```ts // 在 P-8 callAgentWithPersistence success 分支末尾添加 // (LoopController 调,Agent 完全无感) async function commitAfterAgent(pid, name, result, input) { const spec = buildCommitSpec(name, result, input); if (!spec) return; // 某些 agent 不 commit (如 Integrator 失败时) const { ok, sha, skipped } = await gitVault.commit(pid, spec); if (ok && !skipped) { timelineLogger.append(pid, { source: 'system', kind: 'git_committed', ref: sha.slice(0, 7), summary: spec.summary, }); sseBroker.emit(pid, 'git.committed', { sha, kind: spec.kind, summary: spec.summary }); } } function buildCommitSpec(name, result, input) { if (name === 'pm') { return { kind: 'prd', summary: `prd: rev ${result.prd_rev} · ${result.change_summary}`, author: { name: 'PM Agent', email: 'pm@devloop.local' }, }; } if (name === 'architect') { const prefix = input.mode === 'patch' ? 'sdd: patch' : 'sdd: rev ' + result.sdd_rev; return { kind: 'sdd', summary: `${prefix} · ${result.units_count} units · ${result.change_summary}`, author: { name: 'Architect Agent', email: 'architect@devloop.local' }, }; } if (name === 'coder' || name === 'tester') { // Coder/Tester 合并 commit:Tester 绿后才 commit,把 code + tests 一起 if (name === 'coder') return null; // 等 Tester 绿 if (!result.ok) return null; // Tester 红不 commit const verb = result.attempt > 1 ? 'retry → green' : 'implement'; return { kind: 'code', unit_id: result.unit_id, summary: `code(${result.unit_id}): ${verb} · attempt ${result.attempt}`, author: { name: 'Coder Agent', email: 'coder@devloop.local' }, }; } if (name === 'integrator') { return { kind: 'integ', run_id: result.run_id, summary: `integ(${result.run_id.slice(-12)}): ${result.ok ? 'pass' : 'fail'} · ${result.cases_passed}/${result.cases_total}`, author: { name: 'Integrator Agent', email: 'integrator@devloop.local' }, }; } if (name === 'bug-triager') { return { kind: 'triage', run_id: input.run_id, summary: `triage(${input.run_id.slice(-12)}): ${result.category} · confidence ${result.confidence}`, author: { name: 'BugTriager Agent', email: 'triager@devloop.local' }, }; } return null; } ``` - Coder commit 推迟到 Tester 绿(避免红色代码进 main) - 所有 commit author 标 Agent 名,git log 可读「哪个 Agent 改了哪段」 - nothing-to-commit 是 ok(某些 retry 没改文件) - commit 失败 → 上报 LoopController · 不阻塞 Agent 返回(git 是 nice-to-have,不是阻塞依赖) ### 项目级 tag 时机 | 事件 | tag 名 | 触发 | | --- | --- | --- | | L2 通过 | `shipped-v{prd_rev}-{sdd_rev}` | LoopController.runProject return 'shipped' | | 人类 sign-off | `final-v{prd_rev}` | POST /sign-off | | abandon | `abandoned` | POST /abandon | | 每个 PRD rev | `prd-rev-{N}` | PM 完成且写完 changelog | | 每个 SDD rev | `sdd-rev-{N}` | Architect 完成 generate(不含 patch) | ## ⑨ 与 timeline / changelog / bugs 的关系 #### git log

真源:**文件层变化**。git diff 看代码 / 文档真实改了什么。
包括 author / sha / 时间。 #### timeline.ndjson 真源:**事件流**。含非文件改动(如 SSE / Agent 进出 / state transition)。比 git log 信息量更大。 #### changelog.ndjson 派生:**文档级摘要**。每条对应一次 PRD/SDD/units rev,与 git tag `prd-rev-N` / `sdd-rev-N` 一一对应。 三者互补不冲突。git log 和 timeline 各自**append-only**,drift 风险低(都由 LoopController 在 callAgent 末尾原子写)。 ## ⑩ 失败恢复 · 新能力 | 场景 | 没 git 时 | 有 git 时 | | --- | --- | --- | | Server crash | agent-state.paused,但代码可能**半写** | `git status` 看脏文件 · `git reset --hard HEAD` 一键恢复 | | Coder 3 次 retry 都红 | 只能看 coder.log 文字 | `git log --author='Coder'` 看 3 次 commit · `git diff` 看模型 3 次都改了啥 | | L2 通过后人类突然改主意 | 整个手动 hack 回退 | `git revert ship-v1` 即可 | | L3 反馈引入回归 | 需要重新跑全套 | `git bisect` 找到哪次 commit 引入了破坏 | | 项目导出 | `cp -r` 整个目录 30MB | `git archive HEAD` 干净打包,仅 tracked 文件 ~5MB | | 跨机器同步项目状态 | 不可能(带 .vitest-cache 等垃圾) | `git push` + 对端 `git clone` | ## ⑪ P-8 状态机的协同 P-8 持久化层与 git 不重叠: ```ts // Agent 调用结束的完整流程 (P-8 + P-10) async function callAgentWithPersistence(name, input) { // P-8 step 1: 写 runtime status=running await writeRuntimeState(pid, name, 'running'); // 实际调用 (Agent 通过工具写文件) const result = await agentManager.callAgent(...); // P-8 step 2: 写 runtime status=done await writeRuntimeState(pid, name, 'done'); // P-10 step 3: git commit (新增) if (result.ok) await commitAfterAgent(pid, name, result, input); // timeline / SSE / changelog 等照旧 ... } // recoverOnBoot 增加 git 清理: async function recoverOnBoot() { for (const pid of listAllProjects()) { // 既有: 扫 agent-state · 改 running → paused ... // 新增: 检查 git 工作区是否脏 const status = await gitVault.status(pid); if (status.dirty) { // 上次 crash 留下未提交脏文件 sseBroker.emit(pid, 'recovery_dirty_tree', { files: status.files, options: ['commit_as_wip', 'reset_hard', 'manual_inspect'], }); } } } ``` ## ⑫ 推 remote · v2 留口 v1 不做。但 GitVault API 已留 `addRemote` + `push`。v2 可加: ```js # 把项目代码推到 GitHub POST /api/devloop/projects/:pid/push { "remote": "origin", "url": "git@github.com:yibo/auto-todo.git", "branch": "main", "open_pr": false } # GitHub Actions 接管 CI/CD,DevLoop 只负责生产 + 提交 ``` v2 还可加 **git submodule 共享**:跨项目复用 utils → 抽 shared/ submodule。 ## ⑬ 安全考量 | 风险 | 缓解 | | --- | --- | | git push 凭据泄露(v2) | v1 不实现 push;v2 用 SSH key per company,不存 password | | Agent 越权调 git | Agent 的 toolPolicy 不含 git 工具,Bash 不允许 `git *` 命令(jsonGuard 类似的 cmdGuard) | | commit 含敏感数据 | 项目 .gitignore 排除 secrets/ · token.txt · *.env | | 仓库膨胀 | stream.log 大文件 .gitignore;定期 `git gc` | | 并发 commit race | GitVault 按 pid 串行化(mutex per pid) | ## ⑭ P0 工时增量 | P0 子项 | 新增工时 | | --- | --- | | GitVault 实现 (~150 行 spawn git wrapper) | 1 天 | | .gitignore 模板 + LoopController.createProject git init 集成 | 0.5 天 | | commitAfterAgent + buildCommitSpec | 0.5 天 | | recoverOnBoot 加 git status 检查 | 0.5 天 | | 验证: phase0-verify 加 git init + 一次 commit 验证 | 0.5 天 | | **P0 +3 天**(总 P0 14 天) | — | > _原 11 天 P0 → 14 天,总 9.5 周 → 10 周。代价相对收益(真版本管理)合理。_ ## ⑮ 实战命令 ```js # P0 后验证 git 集成 # 1. 创建项目应该自动 git init curl -X POST http://localhost:3010/api/devloop/projects -d @/tmp/create.json ls data/companies/c-test/projects/p-XXXXXX/.git/ # 期望: HEAD config objects refs/ 等 # 2. 跑完 PM 应该有 1 个 prd commit git -C data/companies/c-test/projects/p-XXXXXX log --oneline # 期望: 至少 2 行,init + prd: rev 1 # 3. 跑完 Architect # 期望: 多 1 行 sdd: rev 1 # 4. 跑完 Coder→Tester 绿后 # 期望: 多 1 行 code(todo-be): implement · attempt 1 # 5. L2 通过后 git -C ... log --oneline # 期望: ship: prd@1 sdd@1 git -C ... tag --list # 期望: prd-rev-1, sdd-rev-1, shipped-v1-1 # 6. retry 演示 # 注入 bug 让 Coder retry 2 次再绿 git -C ... log --author='Coder' --oneline # 期望: 仅 1 个 commit (因为只在 Tester 绿才 commit) # 但 coder.log 有 2 条 attempt 记录 # 7. 导出 node -e "require('./server/services/git-vault').gitVault.archive('p-XXXXXX').then(r => console.log(r))" # 期望: /tmp/p-XXXXXX-HEAD.zip 含 tracked files only ``` ## ⑯ 关联 - [sdd-process](sdd-process.html) · P-10 自动提交(同此页 §⑧) - [sdd-control](sdd-control.html) · C-18 GitVault(同此页 §⑦) - [data-layout](data-layout.html) · .git/ 目录位置 - [plan-migration](plan-migration.html) · P0 工时已含 GitVault - [代码归宿](plan-code-lifecycle.html) · v2 自动 push 是这一层的延伸