butler-git —— 节点是「意图」,commit 是「证据」
门禁零依赖。 plan / status / commit / abandon / approve 只需要 node 和 git。
一个例外,写在这免得误解:
bg html(给人看图的那条命令)用 mermaid 做布局, 从 CDN 取 —— 打开页面需要联网。渲染坏了不影响门禁,那条规矩在门禁上没破。 为什么破、代价多大,见「那张图在画什么」一节和lib/mermaid.mjs顶部。
节点先存在(你要达成什么),commit 后出现(你做完了什么)。
两者不是相等关系,而是**达成关系**:节点达成时,留下一个 commit 作为证据。
概念和状态机定稿见 DESIGN.md,那份是设计依据。 这份是怎么跑起来。
它取代了 README 里原来那套"两盏灯 + 断言谓词"的模型 —— 那套是过渡形态,讨论下来收敛到了现在这个。
跑起来
node test/run.mjs # 57 条负向验收
node test/plugin.mjs # 插件自测(26 条)
# 或
npm test
单步(在一个 git 仓库里):
BG="node bin/bg.mjs"
B=$(git rev-parse HEAD)
$BG plan --id n7 --expect "让导出支持非 ASCII" --base $B \
--verify "pytest -q" --delta "M:src/csv.py" --delta "A:src/enc.py"
$BG status n7 # 改了什么;和 Δ 比差在哪(便宜,不跑 P)
$BG commit n7 # 提交即门禁:Δ + P,过了才产生版本
$BG abandon n7 # 放弃一个声明了但没做的任务
# 人的面
$BG tree # 看整棵树(灯 + P 原文 + 弱 P 标记 + 图外的提交)
$BG html # 生成 .bg/tree.html —— 一幅真 DAG 图(点=commit,线=git 父子)
$BG serve # 前台实时查看器(默认 127.0.0.1:8731)
$BG recheck # 对当前 commit 复查各历史节点(诊断,不是灯)
$BG health # 心跳一行
$BG log # 从 commit trailer 读出来的历史
$BG crosscheck # 图和 commit 对不对得上
bg html的图需要联网。 它用 mermaid 做布局(从 jsdelivr CDN 取,3.3MB)。 断网时页面会明说加载失败,并把节点清单用纯文字列出来 —— 图没了,信息不丢。 取舍的原因和实测代价见lib/mermaid.mjs顶部。门禁本身不受影响:
plan/status/commit/abandon/approve仍然只需要node和git,渲染坏了也不影响它们。
三个概念
| 是什么 | 谁定 | |
|---|---|---|
| 节点 | 一个意图(要达成什么) | 声明它的那个 |
| Δ | 预期变化:精确集合,带方向 | 同上 |
| P | 检测程序:一条命令,退出码 0 才算过 | 同上(必须有) |
| commit | 达成时留下的证据,不可变、可重放 | 门禁过了才产生 |
绿 = 一个关于某个 commit 的事实。 那个 commit 不可变,所以这个事实 永不过时、也不会被推翻。所以只需要一盏灯。
checkout 那个 commit
跑 (Δ, P)
-> 结果永远一样
绿只对 commit 说,不对工作区说。工作区有未提交改动时, 没有任何绿能代表它 —— 这时要明说"当前状态没被验过", 不许拿最后一个绿来顶。
Δ 的四个性质
① 精确集合,带方向 M / A / D / R 改了 / 新增 / 删除 / 重命名
② 不是"作用域" 精确集合本身就说了"必须"和"允许",不需要另一个 allow
③ 可以为空,空 = 没有预测 (不是"没改动"!)
④ 可以是预测或转录 但必须标出来 —— 强度不同,不同要可见
一次比较回答三件事:
diff(base, result) 和 Δ 比:
少了 -> 漏做
多了 -> **预期外的改动**
类型不符 -> 做错方向(该删的改了、该加的删了)
"多了"是最要紧的一条 —— 漏做模型自己会发现,预期外的改动它看不见。
门禁
一次提交 = 一次门禁。不通过,不许 commit。
node_commit(n):
1. 固定工作区:算它的树 Y
2. 查 Δ:diff(base, Y) 精确匹配 n.delta(为空的场合跳过)
3. 跑 P:在 Y 上跑 n.verify
全部通过 -> commit,trailer 记下证据
任何一条不过 -> **不提交**,逐条给出"要求"
顺序很重要:必须先固定树、再跑 P。否则:
跑 P -> 通过
[这中间工作区又变了]
commit -> 提交的是另一个状态,而 trailer 说它通过了
所以第 3 步用 git commit-tree 精确提交 Y,不是 git commit ——
验证程序留下的临时产物进不了那个 commit。实测:
bg-verified-tree 恒等于 commit^{tree}。
三种结果,不是两种
ok 确实满足
fail 确实不满足(附一条"要求",模型能照着修)
unknown **不知道** —— 观测能力受限
unknown 不算通过。它必须能被表达出来,否则"看不见"会被读成"没问题"。
权限:凭证,像目录权限
持有 X 的凭证 -> 能改 X 的**所有后代**(向下包含)
改 X 自己 -> 需要 parent(X) 的凭证 <- 这就是「向创建者提权」
根节点 -> 没有父,只能由人签发(--as-user)
已达成 -> **冻结,任何凭证都改不动**
所以一个 agent 拿到一个节点的凭证,就在它下面任意控制; 但要改那个节点本身,得找创建它的那个(它的父)拿凭证。
$ bg plan --as-user --id root ... # 人建根,拿回 🔑
$ bg plan --token <根凭证> --id n1 --parent root ... # 拿回 n1 自己的 🔑
凭证明文只在创建那一刻出现一次,之后库里只有它的 hash。 所以泄漏了库也拿不到凭证。
为什么是凭证,不是 agent id
工具拿得到 exec.agent.id,但它不该被当成认证依据 ——
一个 subagent 报上来的身份,工具核验不了。拿它做权限,
等于把一道门建在自己都验证不了的东西上。
凭证绕开这件事:谁拿出凭证谁有权,不问你是谁。
代价要说清:它防的是误伤,不是恶意。一个 agent 想把自己的凭证 递给另一个,没有任何机制拦得住 —— 但递出去这件事会留在账本里。 和"绕过只能发现不能禁止"是同一条线:不拦,但不隐瞒。
冻结优先于凭证
冻结保护的是历史事实(那个 commit 真的被验过), 凭证保护的是"谁能改意图"。这是两件事。
让提权能破冻结,那条绿就变成可以事后修改的东西 ——
而 verified-tree 的全部意义就是它不可事后修改。所以:
✗ 节点 n1 已经**达成**过 —— 门禁冻结,**任何凭证都改不动它**。
要变,起一个新节点(历史不改写)。
两道门
| 谁定 | 谁能改 | 靠什么 | |
|---|---|---|---|
| 根节点 | 人 | 只有人 | 机器(没有父,凭证只能人签发) |
| 子节点的 Δ / P | 创建它的那个 | 持有父凭证的 | 凭证 + 可见性 |
| 已达成 | —— | 谁都不能 | 冻结(绝对) |
所以中间节点的绿不承诺任何东西 —— 承诺在根上。 这是设计,不是缺陷。
要变,起一个新节点把它做出来 —— 历史不改写,旧节点保持绿。
可见性是唯一的问责机制,所以它得真的可见
① P 必须跟着版本走(进 trailer),不能只活在工具的运行时
② 弱 P 要能被机器判出来的那种就标出来:
P 在基线时就通过 -> ⚠ 它区分不了你做没做
③ 展示时 P 的**原文**要看得见,不能只显示"通过"
不拦,但不隐瞒。这是"模型自决"能站住的唯一方式 —— 自决不等于无痕。
所有节点一视同仁
这里原来叫「向根负责」,已删除。 那个模型是:
节点的验证 = 自己的 (Δ, P) + 【根门禁的 P】 ← 删掉的
↑ 模型自选 ↑ 模型动不了
它建立在一条不变式上:"每一个绿的 commit 上,根门禁都是通过的"。
问题是它把根特殊化了。 而根 P 和子节点的 P 其实是同一种东西 —— 都只是"这件事做对了"的证明:
根节点 P = "整体可用"(如 node test/run.mjs && node test/plugin.mjs)
子节点 n1 P = "n1 这件事做对了"
子节点 n2 P = "n2 这件事做对了"
根不特殊,只是它恰好验的是"整体"。它只在自己提交时跑,不下来压每一个子节点。
原来的做法有两个毛病:
① 它把"整体可用"变成了每个子任务的义务。 而子任务只该对它自己那件事
负责 —— 拿整体去卡一个局部改动,不是这套东西的哲学。
② "根"被迫特殊化。 取哪个根?多个 parent=null 的节点怎么办?
rootNode() 只能 find 第一个 —— 而一个仓库明明可以有任意多个任务根。
所以现在:
任何节点达成时,只跑它自己的 P。根也一样。
代价(说清)
"整体可用"不再是自动保证的。
它变成根的 P 的责任:根节点拿到一个好用的 base,它的 P 保证整体可用; 子任务只管自己那一段。想要整体保证,就建一个 P 是整体验证的节点。
这不是退化,是把那个决定权交回给声明者 —— 什么时候验整体、验多全,由他定。
顺带:
bg-root-verify这个 trailer 字段也不再写了。老节点上的保持原样 (历史不改写),新节点没有它。
证据:commit trailer
让导出支持非 ASCII
bg-node: n7
bg-owner: model
bg-parent: n3
bg-base: 9330fc81…
bg-delta: M:src/csv.py A:src/enc.py
bg-delta-source: before-work
bg-verify: pytest -q
bg-verified-tree: 4db1466a…
为什么要记 verified-tree:commit 不可变,但可以被 amend。
走门禁 commit 了 V(带 trailer:"P 通过")
git commit --amend 改内容(保留 message 和 trailer)
-> V' 内容变了,trailer 还写着"P 通过" -> 一个伪造的绿
校验时比对 commit^{tree} == bg-verified-tree,不等就是改过。
(不能记 commit 自己的哈希 —— 鸡生蛋。但树哈希可以在 commit 之前算出来。)
这一条同时让证据可重放:
checkout <tree>
跑 bg-verify 里那条命令
-> 结果应当一致
bg recheck 会扫全部历史 —— 它只看 commit 自己,不依赖 .bg/,
所以图丢了、被换了一份、被人手改过,这条照样抓得住。实测:
✗ 有 1 个 commit 的**证据对不上自己写下的 trailer**:
9972c5de(节点 n1) 的内容被改过:它的树是 9f012632,
但它的 trailer 说验过的是 4d21dd80 —— **这是一个伪造的绿**
绕过:只能发现,但很好发现
模型有 bash,没法禁止它裸 git commit。所以不是"禁止",而是"发现":
有 bg-node trailer -> 走门禁的
没有 -> **绕过的**
$ bg tree
⚠ 有 2 个提交不在图里(a1b2c3d, e4f5a6b)
它们没有 bg-node trailer —— 绕过了门禁
统一面 ≠ 强制面,但"绕过会被看见"就够强了。
面
给模型的四个工具
| 工具 | 干什么 | 跑 P 吗 |
|---|---|---|
node_plan |
声明一个任务(base / expect / Δ? / P) | 否 |
node_status |
相对 base 改了什么;和 Δ 比差在哪 | 否(和 git status 一样便宜) |
node_commit |
提交即门禁:Δ + P,过了才产生版本 | 是 |
node_abandon |
把一个声明了但没做的任务从图里移除 | 否 |
上一版 25 个(后来收到 6 个)是负担 —— 模型会挑一个差不多的用,或者开始乱试。四个。
"看树"和"合并"都不占模型的工具位:
看树 状态就是版本 -> git log 就是树。模型本来就有 bash。
而且"看树"是**人的面** —— 人的面不该占模型的工具位。
合并 折进 node_commit —— 检测到 MERGE_HEAD 就走合并分支。
实测:模型自己 git merge --no-commit 之后由工具提交,
产出的提交**有两个父**,是一个真正的 merge commit。
但 node_abandon 砍不掉
node_plan 声明一个意图 -> 它必须被记下来(否则人看不见计划)
声明了不做 -> 那条记录必须**能被移除**
否则图里永远挂着一个"待办",而图会看起来像还在做这件事。
但它只做一件事:从图里移除声明。
"把工作区撤回去"不是它的职责 —— 那是 git reset --hard <base>,
模型有 bash,自己就能做。工具只管"图",不管"工作区"。
node_status 是这套东西的使用体验所在
当前版本 4db1466 "让导出支持非 ASCII"
基准版本 9330fc81
相对基准改了 3 个文件
M src/export/csv.py
A src/export/encoding.py
D src/export/legacy.py
和声明的 Δ 比
✓ M src/export/csv.py
✓ A src/export/encoding.py
✗ D src/export/legacy.py 声明了,但**没发生**
+ M src/util.py **多出来的 —— 预期外的改动**
最后一行就是"防止预期外的改动"落地的地方。
人的面(CLI,不占模型的工具位)
bg tree 看整棵树(灯 + 状态 + 弱 P 标记 + 图外的提交)
bg html 生成 .bg/tree.html —— 一幅真 DAG 图(**需要联网**)
bg serve 前台实时查看器(默认 127.0.0.1:8731)
bg recheck 对当前 commit 复查各历史节点 —— 诊断,不是灯
bg health 心跳一行
bg log 从 commit trailer 读出来的历史
bg crosscheck 图和 commit 对不对得上
那张图在画什么
bg html 画的是真 git DAG,不是"按某个数值排成一行":
● = commit(只画有节点的点,普通提交压成线)
实线 --> = git 父子(`git log %P`)—— 这就是主干,分叉/合并原样保留
虚线 -.-> = 意图父子(节点的 parent 字段)—— "谁拆给谁的"
两种线必须分开。 混画会让人以为"拆给谁的"就是"代码从哪儿来的",那是撒谎。
上一版把提交映射成一个标量(git rev-list --count)当坐标 —— 那在分叉历史里
会编造一个顺序。本仓库恰好是纯线性的,所以它看起来是对的,那是最坏的那种错。
现在只画 git 真实给出的父子关系,不映射成"第几个"。test/run.mjs 第 20 节
会另造一个真有分叉的仓库来验这件事 —— 直线历史证明不了任何事。
复查不是灯,是一次查询
一个灯会丢失"回归可见性":
node 5 弄坏了 node 2 的功能
node 5 的 P 如果没覆盖 -> 它绿
node 2 的灯是对它那个 commit 的事实 -> 它**还是绿**
补法(和"一个绿灯"不冲突):
bg recheck
-> 对【当前 commit】重跑各历史节点的 (Δ, P)
-> 指出谁现在会不通过
它是"看的时候才算"的诊断,不是节点的一个状态。 所以灯还是一个。
合并
合并也是一次提交,所以它也是一个节点。但有两处不同:
没有单一的 base -> 它有多个父
不用 Δ -> 无冲突时 Δ 可推导;有冲突时解决是刻意的
所以合并节点的门禁是:
1. 结果必须是**所有**父的后代
2. 每个父的 Δ,在它自己碰过的路径上,合并后仍然成立
3. P 在结果上通过
第 2 条是必需的,而且不是 Δ。 因为:
git merge-base --is-ancestor X_A X_M -> ✓ 通过
git diff X_A X_M -- feature.py -> M feature.py ← A 的成果没了
"X_A 是祖先"只证明历史里包含 A,不证明 A 的内容还在。 解决冲突时可以把一个分支的成果整个撤销掉,而祖先检查看不出来。
用法:
git merge --no-commit --no-ff A B # 有冲突就自己解决(用 bash)
bg commit m1 # 查上面三条,过了才产生真正的 merge commit
实测(把 A 的成果删掉再"解决"冲突):
✗ nA(3133948) 的 Δ 里 A a.txt 在合并结果里找不到了
—— **它的成果在解决冲突时被撤销了**
语义冲突没有机械解
A:把 total() 改成返回字符串
B:加了个 report(),依赖 total() 返回数字
-> 0 个冲突文件、两边各自的门禁都过、合并后 report([1,2]) == "33"
这个只能靠第 3 条兜(P 在合并结果上通过),而且 P 得够全。 合并出的绿是新的绿,不是两个绿的相加。
状态在哪里
<项目>/.bg/
├── nodes.json 图(**权威**:节点的当前状态在这里)
└── ledger.jsonl 账本(追加式,不改写)
插件目录里没有状态 —— 它只是代码。所以同一个插件可以服务多个项目。
图和 commit 的分工:
图(.bg/) 当前状态 —— 权威,读得快,可写
commit 做成的证据 —— 不可变,可重放,带 verified-tree 防篡改
绿是"这个节点达成了,并且那次达成的证据还在"。
两者对不上时 bg crosscheck 会报出来。
账本为什么必须是追加式
"达成过"是历史事实。历史要能被引用,前提是它不能被改写。
声明 / 改写 -> 追加一条,带上改前的门禁
达成 -> 追加一条,带上证据(树 / P / 时间)
放弃 -> 追加一条
没有任何一条操作会动已有的行。
指错目录会怎样
每个工具都能显式给 project;不给才退回会话 cwd。
"节点声明在 A 目录、会话在 B 目录"时,不给 project 就会
看不见自己刚声明的节点 —— 这个坑实测过,所以 test/plugin.mjs
有一条守卫逐个检查四个工具都带这个参数。
并行:一个工作区、两个 subagent
治得了的:状态文件
.bg/nodes.json 是读-改-写,两个进程并行时会互相覆盖。实测(修复前):
20 轮并行 declare(每轮 2 个,期望 40 个节点)
-> 实际只有 20 个,**账本也只有 20 条** —— 丢的那 20 个没有任何痕迹
-> 而且进程直接崩:ENOENT: rename '.bg/nodes.json.tmp'
(固定临时名,两个进程抢同一个)
修法两条:
- 临时文件名唯一(pid + 随机数)—— 修崩溃
plan的"读 → 判 → 写 → 记账"整段进排它锁(.bg/.lock,wx排它创建) —— 修丢节点。锁太久(持有者崩了)会被抢过来,否则一次崩溃会把项目永久锁死
实测修复后:40/40,账本 40 条,零崩溃。
治不了的:并行改同一个工作区时,归属无法确定
工作区是共享的。别人在我干活期间改的东西,会出现在我的 diff 里:
agentA 声明 Δ=src/a.py,agentB 声明 Δ=src/b.py
B 改了自己的 src/b.py
-> A 的 Δ 比对里会多出 "M src/b.py" —— **预期外的改动**
这不是冤枉:从 A 的角度看,那确实是一个它没声明的改动。 工具做的就是照实报出来,由 A 决定"那是别人的,我等它合进来再算"。
所以真要并行,只有三条路:
| 做法 | 代价 | |
|---|---|---|
| A. 串行 | 一次只让一个节点在飞 | 没有并行,但归属永远清楚 |
| B. 每 agent 一个 worktree | git worktree add 给每个 agent 独立的树 |
真并行;合并要单独设计 |
| C. 并行 + 共享树 | 就是现在这样 | Δ 会把别人的改动报成"预期外" |
当前实现是 C,并且它会照实报出来(不是假装知道)。
B 是真正支持并行的方向 —— 每个 agent 有自己的树和自己的 .bg/。没做。
一句话:git 能告诉你"世界变了什么",但一个共享工作区里, 它没法告诉你"是谁改的"。 前者是 git 的能力,后者需要隔离。
目录
butler-git/
├── package.json **插件包**(main = plugin/index.js)
├── bin/bg.mjs 命令行(人的面)
├── lib/
│ ├── git.mjs git 原语:树哈希 / diff / commit-tree / 临时索引 / 临时 worktree
│ ├── store.mjs 图 + 追加式账本 + 排它锁
│ ├── delta.mjs Δ:解析 / 自洽检查 / 三方比对(漏做·预期外·方向错)
│ ├── cap.mjs 凭证:签发 / hash / 向下包含判定
│ ├── gate.mjs 门禁求值:Δ + P -> ok / fail / unknown
│ ├── nodes.mjs 写路径:plan / commit / abandon + 冻结 + 权限 + trailer
│ ├── recheck.mjs 复查(诊断)+ 篡改检测 + 图和 commit 交叉校验
│ ├── view.mjs 渲染(只画,不算)—— 终端版
│ ├── html.mjs HTML 外壳 + 取数据(buildTree)
│ ├── mermaid.mjs 图 -> mermaid flowchart(网页版;**唯一破了"零依赖"的地方**)
│ └── serve.mjs 前台实时查看器
├── plugin/ DSH 工具插件(四个工具)
└── test/ run.mjs(79 条负向验收)+ plugin.mjs(39 条)
逻辑只有一份:插件和 CLI 都调 lib/。这不是洁癖 ——
上一个实现把核心写了两遍(Python 一份、JS 一份),改了一边另一边静默落后,
于是"模型用的那半边没有新门禁"。test/plugin.mjs 有一条守卫查这件事。
已知边界(诚实的部分)
本节是已经接受的取舍 —— 它们不是 bug,是明码标价的代价。
P够不够全,判不了。 那是语义问题。 机器只能判"有没有"、"是不是恒真"(弱 P 探测)。Δ为空时,没有任何东西防止意外改动。 只有 P 在承重。 —— 会在status/tree/commit里明确标出来,不沉默。- 合并的语义冲突没有机械解。 靠合并节点的 P 兜。
- 网络副作用完全在观测之外。这是唯一一个无法验证的维度。
verify会被真的跑起来。 它是模型写的命令,在项目的正常工作区里跑 —— 和跑测试没有区别,但要知道它确实执行了。- 宿主必须有可用的 git。 算不出树哈希时报
unknown,不假装查过。 - 凭证防的是误伤,不是恶意。 递出去没机制拦,但会留在账本里。
历史:BUG 清单(已全部处理)
本节的来历:本来要在本仓库上加一个"给人看的节点树可视化"(第三渲染前端)。
它被回退了 —— 因为 BUG-1 让 workingTreeHash 恒为 null,
于是"验收"这件事的证据锚永远是空的。在那种地基上交付任何东西,都没法真诚地验收。
回退是对的。下面 7 条是当时的盘点,现在全部已修。 每条都补了回归验收,所以它们不会再悄悄回来。
| # | 严重度 | 位置 | 一句话 | 状态 |
|---|---|---|---|---|
| BUG-1 | 高 | lib/git.mjs |
workingTreeHash 恒返回 null → 任何树判据永远不成立 |
已修(下面单列) |
| BUG-2 | 中 | lib/view.mjs |
把「从未通过过」说成「通过过,但现在坏了」 | 已修 |
| BUG-3 | 中 | lib/gate.mjs |
allow(可写边界)只声明不执行 |
已被设计取代(Δ 取代了 allow) |
| BUG-4 | 中 | plugin/index.js |
工具没有 project 参数,写死会话 cwd |
已修(有守卫) |
| BUG-5 | 低 | plugin/cordis.patch.yml |
注释里的工具数与实现不符 | 已修(有守卫) |
| BUG-6 | 中 | 部署约定 | 安装副本是整目录复制,改源码不重装 = 改了但没生效 | 已写进文档 |
| BUG-7 | 中 | lib/nodes.mjs |
没有"放弃一个计划"的原语 | 已修(node_abandon) |
BUG-1(高)树哈希恒为 null
原报告::(exclude) 在 git 2.50.1 上"能排除文件,不能排除目录"。
核实结果:第二半不成立。 实测未跟踪目录排得掉。但真正的病根被找到了,而且更隐蔽:
git add -A -- . ':(exclude).bg' // ← 原来这一句
:(exclude) 本身没问题。问题在于 git add 会先拿 pathspec 去匹配 .bg,
撞上 .gitignore 里的 .bg/ 就直接报错退出:
The following paths are ignored by one of your .gitignore files: .bg
exit=1
于是凡是把 .bg/ 写进 .gitignore 的仓库(也就是按文档推荐的写法),
树哈希恒为 null。
修法(两步,两种仓库都实测稳定):
git add -A -- . # 不再显式匹配 .bg,不会报错
git rm -r --cached --ignore-unmatch .bg # 从**索引**里抹掉它
为什么原来的测试没抓到:所有临时测试仓库都没有 .gitignore ——
也就是恰好走的是那条"能工作"的路。这就是测试覆盖的缺口本身。
这段教训仍然活在 lib/git.mjs 的注释里 —— 它是这个设计里
"观测者不能出现在被观测对象里"这条规则的来源。
BUG-6(中)安装副本不会随源码更新
nodeLinker: hoisted 下 file: 依赖是整目录复制,不是软链。
而 patchReload: "live" 只重载组合层的 cordis.patch.yml,不重载 JS。
⟹ 改完源码必须 pnpm install(在 profile 目录里),
否则"改了但没生效"且没有任何提示。
No comments yet. Be the first to write one.