Skip to content

refactor(pilot): 起跑门禁认「规划在别处」—— 一句声明,不做校验 - #49

Merged
jhfnetboy merged 7 commits into
mainfrom
fix/planning-source
Aug 6, 2026
Merged

refactor(pilot): 起跑门禁认「规划在别处」—— 一句声明,不做校验#49
jhfnetboy merged 7 commits into
mainfrom
fix/planning-source

Conversation

@jhfnetboy

@jhfnetboy jhfnetboy commented Aug 5, 2026

Copy link
Copy Markdown
Member

这个 PR 变了:从「可配置的路径校验器」改成「一句声明」

原方案是 planning_requires: [backlog/tasks] —— 让仓库指出真正的规划源,门禁去校验那些路径(同样的内容判据、目录要有实质文件、不许指向仓库根…)。

两轮评审在这一个能力上找出五个缺陷,而且全部在路径校验里,没有一个在它要解决的那个问题上:

.// ./. ././ ./// 绕过「不许指向仓库根」的拒绝表
.GIT 在大小写不敏感的文件系统上绕过 .git 的字符串匹配
指向仓库外的文件软链 只解析了父目录,内容检查直接读穿过去
条目按 CWD 解析、配置按 toplevel 定位 同一份 .pilot.yml 在不同目录下含义不同
我自己加的 declared-but-unparseable abort 合法的零缩进 YAML 列表判成配置错误,硬停门禁

一个「换个路径去查」的旋钮,必须扛住路径能撒谎的每一种方式,而那是个比原问题大得多的问题。

改成

planning_source: docs | external

external 时门禁什么都不查,打印 NOTHING WAS CHECKED 然后放行:

PILOT_DOCS: mode=strict source=external (declared in .pilot.yml) — NOTHING WAS CHECKED
  This repo declares its planning lives outside 'docs/agent'. This gate verified nothing:
  it did not look at the planning source and cannot vouch for it.
  Report it that way. Do NOT say 'planning verified' or 'ready' — say the repo declares
  its planning is external, and that starting an unattended run asserts it is complete.

没有判据可以被绕过,因为根本没有判据。

门禁存在的意义是「别在规划不全的时候无人值守开跑」。仓库里的人写下 external,就是显式接过了这个责任——这正是门禁本来在要求的东西。代价是这类仓库的门禁变成不查;但门禁本来只为无人值守而存在,人可以选择不开无人值守。

SKILL.mdrun.md 里配套写死一条:汇报时必须照实说「本仓库声明规划在别处、门禁未核实」,不能说成「规划已验证」 —— 放行是人担保的,不是脚本核实的。

规模与断言

check-docs.sh+304 行砍到 +67 行;整个 PR 从 +431 砍到 +114。

断言也换了性质——从「九条只为覆盖路径能怎么撒谎」变成六条行为断言:

ok  planning_source: external → pass (rc=0)
ok  planning_source: docs → checks docs (rc=1)
ok  planning_source: unknown → abort (rc=2)     ← 不猜:猜 docs 会假 NOT-ready,猜 external 会放行没人担保的仓库
ok  no planning_source → checks docs (rc=1)
ok  trailing comment tolerated (rc=0)
ok  external run states it verified nothing      ← 输出里必须有 NOTHING WAS CHECKED
ok  --no-config ignores declaration (rc=1)       ← 否则上面每条断言都是空的

最后那条是必需的:CI 从仓库根跑,一旦本仓库声明 external,没有 --no-config 的话上面每条断言都会 exit 0 却照样打印 ok。

Closes FU-5

https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk

门禁只认 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
@jhfnetboy
jhfnetboy requested a review from clestons as a code owner August 5, 2026 16:42
SKILL.md 冲突:#48 改 doctor 第 4 步(集成分支三分类),本 PR 改第 3 步
(规划层的 source= 字段说明)。两边都要,不是二选一。

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]

方向是对的:七件套文件名认不出等价规划源,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.shok=2/2declared 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/1REQ_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
@jhfnetboy

Copy link
Copy Markdown
Member Author

两条 High 全部成立,已修并推 e26452e没有一条驳回。

