refactor(pilot): 起跑门禁认「规划在别处」—— 一句声明,不做校验 - #49
Conversation
门禁只认 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
SKILL.md 冲突:#48 改 doctor 第 4 步(集成分支三分类),本 PR 改第 3 步 (规划层的 source= 字段说明)。两边都要,不是二选一。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — [4-round]
方向是对的:七件套文件名认不出等价规划源,plan.md §A.3 又明写「已有规划不要重复造」,这个矛盾确实该由「查哪里可配置、查得严不严不变」来解。判据也确实没松(MIN_BYTES + 未填 <...> 槽位不计数,两条路径共用)。11 组该拒的都拒了:[.] [./] [..] [../] [/] [~] [/etc] [a/../..] [backlog/*] [backlog/?] [backlog/[a]] 全部 rc=2;正常配置内容不够 → rc=1 NOT ready;空目录 → rc=1 NOT ready。
但有两条 High,而且第二条意味着这个功能在它自己的文档形态下根本跑不起来。
🔴 Blocking
1. [High] check-docs.sh:199 —— 「匹配整个仓库」的拒绝是一张精确字符串表,多打一个字符就绕过去了
拒绝表是 .|./|..|../|/|'~'。而 .//、./.、././、.///、.//. 既不是绝对路径、不含 ..、不含通配符 —— 全部通过校验;随后 p="${p%/}" 把它归一化,path_has_content 就去走整个仓库了。
在一个完全没有规划文档的仓库上,走真实的 .pilot.yml 配置路径实测:
(不配置) → rc=1 ok=0/7 NOT ready
planning_requires: [.//] → ok=1/1
declared planning source: ./
PILOT_DOCS: ready — planning layer complete, safe to run unattended.
rc=0
.git 更省事 —— 它在任何 git 仓库里都无条件通过。
这正是这条检查自己的报错要防的事(原文:「that disables the gate rather than configuring it」)。三方各自复现(reviewer / Codex / 终裁)。
修复:别再用模式匹配去枚举「. 有多少种拼法」,改成解析后比较:
rp="$(cd "$p" 2>/dev/null && pwd -P)"
[ "$rp" = "$(git rev-parse --show-toplevel)" ] && refuse再拒掉 .git 和任何解析后落在 toplevel 之外的路径。这一下把 .// / ./. / .git / 「符号链接 + 尾斜杠」整个家族一次性关掉,而不是等下一个变体再补一行。
2. [High] SKILL.md:67-68 + templates/pilot.example.yml:23 —— 这个 PR 自己文档的配置写法,这个 PR 自己新增的解析器读不了,而且是静默失败
SKILL.md:67 文档的写法是:
planning_requires: # 可选。规划已在别处时,声明它的本地路径,起跑门禁改查这些
- backlog/tasks #(不声明 = 老行为:查 docs_dir 下那七个固定文件名)而 awk 的 key 正则是 /^planning_requires:[[:space:]]*$/ —— 行尾注释让它匹配不上,inlist 永不置位,声明被静默丢弃,门禁退回七件套。按 SKILL.md 原文一字不改地喂进去实测:
PILOT_DOCS: mode=strict dir=docs/agent min_bytes=120 ok=0/7
MISSING (file absent):
- docs/agent/research.md ...
—— 正是这个 PR 立项要消灭的那个假 NOT-ready,而且没有任何信号告诉操作员「我看见这个 key 了但没读懂」。去掉注释的对照组解析正常(source=planning_requires(.pilot.yml))。
条目级注释更糟:- backlog/tasks # 注释 会被 tr -d ' ' 把注释粘进路径变成 backlog/tasks#注释 → MISSING。shipped 模板 :23 那行 docs/agent/progress.md # 运行态文档仍归 docs_dir… 就是这个形状 —— 照模板取消注释就会得到一条垃圾路径。
修复:awk 里先剥掉 #… 注释(key 行和条目行都要),key 正则放宽成 ^planning_requires:[[:space:]]*(#.*)?$;并且**「key 存在但解析出 0 条」必须 exit 2 或大声告警,绝不能静默回退**。
已确认
| 严重度 | 位置 | 问题 |
|---|---|---|
| Medium | check-docs.sh:103 vs :221 |
配置是从 git rev-parse --show-toplevel 找的,条目却是拿 cwd 去 -e 测的。同一个仓库同一份配置,根目录 → rc=0 ready,从 sub/ 跑 → rc=1 NOT ready,还附赠一句「correct planning_requires: if it points at the wrong paths」的误导建议。改成对 $_top 解析 |
| Medium | scripts/ci/check-docs-gate.sh:118 |
断言 7 是空转:Brood 自己的 .pilot.yml 压根没声明 planning_requires(已核实),所以 --no-config 是 no-op,这条断言与断言 1 逐字节相同;而且它把 flag 硬编码在自己这行、没走 run(),所以从 run() 里删掉这个 flag 它也不会变红 —— 与它注释里声称守护的东西正好相反 |
| Medium | check-docs.sh:161-167 |
find … | head -200 这个 200 上限,让一个大规划目录的判定取决于 find 的遍历顺序。实测:601 个文件的 backlog 式目录、唯一够实质的那个排在第 461 位 → ok=0/1 rc=1 NOT ready。而大的 backlog/tasks/ 正是本 PR 的招牌用例,不是理论问题。另外 :160 的注释还写着 -print -quit「costs one file read」,代码早就不是那样了 |
| Medium | check-docs.sh:151 |
文件符号链接解析到仓库外照样通过(ok=1/1);目录符号链接本身不通过(find 没有 -L),但条目只要拼成 linkdir// 或 linkdir/. 就通过了(rc=1 → rc=0)。根因与上面那条 blocking 相同:归一化发生在校验之后 |
| Low | check-docs.sh:218 |
planning_requires: [] 静默回退到七件套,而不是拒绝 —— 旁边那条守卫存在的理由恰恰就是拒绝空列表(--planning-requires " , " 会 exit 2,两者不一致) |
| Low | SKILL.md:71 |
「把值作为 flag 传给脚本(脚本本身不解析 YAML,保持简单确定)」现在是假的,并且与同一文件 13 行之下的 §3 自相矛盾;templates/pilot.example.yml:2 重复了同一句过期说法 |
| Low | .pilot.yml |
Brood 自己从没切到 planning_requires,所以 FU-5 对本仓库仍未解决,docs/agent/ 那层适配还在,功能零生产验证就发版了 |
补充发现(R4 全量复扫)
[Low] check-docs.sh:213 —— REQ_LIST 是全脚本唯一没有初始化的累加器
REQ_LIST="${REQ_LIST:-} $p" 会继承环境里导出的同名变量。实测 REQ_LIST=/ bash check-docs.sh → ok=2/2、declared planning source:/ README.md、rc=0。注入进来的条目从不经过循环,于是绕开 :198-211 那个 case 块加的每一条拒绝(绝对路径、.、..、通配符),并污染 declared planning source: 这行审计输出 —— 而那正是一个 fail-closed 门禁的报告所依赖的东西。它单独还翻不动 MISSING→ready(非空 $missing 仍强制 exit 1),所以定 Low。修:循环前加一句 REQ_LIST=""。
[Low] check-docs.sh:119 —— tr -d ' ' 把整个列表的空格都删了,所以含空格的路径被静默改写。实测 - my docs/tasks → 实际去查 mydocs/tasks → MISSING,一次假 NOT-ready 且没有任何提示说路径被改过;走 flag 形式 --planning-requires 'my docs/tasks' 则在 for p in $REQ_LIST 处被词分割成两条(ok=0/2)。修:只按条目做首尾裁剪(:196 的 per-item sed 本来就做对了),并给展开加引号。
驳回
- R1a 那条(
awk从不重置inlist) —— 不成立:inlist块在第一个「非条目、非空」行上就exit了。实测一个块列表后面跟remote: origin/docs_dir:,解析结果正是声明的那几条。⚠️ 另外必须记一笔:R1a 这一轮输出退化成了复读循环,把这同一条 finding 逐字重复输出了 109 次以上(编号 1..109)才被截断。这一轮 DeepSeek 的有效产出为零。 - R1b B1(符号链接路径穿越,Medium) —— 按其原文不成立:写
.pilot.yml的人和放符号链接的人是同一个,不存在信任边界。只有被重构成上表那条「尾斜杠 + 目录链接」的窄形式才成立。 - R1b B2(目录检查会跟随仓库外的符号链接) —— 按其原文是假的:
find不带-L不跟随目录符号链接,纯linkdir实测 rc=1。它只在条目被拼成linkdir///linkdir/.时才跟随 —— 那是上面那条绕过,不是符号链接缺陷。
建议
- 结构性问题是:一个 fail-closed 门禁的新配置面,靠字符串匹配来防守,而测试表枚举的正是同一批字符串 —— 测试之所以全绿,恰恰是因为它把这个缺陷一起编码进去了。改成解析后比较,这一整个家族当场消失。
- 目录只要一个够实质的文件就算过,所以
[backlog/tasks]一个 task 文件就能过关,相对于此前七份独立校验的文档是实质性放宽。建议要么要求最小文件数,要么在 SKILL.md 里把这个放宽明确写出来。 - 把 Brood 自己的
.pilot.yml切到planning_requires(或给 CI 加一个真声明了它的 fixture 仓库),让这个功能至少有一次真实演练 —— 现在每一条绿色断言跑的都是旧路径。
PK Review v4 · 4 轮:R1a DeepSeek-v4-flash(输出退化成复读循环,同一条误报重复 109+ 次,有效产出为零)/ R1b(2 条,按原文均不成立,其中一条实测为假)→ R2 Opus 独立战略评审(自建复现环境,两条 High + 四条 Medium 全部自出,并给出「模式匹配防守 + 测试表编码同一缺陷」的结构性判断)→ R3 Codex 对抗挑战(CONFIRM F1-F4,正确挑战了 DeepSeek 的 awk 说法和 R2 关于纯目录符号链接的假设,另补「目录链接 + 双斜杠」与 [] 静默回退)→ R4 Opus 全量裁决(另补 REQ_LIST 未初始化可被环境注入、tr -d ' ' 吃掉路径空格两条)。机械证据:11 组该拒条目全部 rc=2;[.//] 经 .pilot.yml 端到端拿到 ready … safe to run unattended rc=0;按 SKILL.md:67 原文喂入得到 dir=docs/agent ok=0/7(声明被静默丢弃),去注释对照组正常;601 文件目录第 461 位命中 → ok=0/1;REQ_LIST=/ 环境注入 → ok=2/2 rc=0。
## 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
|
两条 High 全部成立,已修并推 第二条我要特别认:这个 PR 自己文档里的写法,这个 PR 自己新增的解析器读不了,而且是静默失败 —— 也就是说这个功能会用它自己的实现,重新制造它立项要消灭的那个假 NOT-ready。你按 SKILL.md 一字不改喂进去的那次实测,比任何论证都直接。 1. 不再枚举「
|
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第二轮 [4-round]
方法换对了(resolve-and-compare 胜过拼法表),而且这一轮修好的东西是实打实的,我逐条跑过:
| 上一轮的问题 | 实测 |
|---|---|
| [High] 拒绝表可绕过 | .// ./. ././ ./// .//. . ./ .git .git/refs 全部 rc=2 ✅;正常的 backlog/tasks 仍 rc=0 |
| [High] 解析器读不了自己文档的写法 | key 行尾注释、条目行尾注释、行内数组带注释、注释单独成行 —— 全部能解析 ✅;而且路径里合法的 # 仍然存活 ✅ |
| 静默回退 | planning_requires: 空 与 [] 都变成 rc=2 大声报错 ✅;完全没有这个 key → rc=1 正常回退(正确地不报错)✅ |
| CI | scripts/ci/check-docs-gate.sh 在普通 clone 下全绿 ✅ |
问题是:同一个守卫还有三条路能走到 "ready — safe to run unattended",而且都是上一轮那条 High 换个拼法回来。
以下全部在一个规划文档为零的仓库上实测(该仓库不配置时的基线是 rc=1 NOT ready):
🔴 Blocking
1. [High] check-docs.sh:259-262 —— .git 拒绝仍然是按名字比字符串,.GIT 直接走过去
--planning-requires .git → rc=2 正确拒绝
--planning-requires .GIT → rc=0 ok=1/1 "ready — planning layer complete, safe to run unattended"
--planning-requires .Git → rc=0 同上
macOS 默认文件系统大小写不敏感,而 bash 的 pwd -P 保留你敲进去的拼法(实测 bash -c 'cd .GIT && pwd -P' → …/.GIT,zsh 则会返回 .git),所以 _abs 永远匹配不上字面量 "$_rootp"/.git。.GIT/config 同样通过,而且它自己就够 MIN_BYTES。
commit message 写着「不要试图枚举 . 有多少种拼法」—— 但 .git 仍然是按名字枚举的。
修复:问 git,别比字符串。cd "$_abs" && git rev-parse --is-inside-git-dir → true 就拒(实测 cd .GIT 下返回 true)。这同时也修好下面那条 worktree 里的 CI 断言。
2. [High] check-docs.sh:249-254 —— 守卫按 CWD 解析,而配置按 toplevel 定位,同一份 .pilot.yml 在不同目录下含义不同
同一份 planning_requires:\n - .:
从仓库根跑 → rc=2 正确拒绝
从 sub/ 跑 → rc=0 ok=1/1 "ready — safe to run unattended"
(sub/ 里只要有一个超过字节门槛的普通文件即可)
反方向同样开着:一份合法的 - plan-src 配置在根目录 rc=0、在 sub/ rc=1 —— 正是这个功能立项要消灭的那个假 NOT-ready。
修复:条目一律相对 $_root 解析(循环前 cd "$_root",或直接用 "$_root/$p" 算 _abs)—— 文档里本来就写着条目是 repo-relative 的。
3. [High] check-docs.sh:250 —— 非目录分支只解析了父目录**,所以指向仓库外的文件软链能通过**
planfile -> /etc/passwd, planning_requires: [planfile] → rc=0 ok=1/1 ready
-e 分支是 cd "$(dirname "$p")" && pwd -P,解析的是父目录,于是 _abs = <repo>/planfile,落在仓库内 → 通过;随后 path_has_content 直接读穿软链到 /etc/passwd,轻松过字节门槛。
目录软链是拦住了的(outlink -> /etc → rc=2),所以 commit message 里「符号链接一并关掉」只对了一半。(这条是 Codex 挑出来的 —— 它 CHALLENGE 了我上一轮「软链已拒」的说法,我只测了目录那一半。)
修复:解析条目本身(os.path.realpath 或 readlink 循环),再对解析后的目标重做包含性检查。
🔴 本轮新引入的回归
[Medium] check-docs.sh:131 —— 新加的大声 exit 2 会在合法 YAML 上炸
^[[:space:]]+- 要求至少一个空格,于是零缩进块序列解析不出任何东西,直接撞上新的 exit 2:
planning_requires:
- plan-src # 合法 YAML(yaml.safe_load 实测得到 {'planning_requires': ['plan-src']})
# 而且是不少格式化工具的默认输出标量写法 planning_requires: plan-src 同样。这个 commit 之前它们是静默回退的,现在变成一份合法配置把门禁硬停掉,还附一句「配置格式不对」——正是被修的那个 bug 的镜像。
修复:条目正则放成 ^[[:space:]]*-[[:space:]]*[^[:space:]],再加一个标量分支;exit 2 只留给真正空的声明。
[Medium] scripts/ci/check-docs-gate.sh:114 —— 新加的 .git/refs 断言依赖运行环境
在 linked worktree(.git 是文件不是目录,也就是 pilot 自己「一 task 一 worktree」教条下的形态)里,.git/refs 根本不存在 → _abs 为空 → .git 拒绝从不触发 → 断言变红:
FAIL planning_requires='.git/refs' refused — expected rc=2, got rc=1
1 assertion(s) failed — the planning-docs gate is not fail-closed.
普通 clone 里全绿(两种都实测过),所以不会让 CI 变红;但开发者在 pilot 推荐的 worktree 形态下本地跑这套件,看到的是一片红。结果本身仍是 fail-closed(rc=1),所以只是可移植性 + 信息质量问题。
修复:断言路径从 git rev-parse --git-dir / --git-common-dir 拼出来,或者像 $yml 那段一样在专门造的临时仓库里跑。
已确认(上一轮提过、本轮未动)
| 严重度 | 位置 | 问题 |
|---|---|---|
| Low | :151 |
无条件 break 让 .repo-pilot.yml 在「.pilot.yml 存在但没有这个 key」时永远读不到。实测:key 只在 .repo-pilot.yml + 一个无 key 的 .pilot.yml → rc=1 七件套回退;删掉 .pilot.yml → rc=0。这正是 :143-149 宣称不可接受的那种静默回退。改成解析成功才 break |
| Low | :269 |
REQ_LIST 仍然从不初始化,可被环境注入:REQ_LIST=/ bash check-docs.sh --planning-requires .GIT → ok=2/2 rc=0 ready,注入的条目绕开 case 块里的每一条拒绝。而这个脚本自己对 PILOT_DOC_MIN_BYTES 做了完全相同类型的加固,两者不一致 |
| Low | :221/:276 |
含空格的路径仍被词分割成两条(机制已从 tr -d ' ' 移到 REQ_LIST 的词分割,所以上一轮建议的改法落不到点上);head -200 让目录判定依赖 find 的遍历顺序 |
| Low | — | ~ 悄悄丢了它的解释性拒绝(上一轮 rc=2,现在作为 MISSING 落到 rc=1)。仍然 fail-closed,不阻塞,但旧消息会告诉用户为什么,值得作为具名分支恢复 |
补充发现(R4 全量复扫)
[Low] SKILL.md:72 与 templates/pilot.example.yml:2 —— 两处仍然写着「脚本本身不解析 YAML,由 skill 用 Read 读取后作为 flag 传给脚本」。而脚本现在自己解析 .pilot.yml,check-docs.sh:92-99 还专门论证了直接读配置是承重的(因为 phases/run.md:14 调用门禁时不传 --planning-requires)。本 commit 重写了这两个文件里的 YAML 示例,却把与之矛盾的那句话留下了 —— 于是发出去的文档在告诉下一个读者:那条读配置的路径不存在。
[Low] templates/pilot.example.yml:17-18 —— 新改的拒绝清单(「指向仓库根(. 的任何拼法)/ .git / 绝对路径 / 仓库外 … 都会被直接拒绝」)现在三处都在夸大:.GIT、子目录里调用的 .、仓库外的文件软链,实测全部通过。一个 fail-closed 门禁的文档夸大它拒绝什么,比没有文档更糟。
驳回(DeepSeek 5 条全部不成立)
- A1「grep 会匹配被注释掉的 key」 ——
^是锚定的。把真实的、整段被注释掉的pilot.example.yml喂进去 → rc=1 正常回退,无 abort。 - A2「grep 会匹配带前导空格的 key」 —— 反了:缩进的 key grep 和解析器都不匹配,直接回退。
- B1「
pwd -P会让仓库内软链指向外部从而绕过」 —— 机制说反了:pwd -P正是抓住目录软链的那一步(outlink -> /etc→ rc=2)。真正的洞是:250只解析父目录导致的文件软链,位置对了、成因错了。 - B2/B3 —— 无任何经得起实测的行为主张。
建议
- 先把这三条幸存的绕过写成 CI 断言,再去修 —— 套件里已经有
$yml那套临时仓库脚手架,造.GIT、sub/调用、软链三个用例都很便宜。 - NOT-ready 那段文案(
:323)不论列表来自--planning-requires还是.repo-pilot.yml,都写成「declared in .pilot.yml」;planning_src已经算好了,直接用它。
PK Review v4 · 4 轮:R1a/R1b DeepSeek-v4-flash(并行,5 条全部驳回,其中两条把机制说反了)→ R2 Opus 独立战略评审(挖出 .GIT 大小写绕过、CWD-vs-toplevel 绕过,以及零缩进 YAML 硬中止这条本轮新引入的回归)→ R3 Codex 对抗挑战(CONFIRM 4 条,并CHALLENGE 掉执行器「软链已拒」的说法 —— 只有目录软链被拒,由此挖出 /etc/passwd 文件软链绕过)→ R4 Opus 全量裁决(补出 bash-vs-zsh pwd -P 保留拼法这一成因、.GIT/config、两处过期文档)。机械证据:零规划仓库上三条绕过各自端到端复现到 ready … safe to run unattended rc=0;上一轮 9 种拼法 + 目录软链全部 rc=2 复验;解析器 6 种 YAML 形态逐一验证;yaml.safe_load 确认零缩进块序列合法;CI 套件在普通 clone 全绿 / 在 linked worktree 变红两种环境对照。
## 三条 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
|
五条全部成立,已修并推 我引入的回归零缩进块序列 + 标量写法被新加的
三条 High:同一个 resolve 步骤,三件事各自手做
改法是把三件事一次做对:相对仓库根解析、解析条目本身(realpath)、然后问 git 它是什么。
只用后者时,linked worktree 里的 实测断言 28 → 38 条,而且这次在两种仓库形态下都跑第一次改完我只在主仓库测,拿到 linked worktree 里立刻红了 2 条 —— 正是你说的那个形态。修完再跑: 「断言本身依赖环境」这条我记住了:一条只在作者机器上成立的断言,和没有断言的区别只是它会在别人那里变成噪音。所以新增的每条都问了一遍「换个形态还成立吗」。 |
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第三轮 [4-round]
先说这一轮真修好了什么(全部在 head ede9fd78 上实跑,与父提交 818a5651 逐条对照)
| 上一轮的问题 | 实测(parent → head) |
|---|---|
[High] .git 按名字比字符串,.GIT 走过去 |
--planning-requires .GIT:rc=0「ready」→ rc=2 拒绝 ✅ |
| [High] 守卫按 CWD 解析、配置按 toplevel 定位 | 同一份 .pilot.yml:parent 在 root=0 / 在 sub/=1(自相矛盾)→ head 两处都是 0 ✅ |
| [High] 只解析父目录,符号链接文件能指出仓库外 | planfile -> /etc/passwd:rc=0 → rc=2 ✅ |
标量形式 planning_requires: plan-src 被判「声明了但解析不出」 |
rc=2 abort → rc=0 ✅ |
.// ./. ././ ./// .//. .git .git/refs |
全部 rc=2 ✅ |
仓库自带 scripts/ci/check-docs-gate.sh |
33 条断言全绿,而且在 linked worktree(.git 是 gitfile)里也绿 —— --resolve-git-dir 那半的说法站得住 ✅ |
这些是实打实的,不是加注释糊过去的。
但这一轮又是同一个老毛病,而且这次是三连发:三处修复,每一处都把「不依赖外部工具的检查」换成了「依赖外部工具的检查」,而三处都没处理那个工具不在的情况。 两条 High 全部长在这一个模式上。
🔴 Blocking
1. [High] check-docs.sh:235,294-303 —— 删掉字面量 .git 拒绝改问 git,git 一旦不可用,守卫直接消失,而且是 fail-OPEN
父提交那句 case "$_abs" in "$_rootp"/.git|"$_rootp"/.git/*) 被删掉了,.git 的拒绝现在完全依赖 git rev-parse 能跑起来;而 _root 又是 git rev-parse --show-toplevel 2>/dev/null || pwd -P,git 失败时静默退回 CWD。
实测 --planning-requires .git:
parent 818a5651 → rc=2 拒绝(零依赖,靠字符串就挡住了)
head ede9fd78 → rc=0 "ready — planning layer complete, safe to run unattended"
两个独立触发条件都复现了:git 不在 PATH 里;以及 git 在、但拒绝服务(fatal: detected dubious ownership —— 这就是普通 docker / CI 挂载、safe.directory 没配的形态,不是什么极端环境)。也就是说,在这些环境里这个门禁会对一个规划文档为零的仓库说「可以无人值守跑」,而上一版不会。三方独立确认(reviewer / Opus R2 / Codex R3 各自在自己的沙箱里跑出同样的 rc)。
修复:把字面量 .git 字符串拒绝留着当零依赖底线,git rev-parse 作为附加检查而不是替代;并且 git rev-parse --show-toplevel 失败时 exit 2,不要退回 pwd -P —— 一个定位不到仓库根的门禁,本来就无法证明 containment。
2. [High] check-docs.sh:281,321 —— planning_requires 现在硬依赖 python3,python3 不在就整个功能报废,而且报的正是它要消灭的那个假「NOT ready」
两处 realpath 都是 python3 -c ... 2>/dev/null || true:_abs 空 → 整个校验块被跳过;_pabs 空 → [ -z "$_pabs" ] 判定条目 MISSING。
拿一个完全正常的配置实测(planning_requires: [plan-src],plan-src/tasks.md 内容充足):
有 python3 → rc=0 "ready — planning layer complete, safe to run unattended"
无 python3 → rc=1 "NOT ready — ... is incomplete" MISSING (path absent): plan-src
parent 无 python3 → rc=0 (父提交是纯 shell,根本不需要 python3)
报错里一个字都没提 python3,操作者看到的是「你的规划源不完整」——他会去改根本没问题的规划文档。这正好是 FU-5 这个功能立项要消灭的那类假 NOT-ready,由这个功能自己引进来。
修复:进循环前探一次 command -v python3 >/dev/null || { echo "ERROR: planning_requires validation needs python3" >&2; exit 2; },缺解释器就大声退出,别静默把结论反过来。cd "$_rootp" 失败导致 _abs 为空同理(F3)——必须 exit 2,不能变成「跳过检查」。
3. [Medium] check-docs.sh:144 —— 本次唯一的 inlist 改动([[:space:]]+ → *)让 YAML 文档分隔符 --- 被当成条目 --
零缩进序列该支持,这个方向没错。但放宽之后 --- 也匹配 ^[[:space:]]*-[[:space:]]*[^[:space:]],于是它不再终止列表,而是被吃成一个条目。
同一份输入 planning_requires:\n - plan-src\n---\nother: 1\n:
parent → rc=0 declared planning source: plan-src (ready)
head → rc=1 declared planning source: plan-src -- MISSING: --
yaml.safe_load_all 读出来是 ['plan-src']。又一个「修假 NOT-ready 的补丁自己造出假 NOT-ready」。
修复:在 inlist 规则前显式终止 —— line ~ /^(---|\.\.\.)[[:space:]]*$/ { exit },或者按下面的建议整体换掉这个 awk 解析器。
4. [Medium] check-docs.sh:121,150 —— 顶格 # 注释行触发 exit,静默丢掉后面所有条目,fail-OPEN
注释剥离要求 # 前面有空白([[:space:]]+#),所以顶格 # note 不被剥离、不是 - item、不是空行 → 撞上 exit。
planning_requires:
- plan-src # 有内容
# note
- plan-two # 空的
→ head rc=0「ready」,plan-two 从头到尾没被检查过;parent 是 rc=2。这条截断规则本身是旧的,但是这次提交把零缩进列表变成了合法写法,而顶格注释正是零缩进列表的惯用搭配——同时它把父提交那个响亮的 rc=2 变成了静默的 rc=0。
修复:在条目判断前先剥掉 ^[[:space:]]*# 整行注释;并且把「遇到看不懂的行」从静默 exit 改成 exit 2 大声报错。
其余(不阻塞,但都实测过)
- [Medium]
check-docs.sh:122-127(旧问题,非本次引入) —— 同一类截断在 inline array 上也 fail-open:planning_requires: [plan-src,\n plan-two]首行没有],sub(/\].*$/)找不到就exit,plan-two空时仍然 rc=0 ok=1/1「ready」。父提交也是 rc=0,所以不算回归——但这是同一个解析器里第三处「截断即放行」,也正是「再加第五条正则」方向不对的原因。 - [Low]
:131-135(本次新引入) —— 新的标量规则会把 YAML 的 null / anchor 当成字面路径:planning_requires: null/~/&a+ 块列表 → head rc=1,报declared planning source: null/~/&a;parent 是 rc=2「声明了但解析不出」。其中&a那个是真的假 NOT-ready(yaml.safe_load读出来是['plan-src'])。 - [Low]
:296-303——--is-inside-git-dir探测会cd进$_chk,而它可能在仓库外,于是去问了另一个仓库。实测:仓库内一个指向/tmp/otherrepo/.git的符号链接,报的是「points inside the git directory」而不是「resolves outside the repository」,:304 的 containment 检查根本没走到。rc 对(2)、理由错,而且意味着 git 探测静默盖过了 containment。 - [Low]
:311,318,330(旧问题) ——REQ_LIST是空格拼接的字符串,带空格的条目会被拆成两个假路径(--planning-requires "with space"对一个有内容的with space/目录 → rc=1)。父提交一样,趁这次重写循环顺手修掉。
建议(下一轮的方向,不是加第五条正则)
- 把四条 awk 规则换成一次 python3 读取(
yaml.safe_load,PyYAML 缺失才退回严格行解析)。它一次性解决 X1(---)、X2(顶格注释)、flow-array 截断、null/anchor 四类问题。而且 python3 已经是同目录git-guard.sh/pr-monitor.sh的硬依赖——真要依赖就统一依赖,并且把「外部工具缺失」一律做成显式exit 2。 - 给下一轮立一条可被检验的规则:这个门禁不允许因为某个工具不在而丢掉任何一项检查。 本次三处修复全部踩了这一条,两条 High 都长在上面。
- 把这四个已证明的用例加进
scripts/ci/check-docs-gate.sh:无 python3 的 PATH、无 git 的 PATH、---分隔符、顶格注释。现在这套断言在 head 上全绿(33/33,已验证),恰恰是因为它只测上一轮那几个具体实例——这就是这个 PR 走到第三轮、同一个 bug 类还活着的机制原因。
4-round:R1a/R1b DeepSeek-v4-flash → R2 Opus 独立复审 → R3 Codex 对抗性 PK(0 挑战 / 4 确认,在独立沙箱跑真脚本)→ R4 Opus 终裁 + 全量补扫。所有 rc 数字均为在隔离 worktree 中对 head 与 parent 同一输入的实跑结果。
原方案是 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
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — 第四轮 [4-round]
先把话说清楚:砍掉整个路径校验器这个决定是对的,我把理由写在最后一节,这不是「因为轮数多所以继续挡」。 两条 blocking 都是机械问题(一个管道、一个 sed),各自两行能修完。
这次删掉的东西,删干净了
grep -rn planning_requires在整个 HEAD 上只剩check-docs.sh:87那条历史注释 —— docs / templates / CI / workflows 里没有任何悬空引用。- 上一轮四条 blocker(python3 硬依赖、
.git守卫 fail-open、---分隔符、顶格注释截断)全部随校验器一起消失,不存在残留路径。 - CI 缩掉的 ~110 行不是覆盖率损失:那些断言测的全是
planning_requires的「路径能怎么撒谎」,而这个特性已经不存在了。R2 对存活下来的断言做了变异测试——注入 9 个回归,7 个变红(catch-all 猜成 external、catch-all 直接放行、--no-config失效、EMPTY 不再拦、MIN_BYTES 校验被删、前缀匹配放宽、banner 被改写),只有「值匹配变成大小写不敏感」漏网。 - 新的声明解析器语义面我实测了 12 种写法,行为都对:
external→rc=0;docs/缺失/空值→rc=1(fail-closed);EXTERNAL/bogus→rc=2;缩进的嵌套键、块标量里的同名行都正确忽略;旧的planning_requires键被干净地忽略。
🔴 Blocking
1. [High] scripts/ci/check-docs-gate.sh:98 —— 这条新断言让 docs-gate 这个必过检查有约三分之一的概率随机变红,而且报的错完全误导人
if (cd "$ps" && bash "$GATE_ABS" --strict 2>&1) | grep -q "NOTHING WAS CHECKED"; thengrep -q 匹配到第 1 行就立刻退出,而 gate 那个子 shell 还要再 echo 4 行 → 收到 SIGPIPE → 子 shell 退出码 141;第 12 行的 set -o pipefail 把 141 提升成整条管道的状态,于是 if 走 else 分支——banner 明明正确打印了,断言却判失败。
我独立复现(不是复述 R4):在 PR head 上连跑 15 次完整 CI 套件,5 次红(33%):
run 3, 4, 9, 11, 15 → rc=1
"1 assertion(s) failed — the planning-docs gate is not fail-closed."
R4 另外三组独立采样是 9/25、6/20、4/15、6/25,同一量级。父提交 ede9fd78 的 CI 对父 gate 跑 20/20 全绿,grep -n "grep -q" 在 ede9fd7 上零命中——这是本次提交引入的回归。
.github/workflows/verify.yml:47-52 把它作为 docs-gate job 跑,没有 continue-on-error。所以这个 PR 会送上去一个**三分之一概率红、而且红的时候声称「门禁不是 fail-closed」**的必过检查——恰恰是最容易让人误以为门禁真坏了的那句话。
修复:别用管道。
_out="$(cd "$ps" && bash "$GATE_ABS" --strict 2>&1)"
case "$_out" in *"NOTHING WAS CHECKED"*) : ;; *) fail ;; esacR4 用这个改法跑了 25/25 全绿。改完请连跑 ≥20 次再推——在 33% 的失败率下,单次绿什么都证明不了。
2. [Medium] check-docs.sh:104 —— sed/tr 解析器会把「根本不是它声称要读的那种 YAML」当成 external,而且一律 fail-OPEN
三种输入,同时对 gate 和 yaml.safe_load 各测一遍(基线:未配置的空仓库 rc=1):
.pilot.yml 内容 |
真实 YAML 语义 | gate |
|---|---|---|
planning_source:external(无空格) |
一个纯标量字符串,根本没有 key | rc=0 |
planning_source:<TAB>external |
ScannerError(line 1 col 17) | rc=0 |
planning_source: ex"ter"nal |
值就是 ex"ter"nal,本该命中 *) 的「拒绝而不是猜」分支 |
tr -d "\"'" 把它抹成 external → rc=0 |
三种全部倒向**「门禁关闭」那一边。这正是被删掉的那个校验器的同一个缺陷类,只是小了一号——而这次落点更糟:以前是路径判断出错,现在是整个门禁被无声关掉**。
代码注释里那句「a declaration has no criterion to subvert」,对语义成立,对解析器不成立:人的意图和「门禁不检查任何东西」之间,仍然隔着这 6 行 sed/tr。
修复:sed 里要求一个真空格 s/^planning_source:[[:space:]]\{1,\}//p(畸形写法就自然落回 docs 检查),并且只脱成对引号 s/^"\(.*\)"$/\1/ 而不是 tr -d。这三个用例请一并进 CI。
已确认(不阻塞)
| 严重度 | 位置 | 问题 |
|---|---|---|
| Medium | phases/run.md:17 |
第一条 bullet 仍写着 rc=0 → 「规划层齐全且已填写…继续 0b」,对 external 已经是假的;agent 是先用 $? 匹配、之后才可能知道自己在 external 分支。第 19 行那条 caveat 只帮得到「已经知道自己是这种情况」的人。建议按输出而不是 $? 拆这条 bullet(ready vs NOTHING WAS CHECKED),把这个字符串区分提升为明写的接口契约,并在 CI 里双向断言(docs 通过路径永不打印 banner;external 路径永不打印 ready) |
| Medium | check-docs.sh:112-120 |
F1/C1:external 的 rc=0 和「验证通过」的 rc=0 靠 $? 无法区分,只有 stdout 分得开。这是这个设计自愿付的代价,我不当 blocker——但它让上面那条 run.md 修复和(目前还在抽风的)banner 断言从「锦上添花」变成承重结构 |
| Low | check-docs.sh:101 |
声明是从 CWD 的 git toplevel 读的,而实际检查的是 --docs-dir 这个不相干的路径,中间没有任何东西断言两者属于同一个仓库。实测:同一个 --docs-dir,从声明了 external 的 A 仓库调用 → rc=0,从 B 仓库调用 → rc=1。被删掉的 CI 断言 #10(「一份配置在哪个目录跑都必须只有一个含义」)保护的正是这条,删了没有替代 |
| Low | check-docs.sh:102 |
.repo-pilot.yml 被静默接受,而 SKILL.md:72 / run.md:54 / status.md:7 都要求旧文件生效时大声弃用警告。而且比报告的更尖锐:输出归错了文件——只有 .repo-pilot.yml 的仓库会打印 source=external (declared in .pilot.yml)(:120 的拒绝分支也硬编码了同一个名字),把操作者指向一个根本不含该声明的文件 |
补充发现(R4 全量复扫)
- [Low]
check-docs.sh:71——-h|--help把# ---8<--- end of help哨兵换成了硬编码的sed -n '2,25p'。同一个 hunk 里被删掉的那条注释,警告的就是硬编码行号会「SILENTLY 漂移(已在 git-guard.sh 上实测过)」。而且它一上来就已经错了:第 26 行正是# Exit: 0 = ready, 1 = gaps found, 2 = usage error,所以--help | grep -c "Exit: 0 = ready"→ 0。一个全部契约就是退出码的门禁,--help里不再写它的退出码了;父提交是有的。建议把哨兵放回去。 - [Low]
SKILL.md:70—— 「读取方式:…把值作为 flag 传给脚本(脚本本身不解析 YAML,保持简单确定)」两半现在都不成立:脚本确实在解析 YAML(:104),而删掉--planning-requires之后没有任何 flag 能承载planning_source,所以它上面那段刚引入的字段,文档描述的机制是做不到的。
关于这次「砍掉校验器」的决定
我支持,而且想把这句话留在记录里。 两轮评审、五个缺陷,每一个都在路径校验里,没有一个在它想解决的问题上——这本身就是「让门禁去核实一个可配置路径」比它要解决的问题更大的有力证据。把「路径能怎么撒谎」这一整类问题连根拔掉,比再补第六条正则正确。CI 缩水也不是覆盖率损失(见上)。
两条建议:
ready/NOTHING WAS CHECKED这个字符串二分,现在是唯一能区分两种 rc=0 的东西,但它没有在任何地方被写成接口。下一个check-docs.sh && pilot run包装脚本、或者下一次改文案,都会无声地把它丢掉。写进契约 + CI 双向断言。- 这个特性目前在本仓库零使用者:Brood 自己的
.pilot.yml没有设planning_source,而docs/agent现在实测 7/7 —— 代码注释里那个「Brood 被判 0/7」的立项场景已经过期了。加上所有 CI fixture 都传--no-config,将来某天有人一行改成external,CI 会全绿地放过去。建议加一条断言:Brood 自己的配置不得是external。
4-round:R1a/R1b DeepSeek-v4-flash(F1 站得住并被后续采纳为非阻塞项,F2/F3/S1/S2 被反证驳回)→ R2 Opus 独立评审(跑真脚本 + yaml.safe_load 对照 + 对存活 CI 做 9 个变异注入)→ R3 Codex 对抗挑战(独立沙箱,0 挑战 / 3 确认)→ R4 Opus 终裁 + 全量复扫(挖出 CI SIGPIPE 抽风这条 High,并给出 25/25 验证过的修法)。机械证据:本 reviewer 在 PR head 上独立连跑 CI 15 次得 5 次红(33%),与 R4 的多组采样同量级;解析器 12 种写法逐一实测;父提交 CI 20/20 全绿作为对照。
## 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
|
两条全部成立,已修并推 1. SIGPIPE 那条:我今天刚修过同一个 bug
改法用你给的:捕获到变量 + 你指出「它报的是 the planning-docs gate is not fail-closed,恰恰最容易让人以为门禁真坏了」——这句我特别认。一条会随机变红、而且红得像真事故的必过检查,比没有这条检查更糟。 2. 解析器 fail-open:声明去掉了判据,但没去掉读它的那 6 行你这句话把我说服了:
确实如此,而且落点比原来更糟:以前是路径判断出错,现在是整个门禁被无声关掉。 改法:
七种写法逐一实测,并与
四个新用例(三个畸形 + 成对引号)全部进了 CI 断言。 关于你最后那节「砍掉整个路径校验器是对的」这个判断,连同你做的那些交叉核对(悬空引用清零、上一轮四条 blocker 随校验器一起消失、对存活断言做变异测试 9 注入 7 变红、12 种声明写法的语义面)——这些比一句 approve 有用得多,因为它们说明砍掉之后剩下的东西确实还站得住,而不只是「代码少了所以问题少了」。 那条漏网的变异(值匹配变成大小写不敏感)我看了: |
clestons
left a comment
There was a problem hiding this comment.
✅ APPROVE — 第五轮 [2-round,post-R2 全 Low → 按闸门规则跳过 Codex]
上一轮的两条 blocking 都真修好了,41 行改动,没找到回归。
机械证据(reviewer / Opus R2 / Opus R4 各自独立跑)
H1 —— CI 的 SIGPIPE 抖动,是「按构造消失」而不是「概率变小」
parent 36a8d9a3 |
head 09503fcb |
|
|---|---|---|
| reviewer | (上轮测得 5 红 / 15) | 0 红 / 25 |
| R2 | 11 红 / 40(≈28%) | 0 红 / 40 |
| R4 | 9 红 / 30(30%) | 0 红 / 45 |
| 合计 head | 0 红 / 110 次 |
_psout="$(…)" + case 把管道整个去掉了,SIGPIPE 竞态不再存在——不是变罕见。而且注释里那句「我自己在 safe-cleanup.sh 写了 list_has 来躲这个坑,然后在同一批 PR 里又踩进去一次」,值得留着。
H2 —— 三个 fail-open 全部翻成 fail-closed / refuse
在空规划文档的一次性仓库里跑(未配置基线 rc=1),每一条都拿 yaml.safe_load 对照过:
.pilot.yml |
上一轮 | 本轮 |
|---|---|---|
planning_source:external(无空格,YAML 里根本没有 key) |
rc=0 门禁关 | rc=1 fail-CLOSED |
planning_source:<TAB>external(yaml.safe_load 报 ScannerError) |
rc=0 门禁关 | rc=1 fail-CLOSED |
planning_source: ex"ter"nal(值就该被拒绝) |
rc=0 门禁关 | rc=2 refuse |
原有的五种合法写法全部照旧 rc=0(external / 两个空格 / "external" / 'external' / 尾注释),docs→1、bogus→2、未配对引号→2、空值→1,CRLF 正确处理。36 个用例矩阵,零回归。
用 [[:space:]]\{1,\} 而不是 [[:space:]]*、并且明确选了字面空格而非字符类(因为该类含 TAB 而 YAML 不含)——这个推理是对的,我按它复核过。
后续项(不阻塞,建议一次性收)
解析器还剩四处同类 fail-open,是一次重构而不是四个补丁:
:121(就在本次新增的那两行里) —— 两个脱引号表达式是顺序执行的,所以会级联:planning_source: "'external'"→ 脱双引号得'external'→ 再脱单引号得external→ rc=0 门禁关,而 PyYAML 读出来的值是'external',本该走 refuse。不对称:'"external"'反过来是正确的 rc=2。修法(8 个探针全部验过):去掉那两个-e,改成互斥的case "$v" in '"'*'"') …;; "'"*"'") …;; esac。别用sed … -e 't'—— 前面那条s/[[:space:]]*$//必然成功,t会无条件触发、把'external'弄坏。:120—— 注释剥离用的是[[:space:]]*#(零个也行),而 YAML 只有在#前有空白时才算注释。planning_source: external#foo(真实值external#foo,该 refuse)被抹成external→ rc=0。planning_source: "external"#c同理(剥注释跑在脱引号之前)。:119——head -1取第一个planning_source:,而 PyYAML 重复键是后者胜出。写external再写docs,脚本 rc=0(门禁关)而有效配置是docs;三个重复键(external/backlog/docs)时 PyYAML 解析为docs,脚本仍 rc=0。改成while read取最后一个,顺手也解决这条。- (Info)
external<TAB><TAB>→ rc=0,但yaml.safe_load对该文件报 ScannerError;external<NBSP>在en_US.UTF-8下 BSD sed 的[[:space:]]会吃掉 NBSP → rc=0。都是既有行,本次未改动。
兄弟脚本里同一形状的 reader 还有四个 —— git-guard.sh:37-39、preflight.sh:62、grade-change.sh:45-46 仍逐字保留着 [[:space:]]* + 一刀切 tr -d " \"'[]"。建议先修 preflight.sh:62:它是四个里唯一一个「被误解析的值会被当成命令执行」的(不构成权限边界,.pilot.yml 本来就合法地指定要跑的命令,但它的脱引号是不配对的 s/^["']//;s/["']$//,而且 [[:space:]]* 接受本次刚在 check-docs.sh 里关掉的无空格/TAB 两种写法)。长远做法是抽一个 read_yaml_scalar() 共用。
(safe-cleanup.sh:137 和 git-guard.sh:98 也是 | grep -q + pipefail 的同形状,但各测 1000 次零命中——单次 syscall 的生产者(git branch / git remote)赢得竞态,而 H1 那个多次 echo 的写者是 34/300。而且它们取反后都落在 refuse/KEEP 分支,fail-safe。仅记录。)
上一轮四条文档漂移仍未处理(都早于本次提交、本次也没让它们变坏):--help 的 sed -n '2,25p' 截在第 25 行,而 Exit: 0 = ready… 在第 26 行,实测 --help | grep -c "Exit: 0 = ready" = 0(改成 '2,26p',并让 CI 断言这一行);:129 仍硬编码 (declared in .pilot.yml),即使值来自 .repo-pilot.yml,而 SKILL.md:72 要求「绝不静默忽略旧文件」并大声弃用警告;SKILL.md:70 仍写「脚本本身不解析 YAML」「把值作为 flag 传给脚本」,两句现在都不成立、也没有这样的 flag。
关于
phases/run.md:17那条我上一轮提的意见,我收回一半:rc=0那条 bullet 确实是按 docs 情况写的,但往下三行那条 bullet 明确覆盖了planning_source: external、要求汇报「门禁未核实」、禁止写「规划已验证」。顺序别扭,但该有的指令在,而且是对的。
为什么这轮放行
两条 blocking 是仅有的 Medium+ 项,都用我自己独立跑出来的机械证据关掉了(CI 红率 30% → 0%、三个 fail-open 翻成 fail-closed、36 个用例对照 PyYAML 零回归)。剩下的全是同一个形状:一个任何 YAML 解析器都不认的 .pilot.yml 值,而写它的正是那个本来直接敲 external 就能合法关掉门禁的人。 这个门禁是声明不是判据,威胁模型是「一个诚实的作者承担责任」,不是「一份精心构造的配置」——而 commit message 自己解释的正是:声明式设计存在的意义,就是不再去追「输入能怎么撒谎」的每一种写法。为了 "'external'" 再开第六轮,等于重新走回这个设计刚逃出来的那个兔子洞。
轮数没有影响这个判断,作者辛苦也没有:如果 "'external'" 是普通用户真会走的路径,或者 CI 还在抖,我照样挡。
2-round:R1a/R1b DeepSeek-v4-flash(F1「tab 测试绕过 psrun」被驳回——psrun 写的就是同一个 $ps/.pilot.yml、同一条调用;F2「head -1 截断多行值」被驳回——块标量 >-、流式序列 [external]、锚点 &a external 实测全是 rc=2 refuse,fail-closed)→ R2 Opus 独立评审(跑真脚本、两个 commit 各 40 次 CI、完整解析矩阵)→ post-R2 severity gate 全 Low,按 skill 规则跳过 Codex R3 → R4 Opus 终裁 + 全量复扫(独立复现 H1 9/30→0/45、H2 36 用例对 PyYAML;在本次新增的那两行里补出 "'external'" 级联脱引号这条 Low)。
这个 PR 变了:从「可配置的路径校验器」改成「一句声明」
原方案是
planning_requires: [backlog/tasks]—— 让仓库指出真正的规划源,门禁去校验那些路径(同样的内容判据、目录要有实质文件、不许指向仓库根…)。两轮评审在这一个能力上找出五个缺陷,而且全部在路径校验里,没有一个在它要解决的那个问题上:
.//./../././//.GIT.git的字符串匹配.pilot.yml在不同目录下含义不同一个「换个路径去查」的旋钮,必须扛住路径能撒谎的每一种方式,而那是个比原问题大得多的问题。
改成
external时门禁什么都不查,打印NOTHING WAS CHECKED然后放行:没有判据可以被绕过,因为根本没有判据。
门禁存在的意义是「别在规划不全的时候无人值守开跑」。仓库里的人写下
external,就是显式接过了这个责任——这正是门禁本来在要求的东西。代价是这类仓库的门禁变成不查;但门禁本来只为无人值守而存在,人可以选择不开无人值守。SKILL.md和run.md里配套写死一条:汇报时必须照实说「本仓库声明规划在别处、门禁未核实」,不能说成「规划已验证」 —— 放行是人担保的,不是脚本核实的。规模与断言
check-docs.sh从 +304 行砍到 +67 行;整个 PR 从 +431 砍到 +114。断言也换了性质——从「九条只为覆盖路径能怎么撒谎」变成六条行为断言:
最后那条是必需的:CI 从仓库根跑,一旦本仓库声明
external,没有--no-config的话上面每条断言都会 exit 0 却照样打印 ok。Closes FU-5
https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk