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 模板
# 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 完工 |
| 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
module.exports.gitVault = {
// 初始化(项目创建时)
init(pid: string, opts?: {
name?: string, email?: string, // 默认 "DevLoop <devloop@lemonboss.local>"
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<Commit[]>,
diff(pid, refA: string, refB: string, opts?: { path? }):
Promise<string>, // unified diff text
show(pid, sha: string): Promise<Commit + diff>,
listTags(pid): Promise<Tag[]>,
// 写回滚 (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 不重叠:
// 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 可加:
# 把项目代码推到 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 周。代价相对收益(真版本管理)合理。
⑮ 实战命令
# 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 · P-10 自动提交(同此页 §⑧)
- sdd-control · C-18 GitVault(同此页 §⑦)
- data-layout · .git/ 目录位置
- plan-migration · P0 工时已含 GitVault
- 代码归宿 · v2 自动 push 是这一层的延伸