第二条我要特别认:这个 PR 自己文档里的写法,这个 PR 自己新增的解析器读不了,而且是静默失败 —— 也就是说这个功能会用它自己的实现,重新制造它立项要消灭的那个假 NOT-ready。你按 SKILL.md 一字不改喂进去的那次实测,比任何论证都直接。

1. 不再枚举「. 有多少种拼法」

你说得对,那是一张精确字符串表,多打一个字符就绕过去。改成解析后比较:

_abs="$(cd "$p" && pwd -P)"
[ "$_abs" = "$(git rev-parse --show-toplevel)" ] && refuse   # 仓库根
case "$_abs" in "$_root"/.git|"$_root"/.git/*) refuse ;;      # .git
                "$_root"/*) : ;; *) refuse ;;                 # 仓库外
esac

-P 是为了把符号链接也算进去。实测七种拼法 + .git 全部 rc=2:

.  .//  ./.  ././  .///  .//.   → ERROR: … resolves to the repository root (/Users/…)
.git  .git/refs                 → ERROR: … points inside .git — it would pass in ANY git repo

2. 注释 + 大声失败

awk 先剥 #…(只剥空白开头的那种,YAML 的注释就是这个形状;路径里合法的 # 不受影响),key 行和条目行都剥;key 正则相应放宽。

「key 存在但解析出 0 条」现在 exit 2,不再静默回退:

planning_requires:        → ERROR: … declares 'planning_requires:' but no usable entries parsed out of it.
                              Refusing to silently fall back to the default docs — that would report
                              NOT ready for a repo that DID declare a source.        rc=2
planning_requires: []     → 同上,rc=2
(没有这个 key)             → 正常回退七件套,rc=1        ← 这才是文档承诺的默认行为

按 SKILL.md 原文(key 行带注释 + 条目带注释)重测:

PILOT_DOCS: mode=strict source=planning_requires(.pilot.yml) min_bytes=120 ok=1/1
  declared planning source: backlog/tasks
PILOT_DOCS: ready — planning layer complete, safe to run unattended.

顺带把 SKILL.md:67templates/pilot.example.yml:23 那两处改成解析器确实能读的形状 —— 你指出的模板那行(- docs/agent/progress.md # 运行态…)照着取消注释会得到一条垃圾路径,这条修好之后两种写法都能读,但文档本身也不该再示范容易踩的形状。

断言从 16 条加到 28 条

新增全绿:七种仓库根拼法 + .git + .git/refs 全部 rc=2;key 行/条目/行内数组带注释都能读;declared-but-empty 和显式空数组都 exit 2;没有 key 时正常回退 rc=1。

这一类「拒绝表追不上变体」和「解析器读不了自己的文档」都属于本仓库反复踩的同一形状 —— 读起来很严、但有一条输入路径它从来没走过。所以这次的修法都往「结构上不可能」的方向做:解析后比较而不是列拼法,断言覆盖真实配置文件而不是只测 flag。

@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]

方法换对了(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-dirtrue 就拒(实测 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 .GITok=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:72templates/pilot.example.yml:2 —— 两处仍然写着「脚本本身不解析 YAML,由 skill 用 Read 读取后作为 flag 传给脚本」。而脚本现在自己解析 .pilot.ymlcheck-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 那套临时仓库脚手架,造 .GITsub/ 调用、软链三个用例都很便宜。
  • 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
@jhfnetboy

Copy link
Copy Markdown
Member Author

五条全部成立,已修并推 ede9fd7没有一条驳回。 其中两条是我上一轮自己引入的,先说这两条。

我引入的回归

零缩进块序列 + 标量写法被新加的 exit 2 炸掉 —— 这条最难看:我为了消灭「静默回退」加了大声失败,结果让一份能用的配置把门禁硬停掉,还告诉操作员「你的配置格式不对」。被修的那个 bug 的镜像,由修它的那个 commit 送达。 正则放成 ^[[:space:]]*- 并加标量分支;exit 2 只留给真正空的声明。

.git/refs 断言依赖运行环境 —— 你指出它在 linked worktree 里不存在。我照着改的时候本来还想加一条无条件的 .GIT 断言,写之前想了一下:那条在 Linux runner 上会同样地红(大小写敏感,.GIT 不存在)。所以 .git 用本体断言,大小写变体改成 [ -e ".GIT" ] 条件断言。

三条 High:同一个 resolve 步骤,三件事各自手做

.GIT 那条我要认得明确一点:上一个 commit message 写着「不要试图枚举 . 有多少种拼法」,而它自己正在枚举 .git 的拼法

改法是把三件事一次做对:相对仓库根解析、解析条目本身(realpath)、然后问 git 它是什么。

.git 的两种形态需要两个问题,这一点是我改完第一版之后才发现的:

--resolve-git-dir   → 认 .git 目录,也认 linked worktree 的 .git 文件(gitdir: 指针)
--is-inside-git-dir → 认 .git 底下的子路径(.GIT/config)

只用后者时,linked worktree 里的 .git 文件溜过去了:realpath 不跟随 gitfile,它的父目录是工作树而不是 git 目录,于是 .git 被接受、然后仅仅因为体积不够判成 rc=1 —— 一个尺寸理由,而不是真实理由。

实测

.git / .GIT / .Git / .GIT/config      → 全部 rc=2
同一份 `- .`      从根 rc=2、从 sub/ rc=2
同一份 `- plan-src` 从根 rc=0、从 sub/ rc=0
planfile -> /etc/passwd(文件软链)     → rc=2
outlink  -> /etc     (目录软链)       → rc=2
零缩进块序列 / 标量写法               → rc=0
真正空的声明 / []                     → rc=2

断言 28 → 38 条,而且这次在两种仓库形态下都跑

第一次改完我只在主仓库测,拿到 linked worktree 里立刻红了 2 条 —— 正是你说的那个形态。修完再跑:

普通 clone            → All assertions passed
linked worktree(.git 是文件) → All assertions passed

「断言本身依赖环境」这条我记住了:一条只在作者机器上成立的断言,和没有断言的区别只是它会在别人那里变成噪音。所以新增的每条都问了一遍「换个形态还成立吗」。

@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]

先说这一轮真修好了什么(全部在 head ede9fd78 上实跑,与父提交 818a5651 逐条对照)

上一轮的问题 实测(parent → head)
[High] .git 按名字比字符串,.GIT 走过去 --planning-requires .GITrc=0「ready」→ rc=2 拒绝
[High] 守卫按 CWD 解析、配置按 toplevel 定位 同一份 .pilot.yml:parent 在 root=0 / 在 sub/=1(自相矛盾)→ head 两处都是 0
[High] 只解析父目录,符号链接文件能指出仓库外 planfile -> /etc/passwdrc=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(/\].*$/) 找不到就 exitplan-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)。父提交一样,趁这次重写循环顺手修掉。

建议(下一轮的方向,不是加第五条正则)

  1. 把四条 awk 规则换成一次 python3 读取yaml.safe_load,PyYAML 缺失才退回严格行解析)。它一次性解决 X1(---)、X2(顶格注释)、flow-array 截断、null/anchor 四类问题。而且 python3 已经是同目录 git-guard.sh / pr-monitor.sh 的硬依赖——真要依赖就统一依赖,并且把「外部工具缺失」一律做成显式 exit 2
  2. 给下一轮立一条可被检验的规则这个门禁不允许因为某个工具不在而丢掉任何一项检查。 本次三处修复全部踩了这一条,两条 High 都长在上面。
  3. 把这四个已证明的用例加进 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
@jhfnetboy jhfnetboy changed the title feat(pilot): FU-5 —— 起跑门禁支持仓库已有的规划源 (v1.4.0) refactor(pilot): 起跑门禁认「规划在别处」—— 一句声明,不做校验 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]

先把话说清楚:砍掉整个路径校验器这个决定是对的,我把理由写在最后一节,这不是「因为轮数多所以继续挡」。 两条 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"; then

grep -q 匹配到第 1 行就立刻退出,而 gate 那个子 shell 还要再 echo 4 行 → 收到 SIGPIPE → 子 shell 退出码 141;第 12 行的 set -o pipefail 把 141 提升成整条管道的状态,于是 ifelse 分支——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 ;; esac

R4 用这个改法跑了 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 "\"'" 把它抹成 externalrc=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 缩水也不是覆盖率损失(见上)。

两条建议:

  1. ready / NOTHING WAS CHECKED 这个字符串二分,现在是唯一能区分两种 rc=0 的东西,但它没有在任何地方被写成接口。下一个 check-docs.sh && pilot run 包装脚本、或者下一次改文案,都会无声地把它丢掉。写进契约 + CI 双向断言。
  2. 这个特性目前在本仓库零使用者: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
@jhfnetboy

Copy link
Copy Markdown
Member Author

两条全部成立,已修并推 09503fc没有一条驳回。

1. SIGPIPE 那条:我今天刚修过同一个 bug

producer | grep -q + pipefail = 141,这正是我今天在 safe-cleanup.sh 里修掉的东西 —— 那边为它专门写了 list_has 辅助函数,然后我在同一批 PR 的 CI 脚本里又踩了一遍。你的 33% 我自测复现了:

修复前:15 次里红 5 次
修复后:30 次里红 0 次     ← 按你说的,≥20 次

改法用你给的:捕获到变量 + case 匹配,不走管道。注释里把「这个 helper 是我写的、然后我又踩了一次」记了进去,因为这条比 bug 本身更值得留痕。

你指出「它报的是 the planning-docs gate is not fail-closed,恰恰最容易让人以为门禁真坏了」——这句我特别认。一条会随机变红、而且红得像真事故的必过检查,比没有这条检查更糟。

2. 解析器 fail-open:声明去掉了判据,但没去掉读它的那 6 行

你这句话把我说服了:

代码注释里那句「a declaration has no criterion to subvert」,对语义成立,对解析器不成立。

确实如此,而且落点比原来更糟:以前是路径判断出错,现在是整个门禁被无声关掉

改法:

  • 要求一个字面空格 s/^planning_source: \{1,\}//p不能用 [[:space:]] —— 它包含 TAB,而 YAML 不接受(yaml.safe_loadkey:<TAB>value 直接 ScannerError),匹配它等于接受一份任何 YAML 解析器都读不了的文件。
  • 只脱成对引号(s/^"\(.*\)"$/\1/),不再 tr -d

七种写法逐一实测,并与 yaml.safe_load 对照:

写法 rc 说明
planning_source:external 1 YAML 里是纯标量,没有 key → 落回查 docs
planning_source:<TAB>external 1 ScannerError → 落回查 docs
planning_source: ex"ter"nal 2 值确实是 ex"ter"nal → 拒绝,不猜
planning_source: "external" 0 成对引号,正常识别
planning_source: external 0 正常
planning_source: external 0 多空格
planning_source: external # 注释 0 行尾注释

四个新用例(三个畸形 + 成对引号)全部进了 CI 断言。

关于你最后那节

「砍掉整个路径校验器是对的」这个判断,连同你做的那些交叉核对(悬空引用清零、上一轮四条 blocker 随校验器一起消失、对存活断言做变异测试 9 注入 7 变红、12 种声明写法的语义面)——这些比一句 approve 有用得多,因为它们说明砍掉之后剩下的东西确实还站得住,而不只是「代码少了所以问题少了」。

那条漏网的变异(值匹配变成大小写不敏感)我看了:case 的分支是精确匹配 external/docs,EXTERNAL 会落到 *) 拒绝 —— 实测 rc=2。如果你指的是有人把 case 改成 [Ee][Xx]... 那种,那属于「改坏了不会被断言抓住」,我同意它是个真空白,但我不打算再为它加断言:那会变成给每一种可能的改坏方式各写一条,而这正是这个 PR 刚砍掉的那个思路。

@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]

上一轮的两条 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' → 再脱单引号得 externalrc=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)被抹成 externalrc=0planning_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-39preflight.sh:62grade-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:137git-guard.sh:98 也是 | grep -q + pipefail 的同形状,但各测 1000 次零命中——单次 syscall 的生产者(git branch / git remote)赢得竞态,而 H1 那个多次 echo 的写者是 34/300。而且它们取反后都落在 refuse/KEEP 分支,fail-safe。仅记录。)

上一轮四条文档漂移仍未处理(都早于本次提交、本次也没让它们变坏):--helpsed -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)。

@jhfnetboy
jhfnetboy merged commit 1a83afb into main Aug 6, 2026
5 checks passed
@jhfnetboy
jhfnetboy deleted the fix/planning-source branch August 6, 2026 04:17
@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