Skip to content
2 changes: 1 addition & 1 deletion plugins/pilot/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 8 additions & 2 deletions plugins/pilot/skills/pilot/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
---
Expand Down Expand Up @@ -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` 生成。
Expand All @@ -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 <skill>/scripts/check-docs.sh --docs-dir <docs_dir> --strict`。
3. **规划层齐全度**(`run` 无人值守的硬前提):`bash <skill>/scripts/check-docs.sh --docs-dir <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/<integration>`)。**分支不存在时不要一律说「先建一个」**——先分清是哪一种,三种情况的正确答案完全不同:
- **`.pilot.yml` 里 `integration_branch` == `base_branch`**(单主干仓库,PR 直接开向主干)→ **这是合法配置,什么都不缺**。不要建议建集成分支。提示:合并走 `git-guard.sh merge-pr <n> --integration <base> --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`。
Expand Down
3 changes: 2 additions & 1 deletion plugins/pilot/skills/pilot/phases/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ task → 等评审 → 合并 → 再挑下一个,**直到 §3 的交付条件
bash <skill>/scripts/check-docs.sh --docs-dir <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),
并在汇报里写明**这是降级运行、缺哪几份文档**。降级是用户的决定,不是你的。
Expand Down
83 changes: 81 additions & 2 deletions plugins/pilot/skills/pilot/scripts/check-docs.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:<TAB>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"
Expand Down
6 changes: 6 additions & 0 deletions plugins/pilot/skills/pilot/templates/pilot.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
53 changes: 50 additions & 3 deletions scripts/ci/check-docs-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ check() { # check <description> <expected_rc> <actual_rc>
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:"

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading