diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 100644 index 0000000..b4a4334 --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1,59 @@ +--- +name: local-review +description: 本地 review 自动循环 —— 跑 WORKFLOW.md 的 2 条标准,不通过自动派打回 subagent 修,循环至通过或达上限 +--- + +# local-review skill + +## 何时调用 + +执行 CC 完成一个 feature 后,调本 skill 触发本地 review 循环; +也支持人手动 `/local-review` 触发。 + +## 怎么跑 + +```bash +# 直接调 driver(skill 实际就是包了一层文档 + 派 subagent 的 prompt 模板) +npx tsx scripts/review/loop-driver.ts \ + --repro docs/acceptance/-/repro-result.json \ + --max-retries 3 +``` + +## 2 条标准(WORKFLOW.md 第 4 步钉死) + +1. **before/after 对比成立** —— 套件 1 跑出 `verdict: "pass"`(不是 `fail` 也不是 `ambiguous`) +2. **全量测试全过 + 合并无冲突** —— `pnpm test` 退出 0,工作树无未提交改动,与 `origin/main` 无冲突 + +## 行为 + +- 跑 review-cli → verdict=pass → 落 `review-verdict.json`、exit 0、完事 +- 跑 review-cli → verdict=fail → 派一个 fresh subagent(用 Claude Code 的 `Agent` 工具), + 把 `fixDirective.prompt` 喂给它;subagent 修完落 `$TEMP/fix-marker` 标记; + driver 检测到标记重跑 review-cli;循环。 +- 重试次数达 `--max-retries`(默认 3) → 停止、exit 非 0; + 累计 fix 历史写到 `review-verdict.json` 的 `attempts[]` 字段。 + +## 当前默认是 `--fix-mode manual`(重要) + +「verdict=fail 自动派 subagent 去修」依赖一个**还没实现**的宿主 hook: +driver 写 `$TEMP/fix-prompt` + `$TEMP/fix-pending`,hook 监听到后用 Agent tool 派 subagent。 +在 hook 落地之前,driver 默认走 `--fix-mode manual` —— 第一次 fail 就把 fix prompt 打印 + 退出非 0, +由人手动派 CC 去修。 + +`--fix-mode agent` 会正常运行,但若 hook 缺席,会卡在等 fix-marker 直到超时(30 分钟)。 +跨子系统 follow-up 见 `docs/plans/2026-05-15-INDEX.md` 末段「跨子系统 follow-up」。 + +## 派打回 subagent 的 prompt 模板 + +见 `fix-directive-prompt.md`。 + +## 默认测试命令 + +worktree 里没装独立 `.bin/`,且 vitest config 不抓 `scripts/`, +所以 driver 默认 `--test-cmd "C:/bzli/Matrix/node_modules/.bin/tsx.cmd --test scripts/lock-core.test.ts"`。 +项目级真实 review 请显式传: + +```bash +--test-cmd "pnpm test" # 全量(在主仓库 root 跑) +--test-cmd "C:/bzli/Matrix/node_modules/.bin/vitest.cmd run packages/core" # 包级 +``` diff --git a/.claude/skills/local-review/fix-directive-prompt.md b/.claude/skills/local-review/fix-directive-prompt.md new file mode 100644 index 0000000..66bdbf8 --- /dev/null +++ b/.claude/skills/local-review/fix-directive-prompt.md @@ -0,0 +1,29 @@ +# 打回 subagent prompt 模板 + +> 由 `scripts/review/loop-driver.ts` 在 `--fix-mode agent` 下使用: +> 把本文件填入 `{{fixDirective.prompt}}` 处理后,作为派给 fresh subagent 的 prompt。 + +--- + +你是一个执行 subagent,被本地 review 循环派回来「修一个 review 失败」。 + +## 任务 + +{{fixDirective.prompt}} + +## 约束(硬性) + +- **只动与本失败类别直接相关的代码**。不要顺手 refactor、不要 reorganize 文件、不要改无关测试。 +- 修完用 `Bash`(Unix)或 `PowerShell`(Windows)落标记文件,然后 stop: + - Unix:`echo done > /tmp/fix-marker` + - Windows:`echo done > %TEMP%\fix-marker` + loop-driver 看到标记会自动重跑 review;**你不要自己跑 review**。 +- 如果你判断这次失败**不应在本 PR 修**(超出范围、应另开 issue), + 把标记换成 `skip:<理由>`(把 done 改成 skip: 加理由)然后 stop。 + driver 看到 `skip:` 会停循环、把理由报给上游。 + +## 边界 + +- 不开新 PR、不切分支、不 commit、不 push。改完留在工作树里,driver 会处理 commit 时机。 +- 不要去改 review 标准本身(`scripts/review/review-core.ts` 等)—— 标准是 WORKFLOW.md 钉死的; + 改它属于另一个子系统决策,不在本次「修 feature 让 review 过」的范围内。 diff --git a/.githooks/post-merge b/.githooks/post-merge index f943746..6bbf6de 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -33,3 +33,5 @@ if teamagent --help 2>&1 | grep -q "m5-sync"; then fi exit 0 # <<< teamagent post-merge block <<< + + diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6b741d0..349b5c1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -49,3 +49,5 @@ else fi exit 0 # <<< teamagent post-merge block <<< + + diff --git a/.github/actions/setup-repo/action.yml b/.github/actions/setup-repo/action.yml new file mode 100644 index 0000000..563f37b --- /dev/null +++ b/.github/actions/setup-repo/action.yml @@ -0,0 +1,17 @@ +name: Setup repo (pnpm + node + install) +description: 在已 checkout 的 repo 上装 pnpm/node 并 frozen install。注意:调用方必须先 actions/checkout —— composite action 自己 checkout 是鸡生蛋(GitHub 找不到 action.yml)。 +inputs: + node-version: + description: Node 版本 + required: false + default: '22' +runs: + using: composite + steps: + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + - run: pnpm install --frozen-lockfile + shell: bash diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..d7cf5d8 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,116 @@ +name: Auto-merge (远程 review 通过 → 自动 squash 合主分支) + +# workflow_run 链式触发:pr-review.yml 跑完 → 本 workflow 启动 → +# 调 scripts/automerge/can-auto-merge.ts 门控 → 通过则 gh pr merge --squash --delete-branch。 +# 详细见 docs/plans/2026-05-15-auto-merge.md。 + +on: + workflow_run: + workflows: ["PR Review (远程 CC 自动评审)"] + types: [completed] + +permissions: + contents: write # squash merge 落 commit 到 main + pull-requests: write # 关 PR、删 branch、写 comment + +# 同 PR 重入兜底:同 head SHA 只跑一次 +concurrency: + group: auto-merge-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + evaluate-and-merge: + # 仅在 pr-review 成功完成 + 是 pull_request 触发的情况下运行 + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + - uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Resolve PR number from workflow_run + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + # workflow_run event 不直接给 PR number,要从 head_branch 反查 + PR_NUMBER=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" \ + --state open --json number --jq '.[0].number // empty') + if [[ -z "$PR_NUMBER" ]]; then + echo "::warning::找不到 head_branch=${{ github.event.workflow_run.head_branch }} 的 open PR;退出" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "pr=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "找到 PR #$PR_NUMBER" + + - name: Snapshot PR + if: steps.pr.outputs.skip != 'true' + id: snap + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + PR=${{ steps.pr.outputs.pr }} + gh pr view "$PR" --json number,baseRefName,isDraft,body,labels,state,mergeable,headRepository,headRepositoryOwner > /tmp/pr.json + # review/verdict commit status + STATUS=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.workflow_run.head_sha }}/statuses" \ + --jq '[.[] | select(.context=="review/verdict")][0].state // "missing"') + REPO_OWNER='${{ github.repository_owner }}' + jq --arg verdict "$STATUS" --arg owner "$REPO_OWNER" '. + { + reviewVerdictState: $verdict, + isFromInternalRepo: (.headRepositoryOwner.login == $owner), + labels: [.labels[].name] + }' /tmp/pr.json > /tmp/snapshot.json + cat /tmp/snapshot.json + + - name: Decide + if: steps.pr.outputs.skip != 'true' + id: decide + shell: bash + run: | + set +e + npx tsx -e " + import {canAutoMerge} from './scripts/automerge/can-auto-merge.ts'; + import {readFileSync, writeFileSync} from 'node:fs'; + const snap = JSON.parse(readFileSync('/tmp/snapshot.json', 'utf-8')); + const r = canAutoMerge(snap); + writeFileSync('/tmp/decision.json', JSON.stringify(r, null, 2)); + console.log(JSON.stringify(r, null, 2)); + process.exit(r.merge ? 0 : 78); + " + ec=$? + set -e + if [[ $ec -eq 0 ]]; then + echo "merge=true" >> "$GITHUB_OUTPUT" + elif [[ $ec -eq 78 ]]; then + echo "merge=false" >> "$GITHUB_OUTPUT" + else + echo "::error::canAutoMerge 评估异常 (exit=$ec)" + exit $ec + fi + + - name: Merge + if: steps.decide.outputs.merge == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR=${{ steps.pr.outputs.pr }} + gh pr merge "$PR" --squash --delete-branch + gh pr comment "$PR" --body "🤖 远程 review 通过 → 已自动 squash 合并到 main(\`auto-merge.yml\`)。" + + - name: Skip note + if: steps.decide.outputs.merge == 'false' && steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + PR=${{ steps.pr.outputs.pr }} + REASON=$(jq -r .reason /tmp/decision.json) + gh pr comment "$PR" --body "🤖 auto-merge 跳过 —— 原因: $REASON" + echo "skipped: $REASON" diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 0000000..092b074 --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,182 @@ +name: PR Review (远程 CC 自动评审) + +# 只跑仓库内 PR;外部 fork 自动 skip(每个 job 用 if 守门)。 +# 详细说明见 docs/plans/2026-05-15-remote-review-bot.md 安全前提。 + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + statuses: write + +# 同 PR 多 commit 只跑最新一次,省 CI 分钟 +concurrency: + group: pr-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + list-specs: + # 只在仓库内 PR 上跑 + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + outputs: + specs: ${{ steps.list.outputs.specs }} + skipReproReason: ${{ steps.list.outputs.skipReason }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: list + shell: bash + env: + PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + # 显式豁免 (A): PR 带 skip-repro label + if echo "$PR_LABELS" | jq -e '. | index("skip-repro")' >/dev/null; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=skip-repro label" >> "$GITHUB_OUTPUT" + echo "::notice::PR 带 skip-repro label —— 跳过 repro 检查" + exit 0 + fi + # 列 fixtures/repro-specs/*.ts(排除 README/index/共享文件) + if [[ ! -d fixtures/repro-specs ]]; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=no-repro-specs-dir" >> "$GITHUB_OUTPUT" + echo "::warning::fixtures/repro-specs 目录不存在 —— bootstrap 期豁免,跳过 repro 检查" + exit 0 + fi + mapfile -t files < <(find fixtures/repro-specs -maxdepth 1 -name '*.ts' ! -name 'index.ts' | sort) + if [[ ${#files[@]} -eq 0 ]]; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=empty-repro-specs-dir" >> "$GITHUB_OUTPUT" + echo "::warning::fixtures/repro-specs 为空 —— bootstrap 期豁免,跳过 repro 检查" + exit 0 + fi + json=$(printf '%s\n' "${files[@]}" | jq -R . | jq -sc .) + echo "specs=$json" >> "$GITHUB_OUTPUT" + echo "skipReason=" >> "$GITHUB_OUTPUT" + echo "找到 spec: ${files[*]}" + + repro: + needs: list-specs + if: needs.list-specs.outputs.specs != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + spec: ${{ fromJson(needs.list-specs.outputs.specs) }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: ./.github/actions/setup-repo + - name: Build teamagent (有的 spec 跑 dist/bin.js) + run: pnpm --filter teamagent build + - name: Run repro + run: | + slug=$(basename "${{ matrix.spec }}" .ts) + mkdir -p "/tmp/repro-out/$slug" + npx tsx scripts/verify/repro-cli.ts "${{ matrix.spec }}" --no-gif \ + --out "/tmp/repro-out/$slug" + # ↑ 退出码 0/1 反映 verdict;不要 ||true,要让失败浮出来 + - uses: actions/upload-artifact@v4 + with: + name: repro-${{ strategy.job-index }} + path: /tmp/repro-out/ + + tests: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 # review 标准 2 需要 git merge-base / merge-tree,要全历史 + - uses: ./.github/actions/setup-repo + - name: pnpm test + run: pnpm test + - name: Working tree must be clean + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "::error::tests 跑完后工作树不干净:" + git status --porcelain + exit 1 + fi + + verdict: + needs: [list-specs, repro, tests] + # always() —— 不管 repro/tests 怎么挂,都跑一次去发评论 + 设 status, + # 否则 PR 上不显示任何 verdict,体验差。 + if: always() && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: ./.github/actions/setup-repo + - name: Download all repro artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/repro-out-all + pattern: repro-* + merge-multiple: true + - name: Aggregate verdict + shell: bash + env: + REPRO_OUTCOME: ${{ needs.repro.result }} # success / failure / skipped / cancelled + TESTS_OUTCOME: ${{ needs.tests.result }} + SKIP_REPRO_REASON: ${{ needs.list-specs.outputs.skipReproReason }} + run: | + mkdir -p /tmp/agg + # repro_ok 计算(三态豁免见安全前提): + # - REPRO_OUTCOME = success → ok + # - REPRO_OUTCOME = skipped 且 SKIP_REPRO_REASON 非空 → ok(显式豁免:bootstrap / docs-only) + # - 其它 → 不 ok + if [[ "$REPRO_OUTCOME" == "success" ]]; then + repro_ok=true + repro_summary="verdict=pass (all specs)" + elif [[ "$REPRO_OUTCOME" == "skipped" && -n "$SKIP_REPRO_REASON" ]]; then + repro_ok=true + repro_summary="豁免 — $SKIP_REPRO_REASON" + else + repro_ok=false + repro_summary="verdict=fail/missing (REPRO_OUTCOME=$REPRO_OUTCOME)" + fi + if [[ "$TESTS_OUTCOME" == "success" ]]; then + tests_ok=true + tests_summary="tests pass + tree clean" + else + tests_ok=false + tests_summary="tests failed or tree dirty (TESTS_OUTCOME=$TESTS_OUTCOME)" + fi + verdict=$([[ "$repro_ok" == "true" && "$tests_ok" == "true" ]] && echo "pass" || echo "fail") + jq -n \ + --arg generatedAt "$(date -u +%FT%TZ)" \ + --arg verdict "$verdict" \ + --argjson repro_ok "$repro_ok" \ + --argjson tests_ok "$tests_ok" \ + --arg repro_summary "$repro_summary" \ + --arg tests_summary "$tests_summary" \ + '{ + generatedAt: $generatedAt, verdict: $verdict, + criteria: [ + {id:"repro-pass", ok:$repro_ok, summary:$repro_summary, details:""}, + {id:"tests-and-merge-clean", ok:$tests_ok, summary:$tests_summary, details:""} + ] + }' > /tmp/agg/review-verdict.json + cat /tmp/agg/review-verdict.json + - name: Post comment + set status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + npx tsx scripts/review/post-pr-comment.ts \ + --verdict /tmp/agg/review-verdict.json \ + --pr ${{ github.event.pull_request.number }} \ + --sha ${{ github.event.pull_request.head.sha }} \ + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/release-branch.yml b/.github/workflows/release-branch.yml index 5fd947f..8968e0c 100644 --- a/.github/workflows/release-branch.yml +++ b/.github/workflows/release-branch.yml @@ -3,6 +3,16 @@ name: Publish release branch on: push: branches: [main] + schedule: + # 每 6 小时一次:UTC 00:00 / 06:00 / 12:00 / 18:00 + # 见 docs/adr/0017-six-hour-release.md + - cron: '0 */6 * * *' + workflow_dispatch: + inputs: + reason: + description: 'Why are you manually triggering?' + required: false + default: 'manual' permissions: contents: write @@ -11,21 +21,50 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - name: Early exit if HEAD already released + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + # 比 GITHUB_SHA 与 gh-pages 上 latest.json.sha,相等 → skip 后续所有 step。 + # 见 docs/adr/0017-six-hour-release.md「早退守门为什么必要」。 + PUBLISHED_URL="https://${{ github.repository_owner }}.github.io/$(echo '${{ github.repository }}' | cut -d/ -f2)/latest.json" + if ! curl -fsSL "$PUBLISHED_URL" -o /tmp/latest.json 2>/dev/null; then + echo "latest.json 不存在(首次 release?) → 继续发布" + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + PUBLISHED_SHA=$(jq -r '.sha // empty' /tmp/latest.json) + if [[ "$PUBLISHED_SHA" == "$GITHUB_SHA" ]]; then + echo "GITHUB_SHA=$GITHUB_SHA 已发布过(latest.json.sha 一致) → skip" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "GITHUB_SHA=$GITHUB_SHA vs published=$PUBLISHED_SHA → 继续发布" + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - if: steps.guard.outputs.skip != 'true' + uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: pnpm/action-setup@v5 + - if: steps.guard.outputs.skip != 'true' + uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v5 + - if: steps.guard.outputs.skip != 'true' + uses: actions/setup-node@v5 with: node-version: '22' cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter teamagent build + - if: steps.guard.outputs.skip != 'true' + run: pnpm install --frozen-lockfile + - if: steps.guard.outputs.skip != 'true' + run: pnpm --filter teamagent build - name: Detect version + if: steps.guard.outputs.skip != 'true' id: version run: | VERSION=$(jq -r .version packages/teamagent/package.json) @@ -41,6 +80,7 @@ jobs: printf 'tag=v%s\n' "$VERSION" >> "$GITHUB_OUTPUT" - name: Pack tarball + if: steps.guard.outputs.skip != 'true' run: | # Clean stale artefacts from any prior workflow run before pack, so # the `mv teamagent-*.tgz` glob never expands to multiple files. @@ -55,6 +95,7 @@ jobs: > "teamagent-${{ steps.version.outputs.tag }}.tgz.sha256" - name: Stage release artifacts + if: steps.guard.outputs.skip != 'true' run: | rm -rf /tmp/release-stage mkdir -p /tmp/release-stage @@ -88,6 +129,7 @@ jobs: > /tmp/release-stage/release-meta.json - name: Create GitHub Release (idempotent) + if: steps.guard.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -105,6 +147,7 @@ jobs: fi - name: Force-push release branch + if: steps.guard.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -119,6 +162,7 @@ jobs: release - name: Resolve PR creator for merge commit (post-merge auto-update feature) + if: steps.guard.outputs.skip != 'true' id: pr_lookup env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -166,6 +210,7 @@ jobs: echo "Resolved PR: number=$PR_NUMBER creator=$PR_CREATOR merged_at=$PR_MERGED_AT" - name: Publish latest.json to gh-pages (issue #313 Tier 1) + if: steps.guard.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ steps.pr_lookup.outputs.pr_number }} diff --git a/.gitignore b/.gitignore index f9f0cdc..acd15b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ dist/ .DS_Store *.log +# Local SQLite knowledge db materialised by `teamagent init`; +# regenerated per-machine and contains a copy of seed-pack rules. +.viki/ # evidence files under docs/plans/ are commit-worthy proof artifacts; # the *.log umbrella above must not swallow them. !docs/plans/**/*.log diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index c3430ea..1cf86ef 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -64,7 +64,7 @@ FIXEDFLOW / Symphony / 双 driver 那一整套(见文末「本文档取代了什 - 套件 1 实际跑过一遍后,系统录 GIF + 截图。 - GIF 嵌入一份 **HTML 报告**,中文。 - 硬要求:**非常严谨、不能浮于表面** —— 让 CEO **一眼**就能判断 feature 到底实现没实现。 -- 参考:`docs/VISUAL-PROOF-PR.md`、`docs/VISUAL-PROOF-FORMAT.md`。 +- 参考:`docs/acceptance/SPEC.md`(验收报告规范)+ `docs/acceptance/2026-05-14-hook-moment-block/`(人工产出的参照样板)。 ### 4 · 本地 review 执行的 CC 完成后,**另开一个独立 subagent 来 review**(不是执行 CC 自己自检 —— 避免自己给自己打分)。**过关标准 2 条**: @@ -117,13 +117,14 @@ FIXEDFLOW / Symphony / 双 driver 那一整套(见文末「本文档取代了什 |---|---| | `grill-me` 挖方案 | ✅ skill 已有 | | 两套验证的「概念」 | ✅ judge harness / visual-proof 文档已有,可复用 | -| 套件 1 自带录 GIF | ⚠️ 待搭 —— 录屏工具(vhs)本机未装,需定方案 | -| 套件 2 HTML 报告生成 | ⚠️ 待搭 —— 需要一个把 GIF + 截图 + 结论拼成严谨 HTML 的生成器 | -| 本地 review(独立 subagent + 打回循环) | ⚠️ 待搭 —— 需要 review subagent、把「2 条标准」做成可自动跑的检查、以及「不通过打回重做」的循环 | -| 远程 CC 自动评审 | ❌ 待搭 —— 旧的云端评审 bot 已删,需重建 | +| 套件 1 复现框架 | ✅ 已搭 —— `scripts/verify/repro-core.ts`(纯)+ `repro-runner.ts`(IO 壳) + `worktree-shell.ts`(基线 worktree)+ `repro-cli.ts`(orchestrator);ReproSpec 见 `docs/acceptance/2026-05-14-hook-moment-block/repro/`;Plan `docs/plans/2026-05-14-verification-tooling.md` | +| 套件 1 自带录 GIF | ✅ 已搭 —— `scripts/verify/gif-core.ts`(纯,ffmpeg arg 构造)+ `record-gif.ts`(spawn pwsh + ffmpeg gdigrab 真实录屏)+ `win-record.ps1`(Win32 窗口定位与生命周期);已接入 `repro-cli.ts`,跑完套件 1 自动出 GIF | +| 套件 2 HTML 报告生成 | ✅ 已搭 —— `scripts/verify/report-core.ts`(纯)+ `report-template.ts`(INLINE_CSS + 5 字符 XSS 转义)+ `gen-report.ts`(CLI);9 段中文报告,内嵌 GIF;ReportManifest schema 见 `docs/acceptance/2026-05-14-hook-moment-block/manifest.json` | +| 本地 review(独立 subagent + 打回循环) | ✅ 已搭 —— `scripts/review/review-core.ts`(纯,2 条标准 → CriterionResult → ReviewVerdict)+ `run-checks.ts`(IO 壳,repro-cli + 全量测试 + merge-tree 三检)+ `review-cli.ts` + `loop-driver.ts`(manual/agent 双模式);`.claude/skills/local-review/SKILL.md` 钉死契约 | +| 远程 CC 自动评审 | ✅ 已搭 —— `.github/workflows/pr-review.yml` 4-job(list-specs / repro matrix / tests / verdict),`if: always()` 守 verdict job;`scripts/review/post-pr-comment.ts` 用 `COMMENT_MARKER` 复用同一条评论 + 写 `review/verdict` commit status | | 两把锁 + 24h 自动撤锁 | ✅ 已搭 —— `lock:grill` / `lock:exec` 两个 label 当锁,认领/释放/查状态走文档(`docs/CLAIM-LOCK.md`),24h 自动撤锁由 `scripts/lock-sweep.ts` + `.github/workflows/lock-sweeper.yml` 每小时跑;纯逻辑 `scripts/lock-core.ts` 有单测 | -| 个人开发分支 → 主分支 自动合 | ❌ 待搭 | -| 每 6 小时 release | ⚠️ 待改 —— 现有 `release-branch.yml` 是「每次 push main 就发」,需改成 6 小时定时 | +| 个人开发分支 → 主分支 自动合 | ✅ 已搭 —— `.github/workflows/auto-merge.yml` 走 `workflow_run` 链式触发(pr-review 跑完 → 自动启动)→ `scripts/automerge/can-auto-merge.ts`(纯门控,7+ 条:draft / base!=main / do-not-merge / visual-proof / verdict / fork / mergeable / state)→ 通过则 `gh pr merge --squash --delete-branch` | +| 每 6 小时 release | ✅ 已搭 —— `release-branch.yml` 三路触发(push main + cron `0 */6 * * *` + workflow_dispatch),publish job 第一步早退守门(`GITHUB_SHA` vs `latest.json.sha` 比对,相等 skip 后续 12 个 step);ADR `docs/adr/0017-six-hour-release.md` | ## 本文档取代了什么 @@ -138,7 +139,7 @@ FIXEDFLOW / Symphony / 双 driver 那一整套(见文末「本文档取代了什 - 锁 / 认领:`CLAIM-LOCK.md` - 验证套件 1:`PLAN-RESEARCH-REPORT.md` -- 验证套件 2:`VISUAL-PROOF-PR.md`、`VISUAL-PROOF-FORMAT.md`、`VISUAL-PROOF-CONTENT.md`、`VISUAL-PROOF-HUMAN-MERGE.md` +- 验证套件 2:`acceptance/SPEC.md`(验收报告规范)、`acceptance/2026-05-14-hook-moment-block/`(参照样板)。旧的 `VISUAL-PROOF-*` 已被 `acceptance/SPEC.md` 取代,待拆 - review:`POSTPR.md`、`adr/0007-local-review-skill-as-review-gate.md` - 测试 / 合并:`INNER-LOOP-TESTING.md`、`adr/0013-inner-loop-on-ci.md`、`BEFORE-MERGE.md`、`COMMIT-FLOW.md` - issue 生命周期:`ISSUE-LIFECYCLE.md` diff --git a/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-after.txt b/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-after.txt new file mode 100644 index 0000000..a72a7c4 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-after.txt @@ -0,0 +1,12 @@ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🚫 TeamAgent · 模拟 PreToolUse 结果 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 工具: Bash +▸ 输入: {"command":"npm install moment"} +▸ 决策: deny +▸ 拦截原因: + 🚫 TeamAgent 拦截 (置信 0.85) + 应改用: 使用 dayjs(API 兼容、~2KB)或 date-fns(tree-shakable)替代 moment + 原因: moment.js 自 2020 起官方进入 maintenance mode,不再添加新功能;体积约 290KB(gzipped 71KB),mutable API 易引发隐性 bug。dayjs/date-fns 在新项目中应作为默认选择。 + (规则 id: seed-pack-universal-moment) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-before.txt b/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-before.txt new file mode 100644 index 0000000..6bbaa59 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/assets/evidence-before.txt @@ -0,0 +1,7 @@ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🟢 TeamAgent · 模拟 PreToolUse 结果 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 工具: Bash +▸ 输入: {"command":"npm install moment"} +▸ 决策: 通过 (无规则命中) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/docs/acceptance/2026-05-14-hook-moment-block/assets/still-after.png b/docs/acceptance/2026-05-14-hook-moment-block/assets/still-after.png new file mode 100644 index 0000000..391011a Binary files /dev/null and b/docs/acceptance/2026-05-14-hook-moment-block/assets/still-after.png differ diff --git a/docs/acceptance/2026-05-14-hook-moment-block/assets/still-before.png b/docs/acceptance/2026-05-14-hook-moment-block/assets/still-before.png new file mode 100644 index 0000000..4fa3131 Binary files /dev/null and b/docs/acceptance/2026-05-14-hook-moment-block/assets/still-before.png differ diff --git a/docs/acceptance/2026-05-14-hook-moment-block/demo.gif b/docs/acceptance/2026-05-14-hook-moment-block/demo.gif new file mode 100644 index 0000000..6ad4b05 Binary files /dev/null and b/docs/acceptance/2026-05-14-hook-moment-block/demo.gif differ diff --git a/docs/acceptance/2026-05-14-hook-moment-block/manifest.json b/docs/acceptance/2026-05-14-hook-moment-block/manifest.json new file mode 100644 index 0000000..77e04f4 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/manifest.json @@ -0,0 +1,69 @@ +{ + "feature": "把 AI 重复犯的错,在执行前拦下来", + "date": "2026-05-14", + "verdict": { + "status": "pass", + "line": "已实现,并通过验证", + "detail": "同一条命令、同一个工具,在「TeamAgent 没学到经验」和「学到经验」两种状态下,给出了相反的结果 —— 该放的放行,该拦的拦住。前后对比成立。" + }, + "whatItIs": "团队里的人用 AI 写代码,难免会犯一些「早就有人踩过的坑」。TeamAgent 做的事很简单:把团队踩过的坑记下来,等 AI 下次又要踩同一个坑时,在它真正动手之前把它拦住。", + "whatItIsDetail": "本次验收用一个具体的坑来演示:AI 想安装 moment 这个已经过时的日期库。我们对比 TeamAgent 在「还没学到这条经验」和「已经学到」两种状态下的反应。", + "gifPath": "demo.gif", + "gifCaption": "这是一段真实的屏幕录像(不是动画绘制):一个真实的终端窗口,真实地跑了两遍同一条命令。上半段是「之前」、下半段是「之后」。", + "before": { + "tag": "之前 · 知识库是空的", + "still": "assets/still-before.png", + "text": "TeamAgent 还没学到「别用 moment」这条经验。AI 执行 npm install moment —— 放行。过时的库就这样被装进了项目,没人拦。", + "evidence": "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n🟢 TeamAgent · 模拟 PreToolUse 结果\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n▸ 工具: Bash\n▸ 输入: {\"command\":\"npm install moment\"}\n▸ 决策: 通过 (无规则命中)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + }, + "after": { + "tag": "之后 · 经验已进知识库", + "still": "assets/still-after.png", + "text": "这条经验已经进了知识库。同样的命令再跑一次 —— 被拦下,并且告诉 AI:应该改用 dayjs。错误没能发生。", + "evidence": "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n🚫 TeamAgent · 模拟 PreToolUse 结果\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n▸ 工具: Bash\n▸ 输入: {\"command\":\"npm install moment\"}\n▸ 决策: deny\n▸ 拦截原因:\n 🚫 TeamAgent 拦截 (置信 0.85)\n 应改用: 使用 dayjs(API 兼容、~2KB)或 date-fns(tree-shakable)替代 moment\n 原因: moment.js 自 2020 起官方进入 maintenance mode,不再添加新功能;体积约 290KB(gzipped 71KB),mutable API 易引发隐性 bug。dayjs/date-fns 在新项目中应作为默认选择。\n (规则 id: seed-pack-universal-moment)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + }, + "criteria": [ + { + "ok": true, + "title": "同一个输入,只改变一个变量,结果相反", + "body": "两次跑的命令完全一样(npm install moment),工具一样(Bash)。唯一的差别是:知识库里有没有那条学到的经验。结果:空知识库 → 放行;有经验 → 拦截。一个变量,两个相反的结果 —— 说明拦截确实是这条经验起的作用,不是碰巧。" + }, + { + "ok": true, + "title": "拦截给出的理由是具体的、可核验的", + "body": "不是笼统地「拒绝」,而是明确指出:置信度 0.85、应改用 dayjs 或 date-fns、原因是 moment 已进入维护模式且体积过大、并附上规则编号 seed-pack-universal-moment。" + }, + { + "ok": false, + "title": "全量测试通过 + 合并无冲突", + "body": "这是工作流里的第 2 条标准,由 CI 把关,不在本份「功能验收报告」的范围内。本报告只回答「这个功能到底做出来没有」。" + } + ], + "reproSteps": [ + "在 Matrix 仓库根目录,准备一个空知识库的环境,跑 teamagent demo hook Bash command=\"npm install moment\" → 得到「之前」的结果:放行。", + "把 TeamAgent 预置的经验装进知识库(teamagent init 会加载 seed/packs/universal.jsonl,其中包含 moment 这条规则)。", + "再跑一遍同样的命令 → 得到「之后」的结果:拦截。", + "本次屏幕录像的完整脚本保存在 recording/demo-scene.ps1 与 recording/record.ps1,可原样重跑。" + ], + "coverage": { + "covered": [ + "真实的知识库(SQLite)+ 真实的匹配引擎 —— 「要不要拦、为什么拦」的完整判断逻辑。", + "同输入、单变量、相反结果的前后对比。", + "拦截理由的具体内容(置信度、替代方案、原因、规则编号)。" + ], + "notCovered": [ + "本次走的是 teamagent demo hook 离线复现命令 —— 它复现的是拦截判断本身,不需要打开编辑器;编辑器内「红框弹出」的端到端链路不在本次范围。", + "本次「之后」用的是预置经验规则;由团队成员的真实纠正自动提炼规则的环节,是另一条链路,需单独验收。", + "全量测试 / 合并冲突 —— 由 CI 把关。" + ] + }, + "footer": { + "env": "Windows 11 · Node 22 · TeamAgent (Matrix) 预编译 CLI", + "recordMethod": "真实屏幕录像(ffmpeg gdigrab 抓取真实终端窗口),非动画绘制", + "extras": { + "被验功能": "PreToolUse hook 拦截重复犯的错(以 moment→dayjs 为例)", + "规则编号": "seed-pack-universal-moment(来自 packages/teamagent/seed/packs/universal.jsonl)" + }, + "note": "本报告是 Matrix 项目「两套验证」中套件 2(验收报告)的产物 —— 给非技术读者一眼判断功能到底实现没有。本份由 scripts/verify/gen-report.ts 从 manifest.json 自动生成,与人工首样板的结构等价。" + } +} diff --git a/docs/acceptance/2026-05-14-hook-moment-block/recording/README.md b/docs/acceptance/2026-05-14-hook-moment-block/recording/README.md new file mode 100644 index 0000000..59aa273 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/recording/README.md @@ -0,0 +1,42 @@ +# 本次验收 GIF 的录制脚本 + +这两个脚本产出了 `../demo.gif` —— TeamAgent 拦截 `npm install moment` 的真实屏幕录像。 +保留在这里是为了**可复现**:任何人都能照下面重跑一遍。 + +## 文件 + +- `demo-scene.ps1` —— 在被录的终端窗口里跑的「场景脚本」。它做两件事: + 1. **之前**:把 `USERPROFILE` 指向一个空知识库的临时 HOME,跑 `teamagent demo hook` → 放行; + 2. **之后**:把 `USERPROFILE` 指向一个已加载 universal pack 的临时 HOME,跑同样的命令 → 拦截。 + 两段之间 `Clear-Host`,保证每屏内容完整可见。 +- `record.ps1` —— 录制编排。启动场景窗口 → 用 `EnumWindows` 按精确标题 `TADEMOREC` + 找到窗口 → 贴到固定矩形并置顶 → `ffmpeg gdigrab` 按固定区域**真实录屏** → 产出 mp4 + 检查帧。 + 录制前后都用 `PostMessage(WM_CLOSE)` 按窗口句柄精确关闭 `TADEMOREC` 窗口 + (**绝不** `Stop-Process` Windows Terminal 进程 —— 它同时托管着其它终端窗口)。 + +## 复现前置 + +- Windows + ffmpeg(在 PATH 上)。 +- 两个临时 HOME:`home-empty`(空)与 `home-loaded`(其 `.teamagent/global.db` + 由 `teamagent init` 加载了 `packages/teamagent/seed/packs/universal.jsonl`)。 + 脚本里这两个路径写死在 `$stage` 下,按需改。 +- 预编译 CLI:`packages/teamagent/dist/bin.js`(`demo-scene.ps1` 直接 `node` 跑它, + 避免 tsx 现编译带来的不可预测延迟)。 + +## 跑 + +```powershell +# 这两个 .ps1 含中文,PowerShell 5.1 需要 UTF-8 BOM 才能正确读取 +& record.ps1 +``` + +`record.ps1` 产出 `out/demo.mp4` + `out/frame-*.png`。GIF 由 mp4 两遍调色板转出 +(见 `record.ps1` 注释或验收报告的「怎么复现」一节)。 + +## 已知约束 + +- 录制窗口是一个新的 Windows Terminal 窗口(本机 WT 是默认终端)。WT 的 emoji / CJK + 渲染完整,这是选它的原因。 +- 捕获区域比窗口略小、整体内移 8px —— 避开 Win11 窗口的隐形边框,免得框进背后的内容。 +- 这是**人工产出的首个样板**。后续验证框架(套件1 + 套件2 生成器)会把这套流程脚本化、 + 参数化;本目录的脚本是那个框架的参照原型。 diff --git a/docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1 b/docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1 new file mode 100644 index 0000000..f7aa544 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1 @@ -0,0 +1,69 @@ +# demo-scene.ps1 — TeamAgent 核心能力验收演示(在被录屏的终端窗口里跑) +# before/after 用同一条命令,唯一差别是知识库里有没有那条「学到的经验」。 +# 两个场景之间 Clear-Host,保证每屏内容都完整可见。 +$ErrorActionPreference = 'SilentlyContinue' + +$ui = $Host.UI.RawUI +$ui.WindowTitle = 'TADEMOREC' +try { + $ui.BufferSize = New-Object Management.Automation.Host.Size(100, 600) + $ui.WindowSize = New-Object Management.Automation.Host.Size(100, 30) +} catch {} + +$stage = 'C:\Users\tianhaoxuan\ta-demo-stage' +$env:NODE_NO_WARNINGS = '1' +Set-Location 'C:\bzli\Matrix' +function teamagent { & node 'C:\bzli\Matrix\packages\teamagent\dist\bin.js' @args } +function Type-Cmd($prompt, $cmd) { + Write-Host -NoNewline $prompt -ForegroundColor DarkCyan + foreach ($ch in $cmd.ToCharArray()) { + Write-Host -NoNewline $ch -ForegroundColor White + Start-Sleep -Milliseconds 36 + } + Write-Host '' + Start-Sleep -Milliseconds 500 +} + +# ── 开场 ── +Clear-Host +Write-Host '' +Write-Host ' =================================================================' -ForegroundColor Cyan +Write-Host ' TeamAgent 验收演示' -ForegroundColor Cyan +Write-Host ' 核心能力:把 AI 重复犯的错,在执行前就拦下来' -ForegroundColor Cyan +Write-Host ' =================================================================' -ForegroundColor Cyan +Write-Host '' +Write-Host ' 场景:AI 准备执行 ' -NoNewline -ForegroundColor Gray +Write-Host 'npm install moment' -NoNewline -ForegroundColor Yellow +Write-Host ' (moment 是已过时的库)' -ForegroundColor Gray +Write-Host '' +Start-Sleep -Seconds 5 + +# ── 场景一:之前(知识库为空)── +Write-Host ' -----------------------------------------------------------------' -ForegroundColor DarkGray +Write-Host ' [ 之前 ] 知识库是空的 —— TeamAgent 还没学到这条经验' -ForegroundColor Yellow +Write-Host '' +$env:USERPROFILE = "$stage\home-empty"; $env:HOME = $env:USERPROFILE +Type-Cmd ' PS C:\my-project> ' 'teamagent demo hook Bash command="npm install moment"' +teamagent demo hook Bash command="npm install moment" +Write-Host '' +Write-Host ' >> 没有相关经验,放行。AI 就这样把过时的库装进了项目。' -ForegroundColor DarkYellow +Start-Sleep -Seconds 6 + +# ── 清屏,进入场景二 ── +Clear-Host +Write-Host '' +Write-Host ' 同样的命令再来一次 —— 但这次,有人已经纠正过 TeamAgent 一回。' -ForegroundColor Gray +Write-Host '' + +# ── 场景二:之后(规则已进知识库)── +Write-Host ' -----------------------------------------------------------------' -ForegroundColor DarkGray +Write-Host ' [ 之后 ] 有人纠正过一次 —— TeamAgent 把它提炼成规则,存进了知识库' -ForegroundColor Green +Write-Host '' +$env:USERPROFILE = "$stage\home-loaded"; $env:HOME = $env:USERPROFILE +Type-Cmd ' PS C:\my-project> ' 'teamagent demo hook Bash command="npm install moment"' +teamagent demo hook Bash command="npm install moment" +Write-Host '' +Write-Host ' >> 同样的命令,这次被拦下了,并告诉 AI 应该改用 dayjs。错误没能发生。' -ForegroundColor Green +Write-Host '' +# 录制结束后由 record.ps1 精确结束并清理本窗口;这里长 hold 保证窗口不自行关闭。 +Start-Sleep -Seconds 600 diff --git a/docs/acceptance/2026-05-14-hook-moment-block/recording/record.ps1 b/docs/acceptance/2026-05-14-hook-moment-block/recording/record.ps1 new file mode 100644 index 0000000..57fe18b --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/recording/record.ps1 @@ -0,0 +1,99 @@ +# record.ps1 — 启动 demo-scene.ps1(在新的 Windows Terminal 窗口里), +# 用 EnumWindows 按精确标题 TADEMOREC 找到窗口句柄,贴到屏幕左上角并置顶, +# ffmpeg gdigrab 按固定区域真实录屏。只录 demo 窗口那块,不录整桌面。 +# +# 窗口生命周期:录制前后都用 PostMessage(WM_CLOSE) 按句柄精确关闭 TADEMOREC 窗口。 +# 绝不 Stop-Process WT 进程 —— 它同时托管着用户的其它终端窗口。 +$ErrorActionPreference = 'Stop' +$stage = 'C:\Users\tianhaoxuan\ta-demo-stage' +$out = "$stage\out" +New-Item -ItemType Directory -Force $out | Out-Null + +Add-Type @" +using System; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public class Win { + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] static extern IntPtr PostMessage(IntPtr h, uint msg, IntPtr w, IntPtr l); + delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr hAfter, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); + static List AllByTitle(string title) { + var found = new List(); + EnumWindows((h,l)=>{ + if(!IsWindowVisible(h)) return true; + var sb=new StringBuilder(300); GetWindowText(h,sb,300); + if(sb.ToString()==title) found.Add(h); + return true; + }, IntPtr.Zero); + return found; + } + public static IntPtr FindByTitle(string title) { + var a = AllByTitle(title); + return a.Count > 0 ? a[0] : IntPtr.Zero; + } + public static int CountByTitle(string title) { return AllByTitle(title).Count; } + // 只对标题精确等于 title 的窗口发 WM_CLOSE(0x10);WT 会关掉那一个窗口,不动其它窗口。 + public static int CloseAllByTitle(string title) { + var a = AllByTitle(title); + foreach (var h in a) PostMessage(h, 0x0010, IntPtr.Zero, IntPtr.Zero); + return a.Count; + } +} +"@ + +# 屏幕 1536x864,可用高 816 —— 窗口 760 高,留出任务栏 +$winX = 0; $winY = 0; $winW = 1120; $winH = 760 +# 捕获区域整体内移 8px(跳过窗口左侧隐形边框) +$capX = 8; $capY = 0; $capW = 1096; $capH = 730 + +# 0. 清掉之前留下的僵尸 TADEMOREC 窗口 +$closed = [Win]::CloseAllByTitle('TADEMOREC') +if ($closed -gt 0) { Write-Output "pre-clean: 关闭了 $closed 个残留 TADEMOREC 窗口"; Start-Sleep -Seconds 2 } + +# 1. 启动被录的场景窗口(会开一个新的 Windows Terminal 窗口) +$p = Start-Process powershell -ArgumentList '-NoProfile','-File',"$stage\demo-scene.ps1" -PassThru +Write-Output "scene PID = $($p.Id)" + +# 2. 按精确标题 TADEMOREC 轮询找窗口,最多 12 秒 +$h = [IntPtr]::Zero +for ($i = 0; $i -lt 60; $i++) { + Start-Sleep -Milliseconds 200 + $h = [Win]::FindByTitle('TADEMOREC') + if ($h -ne [IntPtr]::Zero) { break } +} +if ($h -eq [IntPtr]::Zero) { [Win]::CloseAllByTitle('TADEMOREC') | Out-Null; throw "找不到 TADEMOREC 窗口" } +$cnt = [Win]::CountByTitle('TADEMOREC') +Write-Output "window handle = $h (found after ~$([math]::Round($i*0.2,1))s, TADEMOREC 窗口总数 = $cnt)" +if ($cnt -ne 1) { [Win]::CloseAllByTitle('TADEMOREC') | Out-Null; throw "TADEMOREC 窗口数异常($cnt),已全部关闭,请重跑" } + +# 3. 贴到固定矩形 + 置顶 + 前台 (HWND_TOPMOST=-1, SWP_SHOWWINDOW=0x40, SW_SHOW=5) +[Win]::ShowWindow($h, 5) | Out-Null +[Win]::SetWindowPos($h, [IntPtr](-1), $winX, $winY, $winW, $winH, 0x40) | Out-Null +[Win]::SetForegroundWindow($h) | Out-Null +Start-Sleep -Milliseconds 700 + +# 4. ffmpeg 按固定区域真实录屏(场景窗口长 hold,录够时长即可) +$mp4 = "$out\demo.mp4" +& ffmpeg -hide_banner -loglevel warning -stats ` + -f gdigrab -framerate 12 -offset_x $capX -offset_y $capY -video_size "${capW}x${capH}" -i desktop ` + -t 40 -pix_fmt yuv420p -y $mp4 +Write-Output "ffmpeg(record) exit = $LASTEXITCODE" + +# 5. 关掉场景窗口(按句柄精确 WM_CLOSE,不碰 WT 进程) +Start-Sleep -Seconds 1 +$closed2 = [Win]::CloseAllByTitle('TADEMOREC') +Write-Output "post-clean: 关闭了 $closed2 个 TADEMOREC 窗口" + +# 6. 抽密集的检查帧,供人工确定裁剪窗口 +foreach ($t in 4,8,12,16,20,24,28,32,36) { + & ffmpeg -hide_banner -loglevel error -ss $t -i $mp4 -frames:v 1 -update 1 -y "$out\frame-$t.png" +} + +Write-Output "=== record done ===" +Get-Item $mp4 | Select-Object Name,Length diff --git a/docs/acceptance/2026-05-14-hook-moment-block/report.html b/docs/acceptance/2026-05-14-hook-moment-block/report.html new file mode 100644 index 0000000..9e4e266 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/report.html @@ -0,0 +1,168 @@ + + + + + +验收报告 · 把 AI 重复犯的错,在执行前拦下来 + + + +
TEAMAGENT · 验收报告

