diff --git a/plugins/pilot/.claude-plugin/plugin.json b/plugins/pilot/.claude-plugin/plugin.json index 3d55bb4..2c97559 100644 --- a/plugins/pilot/.claude-plugin/plugin.json +++ b/plugins/pilot/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pilot", - "version": "1.3.0", + "version": "1.4.0", "description": "仓库级开发操作系统:status 盘点+安全清理 → plan 三级规划 → run 无人值守交付循环(跑到交付为止,起跑前强制文档齐全门禁 + 默认设 /goal 停止闸);git-guard 危险动作防护(best-effort) + safe-cleanup 已合并分支清理 + PR 开出后按 reference/review-contract.md 盯外部评审裁决(不启动、不感知任何评审后端)。", "author": { "name": "AAStarCommunity" diff --git a/plugins/pilot/skills/pilot/SKILL.md b/plugins/pilot/skills/pilot/SKILL.md index 459aafa..3f4ec46 100644 --- a/plugins/pilot/skills/pilot/SKILL.md +++ b/plugins/pilot/skills/pilot/SKILL.md @@ -1,6 +1,6 @@ --- name: pilot -version: 1.3.0 +version: 1.4.0 description: 仓库级开发操作系统。三阶段驱动一个仓库从「盘点 → 规划 → 持续开发」全流程。status=汇报进展+安全清理已合并分支/worktree;plan=建立/汇报 Milestone→Feature→Task 三级规划;run=先交出一条填好的 /goal 交付契约(说清怎么用 plan 的文档、怎么验证、PR 由外部评审服务裁决要怎么等、什么时候才算交付),再照它连续迭代做到交付;起跑前强制检查规划文档齐全。当用户说 pilot / 整理仓库 / 汇报进展 / 清理分支 / 规划里程碑 / 持续开发 / 跑通宵开发时使用。 allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task, TodoWrite, Monitor, ScheduleWakeup --- @@ -64,6 +64,7 @@ protect_patterns: [release, hotfix] # 额外保护的分支前缀 remote: origin allow_remote_cleanup: false # 删除远程已合并分支需显式置 true docs_dir: docs/agent # 规划/运行态文档目录 +planning_source: docs # docs(默认)=查上面那七个文件 | external=规划在别处,门禁不查(见下) ``` 读取方式:用 Read 工具读该文件,把值作为 flag 传给脚本(脚本本身不解析 YAML,保持简单确定)。文件不存在时用上表默认值,并提示用户运行 `pilot doctor` 生成。 @@ -79,9 +80,14 @@ docs_dir: docs/agent # 规划/运行态文档目录 2b. **配置迁移硬检查**:若旧文件 `.repo-pilot.yml` 存在—— - 只有旧文件、无 `.pilot.yml`:红字提示「`.repo-pilot.yml` 已弃用,运行 `git mv .repo-pilot.yml .pilot.yml`」;在迁移前 `run`/`status` 会读旧文件兜底。 - **两个文件都在、且 `integration_branch` 不一致:`FAIL`(阻断)**——因为哪个生效不确定、错的那个可能把 PR 直合进主干。要求用户先删掉/合并旧文件再继续,`doctor` 不擅自改。 -3. **规划文档齐全度**(`run` 无人值守的硬前提):`bash /scripts/check-docs.sh --docs-dir --strict`。 +3. **规划层齐全度**(`run` 无人值守的硬前提):`bash /scripts/check-docs.sh --docs-dir --strict`。 报 MISSING/EMPTY 就照实列出并建议 `pilot plan` 补齐——`run` 会在同一道门禁上 fail-closed 拒跑, 在这里先看见比半夜被拦住强。(脚本会识别「文件在但还是原样模板」:占位符没填等于没答。) + **规划已经在 backlog.md / issue tracker / 别的工具里的仓库**:报 NOT ready 时**不要**建议把规划重抄进 + 七件套——那违反 plan.md §A.3(已有规划不要重复造)。正确做法是在 `.pilot.yml` 里写 `planning_source: external`。 + 之后门禁会打印 `source=external — NOTHING WAS CHECKED` 并放行:**它没有检查任何东西**。 + 汇报时必须照实说「本仓库声明规划在别处、门禁未核实」,**不能说成「规划已验证」或「就绪」**—— + 那句放行是人担保的,不是脚本核实的。 4. **集成分支**(`git show-ref refs/heads/`)。**分支不存在时不要一律说「先建一个」**——先分清是哪一种,三种情况的正确答案完全不同: - **`.pilot.yml` 里 `integration_branch` == `base_branch`**(单主干仓库,PR 直接开向主干)→ **这是合法配置,什么都不缺**。不要建议建集成分支。提示:合并走 `git-guard.sh merge-pr --integration --allow-trunk`,并说明 `--allow-trunk` 不是绕过(仍要求分支保护要求审批、PR 已 `APPROVED`、且该分支开启了 stale-dismissal,读不到就 fail-closed)。 - **没有 `.pilot.yml`**(于是 `integration_branch` 落到默认值 `preview`)**且默认分支存在** → **默认值对这个仓库很可能是错的**,别让用户去建一个 `preview` 来迁就默认值。先问:这个仓库是单主干(PR 直接进 `main`)还是双分支流(`preview` 汇总后再进 `main`)?单主干 → 写 `.pilot.yml` 把 `integration_branch` 设成主干名,合并加 `--allow-trunk`;确实要双分支流 → 才建 `preview`。 diff --git a/plugins/pilot/skills/pilot/phases/run.md b/plugins/pilot/skills/pilot/phases/run.md index a36fa77..7d8488a 100644 --- a/plugins/pilot/skills/pilot/phases/run.md +++ b/plugins/pilot/skills/pilot/phases/run.md @@ -14,8 +14,9 @@ task → 等评审 → 合并 → 再挑下一个,**直到 §3 的交付条件 bash /scripts/check-docs.sh --docs-dir --strict ``` -- `rc=0` → 七件套齐全且**已填写**(脚本会识别「还是原样模板」——占位符没填等于没答,比没有更危险),继续 0b。 +- `rc=0` → 规划层齐全且**已填写**(脚本会识别「还是原样模板」——占位符没填等于没答,比没有更危险),继续 0b。 - **`rc≠0` 一律停下**(`1`=有缺口,`2`=用法/参数错,例如 `PILOT_DOC_MIN_BYTES` 不是正整数)。把脚本输出原样报给用户;`rc=1` 时建议 `pilot plan` 补齐。**不要把非零当成「大概能跑」**。 +- **规划已经在别处的仓库**(backlog.md / issue tracker / 别的工具):不要为了过门禁去把已有规划重抄一份到七件套里——那正违反 plan.md §A.3。在 `.pilot.yml` 里写 `planning_source: external`,门禁会放行并打印 `NOTHING WAS CHECKED`。**这条放行是人担保的,不是脚本核实的**:汇报时照实说「本仓库声明规划在别处、门禁未核实」,绝不能写成「规划已验证」。 **不许带着缺口开跑**,也不许自己动手把文档编出来替用户拍板(违反 SKILL.md 硬约束 7)。 - 用户明确说「有人盯着、先跑起来」→ 才可降级 `--minimal`(只要 roadmap+tasks+progress), 并在汇报里写明**这是降级运行、缺哪几份文档**。降级是用户的决定,不是你的。 diff --git a/plugins/pilot/skills/pilot/scripts/check-docs.sh b/plugins/pilot/skills/pilot/scripts/check-docs.sh index 80714d4..b57ba71 100755 --- a/plugins/pilot/skills/pilot/scripts/check-docs.sh +++ b/plugins/pilot/skills/pilot/scripts/check-docs.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash # check-docs.sh — gate: is the planning layer complete enough to run UNATTENDED? # -# bash scripts/check-docs.sh [--docs-dir docs/agent] [--strict|--minimal] +# bash scripts/check-docs.sh [--docs-dir docs/agent] [--strict|--minimal] [--no-config] +# +# Repos whose planning lives elsewhere (backlog.md, an issue tracker, …) declare it in .pilot.yml: +# +# planning_source: external # docs (default) | external +# +# and this gate then checks NOTHING and says so, instead of reporting a false NOT-ready. See the +# long note at the planning_source block for why that is a declaration rather than a check. # # Unattended delivery means nobody is around to answer "what did you mean here?". # Every gap in the planning layer becomes a guess the model makes alone at 3am, so this @@ -50,16 +57,88 @@ if [ "$MIN_BYTES" -eq 0 ]; then exit 2 fi +read_config=1 while [ $# -gt 0 ]; do case "$1" in --docs-dir) [ $# -ge 2 ] || { echo "ERROR: --docs-dir needs a path" >&2; exit 2; }; docs_dir="$2"; shift 2 ;; --strict) mode="strict"; shift ;; --minimal) mode="minimal"; shift ;; - -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + # Ignore .pilot.yml entirely. Used by scripts/ci/check-docs-gate.sh, which tests this gate's own + # logic against fixture directories from the repo root — without this, a repo that declares + # `planning_source: external` would make every one of those assertions exit 0 while claiming to + # test something else. + --no-config) read_config=0; shift ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done +# ---- planning_source: where does this repo's planning actually live? -------------------------- +# +# Default (absent, or `docs`): the seven files under docs_dir, checked as below. +# +# `external`: the repo plans in backlog.md / an issue tracker / anywhere else. The gate then +# verifies NOTHING and says so, loudly, instead of reporting a false NOT-ready. Measured: Brood +# plans in backlog/ (4 milestones, 49 tasks with acceptance criteria, 2 ADRs) and this gate scored +# it 0/7 — while plan.md §A.3 tells you not to re-create planning that already exists. Both rules +# could not be obeyed at once. +# +# WHY A DECLARATION AND NOT A CHECK. The obvious fix is to let the repo point the gate at its real +# planning source and have the gate verify THAT. I built it: `planning_requires: [backlog/tasks]`, +# with the same content bar. Two review rounds found five defects and every one of them was in the +# path validation, not in the thing it was meant to solve — the repo root spelled `.//`, `./.`, +# `././`; `.GIT` slipping past a `.git` string match on a case-insensitive filesystem; a symlinked +# FILE pointing out of the repo; entries resolved against the CWD while the config was found from +# the toplevel; and my own "declared but unparseable" abort killing a perfectly valid zero-indent +# YAML list. A knob that says "check this path instead" has to be robust against every way a path +# can lie, and that is a much bigger problem than the one being solved. +# +# A declaration has no criterion to subvert, because it has no criterion. The gate exists so nobody +# starts an UNATTENDED run against an incomplete plan; a human writing `external` in the repo's own +# config is taking that responsibility explicitly, which is the same thing the gate was asking for. +planning_source="" +if [ "$read_config" = "1" ]; then + _top="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" + for _f in "$_top/.pilot.yml" "$_top/.repo-pilot.yml"; do + [ -f "$_f" ] || continue + # Require a REAL separating space, and strip only MATCHED quotes. + # + # The declaration removes the criterion, but not the 6 lines that read it — and those lines had + # the same fail-OPEN shape as the validator they replaced, one size smaller. All three of these + # were read as `external` (i.e. gate off), and none of them is what it looks like: + # planning_source:external → to YAML this is a plain SCALAR STRING; there is no key here + # planning_source:external → yaml.safe_load raises ScannerError + # planning_source: ex"ter"nal → the value IS `ex"ter"nal`; it must hit the `*)` refuse arm, + # but `tr -d "\"'"` mangled it into `external` + # `[[:space:]]\{1,\}` makes the first two miss the pattern entirely, so they fall back to + # checking the docs (fail-CLOSED); the paired-quote strip leaves the third as `ex"ter"nal`, + # which the case below refuses instead of guessing. + # A literal SPACE, not `[[:space:]]` — that class includes TAB, and YAML does not: a tab after + # the colon is a ScannerError, not a value. Matching it would have accepted a file no YAML + # parser will read. + planning_source="$(sed -n 's/^planning_source: \{1,\}//p' "$_f" | head -1 \ + | sed -e 's/[[:space:]]*#.*$//' -e 's/[[:space:]]*$//' \ + -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")" + break + done +fi + +case "$planning_source" in + ''|docs) : ;; # default: check the seven docs, exactly as before + external) + echo "PILOT_DOCS: mode=$mode source=external (declared in .pilot.yml) — NOTHING WAS CHECKED" + echo " This repo declares its planning lives outside '$docs_dir'. This gate verified nothing:" + echo " it did not look at the planning source and cannot vouch for it." + echo " Report it that way. Do NOT say 'planning verified' or 'ready' — say the repo declares" + echo " its planning is external, and that starting an unattended run asserts it is complete." + exit 0 ;; + *) + echo "ERROR: .pilot.yml has planning_source: '$planning_source' — expected 'docs' or 'external'." >&2 + echo " Refusing rather than guessing: guessing 'docs' would report a false NOT-ready, and" >&2 + echo " guessing 'external' would wave through a repo nobody vouched for." >&2 + exit 2 ;; +esac + # Ordered by the information flow in plan.md: research → acceptance → architecture+spec # → roadmap → tasks → progress. Earlier docs constrain later ones, so report them in order. STRICT_DOCS="research acceptance architecture spec roadmap tasks progress" diff --git a/plugins/pilot/skills/pilot/templates/pilot.example.yml b/plugins/pilot/skills/pilot/templates/pilot.example.yml index 11b0c79..fb68a81 100644 --- a/plugins/pilot/skills/pilot/templates/pilot.example.yml +++ b/plugins/pilot/skills/pilot/templates/pilot.example.yml @@ -10,4 +10,10 @@ remote: origin allow_remote_cleanup: false # 删除远程已合并分支需显式置 true(无人值守默认不删远程) docs_dir: docs/agent # 规划/运行态文档目录 +# 规划在哪里。docs(默认)= 起跑门禁检查 docs_dir 下那七个文件; +# external = 规划在别处(backlog.md / issue tracker / 任何工具),门禁【不做任何检查】直接放行。 +# external 是一句人做的担保,不是脚本核实的结论 —— 门禁会明确打印「NOTHING WAS CHECKED」, +# 汇报时必须照实转述,不能说成「规划已验证」。 +planning_source: docs + # PR 的裁决由外部评审服务给出,pilot 只负责盯状态(契约见 reference/review-contract.md) diff --git a/scripts/ci/check-docs-gate.sh b/scripts/ci/check-docs-gate.sh index f1267f0..aa9d8d2 100755 --- a/scripts/ci/check-docs-gate.sh +++ b/scripts/ci/check-docs-gate.sh @@ -38,7 +38,11 @@ check() { # check fi } -run() { bash "$GATE" --docs-dir "$tmp" "$@" >/dev/null 2>&1; echo $?; } +GATE_ABS="$PWD/$GATE" +# --no-config on every fixture call: these assertions are about the gate's own logic, and this +# script runs from the repo root. Without it, a repo declaring `planning_source: external` would +# make every assertion below exit 0 while still printing "ok". +run() { bash "$GATE" --no-config --docs-dir "$tmp" "$@" >/dev/null 2>&1; echo $?; } echo "check-docs.sh fail-closed assertions:" @@ -59,7 +63,7 @@ check "zero threshold rejected" 2 "$(PILOT_DOC_MIN_BYTES=0 run --stric # 3. Missing files must be rejected too (the gate's other half). empty="$(mktemp -d)" -check "empty docs dir rejected" 1 "$(bash "$GATE" --docs-dir "$empty" --strict >/dev/null 2>&1; echo $?)" +check "empty docs dir rejected" 1 "$(bash "$GATE" --no-config --docs-dir "$empty" --strict >/dev/null 2>&1; echo $?)" rm -rf "$empty" # 4. The gate must still PASS on genuinely filled docs — otherwise it is just broken, and a gate @@ -73,9 +77,52 @@ for d in research acceptance architecture spec roadmap tasks progress; do echo "补充说明:这一段用于验证门禁在文档确实填写之后能够正常放行,而不是一律拒绝。" } > "$filled/$d.md" done -check "filled docs accepted" 0 "$(bash "$GATE" --docs-dir "$filled" --strict >/dev/null 2>&1; echo $?)" +check "filled docs accepted" 0 "$(bash "$GATE" --no-config --docs-dir "$filled" --strict >/dev/null 2>&1; echo $?)" rm -rf "$filled" +# 5. `planning_source:` — a DECLARATION, so the only things to assert are that each value routes +# where it says, that an unrecognised value aborts rather than guessing, and that the external +# path is unmistakably labelled as "not checked". There is no criterion here to subvert, which +# is the entire point of it being a declaration; the previous design (a configurable path to +# verify) needed nine assertions just for the ways a path can lie. +ps="$(mktemp -d)"; (cd "$ps" && git init -q .) +psrun() { printf '%s\n' "$1" > "$ps/.pilot.yml"; (cd "$ps" && bash "$GATE_ABS" --strict >/dev/null 2>&1); echo $?; } +check "planning_source: external → pass" 0 "$(psrun 'planning_source: external')" +check "planning_source: docs → checks docs" 1 "$(psrun 'planning_source: docs')" +check "planning_source: unknown → abort" 2 "$(psrun 'planning_source: backlog')" +check "no planning_source → checks docs" 1 "$(psrun 'base_branch: main')" +check "trailing comment tolerated" 0 "$(psrun 'planning_source: external # 规划在 backlog/')" +check "quoted value tolerated" 0 "$(psrun 'planning_source: "external"')" +# Malformed YAML must fall CLOSED, never open. The declaration removes the criterion but not the +# sed that reads it, and that sed had the same fail-open shape as the validator it replaced: +# each of these was read as `external` — i.e. gate off — and none is what it looks like. +# no space → to YAML this is a plain scalar string; there is no key at all +# tab → yaml.safe_load raises ScannerError (YAML does not accept a tab there) +# ex"ter"nal→ the value really is ex"ter"nal and must be REFUSED, not mangled into external +check "no space after colon → checks docs" 1 "$(psrun 'planning_source:external')" +check "tab after colon → checks docs" 1 "$(printf 'planning_source:\texternal\n' > "$ps/.pilot.yml"; (cd "$ps" && bash "$GATE_ABS" --strict >/dev/null 2>&1); echo $?)" +check "inner quotes → abort, not guess" 2 "$(psrun 'planning_source: ex"ter"nal')" +# The external path MUST say it checked nothing — a "ready" here would be a lie the run report +# would then repeat. +# Capture to a variable and match in bash — NOT `… | grep -q`. `grep -q` exits at the first +# match while the gate still has 4 lines to print, so the gate takes SIGPIPE and exits 141; +# `set -o pipefail` (line 12) promotes that to the pipeline's status and the `if` takes the +# ELSE branch — the banner printed correctly and the assertion failed anyway. Measured on the +# previous commit: 5 red out of 15 full runs, each claiming "the planning-docs gate is not +# fail-closed" on a `docs-gate` job with no continue-on-error. This is the same SIGPIPE/pipefail +# trap safe-cleanup.sh has a helper (`list_has`) to avoid; I wrote that helper and then walked +# into it again here, in the same batch of PRs. +printf 'planning_source: external\n' > "$ps/.pilot.yml" +_psout="$(cd "$ps" && bash "$GATE_ABS" --strict 2>&1)" +case "$_psout" in + *"NOTHING WAS CHECKED"*) echo " ok external run states it verified nothing" ;; + *) echo " FAIL external run does not say it verified nothing" >&2; fails=$((fails + 1)) ;; +esac +# And --no-config must ignore the declaration, or every assertion above this line is vacuous. +check "--no-config ignores declaration" 1 \ + "$(cd "$ps" && bash "$GATE_ABS" --strict --no-config >/dev/null 2>&1; echo $?)" +rm -rf "$ps" + echo if [ "$fails" -ne 0 ]; then echo "$fails assertion(s) failed — the planning-docs gate is not fail-closed." >&2