🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

提交门禁 Hercules-debug/butler-git

DeepSeek Harness
Hercules-debug31 天前有提交已提供中文文档扫描:含敏感能力

butler-git —— 节点是「意图」,commit 是「证据」

30.5/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/3 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查有告警:未发布到 npm registry,仅可从源码安装。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add Hercules-debug/butler-git
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:需留意
是什么
dsh 原生插件 · dev
装得上吗
实装验证未通过(dependency_conflict),装前请到仓库确认最近更新与 issue
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 31 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
⚠︎ 实装验证未通过(dependency_conflict · 2026/9/30) ——可能是验证环境差异,装前建议到 GitHub 仓库确认最近更新与 issue。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查⚠ 装前注意

未发布到 npm registry,仅可从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-butler-git(未发布到 npm,仅可源码安装)
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/7 13:15:02

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 28 处,展示前 28 条)
  • 发起网络请求
    plugin/index.js:191
    outcome = await approval.request({
  • 写入本地文件
    bin/bg.mjs:201
    writeFileSync(dest, renderHtml(dir), "utf8");
  • 写入本地文件
    lib/nodes.mjs:921
    writeFileSync(paths(dir).nodes, JSON.stringify(obj, null, 2), "utf8");
  • 读取本地文件
    lib/store.mjs:50
    if (!existsSync(p.root)) mkdirSync(p.root, { recursive: true });
  • 写入本地文件
    lib/store.mjs:50
    if (!existsSync(p.root)) mkdirSync(p.root, { recursive: true });
  • 读取本地文件
    lib/store.mjs:57
    if (!existsSync(file)) return fallback;
  • 读取本地文件
    lib/store.mjs:59
    return JSON.parse(readFileSync(file, "utf8"));
  • 写入本地文件
    lib/store.mjs:76
    writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
  • 写入本地文件
    lib/store.mjs:77
    renameSync(tmp, file);
  • 删除本地文件
    lib/store.mjs:79
    try { rmSync(tmp, { force: true }); } catch { }
  • 写入本地文件
    lib/store.mjs:114
    writeFileSync(fd, String(process.pid));
  • 删除本地文件
    lib/store.mjs:118
    try { rmSync(lock, { force: true }); } catch { }
  • 删除本地文件
    lib/store.mjs:121
    try { rmSync(lock, { force: true }); } catch { }
  • 读取本地文件
    lib/store.mjs:129
    const st = statSync(lock);
  • 删除本地文件
    lib/store.mjs:131
    rmSync(lock, { force: true });
  • 写入本地文件
    lib/store.mjs:172
    appendFileSync(ensure(dir).ledger, `${JSON.stringify({ ts: nowIso(), ...event })}\n`, "utf8");
  • 读取本地文件
    lib/store.mjs:177
    if (!existsSync(p)) return [];
  • 读取本地文件
    lib/store.mjs:178
    return readFileSync(p, "utf8")
  • 执行外部命令固定命令
    lib/git.mjs:46
    const r = spawnSync("git", args, {
  • 删除本地文件递归
    lib/git.mjs:144
    rmSync(tmp, { recursive: true, force: true });
  • 读取本地文件
    lib/git.mjs:204
    if (!existsSync(f)) return [];
  • 读取本地文件
    lib/git.mjs:205
    return readFileSync(f, "utf8")
  • 读取本地文件
    lib/git.mjs:218
    if (existsSync(p)) unlinkSync(p);
  • 删除本地文件
    lib/git.mjs:218
    if (existsSync(p)) unlinkSync(p);
  • 执行外部命令来自配置/变量
    lib/git.mjs:357
    const r = spawnSync(cmd, {
  • 删除本地文件递归
    lib/git.mjs:370
    rmSync(tmp, { recursive: true, force: true });
  • 读取本地文件
    lib/git.mjs:417
    return existsSync(join(dir, rel));
  • 执行外部命令来自配置/变量
    lib/gate.mjs:54
    r = spawnSync(cmd, {
扫描于 2026/11/1(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-tools
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • Δ预期改动校验
  • P检测程序校验
  • 通过后才生成commit

把「我改完了」变成可验证的事实:Δ(预期改动)+ P(检测程序)全过才产生 commit。零依赖,只用 node 和 git。| 把「完成」变成可验证的事实——只有 Δ + P 都通过,commit 才存在。

README

由 DeepSeek 最新模型翻译生成
butler-git —— 节点是「意图」,commit 是「证据」 门禁零依赖。 plan / status / commit / abandon / approve 只需要 node 和 git。 一个例外,写在这免得误解:bg html(给人看图的那条命令)用 mermaid 做布局, 从 CDN 取 —— 打开页面需要联网。渲染坏了不影响门禁,那条规矩在门禁上没破。 为什么破、代价多大,见「那张图在画什么」一节和 lib/mermaid.mjs 顶部。 节点先存在(你要达成什么),commit 后出现(你做完了什么)。 两者不是相等关系,而是达成关系:节点达成时,留下一个 commit 作为证据。 概念和状态机定稿见 DESIGN.md,那份是设计依据。 这份是怎么跑起来。 它取代了 README 里原来那套"两盏灯 + 断言谓词"的模型 ——那套是过渡形态,讨论下来收敛到了现在这个。
展开完整文档(README 剩余 1 节)
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
跑 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 ,模型有 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' (固定临时名,两个进程抢同一个)

修法两条:

1. 临时文件名唯一(pid + 随机数)—— 修崩溃
2. 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,是明码标价的代价。

1. P 够不够全,判不了。 那是语义问题。
机器只能判“有没有”、“是不是恒真”(弱 P 探测)。
2. Δ 为空时,没有任何东西防止意外改动。 只有 P 在承重。
—— 会在 status / tree / commit 里明确标出来,不沉默。
3. 合并的语义冲突没有机械解。 靠合并节点的 P 兜。
4. 网络副作用完全在观测之外。这是唯一一个无法验证的维度。
5. verify 会被真的跑起来。 它是模型写的命令,在项目的正常工作区里跑—— 和跑测试没有区别,但要知道它确实执行了。
6. 宿主必须有可用的 git。 算不出树哈希时报 unknown,不假装查过。
7. 凭证防的是误伤,不是恶意。 递出去没机制拦,但会留在账本里。

历史: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 目录里),否则“改了但没生效”且没有任何提示。
预览分享卡片长什么样 ↓Hercules-debug/butler-git
分享 · DeepSeek Harness Hub
Hercules-debug/butler-git

butler-git —— 节点是「意图」,commit 是「证据」

回到顶部