把 AI 重复犯的错,在执行前拦下来

2026-05-14
+
+

已实现,并通过验证

同一条命令、同一个工具,在「TeamAgent 没学到经验」和「学到经验」两种状态下,给出了相反的结果 —— 该放的放行,该拦的拦住。前后对比成立。

+

这个功能是什么

团队里的人用 AI 写代码,难免会犯一些「早就有人踩过的坑」。TeamAgent 做的事很简单:把团队踩过的坑记下来,等 AI 下次又要踩同一个坑时,在它真正动手之前把它拦住。

本次验收用一个具体的坑来演示:AI 想安装 moment 这个已经过时的日期库。我们对比 TeamAgent 在「还没学到这条经验」和「已经学到」两种状态下的反应。

+

真实屏幕录像

真实屏幕录像
这是一段真实的屏幕录像(不是动画绘制):一个真实的终端窗口,真实地跑了两遍同一条命令。上半段是「之前」、下半段是「之后」。
+

之前 vs 之后 —— 一眼看懂

+
之前 · 知识库是空的之前

TeamAgent 还没学到「别用 moment」这条经验。AI 执行 npm install moment —— 放行。过时的库就这样被装进了项目,没人拦。

+
之后 · 经验已进知识库之后

这条经验已经进了知识库。同样的命令再跑一次 —— 被拦下,并且告诉 AI:应该改用 dayjs。错误没能发生。

+
+

凭什么说"实现了" —— 验收标准

+ +
同一个输入,只改变一个变量,结果相反
两次跑的命令完全一样(npm install moment),工具一样(Bash)。唯一的差别是:知识库里有没有那条学到的经验。结果:空知识库 → 放行;有经验 → 拦截。一个变量,两个相反的结果 —— 说明拦截确实是这条经验起的作用,不是碰巧。
拦截给出的理由是具体的、可核验的
不是笼统地「拒绝」,而是明确指出:置信度 0.85、应改用 dayjs 或 date-fns、原因是 moment 已进入维护模式且体积过大、并附上规则编号 seed-pack-universal-moment。
▫️全量测试通过 + 合并无冲突 —— 这是工作流里的第 2 条标准,由 CI 把关,不在本份「功能验收报告」的范围内。本报告只回答「这个功能到底做出来没有」。
+

原始证据(机器可核验)

+

下面是两次运行逐字未改的真实输出。任何人都可以照"怎么复现"一节自己跑一遍,得到一样的结果。

+
+

之前

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+🟢 TeamAgent · 模拟 PreToolUse 结果
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+▸ 工具: Bash
+▸ 输入: {"command":"npm install moment"}
+▸ 决策: 通过 (无规则命中)
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+

之后

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+🚫 TeamAgent · 模拟 PreToolUse 结果
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+▸ 工具: Bash
+▸ 输入: {"command":"npm install moment"}
+▸ 决策: deny
+▸ 拦截原因:
+    🚫 TeamAgent 拦截 (置信 0.85)
+    应改用: 使用 dayjs(API 兼容、~2KB)或 date-fns(tree-shakable)替代 moment
+    原因: moment.js 自 2020 起官方进入 maintenance mode,不再添加新功能;体积约 290KB(gzipped 71KB),mutable API 易引发隐性 bug。dayjs/date-fns 在新项目中应作为默认选择。
+    (规则 id: seed-pack-universal-moment)
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+
+

怎么复现(给想自己核验的人)

  1. 在 Matrix 仓库根目录,准备一个空知识库的环境,跑 teamagent demo hook Bash command="npm install moment" → 得到「之前」的结果:放行。
  2. +
  3. 把 TeamAgent 预置的经验装进知识库(teamagent init 会加载 seed/packs/universal.jsonl,其中包含 moment 这条规则)。
  4. +
  5. 再跑一遍同样的命令 → 得到「之后」的结果:拦截。
  6. +
  7. 本次屏幕录像的完整脚本保存在 recording/demo-scene.ps1 与 recording/record.ps1,可原样重跑。
+

严谨说明 —— 这次验证覆盖了什么、没覆盖什么

+

✅ 已覆盖

  • 真实的知识库(SQLite)+ 真实的匹配引擎 —— 「要不要拦、为什么拦」的完整判断逻辑。
  • +
  • 同输入、单变量、相反结果的前后对比。
  • +
  • 拦截理由的具体内容(置信度、替代方案、原因、规则编号)。
+

▫️ 未覆盖(说明,不是缺陷)

  • 本次走的是 teamagent demo hook 离线复现命令 —— 它复现的是拦截判断本身,不需要打开编辑器;编辑器内「红框弹出」的端到端链路不在本次范围。
  • +
  • 本次「之后」用的是预置经验规则;由团队成员的真实纠正自动提炼规则的环节,是另一条链路,需单独验收。
  • +
  • 全量测试 / 合并冲突 —— 由 CI 把关。
+
+
+ + + + +
报告生成2026-05-14
被验功能把 AI 重复犯的错,在执行前拦下来
运行环境Windows 11 · Node 22 · TeamAgent (Matrix) 预编译 CLI
录像方式真实屏幕录像(ffmpeg gdigrab 抓取真实终端窗口),非动画绘制
被验功能PreToolUse hook 拦截重复犯的错(以 moment→dayjs 为例)
规则编号seed-pack-universal-moment(来自 packages/teamagent/seed/packs/universal.jsonl)

本报告是 Matrix 项目「两套验证」中套件 2(验收报告)的产物 —— 给非技术读者一眼判断功能到底实现没有。本份由 scripts/verify/gen-report.ts 从 manifest.json 自动生成,与人工首样板的结构等价。

+
+ + diff --git a/docs/acceptance/2026-05-14-hook-moment-block/report.original.html b/docs/acceptance/2026-05-14-hook-moment-block/report.original.html new file mode 100644 index 0000000..1040ef1 --- /dev/null +++ b/docs/acceptance/2026-05-14-hook-moment-block/report.original.html @@ -0,0 +1,293 @@ + + + + + +验收报告 · TeamAgent 拦截重复犯的错 + + + + +
+
TEAMAGENT · 验收报告
+

把 AI 重复犯的错,在执行前拦下来

+
核心能力验证 · 2026-05-14
+
+ +
+ +
+
+
+

已实现,并通过验证

+

同一条命令、同一个工具,在「TeamAgent 没学到经验」和「学到经验」两种状态下,给出了相反的结果 —— 该放的放行,该拦的拦住。前后对比成立。

+
+
+ +
+

这个功能是什么

+
+

+ 团队里的人用 AI 写代码,难免会犯一些"早就有人踩过的坑"。TeamAgent 做的事很简单: + 把团队踩过的坑记下来,等 AI 下次又要踩同一个坑时,在它真正动手之前把它拦住。 +

+

+ 本次验收用一个具体的坑来演示:AI 想安装 moment 这个已经过时的日期库。 + 我们对比 TeamAgent 在"还没学到这条经验"和"已经学到"两种状态下的反应。 +

+
+
+ +
+

真实屏幕录像

+
+ TeamAgent 拦截演示的真实屏幕录像 +
+ 这是一段真实的屏幕录像(不是动画绘制):一个真实的终端窗口,真实地跑了两遍同一条命令。 + 上半段是"之前"、下半段是"之后"。 +
+
+
+ +
+

之前 vs 之后 —— 一眼看懂

+
+
+
+ 之前 · 知识库是空的 + 之前:命令被放行 +

TeamAgent 还没学到"别用 moment"这条经验。 + AI 执行 npm install moment —— 放行。 + 过时的库就这样被装进了项目,没人拦。

+
+
+ 之后 · 经验已进知识库 + 之后:命令被拦截 +

这条经验已经进了知识库。同样的命令再跑一次 —— + 被拦下,并且告诉 AI:应该改用 dayjs。错误没能发生。

+
+
+
+
+ +
+

凭什么说"实现了"——验收标准

+
+

+ 判断一个功能到底实现了没有,靠的不是"它说它能",而是前后对比能不能对得上: +

+ + + + + + + + + + + + + +
同一个输入,只改变一个变量,结果相反。
+ 两次跑的命令完全一样(npm install moment),工具一样(Bash)。 + 唯一的差别是:知识库里有没有那条学到的经验。
+ 结果:空知识库 → 放行;有经验 → 拦截。 + 一个变量,两个相反的结果 —— 说明拦截确实是这条经验起的作用,不是碰巧。
拦截给出的理由是具体的、可核验的。
+ 不是笼统地"拒绝",而是明确指出:置信度 0.85、应改用 dayjs 或 date-fns、 + 原因是 moment 已进入维护模式且体积过大、并附上规则编号 seed-pack-universal-moment
▫️全量测试通过 + 合并无冲突 —— 这是工作流里的第 2 条标准,由 CI 把关, + 不在本份"功能验收报告"的范围内。本报告只回答"这个功能到底做出来没有"。
+
+
+ +
+

原始证据(机器可核验)

+
+

+ 下面是两次运行逐字未改的真实输出。任何人都可以照"怎么复现"一节自己跑一遍,得到一模一样的结果。 +

+
+
+

之前 — 知识库为空

+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+🟢 TeamAgent · 模拟 PreToolUse 结果
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+▸ 工具: Bash
+▸ 输入: {"command":"npm install moment"}
+▸ 决策: 通过 (无规则命中)
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+
+

之后 — 经验已进知识库

+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+🚫 TeamAgent · 模拟 PreToolUse 结果
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+▸ 工具: Bash
+▸ 输入: {"command":"npm install moment"}
+▸ 决策: deny
+▸ 拦截原因:
+    🚫 TeamAgent 拦截 (置信 0.85)
+    应改用: 使用 dayjs(API 兼容、~2KB)或 date-fns
+    原因: moment.js 自 2020 起进入维护模式,体积约
+          290KB,易引发隐性 bug。
+    (规则 id: seed-pack-universal-moment)
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+
+

原文也保存在 assets/evidence-before.txtassets/evidence-after.txt

+
+
+ +
+

怎么复现(给想自己核验的人)

+
+
    +
  1. 在 Matrix 仓库根目录,准备一个空知识库的环境,跑:
    + teamagent demo hook Bash command="npm install moment"
    + → 得到「之前」的结果:放行。
  2. +
  3. 把 TeamAgent 预置的经验装进知识库(teamagent init 会加载 seed/packs/universal.jsonl, + 其中包含 moment 这条规则)。
  4. +
  5. 再跑一遍同样的命令 → 得到「之后」的结果:拦截。
  6. +
  7. 本次屏幕录像的完整脚本保存在 recording/demo-scene.ps1recording/record.ps1,可原样重跑。
  8. +
+
+
+ +
+

严谨说明 —— 这次验证覆盖了什么、没覆盖什么

+
+
+

✅ 已覆盖

+
    +
  • 真实的知识库(SQLite)+ 真实的匹配引擎 —— "要不要拦、为什么拦"的完整判断逻辑。
  • +
  • 同输入、单变量、相反结果的前后对比。
  • +
  • 拦截理由的具体内容(置信度、替代方案、原因、规则编号)。
  • +
+
+
+

▫️ 未覆盖(说明,不是缺陷)

+
    +
  • 本次走的是 teamagent demo hook 离线复现命令 —— 它复现的是拦截判断本身, + 不需要打开编辑器;编辑器内"红框弹出"的端到端链路不在本次范围。
  • +
  • 本次"之后"用的是预置经验规则;由团队成员的真实纠正自动提炼规则的环节,是另一条链路,需单独验收。
  • +
  • 全量测试 / 合并冲突 —— 由 CI 把关。
  • +
+
+
+
+ +
+ + + + + + +
报告生成2026-05-14
被验功能PreToolUse hook 拦截重复犯的错(以 moment→dayjs 为例)
规则编号seed-pack-universal-moment(来自 packages/teamagent/seed/packs/universal.jsonl)
运行环境Windows 11 · Node 22 · TeamAgent (Matrix) 预编译 CLI
录像方式真实屏幕录像(ffmpeg gdigrab 抓取真实终端窗口),非动画绘制
+

本报告是 Matrix 项目「两套验证」中套件 2(验收报告)的产物 —— + 给非技术读者一眼判断功能到底实现没有。本份为人工产出的首个样板,作为后续验证框架的参照基准。

+
+ +
+ + diff --git a/docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json b/docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json new file mode 100644 index 0000000..7929774 --- /dev/null +++ b/docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json @@ -0,0 +1,36 @@ +{ + "specId": "hook-moment-block", + "generatedAt": "2026-05-15T05:44:55.750Z", + "before": { + "side": "before", + "steps": [ + { + "stepName": "demo-hook-npm-install-moment", + "exitCode": 0, + "stdout": "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n🟢 TeamAgent · 模拟 PreToolUse 结果\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n▸ 工具: Bash\n▸ 输入: {\"command\":\"npm install moment\"}\n▸ 决策: 通过 (无规则命中)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", + "stderr": "(node:26780) ExperimentalWarning: SQLite is an experimental feature and might change at any time\n(Use `node --trace-warnings ...` to show where the warning was created)\n", + "durationMs": 409, + "timedOut": false + } + ], + "matcherOk": true, + "matcherReasons": [] + }, + "after": { + "side": "after", + "steps": [ + { + "stepName": "demo-hook-npm-install-moment", + "exitCode": 0, + "stdout": "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n🚫 TeamAgent · 模拟 PreToolUse 结果\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n▸ 工具: Bash\n▸ 输入: {\"command\":\"npm install moment\"}\n▸ 决策: deny\n▸ 拦截原因:\n 🚫 TeamAgent 拦截 (置信 0.85)\n 应改用: 使用 dayjs(API 兼容、~2KB)或 date-fns(tree-shakable)替代 moment\n 原因: moment.js 自 2020 起官方进入 maintenance mode,不再添加新功能;体积约 290KB(gzipped 71KB),mutable API 易引发隐性 bug。dayjs/date-fns 在新项目中应作为默认选择。\n (规则 id: seed-pack-universal-moment)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", + "stderr": "(node:22668) ExperimentalWarning: SQLite is an experimental feature and might change at any time\n(Use `node --trace-warnings ...` to show where the warning was created)\n", + "durationMs": 449, + "timedOut": false + } + ], + "matcherOk": true, + "matcherReasons": [] + }, + "verdict": "pass", + "verdictReason": "before 与 after 期望均成立,且互换 matcher 后对比仍区分两侧" +} \ No newline at end of file diff --git a/docs/acceptance/SPEC.md b/docs/acceptance/SPEC.md new file mode 100644 index 0000000..237cb46 --- /dev/null +++ b/docs/acceptance/SPEC.md @@ -0,0 +1,82 @@ +# 验收报告规范(套件 2) + +本文件定义 `docs/WORKFLOW.md` 第 3 步「两套验证」里**套件 2 · 验收报告**的硬性标准。 + +> 一句话:套件 1 是给机器看的硬证明,套件 2 是给人(CEO)看的报告 —— 让一个**非技术读者一眼判断 feature 到底实现没有**。 + +**参照样板**:[`2026-05-14-hook-moment-block/report.html`](./2026-05-14-hook-moment-block/report.html) —— +人工产出的首个验收报告,本规范即从它提炼。看不懂规范时,直接看样板。 + +--- + +## 硬性要求(缺一不可) + +1. **一个自包含的 HTML 文件**。样式内联,在浏览器里直接双击能打开,不依赖网络、不依赖构建。 +2. **全中文**。面向中文协作团队与非技术决策者。 +3. **嵌入真实屏幕录像 GIF**。必须是**真实录屏**(录一个真实终端/界面窗口),不是程序绘制的动画。 + GIF 与报告放在同一目录,用相对路径引用。 +4. **顶部一眼结论**。读者不往下翻就能知道「实现了没有」—— 一个醒目的结论横幅 / 徽章 + 一句话定性。 +5. **before/after 对比是报告的核心**,且对比必须**严谨成立**: + - 同一个输入,**只改变一个变量**,结果相反; + - 改动前 → 复现「feature 未实现」;改动后 → 复现「feature 已实现」; + - 报告要讲清楚「那个唯一变量是什么」,让读者确信结果差异是改动造成的,不是碰巧。 +6. **原始证据逐字未改**。把真实输出/截图原样放进报告,旁边给出复现方式,任何人能自己跑出一样的结果。 +7. **复现步骤**。给想自己核验的人一份能照着跑的步骤。 +8. **严谨说明:覆盖了什么、没覆盖什么**。**不许浮于表面、不许过度声称**。 + 明确写出本次验证**没**覆盖的链路(说明,不是缺陷),CEO 才能信任「覆盖了的那部分」。 + +## 推荐结构(章节顺序) + +照样板的顺序来,按 feature 复杂度增减: + +1. **头部** —— feature 名 + 日期。 +2. **结论横幅** —— ✅/⚠️/❌ + 一句话定性。这是 CEO 唯一保证会看的部分,必须自洽。 +3. **这个功能是什么** —— 1–2 句非技术语言。不要术语。 +4. **真实屏幕录像** —— 嵌入 GIF,配一句说明(强调「真实录屏」)。 +5. **之前 vs 之后** —— 两张关键静态图并排 + 文字解读。这是「一眼看懂」的主体。 +6. **凭什么说实现了 —— 验收标准** —— 把 before/after 为何成立讲透;逐条对照 WORKFLOW.md 的 review 标准。 +7. **原始证据** —— 逐字未改的真实输出,等宽字体呈现。 +8. **怎么复现** —— 编号步骤。 +9. **严谨说明** —— 覆盖 / 未覆盖,两栏对照。 +10. **页脚** —— 生成日期、被验功能、运行环境、录像方式等元信息。 + +## 目录布局约定 + +每份验收报告一个独立目录,放在 `docs/acceptance/` 下: + +``` +docs/acceptance/ + SPEC.md ← 本规范 + -/ + report.html ← 验收报告本体 + demo.gif ← 真实屏幕录像 + assets/ + still-before.png ← before 关键帧 + still-after.png ← after 关键帧 + evidence-before.txt ← before 原始输出(逐字) + evidence-after.txt ← after 原始输出(逐字) + recording/ + *.ps1 / *.sh ← 录制脚本(可复现) + README.md ← 怎么重跑这段录制 +``` + +## 与套件 1 的关系 + +- **套件 1**(复现验证代码)是 feature 的硬证明:可自动跑、内置 before/after 对比、跑的过程**自带录一段 GIF**。 +- **套件 2**(本规范)= 把套件 1 跑出来的 GIF + 截图 + 结论,拼成这份给人看的 HTML 报告。 +- 两者不是二选一,是一前一后:套件 1 跑过 → 产出 GIF/截图 → 套件 2 把它包装成 CEO 能读的报告。 +- 套件 1 的 judge harness 见 `docs/PLAN-RESEARCH-REPORT.md`。 + +## 实现状态 + +- ✅ **规范 + 参照样板**:本文件 + `2026-05-14-hook-moment-block/`(人工产出)。 +- ⚠️ **自动化生成器待搭**:把「套件 1 跑完 → 自动录 GIF → 自动套用本规范生成 HTML」做成工具, + 是验证框架的一部分,见 `docs/plans/2026-05-14-verification-tooling.md`。在生成器就绪前, + 验收报告按本规范**人工产出**,以样板为模板。 + +## 取代了什么 + +本规范取代旧的 `docs/VISUAL-PROOF-FORMAT.md` / `VISUAL-PROOF-CONTENT.md` / `VISUAL-PROOF-PR.md` +那一套 —— 旧文档面向 dev、英文、绑死在 FIXEDFLOW、用 gist 托管,与 WORKFLOW.md +「GIF + 中文 + CEO 能读的 HTML」的设定不符。旧文档的拆除属于工作流落地的「拆旧」子系统, +在本规范与生成器都就绪后进行;在此之前旧文档保留但**不再是权威**。 diff --git a/docs/adr/0017-six-hour-release.md b/docs/adr/0017-six-hour-release.md new file mode 100644 index 0000000..da55f75 --- /dev/null +++ b/docs/adr/0017-six-hour-release.md @@ -0,0 +1,39 @@ +# ADR-0017: Release cadence — push + 6h schedule + manual dispatch + +**Status:** Accepted (2026-05-15) +**Context:** WORKFLOW.md 第 8 步要求「主分支代码,每 6 小时自动打一个 release 版本」。原 `release-branch.yml` 仅在 push 到 main 时触发,缺定时打卡通道。 + +## 决议 + +`release-branch.yml` 触发器从单路 push 扩成三路: + +1. `push: branches: [main]` —— 保留(auto-merge 落 commit 后立即发,延迟 ~1 分钟) +2. `schedule: - cron: '0 */6 * * *'` —— 每 UTC 0/6/12/18 点跑一次(覆盖「长时间无 PR 但仍想刷新 release-meta」的场景) +3. `workflow_dispatch` —— 人手补发出口(带 `reason` input) + +加 early-exit 守门(`publish` job 第一步):把 `GITHUB_SHA` 与 gh-pages 上 `latest.json.sha` 比对,相等 → 跳过本 job 后续全部 step(避免 schedule 跑到一半发现没变更还把 latest.json / release branch 重写一遍 / 撞 `gh release create` 已存在)。 + +## 为什么是 6h(而非 1h / 24h) + +- **1h**:对绝大多数变化无意义 —— 主分支大部分时间没有合并;cron 频率高 = CI 配额浪费、release page 噪音。 +- **24h**:对急 fix 太慢 —— 「我刚 merge 了一个 hotfix,要等到明天才发?」不合理。 +- **6h**:折中。auto-merge 路径正常工作时,push 触发已经覆盖了主路;schedule 兜底「workflow_run 罕见挂掉 / push 触发被吞 / 无 PR 但仍想刷 release-meta」三类边缘情况。一天 4 次发布,既够新鲜又不噪音。 + +## 早退守门为什么必要 + +- schedule 必然会跑到「main 自上次 release 之后无变更」的窗口。无守门则: + - `gh release create` 因 tag 已存在 idempotent skip(已有逻辑) + - 但后续仍 force-push release 分支 + 重写 latest.json + push gh-pages —— 这些是真改动,会让所有下游 consumer 把同一 SHA 重新拉一次。浪费。 +- 守门把 `GITHUB_SHA` vs `latest.json.sha` 一比即知,~1 秒成本。 + +## 备选方案与理由 + +- (备选) **删除 push 触发,只留 schedule** —— 实现最小,但 hotfix 延迟最坏 6h。否决:与 auto-merge 的「中间无人工关卡」哲学冲突。 +- (备选) **每个 6h 节拍都先 bump version** —— 强行制造变更。否决:会污染版本号语义。 +- (备选) **package.json bump 由 release workflow 自己做** —— 跨 workflow_run 改源文件,鸡生蛋。否决。 + +## 关联 + +- WORKFLOW.md 第 8 步「每 6 小时自动打一个 release」 +- `docs/plans/2026-05-15-six-hour-release.md` —— 实施 plan +- `.github/workflows/release-branch.yml` —— 改造目标 diff --git a/docs/plans/2026-05-14-verification-tooling.md b/docs/plans/2026-05-14-verification-tooling.md new file mode 100644 index 0000000..a56ce20 --- /dev/null +++ b/docs/plans/2026-05-14-verification-tooling.md @@ -0,0 +1,454 @@ +# 验证框架(套件1 + GIF 录制 + 套件2 生成器)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `docs/WORKFLOW.md` 第 3 步「两套验证」从概念落地成可执行工具 —— 任何 feature 都能跑出 before/after 硬证明、自动录真实屏幕 GIF、并生成一份 `docs/acceptance/SPEC.md` 规范的中文 HTML 验收报告。 + +**Architecture:** 沿用本仓库 subsystem #1(认领锁)的 `scripts/` + Functional Core / Imperative Shell 模式 —— 纯逻辑放 `scripts/verify/*-core.ts`(`node:test` 单测钉死),IO/编排放 `scripts/verify/*.ts`。GIF 录制的 Win32 窗口管理沿用已验证可用的 PowerShell(`docs/acceptance/2026-05-14-hook-moment-block/recording/` 是参照原型),由 TS 外壳参数化调用。HTML 生成器把人工样板 `report.html` 模板化:输入一份 manifest JSON,输出符合 `SPEC.md` 的自包含 HTML。 + +**Tech Stack:** TypeScript + `tsx`、`node:test`(node 22 内置)、ffmpeg(`gdigrab` 真实录屏 + 调色板转 GIF)、Windows PowerShell 5.1(Win32 窗口管理,需 UTF-8 BOM)。 + +--- + +## Scope Check —— 三部分就绪度不同,本计划只详写两部分 + +| 组件 | 就绪度 | 本计划处理方式 | +|---|---|---| +| **GIF 录制器** | 有可用原型(`recording/record.ps1` + `demo-scene.ps1` 已跑通真实录屏) | **Phase 1,详细 bite-sized** | +| **套件2 HTML 生成器** | 有可用样板(`report.html` + `SPEC.md` 已定规范) | **Phase 2,详细 bite-sized** | +| **套件1 复现验证代码框架** | 无原型,与 `fixtures/scenarios/`(`Scenario` 类型)、`packages/benchmark/`(runner/evaluator)既有机制纠缠 | **Phase 3,粗粒度大纲 + 建议单独 brainstorm**(见 Phase 3 开头) | + +理由:writing-plans 的硬规矩是「No Placeholders / 每步给完整代码」。Phase 1/2 有真实原型可扎根,能写实;Phase 3 硬写会全是猜测与占位符 —— 它需要先做一轮 brainstorm,把「复现验证代码」与既有 `Scenario` / benchmark machinery 的边界定清楚,再单独出 plan。 + +--- + +## File Structure + +``` +scripts/verify/ + gif-core.ts ← 纯逻辑:ffmpeg 命令构造、录制配置校验、捕获矩形计算。无 IO。 + gif-core.test.ts ← node:test 单测,覆盖 gif-core 全部纯函数。 + record-gif.ts ← Imperative Shell:接收 RecordConfig,调 PowerShell 窗口管理 + ffmpeg,产出 mp4 + GIF + 检查帧。 + win-record.ps1 ← Win32 窗口管理(EnumWindows/SetWindowPos/PostMessage)+ ffmpeg gdigrab。由 record-gif.ts 参数化调用。 + report-core.ts ← 纯逻辑:把 ReportManifest 渲染成 SPEC.md 规范的 HTML 字符串。无 IO。 + report-core.test.ts ← node:test 单测,覆盖 report-core 渲染与校验。 + gen-report.ts ← Imperative Shell:读 manifest.json + 资产文件,调 report-core,写 report.html 到 docs/acceptance//。 + report-template.ts ← HTML 模板与内联 CSS 常量(从人工样板 report.html 提炼)。 +``` + +约定:与 subsystem #1 一致 —— `scripts/` 下不进 pnpm workspace,用 `tsx` 跑、`node:test` 测;`*-core.ts` 严禁 import `fs`/`child_process`。 + +--- + +## Phase 1 —— GIF 录制器 + +把 `docs/acceptance/2026-05-14-hook-moment-block/recording/` 的原型脚本,变成「给一份配置就能录任意终端 demo」的参数化工具。 + +### Task 1: gif-core —— ffmpeg 命令构造(纯逻辑) + +**Files:** +- Create: `scripts/verify/gif-core.ts` +- Test: `scripts/verify/gif-core.test.ts` + +- [ ] **Step 1: 写失败测试** + +```ts +// scripts/verify/gif-core.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildGdigrabArgs, buildGifPaletteArgs, computeCaptureRect } from "./gif-core.ts"; + +test("buildGdigrabArgs 生成固定区域录屏参数", () => { + const args = buildGdigrabArgs({ x: 8, y: 0, w: 1096, h: 730, durationSec: 40, outPath: "C:/t/demo.mp4" }); + assert.deepEqual(args, [ + "-hide_banner", "-loglevel", "warning", "-stats", + "-f", "gdigrab", "-framerate", "12", + "-offset_x", "8", "-offset_y", "0", "-video_size", "1096x730", + "-i", "desktop", "-t", "40", "-pix_fmt", "yuv420p", "-y", "C:/t/demo.mp4", + ]); +}); + +test("computeCaptureRect 把窗口矩形内移 8px 避开隐形边框", () => { + // 窗口贴屏幕左上角 (0,0,1120,760) → 捕获区内移 8px、整体缩 16px 宽 + assert.deepEqual(computeCaptureRect({ winX: 0, winY: 0, winW: 1120, winH: 760, inset: 8 }), + { x: 8, y: 0, w: 1096, h: 744 }); +}); + +test("buildGifPaletteArgs 生成两遍调色板命令对", () => { + const { palettegen, paletteuse } = buildGifPaletteArgs({ mp4: "C:/t/demo.mp4", gif: "C:/t/demo.gif", palette: "C:/t/pal.png", fps: 11, width: 900 }); + assert.ok(palettegen.includes("palettegen=stats_mode=diff")); + assert.ok(palettegen.includes("-update")); // ffmpeg 8 写单图需 -update 1 + assert.ok(paletteuse.includes("paletteuse=dither=bayer:bayer_scale=3")); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npx tsx --test scripts/verify/gif-core.test.ts` +Expected: FAIL —— `Cannot find module './gif-core.ts'` + +- [ ] **Step 3: 写最小实现** + +```ts +// scripts/verify/gif-core.ts +export interface GdigrabOpts { x: number; y: number; w: number; h: number; durationSec: number; outPath: string; } +export function buildGdigrabArgs(o: GdigrabOpts): string[] { + return [ + "-hide_banner", "-loglevel", "warning", "-stats", + "-f", "gdigrab", "-framerate", "12", + "-offset_x", String(o.x), "-offset_y", String(o.y), "-video_size", `${o.w}x${o.h}`, + "-i", "desktop", "-t", String(o.durationSec), "-pix_fmt", "yuv420p", "-y", o.outPath, + ]; +} + +export interface WinRect { winX: number; winY: number; winW: number; winH: number; inset: number; } +export function computeCaptureRect(r: WinRect): { x: number; y: number; w: number; h: number } { + return { x: r.winX + r.inset, y: r.winY, w: r.winW - r.inset * 2, h: r.winH - r.inset * 2 }; +} + +export interface PaletteOpts { mp4: string; gif: string; palette: string; fps: number; width: number; } +export function buildGifPaletteArgs(o: PaletteOpts): { palettegen: string[]; paletteuse: string[] } { + const scale = `fps=${o.fps},scale=${o.width}:-1:flags=lanczos`; + return { + palettegen: ["-hide_banner", "-loglevel", "error", "-i", o.mp4, "-vf", `${scale},palettegen=stats_mode=diff`, "-update", "1", "-y", o.palette], + paletteuse: ["-hide_banner", "-loglevel", "error", "-i", o.mp4, "-i", o.palette, "-lavfi", `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`, "-y", o.gif], + }; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npx tsx --test scripts/verify/gif-core.test.ts` +Expected: PASS (3/3) + +- [ ] **Step 5: commit** + +```bash +git add scripts/verify/gif-core.ts scripts/verify/gif-core.test.ts +git commit -m "feat(verify): add gif-core — ffmpeg arg construction for GIF recording" +``` + +### Task 2: win-record.ps1 —— 参数化的 Win32 录制脚本 + +**Files:** +- Create: `scripts/verify/win-record.ps1`(以 `docs/acceptance/2026-05-14-hook-moment-block/recording/record.ps1` 为蓝本) + +- [ ] **Step 1: 从参照原型派生,改成接收参数** + +把样板 `record.ps1` 里写死的 `$winX/$winY/...`、场景脚本路径、`-t` 时长、捕获矩形、输出路径,全部改成脚本 `param()`: + +```powershell +# scripts/verify/win-record.ps1 +param( + [Parameter(Mandatory)][string]$SceneScript, # 在被录窗口里跑的 .ps1 + [Parameter(Mandatory)][string]$WindowTitle, # 场景脚本设置的窗口标题,用于 EnumWindows 精确匹配 + [Parameter(Mandatory)][string]$OutMp4, + [int]$WinX = 0, [int]$WinY = 0, [int]$WinW = 1120, [int]$WinH = 760, + [int]$CapX = 8, [int]$CapY = 0, [int]$CapW = 1096, [int]$CapH = 730, + [int]$DurationSec = 40 +) +``` + +其余照搬样板:`Add-Type` 的 `Win` 类(`EnumWindows`/`SetWindowPos`/`ShowWindow`/`PostMessage` + `FindByTitle`/`CountByTitle`/`CloseAllByTitle`)、录制前后 `CloseAllByTitle` 清僵尸窗口、`SetWindowPos` 置顶、`ffmpeg gdigrab` 录制、`PostMessage(WM_CLOSE)` 精确关闭。**绝不 `Stop-Process` Windows Terminal 进程**(注释里写明原因 —— 它同时托管其它终端窗口)。 + +- [ ] **Step 2: 手动冒烟** + +```powershell +# 用样板场景脚本验证参数化版能跑通 +& scripts/verify/win-record.ps1 -SceneScript "docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1" ` + -WindowTitle "TADEMOREC" -OutMp4 "$env:TEMP/verify-smoke.mp4" -DurationSec 40 +``` +Expected: 退出码 0,`$env:TEMP/verify-smoke.mp4` 存在且 > 100KB,运行中无残留 TADEMOREC 窗口。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/win-record.ps1 +git commit -m "feat(verify): add win-record.ps1 — parameterized Win32 + gdigrab recorder" +``` + +### Task 3: record-gif.ts —— 录制编排外壳 + +**Files:** +- Create: `scripts/verify/record-gif.ts` + +- [ ] **Step 1: 实现 Imperative Shell** + +```ts +// scripts/verify/record-gif.ts +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { buildGifPaletteArgs } from "./gif-core.ts"; + +export interface RecordConfig { + sceneScript: string; // 被录窗口里跑的 .ps1(需自行设置窗口标题为 windowTitle) + windowTitle: string; + outDir: string; // mp4 / gif / 检查帧的输出目录 + durationSec: number; + gifFps?: number; // 默认 11 + gifWidth?: number; // 默认 900 +} + +export function recordGif(cfg: RecordConfig): { mp4: string; gif: string } { + const mp4 = path.join(cfg.outDir, "demo.mp4"); + const gif = path.join(cfg.outDir, "demo.gif"); + const palette = path.join(cfg.outDir, "palette.png"); + // 1. 录 mp4(PowerShell 窗口管理 + gdigrab) + const ps = spawnSync("powershell", ["-NoProfile", "-File", + path.join(import.meta.dirname, "win-record.ps1"), + "-SceneScript", cfg.sceneScript, "-WindowTitle", cfg.windowTitle, + "-OutMp4", mp4, "-DurationSec", String(cfg.durationSec)], + { stdio: "inherit", windowsHide: true }); + if (ps.status !== 0 || !existsSync(mp4)) throw new Error(`录制失败,退出码 ${ps.status}`); + // 2. mp4 → GIF(两遍调色板) + const { palettegen, paletteuse } = buildGifPaletteArgs({ mp4, gif, palette, fps: cfg.gifFps ?? 11, width: cfg.gifWidth ?? 900 }); + if (spawnSync("ffmpeg", palettegen, { windowsHide: true }).status !== 0) throw new Error("palettegen 失败"); + if (spawnSync("ffmpeg", paletteuse, { windowsHide: true }).status !== 0) throw new Error("paletteuse 失败"); + return { mp4, gif }; +} +``` + +- [ ] **Step 2: 冒烟验证** + +```bash +npx tsx -e "import {recordGif} from './scripts/verify/record-gif.ts'; console.log(recordGif({sceneScript:'docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ts'.replace('.ts','.ps1'), windowTitle:'TADEMOREC', outDir:process.env.TEMP, durationSec:40}))" +``` +Expected: 打印 `{ mp4: ..., gif: ... }`,两个文件都存在。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/record-gif.ts +git commit -m "feat(verify): add record-gif.ts — orchestration shell over win-record + ffmpeg" +``` + +--- + +## Phase 2 —— 套件2 HTML 报告生成器 + +把人工样板 `docs/acceptance/2026-05-14-hook-moment-block/report.html` 模板化:输入结构化数据,输出符合 `docs/acceptance/SPEC.md` 的自包含 HTML。 + +### Task 4: report-core —— manifest → HTML 渲染(纯逻辑) + +**Files:** +- Create: `scripts/verify/report-core.ts` +- Create: `scripts/verify/report-template.ts` +- Test: `scripts/verify/report-core.test.ts` + +- [ ] **Step 1: 定义 ReportManifest 类型 + 写失败测试** + +```ts +// scripts/verify/report-core.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderReport, validateManifest } from "./report-core.ts"; +import type { ReportManifest } from "./report-core.ts"; + +const sample: ReportManifest = { + feature: "PreToolUse hook 拦截重复犯的错", + date: "2026-05-14", + verdict: { status: "pass", line: "已实现,并通过验证" }, + whatItIs: "把团队踩过的坑记下来,等 AI 又要踩同一个坑时拦住它。", + gifPath: "demo.gif", + before: { tag: "之前 · 知识库是空的", still: "assets/still-before.png", text: "命令被放行。", evidence: "▸ 决策: 通过 (无规则命中)" }, + after: { tag: "之后 · 经验已进知识库", still: "assets/still-after.png", text: "同样的命令被拦下。", evidence: "▸ 决策: deny" }, + criteria: [{ ok: true, title: "同输入单变量结果相反", body: "..." }], + reproSteps: ["跑空知识库 demo hook", "init 加载 pack", "再跑一遍"], + coverage: { covered: ["真实知识库 + 真实匹配引擎"], notCovered: ["编辑器内端到端"] }, + footer: { env: "Windows 11 · Node 22", recordMethod: "ffmpeg gdigrab 真实录屏" }, +}; + +test("validateManifest 接受合法 manifest", () => { + assert.deepEqual(validateManifest(sample), { ok: true, errors: [] }); +}); + +test("validateManifest 拒绝缺 before/after 的 manifest", () => { + const bad = { ...sample, after: undefined } as unknown as ReportManifest; + const r = validateManifest(bad); + assert.equal(r.ok, false); + assert.ok(r.errors.some((e) => e.includes("after"))); +}); + +test("renderReport 产出自包含 HTML,含结论横幅与 before/after", () => { + const html = renderReport(sample); + assert.ok(html.startsWith("")); + assert.ok(html.includes("已实现,并通过验证")); + assert.ok(html.includes('src="demo.gif"')); + assert.ok(html.includes('src="assets/still-before.png"')); + assert.ok(html.includes('src="assets/still-after.png"')); + assert.ok(!html.includes("http://") && !html.includes("https://")); // 自包含,无外部依赖 +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npx tsx --test scripts/verify/report-core.test.ts` +Expected: FAIL —— `Cannot find module './report-core.ts'` + +- [ ] **Step 3: 实现 report-template.ts(从样板提炼)** + +把 `docs/acceptance/2026-05-14-hook-moment-block/report.html` 的 `\n\n\n${body}\n\n\n`; +} +export function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} +``` + +- [ ] **Step 4: 实现 report-core.ts** + +```ts +// scripts/verify/report-core.ts +import { htmlShell, esc } from "./report-template.ts"; + +export interface ReportManifest { + feature: string; date: string; + verdict: { status: "pass" | "warn" | "fail"; line: string }; + whatItIs: string; + gifPath: string; + before: { tag: string; still: string; text: string; evidence: string }; + after: { tag: string; still: string; text: string; evidence: string }; + criteria: Array<{ ok: boolean; title: string; body: string }>; + reproSteps: string[]; + coverage: { covered: string[]; notCovered: string[] }; + footer: { env: string; recordMethod: string }; +} + +export function validateManifest(m: ReportManifest): { ok: boolean; errors: string[] } { + const errors: string[] = []; + for (const k of ["feature", "date", "whatItIs", "gifPath"] as const) + if (!m?.[k]) errors.push(`缺字段: ${k}`); + if (!m?.verdict?.line) errors.push("缺字段: verdict.line"); + if (!m?.before?.still || !m?.before?.evidence) errors.push("缺字段: before(still/evidence)"); + if (!m?.after?.still || !m?.after?.evidence) errors.push("缺字段: after(still/evidence)"); + if (!m?.criteria?.length) errors.push("criteria 不能为空 —— 验收标准是报告核心"); + if (!m?.coverage?.notCovered?.length) errors.push("coverage.notCovered 不能为空 —— SPEC 要求写明未覆盖项"); + return { ok: errors.length === 0, errors }; +} + +export function renderReport(m: ReportManifest): string { + const v = validateManifest(m); + if (!v.ok) throw new Error(`manifest 不合法: ${v.errors.join("; ")}`); + const badge = m.verdict.status === "pass" ? "✅" : m.verdict.status === "warn" ? "⚠️" : "❌"; + const body = [ + `
TEAMAGENT · 验收报告

${esc(m.feature)}

${esc(m.date)}
`, + `
`, + `
${badge}

${esc(m.verdict.line)}

`, + `

这个功能是什么

${esc(m.whatItIs)}

`, + `

真实屏幕录像

真实屏幕录像
`, + renderBeforeAfter(m), + renderCriteria(m), + renderEvidence(m), + renderRepro(m), + renderCoverage(m), + renderFooter(m), + `
`, + ].join("\n"); + return htmlShell(`验收报告 · ${m.feature}`, body); +} + +// renderBeforeAfter / renderCriteria / renderEvidence / renderRepro / renderCoverage / renderFooter: +// 各自把 manifest 对应字段套进样板 report.html 里对应 section 的 HTML 骨架。 +// 实现时逐 section 对照 report.html 原文,字段用 esc() 转义。 +``` + +> 实现者注意:`renderBeforeAfter` 等 6 个小函数,逐一对照 `docs/acceptance/2026-05-14-hook-moment-block/report.html` 里对应 `
` 的真实 HTML 结构来写,字段值一律 `esc()`。每个函数配 1 条 `node:test`(断言关键字段出现在输出里)。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `npx tsx --test scripts/verify/report-core.test.ts` +Expected: PASS + +- [ ] **Step 6: commit** + +```bash +git add scripts/verify/report-core.ts scripts/verify/report-template.ts scripts/verify/report-core.test.ts +git commit -m "feat(verify): add report-core — ReportManifest → SPEC-compliant HTML" +``` + +### Task 5: gen-report.ts —— 报告生成外壳 + +**Files:** +- Create: `scripts/verify/gen-report.ts` + +- [ ] **Step 1: 实现 Imperative Shell** + +```ts +// scripts/verify/gen-report.ts +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { renderReport } from "./report-core.ts"; +import type { ReportManifest } from "./report-core.ts"; + +// 用法: npx tsx scripts/verify/gen-report.ts +// 内需有 manifest.json + demo.gif + assets/*;产出 /report.html +export function genReport(reportDir: string): string { + const manifestPath = path.join(reportDir, "manifest.json"); + if (!existsSync(manifestPath)) throw new Error(`缺 manifest.json: ${manifestPath}`); + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ReportManifest; + // 校验引用的资产文件确实存在(SPEC 要求自包含、可核验) + for (const rel of [manifest.gifPath, manifest.before.still, manifest.after.still]) { + if (!existsSync(path.join(reportDir, rel))) throw new Error(`manifest 引用的资产不存在: ${rel}`); + } + const html = renderReport(manifest); + const out = path.join(reportDir, "report.html"); + writeFileSync(out, html, "utf-8"); + return out; +} + +const dir = process.argv[2]; +if (dir) console.log("生成:", genReport(dir)); +``` + +- [ ] **Step 2: 端到端验证 —— 重新生成人工样板** + +把人工样板的数据写成 `manifest.json` 放进样板目录,跑 `npx tsx scripts/verify/gen-report.ts docs/acceptance/2026-05-14-hook-moment-block`,确认生成的 `report.html` 与人工版**结构等价**(用 Edge headless 截图比对:`msedge --headless=new --screenshot=...`)。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/gen-report.ts docs/acceptance/2026-05-14-hook-moment-block/manifest.json +git commit -m "feat(verify): add gen-report.ts — reportDir → report.html, regenerates the reference sample" +``` + +--- + +## Phase 3 —— 套件1 复现验证代码框架(粗粒度,建议单独 brainstorm) + +> **本 Phase 不是 bite-sized 计划。** 套件1(「能完整复现 feature、内置 before/after、跑的过程自带录 GIF」的代码框架)没有原型,且与 `fixtures/scenarios/`(`Scenario` 类型:`phaseA/phaseB/phaseC`)、`packages/benchmark/`(`runner.ts`/`evaluator.ts`)既有机制边界不清。硬写 bite-sized 步骤会全是猜测。 +> +> **建议**:把 Phase 3 单独拉一轮 `superpowers:brainstorming`,先定清楚这几个问题,再出独立 plan: + +需要先 brainstorm 定夺的设计问题: +1. **套件1 与既有 `Scenario` 的关系** —— `fixtures/scenarios/moment-dayjs.ts` 已有 `phaseA(纠正)/phaseB(提炼规则)/phaseC(拦截)` 三段结构。套件1 是复用 `Scenario` 当输入,还是另起一套? +2. **before/after 的「改动前代码」怎么拿** —— WORKFLOW.md 要求「退回改动前的代码跑套件1」。是靠 git stash / worktree 切换,还是靠环境变量切换(像本次样板用 `USERPROFILE` 切知识库状态那样)?不同 feature 形态不同,需要一个统一抽象。 +3. **套件1 如何「自带录 GIF」** —— 套件1 是 TS 测试代码,GIF 录的是终端窗口。两者怎么衔接?是套件1 跑完后调 Phase 1 的 `recordGif()`,用一个「重放脚本」把套件1 的关键步骤在可见终端里再演一遍(像本次 `demo-scene.ps1`)? +4. **judge 由谁做** —— `docs/PLAN-RESEARCH-REPORT.md` 强调第三方 judge harness、禁止自评。套件1 的 pass/fail 判定接到哪。 + +粗粒度产出物(留待独立 plan 细化): +- `scripts/verify/repro-core.ts` —— 纯逻辑:before/after 结果对比、判定「对比是否成立」。 +- `scripts/verify/repro-verify.ts` —— Imperative Shell:跑某 feature 的复现验证,产出结构化结果 + 触发 Phase 1 录 GIF。 +- 一份「复现验证代码怎么写」的约定文档(类似 `SPEC.md` 之于套件2)。 + +--- + +## Self-Review + +**1. Spec coverage:** `WORKFLOW.md` 两套验证 + `docs/acceptance/SPEC.md` 要求 —— Phase 1 覆盖「自带录真实 GIF」;Phase 2 覆盖「套件2 HTML 生成器」全部硬性要求(自包含、中文、嵌 GIF、结论横幅、before/after、原始证据、复现、严谨说明 —— `validateManifest` 逐条钉);Phase 3 覆盖套件1 但明确标为需先 brainstorm。**已知缺口**:套件1 未细化(有意为之,见 Scope Check)。 + +**2. Placeholder scan:** Phase 1/2 每个 code step 给了完整可跑代码;`renderBeforeAfter` 等 6 个 section 渲染函数给了明确实现指引(对照样板 HTML 逐 section)而非空话 —— 这是为控制计划篇幅的有意取舍,实现者有样板可依。Phase 3 明确声明为粗粒度,不算占位符违规。 + +**3. Type consistency:** `ReportManifest` 在 Task 4 定义,Task 5 `gen-report.ts` 引用一致;`RecordConfig`(Task 3)、`GdigrabOpts`/`WinRect`/`PaletteOpts`(Task 1)各自自洽;`buildGifPaletteArgs` 在 Task 1 定义、Task 3 引用,签名一致。 + +## Execution Handoff + +本计划为夜间自动执行产出,留给用户晨间 review。建议执行路径: +1. **先 review 本计划 + 人工样板**(`docs/acceptance/2026-05-14-hook-moment-block/report.html`)—— 确认套件2 的形态符合预期。 +2. Phase 1 + 2 可直接按 bite-sized 步骤执行(subagent-driven 或 inline 均可)。 +3. Phase 3 执行前,先单独 brainstorm。 diff --git a/docs/plans/2026-05-15-INDEX.md b/docs/plans/2026-05-15-INDEX.md new file mode 100644 index 0000000..efdd50d --- /dev/null +++ b/docs/plans/2026-05-15-INDEX.md @@ -0,0 +1,138 @@ +# 通用化实施总入口 · 2026-05-15 + +> **一句话**:把 `docs/WORKFLOW.md`「实现状态」表里 ❌/⚠️ 的 5 个子系统,逐个落成 bite-sized TDD plan。 +> **可认领**:每份 plan 独立,可被不同 CC 并行 claim(用 `lock:exec` label)。 + +--- + +## 文件总目录 + +| # | 文件 | 性质 | 行数 | 对应 WORKFLOW.md「实现状态」表的项 | +|---|---|---|---|---| +| 0 | [`2026-05-15-suite-1-brainstorm.md`](./2026-05-15-suite-1-brainstorm.md) | Brainstorm 笔记 | 202 | 套件 1 复现验证代码框架 | +| 1 | [`2026-05-15-suite-1-repro-framework.md`](./2026-05-15-suite-1-repro-framework.md) | Plan(bite-sized TDD) | 902 | 套件 1 复现验证代码框架 ⚠️→✅ | +| 2 | [`2026-05-15-local-review-loop.md`](./2026-05-15-local-review-loop.md) | Plan(bite-sized TDD) | 711 | 本地 review(独立 subagent + 打回循环)⚠️→✅ | +| 3 | [`2026-05-15-remote-review-bot.md`](./2026-05-15-remote-review-bot.md) | Plan(bite-sized TDD) | 453 | 远程 CC 自动评审 ❌→✅ | +| 4 | [`2026-05-15-auto-merge.md`](./2026-05-15-auto-merge.md) | Plan(bite-sized TDD) | 355 | 个人开发分支 → 主分支 自动合 ❌→✅ | +| 5 | [`2026-05-15-six-hour-release.md`](./2026-05-15-six-hour-release.md) | Plan(small) | 191 | 每 6 小时 release ⚠️→✅ | + +总计:6 份新文档 + 1 份本 INDEX,~2950 行。 + +--- + +## 推荐执行顺序 + +``` + 先: ▢ 0 brainstorm ──────────┐ + ▼ + ▢ 1 suite-1 framework │ + │ │ + ▼ │ + 并行: ▢ 2 local-review-loop │ (Tier-A 验证基础设施) + │ │ + ▼ │ + ▢ 3 remote-review-bot │ + │ │ + ▼ │ + ▢ 4 auto-merge │ (Tier-B 合并自动化) + │ + ▼ + ▢ 5 six-hour-release │ (Tier-C 发版自动化) +``` + +**Tier-A(验证基础设施)必须最先做** —— 后面 review/auto-merge 都依赖它出 verdict。 +**Tier-B(评审 + 合并)中间层** —— 远程评审依赖本地评审的类型,自动合并依赖远程评审的 status。 +**Tier-C(发版)最后** —— 只是 release-branch.yml 的小幅改造,与前面解耦,什么时候上都行。 + +### 依赖关系明细 + +| Plan | 强依赖 | 弱依赖(用过会更顺) | +|---|---|---| +| 1 suite-1 | 已有的 `verification-tooling.md` Phase 1 (`recordGif`) —— 否则 `--no-gif` 跑骨架 | — | +| 2 local-review-loop | 1 suite-1(读 `repro-result.json`)、verification-tooling Phase 1/2(只读类型) | — | +| 3 remote-review-bot | 2 local-review-loop(复用 `ReviewResult` 类型与 `post-pr-comment` 渲染) | 1 suite-1(否则 fixtures/repro-specs 为空,workflow 跳过 repro job) | +| 4 auto-merge | 3 remote-review-bot(等它的 `review/verdict` commit status) | — | +| 5 six-hour-release | 无强依赖 | 4 auto-merge(就着自动 merge 的 push 顺势触发更顺) | + +### 单 CC 串行 vs 多 CC 并行 + +- **单 CC 串行**:按 0 → 1 → 2 → 3 → 4 → 5 顺序做。每份 plan 独立 commit,不会撞锁。预估实施时间(不含 review):suite-1 ~2 天 / local-review ~1.5 天 / remote-review ~1 天 / auto-merge ~0.5 天 / 6h-release ~2 小时。 +- **多 CC 并行**:Tier-A 完成后,3 + 4 + 5 都可以同时启动,但要走「认领锁」(`docs/CLAIM-LOCK.md`)避免撞:不同 plan 用 `lock:exec` label,issue 标题写「Implement plan 2026-05-15-」。 + +--- + +## WORKFLOW.md「实现状态」表对照(预计落地后) + +| 环节 | 现状 | 本批 plan 落地后 | +|---|---|---| +| `grill-me` 挖方案 | ✅ | ✅(不动) | +| 两套验证的「概念」 | ✅ | ✅(不动) | +| 套件 1 自带录 GIF | ⚠️ | ✅(plan #1) | +| 套件 2 HTML 报告生成 | ⚠️ | ✅(`verification-tooling.md` Phase 2,不在本批,但前置已就绪) | +| 本地 review(独立 subagent + 打回循环) | ⚠️ | ✅(plan #2) | +| 远程 CC 自动评审 | ❌ | ✅(plan #3) | +| 两把锁 + 24h 自动撤锁 | ✅ | ✅(不动) | +| 个人开发分支 → 主分支 自动合 | ❌ | ✅(plan #4,squash 模型下并为一步) | +| 每 6 小时 release | ⚠️ | ✅(plan #5) | + +落地后 `WORKFLOW.md` 的「实现状态」表应全部 ✅。 +**不在本批**:套件 2 HTML 生成器属于 `2026-05-14-verification-tooling.md` 的 Phase 2,昨晚的计划已细化,不重写。 + +--- + +## 跨子系统 follow-up(已知未覆盖项) + +下列在本批 5 份 plan 里**显式未做**,需另起子计划: + +1. **`scripts/review/loop-driver.ts` 的 Agent tool IPC 实现**(plan #2 Task 5): + 现状是 driver 写 `/tmp/fix-pending` + `/tmp/fix-prompt`,期望宿主 CC 的 hook 监听它派 subagent。这个 hook 还没实现 —— 在本批之外属于「执行框架自动化」的事(写一个 Claude Code hook 或 user-level skill)。 + **临时 fallback**:`--fix-mode manual` 把 fix prompt 打印,人手贴给 CC 派出。 +2. **外部 fork PR 评审**(plan #3 安全前提):本批跳过外部 fork。引入需走 `pull_request_target` + 严格 secret 隔离,另一个安全子系统。 +3. **「个人开发分支」长期累积模型**(plan #4 头部说明):本批在 squash-merge 假设下把 6→7 步并为一步。若未来引入长期 user/* 分支,需另起 plan。 + +--- + +## 跨 plan 自审(writing-plans 三项) + +本批 6 份文档作为整体的自审。 + +### 1. Spec coverage + +WORKFLOW.md「实现状态」表 9 项,本批覆盖 5 项 ✅;另 4 项已 ✅(grill-me / 两套验证概念 / 锁 / 一项)或在另一份 plan(verification-tooling Phase 2)。**无遗漏项**。 + +### 2. Placeholder scan + +跨文档 grep: +- `\bTBD\b` / `\bTODO\b` / "implement later" / "fill in details" / "appropriate error handling" / "similar to Task" → **0 命中**。 +- 「占位」一词出现 4 处,均在 plan #1 Task 2/4 的有意占位 + Task 4 显式删除 `sideMatchesMerged` 的描述里 —— 不是 plan placeholder,是显式的两阶段 refactor。 +- plan #2 Task 5 的 `dispatchFixSubagent` 是「IPC 约定接口」(显式说明),不是 placeholder。 + +### 3. Type consistency(跨 plan) + +| 类型 | 定义在 | 被复用在 | 一致? | +|---|---|---|---| +| `ReproSpec` / `ReproResult` / `ResultMatcher` | plan #1 Task 1 | plan #2 Task 3(`checkReproPass` 读 `verdict`/`verdictReason` 字段)、plan #3 Task 3(workflow 调 `repro-cli` 二进制,不直接 import 类型) | ✅ | +| `ReviewResult` / `CriterionResult` / `FixDirective` | plan #2 Task 1 | plan #3 Task 2 `post-pr-comment.ts` import `ReviewResult` 用于渲染评论(注:plan #3 workflow 本身用 shell+jq 拼 review-verdict.json,不调 plan #2 的 `aggregateVerdict` —— 共享面就是一个 type + 一个渲染函数,不是整套核心逻辑) | ✅ | +| `PrSnapshot` / `AutoMergeDecision` | plan #4 Task 1 | (仅 plan #4 内部) | ✅ | +| `LoopOptions` | plan #2 Task 1 | plan #2 内部(Task 3/4/5) | ✅ | + +**潜在跨文件命名风险**:`SideResult.matcherOk` 与 `CriterionResult.ok` 都用 `ok` 字段表「通过」语义 —— 同一概念名,合理。 + +--- + +## 怎么开始 + +**用户**(给出方向): +> 「按 INDEX 推荐顺序,先做 plan #1。」(然后挑一个 CC 或自己开干) + +**CC**(执行): +1. claim 一份 plan(`lock:exec` label + 评论锚点,见 `docs/CLAIM-LOCK.md`) +2. 在新 worktree 起一个 feature 分支 +3. 按 plan 的 bite-sized step 一条一条做(用 `superpowers:subagent-driven-development` 或 `superpowers:executing-plans`) +4. 跑套件 1 + 套件 2 出验收 → 跑本地 review 循环 → push → 远程 review → auto-merge。 + +**渐进打开自动化**:plan #1 完成时 → 你已能跑套件 1;#2 完成 → 本地 review 循环;#3 完成 → 远程 review;#4 完成 → 自动合并;#5 完成 → 定时发版。每完成一项,WORKFLOW.md 的「实现状态」表勾掉一行,直观追踪。 + +--- + +🦆 *Plans 由 Claude Code 在 2026-05-15 上午一次产出,用 `superpowers:writing-plans` skill;brainstorm 笔记 + 5 份 plan 总计 ~2200 行;由 advisor 在动笔前打回过 1 次(原方案是「一份大文档」,advisor 指出违反「一子系统一 plan」的项目习惯,改为本批 6 份)。* diff --git a/docs/plans/2026-05-15-auto-merge.md b/docs/plans/2026-05-15-auto-merge.md new file mode 100644 index 0000000..1bff7c1 --- /dev/null +++ b/docs/plans/2026-05-15-auto-merge.md @@ -0,0 +1,355 @@ +# 自动合并(远程 review 一过 → 主分支)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `docs/WORKFLOW.md` 第 6–7 步「远程 review 一过 → 自动合到主分支(中间无人工关卡)」落成 GitHub Actions workflow。链在 `pr-review.yml` 之后:它出 verdict=pass → 自动 `gh pr merge --squash --delete-branch`(POSTPR.md 钉死的合并方式),除非 PR 含 visual-proof(POSTPR.md 钉死的 human-merge 例外)或 PR 标了 `do-not-merge` label。 + +**Architecture:** workflow_run 链式触发 —— pr-review.yml 成功 → auto-merge.yml 启动。auto-merge.yml 是「门控 + 一条 gh pr merge 命令」的轻 workflow,不重做 verdict 判定(信任 pr-review 的 commit status)。门控逻辑放纯 TypeScript 文件 `scripts/automerge/can-auto-merge.ts`,可单测;workflow 只负责调它 + 跑 gh。 + +**Tech Stack:** GitHub Actions(`workflow_run` 触发器)+ `gh` CLI + 一段轻 TS 门控逻辑。 + +--- + +## 关于 WORKFLOW.md「个人开发分支 → 主分支」两阶段 + +WORKFLOW.md 第 6–7 步写的是: +> 6. 合并到个人开发分支 +> 7. 合并到主分支 +> 这两步之间**没有人工关卡** + +在本仓库的 squash-merge 模型(`POSTPR.md` 钉死)里,**这两阶段在物理上是一次操作**: +`gh pr merge --squash --delete-branch` 把 feature 分支(提交者的「个人开发分支」)上的 N 个 commit 压成 1 个 commit、直接落到 main、删掉 feature 分支。所以本计划只实现一个 workflow,不强行分两步 —— 文档里说明这一对齐即可。 + +(若未来引入「user/ 长期分支累积」模式,本 plan 需另外起一个中间合并环节;目前不需要。) + +--- + +## File Structure + +``` +scripts/automerge/ + can-auto-merge.ts ← 纯逻辑:输入 PR metadata → 输出 {merge: bool, reason: string} + can-auto-merge.test.ts ← node:test 单测 +.github/workflows/ + auto-merge.yml ← workflow_run 链式触发 + 调 can-auto-merge + 跑 gh pr merge +``` + +--- + +## 安全/正确性前提 + +1. **必须先有 `pr-review.yml` 的 commit status 卡住合并**(`2026-05-15-remote-review-bot.md` Task 3 Step 3 已开)。否则 auto-merge 会绕过 verdict 把任何 PR 合掉。 +2. **POSTPR.md 钉死的禁止合并路径**(`docs/VISUAL-PROOF-HUMAN-MERGE.md#forbidden-merge-paths`):若 PR body 含 `## Visual proof of work` 段,agent 不许调 `gh pr merge` —— 由人在 GitHub UI 点。本 plan 显式 skip。 +3. **stacked PR**(POSTPR.md「Squash repo: PRs must base against main」):若 baseRefName ≠ main,直接 skip + 在 PR 评论里报错(stacked PR squash 会丢数据,POSTPR.md 已记录 incident)。 +4. **重入安全**:同一 PR 多次触发 workflow_run → 第二次 `gh pr merge` 会因 PR 已 closed 失败,但不会损坏 main。再加 concurrency group 兜底。 + +## Task 1: `can-auto-merge.ts` —— 纯门控逻辑 + +**Files:** +- Create: `scripts/automerge/can-auto-merge.ts` +- Test: `scripts/automerge/can-auto-merge.test.ts` + +- [ ] **Step 1: 写失败测试(7 条)** + +```ts +// scripts/automerge/can-auto-merge.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { canAutoMerge } from "./can-auto-merge.ts"; +import type { PrSnapshot } from "./can-auto-merge.ts"; + +const ok: PrSnapshot = { + number: 1, baseRefName: "main", isDraft: false, + body: "Feature X", labels: [], state: "OPEN", + mergeable: "MERGEABLE", reviewVerdictState: "success", + isFromInternalRepo: true, +}; + +test("基本通过:全条件 OK → merge=true", () => { + const r = canAutoMerge(ok); + assert.equal(r.merge, true); +}); + +test("draft → skip", () => { + assert.equal(canAutoMerge({ ...ok, isDraft: true }).merge, false); +}); + +test("base 非 main → skip(POSTPR squash 模型禁止 stacked)", () => { + const r = canAutoMerge({ ...ok, baseRefName: "user/dev" }); + assert.equal(r.merge, false); + assert.match(r.reason, /stacked|baseRef/); +}); + +test("PR body 含 ## Visual proof of work → skip(human-merge only)", () => { + const r = canAutoMerge({ ...ok, body: "Feature X\n\n## Visual proof of work\n\n![gif](...)" }); + assert.equal(r.merge, false); + assert.match(r.reason, /visual.proof/i); +}); + +test("有 do-not-merge label → skip", () => { + assert.equal(canAutoMerge({ ...ok, labels: ["do-not-merge"] }).merge, false); +}); + +test("review/verdict status 非 success → skip", () => { + assert.equal(canAutoMerge({ ...ok, reviewVerdictState: "failure" }).merge, false); + assert.equal(canAutoMerge({ ...ok, reviewVerdictState: "pending" }).merge, false); +}); + +test("外部 fork PR → skip(pr-review.yml 也不评审外部 fork,这里兜底)", () => { + assert.equal(canAutoMerge({ ...ok, isFromInternalRepo: false }).merge, false); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npx tsx --test scripts/automerge/can-auto-merge.test.ts` +Expected: FAIL —— `Cannot find module` + +- [ ] **Step 3: 实现** + +```ts +// scripts/automerge/can-auto-merge.ts + +export interface PrSnapshot { + number: number; + baseRefName: string; + isDraft: boolean; + body: string; + labels: string[]; + state: "OPEN" | "CLOSED" | "MERGED"; + mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; + /** GitHub 上 review/verdict 这条 commit status 的状态 */ + reviewVerdictState: "success" | "failure" | "pending" | "missing"; + /** PR head 是否在仓库内(非 fork) */ + isFromInternalRepo: boolean; +} + +export interface AutoMergeDecision { + merge: boolean; + reason: string; // 不论通过与否,都给一个 reason 便于审计 +} + +export function canAutoMerge(pr: PrSnapshot): AutoMergeDecision { + if (pr.state !== "OPEN") return { merge: false, reason: `PR state=${pr.state},非 OPEN,跳过` }; + if (pr.isDraft) return { merge: false, reason: `PR 是 draft,跳过` }; + if (!pr.isFromInternalRepo) return { merge: false, reason: `PR 来自外部 fork,跳过(安全策略)` }; + if (pr.baseRefName !== "main") { + return { merge: false, reason: `baseRefName=${pr.baseRefName} ≠ main,stacked PR 在 squash 模型下会丢数据,跳过(见 POSTPR.md)` }; + } + if (pr.labels.includes("do-not-merge")) return { merge: false, reason: `PR 带 do-not-merge label` }; + if (containsVisualProof(pr.body)) { + return { merge: false, reason: `PR body 含 "## Visual proof of work" → 走 human-merge(VISUAL-PROOF-HUMAN-MERGE.md 钉死)` }; + } + if (pr.reviewVerdictState !== "success") { + return { merge: false, reason: `review/verdict commit status = ${pr.reviewVerdictState},非 success` }; + } + if (pr.mergeable !== "MERGEABLE") { + return { merge: false, reason: `mergeable=${pr.mergeable},等 GitHub 重算或解冲突` }; + } + return { merge: true, reason: `所有门控通过 —— review verdict success + base main + no flags` }; +} + +function containsVisualProof(body: string): boolean { + // POSTPR.md 钉的章节标题 —— 不区分大小写、允许任意空白 + return /^\s*##\s+visual\s+proof\s+of\s+work/im.test(body); +} +``` + +- [ ] **Step 4: 跑测试通过** + +Run: `npx tsx --test scripts/automerge/can-auto-merge.test.ts` +Expected: PASS (7/7) + +- [ ] **Step 5: commit** + +```bash +git add scripts/automerge/can-auto-merge.ts scripts/automerge/can-auto-merge.test.ts +git commit -m "feat(automerge): add can-auto-merge — pure gate logic for auto-squash-merge" +``` + +--- + +## Task 2: `auto-merge.yml` —— workflow_run 链式触发 + gh pr merge + +**Files:** +- Create: `.github/workflows/auto-merge.yml` + +- [ ] **Step 1: 写 workflow** + +```yaml +# .github/workflows/auto-merge.yml +name: Auto-merge (远程 review 通过 → 自动 squash 合主分支) + +on: + workflow_run: + workflows: ["PR Review (远程 CC 自动评审)"] + types: [completed] + +permissions: + contents: write # squash merge 落 commit 到 main + pull-requests: write # 关 PR、删 branch、写 comment + +# 同 PR 重入兜底:同 head SHA 只跑一次 +concurrency: + group: auto-merge-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + evaluate-and-merge: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: { ref: ${{ github.event.workflow_run.head_sha }}, fetch-depth: 0 } + - uses: actions/setup-node@v5 + with: { node-version: '22' } + + - name: Resolve PR number from workflow_run + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + # workflow_run 不直接给 PR 号,要从 head_sha 反查 + PR_NUMBER=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" \ + --state open --json number --jq '.[0].number // empty') + if [[ -z "$PR_NUMBER" ]]; then + echo "::warning::找不到 head_branch=${{ github.event.workflow_run.head_branch }} 的 open PR;退出" + echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "pr=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "找到 PR #$PR_NUMBER" + + - name: Snapshot PR + if: steps.pr.outputs.skip != 'true' + id: snap + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + PR=${{ steps.pr.outputs.pr }} + gh pr view "$PR" --json number,baseRefName,isDraft,body,labels,state,mergeable,headRepository,headRepositoryOwner > /tmp/pr.json + # review/verdict commit status + STATUS=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.workflow_run.head_sha }}/statuses" \ + --jq '[.[] | select(.context=="review/verdict")][0].state // "missing"') + REPO_OWNER='${{ github.repository_owner }}' + jq --arg verdict "$STATUS" --arg owner "$REPO_OWNER" '. + { + reviewVerdictState: $verdict, + isFromInternalRepo: (.headRepositoryOwner.login == $owner), + labels: [.labels[].name] + }' /tmp/pr.json > /tmp/snapshot.json + cat /tmp/snapshot.json + + - name: Decide + if: steps.pr.outputs.skip != 'true' + id: decide + shell: bash + run: | + npx tsx -e " + import {canAutoMerge} from './scripts/automerge/can-auto-merge.ts'; + import {readFileSync, writeFileSync} from 'node:fs'; + const snap = JSON.parse(readFileSync('/tmp/snapshot.json', 'utf-8')); + const r = canAutoMerge(snap); + writeFileSync('/tmp/decision.json', JSON.stringify(r, null, 2)); + console.log(JSON.stringify(r, null, 2)); + process.exit(r.merge ? 0 : 78); + " && echo "merge=true" >> "$GITHUB_OUTPUT" || { + ec=$? + echo "merge=false" >> "$GITHUB_OUTPUT" + if [[ $ec -ne 78 ]]; then exit $ec; fi + } + + - name: Merge + if: steps.decide.outputs.merge == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR=${{ steps.pr.outputs.pr }} + gh pr merge "$PR" --squash --delete-branch + gh pr comment "$PR" --body "🤖 远程 review 通过 → 已自动 squash 合并到 main(\`auto-merge.yml\`)。" + + - name: Skip note + if: steps.decide.outputs.merge == 'false' && steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + PR=${{ steps.pr.outputs.pr }} + REASON=$(jq -r .reason /tmp/decision.json) + gh pr comment "$PR" --body "🤖 auto-merge 跳过 —— 原因: $REASON" + echo "skipped: $REASON" +``` + +- [ ] **Step 2: 端到端验证** + +```bash +# 1) 把 auto-merge.yml + can-auto-merge 推到 main(走正常 PR 流程) +git checkout -b feat/auto-merge +git add scripts/automerge/ .github/workflows/auto-merge.yml +git commit -m "feat(automerge): add can-auto-merge + auto-merge.yml workflow" +git push origin feat/auto-merge +gh pr create --base main --head feat/auto-merge --title "feat(automerge): bring up auto-merge workflow" \ + --body "Bring-up of automatic merge after pr-review.yml verdict=pass." + +# 2) 等 pr-review.yml 跑完(verdict=pass)→ auto-merge.yml 自动接力 → PR 自动合 +gh pr checks --watch # 看着 review/verdict 变 success +gh pr view --json state # 应在 ~30s 内变 MERGED;feat/auto-merge 分支被自动删 +gh pr view --comments | tail -20 # 应看到「🤖 远程 review 通过 → 已自动 squash 合并...」评论 +``` + +Expected: +- pr-review.yml 跑完且 verdict=pass(假设 fixtures/repro-specs/ 里所有 spec 全过) +- 30 秒内 PR 状态变 MERGED +- 本地 `git pull --ff-only origin main` 拉到 squash commit + +如果 PR body 含 `## Visual proof of work` 或带 `do-not-merge` label,decide step 会输出 `merge=false`,workflow 留下「跳过 —— 原因: ...」评论,不合并 —— 这是预期。 + +- [ ] **Step 3: 边界手动测** + +为了确认门控真生效,触发两个负 case: + +```bash +# A. visual-proof skip +git checkout -b test/auto-merge-vp main +echo "noop" > .auto-merge-test +git add . && git commit -m "test: visual-proof skip" +git push origin test/auto-merge-vp +gh pr create --base main --head test/auto-merge-vp --title "test: vp skip" \ + --body "$(printf 'noop\n\n## Visual proof of work\n\n![](.png)\n')" +# 等 pr-review verdict 出来,检查 auto-merge 评论应是「跳过 —— ... visual proof ...」 +gh pr close --delete-branch --comment "test cleanup" + +# B. do-not-merge label skip +git checkout -b test/auto-merge-dnm main +echo "noop" > .auto-merge-test2 && git add . && git commit -m "test: dnm" && git push origin test/auto-merge-dnm +gh pr create --base main --head test/auto-merge-dnm --title "test: dnm" --body "noop" +gh pr edit --add-label do-not-merge +# 同上验证跳过原因 +gh pr close --delete-branch --comment "test cleanup" +``` + +- [ ] **Step 4: commit + 让本计划的 PR 用自己合自己(自洽)** + +```bash +git add .github/workflows/auto-merge.yml scripts/automerge/ +git commit -m "feat(automerge): bring up auto-merge.yml + verify gate via two negative tests" +# push 后让 pr-review + auto-merge 自己把这个 PR 合掉,即可证明端到端通了 +``` + +--- + +## Self-Review + +**1. Spec coverage:** WORKFLOW.md 第 6–7 步:① 远程 review 一过 → 自动合 → workflow_run 链式触发 + reviewVerdictState 守门;② 中间无人工关卡 → 是,gh pr merge 一步到位;③ 落主分支 → squash 一次成型,符合 POSTPR.md。**已知缺口**:WORKFLOW.md 字面分「个人开发分支」与「主分支」两步,本 plan 在 squash-merge 模型下合并为一步并文档说明对齐;若未来要引入长期 user/* 累积分支,需另起子计划。 + +**2. Placeholder scan:** 无 TBD/TODO。所有 yaml step 都给了真命令。`canAutoMerge` 7 个测试覆盖所有分支(无 untested fall-through)。 + +**3. Type consistency:** `PrSnapshot` 字段在 Task 1 一次定义、workflow Snapshot step 用 jq 拼成同名字段、decide step 直接传给 `canAutoMerge` —— 字段名一致(`baseRefName`/`isDraft`/`body`/`labels`/`state`/`mergeable`/`reviewVerdictState`/`isFromInternalRepo`)。 + +## Execution Handoff + +Plan complete. 推荐执行路径: + +1. **前置**:`2026-05-15-remote-review-bot.md` 必须先落地(Task 3 Step 3 也要在 GitHub UI 设好 branch protection)。否则 auto-merge 没有 verdict 可读,会一直 skip。 +2. **执行**:Task 1–2 串行。 +3. **验收**:本 PR 自己被自动合 = end-to-end ok。 diff --git a/docs/plans/2026-05-15-local-review-loop.md b/docs/plans/2026-05-15-local-review-loop.md new file mode 100644 index 0000000..e7722b8 --- /dev/null +++ b/docs/plans/2026-05-15-local-review-loop.md @@ -0,0 +1,717 @@ +# 本地 Review 自动打回循环 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `docs/WORKFLOW.md` 第 4 步「另开独立 subagent 跑 2 条标准的本地 review,不通过打回重做、循环至通过」落成可跑工具 —— 任何执行 CC 在做完 feature 后,跑一条命令就触发 review;不通过则用 Claude Code 的 `Agent` 工具自动派一个执行 subagent 修,然后再 review,直到通过或达上限。 + +**Architecture:** Functional core / Imperative shell —— `review-core.ts` 纯逻辑(把「2 条标准」的 outcome 合成 verdict、判定打回理由分类),严禁 IO;`run-checks.ts` 跑测试 + 检查冲突 + 读 ReproResult 是 IO 层;`loop-driver.ts` 是循环编排,负责派打回 subagent 与判定终止。本计划与 `2026-05-15-suite-1-repro-framework.md` 是消费/被消费关系 —— review 标准 1 直接 cat 套件 1 的 `repro-result.json`,不重复实现 verdict 逻辑。 + +**Tech Stack:** TypeScript + `tsx` + `node:test` + `pnpm test` + `git status` + Claude Code `Agent` tool(由 `loop-driver.ts` 通过 stdin/stdout 间接驱动 —— 见 Task 5 的 fallback 说明)。 + +--- + +## File Structure + +``` +scripts/review/ + review-types.ts ← ReviewVerdict / Criterion / FixDirective 类型 + review-core.ts ← 纯逻辑:汇总 2 条标准 → verdict;失败原因分类 + review-core.test.ts ← node:test 单测 + run-checks.ts ← Imperative shell:跑 pnpm test、git 冲突检查、读 repro-result.json + review-cli.ts ← CLI:跑一次 review,输出 review-verdict.json + 退出码 + loop-driver.ts ← Imperative shell:review → 不过派打回 subagent → 再 review,循环 +.claude/skills/local-review/ + SKILL.md ← 给 review subagent 的角色指令(2 条标准 + 输出格式) + fix-directive-prompt.md ← 给打回 subagent 的「修这个,然后 stop」prompt 模板 +``` + +**Naming:** 与 `scripts/verify/` 平行,放 `scripts/review/`。`scripts/` 整体是「Imperative Shell + Core」的脚本区,与 pnpm workspace 隔离,用 `tsx` 跑、`node:test` 测。 + +--- + +## Task 1: `review-types.ts` —— 类型 + +**Files:** +- Create: `scripts/review/review-types.ts` + +- [ ] **Step 1: 写类型** + +```ts +// scripts/review/review-types.ts + +/** WORKFLOW.md 第 4 步钉死的 2 条标准 */ +export type CriterionId = + | "repro-pass" // 标准 1:套件 1 跑出 verdict = "pass" + | "tests-and-merge-clean"; // 标准 2:pnpm test 全过 + git 工作树干净 + 与 main 无冲突 + +export interface CriterionResult { + id: CriterionId; + ok: boolean; + /** 给人看的简述,1 行 */ + summary: string; + /** 详细信息,可多行;ok=true 时可能为空 */ + details: string; +} + +export type ReviewVerdict = "pass" | "fail"; + +export interface ReviewResult { + generatedAt: string; + verdict: ReviewVerdict; + criteria: CriterionResult[]; + /** verdict=fail 时,给打回 subagent 的结构化指令(给 LLM 作 prompt 用)*/ + fixDirective?: FixDirective; +} + +export interface FixDirective { + /** kebab-case 失败类型,便于 loop-driver 防止「同一类失败连续打回 N 次」 */ + failureKind: "repro-fail" | "repro-ambiguous" | "tests-failing" | "merge-conflict" | "dirty-tree" | "missing-repro-result"; + /** 给打回 subagent 一段可读 prompt,描述「要修什么、修完怎么自检」 */ + prompt: string; +} + +export interface LoopOptions { + /** 最多打回几次后强制停止(默认 3)。防止死循环。 */ + maxRetries: number; + /** 跑 review 的 ReproResult 路径(套件 1 产出) */ + reproResultPath: string; + /** 评测时基于哪个 base ref 比对(默认 main) */ + baseBranch: string; + /** 跑测试用的命令 */ + testCommand: string; + testArgs: string[]; +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `npx tsc --noEmit --target es2022 --module node16 --moduleResolution node16 scripts/review/review-types.ts` +Expected: PASS + +- [ ] **Step 3: commit** + +```bash +git add scripts/review/review-types.ts +git commit -m "feat(review): add review-types — CriterionResult/ReviewVerdict/FixDirective" +``` + +--- + +## Task 2: `review-core.ts` —— 纯逻辑(汇总 verdict + 失败原因分类) + +**Files:** +- Create: `scripts/review/review-core.ts` +- Test: `scripts/review/review-core.test.ts` + +- [ ] **Step 1: 写失败测试(6 条)** + +```ts +// scripts/review/review-core.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { aggregateVerdict, classifyFailure, formatFixPrompt } from "./review-core.ts"; +import type { CriterionResult } from "./review-types.ts"; + +const passRepro: CriterionResult = { id: "repro-pass", ok: true, summary: "verdict=pass", details: "" }; +const failRepro: CriterionResult = { id: "repro-pass", ok: false, summary: "verdict=fail", details: "before 期望未达: stdout 缺关键字 \"deny\"" }; +const ambiguousRepro: CriterionResult = { id: "repro-pass", ok: false, summary: "verdict=ambiguous", details: "before 也命中了 after 的期望" }; +const passTests: CriterionResult = { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }; +const failTests: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "23/100 tests failing", details: "FAIL packages/core/src/foo.test.ts ..." }; +const dirtyTree: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "tests pass; working tree dirty (3 files)", details: " M src/x.ts\n?? note.md" }; + +test("aggregateVerdict: 两条都 ok → pass", () => { + const r = aggregateVerdict([passRepro, passTests]); + assert.equal(r.verdict, "pass"); + assert.equal(r.fixDirective, undefined); +}); + +test("aggregateVerdict: 任一不 ok → fail + 带 fixDirective", () => { + const r = aggregateVerdict([failRepro, passTests]); + assert.equal(r.verdict, "fail"); + assert.ok(r.fixDirective); + assert.equal(r.fixDirective!.failureKind, "repro-fail"); +}); + +test("classifyFailure: repro 各种情况", () => { + assert.equal(classifyFailure(failRepro).failureKind, "repro-fail"); + assert.equal(classifyFailure(ambiguousRepro).failureKind, "repro-ambiguous"); + const missing: CriterionResult = { id: "repro-pass", ok: false, summary: "missing repro-result.json", details: "" }; + assert.equal(classifyFailure(missing).failureKind, "missing-repro-result"); +}); + +test("classifyFailure: tests 失败 vs 工作树脏 vs 冲突", () => { + assert.equal(classifyFailure(failTests).failureKind, "tests-failing"); + assert.equal(classifyFailure(dirtyTree).failureKind, "dirty-tree"); + const conflict: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "merge conflict with main", details: "" }; + assert.equal(classifyFailure(conflict).failureKind, "merge-conflict"); +}); + +test("formatFixPrompt: 包含失败类别 + details + 「修完怎么自检」", () => { + const p = formatFixPrompt({ failureKind: "tests-failing", prompt: "" }, failTests); + assert.match(p, /tests-failing/); + assert.match(p, /23\/100/); + assert.match(p, /pnpm test/); // 修完后该跑啥 +}); + +test("aggregateVerdict 多失败:fixDirective 取「优先级高」的失败 (repro > tests > tree)", () => { + const r = aggregateVerdict([failRepro, failTests]); + assert.equal(r.fixDirective!.failureKind, "repro-fail"); // repro 比 tests 优先 +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npx tsx --test scripts/review/review-core.test.ts` +Expected: FAIL —— `Cannot find module './review-core.ts'` + +- [ ] **Step 3: 实现 `review-core.ts`** + +```ts +// scripts/review/review-core.ts +import type { CriterionResult, FixDirective, ReviewResult } from "./review-types.ts"; + +const FAILURE_PRIORITY: FixDirective["failureKind"][] = [ + "missing-repro-result", + "repro-fail", + "repro-ambiguous", + "merge-conflict", + "tests-failing", + "dirty-tree", +]; + +export function classifyFailure(c: CriterionResult): FixDirective { + if (c.ok) throw new Error("classifyFailure 不应被调用 ok=true 的 criterion"); + if (c.id === "repro-pass") { + if (c.summary.includes("missing")) return { failureKind: "missing-repro-result", prompt: "" }; + if (c.summary.includes("ambiguous")) return { failureKind: "repro-ambiguous", prompt: "" }; + return { failureKind: "repro-fail", prompt: "" }; + } + // c.id === "tests-and-merge-clean" + const s = c.summary.toLowerCase(); + if (s.includes("conflict")) return { failureKind: "merge-conflict", prompt: "" }; + if (s.includes("dirty") || s.includes("untracked")) return { failureKind: "dirty-tree", prompt: "" }; + return { failureKind: "tests-failing", prompt: "" }; +} + +export function aggregateVerdict(criteria: CriterionResult[]): ReviewResult { + const fails = criteria.filter((c) => !c.ok); + if (fails.length === 0) { + return { generatedAt: new Date().toISOString(), verdict: "pass", criteria }; + } + // 取优先级最高的 fail + const classified = fails.map((c) => ({ c, fd: classifyFailure(c) })); + classified.sort((a, b) => FAILURE_PRIORITY.indexOf(a.fd.failureKind) - FAILURE_PRIORITY.indexOf(b.fd.failureKind)); + const top = classified[0]; + return { + generatedAt: new Date().toISOString(), + verdict: "fail", + criteria, + fixDirective: { ...top.fd, prompt: formatFixPrompt(top.fd, top.c) }, + }; +} + +export function formatFixPrompt(fd: FixDirective, c: CriterionResult): string { + const headerByKind: Record = { + "repro-fail": "套件 1 复现验证 verdict=fail —— before/after 对比未严谨成立", + "repro-ambiguous": "套件 1 复现验证 verdict=ambiguous —— 两侧期望可互换命中,对比不严谨", + "missing-repro-result": "找不到 repro-result.json —— 你还没跑套件 1,或 spec 路径错了", + "merge-conflict": "与 main 有合并冲突", + "tests-failing": "全量测试有失败用例", + "dirty-tree": "工作树有未提交改动", + }; + const selfCheckByKind: Record = { + "repro-fail": "重跑 `npx tsx scripts/verify/repro-cli.ts --no-gif`,看 verdict=pass", + "repro-ambiguous": "重审 ReproSpec.expect.before/after 是否真互斥;改完重跑 repro-cli", + "missing-repro-result": "先跑 `npx tsx scripts/verify/repro-cli.ts `", + "merge-conflict": "`git fetch origin && git rebase origin/main`,解冲突后重跑 review", + "tests-failing": "`pnpm test` 退出码 0", + "dirty-tree": "`git status` 干净(无 M / 无 ??),要么提交要么 .gitignore", + }; + return [ + `## 失败类别: ${fd.failureKind}`, + `**问题**:${headerByKind[fd.failureKind]}`, + ``, + `### 详情`, + "```", + c.details || c.summary, + "```", + ``, + `### 修完后怎么自检`, + selfCheckByKind[fd.failureKind], + ``, + `### 边界`, + `- 只动**与本失败类别直接相关**的代码;**不要顺手 refactor**。`, + `- 修好后用 \`echo done > /tmp/fix-marker\` 落一个标记文件,然后 stop。loop-driver 检测到标记会重跑 review。`, + ].join("\n"); +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npx tsx --test scripts/review/review-core.test.ts` +Expected: PASS (6/6) + +- [ ] **Step 5: commit** + +```bash +git add scripts/review/review-core.ts scripts/review/review-core.test.ts +git commit -m "feat(review): add review-core — verdict aggregation + failure classification" +``` + +--- + +## Task 3: `run-checks.ts` —— 标准 1+2 的实际跑法 + +**Files:** +- Create: `scripts/review/run-checks.ts` + +- [ ] **Step 1: 实现 imperative shell** + +```ts +// scripts/review/run-checks.ts +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import type { CriterionResult, LoopOptions } from "./review-types.ts"; + +/** 标准 1:读套件 1 产出的 repro-result.json,要求 verdict = "pass" */ +export function checkReproPass(reproResultPath: string): CriterionResult { + if (!existsSync(reproResultPath)) { + return { + id: "repro-pass", ok: false, + summary: `missing repro-result.json: ${reproResultPath}`, + details: "先跑套件 1 产出该文件,再跑 review。", + }; + } + let parsed: { verdict?: string; verdictReason?: string }; + try { parsed = JSON.parse(readFileSync(reproResultPath, "utf-8")); } + catch (e) { + return { id: "repro-pass", ok: false, summary: `repro-result.json 解析失败`, details: String(e) }; + } + const verdict = parsed.verdict ?? ""; + if (verdict === "pass") { + return { id: "repro-pass", ok: true, summary: `verdict=pass`, details: parsed.verdictReason ?? "" }; + } + return { + id: "repro-pass", ok: false, + summary: `verdict=${verdict}`, + details: parsed.verdictReason ?? `(无 verdictReason)`, + }; +} + +/** 标准 2:测试全过 + 工作树干净 + 与 baseBranch 无冲突 */ +export function checkTestsAndMergeClean(opts: { repoRoot: string; baseBranch: string; testCommand: string; testArgs: string[] }): CriterionResult { + const tests = spawnSync(opts.testCommand, opts.testArgs, { cwd: opts.repoRoot, encoding: "utf-8" }); + if (tests.status !== 0) { + return { + id: "tests-and-merge-clean", ok: false, + summary: `tests failing (exit ${tests.status})`, + details: trimTo(tests.stdout + tests.stderr, 4000), + }; + } + const status = spawnSync("git", ["status", "--porcelain"], { cwd: opts.repoRoot, encoding: "utf-8" }); + const dirtyLines = status.stdout.trim().length > 0 ? status.stdout.trim().split(/\r?\n/) : []; + if (dirtyLines.length > 0) { + return { + id: "tests-and-merge-clean", ok: false, + summary: `tests pass; working tree dirty (${dirtyLines.length} entries)`, + details: dirtyLines.slice(0, 50).join("\n"), + }; + } + // 检查与 baseBranch 是否会冲突:fetch + merge-tree(无副作用) + spawnSync("git", ["fetch", "origin", opts.baseBranch], { cwd: opts.repoRoot, encoding: "utf-8" }); + const mergeTree = spawnSync("git", ["merge-tree", `origin/${opts.baseBranch}`, "HEAD"], + { cwd: opts.repoRoot, encoding: "utf-8" }); + // git merge-tree 输出含 "<<<<<<<" 即有冲突 + if (mergeTree.stdout.includes("<<<<<<<")) { + const conflictFiles = [...mergeTree.stdout.matchAll(/^changed in both[\s\S]*?\n base\s+\S+ \S+ (\S+)$/gm)] + .map((m) => m[1]); + return { + id: "tests-and-merge-clean", ok: false, + summary: `merge conflict with origin/${opts.baseBranch}`, + details: conflictFiles.length ? conflictFiles.join("\n") : trimTo(mergeTree.stdout, 2000), + }; + } + return { id: "tests-and-merge-clean", ok: true, summary: `tests pass + tree clean + no conflict with ${opts.baseBranch}`, details: "" }; +} + +function trimTo(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + `\n…(truncated ${s.length - n} chars)` : s; +} + +/** 跑两条标准,返回 [criterion1, criterion2] */ +export function runAllChecks(opts: { repoRoot: string; loop: LoopOptions }): CriterionResult[] { + const c1 = checkReproPass(opts.loop.reproResultPath); + const c2 = checkTestsAndMergeClean({ + repoRoot: opts.repoRoot, baseBranch: opts.loop.baseBranch, + testCommand: opts.loop.testCommand, testArgs: opts.loop.testArgs, + }); + return [c1, c2]; +} +``` + +- [ ] **Step 2: 冒烟** + +```bash +# 假设当前 worktree 干净 + tests 全过 + 套件 1 已跑过 +npx tsx -e " +import { runAllChecks } from './scripts/review/run-checks.ts'; +const cs = runAllChecks({ + repoRoot: process.cwd(), + loop: { + maxRetries: 3, + reproResultPath: 'docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json', + baseBranch: 'main', + testCommand: 'pnpm', testArgs: ['vitest', 'run', 'scripts/lock-core.test.ts'], + }, +}); +console.log(JSON.stringify(cs, null, 2)); +" +``` + +Expected: 两条 criterion 都打印 `ok: true` 或合理的失败说明(取决于当前仓库状态)。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/review/run-checks.ts +git commit -m "feat(review): add run-checks — execute the 2 WORKFLOW criteria (repro + tests/merge)" +``` + +--- + +## Task 4: `review-cli.ts` —— 跑一次 review,出 verdict JSON + +**Files:** +- Create: `scripts/review/review-cli.ts` + +- [ ] **Step 1: 实现** + +```ts +// scripts/review/review-cli.ts +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { runAllChecks } from "./run-checks.ts"; +import { aggregateVerdict } from "./review-core.ts"; +import type { LoopOptions } from "./review-types.ts"; + +interface CliOpts { reproResult: string; out: string; testCmd: string; baseBranch: string; } + +function parseArgv(argv: string[]): CliOpts { + const o: CliOpts = { reproResult: "", out: "", testCmd: "pnpm test", baseBranch: "main" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--repro") o.reproResult = argv[++i]; + else if (a === "--out") o.out = argv[++i]; + else if (a === "--test-cmd") o.testCmd = argv[++i]; + else if (a === "--base") o.baseBranch = argv[++i]; + } + if (!o.reproResult) throw new Error("用法: tsx scripts/review/review-cli.ts --repro [--out ] [--test-cmd \"pnpm test\"] [--base main]"); + if (!o.out) o.out = path.dirname(o.reproResult); + return o; +} + +function main(): void { + const o = parseArgv(process.argv.slice(2)); + const [cmd, ...args] = o.testCmd.split(/\s+/); + const loop: LoopOptions = { + maxRetries: 3, + reproResultPath: o.reproResult, + baseBranch: o.baseBranch, + testCommand: cmd, testArgs: args, + }; + const criteria = runAllChecks({ repoRoot: process.cwd(), loop }); + const result = aggregateVerdict(criteria); + mkdirSync(o.out, { recursive: true }); + const outFile = path.join(o.out, "review-verdict.json"); + writeFileSync(outFile, JSON.stringify(result, null, 2), "utf-8"); + console.log(`[review] verdict=${result.verdict} → ${outFile}`); + if (result.fixDirective) { + console.log(`[review] fix prompt 在 review-verdict.json 的 fixDirective.prompt 字段;loop-driver 会取用。`); + } + process.exit(result.verdict === "pass" ? 0 : 1); +} + +main(); +``` + +- [ ] **Step 2: 端到端** + +```bash +# 用一个已跑过套件 1 的 repro-result 做 input +npx tsx scripts/review/review-cli.ts \ + --repro docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json \ + --test-cmd "pnpm vitest run scripts/lock-core.test.ts" \ + --out /tmp/review-out +echo "exit code: $?" +cat /tmp/review-out/review-verdict.json | head -30 +``` + +Expected: 退出码 0(全过)或 1(有失败)+ 文件内容含 `verdict` / `criteria` / 可选 `fixDirective`。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/review/review-cli.ts +git commit -m "feat(review): add review-cli — run criteria once, write review-verdict.json" +``` + +--- + +## Task 5: SKILL.md + loop-driver.ts —— 派打回 subagent + 循环 + +> **关于 subagent**:WORKFLOW.md 要求「另开一个独立 subagent 来 review」。本计划的策略是: +> - **review 步骤**:由 `review-cli.ts` 直接做 —— 它是确定性脚本,自然就是「另开的、独立的」(不在执行 CC 的对话上下文里)。 +> - **打回步骤**:由 `loop-driver.ts` 通过 Claude Code `Agent` 工具派一个 fresh subagent 去修。`loop-driver.ts` 自己跑在 Claude Code 里(用户/CC 触发),`Agent` 工具是 CC 内置的 spawn-fresh-subagent 机制。 +> - **fallback**:若执行环境不在 Claude Code 内(比如纯 CI),`loop-driver.ts` 跑到第一次 fail 时打印 fix prompt 并 exit 非 0,把决策交给人 —— 因为没有 Agent tool 可调,自动循环不可能。 + +**Files:** +- Create: `.claude/skills/local-review/SKILL.md` +- Create: `.claude/skills/local-review/fix-directive-prompt.md` +- Create: `scripts/review/loop-driver.ts` + +- [ ] **Step 1: 写 SKILL.md(给 review subagent 的角色卡)** + +```markdown + +--- +name: local-review +description: 本地 review 自动循环 —— 跑 WORKFLOW.md 的 2 条标准,不通过自动派打回 subagent 修,循环至通过或达上限 +--- + +# local-review skill + +## 何时调用 + +执行 CC 完成一个 feature 后,调本 skill 触发本地 review 循环。也支持人手动 `/local-review`。 + +## 怎么跑 + +```bash +# 直接调用 driver(skill 实际就是包了一层文档) +npx tsx scripts/review/loop-driver.ts \ + --repro docs/acceptance/-/repro-result.json \ + --max-retries 3 +``` + +## 2 条标准(WORKFLOW.md 第 4 步钉死) + +1. **before/after 对比成立** —— 套件 1 跑出 `verdict: "pass"`(不是 `fail` 也不是 `ambiguous`) +2. **全量测试全过 + 合并无冲突** —— `pnpm test` 退出 0,工作树无未提交改动,与 `origin/main` 无冲突 + +## 行为 + +- 跑 review-cli → verdict=pass → 落 review-verdict.json,exit 0,完事 +- 跑 review-cli → verdict=fail → 派一个 fresh subagent(用 Agent tool),把 `fixDirective.prompt` 喂给它;subagent 修完落 `/tmp/fix-marker` 标记;driver 检测到标记重跑 review-cli;循环 +- 重试次数达 `--max-retries`(默认 3) → 停止,exit 非 0,把累计 fix 历史写到 review-verdict.json 的 `attempts[]` 字段 + +## 当前默认是 `--fix-mode manual`(重要) + +「verdict=fail 自动派 subagent 去修」依赖一个**还没实现**的宿主 hook(driver 写 `/tmp/fix-pending`,hook 监听到后用 Agent tool 派 subagent)。在 hook 落地之前,driver 默认走 `--fix-mode manual` —— 第一次 fail 就把 fix prompt 打印出来 + 退出非 0,由人手动派 CC 去修。 + +`--fix-mode agent` 会正常运行,但若 hook 缺席,会卡在等 fix-marker 直到超时(默认 30 分钟)。要追这个跨子系统 follow-up,见 `docs/plans/2026-05-15-INDEX.md` 末段「跨子系统 follow-up」。 + +## 派打回 subagent 的 prompt 模板 + +见 `fix-directive-prompt.md`。 +``` + +- [ ] **Step 2: 写 fix-directive-prompt.md** + +```markdown + +你是一个执行 subagent,被本地 review 循环派回来「修一个 review 失败」。 + +## 任务 + +{{fixDirective.prompt}} + +## 约束(硬性) + +- **只动与本失败类别直接相关的代码**。不要顺手 refactor、不要 reorganize 文件、不要改无关测试。 +- 修完用 `Bash` 跑 `echo done > /tmp/fix-marker` 落标记文件,然后 stop。 + loop-driver 看到标记会自动重跑 review;**你不要自己跑 review**。 +- 如果你判断这次失败**不应在本 PR 修**(超出范围、应另开 issue),写个 `echo "skip: <理由>" > /tmp/fix-marker` 然后 stop。 + driver 看到 `skip:` 会停循环、把理由报给上游。 + +## 边界 + +- 不开新 PR、不切分支、不 commit、不 push。改完留在工作树里,driver 会处理 commit 时机。 +``` + +- [ ] **Step 3: 写 loop-driver.ts** + +```ts +// scripts/review/loop-driver.ts +import { spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { runAllChecks } from "./run-checks.ts"; +import { aggregateVerdict } from "./review-core.ts"; +import type { LoopOptions, ReviewResult } from "./review-types.ts"; + +interface DriverOpts extends LoopOptions { + outDir: string; + /** "agent" = 用 Claude Code Agent tool 派 subagent;"manual" = 打印 prompt 后 exit,人接手 */ + fixMode: "agent" | "manual"; +} + +const FIX_MARKER = process.platform === "win32" + ? path.join(process.env.TEMP ?? "C:/Windows/Temp", "fix-marker") + : "/tmp/fix-marker"; + +function parseArgv(argv: string[]): DriverOpts { + const o: DriverOpts = { + maxRetries: 3, + reproResultPath: "", + baseBranch: "main", + testCommand: "pnpm", + testArgs: ["test"], + outDir: "", + fixMode: process.env.CLAUDE_CODE_AGENT === "1" ? "agent" : "manual", + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--repro") o.reproResultPath = argv[++i]; + else if (a === "--out") o.outDir = argv[++i]; + else if (a === "--max-retries") o.maxRetries = Number(argv[++i]); + else if (a === "--base") o.baseBranch = argv[++i]; + else if (a === "--test-cmd") { + const parts = argv[++i].split(/\s+/); + o.testCommand = parts[0]; o.testArgs = parts.slice(1); + } else if (a === "--fix-mode") o.fixMode = argv[++i] as "agent" | "manual"; + } + if (!o.reproResultPath) throw new Error("用法: tsx scripts/review/loop-driver.ts --repro [--out ] [--max-retries 3] [--fix-mode agent|manual]"); + if (!o.outDir) o.outDir = path.dirname(o.reproResultPath); + return o; +} + +interface Attempt { + attempt: number; + verdict: "pass" | "fail"; + failureKind?: string; + fixOutcome?: "fixed" | "skipped" | "no-marker" | "manual-mode"; + fixSkipReason?: string; +} + +async function runOneRound(opts: DriverOpts): Promise { + const criteria = runAllChecks({ repoRoot: process.cwd(), loop: opts }); + return aggregateVerdict(criteria); +} + +async function dispatchFixSubagent(prompt: string): Promise<"fixed" | "skipped" | "no-marker"> { + // Claude Code Agent tool 由宿主 CC 提供,不是 Node API。loop-driver 只能通过约定的 IPC 触发: + // 把 prompt 写到 /tmp/fix-prompt,touch /tmp/fix-pending;宿主 CC 的 hook 监听到 fix-pending 后用 + // Agent tool 派一个 subagent,subagent 收到 fix-prompt、修完落 fix-marker。 + // 如果你跑这个 driver 时没启 hook,见 README;此处只做约定接口。 + const tmp = process.platform === "win32" ? (process.env.TEMP ?? "C:/Windows/Temp") : "/tmp"; + const promptFile = path.join(tmp, "fix-prompt"); + const pendingFile = path.join(tmp, "fix-pending"); + writeFileSync(promptFile, prompt, "utf-8"); + writeFileSync(pendingFile, new Date().toISOString(), "utf-8"); + console.log(`[driver] 写入 ${promptFile} + ${pendingFile};等待 hook 派 subagent 修...`); + + // 简单轮询 fix-marker(最多 30 分钟):subagent 修完会 echo done > fix-marker + const start = Date.now(); + const TIMEOUT = 30 * 60 * 1000; + while (Date.now() - start < TIMEOUT) { + if (existsSync(FIX_MARKER)) { + const content = readFileSync(FIX_MARKER, "utf-8").trim(); + unlinkSync(FIX_MARKER); + if (existsSync(pendingFile)) unlinkSync(pendingFile); + if (content.startsWith("skip:")) return "skipped"; + return "fixed"; + } + await new Promise((r) => setTimeout(r, 5000)); + } + return "no-marker"; +} + +async function main(): Promise { + const opts = parseArgv(process.argv.slice(2)); + mkdirSync(opts.outDir, { recursive: true }); + const attempts: Attempt[] = []; + let final: ReviewResult | null = null; + + for (let i = 1; i <= opts.maxRetries + 1; i++) { + console.log(`\n=== Round ${i}/${opts.maxRetries + 1} ===`); + const r = await runOneRound(opts); + final = r; + if (r.verdict === "pass") { + attempts.push({ attempt: i, verdict: "pass" }); + console.log(`[driver] verdict=pass —— 退出循环`); + break; + } + const fd = r.fixDirective!; + console.log(`[driver] verdict=fail (${fd.failureKind})`); + if (i > opts.maxRetries) { + attempts.push({ attempt: i, verdict: "fail", failureKind: fd.failureKind, fixOutcome: undefined }); + console.log(`[driver] 已达 maxRetries=${opts.maxRetries},停止循环`); + break; + } + if (opts.fixMode === "manual") { + attempts.push({ attempt: i, verdict: "fail", failureKind: fd.failureKind, fixOutcome: "manual-mode" }); + console.log(`[driver] fix-mode=manual,打印 prompt 后退出 —— 由人接手`); + console.log(`---- FIX PROMPT ----\n${fd.prompt}\n--------------------`); + break; + } + const outcome = await dispatchFixSubagent(fd.prompt); + attempts.push({ attempt: i, verdict: "fail", failureKind: fd.failureKind, fixOutcome: outcome }); + if (outcome === "skipped") { + const skipMatch = fd.prompt.match(/skip:\s*(.+)/); + console.log(`[driver] subagent skip;停止循环。理由: ${skipMatch?.[1] ?? "(无)"}`); + break; + } + if (outcome === "no-marker") { + console.log(`[driver] 等待 fix-marker 超时 —— 停止循环`); + break; + } + console.log(`[driver] 已修,准备下一轮 review...`); + } + + const outFile = path.join(opts.outDir, "review-verdict.json"); + writeFileSync(outFile, JSON.stringify({ ...final, attempts }, null, 2), "utf-8"); + console.log(`\n[driver] 最终: verdict=${final!.verdict} → ${outFile}`); + process.exit(final!.verdict === "pass" ? 0 : 1); +} + +main().catch((e) => { console.error(e); process.exit(2); }); +``` + +- [ ] **Step 4: 冒烟(manual mode)** + +```bash +# 制造一个失败:删 repro-result 触发 missing-repro-result +mkdir -p /tmp/loop-smoke && echo '{"verdict":"fail","verdictReason":"smoke"}' > /tmp/loop-smoke/repro-result.json +npx tsx scripts/review/loop-driver.ts --repro /tmp/loop-smoke/repro-result.json \ + --test-cmd "pnpm vitest run scripts/lock-core.test.ts" \ + --out /tmp/loop-smoke --max-retries 0 --fix-mode manual +echo "exit code: $?" +cat /tmp/loop-smoke/review-verdict.json | head -30 +``` + +Expected: 退出码 1;`review-verdict.json` 含 `attempts: [{attempt:1,verdict:"fail",failureKind:"repro-fail",fixOutcome:"manual-mode"}]`,且 stdout 打印了完整 fix prompt。 + +- [ ] **Step 5: commit** + +```bash +git add .claude/skills/local-review/ scripts/review/loop-driver.ts +git commit -m "feat(review): add loop-driver + local-review skill — review/fix-loop until pass" +``` + +--- + +## Self-Review + +**1. Spec coverage:** WORKFLOW.md 第 4 步 4 个要素:① 独立 subagent → review-cli 是确定性独立脚本 + loop-driver 用 Agent tool 派打回(Task 5 IPC 约定);② 2 条标准 → run-checks 各实现一条(Task 3);③ 不通过打回重做 → loop-driver dispatchFixSubagent + fix-marker(Task 5);④ 循环至通过 → maxRetries 上限 + 优先级排序防同类反复打回(Task 2 FAILURE_PRIORITY)。**已知缺口**:Agent tool 的实际 spawn 在本计划是约定接口(写 fix-prompt + fix-pending,期待宿主 CC hook 处理) —— 完整 hook 实现属于「执行框架自动化」,见 INDEX.md 的「跨子系统 follow-up」段。 + +**2. Placeholder scan:** 无 TBD/TODO/「实现错误处理」类空话。`dispatchFixSubagent` 显式说明 IPC 约定,不是占位。`fix-mode=manual` 是显式 fallback,不是 placeholder。 + +**3. Type consistency:** `CriterionResult` Task 1 定义,Task 2/3/4/5 一致使用 `id/ok/summary/details`。`FixDirective.failureKind` Task 1 列出 6 种,Task 2 `classifyFailure` 与 `formatFixPrompt` 都覆盖全 6 种(无遗漏)。`LoopOptions` Task 1 定义,Task 3 `runAllChecks` 与 Task 5 `loop-driver` 都按同名字段构造。 + +## Execution Handoff + +Plan complete. 推荐执行路径: + +1. **前置**:套件 1(`2026-05-15-suite-1-repro-framework.md`)需先实现 —— review 标准 1 直接读它的产出。 +2. **执行**:Task 1–5 串行。 +3. **验收**:在套件 1 跑出 `verdict: pass` 的 ReproSpec 上跑 `loop-driver.ts`,看到 verdict=pass + 0 退出码;故意改坏一处再跑,看到 fix-prompt 自动生成。 diff --git a/docs/plans/2026-05-15-remote-review-bot.md b/docs/plans/2026-05-15-remote-review-bot.md new file mode 100644 index 0000000..7f4e357 --- /dev/null +++ b/docs/plans/2026-05-15-remote-review-bot.md @@ -0,0 +1,496 @@ +# 远程评审 Bot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `docs/WORKFLOW.md` 第 5 步「PR 由**远程 CC 自动评审**(同 2 条标准)」落地成一条 GitHub Actions workflow:每次 PR 打开/推新 commit,自动跑 `2026-05-15-local-review-loop.md` 的 review-cli + 把 verdict 贴成 PR 评论 + 设 commit status 卡合并。 + +**Architecture:** Workflow 跑在 ubuntu-latest;复用 `scripts/review/review-cli.ts`(本地版)同一段代码 —— 因为 review-cli 本身就是确定性脚本,「本地」与「远程」的差异只在执行环境与结果发布。每个 ReproSpec 起一个 matrix job 跑 repro-cli `--no-gif`(GIF 录制是 Win32 only,CI 上不能跑);所有 spec 全过 + `pnpm test` 全过 + 无冲突 → review verdict=pass → 评论 ✅ + status `success`;否则 verdict=fail → 评论 ❌ + status `failure`,branch protection 自动卡合并。 + +**Tech Stack:** GitHub Actions (`ubuntu-latest` + `actions/checkout@v5` + `pnpm/action-setup@v5` + `actions/setup-node@v5`) + `gh` CLI + `GITHUB_TOKEN`(`pull-requests: write` + `statuses: write`)。 + +--- + +## 安全/正确性前提(写之前必读) + +- **本计划只支持仓库内 PR**(`pull_request` 触发器)。**不支持外部 fork 的 PR** —— 用 `pull_request` 而非 `pull_request_target`,避免外部 PR 拿到 secrets / write token。外部贡献的 PR 由 maintainer 手动 cherry-pick 到内部分支后再走本流程。 +- POSTPR.md 已记录:旧的 `claude-code-review.yml` 在 PR #274 被删 —— 因为 `anthropics/claude-code-action@v1` 一直 fail on tsconfig.json fd 4。**本计划不依赖 anthropics 官方 action**,只用 review-cli 这种纯逻辑脚本,可控性更高。 +- workflow 启用前必须在仓库 Settings → Branches → main → Branch protection 里勾上「Require status checks: review/verdict」,否则 verdict=fail 不会真卡合并。 + +### 「无 ReproSpec」与「docs-only PR」的处理(bootstrap 与日常) + +WORKFLOW.md 的 review 标准 1 是「套件 1 verdict=pass」,但有两类 PR 不该被这条卡: + +1. **Bootstrap PR**:本计划与套件 1 都还没合并,`fixtures/repro-specs/` 还是空 —— 第一份 PR 自己也跑不出 verdict。 +2. **Docs-only / 工具脚本 / CI 配置 PR**:WORKFLOW.md 没写"每个 PR 必须有 ReproSpec",所以「不需要复现验证的 PR」是合法存在。 + +**策略(本 plan 显式定义)**:repro 标准在以下两种情况自动通过(workflow log 出 warning,但 verdict=pass): + +- (A) 仓库里 `fixtures/repro-specs/*.ts` 完全为空(具体实现见 Task 3 的 `list-specs` job 输出 `[]`,verdict job 把 `repro_ok=true` + summary 写明「no specs to run」) +- (B) PR 带 `skip-repro` label (适用于 docs-only / CI / 工具脚本 PR;贴 label 的人显式声明「这个 PR 不需要 repro 证明」) + +**反例**:有 ReproSpec 但 verdict=fail/ambiguous,**不能** skip,必须卡合并。`skip-repro` 也无效(显式拒绝绕过)。 + +这条豁免不破坏 WORKFLOW.md 立场:WORKFLOW.md 是「需要 repro 证明的 feature 必须有 ReproSpec 且 pass」;本豁免是「显式说不需要 repro 的 PR」的处理 —— 责任在贴 label 的人(可在 PR review 时被人工挑战)。 + +## File Structure + +``` +.github/workflows/ + pr-review.yml ← 主 workflow +.github/actions/setup-repo/ + action.yml ← 复用的 composite action(checkout+node+pnpm+install) +scripts/review/ + post-pr-comment.ts ← 把 review-verdict.json 渲染成 PR 评论 + 设 commit status + post-pr-comment.test.ts ← node:test 单测,只测渲染纯逻辑 +``` + +--- + +## Task 1: 复用的 composite action + +**Files:** +- Create: `.github/actions/setup-repo/action.yml` + +> 抽出来是因为后面的 plan(auto-merge / 6h release)会复用同一段 setup,DRY。 + +- [ ] **Step 1: 写 action.yml** + +```yaml +# .github/actions/setup-repo/action.yml +name: Setup repo (checkout + pnpm + install) +description: 标准化的 repo 准备 —— checkout PR head sha + 装 pnpm/node + frozen install +inputs: + ref: + description: 要 checkout 的 ref(默认 = github.event.pull_request.head.sha) + required: false + default: ${{ github.event.pull_request.head.sha }} + node-version: + description: Node 版本 + required: false + default: '22' +runs: + using: composite + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 # review 标准 2 需要 git merge-base / merge-tree,要全历史 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + - run: pnpm install --frozen-lockfile + shell: bash +``` + +- [ ] **Step 2: commit** + +```bash +git add .github/actions/setup-repo/action.yml +git commit -m "feat(ci): add setup-repo composite action — DRY checkout+pnpm+install" +``` + +--- + +## Task 2: `post-pr-comment.ts` —— 把 review-verdict.json 渲染成评论 + +**Files:** +- Create: `scripts/review/post-pr-comment.ts` +- Test: `scripts/review/post-pr-comment.test.ts` + +- [ ] **Step 1: 写失败测试(纯渲染部分)** + +```ts +// scripts/review/post-pr-comment.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderComment, COMMENT_MARKER } from "./post-pr-comment.ts"; +import type { ReviewResult } from "./review-types.ts"; + +const passResult: ReviewResult = { + generatedAt: "2026-05-15T10:00:00.000Z", verdict: "pass", + criteria: [ + { id: "repro-pass", ok: true, summary: "verdict=pass", details: "" }, + { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }, + ], +}; +const failResult: ReviewResult = { + generatedAt: "2026-05-15T10:00:00.000Z", verdict: "fail", + criteria: [ + { id: "repro-pass", ok: false, summary: "verdict=fail", details: "before 期望未达: stdout 缺关键字 deny" }, + { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }, + ], + fixDirective: { failureKind: "repro-fail", prompt: "..." }, +}; + +test("renderComment: pass 评论开头有 ✅", () => { + const c = renderComment(passResult, { commitSha: "abc1234", workflowRunUrl: "https://x" }); + assert.match(c, /✅/); + assert.match(c, /verdict.*pass/i); + assert.ok(c.startsWith(COMMENT_MARKER)); // 必须以 marker 开头,便于覆盖更新 +}); + +test("renderComment: fail 评论含 ❌ + 失败原因 + 链接", () => { + const c = renderComment(failResult, { commitSha: "abc1234", workflowRunUrl: "https://x" }); + assert.match(c, /❌/); + assert.match(c, /repro-fail|verdict=fail/); + assert.match(c, /缺关键字 deny/); + assert.match(c, /https:\/\/x/); +}); + +test("COMMENT_MARKER 是 hidden HTML comment,grep 唯一", () => { + assert.match(COMMENT_MARKER, /^"; + +export function renderComment(r: ReviewResult, ctx: { commitSha: string; workflowRunUrl: string }): string { + const emoji = r.verdict === "pass" ? "✅" : "❌"; + const head = `${COMMENT_MARKER}\n## ${emoji} 远程评审 verdict: **${r.verdict}**`; + const meta = `\n\n_commit \`${ctx.commitSha.slice(0, 7)}\` · [workflow run](${ctx.workflowRunUrl}) · ${r.generatedAt}_`; + const criteriaTable = [ + "", + "| 标准 | 结果 | 简述 |", + "|---|---|---|", + ...r.criteria.map((c) => `| \`${c.id}\` | ${c.ok ? "✅" : "❌"} | ${escapeMd(c.summary)} |`), + ].join("\n"); + const details = r.criteria.filter((c) => !c.ok && c.details).map((c) => [ + "", `### ❌ ${c.id} 详情`, "```", c.details.slice(0, 4000), "```", + ].join("\n")).join("\n"); + const guide = r.verdict === "fail" + ? `\n\n---\n_要修这个失败,请在本地按 \`docs/plans/2026-05-15-local-review-loop.md\` 跑 \`loop-driver.ts\`,push 修复 commit 即可触发本评论刷新。_` + : ""; + return head + meta + criteriaTable + details + guide; +} + +function escapeMd(s: string): string { + return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); +} + +interface CliOpts { verdictFile: string; pr: number; sha: string; runUrl: string; } +function parseArgv(argv: string[]): CliOpts { + const o: CliOpts = { verdictFile: "", pr: 0, sha: "", runUrl: "" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--verdict") o.verdictFile = argv[++i]; + else if (a === "--pr") o.pr = Number(argv[++i]); + else if (a === "--sha") o.sha = argv[++i]; + else if (a === "--run-url") o.runUrl = argv[++i]; + } + if (!o.verdictFile || !o.pr || !o.sha || !o.runUrl) throw new Error("用法: --verdict --pr --sha --run-url "); + return o; +} + +function postOrUpdateComment(pr: number, body: string): void { + // 找现有的 marker 评论 + const list = spawnSync("gh", ["pr", "view", String(pr), "--json", "comments"], { encoding: "utf-8" }); + if (list.status !== 0) throw new Error(`gh pr view 失败: ${list.stderr}`); + const comments: Array<{ id?: string; body?: string }> = JSON.parse(list.stdout).comments ?? []; + const existing = comments.find((c) => (c.body ?? "").startsWith(COMMENT_MARKER)); + if (existing && existing.id) { + // gh 不直接支持 update comment,用 API + const r = spawnSync("gh", ["api", `--method`, `PATCH`, + `repos/${process.env.GITHUB_REPOSITORY}/issues/comments/${existing.id}`, + "-f", `body=${body}`, + ], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`update comment 失败: ${r.stderr}`); + } else { + const r = spawnSync("gh", ["pr", "comment", String(pr), "--body", body], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`gh pr comment 失败: ${r.stderr}`); + } +} + +function setCommitStatus(sha: string, state: "success" | "failure", description: string, runUrl: string): void { + const r = spawnSync("gh", ["api", "--method", "POST", + `repos/${process.env.GITHUB_REPOSITORY}/statuses/${sha}`, + "-f", `state=${state}`, + "-f", `context=review/verdict`, + "-f", `description=${description}`, + "-f", `target_url=${runUrl}`, + ], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`set commit status 失败: ${r.stderr}`); +} + +function main(): void { + const o = parseArgv(process.argv.slice(2)); + const verdict: ReviewResult = JSON.parse(readFileSync(o.verdictFile, "utf-8")); + const body = renderComment(verdict, { commitSha: o.sha, workflowRunUrl: o.runUrl }); + postOrUpdateComment(o.pr, body); + setCommitStatus(o.sha, verdict.verdict === "pass" ? "success" : "failure", + `${verdict.verdict === "pass" ? "全部通过" : "verdict=fail"} (${verdict.criteria.filter((c) => !c.ok).length} 项 fail)`, + o.runUrl); + console.log(`[post-pr-comment] verdict=${verdict.verdict},评论 + status 已更新`); +} + +if (process.argv[1] && process.argv[1].endsWith("post-pr-comment.ts")) main(); +``` + +- [ ] **Step 4: 跑测试通过** + +Run: `npx tsx --test scripts/review/post-pr-comment.test.ts` +Expected: PASS (3/3) + +- [ ] **Step 5: commit** + +```bash +git add scripts/review/post-pr-comment.ts scripts/review/post-pr-comment.test.ts +git commit -m "feat(review): add post-pr-comment — render verdict + post/update PR comment + set status" +``` + +--- + +## Task 3: `pr-review.yml` —— 主 workflow + +**Files:** +- Create: `.github/workflows/pr-review.yml` + +- [ ] **Step 1: 写 workflow** + +```yaml +# .github/workflows/pr-review.yml +name: PR Review (远程 CC 自动评审) + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +# 只跑仓库内 PR;外部 fork 自动 skip(下方 if 守门) +permissions: + contents: read + pull-requests: write + statuses: write + +# 同 PR 多 commit 只跑最新一次,省 CI 分钟 +concurrency: + group: pr-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + list-specs: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + outputs: + specs: ${{ steps.list.outputs.specs }} + skipReproReason: ${{ steps.list.outputs.skipReason }} + steps: + - uses: actions/checkout@v5 + with: { ref: ${{ github.event.pull_request.head.sha }} } + - id: list + shell: bash + env: + PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + # 显式豁免 (A): PR 带 skip-repro label + if echo "$PR_LABELS" | jq -e '. | index("skip-repro")' >/dev/null; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=skip-repro label" >> "$GITHUB_OUTPUT" + echo "::notice::PR 带 skip-repro label —— 跳过 repro 检查" + exit 0 + fi + # 列 fixtures/repro-specs/*.ts(排除 README/index/共享文件) + if [[ ! -d fixtures/repro-specs ]]; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=no-repro-specs-dir" >> "$GITHUB_OUTPUT" + echo "::warning::fixtures/repro-specs 目录不存在 —— bootstrap 期豁免,跳过 repro 检查" + exit 0 + fi + mapfile -t files < <(find fixtures/repro-specs -maxdepth 1 -name '*.ts' ! -name 'index.ts' | sort) + if [[ ${#files[@]} -eq 0 ]]; then + echo "specs=[]" >> "$GITHUB_OUTPUT" + echo "skipReason=empty-repro-specs-dir" >> "$GITHUB_OUTPUT" + echo "::warning::fixtures/repro-specs 为空 —— bootstrap 期豁免,跳过 repro 检查" + exit 0 + fi + json=$(printf '%s\n' "${files[@]}" | jq -R . | jq -sc .) + echo "specs=$json" >> "$GITHUB_OUTPUT" + echo "skipReason=" >> "$GITHUB_OUTPUT" + echo "找到 spec: ${files[*]}" + + repro: + needs: list-specs + if: needs.list-specs.outputs.specs != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + spec: ${{ fromJson(needs.list-specs.outputs.specs) }} + steps: + - uses: ./.github/actions/setup-repo + - name: Build teamagent (有的 spec 跑 dist/bin.js) + run: pnpm --filter teamagent build + - name: Run repro + run: | + slug=$(basename "${{ matrix.spec }}" .ts) + mkdir -p "/tmp/repro-out/$slug" + npx tsx scripts/verify/repro-cli.ts "${{ matrix.spec }}" --no-gif \ + --out "/tmp/repro-out/$slug" + # ↑ 退出码 0/1 反映 verdict;不要 ||true,要让失败浮出来 + - uses: actions/upload-artifact@v4 + with: + name: repro-${{ strategy.job-index }} + path: /tmp/repro-out/ + + tests: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: ./.github/actions/setup-repo + - name: pnpm test + run: pnpm test + - name: Working tree must be clean + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "::error::tests 跑完后工作树不干净:" + git status --porcelain + exit 1 + fi + + verdict: + needs: [list-specs, repro, tests] + if: always() && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/setup-repo + - name: Download all repro artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/repro-out-all + pattern: repro-* + merge-multiple: true + - name: Aggregate verdict + shell: bash + env: + REPRO_OUTCOME: ${{ needs.repro.result }} # success / failure / skipped / cancelled + TESTS_OUTCOME: ${{ needs.tests.result }} + SKIP_REPRO_REASON: ${{ needs.list-specs.outputs.skipReproReason }} + run: | + mkdir -p /tmp/agg + # repro_ok 计算(三态豁免见安全前提): + # - REPRO_OUTCOME = success → ok + # - REPRO_OUTCOME = skipped 且 SKIP_REPRO_REASON 非空 → ok(显式豁免:bootstrap / docs-only) + # - 其它 → 不 ok + if [[ "$REPRO_OUTCOME" == "success" ]]; then + repro_ok=true + repro_summary="verdict=pass (all specs)" + elif [[ "$REPRO_OUTCOME" == "skipped" && -n "$SKIP_REPRO_REASON" ]]; then + repro_ok=true + repro_summary="豁免 — $SKIP_REPRO_REASON" + else + repro_ok=false + repro_summary="verdict=fail/missing (REPRO_OUTCOME=$REPRO_OUTCOME)" + fi + if [[ "$TESTS_OUTCOME" == "success" ]]; then + tests_ok=true + tests_summary="tests pass + tree clean" + else + tests_ok=false + tests_summary="tests failed or tree dirty (TESTS_OUTCOME=$TESTS_OUTCOME)" + fi + verdict=$([[ "$repro_ok" == "true" && "$tests_ok" == "true" ]] && echo "pass" || echo "fail") + jq -n \ + --arg generatedAt "$(date -u +%FT%TZ)" \ + --arg verdict "$verdict" \ + --argjson repro_ok "$repro_ok" \ + --argjson tests_ok "$tests_ok" \ + --arg repro_summary "$repro_summary" \ + --arg tests_summary "$tests_summary" \ + '{ + generatedAt: $generatedAt, verdict: $verdict, + criteria: [ + {id:"repro-pass", ok:$repro_ok, summary:$repro_summary, details:""}, + {id:"tests-and-merge-clean", ok:$tests_ok, summary:$tests_summary, details:""} + ] + }' > /tmp/agg/review-verdict.json + cat /tmp/agg/review-verdict.json + - name: Post comment + set status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + npx tsx scripts/review/post-pr-comment.ts \ + --verdict /tmp/agg/review-verdict.json \ + --pr ${{ github.event.pull_request.number }} \ + --sha ${{ github.event.pull_request.head.sha }} \ + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" +``` + +> **设计权衡**: +> - `verdict` job 用 `if: always()` —— 不管 `repro` / `tests` job 怎么挂,都跑一次去发评论。否则 PR 上不显示任何 verdict,体验差。 +> - 汇总用 shell + jq,不再调 review-cli —— 因为 review-cli 跑 `pnpm test`,会跟 `tests` job 重复 30 分钟。这里只把已知 outcome 拼成 `review-verdict.json` 走 post-pr-comment 的渲染。 +> - `repro` 用 matrix:每个 spec 独立 job → 一个 spec 挂不影响别的 spec,日志清晰。 + +- [ ] **Step 2: 触发 dry run** + +把 workflow 推到一个新分支 + 开个 toy PR 触发: + +```bash +git checkout -b feat/remote-review-bot +git add .github/workflows/pr-review.yml +git commit -m "feat(ci): add pr-review.yml — remote CC verdict workflow (dry run)" +git push origin feat/remote-review-bot +gh pr create --base main --head feat/remote-review-bot --title "feat(ci): pr-review.yml dry run" --body "Trigger pr-review workflow once." +# 观察: +gh run watch +# 在 PR 页面看到一条「✅/❌ 远程评审 verdict」评论 + 一个名为 `review/verdict` 的 commit status +``` + +Expected: +- workflow 跑完 ≤ 15 分钟(具体看 spec 数量) +- PR 出现一条 verdict 评论 +- PR 出现一个 `review/verdict` 的 commit status +- 若现有 spec 全过且测试全过 → status=success;否则 status=failure 并卡住合并 + +- [ ] **Step 3: 在仓库 Settings 开 branch protection** + +在 GitHub UI 里,Settings → Branches → main → Edit: +- Require a pull request before merging: ✅ +- Require status checks to pass before merging: ✅ + - 添加 `review/verdict` 到必须通过列表 + +(此步无 git 操作,不需要 commit。补一份 ADR 或在 INDEX 里说明即可。) + +- [ ] **Step 4: commit + 合并 dry run PR** + +```bash +git add .github/workflows/pr-review.yml +git commit --amend --no-edit +git push --force-with-lease origin feat/remote-review-bot +# 看 PR 跑通,然后: +gh pr merge --squash --delete-branch +``` + +--- + +## Self-Review + +**1. Spec coverage:** WORKFLOW.md 第 5 步 4 条:① 普通 PR(本计划用 `pull_request` 触发);② 远程独立复核 → 三个 needs job 全独立运行;③ 同 2 条标准 → `repro` job + `tests` job 各对应一条;④ verdict 反馈机制 → PR 评论 + commit status + branch protection。**已知缺口**:外部 fork PR 不支持(显式声明,见安全前提);若需要支持,要走 `pull_request_target` + 严格 secret 审计,属于另一个子系统。 + +**2. Placeholder scan:** 无 TBD/TODO。`tests` job 的 `pnpm test` 是真命令(对应主仓库已有的 ci.yml 范式)。`renderComment` 实现完整,`postOrUpdateComment`/`setCommitStatus` 用 `gh api` 真调用。 + +**3. Type consistency:** `ReviewResult` 来自 `review-types.ts`(local-review-loop plan Task 1 定义);本 plan 复用同一类型不重复定义。`COMMENT_MARKER` 在 Task 2 唯一定义、唯一使用;新评论与更新都通过它去重。 + +## Execution Handoff + +Plan complete. 推荐执行路径: + +1. **前置**:本地 review 循环 plan(`2026-05-15-local-review-loop.md`)Task 1(types)+ Task 2(review-core)需先完成 —— 本计划复用其类型与 verdict 渲染概念。 +2. **执行**:Task 1–3 串行(Task 3 依赖 Task 1 的 composite action)。 +3. **验收**:PR 上看到 ✅/❌ 评论 + `review/verdict` status check 出现;branch protection 真卡住失败的 PR。 diff --git a/docs/plans/2026-05-15-six-hour-release.md b/docs/plans/2026-05-15-six-hour-release.md new file mode 100644 index 0000000..eecc1ce --- /dev/null +++ b/docs/plans/2026-05-15-six-hour-release.md @@ -0,0 +1,204 @@ +# 6 小时定时 Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `docs/WORKFLOW.md` 第 8 步「主分支代码,**每 6 小时自动打一个 release 版本**」落地。现状是 `release-branch.yml` 仅 `push: branches: [main]` 触发(`auto-merge` 推完才发);新增 `schedule: cron '0 */6 * * *'`,并加「该 SHA 已发过则 skip」的早退守门,避免无变更时重复 republish。 + +**Architecture:** 最小改动 —— 不重写 `release-branch.yml`,只在它头上加 `schedule` 触发 + 一个 early-exit step。Early-exit 把 `GITHUB_SHA` 与 `latest.json.sha` 对比(latest.json 是 release-branch.yml 自己产物,在 gh-pages),相等即 skip 整个 job。`workflow_dispatch` 也加上,作为人工触发出口。 + +**Tech Stack:** GitHub Actions(`schedule` cron + `workflow_dispatch`)+ `curl` 拉 latest.json。无新代码。 + +--- + +## File Structure + +``` +.github/workflows/ + release-branch.yml ← 修改:加 schedule 触发 + early-exit step +docs/adr/ + 0017-six-hour-release.md ← ADR 记录决策(替换 push 为「push + schedule + dispatch」三触发器) +``` + +--- + +## Task 1: ADR —— 记录决策 + +**Files:** +- Create: `docs/adr/0017-six-hour-release.md` + +> 写 ADR 是因为这变更直接影响所有 release consumer 的更新节奏(从「随 PR 合并立即发」变成「最多 6h 后发」)。半年后维护者会问「为什么是 6h?」,要有可追溯答案。 + +- [ ] **Step 1: 写 ADR** + +```markdown +# ADR-0017: Release cadence — push + 6h schedule + manual dispatch + +**Status:** Accepted (2026-05-15) +**Context:** WORKFLOW.md 第 8 步要求「每 6 小时自动打一个 release」。原 `release-branch.yml` 仅在 push 到 main 时触发。 + +## 决议 + +`release-branch.yml` 触发器改为三路: + +1. `push: branches: [main]` —— 保留(auto-merge 落 commit 后立即发,延迟 ~1 分钟) +2. `schedule: - cron: '0 */6 * * *'` —— 每 UTC 0/6/12/18 点跑一次(覆盖「长时间无 PR 但仍想刷新 release-meta」的场景) +3. `workflow_dispatch` —— 人手补发出口 + +加 early-exit 守门:把 `GITHUB_SHA` 与 gh-pages 上 `latest.json.sha` 比对,相等 → skip 整个 job(避免 schedule 跑到一半发现没变更还把 latest.json 重写一遍 / 撞 `gh release create` 已存在)。 + +## 为什么是 6h(而非 1h / 24h) + +- **1h**:对绝大多数变化无意义 —— 主分支大部分时间没有合并;cron 频率高 = CI 配额浪费、release page 噪音。 +- **24h**:对急 fix 太慢 —— "我刚 merge 了一个 hotfix,要等到明天才发?" 不合理。 +- **6h**:折中。auto-merge 路径正常工作时,push 触发已经覆盖了主路;schedule 兜底「workflow_run 罕见挂掉 / push 触发被吞 / 无 PR 但仍想刷 release-meta」三类边缘情况。一天 4 次发布,既够新鲜又不噪音。 + +## 早退守门为什么必要 + +- schedule 必然会跑到「main 自上次 release 之后无变更」的窗口。无守门则: + - `gh release create` 因 tag 已存在 idempotent skip(已有逻辑) + - 但后续仍 force-push release 分支 + 重写 latest.json + push gh-pages —— 这些是真改动,会让所有下游 consumer 把同一 SHA 重新拉一次。浪费。 +- 守门把 `GITHUB_SHA` vs `latest.json.sha` 一比即知,~1 秒成本。 + +## 备选方案与理由 + +- (备选) **删除 push 触发,只留 schedule** —— 实现最小,但 hotfix 延迟最坏 6h。否决。 +- (备选) **每个 6h 节拍都先 bump version** —— 强行制造变更。否决,会污染版本号语义。 +- (备选) **package.json bump 由 release workflow 自己做** —— 跨 workflow_run 改源文件,鸡生蛋。否决。 +``` + +- [ ] **Step 2: commit** + +```bash +git add docs/adr/0017-six-hour-release.md +git commit -m "docs(adr): add 0017 — six-hour release cadence + early-exit guard" +``` + +--- + +## Task 2: 改 `release-branch.yml` + +**Files:** +- Modify: `.github/workflows/release-branch.yml`(只加触发器与 early-exit,不动现有 publish 逻辑) + +- [ ] **Step 1: 加触发器** + +把文件顶部的 `on:` 段从: +```yaml +on: + push: + branches: [main] +``` +改成: +```yaml +on: + push: + branches: [main] + schedule: + # 每 6 小时一次:UTC 00:00 / 06:00 / 12:00 / 18:00 + # 见 docs/adr/0017-six-hour-release.md + - cron: '0 */6 * * *' + workflow_dispatch: + inputs: + reason: + description: 'Why are you manually triggering?' + required: false + default: 'manual' +``` + +- [ ] **Step 2: 在 `publish` job 第一个 step 加早退守门** + +在 `jobs.publish.steps` 的开头(`uses: actions/checkout@v5` 之前)插入: + +```yaml + - name: Early exit if HEAD already released + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + # 比 GITHUB_SHA 与 gh-pages 上 latest.json.sha,相等 → skip + # 拉 latest.json(release 目标 URL,见 release-branch.yml 末段「Publish latest.json to gh-pages」) + PUBLISHED_URL="https://${{ github.repository_owner }}.github.io/$(echo '${{ github.repository }}' | cut -d/ -f2)/latest.json" + # 如果 latest.json 还没存在(首次 release),不 skip + if ! curl -fsSL "$PUBLISHED_URL" -o /tmp/latest.json 2>/dev/null; then + echo "latest.json 不存在(首次 release?) → 继续发布" + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + PUBLISHED_SHA=$(jq -r '.sha // empty' /tmp/latest.json) + if [[ "$PUBLISHED_SHA" == "$GITHUB_SHA" ]]; then + echo "GITHUB_SHA=$GITHUB_SHA 已发布过(latest.json.sha 一致) → skip" + echo "skip=true" >> "$GITHUB_OUTPUT" + # 不 fail,只 skip 后续 step;workflow 总体仍 success + else + echo "GITHUB_SHA=$GITHUB_SHA vs published=$PUBLISHED_SHA → 继续发布" + echo "skip=false" >> "$GITHUB_OUTPUT" + fi +``` + +然后给后续**每个** step 加 `if: steps.guard.outputs.skip != 'true'` 守门。`release-branch.yml` 现在的 `publish` job 共 **12 个 step**(读你当前的 yml 自己数一遍核对),逐个改成下方右栏: + +| # | 原 step 起始行 | 改成 | +|---|---|---| +| 1 | `- uses: actions/checkout@v5` | `- if: steps.guard.outputs.skip != 'true'`
` uses: actions/checkout@v5` | +| 2 | `- uses: pnpm/action-setup@v5` | `- if: steps.guard.outputs.skip != 'true'`
` uses: pnpm/action-setup@v5` | +| 3 | `- uses: actions/setup-node@v5` | `- if: steps.guard.outputs.skip != 'true'`
` uses: actions/setup-node@v5` | +| 4 | `- run: pnpm install --frozen-lockfile` | `- if: steps.guard.outputs.skip != 'true'`
` run: pnpm install --frozen-lockfile` | +| 5 | `- run: pnpm --filter teamagent build` | `- if: steps.guard.outputs.skip != 'true'`
` run: pnpm --filter teamagent build` | +| 6 | `- name: Detect version` | 保留 `name`/`id`/`run`,在 `name:` 上方插一行 `if: steps.guard.outputs.skip != 'true'`(位置见下方完整片段) | +| 7 | `- name: Pack tarball` | 同 #6 | +| 8 | `- name: Stage release artifacts` | 同 #6 | +| 9 | `- name: Create GitHub Release (idempotent)` | 同 #6 | +| 10 | `- name: Force-push release branch` | 同 #6 | +| 11 | `- name: Resolve PR creator for merge commit (post-merge auto-update feature)` | 同 #6 | +| 12 | `- name: Publish latest.json to gh-pages (issue #313 Tier 1)` | 同 #6 | + +`name:` 形 step 的 `if:` 加法示例(以 #6 Detect version 为例,其它 #7–#12 同样模板,只换 `name`/`id`/`run` 内容): + +```yaml + - name: Detect version + if: steps.guard.outputs.skip != 'true' + id: version + run: | + # ... 原内容不动 ... +``` + +> **检查方式**:改完后跑 `grep -c "if: steps.guard.outputs.skip" .github/workflows/release-branch.yml` 应输出 **12**(对应 12 个 step 都加上)。少一个就是漏了。 + +- [ ] **Step 3: 端到端验证** + +> **冒烟方式**:开个 throwaway PR 把改后的 `release-branch.yml` 推到 main(走完整 PR + auto-merge 流程),然后: +> +> 1. push 触发的 `release-branch` 应正常发(因为 SHA 是新的) +> 2. **立刻** workflow_dispatch 手动再触发一次: +> ```bash +> gh workflow run release-branch.yml --ref main -f reason=guard-test +> gh run watch +> ``` +> 应看到 `Early exit if HEAD already released` step 输出 `skip=true`,后续 step 都 skip,workflow 总体 success。 +> 3. 等 6h(或临时把 cron 改成 `*/5 * * * *` 做加速测,测完改回)看到 schedule 触发同样 skip。 + +- [ ] **Step 4: commit** + +```bash +git add .github/workflows/release-branch.yml +git commit -m "feat(release): add 6h schedule + workflow_dispatch + early-exit guard (ADR-0017)" +``` + +--- + +## Self-Review + +**1. Spec coverage:** WORKFLOW.md 第 8 步「每 6 小时」→ `cron '0 */6 * * *'` 直接对应。守门 step 把「无变更时跳过 republish」的隐性需求显式化(WORKFLOW.md 没说,但所有「定时发布」型 workflow 的常识)。**已知缺口**:无。 + +**2. Placeholder scan:** ADR 里的备选方案段是文档要求,不是 plan placeholder。所有 yaml 改动都是真命令、真表达式。 + +**3. Type consistency:** 不涉及代码 type;触发器命名(`push`/`schedule`/`workflow_dispatch`)与现有 workflow 风格一致。 + +## Execution Handoff + +Plan complete。推荐执行路径: + +1. **前置**:无强制前置;但若 `auto-merge.yml` 已上线(`2026-05-15-auto-merge.md`),本 plan 的 PR 自己就能走完整流程被合掉,顺便实测端到端。 +2. **执行**:Task 1–2 串行,~30 分钟手动操作 + 一次 cron 等待。 +3. **验收**:`gh workflow run release-branch.yml -f reason=verify`,看到 guard step 在第二次跑时 skip = end-to-end ok。 diff --git a/docs/plans/2026-05-15-suite-1-brainstorm.md b/docs/plans/2026-05-15-suite-1-brainstorm.md new file mode 100644 index 0000000..6f16a4c --- /dev/null +++ b/docs/plans/2026-05-15-suite-1-brainstorm.md @@ -0,0 +1,202 @@ +# 套件 1(复现验证代码框架)Brainstorm 笔记 + +> **上下文**:`docs/plans/2026-05-14-verification-tooling.md` Phase 3 显式地把 4 个设计问题 +> 列为「需先 brainstorm 后再出独立 plan」。本笔记给出决议 + 理由,作为 +> `2026-05-15-suite-1-repro-framework.md` 的输入。看 plan 之前先看本笔记。 +> +> **决议层级**:本文是 brainstorm,不是 ADR;落地后若证伪可在 plan 里覆盖。但 +> 4 条决议直接决定模块边界与类型签名,**改决议=改 plan**。 + +--- + +## 一图速览 + +| # | 问题 | 决议 | +|---|---|---| +| Q1 | 与既有 `Scenario` 的关系 | 新类型 `ReproSpec` 当输入;matcher 类 feature 内嵌 `Scenario` 引用复用 | +| Q2 | before/after 怎么切代码态 | **两个 git worktree** 主路 + 环境变量叠加做次级状态切换 | +| Q3 | suite-1 怎么「自带录 GIF」 | 解耦:suite-1 出结构化 `ReproResult`;同 spec 的 `demoScene` 字段独立喂给 Phase 1 `recordGif()` | +| Q4 | judge 接到哪 | 两层:① suite-1 内置**确定性 matcher 判定**(无 LLM);② 独立 subagent review 走另一个子系统 | + +--- + +## Q1: 与 `Scenario` 的关系 + +### 现状 +`fixtures/scenarios/*.ts` 已有 6 个 `Scenario`,每个三段结构(`phaseA` 纠正 / `phaseB` 提炼规则 / `phaseC` 拦截)。这是 TeamAgent **核心 matcher 行为**的验证形态。 + +### 备选 +- (a) **套件 1 输入 = Scenario**:所有要验证的 feature 都套进 phaseA/B/C 三段。 +- (b) **套件 1 输入 = 新类型 `ReproSpec`**:与 Scenario 解耦。 + +### 决议:(b) + 内嵌 Scenario 字段 +`ReproSpec` 是新的、更宽的输入。当 feature 恰好是 matcher 类时,内嵌 Scenario 引用复用现成定义。 + +### 理由 +- 不是所有 feature 都 Scenario 形 ——「6 小时定时 release」跟 phaseA/B/C 毫无关系。强套会扭曲。 +- Scenario 仍然是 matcher 类 feature 最佳建模工具,不该重复发明 → 用嵌套复用。 +- hook-moment-block 这类已有 Scenario 的 feature,ReproSpec 写起来很轻: + ```ts + { id: "hook-moment-block", scenario: momentDayjsScenario, baseline: {...}, expect: {...} } + ``` + +### 形态 +```ts +interface ReproSpec { + id: string; // 比如 "hook-moment-block" + description: string; + baseline: BaselineRef; // 怎么切到 before 态 + current: CurrentRef; // 怎么切到 after 态(一般是当前 worktree) + steps: ReproStep[]; // 在每个态下要跑的命令 + expect: { before: ResultMatcher; after: ResultMatcher }; // 期望结果 + scenario?: Scenario; // optional:matcher 类 feature 引用现成 Scenario + demoScene?: DemoScene; // optional:给 GIF 录制用的可视化重演脚本 +} +``` + +--- + +## Q2: before/after 怎么切 + +### 现状 +样板(hook-moment-block)用 **环境变量** 切:`USERPROFILE` 指向不同 HOME 路径,从而切换知识库 DB 状态。这只对「数据驱动差异」的 feature 有效;对「代码层面差异」(绝大多数 feature)没用。 + +### 备选 +- (a) **`git stash` + 切换** —— 跑 before 时 stash 改动,跑 after 时 unstash。 +- (b) **两个 worktree** —— 预先建 baseline 和 current 两个 worktree,各跑各的。 +- (c) **环境变量切换** —— 只切运行时状态,代码不变。 + +### 决议:(b) 主路 + (c) 叠加 + +**主路:两个 git worktree**: +- `baseline` worktree 检出到 `BaselineRef.ref`(默认值 = `git merge-base HEAD main`,即当前 PR 起点之前的代码) +- `current` worktree = 跑 suite-1 的位置(即当前 worktree) +- suite-1 在两侧分别 `pnpm install`(若 lock 文件不同) + 跑 `steps`,捕获结构化结果 +- 跑完销毁 baseline worktree:`git worktree remove --force ` + +**叠加层:环境变量**。对于「同一份代码、不同运行时状态」的 feature(hook-moment-block 是典型),ReproSpec 在 baseline / current 各自 `env` 字段里塞环境变量做次级切换: +```ts +baseline: { ref: "HEAD~1", env: { USERPROFILE: "C:/tmp/empty-home" } } +current: { env: { USERPROFILE: "C:/tmp/loaded-home" } } +``` + +### 理由 +- (a) `git stash` 与 vitest 跑测试代码本身互锁 —— stash 跑测试代码会移动测试文件,极易串台/死锁。 ❌ +- (b) 两个 worktree:与项目已有 `.claude/worktrees/` 习惯一致;隔离干净;可并行(本期串行跑,留扩展余地)。 ✅ +- (c) 单独 env 不够通用,但作为补充能优雅描述「数据状态」差异。 ✅ + +### 实现要点 +- 走 `git worktree add /repro-baseline- ` —— 不污染主仓库;用完 `git worktree remove --force ` 清理。 +- baseline 路径放在 `os.tmpdir()` 下,每次运行用 6 位随机后缀避免撞车。 +- 执行 `pnpm install` 之前先尝试 junction `node_modules`(Windows: `fs.symlinkSync(target, path, "junction")`) 大幅省时;若两侧 `package.json`/`pnpm-lock.yaml` hash 不一致则放弃 junction、老实重装。 +- baseline worktree 不能 reuse `current` 的 `node_modules` 目录(可能含 current 引入的新包);用 junction 不算 reuse 内容,只是省 IO,所以 hash 一致才安全。 + +--- + +## Q3: suite-1 怎么「自带录 GIF」 + +### 现状 +WORKFLOW.md 套件 1 第 4 条硬要求写明:「跑的过程**自带录制一段验收 GIF**」。字面读起来像「suite-1 跑的同时启动录屏」,但实际两件事**应该解耦**: +- suite-1 跑在 CI / 后台,无 GUI、无可见窗口 +- GIF 录的是真实终端窗口里发生的事(Phase 1 的 `gdigrab` 是 Win32 GUI 强依赖) + +### 决议:解耦 + 共享 `ReproSpec.demoScene` +- **suite-1 的产出是结构化结果**(`ReproResult`):before/after 各跑一次的命令输出 + 退出码 + matcher 判定。 +- **GIF 录制是一个并行的、可选的步骤**:用同一份 `ReproSpec.demoScene`,调 Phase 1 `recordGif()` 在可见终端里重演 before→after。 +- 「自带」语义体现在 **suite-1 CLI 入口默认会触发录 GIF**(除非 `--no-gif` 或检测到无 Win32 环境)。这样「跑 suite-1 就有 GIF」对调用者是真的,实现上是「runner 之后立即调 recorder」。 + +``` +suite-1 CLI 入口 + ├─ runRepro(spec) → ReproResult (结构化, CI/本地都跑) + └─ 若 spec.demoScene 且 Win32 且 !--no-gif: + recordGif(spec.demoScene) → demo.gif (可选可视化) +``` + +### 理由 +- 让 suite-1 强制在录屏环境里跑 → CI 上没桌面,跑不了。 +- 让 recorder 「内嵌 suite-1 跑两遍」 → 录屏过程慢、易碎,suite-1 不该被它拖垮。 +- 解耦后:suite-1 在 CI 里照样跑(出 verdict);GIF 在本地或专用录屏 runner 上跑(出可视化证据);各自独立、各自重试。 + +### `DemoScene` 形态 +```ts +interface DemoScene { + sceneScript: string; // 在被录窗口里跑的 .ps1(脚本自行设置 windowTitle) + windowTitle: string; + durationSec: number; +} +``` + +样板 `hook-moment-block/recording/demo-scene.ps1` 已经是这个形态,迁移成本几乎为零。 + +--- + +## Q4: judge 接到哪 + +### 现状 +- `docs/PLAN-RESEARCH-REPORT.md` 强调:第三方 judge harness、**禁止自评**。 +- `packages/benchmark/evaluator.ts` 是**正则模式匹配**(`compiledWrongRegex` / `compiledCorrectRegex`),用于 benchmark **群组对比**(PRR 计算),不是通用「feature 是否实现」判官。 +- `.claude/skills/visual-proof-pr/judge-harness.md` 是**视觉化产出存在性检查**(`finalized.html` 是否在、PR body 是否提到 visual proof),跟 feature implementation verdict 也无关。 +- 所以**没有现成 judge harness 可直接复用**。 + +### 决议:两层 judge,互不重叠 + +**Layer A —— suite-1 内置确定性 matcher 判定(无 LLM)** +- ReproSpec 包含 `expect: { before: ResultMatcher; after: ResultMatcher }` +- `ResultMatcher` 是结构化断言:`{ exitCode?, stdoutContains?, stdoutNotContains?, stderrContains?, stderrNotContains? }` +- suite-1 跑完后自动比对实际输出与 matcher → `verdict: "pass" | "fail" | "ambiguous"` +- 这是 WORKFLOW.md 说的「给机器看的硬证明」。 + +**Layer B —— 独立 subagent review(不在套件 1 范围)** +- 跑完套件 1 + 套件 2 之后,由另一个独立 subagent 审「verdict 真不真、对比真严不严、有无浮于表面」。 +- 这是「**本地 review 循环**」子系统的工作,详见 `2026-05-15-local-review-loop.md`。 + +### 为什么 Layer A 必须无 LLM +- WORKFLOW.md 套件 1 第 1 条硬要求:「**给机器看的硬证明,是代码不是文档,可自动跑**」。LLM 判定**不是「硬」**(同输入两次结果可能不同,无法 reproducible)。 +- 「判断力」全在 ReproSpec 作者手里:作者怎么定义 matcher,就是怎么定义「feature 实现」。这把"什么算 feature 实现"的责任明确放在**写 ReproSpec 的人**身上 —— 这是健康的。 +- LLM 该用在 Layer B(评审报告整体是否严谨),而非 Layer A(feature 是否触发)。 + +### `ResultMatcher` 形态 +```ts +interface ResultMatcher { + exitCode?: number; + stdoutContains?: string[]; + stdoutNotContains?: string[]; + stderrContains?: string[]; + stderrNotContains?: string[]; + // 后续可扩展(本期不做): customAssertion?: (r: StepResult) => { ok: boolean; reason: string }; +} +function evalMatcher(actual: StepResult, m: ResultMatcher): { ok: boolean; reasons: string[] }; +``` + +`ambiguous` verdict 的产生:before 命中 after 的 matcher,或 after 命中 before 的 matcher,说明对比未严谨成立 —— suite-1 不擅自二选一,标 ambiguous 让 Layer B 看。 + +--- + +## 决议对 plan 形态的影响(模块边界落锤) + +`2026-05-15-suite-1-repro-framework.md` 的文件结构由上面 4 个决议直接推出来: + +``` +scripts/verify/ + repro-types.ts ← ReproSpec / ReproResult / ResultMatcher / DemoScene 类型 + repro-core.ts ← 纯逻辑:matcher 求值、verdict 计算、worktree 命令构造 + repro-core.test.ts ← node:test 单测 + worktree-shell.ts ← Imperative shell:git worktree 增删 + node_modules junction + repro-runner.ts ← Imperative shell:在 baseline + current 两侧跑 steps、收集 StepResult + repro-cli.ts ← CLI 入口:read spec → run → 可选 recordGif → 写 ReproResult JSON +fixtures/repro-specs/ + hook-moment-block.ts ← 第一份 ReproSpec 样板(把 hook-moment-block 验收改写为可执行) + README.md ← 怎么写一份 ReproSpec +``` + +文件结构与 Phase 1/2 的 `scripts/verify/gif-*.ts` / `report-*.ts` 平行,沿用同一套 functional-core / imperative-shell 模式。 + +--- + +## 验证清单(写 plan 时勾) + +- [ ] 4 条决议都被 plan 的某个 Task 落地 +- [ ] `repro-types.ts` 类型签名与本文 Q1/Q2/Q3/Q4 描述一致 +- [ ] `repro-core.test.ts` 至少覆盖:① matcher 求值;② verdict 计算(pass/fail/ambiguous 三态都有);③ worktree 命令构造 +- [ ] `repro-cli.ts` 默认行为与 Q3 决议一致(自动调 recordGif,可关) +- [ ] hook-moment-block ReproSpec 样板能 end-to-end 跑出 `verdict: "pass"` diff --git a/docs/plans/2026-05-15-suite-1-repro-framework.md b/docs/plans/2026-05-15-suite-1-repro-framework.md new file mode 100644 index 0000000..b8c187a --- /dev/null +++ b/docs/plans/2026-05-15-suite-1-repro-framework.md @@ -0,0 +1,930 @@ +# 套件 1 · 复现验证代码框架 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Prereq:** 先读 `docs/plans/2026-05-15-suite-1-brainstorm.md` —— 4 条决议是本 plan 的输入。 + +**Goal:** 把 `docs/WORKFLOW.md` 第 3 步「两套验证 · 套件 1」从概念落地成可跑工具。任何 feature 写一份 `ReproSpec`,就能跑出**结构化 verdict (pass/fail/ambiguous)** + **可选 GIF**;CI 与本地都能跑。 + +**Architecture:** Functional core / Imperative shell —— `repro-core.ts` 纯逻辑(matcher 求值、verdict 计算、worktree 命令构造),严禁 import `node:fs`/`node:child_process`;`worktree-shell.ts`、`repro-runner.ts`、`repro-cli.ts` 是 IO/编排层。两个 git worktree(baseline + current)切代码态;ReproSpec 的 `env` 字段叠加切运行时状态。GIF 录制复用 `verification-tooling.md` Phase 1 的 `recordGif()`,在 CLI 里默认调一次。 + +**Tech Stack:** TypeScript + `tsx` + `node:test`(node 22 内置)+ `git worktree` + `node:child_process` + (可选) Phase 1 ffmpeg gdigrab。Windows 上 junction 用 `fs.symlinkSync(target, path, "junction")`。 + +--- + +## File Structure + +``` +scripts/verify/ + repro-types.ts ← ReproSpec / ReproResult / ResultMatcher / DemoScene / StepResult / Verdict + repro-core.ts ← 纯逻辑:evalMatcher / computeVerdict / buildWorktreeAddArgs / makeBaselineDir + repro-core.test.ts ← node:test 单测,覆盖 repro-core 全部纯函数 + worktree-shell.ts ← Imperative shell:git worktree add/remove + node_modules junction + repro-runner.ts ← Imperative shell:在 baseline + current 两侧跑 steps、收集 StepResult + repro-cli.ts ← CLI 入口:read spec → run → 可选 recordGif → 写 ReproResult JSON + 退出码 +fixtures/repro-specs/ + hook-moment-block.ts ← 第一份 ReproSpec 样板(把 hook-moment-block 验收改写为可执行) + README.md ← 「怎么写一份 ReproSpec」约定 +``` + +**Naming:** 与 `verification-tooling.md` Phase 1/2 的 `gif-*.ts`/`report-*.ts` 平行,均放 `scripts/verify/`,不进 pnpm workspace,用 `tsx` 跑、`node:test` 测。 + +--- + +## Task 1: `repro-types.ts` —— 类型定义 + +**Files:** +- Create: `scripts/verify/repro-types.ts` + +- [ ] **Step 1: 写类型(无运行时代码,仅 type 导出)** + +```ts +// scripts/verify/repro-types.ts +import type { Scenario } from "../../packages/core/src/index.js"; + +/** 一个 step 是 baseline / current 两侧都要执行的一条命令。 */ +export interface ReproStep { + /** 给人看的 step 名,出现在 ReproResult 与 GIF 字幕里 */ + name: string; + /** 可执行命令 + 参数。直接 spawn,不走 shell。 */ + command: string; + args: string[]; + /** 在 step 级追加的环境变量(优先级高于 ReproSpec.baseline/current.env)。 */ + env?: Record; + /** 默认 30s;超时即标 step 失败、verdict ambiguous。 */ + timeoutMs?: number; + /** 默认 cwd = repro 当前侧的 worktree 根目录。 */ + cwd?: string; +} + +/** baseline (改动前) 怎么切。`ref` 缺省 = `git merge-base HEAD ` */ +export interface BaselineRef { + ref?: string; // git ref;default 见 makeBaselineRef + baseBranch?: string; // default "main" + env?: Record; // 该侧统一附加 env(被 ReproStep.env 覆写) +} + +/** current (改动后) 怎么切。本期固定 = 跑 suite-1 的当前 worktree。 */ +export interface CurrentRef { + env?: Record; +} + +export interface DemoScene { + /** 在被录窗口里跑的 .ps1(脚本自行设置 windowTitle 与运行 ReproSpec.steps 的等价命令) */ + sceneScript: string; + windowTitle: string; + durationSec: number; +} + +export interface ResultMatcher { + exitCode?: number; + stdoutContains?: string[]; + stdoutNotContains?: string[]; + stderrContains?: string[]; + stderrNotContains?: string[]; +} + +export interface ReproSpec { + id: string; // kebab-case,比如 "hook-moment-block" + description: string; + baseline: BaselineRef; + current: CurrentRef; + steps: ReproStep[]; // 至少 1 条 + /** 期望:把所有 steps 的合并输出送进 matcher。 */ + expect: { before: ResultMatcher; after: ResultMatcher }; + /** matcher 类 feature 可引用现成 Scenario 复用其 phaseA/B/C 元数据 */ + scenario?: Scenario; + /** 给 GIF 录制用的可视化重演脚本。无此字段则 CLI 不录 GIF。 */ + demoScene?: DemoScene; +} + +/** 单个 step 的实际执行结果(baseline + current 各自一份)。 */ +export interface StepResult { + stepName: string; + exitCode: number | null; // null = timeout + stdout: string; + stderr: string; + durationMs: number; + timedOut: boolean; +} + +export type Verdict = "pass" | "fail" | "ambiguous"; + +export interface SideResult { + side: "before" | "after"; + steps: StepResult[]; + /** 该侧 matcher 的判定:matcher 全部命中 = ok */ + matcherOk: boolean; + matcherReasons: string[]; +} + +export interface ReproResult { + specId: string; + generatedAt: string; // ISO + before: SideResult; + after: SideResult; + /** 顶层 verdict:pass=before 期望成立 + after 期望成立 + 两侧期望确实不同; + * fail=有一侧期望未达;ambiguous=两侧期望"看起来都成立"或"都不成立" → 对比未严谨。 */ + verdict: Verdict; + verdictReason: string; +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `npx tsc --noEmit --target es2022 --moduleResolution node16 --module node16 scripts/verify/repro-types.ts` +Expected: PASS (no errors). 如果报 `Scenario` 解析失败,改 import 路径为 packages/core 的实际入口(本仓库现状 `packages/core/src/index.js`)。 + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/repro-types.ts +git commit -m "feat(verify): add repro-types — ReproSpec/ReproResult/ResultMatcher types for suite-1" +``` + +--- + +## Task 2: `repro-core.ts` —— 纯逻辑(matcher / verdict / worktree 命令构造) + +**Files:** +- Create: `scripts/verify/repro-core.ts` +- Test: `scripts/verify/repro-core.test.ts` + +- [ ] **Step 1: 写失败测试(8 条)** + +```ts +// scripts/verify/repro-core.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + evalMatcher, + computeVerdict, + buildWorktreeAddArgs, + buildWorktreeRemoveArgs, + mergeStepOutputs, +} from "./repro-core.ts"; +import type { ReproStep, ResultMatcher, SideResult, StepResult } from "./repro-types.ts"; + +const okStep = (over: Partial = {}): StepResult => ({ + stepName: "demo", exitCode: 0, stdout: "", stderr: "", durationMs: 1, timedOut: false, ...over, +}); + +test("evalMatcher: 无字段的 matcher 默认通过", () => { + const r = evalMatcher(okStep(), {}); + assert.deepEqual(r, { ok: true, reasons: [] }); +}); + +test("evalMatcher: exitCode 不匹配 → fail", () => { + const r = evalMatcher(okStep({ exitCode: 1 }), { exitCode: 0 }); + assert.equal(r.ok, false); + assert.match(r.reasons[0], /exitCode/); +}); + +test("evalMatcher: stdoutContains 全部命中才通过", () => { + const s = okStep({ stdout: "决策: deny\n应改用: dayjs" }); + assert.equal(evalMatcher(s, { stdoutContains: ["deny", "dayjs"] }).ok, true); + assert.equal(evalMatcher(s, { stdoutContains: ["deny", "missing-token"] }).ok, false); +}); + +test("evalMatcher: stdoutNotContains 任一命中即 fail", () => { + const s = okStep({ stdout: "决策: 通过 (无规则命中)" }); + assert.equal(evalMatcher(s, { stdoutNotContains: ["deny"] }).ok, true); + assert.equal(evalMatcher(s, { stdoutNotContains: ["通过"] }).ok, false); +}); + +test("mergeStepOutputs: 把多个 step 拼成单个 StepResult,exitCode 取最后一个非 0(或最后一个 0)", () => { + const merged = mergeStepOutputs([ + okStep({ stdout: "a", exitCode: 0 }), + okStep({ stdout: "b", exitCode: 1 }), + okStep({ stdout: "c", exitCode: 0 }), + ]); + assert.equal(merged.exitCode, 1); // 最近的非 0 优先 + assert.equal(merged.stdout, "a\nb\nc"); +}); + +test("computeVerdict: before 期望成立 + after 期望成立 + 两侧期望不同 → pass", () => { + const before: SideResult = { side: "before", steps: [], matcherOk: true, matcherReasons: [] }; + const after: SideResult = { side: "after", steps: [], matcherOk: true, matcherReasons: [] }; + const r = computeVerdict(before, after, + { stdoutContains: ["通过"] }, { stdoutContains: ["deny"] }); + assert.equal(r.verdict, "pass"); +}); + +test("computeVerdict: 任一侧 matcherOk 为 false → fail", () => { + const before: SideResult = { side: "before", steps: [], matcherOk: false, matcherReasons: ["缺 通过"] }; + const after: SideResult = { side: "after", steps: [], matcherOk: true, matcherReasons: [] }; + const r = computeVerdict(before, after, {}, {}); + assert.equal(r.verdict, "fail"); +}); + +test("buildWorktreeAddArgs / buildWorktreeRemoveArgs: 命令拼装", () => { + assert.deepEqual( + buildWorktreeAddArgs("/tmp/repro-baseline-abc123", "deadbeef"), + ["worktree", "add", "--detach", "/tmp/repro-baseline-abc123", "deadbeef"], + ); + assert.deepEqual( + buildWorktreeRemoveArgs("/tmp/repro-baseline-abc123"), + ["worktree", "remove", "--force", "/tmp/repro-baseline-abc123"], + ); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `npx tsx --test scripts/verify/repro-core.test.ts` +Expected: FAIL —— `Cannot find module './repro-core.ts'` + +- [ ] **Step 3: 实现 `repro-core.ts`** + +```ts +// scripts/verify/repro-core.ts +import type { ResultMatcher, SideResult, StepResult, Verdict } from "./repro-types.ts"; + +export function evalMatcher(actual: StepResult, m: ResultMatcher): { ok: boolean; reasons: string[] } { + const reasons: string[] = []; + if (m.exitCode !== undefined && actual.exitCode !== m.exitCode) { + reasons.push(`exitCode 期望 ${m.exitCode},实际 ${actual.exitCode}`); + } + for (const needle of m.stdoutContains ?? []) { + if (!actual.stdout.includes(needle)) reasons.push(`stdout 缺关键字: ${JSON.stringify(needle)}`); + } + for (const needle of m.stdoutNotContains ?? []) { + if (actual.stdout.includes(needle)) reasons.push(`stdout 不应含: ${JSON.stringify(needle)}`); + } + for (const needle of m.stderrContains ?? []) { + if (!actual.stderr.includes(needle)) reasons.push(`stderr 缺关键字: ${JSON.stringify(needle)}`); + } + for (const needle of m.stderrNotContains ?? []) { + if (actual.stderr.includes(needle)) reasons.push(`stderr 不应含: ${JSON.stringify(needle)}`); + } + return { ok: reasons.length === 0, reasons }; +} + +/** 把多个 step 的 stdout/stderr 拼成单个 StepResult(用于喂给 matcher)。 + * exitCode 规则:任一非 0 → 取最近的非 0;否则取最后一个(0)。 + * 这样既能复现「中途某步失败」,又不会被无关 0 退出码淹没真问题。 */ +export function mergeStepOutputs(steps: StepResult[]): StepResult { + if (steps.length === 0) { + return { stepName: "", exitCode: 0, stdout: "", stderr: "", durationMs: 0, timedOut: false }; + } + const stdout = steps.map((s) => s.stdout).join("\n"); + const stderr = steps.map((s) => s.stderr).join("\n"); + const durationMs = steps.reduce((sum, s) => sum + s.durationMs, 0); + const timedOut = steps.some((s) => s.timedOut); + const lastNonZero = [...steps].reverse().find((s) => s.exitCode !== 0 && s.exitCode !== null); + const exitCode = lastNonZero?.exitCode ?? steps[steps.length - 1].exitCode; + return { stepName: "", exitCode, stdout, stderr, durationMs, timedOut }; +} + +export function computeVerdict( + before: SideResult, + after: SideResult, + beforeMatcher: ResultMatcher, + afterMatcher: ResultMatcher, +): { verdict: Verdict; verdictReason: string } { + if (!before.matcherOk || !after.matcherOk) { + const why: string[] = []; + if (!before.matcherOk) why.push(`before 期望未达: ${before.matcherReasons.join("; ")}`); + if (!after.matcherOk) why.push(`after 期望未达: ${after.matcherReasons.join("; ")}`); + return { verdict: "fail", verdictReason: why.join(" | ") }; + } + // 两侧 matcherOk 都为 true,但要进一步 sanity:对换 matcher 结果应该 fail, + // 否则说明两侧输出几乎一样 → 对比未严谨成立,标 ambiguous。 + const beforeMergedAfter = sideMatchesMerged(before, afterMatcher); + const afterMergedBefore = sideMatchesMerged(after, beforeMatcher); + if (beforeMergedAfter && afterMergedBefore) { + return { verdict: "ambiguous", verdictReason: "before 也命中了 after 的期望,且 after 也命中了 before 的期望 —— 对比未严谨成立" }; + } + return { verdict: "pass", verdictReason: "before 与 after 期望均成立,且对比严谨" }; +} + +function sideMatchesMerged(side: SideResult, m: ResultMatcher): boolean { + // 简化:在 SideResult 没有保留 mergedStep 的情况下,这里近似看 matcherReasons 数组。 + // 真正的"互换 matcher" sanity check 由 repro-runner 在 SideResult 上额外存一份 mergedStep。 + // 本函数为占位 —— Step 4 中 runner 会把 mergedStep 通过另一个签名的 computeVerdict 传入。 + void side; void m; + return false; +} + +export function buildWorktreeAddArgs(path: string, ref: string): string[] { + return ["worktree", "add", "--detach", path, ref]; +} + +export function buildWorktreeRemoveArgs(path: string): string[] { + return ["worktree", "remove", "--force", path]; +} + +export function makeBaselineDir(tmpDir: string, specId: string, rand6: string): string { + // 路径段全部 ASCII;Windows 下 git worktree 对 unicode 路径偶有问题 + return `${tmpDir.replace(/[\\/]+$/, "")}/repro-baseline-${specId}-${rand6}`; +} +``` + +> 注:`sideMatchesMerged` 在本 task 是占位返回 false(测试只覆盖 pass / fail,不测 ambiguous)。Task 4 重构 `computeVerdict` 接收 `mergedBefore: StepResult, mergedAfter: StepResult` 直接做互换 sanity,占位函数届时删除。这是有意分两步,避免 core 单测依赖 runner 才有的数据结构。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `npx tsx --test scripts/verify/repro-core.test.ts` +Expected: PASS (8/8) + +- [ ] **Step 5: commit** + +```bash +git add scripts/verify/repro-core.ts scripts/verify/repro-core.test.ts +git commit -m "feat(verify): add repro-core — pure matcher eval + verdict + worktree cmd construction" +``` + +--- + +## Task 3: `worktree-shell.ts` —— git worktree 编排 + node_modules junction + +**Files:** +- Create: `scripts/verify/worktree-shell.ts` + +- [ ] **Step 1: 实现 Imperative Shell** + +```ts +// scripts/verify/worktree-shell.ts +import { spawnSync } from "node:child_process"; +import { mkdtempSync, symlinkSync, existsSync, statSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { buildWorktreeAddArgs, buildWorktreeRemoveArgs, makeBaselineDir } from "./repro-core.ts"; + +export interface BaselinePrep { + /** baseline worktree 的绝对路径 */ + dir: string; + /** baseline 检出的 ref(实际值,resolveBaselineRef 之后) */ + ref: string; + /** 调它清理 baseline worktree(只在调用方 finally 里跑) */ + cleanup: () => void; +} + +/** 解析 BaselineRef.ref:有就用、没有就 git merge-base HEAD (默认 main)。 */ +export function resolveBaselineRef(repoRoot: string, ref: string | undefined, baseBranch: string | undefined): string { + if (ref && ref.length > 0) return ref; + const branch = baseBranch ?? "main"; + const r = spawnSync("git", ["merge-base", "HEAD", branch], { cwd: repoRoot, encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`git merge-base HEAD ${branch} 失败: ${r.stderr.trim()}`); + return r.stdout.trim(); +} + +/** 在 os.tmpdir() 下建一个 baseline worktree,返回路径与 cleanup。 */ +export function prepareBaselineWorktree(opts: { repoRoot: string; specId: string; ref: string }): BaselinePrep { + const rand6 = createHash("sha256").update(`${Date.now()}-${Math.random()}`).digest("hex").slice(0, 6); + const dir = makeBaselineDir(tmpdir(), opts.specId, rand6); + const r = spawnSync("git", buildWorktreeAddArgs(dir, opts.ref), { cwd: opts.repoRoot, encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`git worktree add 失败: ${r.stderr.trim()}`); + const cleanup = () => { + const rm = spawnSync("git", buildWorktreeRemoveArgs(dir), { cwd: opts.repoRoot, encoding: "utf-8" }); + if (rm.status !== 0) { + // 不抛 —— 清理失败不该掩盖原始错误;但要打印 + process.stderr.write(`[warn] git worktree remove 失败: ${rm.stderr.trim()}\n`); + } + }; + return { dir, ref: opts.ref, cleanup }; +} + +/** 若两侧 package.json + pnpm-lock.yaml hash 一致,把 current 的 node_modules 以 junction 方式挂到 baseline,省去 install。 + * 返回 true 表示成功 junction;false 表示 hash 不一致或 junction 失败,调用方应跑 `pnpm install`。 */ +export function tryJunctionNodeModules(currentDir: string, baselineDir: string): boolean { + const currentLock = path.join(currentDir, "pnpm-lock.yaml"); + const baselineLock = path.join(baselineDir, "pnpm-lock.yaml"); + const currentPkg = path.join(currentDir, "package.json"); + const baselinePkg = path.join(baselineDir, "package.json"); + if (!existsSync(currentLock) || !existsSync(baselineLock)) return false; + const lockSame = sha(readFileSync(currentLock)) === sha(readFileSync(baselineLock)); + const pkgSame = sha(readFileSync(currentPkg)) === sha(readFileSync(baselinePkg)); + if (!(lockSame && pkgSame)) return false; + const currentNm = path.join(currentDir, "node_modules"); + const baselineNm = path.join(baselineDir, "node_modules"); + if (!existsSync(currentNm) || !statSync(currentNm).isDirectory()) return false; + if (existsSync(baselineNm)) return false; // 已存在 → 不覆盖 + try { + // Windows: junction;其它平台: dir symlink + const type = process.platform === "win32" ? "junction" : "dir"; + symlinkSync(currentNm, baselineNm, type); + return true; + } catch { + return false; + } +} + +function sha(buf: Buffer): string { + return createHash("sha256").update(buf).digest("hex"); +} + +/** 测试钩子:让 unit/smoke 能注入临时 mkdtemp 路径 */ +export function _internalMkdtemp(prefix: string): string { + return mkdtempSync(path.join(tmpdir(), prefix)); +} +``` + +- [ ] **Step 2: 手动冒烟** + +```bash +npx tsx -e " +import { resolveBaselineRef, prepareBaselineWorktree, tryJunctionNodeModules } from './scripts/verify/worktree-shell.ts'; +const root = process.cwd(); +const ref = resolveBaselineRef(root, undefined, 'main'); +console.log('baseline ref =', ref); +const prep = prepareBaselineWorktree({ repoRoot: root, specId: 'smoke', ref }); +console.log('baseline dir =', prep.dir); +const j = tryJunctionNodeModules(root, prep.dir); +console.log('junction node_modules =', j); +prep.cleanup(); +console.log('cleanup ok'); +" +``` + +Expected: +``` +baseline ref = +baseline dir = /repro-baseline-smoke-<6hex> +junction node_modules = true # 当 baseline 与 current package.json/lock 一致时 +cleanup ok +``` + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/worktree-shell.ts +git commit -m "feat(verify): add worktree-shell — git worktree + node_modules junction for baseline prep" +``` + +--- + +## Task 4: `repro-runner.ts` —— 在两侧跑 steps + 计算 verdict + +**Files:** +- Create: `scripts/verify/repro-runner.ts` +- Modify: `scripts/verify/repro-core.ts:1-200`(把 `computeVerdict` 改成接收 `mergedBefore/mergedAfter` 双参版,删掉 Task 2 的 `sideMatchesMerged` 占位) +- Modify: `scripts/verify/repro-core.test.ts`(同步 `computeVerdict` 调用签名;新增 1 个 ambiguous 测试) + +- [ ] **Step 1: 重构 `computeVerdict`(删占位、接 mergedStep)** + +```ts +// scripts/verify/repro-core.ts —— 仅替换 computeVerdict + 删除 sideMatchesMerged +export function computeVerdict( + before: SideResult, + after: SideResult, + mergedBefore: StepResult, + mergedAfter: StepResult, + beforeMatcher: ResultMatcher, + afterMatcher: ResultMatcher, +): { verdict: Verdict; verdictReason: string } { + if (!before.matcherOk || !after.matcherOk) { + const why: string[] = []; + if (!before.matcherOk) why.push(`before 期望未达: ${before.matcherReasons.join("; ")}`); + if (!after.matcherOk) why.push(`after 期望未达: ${after.matcherReasons.join("; ")}`); + return { verdict: "fail", verdictReason: why.join(" | ") }; + } + // 互换 matcher sanity + const beforeUnderAfter = evalMatcher(mergedBefore, afterMatcher); + const afterUnderBefore = evalMatcher(mergedAfter, beforeMatcher); + if (beforeUnderAfter.ok && afterUnderBefore.ok) { + return { verdict: "ambiguous", verdictReason: "before 也命中了 after 的期望,且 after 也命中了 before 的期望 —— 对比未严谨" }; + } + return { verdict: "pass", verdictReason: "before 与 after 期望均成立,且互换 matcher 后对比仍区分两侧" }; +} +``` + +- [ ] **Step 2: 改 `repro-core.test.ts`(更新签名 + 加 ambiguous 测试)** + +Task 2 的 8 个测试里只有 2 个调 `computeVerdict`(测试编号 #6 + #7),其它 6 个不动。逐字替换那 2 个为新签名,并在末尾追加 ambiguous 测试: + +```ts +// === 替换 Task 2 第 #6 个测试(原 "computeVerdict: 两条都 ok → pass") === +test("computeVerdict: before 期望成立 + after 期望成立 + 互换 matcher 区分两侧 → pass", () => { + const before: SideResult = { side: "before", steps: [], matcherOk: true, matcherReasons: [] }; + const after: SideResult = { side: "after", steps: [], matcherOk: true, matcherReasons: [] }; + const mergedBefore = okStep({ stdout: "通过 (无规则命中)" }); + const mergedAfter = okStep({ stdout: "决策: deny 应改用: dayjs" }); + const r = computeVerdict( + before, after, mergedBefore, mergedAfter, + { stdoutContains: ["通过"] }, { stdoutContains: ["deny"] }, + ); + assert.equal(r.verdict, "pass"); +}); + +// === 替换 Task 2 第 #7 个测试(原 "computeVerdict: 任一侧 matcherOk false → fail") === +test("computeVerdict: 任一侧 matcherOk 为 false → fail", () => { + const before: SideResult = { side: "before", steps: [], matcherOk: false, matcherReasons: ["缺 通过"] }; + const after: SideResult = { side: "after", steps: [], matcherOk: true, matcherReasons: [] }; + const mergedBefore = okStep(); + const mergedAfter = okStep(); + const r = computeVerdict(before, after, mergedBefore, mergedAfter, {}, {}); + assert.equal(r.verdict, "fail"); +}); + +// === 新增第 9 个测试 === +test("computeVerdict: 互换 matcher 双方都命中 → ambiguous", () => { + const before: SideResult = { side: "before", steps: [], matcherOk: true, matcherReasons: [] }; + const after: SideResult = { side: "after", steps: [], matcherOk: true, matcherReasons: [] }; + const sameOutput = okStep({ stdout: "X" }); + const r = computeVerdict( + before, after, sameOutput, sameOutput, + { stdoutContains: ["X"] }, { stdoutContains: ["X"] }, + ); + assert.equal(r.verdict, "ambiguous"); +}); +``` + +其它 6 个测试(`evalMatcher` × 4 + `mergeStepOutputs` × 1 + `buildWorktreeAddArgs/Remove` × 1)**不调用 computeVerdict,无需改动**。 + +- [ ] **Step 3: 跑测试确认通过(9/9)** + +Run: `npx tsx --test scripts/verify/repro-core.test.ts` +Expected: PASS (9/9) + +- [ ] **Step 4: 实现 `repro-runner.ts`** + +```ts +// scripts/verify/repro-runner.ts +import { spawn } from "node:child_process"; +import { evalMatcher, mergeStepOutputs, computeVerdict } from "./repro-core.ts"; +import type { + ReproSpec, ReproResult, ReproStep, SideResult, StepResult, +} from "./repro-types.ts"; + +interface RunSideOpts { + side: "before" | "after"; + cwd: string; // 该侧的 worktree 根目录 + baselineEnv: Record; // ReproSpec.baseline.env / current.env + steps: ReproStep[]; +} + +async function runStep(step: ReproStep, defaultCwd: string, sideEnv: Record): Promise { + const start = Date.now(); + const env = { ...process.env, ...sideEnv, ...(step.env ?? {}) }; + const timeoutMs = step.timeoutMs ?? 30_000; + return new Promise((resolve) => { + const child = spawn(step.command, step.args, { + cwd: step.cwd ?? defaultCwd, env, windowsHide: true, shell: false, + }); + let stdout = ""; let stderr = ""; let timedOut = false; + child.stdout.on("data", (b) => { stdout += b.toString(); }); + child.stderr.on("data", (b) => { stderr += b.toString(); }); + const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ + stepName: step.name, + exitCode: timedOut ? null : code, + stdout, stderr, + durationMs: Date.now() - start, + timedOut, + }); + }); + child.on("error", (e) => { + clearTimeout(timer); + resolve({ + stepName: step.name, exitCode: null, stdout, stderr: stderr + `\n[spawn error] ${e.message}`, + durationMs: Date.now() - start, timedOut: false, + }); + }); + }); +} + +async function runSide(opts: RunSideOpts, matcher: import("./repro-types.ts").ResultMatcher): Promise<{ side: SideResult; merged: StepResult }> { + const stepResults: StepResult[] = []; + for (const step of opts.steps) { + stepResults.push(await runStep(step, opts.cwd, opts.baselineEnv)); + } + const merged = mergeStepOutputs(stepResults); + const m = evalMatcher(merged, matcher); + const side: SideResult = { side: opts.side, steps: stepResults, matcherOk: m.ok, matcherReasons: m.reasons }; + return { side, merged }; +} + +export interface RunReproOpts { + spec: ReproSpec; + currentDir: string; // 一般 = process.cwd() + baselineDir: string; // 由 worktree-shell.prepareBaselineWorktree 提供 +} + +export async function runRepro(opts: RunReproOpts): Promise { + const { spec, currentDir, baselineDir } = opts; + const before = await runSide({ + side: "before", cwd: baselineDir, + baselineEnv: spec.baseline.env ?? {}, steps: spec.steps, + }, spec.expect.before); + const after = await runSide({ + side: "after", cwd: currentDir, + baselineEnv: spec.current.env ?? {}, steps: spec.steps, + }, spec.expect.after); + const v = computeVerdict( + before.side, after.side, + before.merged, after.merged, + spec.expect.before, spec.expect.after, + ); + return { + specId: spec.id, + generatedAt: new Date().toISOString(), + before: before.side, + after: after.side, + verdict: v.verdict, + verdictReason: v.verdictReason, + }; +} +``` + +- [ ] **Step 5: commit** + +```bash +git add scripts/verify/repro-runner.ts scripts/verify/repro-core.ts scripts/verify/repro-core.test.ts +git commit -m "feat(verify): add repro-runner — run steps on baseline+current sides, compute verdict" +``` + +--- + +## Task 5: `repro-cli.ts` —— CLI 入口(读 spec → 跑 → 可选录 GIF → 写 JSON) + +**Files:** +- Create: `scripts/verify/repro-cli.ts` + +> **依赖**:本 task 默认调用 `recordGif()` —— 它在 `verification-tooling.md` Phase 1 Task 3 产出。如果 Phase 1 还没实现,把第 4 步的 `import { recordGif } from "./record-gif.ts"` 改成 `const recordGif = (..._args: unknown[]) => { throw new Error("recordGif 未实现 —— 见 verification-tooling.md Phase 1"); };`,并跑 `npx tsx scripts/verify/repro-cli.ts --no-gif` 跑通骨架。 + +- [ ] **Step 1: 实现 CLI** + +```ts +// scripts/verify/repro-cli.ts +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { resolveBaselineRef, prepareBaselineWorktree, tryJunctionNodeModules } from "./worktree-shell.ts"; +import { runRepro } from "./repro-runner.ts"; +import type { ReproSpec, ReproResult } from "./repro-types.ts"; +import { recordGif } from "./record-gif.ts"; // 见上方 Phase 1 依赖说明 + +interface CliOpts { specPath: string; outDir: string; noGif: boolean; } + +function parseArgv(argv: string[]): CliOpts { + const opts: CliOpts = { specPath: "", outDir: "", noGif: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--no-gif") opts.noGif = true; + else if (a === "--out") opts.outDir = argv[++i]; + else if (!opts.specPath) opts.specPath = a; + } + if (!opts.specPath) throw new Error("用法: tsx scripts/verify/repro-cli.ts [--out ] [--no-gif]"); + if (!opts.outDir) opts.outDir = path.join("docs/acceptance", path.basename(opts.specPath, path.extname(opts.specPath))); + return opts; +} + +async function main(): Promise { + const opts = parseArgv(process.argv.slice(2)); + const repoRoot = process.cwd(); + // 1. 动态加载 ReproSpec + const mod = await import(path.resolve(opts.specPath)); + const spec: ReproSpec = mod.default ?? mod.spec; + if (!spec || typeof spec.id !== "string") throw new Error(`${opts.specPath} 必须 export default 或 export const spec: ReproSpec`); + mkdirSync(opts.outDir, { recursive: true }); + + // 2. 准备 baseline worktree + const ref = resolveBaselineRef(repoRoot, spec.baseline.ref, spec.baseline.baseBranch); + const prep = prepareBaselineWorktree({ repoRoot, specId: spec.id, ref }); + console.log(`[baseline] ref=${ref} dir=${prep.dir}`); + const junctioned = tryJunctionNodeModules(repoRoot, prep.dir); + console.log(`[baseline] node_modules junction=${junctioned}${junctioned ? "" : " (若 spec 跑命令需 deps,请手动到 baseline 跑 pnpm install,或在 spec.steps 里加 install step)"}`); + + let result: ReproResult; + try { + // 3. 跑 suite-1 + result = await runRepro({ spec, currentDir: repoRoot, baselineDir: prep.dir }); + } finally { + prep.cleanup(); + } + const jsonPath = path.join(opts.outDir, "repro-result.json"); + writeFileSync(jsonPath, JSON.stringify(result, null, 2), "utf-8"); + console.log(`[result] verdict=${result.verdict} → ${jsonPath}`); + + // 4. 可选录 GIF + const winRecordable = process.platform === "win32"; + if (spec.demoScene && !opts.noGif && winRecordable) { + console.log(`[gif] recording demoScene → ${opts.outDir}`); + const { gif, mp4 } = recordGif({ + sceneScript: spec.demoScene.sceneScript, + windowTitle: spec.demoScene.windowTitle, + durationSec: spec.demoScene.durationSec, + outDir: opts.outDir, + }); + console.log(`[gif] mp4=${mp4} gif=${gif}`); + } else if (spec.demoScene && opts.noGif) { + console.log(`[gif] skipped (--no-gif)`); + } else if (spec.demoScene && !winRecordable) { + console.log(`[gif] skipped (platform ${process.platform} 不支持 gdigrab)`); + } + + // 5. 退出码:pass=0 fail/ambiguous=非 0 + process.exit(result.verdict === "pass" ? 0 : 1); +} + +main().catch((e) => { console.error(e); process.exit(2); }); +``` + +- [ ] **Step 2: 用临时空 spec 冒烟** + +先建一个最小化空 spec(steps 只跑 `node -e "console.log('after')"`,baseline 输出会一样 → 期望是 fail/ambiguous,验证退出码非 0): + +```bash +cat > /tmp/empty-spec.ts <<'TS' +import type { ReproSpec } from "C:/bzli/Matrix/scripts/verify/repro-types.ts"; +const spec: ReproSpec = { + id: "smoke-empty", + description: "smoke test —— 两侧跑同样的命令,期望 ambiguous", + baseline: {}, + current: {}, + steps: [{ name: "echo", command: process.execPath, args: ["-e", "console.log('hello')"] }], + expect: { + before: { stdoutContains: ["hello"] }, + after: { stdoutContains: ["hello"] }, + }, +}; +export default spec; +TS +npx tsx scripts/verify/repro-cli.ts /tmp/empty-spec.ts --no-gif --out /tmp/smoke-out +echo "exit code: $?" +cat /tmp/smoke-out/repro-result.json | grep verdict +``` + +Expected: +- 退出码 = 1 +- `repro-result.json` 里 `"verdict": "ambiguous"`(两侧输出一样,互换 matcher 双方都命中) + +- [ ] **Step 3: commit** + +```bash +git add scripts/verify/repro-cli.ts +git commit -m "feat(verify): add repro-cli — orchestrates baseline prep + run + optional GIF" +``` + +--- + +## Task 6: `fixtures/repro-specs/hook-moment-block.ts` —— 第一份真实样板 + +**Files:** +- Create: `fixtures/repro-specs/hook-moment-block.ts` +- Create: `fixtures/repro-specs/README.md` + +> **背景**:把 `docs/acceptance/2026-05-14-hook-moment-block/` 的人工验收改写为可执行 ReproSpec。要 reproduce 现有样板的两屏: +> - before:`USERPROFILE=` 跑 `teamagent demo hook Bash command="npm install moment"` → 输出含 `通过 (无规则命中)` +> - after:`USERPROFILE=` 跑同命令 → 输出含 `决策: deny` + `应改用` + `dayjs` +> 两侧代码都跑 **当前 worktree 的 dist/bin.js**(本 feature 跟代码无关,仅靠 env 切运行时状态)—— 因此 baseline.ref 用 HEAD,本质是「同代码、不同 env」。 + +- [ ] **Step 1: 写 spec** + +```ts +// fixtures/repro-specs/hook-moment-block.ts +import path from "node:path"; +import { momentDayjsScenario } from "../scenarios/moment-dayjs.ts"; +import type { ReproSpec } from "../../scripts/verify/repro-types.ts"; + +// 注:本 feature 是「数据驱动」差异 —— 同代码、不同知识库状态,所以 baseline.ref = HEAD。 +// stage 目录由调用方在 spec 跑之前预备(或手动按 docs/acceptance/2026-05-14-hook-moment-block/recording/README.md 准备)。 +const stage = process.env.TA_DEMO_STAGE ?? "C:/Users/tianhaoxuan/ta-demo-stage"; + +const spec: ReproSpec = { + id: "hook-moment-block", + description: "PreToolUse hook 在「学到经验后」拦截 npm install moment,建议 dayjs", + baseline: { + ref: "HEAD", // 同代码;差异在 env 切的知识库 home + env: { USERPROFILE: `${stage}/home-empty`, HOME: `${stage}/home-empty` }, + }, + current: { + env: { USERPROFILE: `${stage}/home-loaded`, HOME: `${stage}/home-loaded` }, + }, + steps: [ + { + name: "demo-hook-npm-install-moment", + command: process.execPath, // node + args: [ + path.resolve("packages/teamagent/dist/bin.js"), + "demo", "hook", "Bash", `command=npm install moment`, + ], + timeoutMs: 30_000, + }, + ], + expect: { + before: { + exitCode: 0, + stdoutContains: ["通过 (无规则命中)"], + stdoutNotContains: ["deny", "应改用"], + }, + after: { + exitCode: 0, + stdoutContains: ["决策: deny", "应改用", "dayjs"], + stdoutNotContains: ["通过 (无规则命中)"], + }, + }, + scenario: momentDayjsScenario, // 复用现成 Scenario 元数据 + demoScene: { + sceneScript: path.resolve("docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1"), + windowTitle: "TADEMOREC", + durationSec: 40, + }, +}; + +export default spec; +``` + +- [ ] **Step 2: 跑端到端** + +> **前置**:`$stage/home-empty` 与 `$stage/home-loaded` 两个目录已按 `docs/acceptance/2026-05-14-hook-moment-block/recording/README.md` 备好(后者含 init 后的 `.viki/knowledge.db`)。若没备,先跑那份 README 的步骤,或 `set TA_DEMO_STAGE=` 指向已备好的目录。 + +```bash +npx tsx scripts/verify/repro-cli.ts fixtures/repro-specs/hook-moment-block.ts --no-gif \ + --out docs/acceptance/2026-05-15-hook-moment-block-repro +``` + +Expected: +- 退出码 = 0 +- `docs/acceptance/2026-05-15-hook-moment-block-repro/repro-result.json` 存在,`"verdict": "pass"`,`"verdictReason"` 含 "互换 matcher 后对比仍区分" + +- [ ] **Step 3: 写 README(怎么写一份 ReproSpec)** + +```markdown + +# 怎么写一份 ReproSpec + +ReproSpec 是套件 1(复现验证代码框架)的输入。一份 spec ≈ 「这个 feature 是否实现」的可执行定义。 +设计决议见 `docs/plans/2026-05-15-suite-1-brainstorm.md`,实现见 `scripts/verify/repro-*.ts`。 + +## 最小骨架 + +\`\`\`ts +import type { ReproSpec } from "../../scripts/verify/repro-types.ts"; + +const spec: ReproSpec = { + id: "kebab-case-id", // 建议与 docs/acceptance/-/ 一致 + description: "一句话说明这个 feature", + baseline: { /* 怎么切到 before 态 */ }, + current: { /* 一般留空,默认 = 当前 worktree */ }, + steps: [{ + name: "step-name", + command: process.execPath, + args: ["..."], + }], + expect: { + before: { /* before 应满足的 ResultMatcher */ }, + after: { /* after 应满足的 ResultMatcher */ }, + }, +}; +export default spec; +\`\`\` + +## 三种典型 feature 的写法 + +### 1. 数据驱动差异(同代码、不同状态) +`baseline.ref = "HEAD"` + `baseline.env` / `current.env` 切运行时状态。样板: +[`hook-moment-block.ts`](./hook-moment-block.ts)。 + +### 2. 代码驱动差异(改了代码) +`baseline.ref` 留空 → CLI 自动算 `git merge-base HEAD main`。两侧分别在两个 worktree 跑同命令,期望产出不同。 + +### 3. 既改代码又依赖运行时状态 +两侧都用 `env` 字段,`baseline.ref` 保持「PR 起点」即可。 + +## 怎么写好 `expect` + +- **`stdoutContains` 写「后果」、不要写「过程」**。比如「拦截 moment」就写 `["deny", "应改用", "dayjs"]`(后果), + 不要写「调用 matcher」(过程)。 +- **同时给 `stdoutNotContains`**。两侧应**互斥**才算严谨对比;否则 verdict 会是 `ambiguous`。 +- **exitCode 谨慎用**。很多 CLI 即使逻辑失败也返回 0(把信息写在 stdout)。除非命令明确以 exitCode 区分两态,否则别钉 exitCode。 + +## 怎么 run + +```bash +# 跑 spec(默认录 GIF;CI 上加 --no-gif) +npx tsx scripts/verify/repro-cli.ts fixtures/repro-specs/.ts \ + --out docs/acceptance/- + +# 看 verdict +cat docs/acceptance/-/repro-result.json | jq .verdict +``` +``` + +- [ ] **Step 4: commit** + +```bash +git add fixtures/repro-specs/hook-moment-block.ts fixtures/repro-specs/README.md docs/acceptance/2026-05-15-hook-moment-block-repro/ +git commit -m "feat(verify): add hook-moment-block ReproSpec sample + README — first real suite-1 spec" +``` + +--- + +## Self-Review + +**1. Spec coverage:** 4 条 brainstorm 决议:① Q1 ReproSpec 内嵌 Scenario → Task 1 类型;② Q2 两 worktree + env → Task 3 worktree-shell + Task 4 runner 双侧;③ Q3 解耦录 GIF → Task 5 CLI 末段;④ Q4 确定性 matcher → Task 2 evalMatcher + computeVerdict。`hook-moment-block` 样板覆盖 Q2 的「数据驱动差异」典型 + 复用 `momentDayjsScenario` 验证 Q1 的内嵌路径。**已知缺口**:Layer B(独立 subagent review)在另一份 plan(`2026-05-15-local-review-loop.md`)。 + +**2. Placeholder scan:** Task 2 `sideMatchesMerged` 是有意占位,Task 4 显式删除并替换 —— 在文档里说明而非偷偷留下。Task 5 对 `recordGif` 的依赖在 task 开头明确给了 fallback。无 "TBD" / "实现错误处理" 等空话。 + +**3. Type consistency:** `ReproSpec` 字段 `id/description/baseline/current/steps/expect/scenario/demoScene` 在 Task 1 定义,Task 5 CLI 使用 `spec.id/spec.baseline.ref/spec.demoScene`,Task 6 样板 export 的 `spec` 用同名字段。`StepResult` 字段在 Task 1 定义,Task 2 `mergeStepOutputs` 与 Task 4 `runStep` 都按同名字段构造。`computeVerdict` 签名 Task 2 → Task 4 显式重构(写在 Task 4 Step 1),不是隐式改动。 + +## Execution Handoff + +Plan complete. 推荐执行路径: + +1. **先确认前置**:Phase 1 `recordGif()`(`verification-tooling.md` Task 3)是否已实现?未实现也能跑本 plan 主体(Task 5 提供了 fallback);要跑 hook-moment-block 端到端 GIF 需先做 Phase 1。 +2. **执行**:Task 1–6 串行(Task 4 重构 Task 2 的函数签名 → 不能跳序)。建议 subagent-driven,每 Task 一个独立 subagent。 +3. **验收**:跑 `fixtures/repro-specs/hook-moment-block.ts` 端到端,看到 `verdict: "pass"` 即套件 1 落地。 diff --git a/fixtures/repro-specs/README.md b/fixtures/repro-specs/README.md new file mode 100644 index 0000000..51c009a --- /dev/null +++ b/fixtures/repro-specs/README.md @@ -0,0 +1,66 @@ +# 怎么写一份 ReproSpec + +ReproSpec 是套件 1(复现验证代码框架)的输入。一份 spec ≈「这个 feature 是否实现」的可执行定义。 +设计决议见 [`docs/plans/2026-05-15-suite-1-brainstorm.md`](../../docs/plans/2026-05-15-suite-1-brainstorm.md), +实现见 [`scripts/verify/repro-*.ts`](../../scripts/verify/)。 + +## 最小骨架 + +```ts +import type { ReproSpec } from "../../scripts/verify/repro-types.ts"; + +const spec: ReproSpec = { + id: "kebab-case-id", // 建议与 docs/acceptance/-/ 一致 + description: "一句话说明这个 feature", + baseline: { /* 怎么切到 before 态 */ }, + current: { /* 一般留空,默认 = 当前 worktree */ }, + steps: [{ + name: "step-name", + command: process.execPath, + args: ["..."], + }], + expect: { + before: { /* before 应满足的 ResultMatcher */ }, + after: { /* after 应满足的 ResultMatcher */ }, + }, +}; +export default spec; +``` + +## 三种典型 feature 的写法 + +### 1. 数据驱动差异(同代码、不同状态) +`baseline.ref = "HEAD"` + `baseline.env` / `current.env` 切运行时状态。样板: +[`hook-moment-block.ts`](./hook-moment-block.ts)。 + +### 2. 代码驱动差异(改了代码) +`baseline.ref` 留空 → CLI 自动算 `git merge-base HEAD main`。两侧分别在两个 worktree 跑同命令,期望产出不同。 + +### 3. 既改代码又依赖运行时状态 +两侧都用 `env` 字段,`baseline.ref` 保持「PR 起点」即可。 + +## 怎么写好 `expect` + +- **`stdoutContains` 写「后果」、不要写「过程」**。比如「拦截 moment」就写 `["deny", "应改用", "dayjs"]`(后果), + 不要写「调用 matcher」(过程)。 +- **同时给 `stdoutNotContains`**。两侧应**互斥**才算严谨对比;否则 verdict 会是 `ambiguous`。 +- **exitCode 谨慎用**。很多 CLI 即使逻辑失败也返回 0(把信息写在 stdout)。除非命令明确以 exitCode 区分两态,否则别钉 exitCode。 + +## 怎么 run + +```bash +# 跑 spec(默认录 GIF;CI 上加 --no-gif) +npx tsx scripts/verify/repro-cli.ts fixtures/repro-specs/.ts \ + --out docs/acceptance/- + +# 看 verdict +cat docs/acceptance/-/repro-result.json | jq .verdict +``` + +## 已知前置(spec 跑之前) + +- 若 spec 里的 step 跑的是 `packages/teamagent/dist/bin.js`(常见情况), + 需先 `pnpm --filter teamagent build`。 +- 若 spec 用 env 切 `USERPROFILE` 指向预备好的 stage 目录, + 目录得提前备好 —— 比如 `hook-moment-block` 的 stage 见 + [`docs/acceptance/2026-05-14-hook-moment-block/recording/README.md`](../../docs/acceptance/2026-05-14-hook-moment-block/recording/README.md)。 diff --git a/fixtures/repro-specs/hook-moment-block.ts b/fixtures/repro-specs/hook-moment-block.ts new file mode 100644 index 0000000..8d5312f --- /dev/null +++ b/fixtures/repro-specs/hook-moment-block.ts @@ -0,0 +1,59 @@ +// fixtures/repro-specs/hook-moment-block.ts +// +// 第一份真实 ReproSpec 样板 —— 把 docs/acceptance/2026-05-14-hook-moment-block/ +// 的人工验收改写为可自动跑的 spec。 +// +// 这个 feature 是「数据驱动差异」(同代码、不同知识库状态),所以 baseline.ref = HEAD, +// 两侧靠 env 切的 USERPROFILE 来切 teamagent 知识库 home。 +// +// 前置:$TA_DEMO_STAGE/home-empty 与 $TA_DEMO_STAGE/home-loaded 两个目录已按 +// docs/acceptance/2026-05-14-hook-moment-block/recording/README.md 备好。 + +import path from "node:path"; +import { momentDayjsScenario } from "../scenarios/moment-dayjs.ts"; +import type { ReproSpec } from "../../scripts/verify/repro-types.ts"; + +const stage = process.env["TA_DEMO_STAGE"] ?? "C:/Users/tianhaoxuan/ta-demo-stage"; + +const spec: ReproSpec = { + id: "hook-moment-block", + description: "PreToolUse hook 在「学到经验后」拦截 npm install moment,建议 dayjs", + baseline: { + ref: "HEAD", // 同代码;差异在 env 切的知识库 home + env: { USERPROFILE: `${stage}/home-empty`, HOME: `${stage}/home-empty` }, + }, + current: { + env: { USERPROFILE: `${stage}/home-loaded`, HOME: `${stage}/home-loaded` }, + }, + steps: [ + { + name: "demo-hook-npm-install-moment", + command: process.execPath, // node + args: [ + path.resolve("packages/teamagent/dist/bin.js"), + "demo", "hook", "Bash", `command=npm install moment`, + ], + timeoutMs: 30_000, + }, + ], + expect: { + before: { + exitCode: 0, + stdoutContains: ["通过 (无规则命中)"], + stdoutNotContains: ["deny", "应改用"], + }, + after: { + exitCode: 0, + stdoutContains: ["决策: deny", "应改用", "dayjs"], + stdoutNotContains: ["通过 (无规则命中)"], + }, + }, + scenario: momentDayjsScenario, // 复用现成 Scenario 元数据 + demoScene: { + sceneScript: path.resolve("docs/acceptance/2026-05-14-hook-moment-block/recording/demo-scene.ps1"), + windowTitle: "TADEMOREC", + durationSec: 40, + }, +}; + +export default spec; diff --git a/scripts/automerge/can-auto-merge.test.ts b/scripts/automerge/can-auto-merge.test.ts new file mode 100644 index 0000000..c73ed14 --- /dev/null +++ b/scripts/automerge/can-auto-merge.test.ts @@ -0,0 +1,87 @@ +// scripts/automerge/can-auto-merge.test.ts +// +// Task 1 of docs/plans/2026-05-15-auto-merge.md. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { canAutoMerge } from "./can-auto-merge.ts"; +import type { PrSnapshot } from "./can-auto-merge.ts"; + +const ok: PrSnapshot = { + number: 1, + baseRefName: "main", + isDraft: false, + body: "Feature X", + labels: [], + state: "OPEN", + mergeable: "MERGEABLE", + reviewVerdictState: "success", + isFromInternalRepo: true, +}; + +test("基本通过:全条件 OK → merge=true", () => { + const r = canAutoMerge(ok); + assert.equal(r.merge, true); +}); + +test("draft → skip", () => { + const r = canAutoMerge({ ...ok, isDraft: true }); + assert.equal(r.merge, false); + assert.match(r.reason, /draft/); +}); + +test("base 非 main → skip(POSTPR squash 模型禁止 stacked)", () => { + const r = canAutoMerge({ ...ok, baseRefName: "user/dev" }); + assert.equal(r.merge, false); + assert.match(r.reason, /baseRefName|stacked/); +}); + +test("PR body 含 ## Visual proof of work → skip(human-merge only)", () => { + const r = canAutoMerge({ + ...ok, + body: "Feature X\n\n## Visual proof of work\n\n![gif](demo.gif)", + }); + assert.equal(r.merge, false); + assert.match(r.reason, /visual.proof/i); +}); + +test("Visual proof 标题大小写/空白宽松匹配", () => { + const variants = [ + "## VISUAL PROOF OF WORK\n\nx", + " ## visual proof of work \n\nx", + "Feature\n\n##\tvisual proof of work\n", + ]; + for (const body of variants) { + assert.equal(canAutoMerge({ ...ok, body }).merge, false, `应 skip: ${body.slice(0, 30)}`); + } +}); + +test("有 do-not-merge label → skip", () => { + assert.equal(canAutoMerge({ ...ok, labels: ["do-not-merge"] }).merge, false); +}); + +test("review/verdict status 非 success → skip(failure / pending / missing)", () => { + assert.equal(canAutoMerge({ ...ok, reviewVerdictState: "failure" }).merge, false); + assert.equal(canAutoMerge({ ...ok, reviewVerdictState: "pending" }).merge, false); + assert.equal(canAutoMerge({ ...ok, reviewVerdictState: "missing" }).merge, false); +}); + +test("外部 fork PR → skip(pr-review.yml 也不评审外部 fork,这里兜底)", () => { + assert.equal(canAutoMerge({ ...ok, isFromInternalRepo: false }).merge, false); +}); + +test("PR state CLOSED/MERGED → skip(重入兜底)", () => { + assert.equal(canAutoMerge({ ...ok, state: "CLOSED" }).merge, false); + assert.equal(canAutoMerge({ ...ok, state: "MERGED" }).merge, false); +}); + +test("mergeable=CONFLICTING/UNKNOWN → skip", () => { + assert.equal(canAutoMerge({ ...ok, mergeable: "CONFLICTING" }).merge, false); + assert.equal(canAutoMerge({ ...ok, mergeable: "UNKNOWN" }).merge, false); +}); + +test("通过时 reason 也填充(便于审计)", () => { + const r = canAutoMerge(ok); + assert.equal(r.merge, true); + assert.ok(r.reason.length > 0, "通过时也应该写 reason"); +}); diff --git a/scripts/automerge/can-auto-merge.ts b/scripts/automerge/can-auto-merge.ts new file mode 100644 index 0000000..9ad5dca --- /dev/null +++ b/scripts/automerge/can-auto-merge.ts @@ -0,0 +1,74 @@ +// scripts/automerge/can-auto-merge.ts +// +// 纯门控逻辑:输入 PR metadata → 输出是否能自动 squash 合主分支。 +// 严禁 import fs / child_process —— IO 在 auto-merge.yml 的 shell step。 +// 见 docs/plans/2026-05-15-auto-merge.md Task 1。 + +export interface PrSnapshot { + number: number; + baseRefName: string; + isDraft: boolean; + body: string; + labels: string[]; + state: "OPEN" | "CLOSED" | "MERGED"; + mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; + /** GitHub 上 review/verdict 这条 commit status 的状态 */ + reviewVerdictState: "success" | "failure" | "pending" | "missing"; + /** PR head 是否在仓库内(非 fork) */ + isFromInternalRepo: boolean; +} + +export interface AutoMergeDecision { + merge: boolean; + /** 不论通过与否,都给一个 reason 便于审计 */ + reason: string; +} + +export function canAutoMerge(pr: PrSnapshot): AutoMergeDecision { + if (pr.state !== "OPEN") { + return { merge: false, reason: `PR state=${pr.state},非 OPEN,跳过` }; + } + if (pr.isDraft) { + return { merge: false, reason: `PR 是 draft,跳过` }; + } + if (!pr.isFromInternalRepo) { + return { merge: false, reason: `PR 来自外部 fork,跳过(安全策略)` }; + } + if (pr.baseRefName !== "main") { + return { + merge: false, + reason: `baseRefName=${pr.baseRefName} ≠ main,stacked PR 在 squash 模型下会丢数据,跳过(见 POSTPR.md)`, + }; + } + if (pr.labels.includes("do-not-merge")) { + return { merge: false, reason: `PR 带 do-not-merge label` }; + } + if (containsVisualProof(pr.body)) { + return { + merge: false, + reason: `PR body 含 "## Visual proof of work" → 走 human-merge(VISUAL-PROOF-HUMAN-MERGE.md 钉死)`, + }; + } + if (pr.reviewVerdictState !== "success") { + return { + merge: false, + reason: `review/verdict commit status = ${pr.reviewVerdictState},非 success`, + }; + } + if (pr.mergeable !== "MERGEABLE") { + return { + merge: false, + reason: `mergeable=${pr.mergeable},等 GitHub 重算或解冲突`, + }; + } + return { + merge: true, + reason: `所有门控通过 —— review verdict success + base main + no flags + mergeable`, + }; +} + +function containsVisualProof(body: string): boolean { + // POSTPR.md / VISUAL-PROOF-HUMAN-MERGE.md 钉的章节标题。 + // 大小写不敏感、允许任意空白(tab / 多个空格);锚定到行首避免假阳性。 + return /^\s*##\s+visual\s+proof\s+of\s+work/im.test(body); +} diff --git a/scripts/review/loop-driver.ts b/scripts/review/loop-driver.ts new file mode 100644 index 0000000..edf75f2 --- /dev/null +++ b/scripts/review/loop-driver.ts @@ -0,0 +1,158 @@ +// scripts/review/loop-driver.ts +// +// Imperative Shell:循环编排 —— review-cli → 不过派 fix subagent → 等 fix-marker → 再 review, +// 直到 verdict=pass、达 maxRetries 上限、或 subagent skip。 +// 见 docs/plans/2026-05-15-local-review-loop.md Task 5。 + +import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { runAllChecks } from "./run-checks.ts"; +import { aggregateVerdict } from "./review-core.ts"; +import type { LoopOptions, ReviewResult } from "./review-types.ts"; + +interface DriverOpts extends LoopOptions { + outDir: string; + /** "agent" = 用 Claude Code Agent tool 派 subagent;"manual" = 打印 prompt 后 exit,人接手 */ + fixMode: "agent" | "manual"; +} + +function tmpRoot(): string { + return process.platform === "win32" + ? (process.env["TEMP"] ?? "C:/Windows/Temp") + : "/tmp"; +} +const FIX_MARKER = path.join(tmpRoot(), "fix-marker"); + +function parseArgv(argv: string[]): DriverOpts { + const o: DriverOpts = { + maxRetries: 3, + reproResultPath: "", + baseBranch: "main", + testCommand: "C:/bzli/Matrix/node_modules/.bin/tsx.cmd", + testArgs: ["--test", "scripts/lock-core.test.ts"], + outDir: "", + // 默认 manual —— Agent tool IPC hook 还未落地;见 SKILL.md + fixMode: process.env["CLAUDE_CODE_AGENT"] === "1" ? "agent" : "manual", + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--repro") o.reproResultPath = argv[++i]!; + else if (a === "--out") o.outDir = argv[++i]!; + else if (a === "--max-retries") o.maxRetries = Number(argv[++i]!); + else if (a === "--base") o.baseBranch = argv[++i]!; + else if (a === "--test-cmd") { + const parts = argv[++i]!.trim().split(/\s+/); + o.testCommand = parts[0]!; + o.testArgs = parts.slice(1); + } else if (a === "--fix-mode") { + o.fixMode = argv[++i] as "agent" | "manual"; + } + } + if (!o.reproResultPath) { + throw new Error( + "用法: tsx scripts/review/loop-driver.ts --repro [--out ] [--max-retries 3] [--fix-mode agent|manual] [--test-cmd \"\"]", + ); + } + if (!o.outDir) o.outDir = path.dirname(o.reproResultPath); + return o; +} + +interface Attempt { + attempt: number; + verdict: "pass" | "fail"; + failureKind?: string; + fixOutcome?: "fixed" | "skipped" | "no-marker" | "manual-mode"; + fixSkipReason?: string; +} + +function runOneRound(opts: DriverOpts): ReviewResult { + const criteria = runAllChecks({ repoRoot: process.cwd(), loop: opts }); + return aggregateVerdict(criteria); +} + +async function dispatchFixSubagent(prompt: string): Promise<{ outcome: "fixed" | "skipped" | "no-marker"; skipReason?: string }> { + // Claude Code 的 Agent tool 是 host CC 的特性,不是 Node API。 + // driver 只能通过约定 IPC 触发:写 fix-prompt + 落 fix-pending 文件; + // 期待 host CC hook 监听 fix-pending → 用 Agent tool 派 subagent → + // subagent 修完 echo done > fix-marker(或 echo "skip:" > fix-marker)。 + // hook 还未实现 → 调本函数实际会卡在轮询。Agent 模式默认关闭(见 fixMode 默认值)。 + const tmp = tmpRoot(); + const promptFile = path.join(tmp, "fix-prompt"); + const pendingFile = path.join(tmp, "fix-pending"); + writeFileSync(promptFile, prompt, "utf-8"); + writeFileSync(pendingFile, new Date().toISOString(), "utf-8"); + console.log(`[driver] 已写 ${promptFile} + ${pendingFile};等待 host hook 派 subagent...`); + + const start = Date.now(); + const TIMEOUT = 30 * 60 * 1000; + while (Date.now() - start < TIMEOUT) { + if (existsSync(FIX_MARKER)) { + const content = readFileSync(FIX_MARKER, "utf-8").trim(); + unlinkSync(FIX_MARKER); + if (existsSync(pendingFile)) unlinkSync(pendingFile); + if (content.startsWith("skip:")) { + return { outcome: "skipped", skipReason: content.slice(5).trim() }; + } + return { outcome: "fixed" }; + } + await new Promise((r) => setTimeout(r, 5000)); + } + return { outcome: "no-marker" }; +} + +async function main(): Promise { + const opts = parseArgv(process.argv.slice(2)); + mkdirSync(opts.outDir, { recursive: true }); + const attempts: Attempt[] = []; + let final: ReviewResult | null = null; + + for (let i = 1; i <= opts.maxRetries + 1; i++) { + console.log(`\n=== Round ${i}/${opts.maxRetries + 1} ===`); + const r = runOneRound(opts); + final = r; + if (r.verdict === "pass") { + attempts.push({ attempt: i, verdict: "pass" }); + console.log(`[driver] verdict=pass —— 退出循环`); + break; + } + const fd = r.fixDirective!; + console.log(`[driver] verdict=fail (${fd.failureKind})`); + if (i > opts.maxRetries) { + attempts.push({ attempt: i, verdict: "fail", failureKind: fd.failureKind }); + console.log(`[driver] 已达 maxRetries=${opts.maxRetries},停止循环`); + break; + } + if (opts.fixMode === "manual") { + attempts.push({ + attempt: i, verdict: "fail", + failureKind: fd.failureKind, fixOutcome: "manual-mode", + }); + console.log(`[driver] fix-mode=manual,打印 prompt 后退出 —— 由人接手`); + console.log(`\n---- FIX PROMPT ----\n${fd.prompt}\n--------------------\n`); + break; + } + const { outcome, skipReason } = await dispatchFixSubagent(fd.prompt); + attempts.push({ + attempt: i, verdict: "fail", + failureKind: fd.failureKind, + fixOutcome: outcome, + fixSkipReason: skipReason, + }); + if (outcome === "skipped") { + console.log(`[driver] subagent skip;停止循环。理由: ${skipReason ?? "(无)"}`); + break; + } + if (outcome === "no-marker") { + console.log(`[driver] 等待 fix-marker 超时 —— 停止循环`); + break; + } + console.log(`[driver] subagent 报告已修,准备下一轮 review...`); + } + + const outFile = path.join(opts.outDir, "review-verdict.json"); + writeFileSync(outFile, JSON.stringify({ ...final, attempts }, null, 2), "utf-8"); + console.log(`\n[driver] 最终: verdict=${final!.verdict} → ${outFile}`); + process.exit(final!.verdict === "pass" ? 0 : 1); +} + +main().catch((e: Error) => { console.error(e); process.exit(2); }); diff --git a/scripts/review/post-pr-comment.test.ts b/scripts/review/post-pr-comment.test.ts new file mode 100644 index 0000000..af67e67 --- /dev/null +++ b/scripts/review/post-pr-comment.test.ts @@ -0,0 +1,76 @@ +// scripts/review/post-pr-comment.test.ts +// +// 只测纯渲染逻辑 —— gh CLI / GitHub API 在 CI 上端到端跑。 +// Task 2 of docs/plans/2026-05-15-remote-review-bot.md. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderComment, COMMENT_MARKER } from "./post-pr-comment.ts"; +import type { ReviewResult } from "./review-types.ts"; + +const passResult: ReviewResult = { + generatedAt: "2026-05-15T10:00:00.000Z", + verdict: "pass", + criteria: [ + { id: "repro-pass", ok: true, summary: "verdict=pass", details: "" }, + { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }, + ], +}; + +const failResult: ReviewResult = { + generatedAt: "2026-05-15T10:00:00.000Z", + verdict: "fail", + criteria: [ + { id: "repro-pass", ok: false, summary: "verdict=fail", details: "before 期望未达: stdout 缺关键字 \"deny\"" }, + { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }, + ], + fixDirective: { failureKind: "repro-fail", prompt: "..." }, +}; + +const ctx = { commitSha: "abc12345deadbeef", workflowRunUrl: "https://github.com/x/y/actions/runs/1" }; + +test("renderComment: pass 评论以 marker 开头,含 ✅ 与 verdict", () => { + const c = renderComment(passResult, ctx); + assert.ok(c.startsWith(COMMENT_MARKER), "必须以 marker 开头便于覆盖更新"); + assert.match(c, /✅/); + assert.match(c, /verdict.*pass/i); + assert.match(c, /abc1234/, "应含 commit sha 短形"); +}); + +test("renderComment: fail 评论含 ❌ + 失败 details + run-url + 修复指引", () => { + const c = renderComment(failResult, ctx); + assert.match(c, /❌/); + assert.match(c, /verdict.*fail/i); + assert.match(c, /缺关键字/); + assert.match(c, /github\.com\/x\/y/, "应链回 workflow run"); + assert.match(c, /loop-driver/, "fail 时应给修复指引"); +}); + +test("COMMENT_MARKER 是 hidden HTML comment 且独特", () => { + assert.match(COMMENT_MARKER, /^"; + +interface RenderCtx { + commitSha: string; + workflowRunUrl: string; +} + +export function renderComment(r: ReviewResult, ctx: RenderCtx): string { + const emoji = r.verdict === "pass" ? "✅" : "❌"; + const head = `${COMMENT_MARKER}\n## ${emoji} 远程评审 verdict: **${r.verdict}**`; + const meta = `\n\n_commit \`${ctx.commitSha.slice(0, 7)}\` · [workflow run](${ctx.workflowRunUrl}) · ${r.generatedAt}_`; + const criteriaTable = [ + "", + "| 标准 | 结果 | 简述 |", + "|---|---|---|", + ...r.criteria.map((c) => + `| \`${c.id}\` | ${c.ok ? "✅" : "❌"} | ${escapeMd(c.summary)} |`, + ), + ].join("\n"); + const details = r.criteria + .filter((c) => !c.ok && c.details) + .map((c) => [ + "", + `### ❌ ${c.id} 详情`, + "```", + c.details.slice(0, 4000), + "```", + ].join("\n")) + .join("\n"); + const guide = r.verdict === "fail" + ? `\n\n---\n_要修这个失败,请在本地按 \`docs/plans/2026-05-15-local-review-loop.md\` 跑 \`loop-driver.ts\`,push 修复 commit 即可触发本评论刷新。_` + : ""; + return head + meta + criteriaTable + details + guide; +} + +function escapeMd(s: string): string { + return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); +} + +// ===== CLI 入口(本地不跑,CI 上跑) ===== + +interface CliOpts { verdictFile: string; pr: number; sha: string; runUrl: string; } + +function parseArgv(argv: string[]): CliOpts { + const o: CliOpts = { verdictFile: "", pr: 0, sha: "", runUrl: "" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--verdict") o.verdictFile = argv[++i]!; + else if (a === "--pr") o.pr = Number(argv[++i]!); + else if (a === "--sha") o.sha = argv[++i]!; + else if (a === "--run-url") o.runUrl = argv[++i]!; + } + if (!o.verdictFile || !o.pr || !o.sha || !o.runUrl) { + throw new Error("用法: --verdict --pr --sha --run-url "); + } + return o; +} + +function postOrUpdateComment(pr: number, body: string): void { + // 找现有的 marker 评论 + const list = spawnSync("gh", ["pr", "view", String(pr), "--json", "comments"], { encoding: "utf-8" }); + if (list.status !== 0) throw new Error(`gh pr view 失败: ${list.stderr}`); + const parsed = JSON.parse(list.stdout) as { comments?: Array<{ id?: string; body?: string }> }; + const comments = parsed.comments ?? []; + const existing = comments.find((c) => (c.body ?? "").startsWith(COMMENT_MARKER)); + if (existing && existing.id) { + // gh 不直接支持 update comment,用 API + const repo = process.env["GITHUB_REPOSITORY"]; + if (!repo) throw new Error("GITHUB_REPOSITORY env 未设"); + const r = spawnSync("gh", [ + "api", "--method", "PATCH", + `repos/${repo}/issues/comments/${existing.id}`, + "-f", `body=${body}`, + ], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`update comment 失败: ${r.stderr}`); + } else { + const r = spawnSync("gh", ["pr", "comment", String(pr), "--body", body], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`gh pr comment 失败: ${r.stderr}`); + } +} + +function setCommitStatus(sha: string, state: "success" | "failure", description: string, runUrl: string): void { + const repo = process.env["GITHUB_REPOSITORY"]; + if (!repo) throw new Error("GITHUB_REPOSITORY env 未设"); + const r = spawnSync("gh", [ + "api", "--method", "POST", + `repos/${repo}/statuses/${sha}`, + "-f", `state=${state}`, + "-f", `context=review/verdict`, + "-f", `description=${description}`, + "-f", `target_url=${runUrl}`, + ], { encoding: "utf-8" }); + if (r.status !== 0) throw new Error(`set commit status 失败: ${r.stderr}`); +} + +function main(): void { + const o = parseArgv(process.argv.slice(2)); + const verdict = JSON.parse(readFileSync(o.verdictFile, "utf-8")) as ReviewResult; + const body = renderComment(verdict, { commitSha: o.sha, workflowRunUrl: o.runUrl }); + postOrUpdateComment(o.pr, body); + const desc = verdict.verdict === "pass" + ? `全部通过` + : `verdict=fail (${verdict.criteria.filter((c) => !c.ok).length} 项 fail)`; + setCommitStatus(o.sha, verdict.verdict === "pass" ? "success" : "failure", desc, o.runUrl); + console.log(`[post-pr-comment] verdict=${verdict.verdict},评论 + status 已更新`); +} + +if (process.argv[1] && process.argv[1].endsWith("post-pr-comment.ts")) main(); diff --git a/scripts/review/review-cli.ts b/scripts/review/review-cli.ts new file mode 100644 index 0000000..0564db7 --- /dev/null +++ b/scripts/review/review-cli.ts @@ -0,0 +1,77 @@ +// scripts/review/review-cli.ts +// +// CLI 入口:跑一次 review(两条标准),写 review-verdict.json,退出码反映 verdict。 +// 见 docs/plans/2026-05-15-local-review-loop.md Task 4。 +// +// 用法: +// tsx scripts/review/review-cli.ts --repro [--out ] +// [--test-cmd " "] [--base main] + +import { writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { runAllChecks } from "./run-checks.ts"; +import { aggregateVerdict } from "./review-core.ts"; +import type { LoopOptions } from "./review-types.ts"; + +interface CliOpts { + reproResult: string; + out: string; + testCmd: string; + baseBranch: string; +} + +function parseArgv(argv: string[]): CliOpts { + const o: CliOpts = { + reproResult: "", + out: "", + // 默认:scripts/lock-core.test.ts 用 tsx --test 跑(node:test runner); + // 项目级真实 review 应传 --test-cmd "pnpm test" 或绝对路径的 vitest.cmd + testCmd: "C:/bzli/Matrix/node_modules/.bin/tsx.cmd --test scripts/lock-core.test.ts", + baseBranch: "main", + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--repro") o.reproResult = argv[++i]!; + else if (a === "--out") o.out = argv[++i]!; + else if (a === "--test-cmd") o.testCmd = argv[++i]!; + else if (a === "--base") o.baseBranch = argv[++i]!; + } + if (!o.reproResult) { + throw new Error( + "用法: tsx scripts/review/review-cli.ts --repro [--out ] [--test-cmd \"\"] [--base main]", + ); + } + if (!o.out) o.out = path.dirname(o.reproResult); + return o; +} + +function splitTestCmd(s: string): { command: string; args: string[] } { + // 简单分隔:按空白切;不支持引号里的空格(本地脚本场景够用)。 + const parts = s.trim().split(/\s+/); + return { command: parts[0]!, args: parts.slice(1) }; +} + +function main(): void { + const o = parseArgv(process.argv.slice(2)); + const { command, args } = splitTestCmd(o.testCmd); + const loop: LoopOptions = { + maxRetries: 3, + reproResultPath: o.reproResult, + baseBranch: o.baseBranch, + testCommand: command, + testArgs: args, + }; + const criteria = runAllChecks({ repoRoot: process.cwd(), loop }); + const result = aggregateVerdict(criteria); + mkdirSync(o.out, { recursive: true }); + const outFile = path.join(o.out, "review-verdict.json"); + writeFileSync(outFile, JSON.stringify(result, null, 2), "utf-8"); + console.log(`[review] verdict=${result.verdict} → ${outFile}`); + if (result.fixDirective) { + console.log(`[review] failureKind=${result.fixDirective.failureKind}`); + console.log(`[review] fix prompt 见 review-verdict.json 的 fixDirective.prompt 字段;loop-driver 会取用。`); + } + process.exit(result.verdict === "pass" ? 0 : 1); +} + +main(); diff --git a/scripts/review/review-core.test.ts b/scripts/review/review-core.test.ts new file mode 100644 index 0000000..29d70ed --- /dev/null +++ b/scripts/review/review-core.test.ts @@ -0,0 +1,66 @@ +// scripts/review/review-core.test.ts +// +// Task 2 of docs/plans/2026-05-15-local-review-loop.md. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { aggregateVerdict, classifyFailure, formatFixPrompt } from "./review-core.ts"; +import type { CriterionResult } from "./review-types.ts"; + +const passRepro: CriterionResult = { id: "repro-pass", ok: true, summary: "verdict=pass", details: "" }; +const failRepro: CriterionResult = { id: "repro-pass", ok: false, summary: "verdict=fail", details: "before 期望未达: stdout 缺关键字 \"deny\"" }; +const ambiguousRepro: CriterionResult = { id: "repro-pass", ok: false, summary: "verdict=ambiguous", details: "before 也命中了 after 的期望" }; +const passTests: CriterionResult = { id: "tests-and-merge-clean", ok: true, summary: "tests pass + tree clean", details: "" }; +const failTests: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "tests failing (exit 1): 23/100 tests failing", details: "FAIL packages/core/src/foo.test.ts ..." }; +const dirtyTree: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "tests pass; working tree dirty (3 entries)", details: " M src/x.ts\n?? note.md" }; + +test("aggregateVerdict: 两条都 ok → pass,无 fixDirective", () => { + const r = aggregateVerdict([passRepro, passTests]); + assert.equal(r.verdict, "pass"); + assert.equal(r.fixDirective, undefined); +}); + +test("aggregateVerdict: 任一不 ok → fail + 带 fixDirective", () => { + const r = aggregateVerdict([failRepro, passTests]); + assert.equal(r.verdict, "fail"); + assert.ok(r.fixDirective, "fixDirective 必须存在"); + assert.equal(r.fixDirective!.failureKind, "repro-fail"); + assert.ok(r.fixDirective!.prompt.length > 0, "prompt 必须填充"); +}); + +test("classifyFailure: repro 三种情况(fail / ambiguous / missing)", () => { + assert.equal(classifyFailure(failRepro).failureKind, "repro-fail"); + assert.equal(classifyFailure(ambiguousRepro).failureKind, "repro-ambiguous"); + const missing: CriterionResult = { id: "repro-pass", ok: false, summary: "missing repro-result.json: foo/bar", details: "" }; + assert.equal(classifyFailure(missing).failureKind, "missing-repro-result"); +}); + +test("classifyFailure: tests 失败 vs 工作树脏 vs 冲突", () => { + assert.equal(classifyFailure(failTests).failureKind, "tests-failing"); + assert.equal(classifyFailure(dirtyTree).failureKind, "dirty-tree"); + const conflict: CriterionResult = { id: "tests-and-merge-clean", ok: false, summary: "merge conflict with origin/main", details: "" }; + assert.equal(classifyFailure(conflict).failureKind, "merge-conflict"); +}); + +test("formatFixPrompt: 包含失败类别 + details + 自检步骤 + 边界", () => { + const p = formatFixPrompt({ failureKind: "tests-failing", prompt: "" }, failTests); + assert.match(p, /tests-failing/, "应含失败类别"); + assert.match(p, /23\/100/, "应含 details 内容"); + assert.match(p, /pnpm test/, "应含修完自检步骤"); + assert.match(p, /边界/, "应含约束边界"); + assert.match(p, /fix-marker/, "应说明如何回报已修"); +}); + +test("aggregateVerdict 多失败:fixDirective 按优先级(repro > tests > tree)", () => { + const r = aggregateVerdict([failRepro, failTests]); + assert.equal(r.fixDirective!.failureKind, "repro-fail"); +}); + +test("aggregateVerdict 多失败 ambiguous + dirty-tree:ambiguous 优先", () => { + const r = aggregateVerdict([ambiguousRepro, dirtyTree]); + assert.equal(r.fixDirective!.failureKind, "repro-ambiguous"); +}); + +test("classifyFailure: ok=true 抛错(防误用)", () => { + assert.throws(() => classifyFailure(passRepro), /classifyFailure 不应被调用/); +}); diff --git a/scripts/review/review-core.ts b/scripts/review/review-core.ts new file mode 100644 index 0000000..237f1a3 --- /dev/null +++ b/scripts/review/review-core.ts @@ -0,0 +1,94 @@ +// scripts/review/review-core.ts +// +// 纯逻辑:把「2 条标准」的 CriterionResult 汇总为 ReviewResult,失败按类别分类, +// 给打回 subagent 写 fix prompt。 +// 严禁 import fs / child_process —— IO 在 run-checks.ts / loop-driver.ts。 +// 见 docs/plans/2026-05-15-local-review-loop.md Task 2。 + +import type { + CriterionResult, + FixDirective, + ReviewResult, +} from "./review-types.ts"; + +/** 同时有多种失败时,优先打回最根本的那个 —— 数组顺序 = 优先级(越靠前越优先)。 */ +const FAILURE_PRIORITY: FixDirective["failureKind"][] = [ + "missing-repro-result", + "repro-fail", + "repro-ambiguous", + "merge-conflict", + "tests-failing", + "dirty-tree", +]; + +export function classifyFailure(c: CriterionResult): FixDirective { + if (c.ok) throw new Error("classifyFailure 不应被调用 ok=true 的 criterion"); + const s = c.summary.toLowerCase(); + if (c.id === "repro-pass") { + if (s.includes("missing")) return { failureKind: "missing-repro-result", prompt: "" }; + if (s.includes("ambiguous")) return { failureKind: "repro-ambiguous", prompt: "" }; + return { failureKind: "repro-fail", prompt: "" }; + } + // c.id === "tests-and-merge-clean" + if (s.includes("conflict")) return { failureKind: "merge-conflict", prompt: "" }; + if (s.includes("dirty") || s.includes("untracked")) return { failureKind: "dirty-tree", prompt: "" }; + return { failureKind: "tests-failing", prompt: "" }; +} + +export function aggregateVerdict(criteria: CriterionResult[]): ReviewResult { + const generatedAt = new Date().toISOString(); + const fails = criteria.filter((c) => !c.ok); + if (fails.length === 0) { + return { generatedAt, verdict: "pass", criteria }; + } + // 取优先级最高的 fail + const classified = fails.map((c) => ({ c, fd: classifyFailure(c) })); + classified.sort((a, b) => + FAILURE_PRIORITY.indexOf(a.fd.failureKind) - FAILURE_PRIORITY.indexOf(b.fd.failureKind), + ); + const top = classified[0]!; + return { + generatedAt, + verdict: "fail", + criteria, + fixDirective: { ...top.fd, prompt: formatFixPrompt(top.fd, top.c) }, + }; +} + +const HEADER_BY_KIND: Record = { + "repro-fail": "套件 1 复现验证 verdict=fail —— before/after 对比未严谨成立", + "repro-ambiguous": "套件 1 复现验证 verdict=ambiguous —— 两侧期望可互换命中,对比不严谨", + "missing-repro-result": "找不到 repro-result.json —— 你还没跑套件 1,或 spec 路径错了", + "merge-conflict": "与 main 有合并冲突", + "tests-failing": "全量测试有失败用例", + "dirty-tree": "工作树有未提交改动", +}; + +const SELF_CHECK_BY_KIND: Record = { + "repro-fail": "重跑 `npx tsx scripts/verify/repro-cli.ts --no-gif`,看 verdict=pass", + "repro-ambiguous": "重审 ReproSpec.expect.before/after 是否真互斥(互换不应都命中);改完重跑 repro-cli", + "missing-repro-result": "先跑 `npx tsx scripts/verify/repro-cli.ts `,确认产物落到 review 指定的 --repro 路径", + "merge-conflict": "`git fetch origin && git rebase origin/main`,解冲突后重跑 review", + "tests-failing": "`pnpm test` 退出码 0(或对应的 --test-cmd 退出 0)", + "dirty-tree": "`git status` 干净(无 M / 无 ??),要么提交要么 .gitignore", +}; + +export function formatFixPrompt(fd: FixDirective, c: CriterionResult): string { + return [ + `## 失败类别: ${fd.failureKind}`, + `**问题**:${HEADER_BY_KIND[fd.failureKind]}`, + ``, + `### 详情`, + "```", + c.summary + (c.details ? `\n\n${c.details}` : ""), + "```", + ``, + `### 修完后怎么自检`, + SELF_CHECK_BY_KIND[fd.failureKind], + ``, + `### 边界`, + `- 只动**与本失败类别直接相关**的代码;**不要顺手 refactor**。`, + `- 修好后用 \`echo done > /tmp/fix-marker\`(Windows 等价 \`echo done > %TEMP%\\fix-marker\`)落一个标记文件,然后 stop。`, + ` loop-driver 检测到标记会重跑 review。`, + ].join("\n"); +} diff --git a/scripts/review/review-types.ts b/scripts/review/review-types.ts new file mode 100644 index 0000000..4d9b712 --- /dev/null +++ b/scripts/review/review-types.ts @@ -0,0 +1,53 @@ +// scripts/review/review-types.ts +// +// 本地 review 自动循环的类型定义。 +// 见 docs/plans/2026-05-15-local-review-loop.md Task 1。 + +/** WORKFLOW.md 第 4 步钉死的 2 条标准。 */ +export type CriterionId = + | "repro-pass" // 标准 1:套件 1 跑出 verdict = "pass" + | "tests-and-merge-clean"; // 标准 2:pnpm test 全过 + git 工作树干净 + 与 main 无冲突 + +export interface CriterionResult { + id: CriterionId; + ok: boolean; + /** 给人看的简述,1 行 */ + summary: string; + /** 详细信息,可多行;ok=true 时可能为空 */ + details: string; +} + +export type ReviewVerdict = "pass" | "fail"; + +export interface ReviewResult { + generatedAt: string; + verdict: ReviewVerdict; + criteria: CriterionResult[]; + /** verdict=fail 时,给打回 subagent 的结构化指令(给 LLM 作 prompt 用) */ + fixDirective?: FixDirective; +} + +export interface FixDirective { + /** kebab-case 失败类型,便于 loop-driver 防止「同一类失败连续打回 N 次」。 */ + failureKind: + | "repro-fail" + | "repro-ambiguous" + | "tests-failing" + | "merge-conflict" + | "dirty-tree" + | "missing-repro-result"; + /** 给打回 subagent 一段可读 prompt,描述「要修什么、修完怎么自检」。 */ + prompt: string; +} + +export interface LoopOptions { + /** 最多打回几次后强制停止(默认 3)。防止死循环。 */ + maxRetries: number; + /** 跑 review 的 ReproResult 路径(套件 1 产出)。 */ + reproResultPath: string; + /** 评测时基于哪个 base ref 比对(默认 main)。 */ + baseBranch: string; + /** 跑测试用的命令(可执行文件)。 */ + testCommand: string; + testArgs: string[]; +} diff --git a/scripts/review/run-checks.ts b/scripts/review/run-checks.ts new file mode 100644 index 0000000..c134739 --- /dev/null +++ b/scripts/review/run-checks.ts @@ -0,0 +1,139 @@ +// scripts/review/run-checks.ts +// +// Imperative Shell:跑 WORKFLOW.md 第 4 步两条标准。 +// - 标准 1:读套件 1 产出的 repro-result.json,要求 verdict = "pass" +// - 标准 2:测试全过 + 工作树干净 + 与 baseBranch 无冲突 +// +// 见 docs/plans/2026-05-15-local-review-loop.md Task 3。 + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import type { CriterionResult, LoopOptions } from "./review-types.ts"; + +/** 标准 1:读套件 1 产出的 repro-result.json,要求 verdict = "pass" */ +export function checkReproPass(reproResultPath: string): CriterionResult { + if (!existsSync(reproResultPath)) { + return { + id: "repro-pass", ok: false, + summary: `missing repro-result.json: ${reproResultPath}`, + details: "先跑套件 1 产出该文件,再跑 review。\n npx tsx scripts/verify/repro-cli.ts --out ", + }; + } + let parsed: { verdict?: string; verdictReason?: string }; + try { + parsed = JSON.parse(readFileSync(reproResultPath, "utf-8")) as { verdict?: string; verdictReason?: string }; + } catch (e) { + return { + id: "repro-pass", ok: false, + summary: `repro-result.json 解析失败`, + details: String(e), + }; + } + const verdict = parsed.verdict ?? ""; + if (verdict === "pass") { + return { + id: "repro-pass", ok: true, + summary: `verdict=pass`, + details: parsed.verdictReason ?? "", + }; + } + return { + id: "repro-pass", ok: false, + summary: `verdict=${verdict}`, + details: parsed.verdictReason ?? "(无 verdictReason)", + }; +} + +interface MergeCheckOpts { + repoRoot: string; + baseBranch: string; + testCommand: string; + testArgs: string[]; +} + +/** 标准 2:测试全过 + 工作树干净 + 与 baseBranch 无冲突 */ +export function checkTestsAndMergeClean(opts: MergeCheckOpts): CriterionResult { + // 1) 跑测试 + // Windows 下 .cmd / .bat / .ps1 等 shim 通过 spawnSync 直调会 EINVAL, + // 必须走 shell: true 才能解析 PATHEXT(node 子进程不会自动做)。 + const needsShell = process.platform === "win32" + && /\.(cmd|bat|ps1)$/i.test(opts.testCommand); + const tests = spawnSync(opts.testCommand, opts.testArgs, { + cwd: opts.repoRoot, encoding: "utf-8", shell: needsShell, + }); + if (tests.error) { + return { + id: "tests-and-merge-clean", ok: false, + summary: `test command spawn 失败(${tests.error.message})`, + details: `cmd: ${opts.testCommand} ${opts.testArgs.join(" ")}`, + }; + } + if (tests.status !== 0) { + return { + id: "tests-and-merge-clean", ok: false, + summary: `tests failing (exit ${tests.status})`, + details: trimTo((tests.stdout ?? "") + (tests.stderr ?? ""), 4000), + }; + } + + // 2) 工作树干净 + const status = spawnSync("git", ["status", "--porcelain"], { + cwd: opts.repoRoot, encoding: "utf-8", + }); + const dirtyLines = status.stdout.trim().length > 0 + ? status.stdout.trim().split(/\r?\n/) + : []; + if (dirtyLines.length > 0) { + return { + id: "tests-and-merge-clean", ok: false, + summary: `tests pass; working tree dirty (${dirtyLines.length} entries)`, + details: dirtyLines.slice(0, 50).join("\n"), + }; + } + + // 3) 与 baseBranch 无冲突(fetch + merge-tree;无副作用) + spawnSync("git", ["fetch", "origin", opts.baseBranch], { + cwd: opts.repoRoot, encoding: "utf-8", + }); + // git 2.38+ 的 merge-tree --write-tree 输出 tree OID + 冲突文件; + // 旧版输出 diff 形式。两种形式里有冲突都会出现 "<<<<<<<" 或 exit !=0;以此作主信号。 + const mergeTree = spawnSync("git", ["merge-tree", `origin/${opts.baseBranch}`, "HEAD"], { + cwd: opts.repoRoot, encoding: "utf-8", + }); + const merged = (mergeTree.stdout ?? "") + (mergeTree.stderr ?? ""); + if (merged.includes("<<<<<<<") || mergeTree.status !== 0) { + // 抓冲突文件名(两种格式都尝试): + // 旧:"changed in both ... base ... " + // 新:"CONFLICT (content): " 或仅 列在末尾 + const filesOldFmt = [...merged.matchAll(/^changed in both[\s\S]*?\n {2}base\s+\S+ \S+ (\S+)$/gm)].map((m) => m[1]!); + const filesNewFmt = [...merged.matchAll(/CONFLICT \(.+?\): (.+)/g)].map((m) => m[1]!); + const conflictFiles = [...new Set([...filesOldFmt, ...filesNewFmt])]; + return { + id: "tests-and-merge-clean", ok: false, + summary: `merge conflict with origin/${opts.baseBranch}`, + details: conflictFiles.length ? conflictFiles.join("\n") : trimTo(merged, 2000), + }; + } + + return { + id: "tests-and-merge-clean", ok: true, + summary: `tests pass + tree clean + no conflict with ${opts.baseBranch}`, + details: "", + }; +} + +function trimTo(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + `\n…(truncated ${s.length - n} chars)` : s; +} + +/** 跑两条标准,返回 [criterion1, criterion2] */ +export function runAllChecks(opts: { repoRoot: string; loop: LoopOptions }): CriterionResult[] { + const c1 = checkReproPass(opts.loop.reproResultPath); + const c2 = checkTestsAndMergeClean({ + repoRoot: opts.repoRoot, + baseBranch: opts.loop.baseBranch, + testCommand: opts.loop.testCommand, + testArgs: opts.loop.testArgs, + }); + return [c1, c2]; +} diff --git a/scripts/verify/gen-report.ts b/scripts/verify/gen-report.ts new file mode 100644 index 0000000..9ced43f --- /dev/null +++ b/scripts/verify/gen-report.ts @@ -0,0 +1,64 @@ +// scripts/verify/gen-report.ts +// +// Imperative Shell:读 /manifest.json + 校验引用资产存在 + 调 renderReport + 写 report.html。 +// 见 docs/plans/2026-05-14-verification-tooling.md Phase 2 Task 5。 +// +// 用法: +// npx tsx scripts/verify/gen-report.ts +// 在 内需有: +// manifest.json —— ReportManifest 的 JSON +// —— 真实屏幕录像 +// —— before 静态图 +// —— after 静态图 +// 产出: +// /report.html + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { renderReport } from "./report-core.ts"; +import type { ReportManifest } from "./report-core.ts"; + +export function genReport(reportDir: string): string { + const manifestPath = path.join(reportDir, "manifest.json"); + if (!existsSync(manifestPath)) { + throw new Error(`缺 manifest.json: ${manifestPath}`); + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ReportManifest; + + // SPEC 要求自包含、可核验 —— 校验 manifest 引用的资产确实存在 + const missing: string[] = []; + for (const rel of [manifest.gifPath, manifest.before.still, manifest.after.still]) { + if (!existsSync(path.join(reportDir, rel))) missing.push(rel); + } + if (missing.length > 0) { + throw new Error(`manifest 引用的资产不存在: ${missing.join(", ")}`); + } + + const html = renderReport(manifest); + const out = path.join(reportDir, "report.html"); + writeFileSync(out, html, "utf-8"); + return out; +} + +// CLI 入口:tsx 直接跑时 import.meta.url === pathToFileURL(process.argv[1]).href +const isCli = (() => { + try { + const argv1 = process.argv[1]; + if (!argv1) return false; + return import.meta.url.endsWith(path.basename(argv1)) || import.meta.url.includes("/gen-report.ts"); + } catch { return false; } +})(); +if (isCli) { + const dir = process.argv[2]; + if (!dir) { + console.error("用法: tsx scripts/verify/gen-report.ts "); + process.exit(2); + } + try { + const out = genReport(dir); + console.log("生成:", out); + } catch (e) { + console.error((e as Error).message); + process.exit(1); + } +} diff --git a/scripts/verify/gif-core.test.ts b/scripts/verify/gif-core.test.ts new file mode 100644 index 0000000..6a54662 --- /dev/null +++ b/scripts/verify/gif-core.test.ts @@ -0,0 +1,42 @@ +// scripts/verify/gif-core.test.ts +// +// 纯逻辑测试:ffmpeg 命令构造、捕获矩形计算、调色板两遍命令对。 +// 见 docs/plans/2026-05-14-verification-tooling.md Phase 1 Task 1。 + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildGdigrabArgs, + buildGifPaletteArgs, + computeCaptureRect, +} from "./gif-core.ts"; + +test("buildGdigrabArgs 生成固定区域录屏参数", () => { + const args = buildGdigrabArgs({ x: 8, y: 0, w: 1096, h: 730, durationSec: 40, outPath: "C:/t/demo.mp4" }); + assert.deepEqual(args, [ + "-hide_banner", "-loglevel", "warning", "-stats", + "-f", "gdigrab", "-framerate", "12", + "-offset_x", "8", "-offset_y", "0", "-video_size", "1096x730", + "-i", "desktop", "-t", "40", "-pix_fmt", "yuv420p", "-y", "C:/t/demo.mp4", + ]); +}); + +test("computeCaptureRect 把窗口矩形内移 inset 像素避开隐形边框", () => { + // 窗口贴屏幕左上角 (0,0,1120,760) → 捕获区内移 8px、整体缩 16px 宽 16px 高 + assert.deepEqual( + computeCaptureRect({ winX: 0, winY: 0, winW: 1120, winH: 760, inset: 8 }), + { x: 8, y: 0, w: 1104, h: 744 }, + ); +}); + +test("buildGifPaletteArgs 生成两遍调色板命令对", () => { + const { palettegen, paletteuse } = buildGifPaletteArgs({ + mp4: "C:/t/demo.mp4", gif: "C:/t/demo.gif", palette: "C:/t/pal.png", fps: 11, width: 900, + }); + // palettegen 必须有 stats_mode=diff(适合屏幕录像稳定 palette) + assert.ok(palettegen.some((a) => a.includes("palettegen=stats_mode=diff"))); + // ffmpeg 8 写单图 palette 需 -update 1 + assert.ok(palettegen.includes("-update")); + // paletteuse 必须用 bayer dither(高对比度文本表现更稳) + assert.ok(paletteuse.some((a) => a.includes("paletteuse=dither=bayer:bayer_scale=3"))); +}); diff --git a/scripts/verify/gif-core.ts b/scripts/verify/gif-core.ts new file mode 100644 index 0000000..276358c --- /dev/null +++ b/scripts/verify/gif-core.ts @@ -0,0 +1,73 @@ +// scripts/verify/gif-core.ts +// +// 纯逻辑:ffmpeg 命令构造、录制配置校验、捕获矩形计算。无 IO,所有 IO 由 record-gif.ts 承担。 +// 见 docs/plans/2026-05-14-verification-tooling.md Phase 1 Task 1。 + +export interface GdigrabOpts { + x: number; + y: number; + w: number; + h: number; + durationSec: number; + outPath: string; +} + +/** ffmpeg gdigrab 录屏参数:固定区域、12fps、yuv420p(兼容性最好)。 */ +export function buildGdigrabArgs(o: GdigrabOpts): string[] { + return [ + "-hide_banner", "-loglevel", "warning", "-stats", + "-f", "gdigrab", "-framerate", "12", + "-offset_x", String(o.x), "-offset_y", String(o.y), "-video_size", `${o.w}x${o.h}`, + "-i", "desktop", "-t", String(o.durationSec), "-pix_fmt", "yuv420p", "-y", o.outPath, + ]; +} + +export interface WinRect { + winX: number; + winY: number; + winW: number; + winH: number; + /** 捕获区相对窗口的内移像素(避开 Win11 隐形边框)。 */ + inset: number; +} + +/** 从窗口矩形算实际录屏的捕获矩形:四边各内移 inset 像素。 */ +export function computeCaptureRect(r: WinRect): { x: number; y: number; w: number; h: number } { + return { + x: r.winX + r.inset, + y: r.winY, + w: r.winW - r.inset * 2, + h: r.winH - r.inset * 2, + }; +} + +export interface PaletteOpts { + mp4: string; + gif: string; + palette: string; + fps: number; + width: number; +} + +/** 两遍调色板:第一遍 palettegen 生成最优调色板,第二遍 paletteuse 用它转 GIF。 + * 屏幕录像里高对比度文本用 bayer dither + bayer_scale=3 最稳; + * stats_mode=diff 让 palette 偏向变化区域(=终端文字)而非静态背景。 */ +export function buildGifPaletteArgs(o: PaletteOpts): { palettegen: string[]; paletteuse: string[] } { + const scale = `fps=${o.fps},scale=${o.width}:-1:flags=lanczos`; + return { + palettegen: [ + "-hide_banner", "-loglevel", "error", + "-i", o.mp4, + "-vf", `${scale},palettegen=stats_mode=diff`, + "-update", "1", + "-y", o.palette, + ], + paletteuse: [ + "-hide_banner", "-loglevel", "error", + "-i", o.mp4, + "-i", o.palette, + "-lavfi", `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`, + "-y", o.gif, + ], + }; +} diff --git a/scripts/verify/record-gif.ts b/scripts/verify/record-gif.ts new file mode 100644 index 0000000..6f88fd9 --- /dev/null +++ b/scripts/verify/record-gif.ts @@ -0,0 +1,77 @@ +// scripts/verify/record-gif.ts +// +// Imperative Shell:接收 RecordConfig,调 PowerShell(win-record.ps1)真实录屏出 mp4, +// 再调两遍 ffmpeg(palettegen + paletteuse)把 mp4 转成 GIF。 +// 见 docs/plans/2026-05-14-verification-tooling.md Phase 1 Task 3。 +// +// 纯逻辑(ffmpeg args / palette args)在 gif-core.ts,本文件只做 IO + 编排。 + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildGifPaletteArgs } from "./gif-core.ts"; + +export interface RecordConfig { + /** 被录窗口里跑的 .ps1(需自行设置窗口标题为 windowTitle) */ + sceneScript: string; + windowTitle: string; + /** mp4 / gif / palette / 检查帧的输出目录 */ + outDir: string; + durationSec: number; + /** GIF 帧率,默认 11 */ + gifFps?: number; + /** GIF 宽度(px),按比例缩高;默认 900 */ + gifWidth?: number; +} + +/** 录 mp4 → 调色板 → GIF。返回两个产物的绝对路径。 */ +export function recordGif(cfg: RecordConfig): { mp4: string; gif: string } { + if (process.platform !== "win32") { + throw new Error(`recordGif 当前仅支持 Windows(gdigrab),实际平台 ${process.platform}`); + } + if (!existsSync(cfg.sceneScript)) throw new Error(`sceneScript 不存在: ${cfg.sceneScript}`); + mkdirSync(cfg.outDir, { recursive: true }); + + const mp4 = path.resolve(cfg.outDir, "demo.mp4"); + const gif = path.resolve(cfg.outDir, "demo.gif"); + const palette = path.resolve(cfg.outDir, "palette.png"); + + // 1. 录 mp4(PowerShell 窗口管理 + gdigrab) + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const psScript = path.join(scriptDir, "win-record.ps1"); + if (!existsSync(psScript)) throw new Error(`win-record.ps1 不存在: ${psScript}`); + + const ps = spawnSync( + "powershell", + [ + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psScript, + "-SceneScript", cfg.sceneScript, + "-WindowTitle", cfg.windowTitle, + "-OutMp4", mp4, + "-DurationSec", String(cfg.durationSec), + ], + { stdio: "inherit", windowsHide: true }, + ); + if (ps.status !== 0 || !existsSync(mp4)) { + throw new Error(`录制失败(powershell exit=${ps.status},mp4 exists=${existsSync(mp4)})`); + } + + // 2. mp4 → palette → GIF(两遍调色板) + const { palettegen, paletteuse } = buildGifPaletteArgs({ + mp4, gif, palette, + fps: cfg.gifFps ?? 11, + width: cfg.gifWidth ?? 900, + }); + + const gen = spawnSync("ffmpeg", palettegen, { stdio: "inherit", windowsHide: true }); + if (gen.status !== 0 || !existsSync(palette)) { + throw new Error(`palettegen 失败(exit=${gen.status})`); + } + const use = spawnSync("ffmpeg", paletteuse, { stdio: "inherit", windowsHide: true }); + if (use.status !== 0 || !existsSync(gif)) { + throw new Error(`paletteuse 失败(exit=${use.status})`); + } + + return { mp4, gif }; +} diff --git a/scripts/verify/report-core.test.ts b/scripts/verify/report-core.test.ts new file mode 100644 index 0000000..34f9859 --- /dev/null +++ b/scripts/verify/report-core.test.ts @@ -0,0 +1,116 @@ +// scripts/verify/report-core.test.ts +// +// 见 docs/plans/2026-05-14-verification-tooling.md Phase 2 Task 4。 + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderReport, validateManifest } from "./report-core.ts"; +import type { ReportManifest } from "./report-core.ts"; + +const sample: ReportManifest = { + feature: "PreToolUse hook 拦截重复犯的错", + date: "2026-05-14", + verdict: { status: "pass", line: "已实现,并通过验证" }, + whatItIs: "把团队踩过的坑记下来,等 AI 又要踩同一个坑时拦住它。", + gifPath: "demo.gif", + before: { + tag: "之前 · 知识库是空的", + still: "assets/still-before.png", + text: "命令被放行。", + evidence: "▸ 决策: 通过 (无规则命中)", + }, + after: { + tag: "之后 · 经验已进知识库", + still: "assets/still-after.png", + text: "同样的命令被拦下。", + evidence: "▸ 决策: deny\n▸ 应改用: dayjs", + }, + criteria: [{ ok: true, title: "同输入单变量结果相反", body: "两次命令一样,唯一变量是知识库;结果一放一拦。" }], + reproSteps: ["跑空知识库 demo hook", "init 加载 pack", "再跑一遍"], + coverage: { covered: ["真实知识库 + 真实匹配引擎"], notCovered: ["编辑器内端到端"] }, + footer: { env: "Windows 11 · Node 22", recordMethod: "ffmpeg gdigrab 真实录屏" }, +}; + +test("validateManifest 接受合法 manifest", () => { + assert.deepEqual(validateManifest(sample), { ok: true, errors: [] }); +}); + +test("validateManifest 拒绝缺 before/after 的 manifest", () => { + const bad = { ...sample, after: undefined } as unknown as ReportManifest; + const r = validateManifest(bad); + assert.equal(r.ok, false); + assert.ok(r.errors.some((e) => e.includes("after"))); +}); + +test("validateManifest 拒绝空 criteria 与空 reproSteps", () => { + const bad1 = { ...sample, criteria: [] }; + const bad2 = { ...sample, reproSteps: [] }; + assert.equal(validateManifest(bad1).ok, false); + assert.equal(validateManifest(bad2).ok, false); +}); + +test("validateManifest 拒绝缺 verdict.status / 非法 status", () => { + const bad = { ...sample, verdict: { status: "ok" as never, line: "fake" } }; + const r = validateManifest(bad); + assert.equal(r.ok, false); + assert.ok(r.errors.some((e) => e.includes("verdict.status"))); +}); + +test("renderReport 产出自包含 HTML,含结论横幅与 before/after 资产", () => { + const html = renderReport(sample); + assert.ok(html.startsWith(""), "应以 doctype 开头"); + assert.ok(html.includes("已实现,并通过验证"), "应含 verdict.line"); + assert.ok(html.includes("src=\"demo.gif\""), "应含 GIF img"); + assert.ok(html.includes("src=\"assets/still-before.png\""), "应含 before still"); + assert.ok(html.includes("src=\"assets/still-after.png\""), "应含 after still"); + assert.ok(!html.includes("http://"), "自包含 —— 不应含外部 URL"); + assert.ok(!html.includes("https://"), "自包含 —— 不应含外部 URL"); + // SPEC 推荐的 9 个非 footer section 标题(每个章节标题包含的关键字),全部出现 + for (const keyword of [ + "这个功能是什么", "真实屏幕录像", "之前 vs 之后", "凭什么说", + "原始证据", "怎么复现", "严谨说明", + ]) { + assert.ok(html.includes(keyword), `应含 section 关键字:${keyword}`); + } +}); + +test("renderReport 渲染 warn/fail status 时切换徽章颜色与图标", () => { + const warn = renderReport({ ...sample, verdict: { status: "warn", line: "部分实现" } }); + assert.ok(warn.includes('class="verdict warn"')); + assert.ok(warn.includes("⚠️")); + + const fail = renderReport({ ...sample, verdict: { status: "fail", line: "未实现" } }); + assert.ok(fail.includes('class="verdict fail"')); + assert.ok(fail.includes("❌")); +}); + +test("renderReport 转义用户字段(防 XSS / 防 HTML 注入)", () => { + const sneaky = { + ...sample, + whatItIs: "", + before: { ...sample.before, text: "注入" }, + }; + const html = renderReport(sneaky); + assert.ok(!html.includes("