refactor(pilot): squash-merge 仓库的分支清理 —— 只列不删 - #45
Conversation
## FU-1:merge-pr 的 gh flag 从黑名单改成白名单
黑名单只能拒绝**今天存在**的危险 flag。哪天 `gh pr merge` 新增一个能绕过分支保护的 flag,
黑名单会静默放行,这里什么都不会察觉。白名单朝相反方向失败:不认识的 flag 一律拒绝,
直到有人**刻意**把它加进去 —— 这才是护栏该有的失败方向。
放行:`--squash --merge --rebase --auto`,以及带值的
`--body/-b --body-file/-F --subject/-t --match-head-commit`(分离式与 `=` 附着式都支持)。
`--admin` / `--repo` / `-d` 保留各自的专门错误消息(白名单本来也会拒,但那样说不清为什么)。
## FU-2 + FU-4:safe-cleanup 支持 squash-merge 仓库
squash 仓库里 `git branch --merged` **恒返回 0** —— squash 重写补丁,原 commit 不是集成分支的
祖先。实测本仓库 28 个分支返回 0,于是这个脚本在自己家里什么都清理不了,人只能手工 `-D`,
比脚本存在还糟。
新增 `--squash-merged`(opt-in),引入第二种**服务端**证据:GitHub 的
`GET /repos/{o}/{r}/commits/{sha}/pulls` 回答「哪个 PR 把这个 commit 引入了仓库」,
只有拿到 `merged_at != null` 的 PR 才允许 `-D`。
**实现没用 FU-4 记的祖先算法,用了更好的**:祖先算法要先把所有已合并 PR 的 head 抓到本地
再算可达性;这个接口每个分支只要一次 API 调用、不写任何 ref 或对象 —— **dry-run 因此保持零写入**。
两者同样是按 commit 判而不是按分支名判,所以两个方向的错都不存在:
- 漏删:`work-pr18` 这类分支名从没当过 PR head,但 tip 就是别的 PR 的已合并 head
- 误删:分支名可复用,同名分支删掉重开后内容全不同,旧的 MERGED PR 仍然匹配名字
四种形状实测:squash 后的 head ✓ / 分支中间的 commit ✓ / CLOSED 未合并 → 无证据 ✓ /
从未开过 PR → 无证据 ✓。
## 三轮对抗自审抓到的一条(不在计划里)
**「查不了」曾经和「没有可清理的」长得一模一样。** `merged_pr_for` 只在 `$( )` 里被调用,
那是**子 shell**,它设的 `gh_state` 返回后就丢了 —— 于是「无法核实」那条分支永远不可达,
gh 没装/没登录时打印的是 `(none)`,读起来就是「干净,没东西可清」。改成在主 shell 里先 `gh_init`
探测一次。这正是「沉默不等于成功」那个坑,而且是我自己写出来的。
## 六项实测
1. 基线 dry-run → `(none)`(本地仅剩不该删的两个,无证据)
2. 无 flag → 列为 `candidate ... — pass --squash-merged to include`
3. `--squash-merged` 无 `--apply` → `would delete`,分支仍在
4. `--squash-merged --apply` → 只删有证据的;无证据的原样保留
5. **gh 不可用**(PATH 里藏掉 gh)→ `(cannot verify … branches KEPT)`,不删任何东西
6. 受保护分支(`release/_test`,指向已合并 commit)→ **不入列**,计数为 0
白名单侧另测六种形状:`--squash` 放行 / `--admin` 专门消息 / `--delete-branch` 专门消息 /
未知 flag 报「这是白名单」并列出允许项 / `--body` 缺值报错 / 多余位置参数被拒。
README、SKILL.md、reference/git-safety.md 里「永不 -D」的表述同步改准 —— 那是本次唯一放宽的
保证,不能只改代码不改承诺。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
四条都在 #45 里落地:FU-1 白名单、FU-2+FU-4 safe-cleanup 支持 squash 仓库、 FU-3 已在 #43 顺手做掉(check-version-sync 的 SCOPE 注释)。待做归零。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
自查发现的:#45 给 safe-cleanup 加了 --squash-merged,但 phases/status.md —— **唯一调用它的地方** —— 完全没提这个 flag,纪律段还写着「绝不 -D」。 结果是 squash 仓库里 `pilot status` 照样报「没有可清理的」,新能力等于没接上。 这就是本仓库反复栽的那个形状:规范文本指向脚本没有的行为,或者反过来 —— 脚本有了能力而文档不知道。修 FU-2 只改脚本不改调用方,等于没修。 三处: - 清理计划:说明 squash 仓库里「Local merged branches」恒为 (none), 候选在「Squash-merged local branches」一节,并给出带 --squash-merged 的命令 - --apply 那条命令补上 [--squash-merged] - 纪律段:「绝不 -D」改成「-D 只有一个出口:--squash-merged,且必须拿到服务端证据」 另外明确写了:该节若打印 (cannot verify …),那是**查不了**不是**没有**, 要照实说,不要报成「没有可清理的」—— 这条正是 #45 自审时撞出来的坑。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — 核心成就是真的,但发放 -D 的那把钥匙缺了 base 收敛,且输入解析仍在静默吞未知参数
先把做对的说清楚,这些都是实跑验过的:
- 白名单六种形状全对(把
gh从 PATH 移走,过了解析就停在gh not installed,所以信号干净):--squash放行;--admin/--delete-branch给专门消息;--future-evil-flag给 ALLOWLIST 消息;--body缺值报错;--body x与--body=x放行;多余位置参数99被拒。 --squash-merged在自己家里真的活过来了:本仓库 dry-run 下## Local merged branches → (none),而新那节找出 16 个真候选(pr18 (merged via PR #18)、pr36-r2 (merged via PR #36)…)。一个在自家仓库里完全跑不起来的守卫被救活了,这是本 PR 最实的价值。(cannot verify …)那条分支确实可达(把 gh 藏掉即触发)—— PR body 里说的子 shell 状态丢失 bug 是真的,也真修了。- 文档扫干净了:
README:19/SKILL.md:49/status.md:46-47/git-safety.md:28-34是全仓库仅有的四处「永不-D」承诺,全部改准,没有残留。唯一放宽的保证有对应的文档改动,这一点做得对。
问题在于:发放全代码库唯一一个 -D 的那把钥匙,判据不够窄;而配套的输入解析比它的兄弟脚本松一整代。
🔴 Blocking
B1 [Med-High] safe-cleanup.sh:121-122 — 证据从不按 --integration 收敛,能删掉集成分支里没有的工作
jq 只有 select(.merged_at != null),从不看 PR 的 base.ref。于是判据实际是「仓库里某个已合并 PR 关联了这个 tip」,而不是「这个 tip 的工作进了 $integration」。
沙箱复现(一次性 repo + 忠实 gh stub):一个 git merge-base --is-ancestor … main 明确判为不可达的 commit,脚本照样打印 would delete feat-unmerged (merged via PR #99);加 --apply 后该 commit 从任何 ref 都不可达。Codex 独立确认了 GitHub 语义这一侧:/commits/{sha}/pulls 对非默认分支上的 commit 同样返回关联 PR。
现实触发路径不是构造的:stacked PR 合进了父 feature 分支,而父分支的 PR 后来被 closed 未合并 —— 子分支的 tip 从此永远带着一份「已合并」证据,工作却从没进 main。
这条对 pilot 尤其危险,因为特性只在 Brood 验过,而 Brood 恰好 integration == default branch == main;pilot 文档的默认形状是 preview ≠ main,那个形状一次都没验过。 再叠上 phases/status.md:40 允许无人值守直接 --apply(没人看 dry-run),风险是实打实的。
修法:jq 里加 select(.base.ref == "<integration>")(或取 baseRefName 再比),并且额外要求 git merge-base --is-ancestor "$sha" "$integration"。base ≠ integration 一律拒删。
B2 [Med] safe-cleanup.sh:60 — 参数解析仍是 *) shift ;;,静默吞掉未知参数
实测:safe-cleanup.sh --integraton main(少一个字母)整条被丢弃,integration 悄悄回退到 origin/HEAD 猜出来的分支,然后照常往下跑;配 --apply 就是按调用方从没要求过的基准删分支。而 phases/run.md:79 恰恰要求调用方显式传 --integration —— 唯一一个拼错就不可恢复的参数。
这条最刺眼的地方在于:同一个 commit 里的兄弟脚本刚刚为了完全相同的理由从黑名单改成了「不认识就拒绝」。 白名单的道理在 git-guard.sh 成立,在 safe-cleanup.sh 一样成立。
修法:*) echo "safe-cleanup: unknown argument '$1'" >&2; exit 2 ;;
B3 [Med] safe-cleanup.sh:115-127,192-197 — 「查不了」在逐分支这一层又变回了「没有」
(cannot verify) 只认 gh_state,而 gh_state 只记录 gh_init 级失败(没装 / 没登录 / repo 解析不了)。单个分支的 gh api 失败(限流 / 5xx / 网络 / token 缺 scope)落在 2>/dev/null || true 里,和「无证据」完全同形。
- 全部失败:stub 让
gh auth status、gh repo view成功但每个gh api返回 403 限流 → 3 个候选分支,输出(none),和真正干净的仓库逐字节相同。 - 部分失败:12 个分支强制 6 个失败 → 那 6 个凭空消失,剩下的列表看起来像权威结果。
而限流恰恰是最可能的失败,因为这个特性就是每分支一次 API。
更要命的是,这直接违背了本 PR 自己新加的那句话 —— phases/status.md:33-34:「那是查不了,不是没有,照实说,不要报成『没有可清理的』」。脚本现在给不出模型照实说所需要的信息。
修法:merged_pr_for 区分「无证据」和「出错」(它跑在 $( ) 子 shell 里,计数要落盘或改写法);errors > 0 时打印 (N branches could not be verified — KEPT),且永远不要在有失败时打印裸 (none)。
🟠 R4 补扫发现:squash 修复漏了 worktree,而且是在修复内部被封死的
[Med] safe-cleanup.sh:184 + :242 —— 新加的 is_in_worktree "$b" && continue(:184) 把 worktree 分支交给第 2 节,而第 2 节的合并判据(:242)仍然只有 git branch --merged "$integration" —— 正是本 PR 花了 30 行注释论证「在 squash 仓库里恒返回 0」的那个谓词。
结果:squash 仓库里任何 worktree 永远清不掉,哪怕服务端证据齐全。端到端验过:干净 worktree 的分支带着完整证据,打印的是 KEEP (not a merged feature branch),而 squash 那节打印 (none) —— 两边都不说「这个 worktree 有证据但我不支持」。本仓库当前 dry-run 里 6 条 worktree 全是这个形状。
这条的分量在于 SKILL.md:48 的教条是「一个 task = 一个分支 = 一个 worktree = 一个 PR」—— worktree 才是主导单位;而 phases/status.md:19 每次 pilot status 都要汇报 worktree 清理。
这是「守卫跑不起来」家族的第四例(PR body 自己数到三:#39 死代码、#40 随机红灯、FU-2),而且是在修第三例的同一个 PR 里留下的。
修法:handle_wt 里 --merged 判据失败时,落到 merged_pr_for "$short";有证据 + --squash-merged + --apply 才 git worktree remove 然后 git branch -D。最低限度也要报出来:KEEP (squash-merged via PR #N — worktree cleanup not supported),让缺口可见而不是沉默。
🟡 非阻塞
- [Med]
:177,187opt-in 只关了效果,没关成本。gh_init和整个merged_pr_for循环无条件执行,--squash-merged只切换 :202/:206/:209 的措辞。实测:不带 flag 的普通 dry-run,24 个分支 → 20 次gh api、18.9 秒。而phases/status.md第 4 步规定的正是这个普通 dry-run、第 5 步再跑一次带 flag 的 → 每次pilot status2N 次调用。叠加:git branch --merged "$integration"(:186) 在循环体内每个分支重跑一次,没有提出去。 - [Low]
:204全代码库唯一的-D用>/dev/null 2>&1吞掉了 git 的Deleted branch X (was <sha>)—— 把这个唯一没有安全网的操作的唯一恢复句柄扔了;非破坏性的-d路径(:161)反而打印。建议sha=$(git rev-parse --short "$b")后echo " deleted $b (was $sha, merged via PR #$pr)"。 - [Low]
:181sed 's/^[* +] *//'是从第 1 节(输入是带装饰的git branch输出)抄过来的,而这里的输入是%(refname:short),从来不带装饰,所以它只可能改坏合法分支名:叫+shadow的分支被读成shadow,保护判定、合并判定、-D全指向错的名字。直接删掉这个sed。 - [Low]
allow_remote_cleanup在脚本里一个字都没有(全 worktree grep:7 处命中,全在SKILL.md/README.md/status.md/run.md/git-safety.md/pilot.example.yml,.sh里零命中),而文档承诺--remote需要.pilot.yml: allow_remote_cleanup: true。这个闸门只是给模型的一句话,不是机制 —— 正对着README.md:19自己那句「逻辑全在safe-cleanup.sh,不靠模型临场判断」。pre-existing,但本 PR 是重新宣称那句话的地方。建议用git-guard.sh:35-42已有的 3 行sed惯用法在脚本里解析并硬拒。 - [Low]
docs/agent/followups.md:27FU-3 标了[x] … done=PR#45,但本 PR 没有碰check-version-sync.sh;那个改动在d1d4107(PR#43)。改成done=PR#43。
反向验证(这些不是 bug,已排除)
- 一条评审自己撤回的 finding:最初报「PR 号槽位没校验 →
--admin能透传到exec gh pr merge」。用忠实 stub 复核后不成立 —— 脚本总是附加--repo,真 gh 在设了--repo时要求位置参数,且--admin对gh pr view是 unknown flag,所以:285的gh pr view "$n"先失败、:286die,exec不可达。实测:gh pr view --admin --repo AAStarCommunity/Brood --json baseRefName→unknown flag: --admin。第一次的结论是被过于宽松的测试替身造出来的,特此记下。残留只是观感:n没校验,失败时报的是误导性的cannot read PR #--admin base branch (gh auth / wrong number?);可选加一行case "$n" in ''|*[!0-9]*) die "PR number must be numeric";; esac。 - macOS
/bin/bash3.2.57 下sq_names=()空数组 +${#sq_names[@]}+ 索引循环在set -euo pipefail里正常,不是 bug。 - 无 remote 的仓库已经落在
(cannot verify … repo not resolvable; KEPT),不误导。 first // empty只影响显示哪个 PR 号;任何一个已合并 PR 都是等价证据,删除决策不变。case ''|*[!0-9]*已经挡掉了gh api的任何非数字/畸形输出,不需要额外 schema 校验;gh_repo来自gh repo view,路径固定,没有攻击者可控的 URL。
建议
- B1 和 B3 住在同一个函数里,一起改最省:让
merged_pr_for返回三态(evidence/no-evidence/error),调用方再要求base.ref == integration。 --squash-merged --apply跑完打一行 reflog 提示(git reflog/git branch <name> <sha>),毕竟这条路径没有别的恢复手段。- 考虑:只要有分支「无法核实」,
--apply就整体拒绝执行,而不是拿着一份被截断的候选名单往下走。
4-round · R1a/R1b=deepseek-v4-flash · R2/R4=opus · R3=codex(gpt-5.5) · head 6fc9502 · 所有实测均在 PR head 的独立 worktree 与 /tmp 一次性沙箱里跑,未对本仓库执行任何 --apply,未修改任何仓库文件
四条全部成立,不辩。逐条: ## B1 证据从不按 --integration 收敛(采纳一半,另一半会把特性变回死代码) **采纳**:jq 加 `.base.ref == "$integration"`。原判据是「仓库里*某个*已合并 PR 关联了这个 tip」, stacked PR 就能满足:子分支合进父 feature 分支、父分支的 PR 后来 closed 未合并 —— 子分支 tip 从此永远带着「已合并」证据,工作却从没进集成分支。 评审在沙箱里复现了,我复测确认:同一个分支,`--integration main` 给出证据、 `--integration cla-signatures` 证据消失。 **不采纳**:额外要求 `git merge-base --is-ancestor $sha $integration`。 squash 仓库里 tip **按构造**永远不是集成分支的祖先 —— 这正是本函数存在的理由。 加上它等于把特性退回它要修的那个死代码状态。base 收敛是正确的收窄,祖先性在这里不可用。 理由写进函数注释,免得下次有人再加。 ## B2 参数解析静默吞未知参数 `--integraton main`(少一个字母)整条被丢弃,integration 悄悄回退到 origin/HEAD 猜的分支。 评审说得对而且刺眼:**同一个 commit 里的 git-guard 刚为完全相同的理由从黑名单改成白名单**。 现在 unknown → exit 2 并打 usage;带值参数缺值也报错。 ## B3 「查不了」在逐分支这一层又变回「没有」 gh_state 只记 init 级失败;单分支 gh api 的 403/5xx 落在 `|| true` 里,和「无证据」同形。 而限流恰恰是最可能的失败 —— 这个特性每分支一次 API。 现在 merged_pr_for 返回三态:PR 号 / ERR / 空。有失败就打 「N branch(es) could not be verified — KEPT」,**有候选时也追加一行说明列表不完整**, 绝不在有失败时打印裸 (none)。 实测:stub 一个 auth/repo 成功但 api 恒 403 的 gh → 输出 「(4 branch(es) could not be verified — KEPT. NOT the same as 'nothing to clean'.)」 ## R4 worktree —— 第四例,而且是在修第三例的同一个 PR 里留下的 第 2 节的合并判据仍只有 `git branch --merged`,也就是本 PR 花 30 行论证「squash 仓库里恒返回 0」 的那个谓词。结果:squash 仓库里任何 worktree 永远清不掉,哪怕证据齐全。 而 SKILL.md 的教条是「一个 task = 一个分支 = 一个 **worktree** = 一个 PR」,worktree 才是主导单位。 现在 worktree 走同一套证据:git 原生 → `-d`;squash 证据 → `-D`;ERR → KEEP 并说「查不了」; 有证据但没给 flag → KEEP 并提示加 --squash-merged。 ## 实测 - B1:同分支换 --integration,证据随之出现/消失 - B2:拼错参数 → unknown argument + usage;缺值 → requires a value - B3:gh api 恒 403 → 「4 branch(es) could not be verified — KEPT」,不是 (none) - R4:带证据的干净 worktree → 无 flag 时提示、有 flag 时 would remove; **脏 worktree 仍然 KEEP(dirty, 1 changes)** —— 安全性没被这次改动破坏 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
四条全部成立,已修并推
为什么不加
|
冲突只在 docs/agent/followups.md:本分支把 FU-1..4 标了 done=PR#45, main(经 #46)在同一处加了 FU-5/FU-6。两边都要 —— 保留 FU-1..4 的 [x], 追加 FU-5/FU-6 的 [ ]。账本 append-only,没有删任何行。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES(第二轮)— 四条 blocking 全部真修好了,但头号修复自己内部有个能复活它的洞
先把修好的说清楚,全部在 head 0ff600d 上实跑验过:
| 上轮 blocking | 状态 | 实证 |
|---|---|---|
B1 证据不按 --integration 收敛,能删集成分支里没有的工作 |
✅ | jq 加了 .base.ref == $integration。无回归:线上仍是同样 16 个候选 |
B2 *) shift ;; 静默吞未知参数 |
✅ | --integraton main → unknown argument + usage,exit 2;--integration/--protect/--remote-name 缺值都报错 |
| B3 逐分支 API 失败伪装成「没有」 | ✅ | merged_pr_for 改三态 + sq_errors 计数。/tmp/failgh(gh 可用但每个 gh api 403)下打印 (21 branch(es) could not be verified — KEPT. NOT the same as 'nothing to clean'.) —— ERR 路径真可达 |
| B4 squash 仓库里 worktree 永远清不掉(R4 补扫那条) | ✅ | 线上 dry-run 现在打印 KEEP (squash-merged via PR #21) — pass --squash-merged to include,上一版是误导性的 KEEP (not a merged feature branch)。-d 走 git 原生证据、-D 只走 squash 证据,没有任何路径能在不带 --squash-merged 时走到 -D(逐条探过) |
--is-ancestor 那条你主动写下来的「不加」的理由,复核下来比 PR 正文说得还成立:对合法接续的 stacked 工作,父 PR(base=integration)本身也含子分支的 tip,所以证据照样产得出来 —— 这个收敛杀掉了「父分支被废弃」那一类,而没有造出看上去可能会有的漏删类。残留的漏删(PR 合进了后来被改名的 base)落在保留侧,fail-safe。
🔴 Blocking
safe-cleanup.sh:147 [Med] — B1 的修法把 .base.ref 当 jq 源码插值,不是当数据比较
--jq "[.[] | select(.merged_at != null and .base.ref == \"$integration\") | .number] | first // empty"$integration 是 shell 插值进 jq 程序的。一个 git 合法的分支名 x"or(true)or" 会把谓词改写成恒真:
integration=main → jq: .base.ref == "main" → 空 (正确)
integration=x"or(true)or" → jq: .base.ref == "x"or(true)or"" → 7 (base 实为 other 的 PR 也被认作证据)
一次性沙箱里端到端跑到了真删除(throwaway git init,未碰任何真仓库;假 gh 只返回一条 base.ref = "other" 的已合并 PR):
--integration main → (none) ✓
--integration 'x"or(true)or"' --squash-merged --apply
→ deleted feat-b (merged via PR #7) ← 分支消失,而该 PR 的 base 是 other
即:从未进过 integration 的工作被 git branch -D 强删。 这正是第一轮 B1 的失败类,在本 head 仍然可达 —— 只是入口从「jq 里少一个条件」变成了「jq 里那个条件可被输入改写」。
威胁模型照实说:--integration 来自 operator 或 .pilot.yml,不是不可信方,所以这是健壮性/正确性缺陷,不是现成的利用链。判成 blocking 的三条理由是:① 缺陷就在本轮头号修复自身内部;② 后果是不可逆强删;③ 修法一行,且已验证。另外「带引号的值在这条链路上并非纯假想」——git-guard.sh 专门写了 tr -d " \"'[]" 去剥 YAML 引号,而 phases/status.md 是让模型自己拼这个参数的。
修法(三种输入实测全对且不可注入):
PILOT_INTEG="$integration" gh api "repos/$gh_repo/commits/$sha/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == $ENV.PILOT_INTEG) | .number] | first // empty'🟠 safe-cleanup.sh:103 vs git-guard.sh:61 [Low→但被本 PR 接上了破坏性后果]
同一个插件的两个脚本对「protected」的定义不一致,而本 PR 新加的 -D 给这个分歧接上了后果。
# git-guard.sh:61 case "$name" in "$p"[-_/.0-9]*) → release-1.2 / hotfix-urgent / preview.2 全部 PROTECTED
# safe-cleanup.sh:103 case "$name" in "$p"/*) → 只认斜杠,release-1.2 不算 protected沙箱实证:一个 git-guard 会拒绝 push 的 release-1.2,safe-cleanup 在 --squash-merged --apply 下直接 deleted release-1.2 (merged via PR #42)。
两个函数体都是本 PR 之前就有的、本 PR 没改它们 —— 但在本 PR 之前 safe-cleanup 只会 -d(git 自己会拒绝未合并分支),现在它会 -D。建议把 git-guard 的边界集抄过来,或者干脆抽成共用函数(git-guard 那行注释里「[-_/.0-9] 挡住 main 吞掉 mainline」的推理是对的,值得共用)。
🟡 其余(非阻塞,建议顺手带上)
- [Low]
:58need_val只数元数、不看值是不是 flag。实测--protect --apply会把--apply当成 protect 模式吃掉,脚本打印mode=DRY-RUN、exit 0,调用方以为已经执行了;--remote-name --apply同类。(--integration --apply不受影响,:88的 ref 校验兜住了,fail-safe。)这和 B2 要关的「静默吞掉」是同一类。修:case "$2" in -*) 报错退出。 - [Low]
:67-h|--help用sed -n '2,40p' "$0",而第 40 行正是# Usage:标题、真正用法在 41-42 行 —— help 一行用法都不显示。改'2,42p'。 - [Low]
:319vs:244新的handle_wt-D没重定向 stdout,git 的Deleted branch X (was e131b26).会插进结构化报告;而:244的-D仍然>/dev/null把同一句吞掉。两处不一致,而:244吞掉的正是唯一的恢复句柄(上轮的遗留 Low)。建议两处统一:捕获 sha 打进自己的报告行。 - [Low]
:146ERR 三态只因为两个调用点都是命令替换才活着(bash 在 cmdsub 里挂起errexit,inherit_errexit默认关)。实测把:218改成非-cmdsub 调用后,脚本打印完## Squash-merged local branches就静默 exit 1、报告腰斩、无任何错误信息。改成rc=0; out="$(gh api … 2>/dev/null)" || rc=$?既保住真实状态,也扛得住shopt -s inherit_errexit或未来某个非-cmdsub 调用方。 - [Nit]
:231沙箱里只有 main、gh 缺失时仍打印(cannot verify … branches KEPT),其实一个分支都没检查过。属保守方向的措辞问题,不值得为它再开一轮。 - [Low]
git-guard.sh:246-248(补扫) 本 PR 新增的注释自相矛盾且与代码相反:上面五行刚写「stdout ONLY … Do NOT fold in stderr」,代码写的是2>/dev/null,紧接着下一行却写「2>&1above keeps the API's error BODY」。实测代码是对的(gh api … 2>/dev/null对未保护分支把{"message":"Branch not found",…}输出到 stdout,stderr 只有gh: Branch not found (HTTP 404)),所以.message解析和「Branch not protected」分支确实可达 —— 错的是注释。这条注释挂在--allow-trunk这条安全闸门上,下一个维护者照注释改成2>&1,就会按注释自己描述的方式把整条闸门降级成「cannot read protection」。删掉:247-248那句2>&1残留。
上轮遗留,仍未动(一并确认)
- 成本闸门:opt-in 只关效果不关成本,而且这轮更贵了 —— 不带
--squash-merged的普通 dry-run 实测 25 次gh api/ 20.6 秒(上轮 22 次),因为handle_wt也在检查--squash-merged之前就调了merged_pr_for。而phases/status.md第 4 步开的正是这种跑法。把[ "$squash_merged" = 1 ]提到merged_pr_for之上(1b 和 handle_wt 两处),普通 dry-run 就是 0 次调用,而 opt-in 时的 candidate 提示完全不受影响。 :212对无装饰的%(refname:short)仍套sed 's/^[* +] *//';allow_remote_cleanup在任何.sh里仍 0 命中;docs/agent/followups.mdFU-3 仍标done=PR#45(该改动落在 PR#43)。
反向验证(这些不是 bug,已排除)
handle_wt的-D门禁没有绕过:ERR / 无证据 / 有证据但没带--squash-merged三条路径都正确返回 KEEP,detached / protected / 主 worktree 三条路径也都正确。:274的rc=$?不是取管道最后一段的状态 —— 脚本开头的set -o pipefail仍生效,实测git -C /nonexistent status … | wc -l | tr得到rc=128,KEEP (worktree status unavailable)是活代码不是死代码(关掉 pipefail 的对照实验也跑了)。git-guard merge-pr的 PR 号槽位n="${1:-}"虽未校验数字,但$n要先过:281/:286/:290三次gh pr view "$n",flag 形状会让它们全空 →die,fail-closed。(这条上一轮我报过又撤回过,这次复核仍然是撤回态。)
4-round · R1a/R1b=deepseek-v4-flash · R2/R4=opus · R3=codex(gpt-5.5) · 增量 6fc9502..0ff600d(其中 #46 的 .pilot.yml+docs/agent/* 是随 main 合进来的、已单独 APPROVE,本轮不重复审)· 所有破坏性实证均在 /tmp 一次性 git init 沙箱里跑,未对本仓库执行过任何 --apply,未修改任何仓库文件
第二轮评审唯一 blocking,成立。 $integration 原来是 shell 插值进 jq **程序**,不是当**数据**比较。一个 git 合法的分支名 `x"or(true)or"` 会把谓词改写成 `.base.ref == "x"or(true)or""` —— 恒真,于是 base 是别的 分支的已合并 PR 也被当成证据,终点是不可逆的 `git branch -D`。 评审在一次性沙箱里端到端跑到了真删除。这正是第一轮 B1 要堵的失败类, 从**修复自身内部**又绕回来了 —— 入口从「jq 里少一个条件」变成「那个条件可被输入改写」。 改成 PILOT_INTEG 环境变量 + $ENV.PILOT_INTEG,值永远是数据。 实测:--integration main → `would delete _t-inj (merged via PR #42)`(无回归); --integration 'x"or(true)or"' → `(none)`,谓词没被改写。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
已修并推 修法按你给的写法, PILOT_INTEG="$integration" gh api "repos/$gh_repo/commits/$sha/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == $ENV.PILOT_INTEG) | .number] | first // empty'我的第一次验证是假的我直接跑 重做成配对实验,先把那个名字的分支真建出来(git 允许):
对照组:$ENV 版 +
关于你复核
|
## 🔴 两条 blocking **① jq 谓词可被分支名改写**(已在 33cccf9 修,本 commit 是配套验证) $integration 走 $ENV 当数据传,不再插值进 jq 程序。 **② safe-cleanup 与 git-guard 的 protected 语义不一致** `"$p"/*` vs `"$p"[-_/.0-9]*` —— 一个 git-guard **拒绝 push** 的 release-1.2, 在 safe-cleanup 里会被列进候选。两个函数体都不是本 PR 改的,但本 PR 新加的 -D 给这个分歧接上了不可逆后果。把 git-guard 的边界集**逐字抄过来**,并写明为什么是 `[-_/.0-9]` 而不是 `*`(挡住 main 吞掉 mainline)。 实测:release-1.2 在 git-guard 被拒推、在 safe-cleanup 候选里命中 0 次。 ## 成本闸门(上轮遗留,这轮更贵了) handle_wt 在检查 --squash-merged **之前**就调 merged_pr_for,导致普通 dry-run 25 次 gh api / 20.6 秒 —— 而 status.md 第 4 步跑的正是这种。 两处(1b 和 handle_wt)都把 flag 判断提到调用之上。 实测(stub 计数):不带 flag **0 次 API / 0 秒**;带 flag 2 次 / 1 秒。 不带 flag 时不静默 —— 打印「(not checked — one gh API call per branch; pass --squash-merged)」 并说明 squash 仓库里可清理的分支只会出现在这一节,否则沉默又会变成「没有」。 ## 6 条 Low - need_val 只数元数不看值:`--protect --apply` 会把 --apply 当值吃掉,脚本报 mode=DRY-RUN 且 exit 0,调用方以为跑过了。现在拒绝 flag 形状的值。 - `--help` 用 `sed -n '2,40p'`,而真正的用法在 41 行之后 —— 一行用法都不显示。改 2,46p。 - **-D 把唯一的恢复句柄扔了**:git 的 "Deleted branch X (was abc1234)." 被 >/dev/null 吞掉。 现在先取 sha,自己打进报告行:`was=6f3da53 → restore: git branch _t 6f3da53`。 实测真删一次后按该句柄成功还原。两处(1b 与 handle_wt)统一。 - ERR 三态只因为两个调用点恰好是命令替换才活着(bash 在 cmdsub 里挂起 errexit)。 改成 `rc=0; out="$(…)" || rc=$?`,扛得住 inherit_errexit 或未来的非-cmdsub 调用方。 - 去掉对无装饰 `%(refname:short)` 多余的 `sed 's/^[* +] *//'`。 - **git-guard 注释与代码相反**:上面刚写「stdout ONLY, Do NOT fold in stderr」, 代码是 `2>/dev/null`,下一句却说「`2>&1` above keeps the error BODY」。 实测代码是对的(gh api 把 error body 放 stdout),**错的是注释** —— 而它挂在 --allow-trunk 这条安全闸门上,下一个维护者照它改成 2>&1 就会把闸门降级成 「cannot read protection」。删掉那句残留。复验两条分支仍可达。 ## 账本 FU-3 的 done 从 PR#45 改成 **PR#43** —— 那条改动实际落在 #43,记错了归属。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES(第三轮)— 上轮的 blocking 和 6 条 Low 全部真修好了;但本轮新加的恢复句柄自己有条数据丢失路径
先把修好的说清楚,全部在 head bc840cf 上实跑:
| 上轮项 | 实证 |
|---|---|
| BLOCKING jq 谓词可被合法分支名改写 | $ENV.PILOT_INTEG 到位。main→空、other→7、x"or(true)or"→空。注入关掉了 |
is_protected 与 git-guard 语义分歧 |
两处 case "$name" in … 现在逐字节相同;沙箱里 release-1.2/hotfix-urgent 由「被删」变成「被保留」 |
need_val 只数元数 |
--protect --apply / --remote-name --apply 都 exit 2,报 requires a value, got flag '--apply' |
--help 截断 |
用法两行出来了 |
两处 -D 丢掉 (was <sha>) |
都打 was=<sha> → restore: git branch <b> <sha>,沙箱里照这句真恢复回来了 |
rc=$? 只靠 cmdsub 才活着 |
改成 rc=0; … || rc=$? |
1b 循环里多余的 sed |
删了 |
| FU-3 归属 / git-guard 自相矛盾的注释 | 都改对了 |
成本闸门是本轮最大的实际收益:普通 dry-run 从 25 次 gh API / 20.6 秒 → 0 次 / 0.44 秒,而且没有退化成沉默——它明说「not checked,pass --squash-merged to check」。重构这么大,功能还完好:带 --squash-merged 仍是同样 16 个候选 + 2 个 squash-merged worktree;ERR 路径仍触发;-D 在 1b 和 handle_wt 两处都不存在不带双 flag 的路径(三格矩阵验过)。
🔴 Blocking
safe-cleanup.sh:336,354 [Med] — handle_wt 用裸短名解析 ref,同名 tag 会劫持证据和新加的恢复句柄
1b 节免疫(%(refname:short) 对被遮蔽的分支给出 heads/decoy),而 handle_wt 把 porcelain 的 refs/heads/ 剥掉、把消歧信息一起丢了。
沙箱端到端复现(stub 先用正/负两个对照校准过,只有 tag 的 sha 带证据):
branch decoy = d5e5b68 「unmerged work on decoy」
tag decoy = 8313860 「squash of feat-x」
1b 查 d5e5b68 → 无证据 → (none) ✓
handle_wt 查 8313860 → 拿到 PR#42 → would remove ✗
--apply 后:
+ deleted branch decoy (squash-PR#42) was=8313860 → restore: git branch decoy 8313860
refs/heads/decoy 已删除
照这句恢复 → 得到「squash of feat-x」
真正丢掉的是 →「unmerged work on decoy」
即:本轮为了让 -D 可恢复而加的那个恢复句柄,恰恰在最需要它的场景里指向错误的 commit。
证据那一侧在本仓库里近乎自动成立,不需要构造:integration == main,而这里每个 tag 都落在某个已合并 PR 引入的 main commit 上 —— 所以任何 branch/tag 同名碰撞都会产出假证据。
修法:handle_wt 手上本来就有完整 ref(local branch="$2" = refs/heads/decoy)—— 把 "$branch" 传给 merged_pr_for 和 git rev-parse --short。git branch -D -- "$short" 那句不用动,它本来就只在 branch 命名空间里找。
🟠 -D 到不了它的主要调用方(这条会让本轮最大的收益白费)
- [Med]
phases/run.md:78—— 无人值守的合并后清理是safe-cleanup.sh --integration <b> … --apply,没有--squash-merged,而run.md:75合并用的是merge-pr --squash。run.md 本 PR 没动。于是在 FU-2 当初就是为之立案的那种 squash 仓库里,自动化调用方仍然什么都清不掉 —— 而且加了成本闸门之后,它连以前会打印的候选都不打印了。 - [Med]
reference/review-contract.md:54—— 同样的裸调用形状。
建议和 F1 放同一个 commit 里改,否则 --squash-merged 这个能力从自动化路径依然不可达。
🟡 其余(非阻塞)
- [Low]
phases/status.md:24-33(从 Medium 降级,采纳 Codex 的挑战):第 4 步里--squash-merged那条命令仍然能产出「候选带 PR 号」和(cannot verify …)两个文档化字符串,所以只有挂在普通命令上的那段散文过时了。残留的真问题是:文档化的普通 dry-run 现在输出(not checked — one gh API call per branch…),而 status.md 从没提过这个串。建议把「候选列出来」那句挪到--squash-merged命令底下,并补上(not checked)的说明。 - [Low]
:235NB 说「in a squash-merge repo the section above is ALWAYS (none)」,被脚本自己的输出证伪 —— 处于或落后于集成分支 tip 的分支(「一个 task 一个分支」下常见的零提交废弃分支)会被git branch --merged列出来。README.md:20/SKILL.md:49/reference/git-safety.md:29也重复了这句。改成「通常为 (none)」。 - [Low]
:73sed -n '2,46p'越界 4 行,把set -euo pipefail/integration=""/protect_csv=…也打出来。这是写死行号在一个每轮头注释都在长的文件上第二次漂移了 —— 建议在头注释末尾放个哨兵注释,用sed -n '/^#/p'或 awk 读到它为止。 - [Low]
:202,244,388protected 分支被静默continue,任何一节都不出现。配上加宽后的边界集,integration-tests/preview-site/develop-newui现在永久不可清理且零可见理由,handle_wt还把它们错标成「not a merged feature branch」。建议 dry-run 里打一行KEEP (protected)。 - [Low]
:67/:86--integration ""通过了元数和 flag 两道检查,留下空值、落进:86的自动探测默默猜出 main —— 正是:52-57自己称为「不可恢复」并为此拒绝未知参数要防的结果。need_val里把空值也拒掉。 - [Low]
:279,356恢复句柄没有 shell-quote 分支名。实测feat/x$(id)是合法 refname,句柄打出来是→ restore: git branch feat/x$(id) f275ec2,复制粘贴就会执行命令替换。用printf '%q'。 - [Low]
:246-247(Codex 提出、R4 补齐实证并修正了定性):branchrelease-1.2被同名 tag 遮蔽时,1b 迭代到的是heads/release-1.2,它既匹配不上is_protected的release[-_/.0-9]*、也匹配不上is_in_worktree的grep -Fqx。无 tag 时输出(none),有 tag 时输出would delete heads/release-1.2 (merged via PR #42)—— 一个受保护分支被列成删除候选。但不会真丢数据:git branch -D -- heads/release-1.2去找refs/heads/heads/…会失败,--apply实测打的是SKIP (delete failed)、ref 存活。所以这是报告层/保护契约被打破,不是破坏。修法:1b 改用%(refname)再剥refs/heads/,让迭代的是无歧义全 ref、比较的是裸名。 - [Low]
:248vs:277(R4 补扫) 1b 在第一个循环里为所有候选收证据,在第二个循环里才删,中间没有重新校验 tip 是否变了。按每次 gh 调用约 0.8 秒、25 个分支算,这是个 ~20 秒的 TOCTOU 窗口;而 pilot 自己的教条「一个 task = 一个分支 = 一个 worktree」意味着并发 agent 正在往这些分支上提交。-D绕过了 git 自己那道让第 1 节的-d免疫的复查。定为 Low 是因为sha_short是在删除时刻重读的,所以打出来的恢复句柄仍然正确、可恢复。建议删之前重跑一次merged_pr_for,至少比一下 tip sha。
建议
handle_wt那两处分支删除都以|| true结尾,于是git worktree remove成功后分支删除失败会完全静默 —— 报告写着removed … (squash-PR#42),而分支还在。1b 对同一情况打的是SKIP (delete failed),建议对齐。- 仍未动(本轮无变化):
allow_remote_cleanup在任何.sh里 0 命中,--remote闸门仍然只是文档;而且第 3 节依赖git branch -r --merged,它在 squash 仓库里同样恒返回 0 —— 远程清理正以第 1 节当初那种方式死着。值得开一条 followup 同时覆盖这两点。
反向验证(这些不是 bug,已排除)
- R1a 唯一那条 [High] 是自问自答(「still swallows? No — new guard catches flags. OK.」),不是 finding。
--protect ""的空条目被[ -z "$p" ] && continue显式跳过,零影响。 - R1b 说「jq injection via
$ENV.PILOT_INTEG,建议用--arg」——$ENV正是本轮的修法,它把值当数据而非程序传,实测注入已关闭。这条是对着修复本身报警。 -D的可达性矩阵:--apply单独 → 分支和 worktree 都在;--squash-merged单独 → 只打 would;两个都给 → 才删。1b 靠外层if结构性保证,handle_wt靠via != "git"只在 else 臂可达。
4-round · R1a/R1b=deepseek-v4-flash · R2/R4=opus · R3=codex(gpt-5.5) · 增量 0ff600d..bc840cf · 所有破坏性实证均在 /tmp 一次性 git init 沙箱里跑(stub 每次先用正/负对照校准),未对本仓库执行过任何 --apply,未修改任何仓库文件
## 🔴 handle_wt 用裸短名解析 ref,同名 tag 会劫持证据和恢复句柄 `git rev-parse decoy` 在有同名 tag 时取的是 **tag**。于是证据和本轮刚加的恢复句柄 都会描述错误的 commit —— 为了让 -D 可恢复而加的东西,恰恰在最需要它的场景里指错。 改成传完整 ref(`$branch` 本来就是 refs/heads/<name>)。 实测:分支 decoy=6f3da53、同名 tag 指向 main(5549b8b)。 裸短名解析出 5549b8b,完整 ref 解析出 6f3da53;修复后报告给出 **squash-PR#42** (6f3da53 的 PR),不是 tag 指向那个 commit 的 PR。 ## 🟠 -D 到不了它的主要调用方 —— 这是「修的东西到不了调用方」第三次 run.md:78 与 review-contract.md:54 调 safe-cleanup 时**没带 --squash-merged**, 而 run.md:75 合并用的是 merge-pr --squash。也就是说在 **FU-2 当初就是为之立案的**那种 squash 仓库里,自动化调用方仍然什么都清不掉;加了成本闸门之后连候选都不打印了。 前两次是 status.md(第一次)和 status.md 的纪律段(第二次)。同一个盲区第三次: 改完脚本没回头看谁在调它。 ## 7 条 Low - 恢复句柄没 shell-quote:`feat/x$(id)` 是合法 refname,打出来粘贴即执行命令替换。 改用 printf %q。实测输出 `git branch feat/x\$\(id\) 6f3da53`。 - `--integration ""` 过了元数与 flag 两道检查,空值落进自动探测**默默猜 main** —— 正是拒绝未知参数要防的那个「不可恢复」后果。现在空值也拒。 - 1b 迭代 `%(refname:short)`,同名 tag 下会拿到 `heads/x`,既匹配不上 is_protected 也匹配不上 is_in_worktree,受保护分支被列成候选。改成迭代 `%(refname)` 再剥前缀。 - protected 分支静默 continue,加宽边界集后 `integration-tests` 这类永久不可清理且零理由。 现在 dry-run 打 `KEEP (protected by name/pattern)`,但**只报模式保护** —— 集成分支和当前分支是结构性保护,每轮都说是噪音。 - TOCTOU:证据在第一个循环收、删在第二个循环,25 个分支约 20 秒窗口, 而 pilot 自己的教条意味着并发 agent 正在这些分支上提交。删之前重新取证,不一致就 SKIP。 - `--help` 写死行号第二次漂移(这次越界 4 行,把 set -euo 都打出来)。 改成读到哨兵注释为止,以后加头注释不会再漂。 - 「squash 仓库里上一节 **ALWAYS** (none)」被脚本自己的输出证伪 —— 处于或落后于集成分支 tip 的零提交废弃分支仍会被列出。README/SKILL/git-safety 三处同一句一并改成「通常」。 ## status.md 补上普通 dry-run 现在会打印的 `(not checked — one gh API call per branch…)`, 并写明那是**没查**不是**没有**。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
PR#47 review 的建议。那一处实例本 PR 修了,纪律本身没落地。 和 #45 一起收口,不往这个 PR 里加。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 929d8d51 增量复审(第 4 轮)[4-round]
先说好消息:handle_wt 那一半的 tag 修复是真的——实测一个被同名 tag 遮住的 worktree 分支,脚本正确打印 KEEP (not a merged feature branch)。
坏消息:这个 commit 在分支循环里亲手引入了它声称要修的 tag 劫持。
🔴 Blocking
[Critical] safe-cleanup.sh:273(连带 +263 / +295 / +296)—— %(refname:short) → %(refname)|sed 这一改,把一个「消歧过的名字」换成了「可被 tag 劫持的裸名」
%(refname:short) 在名字冲突时会主动吐出消歧后的 heads/decoy;新写法把这个消歧丢掉,只剩裸 decoy,而 git 解析裸名时 refs/tags 优先于 refs/heads。
实测(git 2.50.1,一次性仓库,reviewer 与 Codex 各自独立复现,R4 又复核一遍):
branch decoy = 3661392 ("PRECIOUS UNMERGED WORK")
tag decoy = fece62f (一个已合并的 commit)
OLD git branch --format='%(refname:short)' -> "heads/decoy" -> rev-parse = 3661392 (分支 ✅ 安全)
NEW git branch --format='%(refname)' | sed … -> "decoy" -> rev-parse = fece62f (TAG ❌ 劫持)
端到端复现(带 stub gh):新代码打印
deleted decoy (merged via PR #77) was=… → restore: git branch decoy …——删掉的是那条未合并的分支;旧代码一条都不列,是安全的。
一次踩三个后果:
- 证据取自 tag,却拿去授权对分支执行
git branch -D——未合并的工作被不可逆删除; was=/restore:恢复句柄打印的是 tag 的 sha,照着它恢复会建出错误的 commit,真正的 tip 只剩 reflog;这恰好把本次 commit 自己新加的恢复机制废掉了;- 新加的 TOCTOU 复查
merged_pr_for "$b"重新解析的还是同一个 tag——它永远不会触发。
修复:把 refs/heads/<name> 端到端带下去,并改用
git update-ref -d refs/heads/<name> <取证据时抓到的 sha>。这是一次原子的 compare-and-swap 删除:名字无歧义、没有 TOCTOU 窗口、也不需要第二次 gh 调用——A1 / B1 / 本条 Critical / 下面 handle_wt 那条,一并解决。
[High] safe-cleanup.sh:107-109 —— for p in $protect_csv + p="$(echo "$p" | xargs)" 把 protect 模式搞坏了两种,两种都以「显式受保护的分支落到 -D 路径上」收场
a\b → ab;keep'me 会让 xargs 死于未闭合引号 → 得到空串 → 被静默 continue 掉。实测:--protect "keep'me" 作用在一条真名为 keep'me 的分支上,脚本打印 would delete keep'me (merged via PR #99)。
修复:别用 xargs/echo;用 IFS=',' read -ra 切分,用 ${p#"${p%%[![:space:]]*}"} 去空白,并 set -f(或加引号)避免模式被 glob 展开。
[Medium] safe-cleanup.sh:420(同一缺陷还在 :209 / :262 / :349)—— $integration 仍然以裸名传给 git branch --merged,本轮新发现
这个 commit 修了 handle_wt 里分支名的 tag 遮蔽,却把 $integration 在每一处 --merged / rev-parse 调用点都留成了裸名,而歧义警告又被 2>/dev/null 吞掉。用真脚本端到端复现:仓库里有分支 main 外加一个指向未合并 tip 的 tag main → git branch -r --merged main 列出 origin/unmerged-precious → safe-cleanup.sh --integration main --remote --apply 打印 deleted origin/unmerged-precious,事后 git ls-remote 只剩 refs/heads/main。远程路径是唯一没有 -d 兜底的地方,未合并的工作直接在服务端被销毁。 同一个劫持还会让第 1 节列出真正未合并的分支(只靠 -d 救着),而真正已合并的分支从那一节里静默消失。
修复:在 :98 存在性检查之后立刻算出 integration_ref="refs/heads/$integration",之后每一处 --merged / rev-parse 都用它。
[Medium] safe-cleanup.sh:371-380 —— handle_wt 的 -D 路径没有拿到 TOCTOU 复查
守卫只加在了分支循环里。git worktree remove 拒绝脏工作区,但不拒绝tip 变了但干净的工作区,所以并发提交会在没有任何重新验证的情况下被销毁。用同一个 update-ref -d <expected-sha> 助手即可。
已确认(非 blocking)
| 严重度 | 位置 | 问题 |
|---|---|---|
| Low | safe-cleanup.sh:296-297 |
复查拿 merged_pr_for 的 ERR 哨兵直接比较,于是限流/5xx 会打印错误的理由 SKIP (tip changed since evidence was collected),而且不累加 sq_errors,:308 那句「this list is INCOMPLETE」永远不会出现。应单独判 = ERR、计数、并改说「could not re-verify」 |
| Low | phases/status.md:28 |
「恒返回 0 → 通常返回 0」这个订正做了 README / SKILL / git-safety,漏了 status.md——就在本 commit 编辑的那段下面三行,仍然写着 **恒返回 0**(head 上复核仍在) |
| Low | safe-cleanup.sh:77 |
-h 的 sed 范围包含哨兵行,帮助输出末尾仍会打印字面量 # ---8<--- end of header ---8<---,新加的 grep '^#' 滤不掉它 |
| Low | safe-cleanup.sh:255 |
新加的「说明为什么 KEEP」那行被 [ "$apply" != "1" ] 挡住,而本 PR 自己在 run.md:78 把 --squash-merged … --apply 定成了合并后的标准命令——这条「说清理由」的修复在标准调用里看不见 |
| Low | safe-cleanup.sh:234-236 |
apply 模式现在是 2N 次 gh 调用(取证 + 复查),但成本注释和两处文档都还写着「one gh API call per branch / 25 calls / 20.6s」。限流恰恰是这个特性最容易撞上的失败模式,现在来得快一倍 |
| Low | safe-cleanup.sh:398-423 |
--remote 只经 git branch -r --merged 找候选,而在 squash 仓库里它按构造恒返回 0 行。run.md:79 又把 --remote 写成合并后的远程清理手段——在这个 PR 存在的理由所指的那种仓库形态里,第 3 节是死代码,正是文件头 :14-19 自己论证要避免的「守卫不可用就被手工删除取代」 |
| Low | safe-cleanup.sh:262,:349,:198 |
set -o pipefail 开着,而这几处是 producer | sed | grep -Fqx;grep -q 命中即退出,上游可能吃到 SIGPIPE,于是命中了却返回 141。用 5000 行的 producer 复现:MATCH-LOST rc=141。在 :349 这会把一条 git 原生已合并的 worktree 分支从 -d 路径静默降级到 -D 路径 |
驳回
- A2
printf '%q'可移植性 —— 它是 bash 内建,且输出只是给人看的恢复提示,没有可移植性敞口。 - B2
git update-ref+ expected-old-value —— 那不是一条独立发现,那是 B1 的修法(已采纳进上面的 Critical 修复)。
建议
- 两处
-D站点已经连着三轮各自吃到「只修了一半」的补丁(第 2 轮只在分支循环修了 sha 句柄,第 3 轮只在handle_wt修了 ref 解析)。抽一个destroy_branch <refs/heads/name> <expected-sha> <reason>给两边共用,让下一次"半个修复"在结构上不可能发生。 - 加一组回归夹具:tag 与分支同名、以及 tag 与集成分支同名。这一类缺陷已经在三个不同调用点出现过,没有夹具它还会回来。
PK Review v4 · 4 轮:R1a/R1b DeepSeek-v4-flash(并行;A1 点到了复查的短名问题,A2/B2 驳回)→ R2 Opus 独立战略评审(自建一次性仓库 + stub gh,端到端复现出这条自伤的 Critical)→ R3 Codex 对抗挑战(独立重建仓库,4/4 全部 CONFIRM,未推翻任何 Opus 结论)→ R4 Opus 全量裁决(再补 3 条,其中 $integration 裸名导致远程未合并分支被真删是本轮新发现)。机械证据:git 2.50.1 下 OLD/NEW 两种写法的解析对比、stub-gh 端到端删除复现、--protect "keep'me" 保护绕过复现、git ls-remote 事后核对、5000 行 producer 的 SIGPIPE rc=141 复现。
第四轮指出:上一个 commit【亲手引入了它声称要修的 tag 劫持】——
%(refname:short) 改成 %(refname)|sed 之后,消歧过的名字变回裸名,
而 git 解析裸名时 refs/tags 优先于 refs/heads。
按评审的结构性建议做:抽 destroy_branch,两个 -D 站点共用。
## destroy_branch <refs/heads/name> <expected-sha> <label>
用 git update-ref -d 而不是 git branch -D:
- 全 ref,不经过裸名解析 → tag 劫持在结构上不可能
- expected-old-value = 原子 compare-and-swap → TOCTOU 窗口消失,
而且【不需要】上一轮那次复查调用,gh 调用数从 2N 回到 N
- 用来 CAS 的 sha 就是取证据时那个 sha,也就是打印出来的恢复句柄
—— 从取证到删除到恢复提示,自始至终同一个对象
merged_pr_for 改成接受 sha 而不是 ref:调用方解析一次并留着,
证据和授权删除的 sha 由构造保证是同一个,不存在第二次解析。
## 其余 blocking
- [High] protect 模式改用 IFS=',' read -ra + 参数展开去空白 + set -f。
echo|xargs 会解析 shell 引号:a\b 变成 ab,keep'me 直接让 xargs 死于
未闭合引号、得到空串被静默 continue —— 显式保护的分支落到删除路径。
- [Medium] $integration 全部改用 refs/heads/ 全名(--merged / -r --merged)。
同名 tag 会让 git branch -r --merged 回答 tag 的 commit,而 §3 远程路径
【没有 -d 兜底】,--apply 会在服务端销毁未合并分支。
- [Medium] handle_wt 的 -D 现在也走 destroy_branch,拿到同样的 CAS。
git worktree remove 拒绝脏工作区,但不拒绝 tip 变了的干净工作区。
## 7 条 Low
pipefail+grep -q 的 SIGPIPE(rc=141 把命中报成失败)改用纯 bash 匹配;
KEEP 理由不再被 apply 挡住(标准命令恰恰带 --apply);-h 不再打印哨兵行;
成本注释回到 N 次并说明为什么;status.md 的「恒返回 0」补成「通常」;
§3 在 squash 仓库为空时说清「没查」不是「没有」(远程 squash 清理记 FU-9)。
## 实测(一次性仓库 + 按 commit 诚实作答的 stub gh)
fixture:branch decoy(未合并,珍贵) + 同名 tag decoy(指向已合并 commit)
+ tag main(遮蔽集成分支) + 分支 keep'me(带单引号)
旧版(929d8d5):
## Local merged branches → would delete decoy / would delete keep'me
(tag main 劫持了 --merged,把两条未合并分支列进来)
APPLY 后:keep'me 被真删,decoy 靠 -d 兜住
新版:
## Local merged branches → (none)
## Squash-merged → KEEP (protected by name/pattern) keep'me,再无其他
APPLY 后:两条都在,sha 未变
CAS 语义单独验:正确 sha 删得掉;过期 sha 报
cannot lock ref ... is at X but expected Y,分支保住。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
四条 blocking + 七条 Low 全部成立,已修并推 那条 Critical 的判断我完全接受:上一个 commit 亲手引入了它声称要修的 tag 劫持。 按你的结构性建议做了抽了
顺带把你那条 Low(ERR 哨兵被当成"tip changed")一起消掉了 —— 现在没有第二次查询,也就没有把限流误报成 tip 变化的可能。 实测:按你的 fixture 重建,加了一个诚实的 stub gh我第一版 fixture 的 stub 对任何 sha 都回 fixture: 旧版 新版: 这一屏同时证了三条:Critical(tag 劫持)、High( CAS 语义单独验: 其余
一条我没做,记账了(FU-9)
第五轮只让它把话说清楚(为空且带 关于你建议的回归夹具同意,而且这一类缺陷已经在三个不同调用点出现过。这次的 fixture 脚本我是在临时目录里跑的,没进仓库 —— 因为它需要 stub |
* feat(pilot): FU-1 —— git-guard merge-pr 的 gh flag 改白名单 黑名单只能拒绝今天已知的危险 flag。gh pr merge 将来新增一个能绕过 分支保护的 flag,黑名单会静默放行、这里不会自动知道。改成白名单: 不认识的 flag 一律拒绝,要放行得有人明确加进去并写明理由 —— 这是 守卫应该失败的方向。 - 放行:--squash/--merge/--rebase/--auto,以及 --body/-b、--body-file/-F、 --subject/-t、--match-head-commit 的分离与粘连两种形式 - --admin/--repo/-R/--delete-branch 保留具名拒绝,只为让报错自己解释清楚 (白名单本来也会拒) - 多余位置参数一并拒绝:PR 号是唯一位置参数 - 顺带修一处注释:gh api 的错误 BODY 走 stdout,不需要 2>&1 —— 原注释 说反了,照着改会把 stderr 混进 JSON 让解析全线降级 从 PR#45 拆出。#45 那边只留 squash 清理一件事。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * chore: rebuild dist (averageTaskAge 98→99) fresh-build 检查会随日期漂移:statistics.json 的 averageTaskAge 按天算, 今天重建就与 main 里的不同。与本 PR 代码无关,但不提交 CI 会红。 这条漂移本身是个 followup,见 FU-7。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * docs: 账本 FU-1 标 done=PR#47,新增 FU-7(dist 检查随日期漂移) Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * Revert "chore: rebuild dist (averageTaskAge 98→99)" This reverts commit bfa5c9f. * docs: 撤回 FU-7 —— 误报,verify.yml 早就 pin 掉了那个字段 本地 dist 会随日期漂 ≠ CI 会红。verify.yml 96-122 行在 diff 前把 averageTaskAge pin 到 committed 值(PR#40 做的),所以那条守卫不会 因时钟变红。立 FU-7 时只看了 git status、没读 CI 脚本。 连带 revert 上一个多余的 dist commit。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * fix(pilot): 白名单管住了 flag,管不住它自己的选择器 评审 R3 挖出的:merge-pr 的第一个位置参数从来没被校验过,而 `gh pr merge` 接受 `[<number>|<url>|<branch>]`。URL 选择器完全无视 `--repo`(gh 2.92.0 实测),于是每道闸门读的都是本仓库(protection、 trunk 判定全用本地解析的 $repo),而 exec 出去的合并落在另一个仓库 —— 拿 Brood 的分支保护当证据,去合别人仓库的 PR。这正是上面拒绝 `--repo`/`-R` 要防的事,拒了 flag 却不看选择器,等于闸门开着。 四条一起修: 1. [Blocking] 选择器必须是纯数字。校验放在解析循环【之前】——放在 之后的话,`merge-pr --integration x`(忘了写 PR 号)会先死在循环里, 指着 --integration 的值说『多余的位置参数』,为两个 token 之前的 错误报错。选择器是最先被消费的,就得最先被判。 实测:URL 拒 / 分支名拒 / 首位 flag 拒(并指明是漏了 PR 号)/ 纯数字放行。 2. [Medium] --allow-trunk 增加 dismiss_stale_reviews 必须为 true。 原来的两个证明(保护要求审批、PR 已 APPROVED)都是【调用当刻】的 快照,而 --auto ——以及 gh 自己 help 写明的 merge queue,不带 flag 也会——把真正的合并推迟给 GitHub,那一刻本脚本没有任何检查会再跑。 不开 stale-dismissal 的话,检查通过之后再推的 commit 会顶着旧的 APPROVED 进主干。从已抓到的 $prot 里多解一个字段,不加 API 调用。 实测五种形状:true→放行,false/缺字段/无保护/坏 JSON→全部拒。 并补上 234 行注释:为什么这个字段承重,而 enforce_admins 不承重。 3. [Low] -s/-m/-r 加进白名单。gh 文档里它们是 --squash/--merge/--rebase 的正式别名,放长拒短显得武断,而武断的守卫会被绕过去。 4. [Low] 两处【运行时】报错的 usage 还写着 `[gh args]`,承诺着已经 不存在的透传语义,改成 `[allowlisted gh flags]`。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * docs: 记 FU-8 —— 守卫必须校验自己的选择器(纪律待落地) PR#47 review 的建议。那一处实例本 PR 修了,纪律本身没落地。 和 #45 一起收口,不往这个 PR 里加。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
两处冲突,都是 #47 合入 main 造成的: - git-guard.sh:整个文件取 main 的版本。白名单已经归 #47(以及 #50 的 locale 修复)管,这个分支不该再碰它 —— 本 PR 只做 squash 清理。 - followups.md:FU-1 取 main 的 done=PR#47(白名单实际是 #47 合的, 本分支上标 done=PR#45 是拆分前的旧状态);FU-2/3/4 取本分支的完成 标记;FU-7 及其撤回、FU-8 保留;FU-9 追加在末尾。十个条目一个没少。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第五轮 [4-round]
先说清楚:这一轮修对的东西是实打实的,我逐条跑过。 把两个 -D 站点合成一个 destroy_branch 是正确的结构性动作,它一次性杀掉了 tag 劫持、TOCTOU 和 2N 调用三件事:
| 上一轮的问题 | 实测结果 |
|---|---|
| [Critical] 分支循环里的 tag 劫持 | 仓库里 branch decoy=2801b02d…、TAG decoy=53c8614c…(不同 commit)。--squash-merged --apply 打印 deleted decoy … was=2801b02d…,stub-gh 日志显示取证调用是 repos/…/commits/2801b02d…/pulls —— 取的是分支的 sha;事后 refs/tags/decoy 原封不动 ✅ |
| [Medium] TOCTOU | CAS 是真的:git update-ref -d refs/heads/x <过期sha> 报 error: cannot lock ref …: is at 53c8614c… but expected 2801b02d…,分支存活 ✅ |
[High] echo | xargs 毁掉 protect 模式 |
--protect "keep'me" 现在打印 KEEP (protected by name/pattern) keep'me(此前是 would delete keep'me)✅ |
[Low] -h 打印哨兵行 / 2N gh 调用 / KEEP 理由在 apply 下不显示 / ERR 被误报成 tip changed / SIGPIPE 141 |
逐条实测,全部真修了,不是只加了注释 ✅ |
另外我要更正我自己上一轮的一个说法:那条 High 里 a\b → ab 的那一半是无效的 —— git branch 'a\b' 被 git 自己拒绝(fatal: 'a\b' is not a valid branch name),这个模式永远匹配不到真实分支。只有 keep'me 那一半是真的,而它已经修好了。
问题是这一轮又走了老路:修好一半,破掉另一半。
🔴 Blocking
1. [High] safe-cleanup.sh:225 —— bash 3.2 的 local 语义让恢复句柄指向错误的分支名
destroy_branch() {
local ref="$1" expect="$2" label="$3" short="${ref#refs/heads/}"一条 local 语句里,bash 3.2 用外层作用域展开后面的右值,而不是刚创建的那个 local。 实测(/bin/bash 3.2.57,这台机器上唯一的 bash,也正是 #!/usr/bin/env bash 解析到的那个):
ref=refs/heads/GLOBAL-LEFTOVER (外层遗留值)
one-local: ref=refs/heads/ARGUMENT short=GLOBAL-LEFTOVER ← 错
two-local: ref=refs/heads/ARGUMENT short=ARGUMENT ← 对
所以 short 取的是 §1b 循环在 :371 留下的全局 ref,不是 $1。端到端复现:worktree 分支 precious-wt 被正确删除,但报告打印的是
deleted loose-a (squash-PR#77) was=6161d17c… → restore: git branch loose-a 6161d17c…
—— 错误的分支名,和上一节打印过的那行一模一样,还带着一个看起来很真的 sha。如果 §1b 一次都没循环过,全局是空的,就打印 deleted / restore: git branch '' <sha>。§1b 自己是对的,但那是巧合(那里全局 ref 恰好等于实参)。
这是本轮引入的回归 —— 上一轮的内联代码打印的是调用方的 $b/$short,是对的。而它毁掉的恰恰是不可逆删除的唯一恢复句柄,就在这个 commit message 自己称为「the ONE exit for -D」的那个函数里。
修复:拆成两句 —— local ref="$1" expect="$2" label="$3" 然后 local short="${ref#refs/heads/}",正是 :396/:399 已经在正确使用的那种写法。
2. [High] safe-cleanup.sh:226 —— git update-ref -d 把 git 自带的「分支被 worktree 占用」拒绝给丢了
git branch -D 会拒绝删除任何 worktree 正在使用的 ref;git update-ref -d 什么都不拒绝。而脚本给出的替代守卫 is_in_worktree 是从 git worktree list --porcelain 的 branch refs/heads/* 行构建的 —— 一个停在 git rebase -i 中途的 worktree 根本不输出那一行,porcelain 报的是 detached。
我独立复现(与 R2、Codex 各自的复现无关):
$ git worktree list --porcelain
worktree …/wt
HEAD ca1beafb…
detached ← 没有 branch 行,is_in_worktree 看不见
$ git branch -D feat
error: cannot delete branch 'feat' used by worktree at '…/wt'
$ git update-ref -d refs/heads/feat <sha>
(成功,分支 GONE)
后果链条:agent 的 git rebase --continue 随后死于 update_ref failed for ref 'refs/heads/feat': unable to resolve reference,rebase 出来的 commit 悬在一个 detached worktree 里,而打印的恢复句柄指的是 rebase 之前的 tip —— 恢复不了真正丢掉的东西。
这不是竞态:rebase 状态会一直持续到 agent 恢复为止,而 phases/status.md 规定每次 pilot status 都跑这条命令,配上「一 task 一 worktree」的教条,这是常态而非边角。
修复(二选一):
git update-ref --stdin <<<"verify $ref $expect"验证后再git branch -D -- "$short"—— 保住 git 自己的 worktree 守卫,CAS 窗口 ≈ 0;- 或者把
wt_branches的来源扩到$(git rev-parse --git-common-dir)/worktrees/*/{rebase-merge,rebase-apply}/head-name和BISECT_START。
3. [Medium] safe-cleanup.sh:269 vs :324/:343/:427 —— %(refname:short) 会消歧成 heads/x,而所有消费方比的是裸名
git_merged_names 用 %(refname:short) 构建,而它在存在同名 tag 时会消歧成 heads/x;但每一个消费方比对的都是从 %(refname) 剥出来的裸名。实测(分支 mergedb 已合并进 main,另有 tag mergedb):
- §1 dry-run 打印
would delete heads/mergedb --apply对一个真的已合并的分支报SKIP (not safely merged) heads/mergedb(git branch -d -- heads/mergedb→branch 'heads/mergedb' not found)list_has "$git_merged_names" "mergedb"在 :343 未命中,于是把这个分支从-d安全路径静默降级到-D证据路径
没有数据丢失(全 ref + CAS + 证据仍落在正确对象上),但这是上一轮那条 Critical 的同一个 tag 歧义类别,在路由层存活下来。另外,如果 tag 与当前分支同名,current_branch 会变成 heads/main,is_protected 里「绝不动当前分支」那条比较也跟着退化 —— 今天只是被 is_in_worktree 挡住了。
修复:全程规范化成 full ref(两侧都用 %(refname)),只在 printf 的时候才剥成裸名。
已确认(非 blocking)
| 严重度 | 位置 | 问题 |
|---|---|---|
| Low | :455 |
git worktree remove 跑在 CAS 之前,tip 动过就留下「worktree 删了、分支还在」。:462 的 via="git" 孪生分支更糟:-d 失败被 || true 吞掉、什么都不打印,静默交付得比 :471 dry-run 承诺的少 |
| Low | :501 |
git push --delete 没有 --force-with-lease 那种 expected-old-value,服务端不可逆。§3 是唯一既没有 -d 兜底、也没有 CAS 的破坏性路径 |
| Low | :226/:235 |
2>/dev/null 加上单一 SKIP 文案,把每一种 update-ref 失败(ref 被锁、权限、expect 格式错)都报成「tip moved or ref already gone」 |
| Low | :124 |
read 遇到第一个换行就停,所以 --protect $'a\nb' 静默丢掉 b(实测);含字面 , 的 refname 永远无法被保护(实测)。另外 herestring 不做 glob,set -f/set +f 是死代码,而 set +f 会无条件清掉调用方的 noglob |
| Low | :505 |
远程列表没有 expected-sha 守卫(B2,已从 Medium 降级::487 的 fetch --prune 紧挨着它,我复现不出破坏性后果)。但注意 dry-run 并不 fetch(:487 只在 --apply 下跑),所以预览是拿陈旧的 remote-tracking ref 算的,--apply 可能动到 dry-run 从没显示过的集合 |
补充发现(R4 全量复扫)
[Low] :226 —— git update-ref -d 会留下 branch.<name>.remote / branch.<name>.merge 配置,git branch -D(上一轮的写法)会清掉
两种方式都实测过。在 pilot「一 task 一分支」的命名习惯下,之后一个同名的 task 分支会静默继承过期的 upstream 配置;一次 28 分支的清理会留下 56 条孤儿配置。修:CAS 删除成功后跟一句 git config --remove-section "branch.$short" 2>/dev/null || true。
[Low] :392/:398 —— main_root="$(git rev-parse --show-toplevel)" 取的是「当前」worktree 的根,不是主 worktree
从一个 linked worktree 里跑(也就是文档规定的一 task 一 worktree 形态,pilot status 的正常调用方式),那条注释写着「never the primary worktree」的守卫就豁免错了对象,并把真正的主 worktree 当成候选。实测打印 SKIP (remove failed) …/repo [parked] —— 只是因为 git worktree remove 自己独立拒绝了主 worktree 才没出事,而那条消息把拒绝的原因也说错了。修:取 porcelain 第一个块的路径,或 dirname "$(git rev-parse --git-common-dir)"。
驳回(DeepSeek 5 条,0 条成立)
- A1/B1「
handle_wt缺 CAS / 用了过期的wt_sha」 —— 前提反了:取证时刻的 sha 就是 CAS 的操作数,:438 也确实是从 full ref 解析出来的。真正的残留只是 :455 的顺序问题(Low)。 - A2「
git branch -r --merged仍用裸名解析」 —— 误报,:505 已经是"$integration_ref",那正是上一轮的修复。 - A3「
is_protected仍比裸$integration」 —— 误报,is_protected的每个调用方传的都是裸名,换成 full ref 反而全崩。
建议
- 上面三条 blocking 加两条新 Low,根因和前面四轮完全相同:一个分支同时存在两种表示(裸名 vs full ref、
%(refname:short)vs%(refname)、$1vs 泄漏进来的全局)。建议按 R2 的思路一次性做完规范化 —— 全程 full ref,只在printf时剥裸名 —— 而不是再来一轮逐点打补丁。 - 给 CI 加一个 bash 版本守卫或显式
/bin/bash冒烟测试:bash 3.2 的local语义 bug 在任何 bash ≥ 4 上都看不见,而这个脚本是发给 macOS 的。这台机器上bash和/bin/bash都解析到 3.2.57,连对照组都没有。 - 文件头现在写着「The sha it swaps against is the one the evidence was gathered for, printed as the recovery handle. Same object end to end」—— 在目标平台上、在 §2 里,这句话目前是假的。重新请求评审前,请对着一个真实 worktree 跑一次
--apply验证它。
PK Review v4 · 4 轮:R1a/R1b DeepSeek-v4-flash(并行,5 条 0 成立:A1/B1 前提反了,A2/A3 是对着已经改好的代码报的误报)→ R2 Opus 独立战略评审(自建复现环境,挖出 update-ref 丢失 worktree 守卫这条 High + %(refname:short) 路由层歧义 + 4 条 Low,并点出跨五轮的同一根因)→ R3 Codex 对抗挑战(独立复现 F1/F2/F3,并回验 5 条上一轮的修复,0 挑战)→ R4 Opus 全量裁决(在目标平台 shell 下实际执行,挖出 bash 3.2 local 语义导致恢复句柄指向错误分支这条 High,前三轮都是在「推理重构」而没在 3.2 上跑)。机械证据:tag 劫持端到端复现(取证 sha = 分支 sha,tag 未动);CAS 拒绝过期 sha 的原文错误;--protect "keep'me" 保护生效;rebase 中途 worktree 下 branch -D 拒绝 vs update-ref -d 成功的对照;bash 3.2.57 单条 local 的作用域实测(short=GLOBAL-LEFTOVER)。
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第六轮 [3-round,R3 Codex 本轮未重跑,理由见文末]
先说这次增量是什么
78c27352..e4a93f36 是一次纯粹的 origin/main 合并,带进来的是 #48(doctor 文档)和 #50(locale-proof 选择器)—— 两个都已经单独评审并 APPROVE 过了。三方核实:
git diff 78c27352..e4a93f36 -- plugins/pilot/skills/pilot/scripts/safe-cleanup.sh为空(blob 在两个 commit 上都是d03a80b8)- head 上的
git-guard.sh与 main 逐字节相同 - 在一次性 clone 里重放这次合并,产出的 tree 与实际提交完全一致 —— 没有 evil merge,没有手工解决
所以上一轮的三条 blocker 一条都没动。这是第六轮,remediation 为零。
三条我都在这个 head 上重新执行过一遍(不是重读代码),结论与上轮一致,细节见上一条 review,这里只放复现证据:
[High] safe-cleanup.sh:225 —— 把发布版的 destroy_branch 原样抽出来,设一个被污染的外层 ref=(正是分支循环留下的那个),对 refs/heads/victim 调用:
deleted WRONG-NAME-FROM-OUTER-SCOPE (merged via PR #77) was=928e7131…
→ restore: git branch WRONG-NAME-FROM-OUTER-SCOPE 928e7131…
被删的是 victim,打印的是另一个名字。R2 端到端跑整脚本也复现了(删掉 bravo,打印 alpha 配 bravo 的 sha)—— 不可逆删除的唯一恢复凭据,指向一个从没被碰过的分支,还配着错误的 commit。
[High] :226 —— rebase 中途暂停的 worktree 在 porcelain 里报 detached、is_in_worktree 看不见;git branch -D feat 正确拒绝("cannot delete branch 'feat' used by worktree"),而 git update-ref -d 返回 0;随后 git rebase --continue 死于 cannot lock ref 'refs/heads/feat',rebase 结果只剩一个无引用对象。
[Medium] :269 —— 有同名 tag dup 时 %(refname:short) 产出 heads/dup。R2 把后果延伸了一层:git_merged_names 在 :290 直接喂给 git branch -d -- "$b",于是 heads/dup 报 "branch not found"、打印 SKIP (not safely merged) heads/dup ——一个 -d 安全的分支被谎报成未合并,接着又被 §1b 按 -D 证据路径重新捡起来删掉。
🔴 本轮新发现(前面几轮都没碰到,也从未经过 Codex 挑战)
[High] safe-cleanup.sh:505 → :501 —— §3 --remote 拿「本地」分支当已合并判据,却在「服务端」无条件删除
done < <(git branch -r --merged "$integration_ref" 2>/dev/null) # integration_ref = refs/heads/<local>
...
git push "$remote_name" --delete -- "$short" # 无 --force-with-lease、无 CAS、无 -d 兜底git fetch --prune(:487)只刷新 remote-tracking ref,从不推进本地 main。所以判据是「操作员本地 checkout 恰好是什么样」。
对一个真实 bare remote 复现:
origin/main 是否包含 colleague-precious : 0 ← 服务端并没有合并它
本地 main 是否包含 : 1 ← 操作员本地合了但没 push
$ safe-cleanup.sh --integration main --remote --apply
## Remote merged branches (origin)
deleted origin/colleague-precious
$ git ls-remote --heads origin
main ← 别人的分支在服务端没了
本地分支还能靠 reflog 捞回来,服务端这条捞不回来,而且它本来就不是操作员该弄丢的东西。§3 是三条破坏性路径里唯一既没有 -d 兜底、也没有 compare-and-swap 的一条,而文件头 :4-12 仍然承诺它「只删已合并且未受保护的」。
修复:fetch 之后从 refs/remotes/$remote_name/$integration 取判据(本地 integration 领先于它就直接拒绝),并把删除改成 CAS:git push --force-with-lease="$short:<列举时看到的 sha>" "$remote_name" --delete -- "$short"。
已确认(Low)
safe-cleanup.sh:202 —— case "$out" in *[!0-9]*) 正是 #50 刚从 git-guard.sh 里拿掉的那个 collation 区间构造。#50 自己的 commit 把它定性成文件级约定(「让文件只持有一种『是不是数字』的判据」),连它自己证明不可利用的那处都改了。这次合并把那个教训 merge 进来了,却没有把它应用到同一个 skill 里的兄弟脚本。 这里同样不可利用(值来自 jq 的 ASCII .number),标准与 #50 第二处一致。
驳回(DeepSeek 4 条,0 条成立)
- A1「
*[!0123456789]*仍然拒绝+/-/#」 —— 那就是一个「纯 PR 号」闸门想要的行为;而它建议的改法是退回 #50 刚刚移除的区间写法。 - A2 —— 没有提出任何缺陷,只是在评论一条注释。
- B1/B2 —— 实测为假:枚举写法在测过的每个 locale 下都拒绝 U+0665/U+06F5/U+0660/U+0967/U+FF15;而 B2 建议的「
[0-9]+LC_ALL=C」正是造成这个 bug 的那个构造。
更正我自己上一轮的一处措辞
上一轮我在遗留项里写了「REQ_LIST 仍然从不初始化」—— 那条属于 check-docs.sh(PR#49),不属于本 PR。grep -rn REQ_LIST 在本仓库这个 worktree 里零命中。是我串了两个脚本,撤回。
建议
destroy_branch的2>/dev/null把 git 自己的诊断吞了,于是「blocker 2 那种静默成功的删除」和「真正的锁/权限错误」在输出里长得一模一样,都是那句 "tip moved since evidence was collected, or ref already gone"。把 stderr 捕获下来,失败时打印出来。local a="$1" b="${a...}"这个形状在 bash 3.2 上是全文件级隐患,不是单点:handle_wt/is_protected/merged_pr_for今天安全只是因为它们没有互相引用。建议在set -euo pipefail旁边写一行约定,免得下次编辑又引进来。- 三条破坏性路径现在有三套不同的安全模型(§1/§2-git 用
-d,§1b/§2-squash 用 CASupdate-ref,§3 用裸push --delete)。上面那条新 High 就是这个缺口的代价。
PK Review v4 · 3 轮:R1a/R1b DeepSeek-v4-flash(并行,4 条 0 成立,其中一条建议退回 #50 刚修掉的写法)→ R2 Opus 独立战略评审(三方核实增量是纯合并、逐条重新执行三个 blocker、把 blocker 3 的后果延伸到 §1→§1b 升级、并抓出我上一轮串脚本的错误)→ R4 Opus 全量裁决(挖出 §3 本地判据 / 服务端删除这条新 High)。R3 Codex 本轮刻意未重跑:本轮唯一改动是合并,safe-cleanup.sh 的 blob 在两个 commit 上逐字节相同(d03a80b8),Codex 在第五轮已经对这份完全相同的输入做过对抗挑战,重跑是仪式不是评审。destroy_branch 在污染全局下的直接调用输出;真实 bare remote 上 origin/main 不含 / 本地 main 含 → 服务端分支被删 + git ls-remote 事后核对;blob hash 与三方合并核验。
第五轮的裁决我漏看了(只看了最后一条 review),于是上一次推的只是一次
纯 merge,三条 blocker 一条没动。评审的判词成立:remediation 为零。
## 1. [High] bash 3.2 的 local 语义,把恢复句柄指向了别的分支
local ref="$1" expect="$2" label="$3" short="${ref#refs/heads/}"
一条 local 语句里,bash 3.2 用【外层】作用域展开后面的右值。实测 3.2.57
(macOS 的 /bin/bash,也正是 #!/usr/bin/env bash 解析到的那个):
外层 ref=refs/heads/GLOBAL-LEFTOVER
one-local: ref=refs/heads/ARGUMENT short=GLOBAL-LEFTOVER ← 错
two-local: ref=refs/heads/ARGUMENT short=ARGUMENT ← 对
所以 short 取的是 §1b 循环留下的全局 ref。毁掉的恰恰是不可逆删除的
唯一恢复凭据 —— 而且就在这个 commit 自称「the ONE exit for -D」的函数里。
拆成两句 local。
## 2. [High] update-ref -d 把 git 自带的 worktree 占用拒绝丢了
git branch -D 会拒绝删除 worktree 正在用的 ref;update-ref -d 什么都不拒绝。
而 is_in_worktree 依赖 porcelain 的 branch 行,停在 rebase 中途的 worktree
报的是 detached、根本不输出那一行。
加 ref_in_use_by_worktree():除了 symbolic-ref HEAD,还读每个 worktree 的
rebase-merge/head-name 和 rebase-apply/head-name —— 进行中的操作把真实分支
记在那里,这才是它可被发现的地方。
实测(worktree 停在 git rebase -i 的 edit 上):
第五轮版本: deleted feat … → rebase 结果丢失
现在: SKIP (checked out by a worktree — or being rebased there) feat
## 3. [Medium] %(refname:short) 在同名 tag 下产出 heads/dup
git_merged_names 改成 git_merged_refs,全程用全 ref。短名是【消歧过的】,
heads/dup 喂给 git branch -d 会报 branch not found → 打印
SKIP (not safely merged) —— 一个 -d 安全的分支被谎报成未合并,接着被 §1b
按 -D 证据路径重新捡起来删掉。
实测:第五轮版本 SKIP (not safely merged) heads/dup;现在正常
Deleted branch dup 走 -d 路径。
## 4. [High·本轮新发现] §3 拿本地判据,在服务端不可逆删除
git fetch --prune 只刷新 remote-tracking ref,从不推进本地 main。所以
「本地合了但还没 push」这种再正常不过的状态,会让同事的未合并分支
看起来已合并。对真实 bare remote 复现:
origin/main 含 colleague-precious: 0 ← 服务端并没有合并
本地 main 含 colleague-precious: 1
→ deleted origin/colleague-precious,git ls-remote 确认服务端没了
这是三条破坏性路径里唯一既没有 -d 兜底、也没有 CAS 的一条,而且丢的
不是操作员自己的东西。
改:判据取 refs/remotes/<remote>/<integration>;本地领先于它就直接拒绝
(能一个 push 修好,而错答案不可逆);删除改成
--force-with-lease="<short>:<列举时的 sha>"。
实测:上面那个场景现在打印「skipped — local 'main' is AHEAD of origin/main」,
服务端 colleague-precious 保住;服务端确实合并的正路径照常删,并打印 was= sha。
## 5. [Low] *[!0-9]* → *[!0123456789]*
#50 把这条定成文件级约定(「让文件只持有一种『是不是数字』的判据」),
这次合并把那个教训 merge 进来了,却没应用到同一个 skill 的兄弟脚本。
这里同样不可利用(值来自 jq 的 ASCII .number),标准与 #50 第二处一致。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
你的判词成立,我先认这一条:上一次推的确实只是一次纯 merge,三条 blocker 一条没动 —— 因为我漏看了第五轮的裁决。 我当时用 四条全部修掉,推 1. [High] bash 3.2 的
|
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第七轮 [4-round]
先说清楚:这一轮修的四件事,我逐条实跑过,全部是真的
| 上轮/自查项 | 实测(parent e4a93f36 → head 3dcbdd5f) |
|---|---|
[High] bash 3.2 单条 local 让恢复句柄打印错名字 |
把外层 ref=refs/heads/GLOBAL-LEFTOVER 污染成 §1b 循环留下的样子,再 destroy_branch refs/heads/victim:head 打印 deleted victim … was=3273f7c9… → restore: git branch victim 3273f7c9… —— 名字和 sha 都对了 ✅ |
[Medium] %(refname:short) 在同名 tag 下产出 heads/dup |
同名 tag 场景:parent 报 SKIP (not safely merged) heads/dup(一个 -d 安全的分支被判未合并),head 正确删除 dup ✅ |
| [High/自查] §3 拿本地 integration 当服务端删除的判据 | 真 bare remote:origin/main 不含 colleague-precious、本地 main 含。parent --remote --apply → deleted origin/colleague-precious,git ls-remote 确认服务端已消失、bare remote 无 reflog 可救;head → (skipped — local 'main' is AHEAD…),分支完好 ✅ 这条自查是真发现,修得也对 |
--force-with-lease 对 --delete 到底生不生效 |
git 2.50.1 实测:喂过期 sha → ! [rejected] (delete) -> feat-x (stale info),分支留在服务端;喂当前 sha → - [deleted] feat-x。CAS 成立 ✅(DeepSeek 这条质疑是假阳性,已驳回) |
ref_in_use_by_worktree 能不能看见 porcelain 报 detached 的 rebase 暂停 |
干净的 rebase -i + break:rebase-merge/head-name = refs/heads/feat,函数返回 IN USE ✅ —— 但只在特定 cwd 下,见 Blocking 1 |
问题是:这已经是连续第四轮「把点名的实例修好,同时放出同一类的新变种」。而且这一轮的新变种,恰恰长在这一轮新写的那个保护函数上。
🔴 Blocking
1. [High] safe-cleanup.sh:246 —— 这一轮新写的 rebase 保护,只在 cwd 恰好是主 worktree 根目录时有效
f="$(git -C "$wt" rev-parse --git-path "$p" 2>/dev/null || true)"
[ -f "$f" ] || continue--git-path 对主 worktree 返回的是相对路径(.git/rebase-merge/head-name),只有对 linked worktree 才返回绝对路径。而 [ -f "$f" ] / cat "$f" 是在脚本自己的 cwd 下解析的,不是在 $wt 下。
实测(主 worktree 停在 rebase -i -x false,porcelain 报 detached,head-name=refs/heads/feat):
cwd = repo/ → GUARD: protected
cwd = repo/sub/ → GUARD: NOT PROTECTED → deleted feat (merged via PR #77)
cwd = 另一个 linked worktree → 同上
随后 git rebase --continue → error: cannot lock ref 'refs/heads/feat'
最后那句报错,逐字就是这个函数自己的注释里说它要防的那件事。 而 phases/status.md:40 和 phases/run.md:78 都没有先 cd,加上 SKILL 自己的教条「一个 task = 一个分支 = 一个 worktree」,「agent 的 cwd 是某个 linked worktree」正是常态而不是边角。三方各自独立复现(reviewer / Opus R2 / Codex R3,测到的 git_path_main 字符串完全一致)。
修复:gd="$(git -C "$wt" rev-parse --absolute-git-dir)",再测 "$gd/$p"(已验证:从 repo/sub 跑也能得到 /…/repo/.git/rebase-merge/head-name,exists=yes);或者把相对的 $f 显式挂到 $wt 下。
2. [High] safe-cleanup.sh:238 —— ref_in_use_by_worktree 只枚举了 rebase,git bisect 中途完全没保护 —— 比它替换掉的 git branch -D 更弱
8-commit bisect 进行中,porcelain 报 detached,BISECT_START=feat
git branch -D feat → error: cannot delete branch 'feat' used by worktree at … rc=1
本脚本 → deleted feat (update-ref -d 成功)
git bisect reset → fatal: invalid reference: feat rc=1
从 git branch -D 换到 git update-ref -d 是为了拿 CAS,这没错;但换来的代价是把 git 自带的「这个 ref 有 worktree 在用」拒绝整个丢掉了,然后用手写枚举去重建它。枚举漏一个状态就是一个静默的洞——今天是 bisect,下一个是 revert --continue / sequencer。
修复:短期把 BISECT_START 也加进枚举(注意它存的是短名不是 ref);根本上别再重建这个判断——要么留着 git branch -D 另想办法拿 CAS,要么直接问 git。
3. [High] safe-cleanup.sh:545,567,599 —— dry-run 预测不了 apply,而且偏偏是唯一不可恢复的那条路
git fetch --prune 被 --apply 门控着,可是新的 §3 判据(show-ref / merge-base --is-ancestor)和候选列表本身,读的正是 fetch 会重写的那批 remote-tracking ref。
同一状态、背靠背两次实测(真 bare remote):
dry-run → ## Remote merged branches (origin)
(none)
--remote --apply → deleted origin/feat was=656275ee
git ls-remote 之后 → 只剩 refs/heads/main
另一组状态下则是 dry-run 报 (skipped — local 'main' is AHEAD…)、apply 照样 deleted origin/colleague。操作者看的那次 dry-run,和真正执行的那次不是同一个判断。 这条路删的是服务端,没有 reflog、没有 -d 兜底。
修复:dry-run 也 fetch(或用 ls-remote),两种模式基于同一批 ref 求值。dry-run 只写 remote-tracking ref,这点代价远小于一次无法预测的服务端删除。
4. [High] safe-cleanup.sh:582 —— §3 只用 is_in_worktree 把关,根本没调用这一轮新写的 ref_in_use_by_worktree(前面三轮都漏了这条)
wt_branches(:309)只由 porcelain 的 branch refs/heads/* 行拼出来,所以同一个 detached 盲区在这里原样存在——而这里那句注释写的是「never delete the remote of a checked-out/dirty worktree branch」。
端到端实测:worktree 停在 bisect 中途、BISECT_START=feat、porcelain 报 detached:
ls-remote 之前 → refs/heads/feat refs/heads/main
--remote --apply → deleted origin/feat was=8d2dd2de
ls-remote 之后 → refs/heads/main
在服务端,不可恢复。 新写的保护函数存在,但这条最危险的路没接上去。
修复::582 处同样调用(修好后的)ref_in_use_by_worktree "refs/heads/$short"。三个破坏性站点(§1b / §2 / §3)应当统一走同一个守卫,而不是只有 destroy_branch 里面有。
其余(不阻塞)
- [Low]
:137—— 这次提交自己在:202宣布[!0-9]→[!0123456789]是文件级约定(「不让任何兄弟脚本保留 locale 相关的『这是不是数字』」),但is_protected里仍然是 collation range:case "$name" in "$p"[-_/.0-9]*)。这个方向是 fail-safe(fa_IR 类 collation 下只会过度保护),所以只是 Low —— 但它和同一个文件里刚写下的理由自相矛盾。 - [Low]
:343——git branch -d -- "$b" 2>/dev/null只重定向了 stderr,git 自己的Deleted branch tmpb (was 6a6580a).会从 stdout 漏进报告中间(已验证)。加个>/dev/null。 - [Low] 性能 ——
destroy_branch现在每个候选分支要按已注册 worktree 数量派生 ~1+3× 个 git 进程;文档里那个 25 分支的例子约 250 次。不阻塞,但和这轮刚拿回来的成本闸门方向相反。 - [Note] §3 的
--is-ancestor门 —— 任何人只要本地main上多一个没 push 的 commit,整个远端小节就永久变成 no-op。可以考虑把判据收窄成「候选分支是否被origin/<integration>包含」,而不是整节门控。
关于「为什么这个 PR 走到第七轮」
不是判据在飘。把七轮连起来看,每一轮修的都是上一轮点名的那几个具体实例,而不是那一类:
- 3→4 轮:修好了
handle_wt的 tag 劫持,同一个 commit 在分支循环里造出 tag 劫持; - 4→5 轮:合成
destroy_branch(一次干掉劫持 + TOCTOU + 2N 调用,方向完全正确),引入 bash 3.2local作用域 bug; - 5→6 轮:纯 merge origin/main,remediation 为零;
- 6→7 轮(本轮):三条 blocker 全修好、还自查出一个真 High,但新写的守卫函数本身带着两个 High(cwd 相关失效 + bisect 没枚举到),而且没接到最危险的 §3 上。
真正的根因是架构上的:git update-ref -d 换掉 git branch -D 之后,git 自带的「有 worktree 在用」拒绝被丢掉了,代之以手写枚举——枚举天然是「漏一个就静默放行」。这一条不解决,第八轮大概率还是同样的形状。建议下一轮不要再补第三个状态路径,而是:三个破坏性站点统一走同一个守卫 + 让 git 自己回答「这个 ref 能不能删」。
4-round:R1a/R1b DeepSeek-v4-flash → R2 Opus 独立复审(把 R1 五条全部用反证驳回)→ R3 Codex 对抗性 PK(0 挑战 / 3 确认,独立沙箱跑真脚本,测得数值与 R2 逐位一致)→ R4 Opus 终裁 + 全量补扫(新增第 4 条 Blocking)。所有 rc / ls-remote 结果均为 head 与 parent 在同一状态下的实跑对照。
六轮评审都在同一个能力上,每轮都挖出实测复现的真缺陷:同名 tag 劫持导致
删掉一条未合并分支;恢复句柄打印另一个分支的名字(bash 3.2 在外层作用域
展开 local 多重赋值的右值);丢掉 git 自带的「ref 被 worktree 占用」拒绝;
证据与删除之间的 TOCTOU 窗口;拿本地分支当判据、在服务端删掉同事没合并
的工作。没有一条是评审吹毛求疵 —— 每一条我都自己复现过。
结论不是「防得更严」:
自动执行不可逆删除、而判据必须从服务端推断,所需的把握程度配不上
它买到的东西 —— 它买到的只是不用敲 git branch -D <名字>。
那六个缺陷全部是「删」的属性,不是「列」的属性。所以:
- 删掉 destroy_branch / ref_in_use_by_worktree / update-ref CAS(-75 行)
- §1b 只打印:分支名 + PR 号 + tip sha + 可粘贴的 git branch -D 命令。
带不带 --apply 输出完全一样。
- worktree 的 squash 证据路径同样只打印(git-native 那条仍走 -d,因为
git 自己会拒绝它看不出已合并的东西 —— 那是 git 提供的安全网,不是我写的)
- §3 --remote 只列不删。远程交给 GitHub 的 auto-delete-on-merge:它在
合并真正发生的那一侧判定,不会被一个还没 push 的本地分支骗到。
(「本地领先于 remote-tracking 就拒绝」的检查保留 —— 现在不是防误删,
是防列出一份错误的、会被人粘贴执行的清单)
保留的是这个功能里真正难、也真正有价值的那半:在 git branch --merged
恒为空的仓库里,按 commit 向 GitHub 逐条取合并证据。
在 Brood 自己身上跑(--squash-merged --apply):
## Squash-merged local branches
feat/guard-allowlist (merged via PR #47) tip=b0693bb8699a
delete with: git branch -D feat/guard-allowlist
fix/doctor-single-trunk (merged via PR #48) tip=84b3635e96e0
delete with: git branch -D fix/doctor-single-trunk
fix/selector-locale (merged via PR #50) tip=f89f638fc831
delete with: git branch -D fix/selector-locale
── listed, NOT deleted.
第六轮那四条 blocker 现在【在结构上不存在】,不是被防住了。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第八轮 [4-round]
「把不可逆操作全部删掉」这个方向是对的,而且它确实把上一轮四条 High 连根拔了 —— destroy_branch、ref_in_use_by_worktree、--git-path 在 HEAD 上 grep 零命中,A1/A2/A4 没有任何存活代码路径,bash -n 干净。
但这次的清扫停在了「ref 写入」,没有走到「文件系统写入」。 剩下的唯一一个不可逆动作 —— git worktree remove —— 恰恰是这次提交新接到「GitHub 推断出来的 squash 证据」上的。而且这次新写下的两句绝对保证,被它自己四行之外的代码证伪了。
🔴 Blocking
1. [High] safe-cleanup.sh:466(守卫在 :415)—— git worktree remove 会 rm -rf 掉一个含 gitignore 文件的工作树,依据只是 GitHub 推断的 squash 证据
脏检查用的是 git status --porcelain,它不列 gitignore 的文件;而 git worktree remove 不带 --force 时也容忍「只有 ignored 文件」的工作树。两层各自放行,于是它就被删了。
HEAD 上的完整守卫矩阵(真跑,--squash-merged --apply + stub gh):
| 工作树状态 | 结果 |
|---|---|
| 脏(tracked 改动) | KEEP ✅ |
| 只有 untracked 文件 | KEEP ✅ |
| 只有 gitignore 文件 | 目录被删,文件不可恢复 ❌ |
| rebase 中途(detached) | KEEP ✅ |
复现内容:一个 .env(里面 SECRET=abc)+ ignored-stuff/keep.txt → DIR GONE、文件 LOST。三方各自独立复现(reviewer / Opus R2 / Codex R3,Codex 报 before=1 after=0,并确认输出里写的是 removed … (squash-PR#45))。
这是本 PR 新引入的:git show 23d6442: 显示 PR base 上 §2 只在 git branch --merged 的 git 原生证据下移除工作树,而 squash 仓库里那个谓词按构造返回 0 行 —— 也就是说 §2 以前基本从不触发,现在它对每一个合过 PR 的工作树都触发,而按 SKILL 自己的教条(一个 task = 一个 worktree)这正是主导单位。
而且它是无人值守跑的:phases/run.md:78 和 phases/status.md:40 都传 --apply。
被证伪的三处自述,全在同一个文件里:
- 头部
:5-6「NEVER … This script does not do irreversible deletes at all」 - 头部
:10「removed only if their working tree is CLEAN」 - 本次提交新写的
:470-471「The squash-evidence path deletes NOTHING」
修复:脏检查换成 git status --porcelain --ignored=matching(或者只要工作树里有 ignored 文件就拒绝);并且在 squash 证据这条路上套用这个 PR 自己的论点 —— 打印路径和 git worktree remove 命令,别替人执行。「凭服务端推断的证据删掉一整个装着不可恢复本地文件的目录」,正是这次提交在别处全部删掉的那一类动作。
2. [High] safe-cleanup.sh:498 + :541 —— 看起来最安全的 dry-run,恰恰是那个用陈旧数据吐出危险命令的模式
git fetch --prune 仍然只在 --apply 下跑,而 §3 的清单来自 git branch -r --merged "$remote_integration" —— 正是 fetch 会重写的那批 ref。§3 现在不删了,但它打印的是可以直接粘贴的 git push --delete(PR base 23d6442 打的是信息性的 would delete origin/x,可粘贴命令是本次新加的)。
真 bare remote 上复现:origin/feature-a 已合入 origin/main,随后同事往它推了一个未合并的 commit。
默认 dry-run(不 fetch)→ origin/feature-a — delete with: git push origin --delete feature-a
--apply(会 fetch) → (none) ← 正确
照着 dry-run 那条粘贴执行 → ref 从 bare 仓库消失,logs/refs/heads/ 不存在(bare 下 core.logAllRefUpdates 默认 false),不可恢复。脚本自己 :523 就写着「the commands printed here get pasted」。:520 那个 --is-ancestor 守卫只校验了 integration ref 的新鲜度,从来没校验候选分支本身。
而且 --apply 也不是解药(R4 补扫)::498 写的是
if [ "$apply" = "1" ]; then git fetch --prune "$remote_name" >/dev/null 2>&1 || true; fi|| true 把退出码吞了。实测把 origin 的 URL 改坏(等价于离线/鉴权失败)再跑 --remote --apply:fetch 静默失败,这一节照样打印出那条针对已被同事推进的分支的删除命令,没有任何警告、没有 stale 标记。所以「改成无条件 fetch」是不够的——必须检查刷新是否成功,失败时像 :512、:520 那样跳过整节。
修复:不分模式都刷新候选 ref(用 git ls-remote 就行,它什么都不改,:497 那条「dry-run 保持只读」的理由依然成立),检查刷新结果,失败就拒绝打印任何命令。
已确认(不阻塞)
| 严重度 | 位置 | 问题 |
|---|---|---|
| Medium | :466 |
git bisect 中途、HEAD 仍在分支上时(git bisect start 之后、第一个 good/bad 之前)porcelain 报 0,守卫和 git worktree remove 都不拒绝。实测:工作树被移除,.git/worktrees/<name>/ 连同 BISECT_LOG/BISECT_START/BISECT_NAMES 一起没了(detached 的 bisect 和 rebase 中途都正确 KEEP)。这是上一轮那条 bisect 发现在 §2 里的存活形态。修法:同一处再加「gitdir 里存在 rebase-merge/rebase-apply/BISECT_*/CHERRY_PICK_HEAD/MERGE_HEAD 就 KEEP」 |
| Low | :391 |
§1b 会把「某个活着的工作树正在 rebase/bisect 的分支」当成游离分支列出来并打印 git branch -D <b>,而同一次运行里 §2 对那个工作树打的是 KEEP。已验证无害:git 2.50 会拒绝(error: cannot delete branch 'feat' used by worktree at …,rc=1,分支完好),rebase --continue / bisect good 事后都正常。只是输出自相矛盾——而这恰恰说明删掉 ref_in_use_by_worktree 是对的:-D 自带那个 update-ref -d 缺失的拒绝 |
| Low | :501 等 |
注释漂移:「This section deletes on the SERVER … the only destructive path here with no -d net」—— 它已经不删了。同类还在 :150-151、:247、:317(引用 update-ref 的 expected-old-value,而那个函数已删)、:535。文档侧 reference/git-safety.md:53 仍要求 allow_remote_cleanup: true + 用户同意才能「删远程」,而这个能力本次已被移除。README / SKILL.md / status.md 其余部分改写得是对的 |
补充发现(R4 全量复扫)
- [Low]
phases/run.md:76—— 写着「不加--delete-branch——远程分支删除统一交给 §合并后的 safe-cleanup,受allow_remote_cleanup与 dirty-worktree 检查约束」。但 safe-cleanup 已经不删远程了,而git-guard.sh:220又对merge-pr硬拒绝--delete-branch(提示「clean up branches via safe-cleanup.sh」)。于是远程 head 分支的清理在文档流程里没有主人了,除了 GitHub 的 auto-delete-on-merge —— 而那是个仓库设置,skill 既不检查也没告诉操作者去开。 - [Low] README / SKILL.md / status.md / git-safety.md 四份文档没有一份提到
git worktree remove仍然会删掉一个目录;四份现在读起来都是「这个脚本不做任何不可逆删除」。 - 已排除的一个怀疑:
:396和:550-555那几行结尾的&&在set -e下会不会截断执行 —— 两条路径都能走到== done ==且 exit 0,不是问题。
建议
- 把这次的清扫再跑一遍,这次针对文件系统写入。
git worktree remove是唯一存活的不可逆动作,对它套用这个 PR 自己的规则(难的部分自动化,不可恢复的部分交给人)就能一次性关掉 Blocking 1、上面那条 Medium 和三处头部自相矛盾。 - §3 是在错误的轴上变安全了 —— 它不删了,却把「陈旧」留了下来。一旦「打印出来的命令」本身就是那个破坏性动作,数据新鲜度只会更重要,不会更不重要。
- §1b 现在是个报告,就按报告来计价:每个分支 1 次 gh 调用,本仓库约 25 次 / 20.6 秒,而
status.md:33让它在每次pilot status都跑。建议按 tip sha 做缓存,或者在 status.md 里改成显式 opt-in。
4-round:R1a/R1b DeepSeek-v4-flash(F1 被反证驳回——:384 明写 REPORT ONLY 是有意为之;F2/F3 成立但都被后续证明比它说的严重得多)→ R2 Opus 独立评审(真跑完整守卫矩阵 + 真 bare remote,把 F2 从 Low 提到 High)→ R3 Codex 对抗挑战(独立沙箱,0 挑战 / 3 确认)→ R4 Opus 终裁 + 全量复扫(独立再复现三条、用 PR base 23d6442 确认两条都是本 PR 的回归、补出 || true 吞掉 fetch 失败这条 High、并排除了一个 set -e 截断的误判)。
上一轮把「不可逆操作」删干净了 —— 只删到【ref 写入】为止。评审指出剩下
的那个 git worktree remove 是【文件系统删除】,而且恰恰是上一轮新接到
「GitHub 推断的 squash 证据」上的。
## 1. [High] worktree remove 会 rm -rf 掉装着 gitignore 文件的目录
两层各自放行:git status --porcelain 【不列】 ignored 文件;git worktree
remove 不带 --force 时也【容忍】只有 ignored 文件的工作树。
自测复现(工作树里放 .env=SECRET=abc + ignored-stuff/keep.txt):
旧版 --apply → removed …/wt [feat] (git) → .env 没了,不可恢复
新版 --apply → 只打印路径 + 移除命令 + 「先查 ignored 文件」的提示
.env ✅ 在 ignored-stuff ✅ 在
而且它是新可达的:PR base 上 §2 只在 git 原生证据下触发,而那个谓词在
squash 仓库里按构造返回 0 行 —— 接上 squash 证据之后,它对每一个合过 PR
的 worktree 都触发,而按 SKILL 自己的教条(一个 task = 一个 worktree)
那正是主导单位;run.md/status.md 还都是带 --apply 无人值守跑的。
同时它证伪了我上一轮自己写下的三句话(头部「does not do irreversible
deletes at all」/「removed only if CLEAN」/ §2 的「deletes NOTHING」)。
§2 现在两种模式输出完全一样:只报告。顺带关掉了 bisect 中途那条 Medium。
## 2. [High] §3 在错误的轴上变安全了
它不删了,却把【陈旧】留下来:git fetch --prune 仍只在 --apply 下跑,
而 dry-run —— 看起来最无害的那个模式 —— 用过期的 remote-tracking ref
生成【可直接粘贴】的 git push --delete。评审在真 bare remote 上复现:
同事往已合并分支推了新 commit 之后,dry-run 照样打印删除命令,粘贴执行
则 ref 消失且 bare 仓库不留 reflog。--apply 也不是解药:那行 `|| true`
把 fetch 失败吞掉,离线时照样打印命令、无任何 stale 警告。
一旦【打印出来的命令】本身就是破坏性动作,数据新鲜度只会更重要。做对
它要求:非 --apply 也刷新、检查刷新成功、逐个候选复核 —— 也就是把这个
PR 刚花六轮学会不要建的东西重建一遍。
所以 §3 整节移除:不删也不列,并明说去开 GitHub 的 auto-delete-on-merge
(它在合并真正发生的那一侧判定)。
## 现在脚本唯一会执行的删除
git branch -d,而且是 git 自己在把关:它拒绝未合并的分支,也拒绝被
worktree 占用的分支。除此之外全是报告。
四份文档(README / SKILL / status.md / git-safety.md / run.md)同步 ——
上一轮它们读起来都是「这个脚本不做任何不可逆删除」,而那时文件系统删除
还是活的。
剩余三条 Low 记进账本 FU-10/11/12(注释漂移 + 远程清理在文档里没主人 /
§1b 每次 status 跑 25 次 API / §1b 与 §2 对 rebase 中分支的输出自相矛盾)。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
两条 High 全修,推 你那句判词是准的:「清扫停在了 ref 写入,没走到文件系统写入」我上一轮把 §2 现在两种模式输出完全一样,只报告。这一刀顺带关掉了你标 Medium 的 bisect 那条( 「no irreversible deletes」这句话是我写在头部的,而写它的时候文件系统删除还是活的。 教训我写进注释了:审查破坏性动作时,ref 写入是你会想到的,文件系统写入才是真正丢数据的那个。 §3:你说它「在错误的轴上变安全了」,这句我完全接受它不删了,却把陈旧留下来,而且留在看起来最无害的那个模式里。你在真 bare remote 上的复现 + 所以整节移除:不删也不列,并明说去开 GitHub 的 auto-delete-on-merge。 现在脚本唯一会执行的删除
四份文档同步了(README / SKILL / status.md / git-safety.md / run.md)。你说得对,上一轮它们读起来都是「这个脚本不做任何不可逆删除」。 剩余三条 Low 记进账本,不在本 PR 做
收口仓库 owner 定了规则:这两个 PR 今天收口,只再走一轮。所以这是最后一轮修复 —— 之后无论裁决如何,剩下的问题一律记进账本,不再改代码。这个功能已经从「自动删」砍到「只报告」,再往下就只剩报告本身的措辞了。 |
* feat(pilot): FU-5 —— 起跑门禁支持仓库已有的规划源 (v1.4.0) 门禁只认 docs_dir 下那七个固定文件名,认不出等价(往往更完整)的规划源。 实测:Brood 的规划在 backlog/(4 milestone + 49 个带验收标准的 task + 2 ADR), check-docs.sh --strict 报 0/7、run 直接 fail-closed 拒跑;而 plan.md §A.3 又明写『已有规划 → 不要重复造』—— 两条同时遵守不可能。上次是拿 docs/agent/ 搭适配层绕过去的,这次根治。 ## 改法 .pilot.yml 可声明 planning_requires:,门禁就改查这些路径。判据一个字没松: 每条路径都要真有内容(目录 = 底下至少一个够实质的文件),空目录/只有占位符 的文件照样判 NOT ready。换的是【查哪里】,不是【要不要查】。 脚本自己读 .pilot.yml(两种 YAML 写法都认),不依赖调用方记得传 flag —— 理由和 git-guard.sh 的 self-contained 保护名单一样:run.md 分步执行, 传参会在步骤之间蒸发。这里还多一层:忘传的后果正好就是这条 bug 本身 (退回七件套 → 误判未就绪)。--planning-requires 仍可覆盖。 ## 不能拿它关掉门禁 `.` / `..` / `/` / 绝对路径 / 通配符 / 空列表 —— 全部 exit 2 拒绝, 理由和拒绝 PILOT_DOC_MIN_BYTES=0 一样:那是把门禁关了,不是配置它。 其中通配符那条藏着一个真 bug:`for p in $planning_requires` 分词的同时 也会做 glob 展开,而且发生在循环体之前 —— `backlog/*` 曾经静默变成它 匹配到的九个路径,检查根本看不到那个 `*`(实测 ok=7/9)。加 set -f 才真拒得掉。 ## --no-config 与 CI 的交互(差点埋雷) scripts/ci/check-docs-gate.sh 在仓库根跑 `--docs-dir <tmp>` 测门禁自身。 脚本一旦会读 .pilot.yml,这个仓库将来只要声明了 planning_requires, 那些断言就会【静默改去查 backlog/】,一边继续打印 ok 一边测着别的东西。 加 --no-config 显式隔离,并补一条断言把这件事钉死:谁把 flag 拿掉, 红的是那条断言,而不是悄悄失效。 ## 验证 check-docs-gate.sh 从 8 条断言加到 16 条,全绿: - 老行为逐条回归(原样模板拒 / 阈值非法拒 / 空目录拒 / 填好的放行) - 新增:替代源有内容→放行、全空→拒、路径不存在→拒 - 新增:`.` / `/` / 绝对路径 / 通配符 → 全部 exit 2 - 新增:--no-config 确实隔离仓库声明 另外实测两种 YAML 写法(行内数组 + 块列表)、未声明时退回七件套。 Brood 自己的 .pilot.yml 【这次不改】—— 能力就绪不等于该立刻切换, 那是第二个决定。#45 的教训就是一个 PR 装了四件事。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * fix(pilot): 两条 High —— 拒绝表可绕过 + 解析器读不了自己文档的写法 ## 1. 不再枚举「.」有多少种拼法,改成解析后比较 原来是一张字符串表 `.|./|..|../|/|~`,而 `.//` `./.` `././` `.///` `.//.` 既不是绝对路径、不含 `..`、不含通配符 —— 全部通过,随后 `${p%/}` 把它们 归一化,整个仓库就成了「规划源」。`.git` 更省事,它在任何 git 仓库里都在。 实测(完全没有规划文档的仓库): 不配置 → rc=1 ok=0/7 NOT ready planning_requires: [.//] → rc=0 ok=1/1 "ready — safe to run unattended" 改成 `cd "$p" && pwd -P` 解析后与 toplevel 比较,并拒掉 .git、拒掉解析后 落在仓库外的路径(所以用 -P,符号链接一并关掉)。一次关掉整个家族,不是 等下一个变体再补一行。 ## 2. 我自己文档的写法,我自己的解析器读不了 —— 而且静默 awk 的 key 正则是 `^planning_requires:[[:space:]]*$`,而 SKILL.md 文档的 写法带行尾注释,于是永远匹配不上、inlist 不置位、声明被【静默丢弃】、 门禁退回七件套 —— 正是这个功能立项要消灭的那个假 NOT-ready,由这个功能 自己送达。条目级注释更糟:shipped 模板那行会被粘成 `路径#注释` → MISSING。 改:awk 先剥掉 ` #…`(只剥空白开头的,路径里合法的 `#` 不受影响),key 行 和条目行都剥;并且【key 存在但解析出 0 条 → exit 2】,绝不静默回退 —— 静默回退和这个 bug 本身无法区分。 顺带把 SKILL.md 和模板里那两处写法改成解析器确实能读的形状。 ## 验证 CI 断言 16 → 28 条,全绿。新增覆盖:七种仓库根拼法 + .git / .git/refs 全部 rc=2;key 行和条目带注释都能读;行内数组带注释能读;declared-but-empty 和显式空数组都 exit 2;没有 key 时正常回退七件套(rc=1)。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * fix(pilot): 第二轮 —— 三条 High + 两条我自己引入的回归 ## 三条 High:同一个 resolve 步骤,三件事各自手做,各漏一个口子 1. `.git` 拒绝仍是按名字比字符串,`.GIT` 直接走过去 —— macOS 默认大小写 不敏感,而 bash 的 pwd -P 保留你敲进去的拼法(cd .GIT && pwd -P → …/.GIT), 所以 _abs 永远匹配不上字面量 <root>/.git。实测 .GIT → rc=0 "ready"。 上一个 commit 写着「不要枚举 . 有多少种拼法」,自己却在枚举 .git 的拼法。 2. 守卫按 CWD 解析,而配置按 toplevel 定位 —— 同一份 .pilot.yml 在不同目录 下含义不同:`- .` 在根目录 rc=2、在 sub/ 里 rc=0;反过来合法的 `- plan-src` 在根 rc=0、在 sub/ rc=1,正是这个功能要消灭的那个假 NOT-ready。 3. 非目录分支只解析父目录,指向仓库外的【文件】软链能通过: planfile -> /etc/passwd → rc=0,内容检查直接读穿过去。目录软链是拦住了的, 所以上一条 commit message 里「符号链接一并关掉」只对了一半。 改法:相对【仓库根】解析、解析【条目本身】(realpath)、然后【问 git】它是什么 ——`--resolve-git-dir`(认 .git 目录 + linked worktree 的 .git 文件)加 `--is-inside-git-dir`(认 .git 底下的子路径)。 ## 两条我自己引入的回归 4. 新加的大声 exit 2 会在合法 YAML 上炸:条目正则要求至少一个空格,于是 【零缩进块序列】(合法 YAML,yaml.safe_load 读得出来,也是不少格式化工具的 默认输出)和标量写法都解析不出东西,直接撞上 exit 2 —— 一份能用的配置把 门禁硬停掉,还告诉操作员「格式不对」。这正是被修的那个 bug 的镜像。 正则放成 ^[[:space:]]*- 并加标量分支;exit 2 只留给真正空的声明。 5. 新加的 .git/refs 断言依赖运行环境:在 linked worktree 里(.git 是文件, 也就是 pilot 自己「一 task 一 worktree」教条下的形态)那个路径不存在, 断言无缘无故变红。.GIT 断言有同样的毛病 —— 它只在大小写不敏感的文件系统 上存在,无条件断言会在 Linux runner 上红。前者改用 .git 本身(两种形态都 成立),后者改成有条件断言。 ## 验证 断言 28 → 38 条。而且这次【在两种仓库形态下都跑】:普通 clone 全绿, linked worktree(.git 是文件)也全绿 —— 第一次改完只在主仓库测,拿到 worktree 里立刻红了 2 条,正是评审说的那个形态。 新增覆盖:.git/.GIT/.Git/.GIT/config 全拒;同一配置从根和从子目录同解 (合法的都 rc=0、`- .` 都 rc=2);文件软链和目录软链都拒;零缩进块序列和 标量写法都能解析;真正空的声明仍 exit 2。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * refactor(pilot): 起跑门禁改成一句声明,砍掉整个路径校验器 原方案是 planning_requires: [backlog/tasks] —— 让仓库指出真正的规划源, 门禁去【校验那些路径】。两轮评审在这一个能力上找出五个缺陷,而且 【全部在路径校验里】,没有一个在它要解决的那个问题上: - 仓库根的各种拼法 .// ./. ././ 绕过拒绝表 - .GIT 在大小写不敏感的文件系统上绕过 .git 的字符串匹配 - 指向仓库外的【文件】软链(只解析了父目录) - 条目按 CWD 解析、配置按 toplevel 定位 → 同一份配置在不同目录含义不同 - 我自己加的「declared but unparseable」abort 把合法的零缩进 YAML 列表判成错 一个「换个路径去查」的旋钮,必须扛住路径能撒谎的每一种方式,而那是个 比原问题大得多的问题。 改成: planning_source: docs | external external 时门禁【什么都不查】,打印 NOTHING WAS CHECKED 然后放行。 没有判据可以被绕过,因为根本没有判据。 门禁存在的意义是「别在规划不全的时候无人值守开跑」;仓库里的人写下 external,就是显式接过了这个责任 —— 这正是门禁本来要求的东西。代价是 这类仓库的门禁变成不查,但门禁本来只为无人值守而存在,人可以选择不开。 配套要求写进 SKILL.md 和 run.md:汇报时必须照实说「本仓库声明规划在别处、 门禁未核实」,不能说成「规划已验证」——放行是人担保的,不是脚本核实的。 代码:check-docs.sh +304 → +67 行;CI 断言从「九条只为路径能怎么撒谎」 变成六条「每个取值路由到哪、未知值中止、external 必须自报没查」。 额外一条断言:--no-config 必须能忽略这个声明,否则上面每条断言都是空的。 Closes FU-5 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk * fix(pilot): 第四轮 —— CI 断言的 SIGPIPE 抖动 + 声明解析器 fail-open ## 1. [High] `| grep -q` 让 docs-gate 三分之一概率随机变红 grep -q 匹配到第一行就退出,而 gate 还要再打印 4 行 → SIGPIPE → 141; set -o pipefail 把 141 提成整条管道的状态,if 走 else —— banner 明明 正确打印了,断言却判失败。而它报的是「the planning-docs gate is not fail-closed」,恰恰是最容易让人以为门禁真坏了的那句话。 自测复现:修复前 15 次里红 5 次(33%),与评审的采样同量级。 改成捕获到变量 + case 匹配,不用管道。修复后 30 次全绿。 这个 bug 我今天刚在 safe-cleanup.sh 里修过 —— 那里为它写了 list_has 辅助函数,然后我在同一批 PR 的 CI 脚本里又踩了一遍。注释里记了这件事。 ## 2. [Medium] 声明解析器把「根本不是那种 YAML」当成 external 声明去掉了判据,但没去掉读它的那 6 行 sed —— 而那 6 行有着和被删掉的 校验器同样的 fail-OPEN 形状,只是小一号,落点更糟:以前是路径判断出错, 现在是整个门禁被无声关掉。 planning_source:external → YAML 里这是个纯标量字符串,根本没有 key planning_source:<TAB>external → yaml.safe_load 报 ScannerError planning_source: ex"ter"nal → 值就是 ex"ter"nal,该走「拒绝而不是猜」 三种全被读成 external。改: - 要求一个【字面空格】s/^planning_source: \{1,\}//p。不能用 [[:space:]], 它包含 TAB,而 YAML 不接受 —— 匹配它等于接受一份任何 YAML 解析器都 读不了的文件。前两种因此不匹配,落回查 docs(fail-closed)。 - 只脱【成对】引号,不再 tr -d。第三种因此保持 ex"ter"nal,被 case 的 兜底分支拒绝(exit 2),而不是被抹成 external。 七种写法逐一实测,并与 python yaml.safe_load 的判断对照一致。 三个畸形用例 + 成对引号用例全部进 CI 断言。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
✅ APPROVE — 第九轮 [2-round,post-R2 全 Low → 按闸门规则跳过 Codex]
上一轮两条 High + 一条 Medium,是结构性消失,不是被防住了。这一点我没有只信推理,是跑出来的。
机械证据
「还有什么会改东西」—— 全模式 git 调用审计
挂一个记录每次调用的 git shim + 一个返回「已合并 PR」的 gh stub,在一个真沙箱里(8 个 worktree:ignored-only 装 .env 的、rebase 中途的、bisect 中途的、stale 的、脏的;一个同名 tag;一个真 bare remote)跑 8 种模式:默认 / --apply / --squash-merged / --squash-merged --apply / --remote / --remote --apply / --remote-name upstream --apply / 带 stub gh。
整个运行里唯一会改东西的命令: git branch -d -- <name>
其余全部是只读:rev-parse / show-ref / branch --format / worktree list --porcelain
/ status --porcelain --no-optional-locks / gh api (GET)
无 fetch、无 config 写、无文件系统写、无远程写。§3 发出的 git 命令数 = 0。
快照对比(refs + reflog + 完整文件树哈希 + local config + bare remote refs):八种模式逐字节相同,只差那两个 git 原生已合并分支的删除。那个装着 .env(SECRET=abc)的 ignored-only worktree 在所有模式下都活着 —— H1 死了;§3 一条 git 命令都不发 —— H2 死了。
推断证据到任何变更之间,dataflow 为零:merged_pr_for() 返回的 sq_prs / wt_pr / via,追下去只流向 printf。这是「结构性」的意思——不是多加了一道判断,是那条路不通了。
剩下那一个删除本身仍然安全:候选来自 git branch --merged <完整 integration ref>,执行者是 git branch -d,两道互相独立的 git 原生检查取交集,外加 reflog 兜底。逐条实测在 git 2.50.1 上:worktree 已检出的分支 → 拒绝;rebase 中途的分支 → 脚本自己的 is_in_worktree 看不见(porcelain 报 detached),git 照样拒绝(cannot delete branch 'rbbranch' used by worktree,ref 完好);bisect 中途 → 拒绝。同名 tag:tag dup→未合并 commit、branch dup→已合并,--apply 只删了 refs/heads/dup,tag 和 unmerged-precious 纹丝不动。--protect / --integration 都成立。
新提示不是装饰:在那个 ignored-only worktree 里,脏检查 git status --porcelain 返回 0 行(H1 的数据丢失就是这么发生的),而脚本现在打印的那条提示 status --porcelain --ignored=matching 返回 !! .env。
没有回归:§2 的识别结果和父提交在同一份 8-worktree fixture 上逐字节相同(差分跑,diff 为空)——它只是不再执行,找到的东西没变。%q 引用对含空格 / $( / ' / ; 的路径和含 ; / $( / 反引号的分支名都能原样还原成正确 argv,没有注入面。
建议在本 PR 里顺手改掉的两处(都很小,不阻塞)
1. [Low] safe-cleanup.sh:492 —— §2 对 squash 分支打印的命令注定失败一半
§2 一律打印 git branch -d %q,但 via=squash-PR#N 时这条不可能成功(squash 后原 tip 不是祖先),而 §1b 对同一类证据打印的是 -D。照着粘贴实测:
git worktree remove <path> → 成功,目录没了
git branch -d sqbranch → error: the branch 'sqbranch' is not fully merged
→ 分支变成孤儿
在 squash 仓库里——也就是这个功能唯一存在理由的那种仓库形态——§2 的每一条都是这样。改法:via != git 时打印 -D,和 §1b 一致。
2. [Low] phases/status.md:42-43 —— 仍写着「要连带删远程…仅当 allow_remote_cleanup: true …才加 --remote」,与同一个文件的 :50、以及 git-safety.md:57(「allow_remote_cleanup 因此不再有作用」)直接矛盾。这两行在主动指挥模型去用一个已经死掉的能力,建议现在就删。
其余(建议一起进 FU-13)
- [Low]
--remote/--remote-name已成摆设(R1a 的 F1,唯一站得住的一条):两个 flag 仍被解析、仍在--help的 Usage 和未知参数提示里,但--remote-name现在只喂给## Remote branches ($remote_name)这个标题,--remote只开关一段说明文字;而run.md:78还在传[--remote-name <remote>]。另外这段说明文字从文档流程里已经不可达了(run.md:78把--remote去掉了),所以它只会对「传了一个文档已不再提及的 flag」的调用者打印——建议折进默认输出或doctor(FU-10 已记)。 - [Low] 文档漂移比记录的更广:
status.md:42,43,49、run.md:76、git-safety.md:55、README.md(表格行 / :30 / :35 / :90)、SKILL.md:65、templates/pilot.example.yml:10、.pilot.yml:11—— 仍在承诺删 worktree,或仍把allow_remote_cleanup当作生效的闸门(grep 确认没有任何代码读它)。 - [Low]
:308——--integration X只管列表,真正执行的git branch -d判的是「合进 HEAD」。站在非 integration 分支上时,每一个真正已合并的候选都会打印SKIP (not safely merged)。方向是 fail-safe(只会漏报),但status.md要求模型把输出原样转达给用户,于是它会如实转达一个假的「未合并」。
补充发现(R4 全量复扫)
[Low] safe-cleanup.sh:410 —— main_root 取的是「你站在哪个 worktree」,不是主 worktree
main_root="$(git rev-parse --show-toplevel)"
...
[ "$path" = "$main_root" ] && return # 本意:永不碰主 worktree--show-toplevel 在 linked worktree 里返回的是那个 worktree 自己。而按本 skill 自己的教条(一个 task = 一个 worktree),「从 linked worktree 里跑」正是常态,status.md 还让它每次 pilot status 都跑。
实测:主 worktree 停在已合并分支 primaryfeat,从 /tmp/sb45/wt-env 里跑 §2 —— 它打印
remove with: git worktree remove /private/tmp/sb45/repo && git branch -d primaryfeat
即「删掉那个装着真正 .git 的主仓库目录」,同时把 wt-env 自己从报告里整个漏掉了。
目前无害:git 两半都拒绝(fatal: '…' is a main working tree rc=128;cannot delete branch 'primaryfeat' used by worktree rc=1)。但请注意两件事:这是本仓库「守卫在它最该起作用的场景里跑不起来」家族的第 8 个实例;而且在父提交上——§2 还会真执行 git worktree remove 的时候——它就不是一个错字符串,而是一次对主 checkout 的删除尝试。改法:主 worktree 取 git worktree list --porcelain 的第一段(永远是主 worktree),或 git rev-parse --git-common-dir。
为什么这轮放行
前八轮每一轮都是「拿掉一个实例,能力还接着」;这一轮拿掉的是能力本身。
最有说服力的证据恰恰是上面那条补充发现:我在复扫里找到了这个仓库标志性 bug 的一个全新实例(守卫保护错了对象),而新架构在没有任何人枚举过它的情况下就把它中和了 —— 它从「删主仓库目录」退化成「打印一个错字符串」。这就是「打补丁」和「结构性消除」的区别。
存活下来的东西没有一条超过 Low,也没有一条是数据丢失路径。轮数没有推动这个判断:如果那条 main_root 还能真执行,或者推断证据还能流到任何一个 mutation,我照样挡。
2-round:R1a/R1b DeepSeek-v4-flash(R1a 只有 1 条且重复输出两次,成立;R1b 无 finding)→ R2 Opus 独立评审(git shim 全量调用审计 + 8 种模式快照差分 + 8-worktree 真沙箱 + 真 bare remote,产出 6 条 Low,判「不该再挡」)→ post-R2 severity gate 全 Low,按 skill 规则跳过 Codex R3 → R4 Opus 终裁 + 全量复扫(抽查复核 R2 最强的几条主张而非照抄,补出 main_root 这条新实例,并把文档漂移的范围扩到 10 处)。
这个 PR 变了:从「自动删」改成「列出来」
前六轮它是自动删的(
-D+git push --delete)。六轮评审在这一个能力上找出六个实测复现的缺陷:local多重赋值在外层作用域展开右值update-ref -d没有git branch -D的 worktree 占用拒绝%(refname:short)在同名 tag 下产出heads/x-d安全的分支被谎报成未合并,再被-D捡走没有一条是理论风险,每一条都端到端复现过。所以结论不是「防得更严」:
那六个缺陷全部是「删」的属性,不是「列」的属性。 所以这一版:
destroy_branch/ref_in_use_by_worktree/update-refCAS(-75 行)--apply输出完全一样。git branch -d(git-native 已合并那条)——-d是 git 自己的安全网,它拒绝一切自己看不出已合并的东西,不是我写的判断保留的是这个功能里真正难、也真正有价值的那半:在
git branch --merged恒为空的仓库里,按 commit 向 GitHub 逐条取合并证据(/commits/{sha}/pulls,按 commit 判不按分支名判,两个方向的误判都挡住)。在 Brood 自己身上跑
三条都是本轮真实合并的 PR。这就是它现在的全部行为。
第六轮那四条 blocker
在结构上不存在了,不是被防住了 —— 它们全都需要一次删除才能发生。
--protect解析、$integration全 ref、SIGPIPE、*[!0123456789]*那些非破坏性的修复都保留着。Closes FU-2 / FU-4;FU-9(远程在 squash 仓库里是死代码)一并作废——那一节现在也只列不删。
https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk