Skip to content

refactor(pilot): squash-merge 仓库的分支清理 —— 只列不删 - #45

Merged
jhfnetboy merged 16 commits into
mainfrom
feat/guards-squash-and-whitelist
Aug 6, 2026
Merged

refactor(pilot): squash-merge 仓库的分支清理 —— 只列不删#45
jhfnetboy merged 16 commits into
mainfrom
feat/guards-squash-and-whitelist

Conversation

@jhfnetboy

@jhfnetboy jhfnetboy commented Aug 5, 2026

Copy link
Copy Markdown
Member

这个 PR 变了:从「自动删」改成「列出来」

前六轮它是自动删的(-D + git push --delete)。六轮评审在这一个能力上找出六个实测复现的缺陷:

后果
同名 tag 劫持证据 删掉一条未合并的分支
bash 3.2 的 local 多重赋值在外层作用域展开右值 恢复句柄打印另一个分支的名字
update-ref -d 没有 git branch -D 的 worktree 占用拒绝 rebase 中途的分支被删,结果丢失
证据与删除之间的 TOCTOU 窗口 期间的新提交被销毁
§3 拿本地分支当判据、在服务端删除 删掉同事没合并的工作,无 reflog 可捞
%(refname:short) 在同名 tag 下产出 heads/x -d 安全的分支被谎报成未合并,再被 -D 捡走

没有一条是理论风险,每一条都端到端复现过。所以结论不是「防得更严」:

自动执行不可逆删除、而判据必须从服务端推断,所需的把握程度配不上它买到的东西 ——
它买到的只是不用敲 git branch -D <名字>

那六个缺陷全部是「删」的属性,不是「列」的属性。 所以这一版:

  • 删掉 destroy_branch / ref_in_use_by_worktree / update-ref CAS(-75 行)
  • §1b、worktree 的 squash 路径、§3 全部只打印:分支名 + PR 号 + tip sha + 可粘贴的命令。带不带 --apply 输出完全一样。
  • 唯一保留的删除是 git branch -d(git-native 已合并那条)——-dgit 自己的安全网,它拒绝一切自己看不出已合并的东西,不是我写的判断
  • 远程交给 GitHub 的 auto-delete-on-merge:它在合并真正发生的那一侧判定,不会被一个还没 push 的本地分支骗到

保留的是这个功能里真正难、也真正有价值的那半:git branch --merged 恒为空的仓库里,按 commit 向 GitHub 逐条取合并证据(/commits/{sha}/pulls,按 commit 判不按分支名判,两个方向的误判都挡住)。

在 Brood 自己身上跑

$ safe-cleanup.sh --integration main --squash-merged --apply

## Local merged branches
  (none)                                      ← git 原生判据在 squash 仓库里恒为空

## 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. Read the PR numbers above, then run the commands you agree with.

三条都是本轮真实合并的 PR。这就是它现在的全部行为。

第六轮那四条 blocker

在结构上不存在了,不是被防住了 —— 它们全都需要一次删除才能发生。--protect 解析、$integration 全 ref、SIGPIPE、*[!0123456789]* 那些非破坏性的修复都保留着。

Closes FU-2 / FU-4;FU-9(远程在 squash 仓库里是死代码)一并作废——那一节现在也只列不删。

https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk

## 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
@jhfnetboy
jhfnetboy requested a review from clestons as a code owner August 5, 2026 07:21
四条都在 #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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 statusgh 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 + --applygit worktree remove 然后 git branch -D。最低限度也要报出来KEEP (squash-merged via PR #N — worktree cleanup not supported),让缺口可见而不是沉默。


🟡 非阻塞

  • [Med] :177,187 opt-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 status 2N 次调用。叠加: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] :181 sed '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:27 FU-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 时要求位置参数,且 --admingh pr view 是 unknown flag,所以 :285gh pr view "$n" 先失败、:286 die,exec 不可达。实测:gh pr view --admin --repo AAStarCommunity/Brood --json baseRefNameunknown 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/bash 3.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
@jhfnetboy

Copy link
Copy Markdown
Member Author

四条全部成立,已修并推 7f9dd08B1 采纳一半,另一半我不改,理由在下面。

状态 实测
B1 base 收敛 ✅ 采纳(jq 加 .base.ref == $integration) 同一分支 --integration mainwould delete _t-b1 (merged via PR #42);换成 --integration cla-signatures(none)。证据随 integration 出现/消失。
B1 额外要求 is-ancestor 不采纳 见下
B2 拒未知参数 --integraton mainunknown argument + usage,exit 2;--integration(缺值)→ requires a value
B3 区分「查不了」 stub 一个 auth/repo 成功但 api 恒 403 的 gh → (4 branch(es) could not be verified — KEPT. NOT the same as 'nothing to clean'.);有候选时另追加一行说明列表不完整
R4 worktree 无 flag → KEEP (squash-merged via PR #42) — pass --squash-merged to include;有 flag → would remove … (squash-PR#42);脏 worktree 仍 KEEP (dirty, 1 changes)

为什么不加 git merge-base --is-ancestor $sha $integration

squash 仓库里 tip 按构造永远不是集成分支的祖先 —— 这正是这个函数存在的理由。
实测:git merge-base --is-ancestor 6f3da53 main 返回非 0,而 6f3da53 就是 PR#42 已合并的 head。

加上这条判据,--squash-merged 会对每一个分支都返回假,等于把特性退回它要修的那个死代码状态 ——
也就是这个 PR 想终结的「守卫跑不起来」家族再添一例。

base 收敛是正确的收窄,祖先性在这里不可用。 你 B1 的诊断完全成立(stacked PR 那条路径是真的),
只是这两个条件不是并列关系:前者能挡住它,后者会连合法情形一起挡掉。理由已写进 merged_pr_for
的注释,免得下次有人再加回来。

关于 R4

你说得对,而且这条最该被记住:它是在修第三例的同一个 PR 里留下的第四例
第 2 节的合并判据仍是 git branch --merged —— 正是本 PR 花 30 行论证「恒返回 0」的那个谓词。
我修守卫时只看了「松散分支」这一条路径,没顺着 SKILL.md 自己的教条("一个 task = 一个分支 =
一个 worktree = 一个 PR")去想 worktree 才是主导单位。

现在 worktree 走同一套证据:git 原生 → -d;squash 证据 → -D;ERR → KEEP 并说「查不了」。

冲突只在 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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES(第二轮)— 四条 blocking 全部真修好了,但头号修复自己内部有个能复活它的洞

先把修好的说清楚,全部在 head 0ff600d 上实跑验过:

上轮 blocking 状态 实证
B1 证据不按 --integration 收敛,能删集成分支里没有的工作 jq 加了 .base.ref == $integration无回归:线上仍是同样 16 个候选
B2 *) shift ;; 静默吞未知参数 --integraton mainunknown 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.refjq 源码插值,不是当数据比较

--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 会拒绝 pushrelease-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] :58 need_val 只数元数、不看值是不是 flag。实测 --protect --apply 会把 --apply 当成 protect 模式吃掉,脚本打印 mode=DRY-RUNexit 0,调用方以为已经执行了;--remote-name --apply 同类。(--integration --apply 不受影响,:88 的 ref 校验兜住了,fail-safe。)这和 B2 要关的「静默吞掉」是同一类。修:case "$2" in -*) 报错退出
  • [Low] :67 -h|--helpsed -n '2,40p' "$0",而第 40 行正是 # Usage: 标题、真正用法在 41-42 行 —— help 一行用法都不显示。改 '2,42p'
  • [Low] :319 vs :244 新的 handle_wt -D 没重定向 stdout,git 的 Deleted branch X (was e131b26). 会插进结构化报告;而 :244-D 仍然 >/dev/null 把同一句吞掉。两处不一致,:244 吞掉的正是唯一的恢复句柄(上轮的遗留 Low)。建议两处统一:捕获 sha 打进自己的报告行。
  • [Low] :146 ERR 三态只因为两个调用点都是命令替换才活着(bash 在 cmdsub 里挂起 errexitinherit_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>&1 above 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.md FU-3 仍标 done=PR#45(该改动落在 PR#43)。

反向验证(这些不是 bug,已排除)

  • handle_wt-D 门禁没有绕过:ERR / 无证据 / 有证据但没带 --squash-merged 三条路径都正确返回 KEEP,detached / protected / 主 worktree 三条路径也都正确。
  • :274rc=$? 不是取管道最后一段的状态 —— 脚本开头的 set -o pipefail 仍生效,实测 git -C /nonexistent status … | wc -l | tr 得到 rc=128KEEP (worktree status unavailable) 是活代码不是死代码(关掉 pipefail 的对照实验也跑了)。
  • git-guard merge-pr 的 PR 号槽位 n="${1:-}" 虽未校验数字,但 $n 要先过 :281/:286/:290 三次 gh pr view "$n",flag 形状会让它们全空 → diefail-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
@jhfnetboy

Copy link
Copy Markdown
Member Author

已修并推 33cccf9这条成立,而且我第一次的验证是无效的,一并说明。

修法

按你给的写法,$integration$ENV数据传,不再插值进 jq 程序:

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'

我的第一次验证是假的

我直接跑 --squash-merged --integration 'x"or(true)or"',得到空输出就当通过了 —— 实际上脚本在
更早ERROR: integration branch '…' not found locally 就退出了,根本没跑到 jq
空输出被我当成了「注入被挡住」,其实是「代码没执行到」。

重做成配对实验,先把那个名字的分支真建出来(git 允许):

版本 --integration 'x"or(true)or"'
插值版(把修复回退) would delete _t-inj (merged via PR #42) ← 注入成功
$ENV 版(本 commit) (none) ← 堵住

对照组:$ENV 版 + --integration mainwould delete _t-inj (merged via PR #42),无回归

_t-inj 指向 6f3da53(PR#42 的已合并 head,base=main)。插值版在 integration 是那个注入串时
仍把它当证据,正是你沙箱里那条真删除路径。

关于你复核 --is-ancestor 那段

谢谢——你把「父 PR(base=integration)本身也含子分支 tip,所以合法接续照样产得出证据」这一步补上了,
比我在正文里只说「squash 后 tip 不是祖先」要完整。我当时只论证了「加上它会变回死代码」,
没论证「不加它也不会漏删」。

## 🔴 两条 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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --applyexit 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_forgit rev-parse --shortgit 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] :235 NB 说「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] :73 sed -n '2,46p' 越界 4 行,把 set -euo pipefail / integration="" / protect_csv=… 也打出来。这是写死行号在一个每轮头注释都在长的文件上第二次漂移了 —— 建议在头注释末尾放个哨兵注释,用 sed -n '/^#/p' 或 awk 读到它为止。
  • [Low] :202,244,388 protected 分支被静默 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 补齐实证并修正了定性):branch release-1.2 被同名 tag 遮蔽时,1b 迭代到的是 heads/release-1.2,它既匹配不上 is_protectedrelease[-_/.0-9]*、也匹配不上 is_in_worktreegrep -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] :248 vs :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_wtvia != "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
jhfnetboy added a commit that referenced this pull request Aug 5, 2026
PR#47 review 的建议。那一处实例本 PR 修了,纪律本身没落地。
和 #45 一起收口,不往这个 PR 里加。

Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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 …——删掉的是那条未合并的分支;旧代码一条都不列,是安全的。

一次踩三个后果:

  1. 证据取自 tag,却拿去授权对分支执行 git branch -D——未合并的工作被不可逆删除;
  2. was= / restore: 恢复句柄打印的是 tag 的 sha,照着它恢复会建出错误的 commit,真正的 tip 只剩 reflog;这恰好把本次 commit 自己新加的恢复机制废掉了;
  3. 新加的 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\babkeep'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 maingit branch -r --merged main 列出 origin/unmerged-precioussafe-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_forERR 哨兵直接比较,于是限流/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 模式现在是 2Ngh 调用(取证 + 复查),但成本注释和两处文档都还写着「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 -Fqxgrep -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
@jhfnetboy

Copy link
Copy Markdown
Member Author

四条 blocking + 七条 Low 全部成立,已修并推 7739473没有一条驳回。

那条 Critical 的判断我完全接受:上一个 commit 亲手引入了它声称要修的 tag 劫持%(refname:short) 会在冲突时主动吐出消歧过的 heads/decoy,我改成 %(refname)|sed 把消歧丢掉了,而 git 解析裸名时 tags 优先于 heads —— 修一半的补丁比不修更糟,因为它读起来像修好了。

按你的结构性建议做了

抽了 destroy_branch <refs/heads/name> <expected-sha> <label>,两个 -D 站点共用。用 git update-ref -d 而不是 git branch -D:

解决了什么
全 ref 传到底 不经过裸名解析 → tag 劫持在结构上不可能,不是靠记得在每处都写对
expected-old-value 原子 CAS:证据之后 tip 动了就删不掉 → TOCTOU 窗口消失
顺带 不再需要上一轮那次复查调用 → gh 调用从 2N 回到 N,文档里那个数字重新是真的
顺带 CAS 用的 sha 就是取证据的 sha,也就是打印的恢复句柄 —— 全程同一个对象

merged_pr_for 也改成收 sha 而不是 ref:调用方解析一次并留着,证据和授权删除的 sha 由构造保证同一个,不存在第二次解析可以落到别处。

顺带把你那条 Low(ERR 哨兵被当成"tip changed")一起消掉了 —— 现在没有第二次查询,也就没有把限流误报成 tip 变化的可能。

实测:按你的 fixture 重建,加了一个诚实的 stub gh

我第一版 fixture 的 stub 对任何 sha 都回 77,那测不出「证据取自哪个 commit」—— 修成按 commit 诚实作答(只有真正已合并的那个 commit 才有 PR)之后才是真判据。

fixture:branch decoy(未合并,珍贵)+ 同名 tag decoy(指向已合并 commit)+ tag main(遮蔽集成分支)+ 分支 keep'me

旧版 929d8d5:

## Local merged branches
xargs: unterminated quote
  would delete  decoy
  would delete  keep'me      ← tag main 劫持了 --merged,两条未合并分支被列进来
APPLY 后: Deleted branch keep'me   ← protect 被 xargs 吃掉,真删了
          SKIP (not safely merged) decoy   ← 只有 -d 兜住了它

新版:

## Local merged branches
  (none)                                        ← integration_ref 修复,tag main 不再劫持
## Squash-merged local branches
  KEEP (protected by name/pattern)  keep'me     ← protect 生效,且 APPLY 模式下也打印理由
APPLY 后: 两条分支都在,sha 未变

这一屏同时证了三条:Critical(tag 劫持)、High(keep'me 保护绕过)、Medium($integration 裸名),外加那条 Low(KEEP 理由在 --apply 下看不见 —— 而 run.md:78 定的标准命令恰恰带 --apply)。

CAS 语义单独验:

正确 sha → 删得掉
过期 sha → error: cannot lock ref 'refs/heads/x': is at 6c2daaa but expected b08bff7  → 分支保住

其余

  • [High] protect 解析:IFS=',' read -ra + 参数展开去空白 + set -f。不再有 echo|xargs,a\bkeep'me 都不会再被吃掉。
  • [Medium] $integration::98 之后立刻算 integration_ref="refs/heads/$integration",四处调用点全换。你说得对,远程路径是唯一没有 -d 兜底的地方
  • [Medium] handle_wt 的 -D:走同一个 destroy_branch,拿到同样的 CAS。
  • Low·SIGPIPE:producer | grep -Fqx 全换成纯 bash 匹配(case $'\n'"$list"$'\n' in *$'\n'"$x"$'\n'*)),没有 producer 可杀就没有 141。已合并集合也改成只算一次,原来每个分支重算一遍 = 每次都是一次中彩机会。
  • Low·-h:哨兵行自己就是注释行,grep '^#' 滤不掉,加了 grep -v '^# ---8<---'
  • Low·成本注释:回到 N 并写明为什么(CAS 顶掉了那次复查)。
  • Low·status.md 的「恒返回 0」:补成「通常返回 0(零提交的废弃分支仍会被列出)」—— 你说得对,这是第四次在同一句订正上漏掉一个文件。

一条我没做,记账了(FU-9)

--remote 只用 git branch -r --merged,在 squash 仓库里按构造恒返回 0 行 —— 在这个功能存在的理由所指的那种仓库形态里,第 3 节是死代码,你标 Low 但我认为定性比 Low 重。

第五轮只让它把话说清楚(为空且带 --squash-merged 时打印「这一节只用 git-native 判据,在 squash 仓库里它看不见东西;这是『没查』不是『没有』」,并指向手工清理 / GitHub auto-delete),没有实现远程的证据检查。理由:那要对每个远程分支再花一次 gh 调用,而且远程路径没有 -d 兜底、风险高于本地,值得单独一个 PR 想清楚。这个 PR 已经因为装太多东西走到第五轮了。

关于你建议的回归夹具

同意,而且这一类缺陷已经在三个不同调用点出现过。这次的 fixture 脚本我是在临时目录里跑的,没进仓库 —— 因为它需要 stub gh,进 CI 得先想清楚怎么放才不会变成又一个「读起来很严、其实没在测」的守卫。一并记进 FU-9 旁边,和远程那条一起收口。

jhfnetboy added a commit that referenced this pull request Aug 5, 2026
* 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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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 --porcelainbranch 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-nameBISECT_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/mergedbgit branch -d -- heads/mergedbbranch 'heads/mergedb' not found
  • list_has "$git_merged_names" "mergedb" 在 :343 未命中,于是把这个分支-d 安全路径静默降级到 -D 证据路径

没有数据丢失(全 ref + CAS + 证据仍落在正确对象上),但这是上一轮那条 Critical 的同一个 tag 歧义类别,在路由层存活下来。另外,如果 tag 与当前分支同名,current_branch 会变成 heads/mainis_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)$1 vs 泄漏进来的全局)。建议按 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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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 里报 detachedis_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),不属于本 PRgrep -rn REQ_LIST 在本仓库这个 worktree 里零命中。是我串了两个脚本,撤回。

建议

  • destroy_branch2>/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 用 CAS update-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 在第五轮已经对这份完全相同的输入做过对抗挑战,重跑是仪式不是评审。⚠️ 但这个豁免只覆盖未改动的字节 —— 上面那条 §3 新发现从未经过 Codex;作者修完三条 blocker 之后,R3 必须重新跑。机械证据:发布版 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
@jhfnetboy

Copy link
Copy Markdown
Member Author

你的判词成立,我先认这一条:上一次推的确实只是一次纯 merge,三条 blocker 一条没动 —— 因为我漏看了第五轮的裁决。 我当时用 .[-1] 取最新 review,而那一刻第五轮还没落地,拿到的是第四轮那条;后来推 merge commit 触发了通知,我又只核对了「裁决 commit == 当前 head」就以为没有新东西。在一个正在连续出裁决的 PR 上按索引取 review,本身就是个会静默出错的做法。

四条全部修掉,推 3dcbdd5,逐条给机械证据。

1. [High] bash 3.2 的 local 语义

先自己复现了一遍,不是读代码推断:

$ /bin/bash --version → 3.2.57(1)-release (arm64-apple-darwin25)
外层 ref=refs/heads/GLOBAL-LEFTOVER
  one-local:  ref=refs/heads/ARGUMENT  short=GLOBAL-LEFTOVER   ← 错
  two-local:  ref=refs/heads/ARGUMENT  short=ARGUMENT          ← 对

拆成两句 local。这条我要额外说一句:它毁掉的正是不可逆删除的唯一恢复凭据,而且就在我自己在 commit message 里称为「the ONE exit for -D」的那个函数里 —— 把两处合成一处本来是为了让「修一半」不可能发生,结果我在合并的同一刻引入了一个新的、两处共享的错误。

2. [High] update-ref -d 丢掉了 worktree 占用拒绝

加了 ref_in_use_by_worktree():除 symbolic-ref HEAD 外,还读每个 worktree 的 rebase-merge/head-namerebase-apply/head-name —— 你指出的关键就是进行中的操作把真实分支记在那里,porcelain 的 detached 看不到它。

实测(worktree 停在 git rebase -iedit 上,porcelain 里 branch refs/heads/feat 命中数为 0):

第五轮版本: deleted feat  (merged via PR #77)  was=4b10f6b9…    → rebase 结果丢失
现在:       SKIP (checked out by a worktree — or being rebased there)  feat   → feat 存活

3. [Medium] heads/dup

git_merged_namesgit_merged_refs,全程用全 ref,三个消费点(§1 / §1b 成员测试 / handle_wt)一起改。实测同名 tag 场景:

%(refname:short) 产出: heads/dup main
第五轮版本: SKIP (not safely merged)  heads/dup      ← -d 安全的分支被谎报成未合并
现在:       Deleted branch dup (was c20548d)         ← 走正常 -d 路径

你把后果延伸的那一层(被谎报之后又被 §1b 按 -D 捡回去删)也确认了 —— 这是同一个字符串在两条路径上被反着解释。

4. [High·本轮新发现] §3 拿本地判据做服务端删除

这条我认为定性比你标的还重一点:它丢的不是操作员自己的东西。对真实 bare remote 复现你给的场景:

origin/main 含 colleague-precious : 0     ← 服务端并没有合并
本地 main   含 colleague-precious : 1
第五轮版本 → deleted origin/colleague-precious;git ls-remote 只剩 main

按你给的方向修:判据取 refs/remotes/<remote>/<integration>;本地领先于它就直接拒绝(一个 push 就能修好,而错答案不可逆);删除改成 --force-with-lease="<short>:<列举时的 sha>"

现在 → (skipped — local 'main' is AHEAD of origin/main; the server has not seen those merges. …)
       服务端现存: colleague-precious main       ← 保住了
正路径(服务端确实合并了) → deleted  origin/done-feat  was=9ec01821…   ← 照常删,并打印 sha

5. [Low] *[!0-9]*

改了。你这条的论证方式值得单独回应:你不是说「这里可利用」,而是说 #50 把它定成了文件级约定,而这次合并把那个教训 merge 进来了却没应用到同一个 skill 的兄弟脚本。这个标准我接受 —— 一个 skill 同时发布这两个脚本,它们对「什么是数字」应该只有一种判据。

关于轮次

这是第六轮,而且第六轮的存在完全是我漏读裁决造成的。四条里有三条是「上一轮修好一半、破掉另一半」的同一形状 —— 第 2 轮修 sha 句柄不修 ref 解析,第 3 轮反过来,第 5 轮合并两处却引入共享的 local bug。这次的 ref_in_use_by_worktree 和「服务端判据 + CAS」都是往「结构上不可能」的方向做的,但我不打算据此声称这次不一样 —— 前五轮我也是这么说的。

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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 --applydeleted origin/colleague-preciousgit 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 + breakrebase-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 报 detachedhead-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:40phases/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.2 local 作用域 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
@jhfnetboy jhfnetboy changed the title feat(pilot): FU-1/FU-2/FU-4 —— flag 改白名单 + safe-cleanup 支持 squash 仓库 refactor(pilot): squash-merge 仓库的分支清理 —— 只列不删 Aug 6, 2026

@clestons clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ REQUEST_CHANGES — 第八轮 [4-round]

「把不可逆操作全部删掉」这个方向是对的,而且它确实把上一轮四条 High 连根拔了 —— destroy_branchref_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.txtDIR 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:78phases/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,不是问题。

建议

  1. 把这次的清扫再跑一遍,这次针对文件系统写入。 git worktree remove 是唯一存活的不可逆动作,对它套用这个 PR 自己的规则(难的部分自动化,不可恢复的部分交给人)就能一次性关掉 Blocking 1、上面那条 Medium 和三处头部自相矛盾。
  2. §3 是在错误的轴上变安全了 —— 它不删了,却把「陈旧」留了下来。一旦「打印出来的命令」本身就是那个破坏性动作,数据新鲜度只会更重要,不会更不重要。
  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
@jhfnetboy

Copy link
Copy Markdown
Member Author

两条 High 全修,推 638e224这是最后一轮修复 —— 收口规则见文末。

你那句判词是准的:「清扫停在了 ref 写入,没走到文件系统写入」

我上一轮把 -Dpush --delete 删干净就宣布「zero irreversible operations」,而 git worktree remove 就在四屏之外活着,还是我那一轮新接到 squash 证据上的。自测复现:

工作树里放 .env(SECRET=abc) + ignored-stuff/keep.txt
  git status --porcelain              → 0 行      ← 看不见
  git status --porcelain --ignored    → 2 行
旧版 --apply → removed …/wt [feat] (git)  →  .env 没了,不可恢复
新版 --apply → 只打印路径 + 移除命令 + 「先查 ignored 文件」提示
               .env ✅ 在   ignored-stuff ✅ 在

§2 现在两种模式输出完全一样,只报告。这一刀顺带关掉了你标 Medium 的 bisect 那条(git bisect start 后 HEAD 仍在分支上、porcelain 报 0 的那个形态)——它和 gitignore 那条是同一个出口。

「no irreversible deletes」这句话是我写在头部的,而写它的时候文件系统删除还是活的。 教训我写进注释了:审查破坏性动作时,ref 写入是你会想到的,文件系统写入才是真正丢数据的那个。

§3:你说它「在错误的轴上变安全了」,这句我完全接受

它不删了,却把陈旧留下来,而且留在看起来最无害的那个模式里。你在真 bare remote 上的复现 + || true 吞掉 fetch 失败那条补扫,合起来说明「改成无条件 fetch」不够 —— 要做对得非 apply 也刷新、检查刷新成功、逐个候选复核,也就是把这个 PR 刚花六轮学会不要建的东西重建一遍

所以整节移除:不删也不列,并明说去开 GitHub 的 auto-delete-on-merge。

现在脚本唯一会执行的删除

git branch -d,而且是 git 自己在把关 —— 它拒绝未合并的分支,也拒绝被 worktree 占用的分支。除此之外全是报告。

四份文档同步了(README / SKILL / status.md / git-safety.md / run.md)。你说得对,上一轮它们读起来都是「这个脚本不做任何不可逆删除」。

剩余三条 Low 记进账本,不在本 PR 做

  • FU-10 注释漂移(update-ref/§3 的旧措辞)+ 远程 head 分支清理在文档流程里没有主人 —— 你这条补充发现比它的 Low 定级更值钱,我把「doctor 加一条只读检查:本仓库是否开了 auto-delete-on-merge」一起记进去了
  • FU-11 §1b 每次 pilot status 跑 25 次 API / 20.6 秒 → 按 tip sha 缓存,或改成显式 opt-in
  • FU-12 §1b 与 §2 对「正在 rebase 的分支」输出自相矛盾(已验证无害:git branch -D 自己会拒绝)

收口

仓库 owner 定了规则:这两个 PR 今天收口,只再走一轮。所以这是最后一轮修复 —— 之后无论裁决如何,剩下的问题一律记进账本,不再改代码。这个功能已经从「自动删」砍到「只报告」,再往下就只剩报告本身的措辞了。

jhfnetboy added a commit that referenced this pull request Aug 6, 2026
* 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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 原生已合并分支的删除。那个装着 .envSECRET=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,49run.md:76git-safety.md:55README.md(表格行 / :30 / :35 / :90)、SKILL.md:65templates/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 处)。

@jhfnetboy
jhfnetboy merged commit 19a5ed8 into main Aug 6, 2026
5 checks passed
@jhfnetboy
jhfnetboy deleted the feat/guards-squash-and-whitelist branch August 6, 2026 04:43
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants