From 3dc2cb79701ee27ce553742735c771cdab9c52dd Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?harness=EF=BC=9A=E7=8B=AC=E7=AB=8B=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E8=AF=95=E8=A1=8C=20bin/dispatch=20review=20=E4=B8=8E?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=A0=A1=E5=87=86=EF=BC=88=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E6=80=81=E8=AE=BE=E8=AE=A1=E9=98=B6=E6=AE=B5=E4=BA=8C=20P7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 harness/review.py、review_prompt.md:非设计方在只读评审工作区评审 R2 以上 PR(只看任务书、PR 描述、 diff 与证据包),结论以固定格式评论到 PR;评审方 Pi(默认,glm-5.3)、Codex(gpt-6-sol、max)、Claude Code; 与执行方同宿主时标出;评审方失败(报错、超时、额度或限流)记为评审失败,不评论为结论、不计入校准 - 校准集 evals/review/samples.json(回放用例重新注入的问题改动 + 已知良好 PR),calibrate 逐个落盘、 --resume 续跑、--jobs 并行、连续失败即停;2026-09-28 Pi 校准结果入库(56 个样本,TPR 1.0、TNR 1.0,局限见规范) - auto-merge 对 R2 以上 PR 打 needs-independent-review;bin/dispatch review 子命令 - run_check:任务书随本 PR 提交视为设计方自行实现,不要求运行记录(用户决定方案 B);任务书 T006 随本 PR - 规范、PR 模板、harness README、待办同步(B27 改为 3 个 PR 试行;攒批关闭 B30、B11) Task: T006 Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 2 +- .github/workflows/auto-merge.yml | 6 + docs/plans/backlog.md | 6 +- docs/plans/task-006-independent-review.md | 58 +++ docs/specs/delivery-harness.md | 6 +- evals/review/results/2026-09-28-pi.json | 519 ++++++++++++++++++++++ evals/review/samples.json | 6 + harness/README.md | 1 + harness/dispatch.py | 8 + harness/review.py | 442 ++++++++++++++++++ harness/review_prompt.md | 26 ++ harness/rules.toml | 12 + harness/run_check.py | 32 +- tests/test_harness_review_independent.py | 226 ++++++++++ tests/test_harness_run_check.py | 16 + 15 files changed, 1352 insertions(+), 14 deletions(-) create mode 100644 docs/plans/task-006-independent-review.md create mode 100644 evals/review/results/2026-09-28-pi.json create mode 100644 evals/review/samples.json create mode 100644 harness/review.py create mode 100644 harness/review_prompt.md create mode 100644 tests/test_harness_review_independent.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ff1cffc..9c78132 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,7 @@ -- 任务书:docs/plans/task-<编号>-<名字>.md/无 +- 任务书:docs/plans/task-<编号>-<名字>.md(设计方自行实现时随本 PR 提交;只有纯记录类改动可以写「无」) - 目标终态: - 对应验收编号(docs/specs,新增条目写进规格验收表): diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 7005629..c004dff 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -90,6 +90,12 @@ jobs: gh label create escape --repo "$GITHUB_REPOSITORY" --color d93f0b --description "合并后才发现的逃逸缺陷,正文写明引入的 PR" --force >/dev/null gh label create budget-exceeded --repo "$GITHUB_REPOSITORY" --color d93f0b --description "超出任务预算,不自动合并" --force >/dev/null gh pr edit "$PR" --repo "$GITHUB_REPOSITORY" --add-label "class:$CLASS" + # R2 及以上:标出待独立评审(设计 8.3,试行);评审由非设计方经 bin/dispatch review 完成。 + if [ "$RISK" = "R2" ] || [ "$RISK" = "R3" ]; then + gh label create needs-independent-review --repo "$GITHUB_REPOSITORY" --color 1d76db \ + --description "待非设计方独立评审(试行)" >/dev/null 2>&1 || true + gh pr edit "$PR" --repo "$GITHUB_REPOSITORY" --add-label needs-independent-review + fi gh pr edit "$PR" --repo "$GITHUB_REPOSITORY" --add-reviewer "$OWNER" \ || echo "未能请 $OWNER 评审($RISK),可能是其本人开的 PR" diff --git a/docs/plans/backlog.md b/docs/plans/backlog.md index c1779e3..9ae7869 100644 --- a/docs/plans/backlog.md +++ b/docs/plans/backlog.md @@ -22,7 +22,6 @@ | B2 | 首次真实发版时确认 `release_check` 与 environment `release` 的审批拦停生效 | 用户决定发版;版本号先与用户确认 | 用户确认版本号并审批,评审方核对 | 方案 §13 V6 | | B4 | 按改动行做变异测试(先只支持 Python),自动发现「测试写了但没测到东西」 | 出现一次这类逃逸再做;2026-09-26 评估:约 1 天,每个 PR 的 CI 多 1–4 分钟,覆盖不到 Swift | 评审方 | 修复证据方案讨论(2026-09-26) | | B19 | 首次真实发版前确定轻量的发布后观察与恢复步骤:怎样发现安装、导航或数据异常,观察多久,怎样回退并把逃逸缺陷送入回放;先写人工步骤 | 下一次发版准备时,与 B2 同步 | 用户定可接受风险,评审方准备步骤 | Codex 复评覆盖矩阵第 7 环,全链路审计 3.4 | -| B30 | 核对每周质量检查(`quality.yml`,每周一 02:17 UTC)是否按计划自动触发并通过。2026-09-28 核对:工作流为 active、9-25 起在 main 上,但当天定时到 06:17 UTC 仍未触发(GitHub 定时在高负载时可能延迟或丢弃),至今只有 9-26 一次手动运行 | 2026-10-05(下周一)再核对;P6 已加第二个定时(06:43 UTC),周报列出本周定时触发次数;仍都未触发则改用外部触发 | 评审方 | 全链路审计 2.8 | ## 2. 可以做,暂未排期 @@ -32,7 +31,7 @@ | B17 | 选定重复出现的任务类别,用干净环境多次运行同一任务集;分开统计 `pass^k`、人审无需修改率、自动合并抽审纠错率、逃逸缺陷、人工时间与 harness 误拦和 CI 等待成本 | 先定口径,同类任务积累到 5 个以上再执行 | 评审方 | Codex 复评问题 5,全链路审计 3.1 | | B18 | 下次实质修订调研报告时,在 Claude 编辑源补 METR 样本与时间视界版本、`pass^k` 在异质任务上的限定、自动合并下「无修改合并率」的口径,再重新导出快照 | 随报告下次修订 | 评审方 | Codex 复评「调研文章」部分 | | B23 | 停机演练:按规范 §8 停机 → R0 测试 PR 不被自动合并 → 恢复 → 重跑后被自动合并,结果记入规范 §6(步骤已在 P2 写入) | 进入无人值守前一次,之后每季度 | 用户演练,评审方记录 | 全链路审计 G4、1.5,设计第十七节 | -| B27 | R2+ PR 增加独立上下文评审(另一个设计评审方按评审模板),先在 3 个 PR 上试行,再与用户判断对照校准 | 中;流程成本 | 评审方执行,用户决定是否长期保留 | 全链路审计 G5、2.4 | +| B27 | 独立评审试行:工具与校准集随阶段二 P7 合并;剩在 3 个 R2 PR 上试行(`bin/dispatch review `),再与用户判断对照,由用户决定是否常态化 | 小;P7 合并后,随出现的 R2 PR 进行 | 评审方执行,用户决定 | 全链路审计 G5、2.4,设计 8.3 | | B29 | 质量棘轮纳入 Swift 复杂度;加轻量的文档链接与状态新鲜度检查 | 小到中 | 评审方 | 全链路审计 G8、G10、3.2 | ## 3. 持续进行或暂缓 @@ -48,7 +47,6 @@ | 编号 | 事项 | 影响 | |---|---|---| -| B11 | Codex 默认模型 `gpt-6-sol` 不能配 ChatGPT 账号使用(`~/.codex/config.toml` 的 `model`) | 直接运行 `codex` 可能报错;实测时改用 `-m gpt-6-astra` | | B12 | Codex 的 `bigmodel` 服务商缺少密钥文件 `~/.config/glm-developer/glm.key` | ChatGPT 额度用完时无法换用该服务商 | | B13 | V2 探针留下的记录:`~/.codex/config.toml` 中对 scratchpad 探针目录的项目信任与钩子信任 | 无害,可删 | @@ -64,6 +62,8 @@ | B21 | 派发脚本 `bin/dispatch`(PR #55)与端到端验收:真实 K2 任务 T005 经任务书 #56、实现 #57 全程自动完成,主目录无改动;槽位命名随后修正 | 2026-09-28,PR #58 | | B7 | 按任务书预算比对 CI 轮次:合并路由第 5 条超预算即转用户(`run_check.py`),交付度量列出「CI 轮次 / 预算」 | 2026-09-28,PR #59(目标态设计阶段二 P5) | | B25 | 试跑记录自动汇总:周报「试跑记录」表从运行记录、PR 与议题生成;任务 002–004 以 `docs/runs/history.json` 登记 PR,指标由周报读取;任务模板不再手填 | 2026-09-28,PR #60(目标态设计阶段二 P6) | +| B30 | 每周质量检查的定时:2026-09-28 的 02:17 UTC 定时延迟约 6.4 小时后于 08:41 UTC 运行并成功(GitHub 定时会延迟),不是没有触发;P6 已加第二个定时兜底,周报每周列出定时触发次数 | 2026-09-28,目标态设计阶段二 P7(攒批) | +| B11 | Codex 默认模型 `gpt-6-sol` 不能配 ChatGPT 账号:2026-09-28 实测已可用(`codex exec -m gpt-6-sol`,推理强度 max 与 xhigh 均被接受);独立评审按用户指定改用 gpt-6-sol、max | 2026-09-28,目标态设计阶段二 P7(攒批) | | B26 | 开启 GitHub secret scanning 与 push protection:用户在仓库设置中开启,API 核对两项均为 enabled | 2026-09-28,PR #52 | | B5 | Zcode 守卫的 matcher 补上 MCP 批准类工具(同时补议题、标签类;OpenCode、Pi、Claude Code 一并补齐),重新信任转 B32 | 2026-09-28,PR #51(目标态设计阶段一 P3) | | B20 | 回放强制:PR 中每个非文档类 `Defect` 在 head 上须有注入用例、守卫测试或写明原因的暂缓项(`evidence.py`),缺则 harness job 失败 | 2026-09-28,PR #51(目标态设计阶段一 P3) | diff --git a/docs/plans/task-006-independent-review.md b/docs/plans/task-006-independent-review.md new file mode 100644 index 0000000..8b3909f --- /dev/null +++ b/docs/plans/task-006-independent-review.md @@ -0,0 +1,58 @@ +--- +task: T006 +class: K7 +risk: R3 +designer: claude-code +size: medium +architecture: false +spec_refs: [] +no_spec_reason: 护栏与流程改动,验收写在 docs/specs/delivery-harness.md 的约定与验证状态中,没有产品规格验收编号 +budget: + wall_clock_min: 480 + ci_rounds: 3 + retries: 2 + tokens: null +rollback: git revert(删去独立评审工具与校准集;auto-merge 不再打 needs-independent-review 标签) +--- + +# 任务:独立评审试行——非设计方只读评审 R2 以上的 PR,并在校准集上量出可信度 + +状态:设计评审方自行实现(2026-09-28,方案 B:任务书与实现同一个 PR)。来源:目标态设计 8.3、第十六节阶段二 P7;待办 B27;用户决定 5(先在 3 个 R2 PR 上试行)。 + +## 目标终态 + +- `bin/dispatch review ` 在只读的评审工作区(`<仓库名>-review`)中,由不是该任务设计方的评审方评审 PR:只给任务书、PR 描述、diff 与证据包,不给执行过程;结论(通过 / 不通过 / 需用户验收与发现列表)以固定格式评论到 PR,并去掉 `needs-independent-review` 标签。 +- 评审方可选 Pi、Codex、Claude Code;默认由 `harness/rules.toml [review] reviewer` 指定(用户 2026-09-28 定为 Pi + glm-5.3),结构上只读,环境里没有 GitHub 凭据;与执行方同一宿主时在评论中标出。 +- 评审方本身失败(报错、超时、额度用尽)记为「评审失败」:不评论为结论、不计入校准。 +- auto-merge 对 R2 及以上的 PR 打 `needs-independent-review`。 +- 校准集 `evals/review/samples.json`:已知有问题的改动(回放用例在 main 上重新注入)与已知良好的已合并 PR;`review.py calibrate` 逐个落盘、可续跑、连续失败即停,算出 TPR 与 TNR,结果入库。 +- 设计方自行实现的任务(任务书随本 PR 提交)不要求运行记录,仍要求每个提交带 `Task:`(`run_check.py`)。 + +## 非目标与禁止动作 + +- 不让评审结论替代用户审批,不据此自动合并。 +- 不在本 PR 中决定是否常态化:3 个 R2 PR 试行后由用户决定。 +- 不接入 OpenCode 评审方:2026-09-28 实测 OpenCode 在本机无界面运行卡在初始化,待用户排查后再接。 + +## 前置条件(不满足就停下报告) + +- 至少一种评审方能在本机无界面运行并返回可解析的结论。 + +## 验收 + +| 编号 | 验收内容 | 证据类型 | 覆盖(测试名或验证步骤) | +|---|---|---|---| +| 不挂规格:护栏 | 结论解析、分离规则、只读参数、无凭据环境、评论格式、失败处理、校准打分与续跑 | 单测 | `tests.test_harness_review_independent` | +| 不挂规格:护栏 | 设计方自行实现时不要求运行记录、仍要求 `Task:` | 单测 | `tests.test_harness_run_check.RunCheckTest.test_designer_self_implementation_needs_trailers_but_no_record` | +| 不挂规格:护栏 | R2 以上打待评审标签 | CI 断言 | `tests.test_harness_review_independent.WorkflowTest` | +| 不挂规格:护栏 | 在校准集上量出 TPR 与 TNR 并入库 | 真实数据 | `evals/review/results/` 下的结果文件 | +| 不挂规格:护栏 | 3 个 R2 以上 PR 有非设计方的评审结论 | 人工 | 合并后对后续 R2 以上 PR 运行 `bin/dispatch review`,由用户对照(待办 B27) | + +## 步骤与提交顺序 + +| # | 改动 | 涉及文件 | 验证方式 | 对应验收 | +|---|---|---|---|---| +| 1 | 评审工具、提示词、分离规则、失败处理、校准与续跑 | `harness/review.py`、`harness/review_prompt.md`、`harness/dispatch.py`、`harness/rules.toml`、`evals/review/samples.json` | `bin/verify` | 第 1、4 行 | +| 2 | 自行实现的任务不要求运行记录 | `harness/run_check.py` | `bin/verify` | 第 2 行 | +| 3 | auto-merge 打待评审标签 | `.github/workflows/auto-merge.yml` | `bin/verify` | 第 3 行 | +| 4 | 校准结果、规范、README、待办同步 | `evals/review/results/`、`docs/specs/delivery-harness.md`、`harness/README.md`、`docs/plans/backlog.md` | `python3 harness/review.py calibrate --reviewer pi` | 第 4 行 | diff --git a/docs/specs/delivery-harness.md b/docs/specs/delivery-harness.md index 7ea792c..e10d549 100644 --- a/docs/specs/delivery-harness.md +++ b/docs/specs/delivery-harness.md @@ -30,6 +30,8 @@ harness 只依赖 Python 标准库,放在仓库顶层 `harness/`,不进发 | `python3 harness/git_guard.py install` | 把 `core.hooksPath` 指向 `.githooks`(幂等,不覆盖已有设置) | 每个新环境一次 | | `bin/dispatch run <任务书> [--resume] [--model M]` | 派发:准入(含已在 origin/main)→ 认领(推送 `task/<编号>-<名字>`,远端已有即停)→ 取槽位 → 守卫预检 → Pi 执行 → 执行方之外跑 `bin/verify`(失败带摘要重试,同一失败签名连续两次即打转)→ 提交运行记录、推送、开 PR → 等 CI(失败带摘要重试,超 `ci_rounds` 打 `budget-exceeded`);不能完成时升级(PR 评论或议题,`escalation` 标签) | 设计评审方,任务书合并后 | | `bin/dispatch status` / `bin/dispatch stop --all` | 查看槽位;停机:终止所有正在运行的执行方(§8) | 设计评审方、用户 | +| `bin/dispatch review [--reviewer pi\|codex\|claude-code]` | 独立评审:在只读 worktree(`<仓库名>-review`)中由非设计方评审任务书、PR 描述、diff 与证据包,结论以固定格式评论到 PR,并去掉 `needs-independent-review` 标签 | 设计评审方,R2 以上的 PR 进入待评审后 | +| `python3 harness/review.py calibrate --reviewer <评审方> [--limit N]` | 评审校准:回放用例重新注入的已知问题与已知良好的 PR,统计 TPR、TNR,结果写入 `evals/review/results/` | 改评审提示词或换评审模型后 | | `python3 harness/release_check.py --tag vX.Y.Z` | tag 与构建版本一致、构建号递增、tag 在 main 上 | 推 tag 时 CI 自动运行 | `verify` 终端只打印结论,完整输出在 `build/verify/<检查名>.log`,汇总在 `build/verify/summary.json`。判断通过只认退出码和 CI 上当前 head 的运行。 @@ -46,8 +48,9 @@ harness 只依赖 Python 标准库,放在仓库顶层 `harness/`,不进发 - **已知缺陷登记**:发现但暂不修的缺陷写成确定性测试并标 `@unittest.expectedFailure`,说明里写编号与待决事项;修好后它会「意外通过」并报错,逼着移除登记。 - **验收编号**:每份规格的验收表含「证据类型」「覆盖」两列,编号前缀按规格区分——W(桌面组件)、U(用量金额)、DR(工作日报)、IN(统一收件箱)、CB(CLI 会话绑定)、ZN(Zcode 导航)。可自动化条目必须有真实存在的测试;暂缺的登记在 `harness/acceptance-gaps.txt`(带原因,只能缩减)。 - **任务书即合同**(2026-09-28 起):任务按 [task.md](../templates/task.md) 写,开头是 YAML 头部(`task`、`class`、`risk`、`designer`、`size`、`architecture`、`spec_refs` 或 `no_spec_reason`、`budget`、`rollback`),正文含终态、非目标、前置条件、验收、步骤与提交顺序、升级包;由 `harness/taskbook.py` 准入。计划并入任务书的「步骤与提交顺序」,不再单独交计划(计划模板已归档到 `docs/templates/archive/`)。验收表每行的编号是现役规格中的编号、`新增:<规格文件>#<编号>`(同一改动在规格里新增)或 `不挂规格:<原因>`,新需求先进规格再进任务书。执行方对任务书与规格只读(守卫拒绝),需要改合同时写升级包(含澄清)。历史任务 002–004 登记在 `harness/taskbook-exempt.txt`,只检查头部,清单只能缩减。 +- **设计方自行实现也要有任务书**(用户 2026-09-28 决定,方案 B):设计评审方自己实现的工作(如护栏改动)同样先写任务书,与实现放在同一个 PR 中,照常过准入检查,提交带 `Task: <编号>`;独立评审以它为对照。这类 PR 不经派发脚本,不要求运行记录(`run_check.py` 按「任务书随本 PR 提交」识别),本就由用户审批。派给执行方的任务仍须先合并任务书再派发。 - **任务书的审查按类别**(2026-09-28 决定 1):K7 护栏与流程、K8 发版、`architecture: true`(跨两个以上模块、新增或修改公共接口如 CLI 输出与数据格式、数据迁移、新增依赖)的任务书由用户审(R2);其余类别(业务修改、缺陷修复等)的任务书通过准入检查即自动合并(R0)。模板与待办清单由用户审(R2)。类别写低不影响安全:实现 PR 仍按实际改动路径判级。 -- **独立评审**:评审方按 [review-prompt.md](../templates/review-prompt.md) 工作,对照 [review-checklist.md](../templates/review-checklist.md)(由失败分类生成,机器已判定的只核对,评审时间花在机器判定不了的部分);发现编号 `PR<编号>-R<序号>`,修复以证据表为准。 +- **独立评审**(试行,2026-09-28 决定 5;设计 8.3):R2 及以上的 PR 在 CI 通过后由 auto-merge 打上 `needs-independent-review`,设计评审方运行 `bin/dispatch review `。评审方不能是设计方:有任务书时看头部 `designer`,没有时看提交的 `Co-Authored-By`。默认评审方由 `rules.toml [review] reviewer` 指定:用户 2026-09-28 定为 Pi(`zai-coding-cn/glm-5.3`);Codex(`gpt-6-sol`、推理强度 max)与 Claude Code 可用 `--reviewer` 选。评审方与该 PR 的执行方同一宿主时,评论里会标出独立性较弱。评审方只看任务书、PR 描述、diff 与 `review_pack.py` 证据包,不看执行过程;结构上只读(Pi 只开放 read、grep、find、ls 且不加载项目文件,Codex `-s read-only`,Claude Code 只开放 Read、Grep、Glob),环境里没有 GitHub 凭据,结论由派发脚本评论到 PR。评审方本身失败(报错、超时、额度用尽)记为「评审失败」:不评论为结论、保留待评审标签,校准时不计入 TPR、TNR;校准逐个样本落盘,`--resume` 续跑,连续失败 3 次即停。结论只是给用户的输入,不替代用户审批。先试行 3 个 R2 PR,由用户决定是否常态化;30 个 R2 PR 中若没有发现用户未发现的问题,降为抽样。评审提示词 `harness/review_prompt.md` 与校准集 `evals/review/samples.json` 属 R3。人工评审仍可按 [review-prompt.md](../templates/review-prompt.md) 与 [review-checklist.md](../templates/review-checklist.md) 进行。 - **测试的几种形态**:种子固定的性质测试(`tests/test_properties.py`,`PROPTEST_SEEDS=N` 放大搜索)、架构适应度(`tests/test_architecture.py`,同一口径只实现一次)、CLI 黄金快照(`tests/test_golden.py`,有意改变时 `UPDATE_GOLDEN=1` 重新生成,按 R2 评审)。 - **护栏规则先合并**:本机 git 守卫按 origin/main 上的 `harness/rules.toml` 执行(评审 PR7-R3),改规则的 PR 合并前,依赖新规则的文件在本机提交会被拒。放宽类规则(如新增 `[hygiene] allowed` 例外)与依赖它的文件分两个 PR:先合并规则,再提交文件。 - **行为不变的重构**:每个提交带 `Risk: R1`;risk.py 核对只改产品代码、已有测试与黄金快照零改动,另由 `r1_checks.py` 核对被改函数签名不变(Python 按 AST,Swift 按声明文本;新增函数不限)、无新依赖(Python 只能新增标准库与仓库内模块,Swift 不新增 import)、无建表改表语句、增删不超过 400 行,任一不满足按 R2(2026-09-28 决定 2)。变异得分不降要执行代码,由 build 的 harness job 在声明 R1 时运行,下降即失败;想按 R2 走的重构加一个不带 `Risk: R1` 的提交即可。 @@ -158,6 +161,7 @@ Agent 层按角色接入: | 派发脚本(P4) | 单测(`tests/test_harness_dispatch.py`,假执行方与假 GitHub、真实临时 git 远端):成功路径写运行记录、开 PR、主目录无改动、槽位归还;执行方环境无 GitHub 令牌;认领冲突与任务书不在 main 上时不运行执行方;超时、卡死、打转、澄清、停机各自升级;CI 失败带摘要重试、超预算打 `budget-exceeded`;槽位互斥与过期锁回收;守卫取自 origin/main,main 上的守卫失效时停止派发;Pi 参数与事件解析。2026-09-28 真实 Pi 实测 `-na -e` 的守卫加载与拦截 。2026-09-28 真实 K2 任务端到端(B21):任务书 T005(PR #56,R0、K0)由 auto-merge 自动合并;`bin/dispatch` 派发 Pi(glm-5.3-flash)一轮完成、无重试、无守卫拒绝,执行约 12.6 分钟,派发脚本在执行方之外 verify 通过,运行记录与提示词随推送入库,PR #57 描述含运行记录摘要,CI 通过后按 R0、K2 自动合并(合并路由四条均写明理由);主目录前后 git status 无改动;合并后在 main 独立重跑变异,`热力金额分级` 21/21,基线由 90% 升到 100%。发现槽位按启动目录命名(从设计方 worktree 启动得到 `…-sync-slot-1`),已改为按主工作目录命名 | ✅ 单测、守卫实测、真实任务端到端 | | CI 侧复核(P5) | 单测(`tests/test_harness_run_check.py`):派发产生的 PR 通过;设计方分支不适用;带 `Task:` 的任意分支适用;缺记录、提交缺 `Task:`、记录 exit 非 ok、缺字段、task 或分支不符、提示词被改各被标出;以序号最大的记录为准;CI 轮次超预算或读不到时标出,同一提交的重跑不算新轮次;合并路由在缺记录与超预算时转用户、合格时自动合并。T005 的真实运行记录(PR #57)按同一口径合格 | ✅ 单测;首个经合并路由第 5 条判定的真实任务 PR 待下次派发 | | 周报与台账(P6) | 单测(`tests/test_harness_weekly.py`,GitHub 替身):报告含 14.1 全部指标,缺数据标「样本不足」;PR、运行记录与议题算出的各项与手算一致;注入的突增数据在顶部标红,第一期不标、0→2 不标;历史往返跳过当周;试跑记录含历史任务与派发任务;发布时先建后改同一议题;创建标签不带 --force。2026-09-28 用真实数据预演一次(不发布) | ✅ 单测与真实数据预演;首次发布待合并后手动触发 quality | +| 独立评审试行(P7) | 单测(`tests/test_harness_review_independent.py`):结论 JSON 解析(取最后一个、代码块、无法解析按需用户验收)、阻断与严重发现计为抓住;设计方按任务书 `designer` 或 `Co-Authored-By` 判定;评审方参数只读;评审方环境无 GitHub 令牌;评论格式与隐藏数据;TPR、TNR 计算;校准样本覆盖全部回放用例与清单中的 PR,注入样本不动任何分支;R2 以上打待评审标签。校准结果见 `evals/review/results/` | ✅ 单测。2026-09-28 校准(`evals/review/results/2026-09-28-pi.json`,Pi + glm-5.3,56 个样本全部有效):已知有问题 41 个全部判不通过(TPR 1.0),已知良好 15 个无一判不通过(TNR 1.0),其中 12 个判「需用户验收」、3 个通过。局限:问题样本的 PR 描述统一写「行为不变」而 diff 改了行为,识破偏容易,TPR 偏乐观;评审方偏保守,真实 PR 多推回用户确认。过程中发现并修复:评审方失败(Codex 额度用尽、GLM 429 限流)曾被当成结论,现记为评审失败且不计入;校准逐个落盘可续跑,`--jobs` 并行(GLM 限流下 3 路可行、12 路被拒) | ✅ 单测与校准;3 个 R2 PR 试行待做(B27) | | 验收映射 | 6 份规格 69 条编号:可自动化 54 条中 53 条有测试、1 条登记缺口,19 条进入人工清单;检查器在 verify 各档运行 | ✅ | | Swift 回放 | macOS CI `--strict --full`(run 36152060546)通过,Swift 注入在 strict 下不可跳过 | ✅ | | zcode 自检、质量棘轮、交付度量 | 本地实跑;zcode 自检与 CI 中的度量步骤随本 PR 首次在 CI 运行 | ⚠️ 待本 PR 的 CI | diff --git a/evals/review/results/2026-09-28-pi.json b/evals/review/results/2026-09-28-pi.json new file mode 100644 index 0000000..99a181d --- /dev/null +++ b/evals/review/results/2026-09-28-pi.json @@ -0,0 +1,519 @@ +{ + "reviewer": "pi", + "model": "glm-5.3", + "main": "8e7f23389e0799ff9822fb2a9b08777c7efce969", + "date": "2026-09-28", + "samples": [ + { + "kind": "bad", + "id": "V080-R2:0", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 86 + }, + { + "kind": "bad", + "id": "V080-R2:1", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 60 + }, + { + "kind": "bad", + "id": "V080-R3:2", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 197 + }, + { + "kind": "bad", + "id": "V080-R4:3", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 98 + }, + { + "kind": "bad", + "id": "V080-R4:4", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 138 + }, + { + "kind": "bad", + "id": "V080-R19:5", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 73 + }, + { + "kind": "bad", + "id": "V080-R13:6", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 53 + }, + { + "kind": "bad", + "id": "V080-R11:7", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 130 + }, + { + "kind": "bad", + "id": "V080-R11:8", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 98 + }, + { + "kind": "bad", + "id": "V080-R11:9", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 158 + }, + { + "kind": "bad", + "id": "V080-R12:10", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 111 + }, + { + "kind": "bad", + "id": "V080-R7:11", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 106 + }, + { + "kind": "bad", + "id": "V080-R17:12", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 101 + }, + { + "kind": "bad", + "id": "H0925-1:13", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 55 + }, + { + "kind": "bad", + "id": "V080-R8:14", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 47 + }, + { + "kind": "bad", + "id": "H0925-4:15", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 105 + }, + { + "kind": "bad", + "id": "H0925-2:16", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 129 + }, + { + "kind": "bad", + "id": "V080-R15:17", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 76 + }, + { + "kind": "bad", + "id": "V080-R9:18", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 86 + }, + { + "kind": "bad", + "id": "H0926-7:19", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 101 + }, + { + "kind": "bad", + "id": "H0926-8:20", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 94 + }, + { + "kind": "bad", + "id": "H0925-2:21", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 101 + }, + { + "kind": "bad", + "id": "H0925-5:22", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 107 + }, + { + "kind": "bad", + "id": "PR7-R1:23", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 59 + }, + { + "kind": "bad", + "id": "PR7-R2:24", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 84 + }, + { + "kind": "bad", + "id": "H0926-4:25", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 171 + }, + { + "kind": "bad", + "id": "H0926-5:26", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 109 + }, + { + "kind": "bad", + "id": "H0926-5:27", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 87 + }, + { + "kind": "bad", + "id": "PR7-R3:28", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 94 + }, + { + "kind": "bad", + "id": "PR7-R4:29", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 93 + }, + { + "kind": "bad", + "id": "PR7-R5:30", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 97 + }, + { + "kind": "bad", + "id": "PR7-R6:31", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 254 + }, + { + "kind": "bad", + "id": "PR7-R6:32", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 107 + }, + { + "kind": "bad", + "id": "PR7-R7:33", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 100 + }, + { + "kind": "bad", + "id": "PR7-R7:34", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 133 + }, + { + "kind": "bad", + "id": "PR7-R7:35", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 93 + }, + { + "kind": "bad", + "id": "PR7-R9:36", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 145 + }, + { + "kind": "bad", + "id": "H0926-3:37", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 89 + }, + { + "kind": "bad", + "id": "H0926-2:38", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 93 + }, + { + "kind": "bad", + "id": "H0926-5:39", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 104 + }, + { + "kind": "bad", + "id": "V080-R10:40", + "verdict": "不通过", + "flagged": true, + "parsed": true, + "failure": "", + "seconds": 103 + }, + { + "kind": "good", + "id": "PR57", + "verdict": "通过", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 103 + }, + { + "kind": "good", + "id": "PR39", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 295 + }, + { + "kind": "good", + "id": "PR36", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 244 + }, + { + "kind": "good", + "id": "PR30", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 394 + }, + { + "kind": "good", + "id": "PR18", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 293 + }, + { + "kind": "good", + "id": "PR22", + "verdict": "通过", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 434 + }, + { + "kind": "good", + "id": "PR25", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 462 + }, + { + "kind": "good", + "id": "PR14", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 359 + }, + { + "kind": "good", + "id": "PR10", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 208 + }, + { + "kind": "good", + "id": "PR12", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 392 + }, + { + "kind": "good", + "id": "PR43", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 290 + }, + { + "kind": "good", + "id": "PR8", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 356 + }, + { + "kind": "good", + "id": "PR58", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 287 + }, + { + "kind": "good", + "id": "PR9", + "verdict": "需用户验收", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 147 + }, + { + "kind": "good", + "id": "PR59", + "verdict": "通过", + "flagged": false, + "parsed": true, + "failure": "", + "seconds": 341 + } + ], + "bad": 41, + "good": 15, + "errors": 0, + "tpr": 1.0, + "tnr": 1.0, + "unparsed": 0, + "total": 56 +} diff --git a/evals/review/samples.json b/evals/review/samples.json new file mode 100644 index 0000000..5fb5538 --- /dev/null +++ b/evals/review/samples.json @@ -0,0 +1,6 @@ +{ + "_说明": "独立评审校准集 v1(目标态设计 8.3)。已知有问题:harness/replay_cases.py 的每个注入用例在当时的 main 上重新注入,PR 描述统一写成行为不变的整理;已知良好:合并后未发现问题的 PR(避开 v0.8.0 时期的功能 PR 与超大 PR)。改动本文件属 R3。", + "bad_title": "重构:整理代码,行为不变", + "skip_defects": [], + "good_prs": [57, 39, 36, 30, 22, 25, 18, 14, 12, 10, 8, 43, 58, 59, 9] +} diff --git a/harness/README.md b/harness/README.md index 5fcf17b..7d330ec 100644 --- a/harness/README.md +++ b/harness/README.md @@ -54,6 +54,7 @@ | `policy.py`、`autonomy.toml` | 合并路由:风险、类别自治等级、误差预算、规模、任务 PR 的运行记录逐条判定并写理由;K3 三抽一抽审;放权只改 `autonomy.toml`(R3) | | `run_check.py` | 任务 PR 的 CI 侧复核:`Task:` 归属、运行记录格式与一致性、CI 轮次对预算 | | `weekly.py` | 周报:交付指标、突增标红、误差预算与抽审进度、试跑记录汇总,写进「每周质量报告」议题 | +| `review.py`、`review_prompt.md`(`bin/dispatch review`) | 独立评审:非设计方、只读、只看任务书与 diff 与证据包,结论评论到 PR;`calibrate` 在 `evals/review/` 校准集上统计 TPR、TNR | | `release_check.py` | 发版前核对版本号与 tag | | `metrics.py` | 交付度量,与 v0.8.0 基线并列 | | `review_pack.py` | 评审证据包:把机器结论汇成一页 | diff --git a/harness/dispatch.py b/harness/dispatch.py index 40e7df0..86596f1 100644 --- a/harness/dispatch.py +++ b/harness/dispatch.py @@ -19,6 +19,7 @@ bin/dispatch run docs/plans/task-005-x.md [--resume] [--model M] bin/dispatch status bin/dispatch stop --all + bin/dispatch review [--reviewer pi|codex|claude-code] 独立评审(harness/review.py) """ from __future__ import annotations @@ -577,12 +578,19 @@ def main(argv: list[str] | None = None) -> int: run.add_argument("taskbook") run.add_argument("--resume", action="store_true", help="从远端已有的任务分支继续") run.add_argument("--model", help="执行方模型(默认用 Pi 当前设置)") + review = sub.add_parser("review", help="独立评审一个 PR(非设计方,只读)") + review.add_argument("pr", type=int) + review.add_argument("--reviewer", choices=["pi", "codex", "claude-code"]) sub.add_parser("status", help="查看槽位") stop = sub.add_parser("stop", help="停机:终止所有正在运行的执行方") stop.add_argument("--all", action="store_true", required=True) args = parser.parse_args(argv) if args.command == "status": return status() + if args.command == "review": + import review as review_module # review 依赖本模块,按需导入 + + return review_module.review_pr(args.pr, args.reviewer) if args.command == "stop": return stop_all() if (state_dir() / "stop").exists(): diff --git a/harness/review.py b/harness/review.py new file mode 100644 index 0000000..12d9473 --- /dev/null +++ b/harness/review.py @@ -0,0 +1,442 @@ +"""独立评审:R2 及以上的 PR 由非任务设计方的 Agent 评审,结论写回 PR 供用户参考(目标态设计 8.3,决定 5)。 + +分离规则:评审方不是该任务的设计方(任务书头部 designer;没有任务书时按提交的 Co-Authored-By 判断); +也不看执行方轨迹,只看任务书、PR 描述、diff 与证据包。默认评审方由 rules.toml [review] reviewer 指定 +(用户 2026-09-28 定为 Pi + glm-5.3);评审方与执行方同一宿主时在评论中标出,供用户判断独立性。 + +结构上保证只读:评审在单独的 worktree(<仓库名>-review)里进行,Pi 只开放 read、grep、find、ls 且不加载 +项目文件,Codex 以 `-s read-only` 沙箱运行,Claude Code 只开放 Read、Grep、Glob;评审方的环境没有 GitHub +凭据,结论由本脚本以 Agent 身份评论到 PR。 +试行期内评审结论只是给用户的输入,不替代用户审批。 + +校准(设计 8.3):`calibrate` 用已知有问题的改动(由事故回放用例在 main 上重新注入)与已知良好的已合并 PR, +分别统计抓住问题的比例(TPR)与放过好改动的比例(TNR),结果写入 evals/review/results/。 + + bin/dispatch review [--reviewer pi|codex|claude-code] + python3 harness/review.py calibrate --reviewer pi [--limit N] [--jobs N] [--resume evals/review/results/<文件>] +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import subprocess +import sys +import tempfile +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from queue import Queue + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import dispatch +import dispatch_host +import run_check +from common import ROOT, changed_files, commit_field, git, load_rules + +PROMPT = Path(__file__).resolve().parent / "review_prompt.md" +EVALS = ROOT / "evals" / "review" +VERDICTS = ("通过", "不通过", "需用户验收") +OTHER = {"claude-code": "codex", "codex": "claude-code"} +REVIEWERS = ("pi", "codex", "claude-code") +LABEL = "needs-independent-review" + + +@dataclass +class Verdict: + verdict: str + findings: list[dict] = field(default_factory=list) + summary: str = "" + parsed: bool = True + failure: str = "" # 评审方本身失败(报错退出、超时、额度用尽):不是结论,不计入校准,不评论为结论 + + @property + def flagged(self) -> bool: + """校准口径:不通过,或有阻断、严重发现,即视为抓住了问题。""" + return self.verdict == "不通过" or any(item.get("severity") in ("阻断", "严重") for item in self.findings) + + +def parse_output(text: str) -> Verdict: + """取最后一个带 verdict 的 JSON 对象;取不到时按「需用户验收」处理并标明未解析。""" + for line in reversed(text.strip().splitlines()): + line = line.strip().removeprefix("```json").removeprefix("```").strip() + if not (line.startswith("{") and '"verdict"' in line): + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + verdict = data.get("verdict", "") + if verdict in VERDICTS: + findings = [item for item in data.get("findings", []) if isinstance(item, dict)] + return Verdict(verdict, findings, str(data.get("summary", ""))) + return Verdict("需用户验收", [], "评审输出中没有可解析的结论 JSON", parsed=False) + + +# ---- 评审方 ---- + +class Reviewer: + name = "" + + def argv(self, prompt: str, workspace: Path, output: Path) -> list[str]: + raise NotImplementedError + + def read(self, stdout: str, output: Path) -> tuple[str, str]: + """返回 (最终文本, 模型)。""" + raise NotImplementedError + + +class CodexReviewer(Reviewer): + name = "codex" + + def __init__(self, model: str, effort: str | None = None): + self.model, self.effort = model, effort + + def argv(self, prompt, workspace, output): + effort = ["-c", f'model_reasoning_effort="{self.effort}"'] if self.effort else [] + return ["codex", "exec", "-s", "read-only", "-m", self.model, *effort, "-C", str(workspace), + "--skip-git-repo-check", "-o", str(output), prompt] + + def read(self, stdout, output): + label = f"{self.model} {self.effort}" if self.effort else self.model + return (output.read_text(encoding="utf-8") if output.exists() else stdout), label + + +class ClaudeReviewer(Reviewer): + name = "claude-code" + + def __init__(self, model: str | None = None): + self.model = model + + def argv(self, prompt, workspace, output): + command = ["claude", "-p", "--allowedTools", "Read,Grep,Glob", "--output-format", "json"] + if self.model: + command += ["--model", self.model] + return command + [prompt] + + def read(self, stdout, output): + try: + data = json.loads(stdout) + except json.JSONDecodeError: + return stdout, self.model or "" + model = next(iter(data.get("modelUsage") or {}), self.model or "") + return str(data.get("result", "")), model + + +class PiReviewer(Reviewer): + name = "pi" + + def __init__(self, model: str | None = None): + self.model = model + + def argv(self, prompt, workspace, output): + command = ["pi", "-na", "--tools", "read,grep,find,ls", "-p", "--mode", "json", "--no-session"] + if self.model: + command += ["--model", self.model] + return command + [prompt] + + def read(self, stdout, output): + """取最后一条助手消息的文本与模型(pi 的 json 事件流)。""" + text, model = "", self.model or "" + for line in stdout.splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + message = event.get("message") or {} + if event.get("type") == "message_end" and message.get("role") == "assistant": + parts = [part.get("text", "") for part in message.get("content", []) if part.get("type") == "text"] + if any(parts): + text = "\n".join(parts) + model = message.get("model", model) + return text, model + + +def make_reviewer(name: str) -> Reviewer: + config = load_rules().get("review", {}) + if name == "pi": + return PiReviewer(config.get("pi_model")) + if name == "codex": + return CodexReviewer(config.get("codex_model", "gpt-6-sol"), config.get("codex_reasoning_effort")) + if name == "claude-code": + return ClaudeReviewer(config.get("claude_model")) + raise SystemExit(f"未知的评审方:{name}") + + +def run_reviewer(reviewer: Reviewer, workspace: Path, timeout: float) -> tuple[Verdict, str, float]: + prompt = PROMPT.read_text(encoding="utf-8") + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="review-") as tmp: + output = Path(tmp) / "last.txt" + env = dispatch_host.executor_env(dict(os.environ), {}, Path(tmp) / "gh") # 没有 GitHub 凭据 + (Path(tmp) / "gh").mkdir() + try: + result = subprocess.run(reviewer.argv(prompt, workspace, output), cwd=workspace, env=env, + capture_output=True, text=True, timeout=timeout, check=False) + text, model = reviewer.read(result.stdout, output) + failure = "" if result.returncode == 0 else _failure_line(result.stdout + result.stderr, result.returncode) + except subprocess.TimeoutExpired: + text, model, failure = "", "", f"超过 {int(timeout / 60)} 分钟未完成" + verdict = parse_output(text) + if failure or not text.strip(): + verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出") + return verdict, model, time.monotonic() - started + + +def _failure_line(output: str, code: int) -> str: + """评审方报错时取最能说明原因的一行(如额度用尽),不含提示词与材料。""" + lines = [line.strip() for line in output.splitlines() if line.strip().startswith("ERROR")] + return (lines[-1] if lines else f"退出码 {code}")[:300] + + +# ---- 分离规则与材料 ---- + +def designer_of(base: str, head: str, branch: str, cwd: Path) -> str | None: + target = run_check.scope(base, head, branch, cwd) + if target is not None and target.header.get("designer") in OTHER: + return target.header["designer"] + trailers = " ".join(value for sha in git("rev-list", "--no-merges", f"{base}..{head}", cwd=cwd).split() + for value in commit_field(sha, "Co-Authored-By", cwd)) + if "Claude" in trailers: + return "claude-code" + if "Codex" in trailers or "OpenAI" in trailers: + return "codex" + return None + + +def review_workspace(root: Path, index: int = 0) -> Path: + """评审工作区:第 0 个为 <仓库名>-review,并行校准时另建 <仓库名>-review-。""" + main = dispatch.main_checkout(root) + workspace = main.parent / (f"{main.name}-review" + (f"-{index}" if index else "")) + if not workspace.exists(): + git("worktree", "add", "--detach", str(workspace), "origin/main", cwd=root) + return workspace + + +def checkout(workspace: Path, ref: str) -> None: + git("checkout", "--quiet", "--force", "--detach", ref, cwd=workspace) + git("clean", "-ffdxq", "-e", dispatch.VENV, cwd=workspace) + + +def write_materials(workspace: Path, base: str, pr_text: str, task_text: str) -> None: + folder = workspace / "build" / "review" + folder.mkdir(parents=True, exist_ok=True) + (folder / "pr.md").write_text(pr_text, encoding="utf-8") + (folder / "task.md").write_text(task_text or "无", encoding="utf-8") + diff = git("diff", "--no-color", f"{base}...HEAD", cwd=workspace) + (folder / "diff.patch").write_text(diff[:200_000], encoding="utf-8") + pack = subprocess.run([sys.executable, str(workspace / "harness" / "review_pack.py"), "--base", base, "--no-run"], + cwd=workspace, capture_output=True, text=True, check=False) + (folder / "pack.md").write_text(pack.stdout or pack.stderr, encoding="utf-8") + + +def run_check_records(workspace: Path, base: str) -> list[dict]: + """本 PR 新增的运行记录(用于判断评审方是否与执行方同一宿主)。""" + records = [] + for status, path in changed_files(base, "HEAD", workspace): + if status != "D" and run_check.RECORD_PATH.match(path): + try: + records.append(json.loads((workspace / path).read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + continue + return records + + +def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | None, head: str, seconds: float, + same_host: bool = False) -> str: + lines = [ + f"### 独立评审(试行):{verdict.verdict}", "", + f"- 评审方:{reviewer}({model or '模型未报告'});设计方:{designer or '未能判定'};评审的提交:`{head[:12]}`;用时 {seconds / 60:.1f} 分钟", + "- 只读评审:只看任务书、PR 描述、diff 与证据包,不看执行过程;结论供用户参考,不替代用户审批。", + ] + if same_host: + lines.append(f"- ⚠️ 评审方与本 PR 的执行方同为 {reviewer}:独立性较弱,请结合自己的判断。") + lines += ["", f"**结论**:{verdict.summary or '(无)'}", ""] + if verdict.findings: + lines += ["| 严重度 | 位置 | 问题 | 修复要求 |", "|---|---|---|---|"] + lines += [f"| {item.get('severity', '')} | {item.get('location', '')} | {item.get('problem', '')} | " + f"{item.get('fix', '')} |" for item in verdict.findings] + else: + lines.append("没有发现。") + data = {"verdict": verdict.verdict, "reviewer": reviewer, "model": model, "head": head, + "findings": len(verdict.findings), "flagged": verdict.flagged, "parsed": verdict.parsed} + lines += ["", f""] + return "\n".join(lines) + "\n" + + +def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github=None) -> int: + github = github or dispatch.GitHub(root) + pr = json.loads(github._run(["gh", "pr", "view", str(number), "--json", "title,body,headRefName,headRefOid,baseRefName"])) + workspace = review_workspace(root) + git("fetch", "--quiet", "origin", f"pull/{number}/head", "main", cwd=workspace) + checkout(workspace, pr["headRefOid"]) + base = git("merge-base", "origin/main", "HEAD", cwd=workspace) + designer = designer_of(base, "HEAD", pr["headRefName"], workspace) + name = reviewer_name or load_rules().get("review", {}).get("reviewer") or OTHER.get(designer or "") + if name is None: + print("未能判定评审方:用 --reviewer 指定(不能是设计方本身)") + return 2 + if name == designer: + print(f"评审方 {name} 就是设计方,违反分离规则") + return 2 + target = run_check.scope(base, "HEAD", pr["headRefName"], workspace) + task_text = (workspace / target.taskbook).read_text(encoding="utf-8") if target and target.taskbook else "" + write_materials(workspace, base, f"# {pr['title']}\n\n{pr['body']}", task_text) + verdict, model, seconds = run_reviewer(make_reviewer(name), workspace, load_rules().get("review", {}).get( + "timeout_minutes", 30) * 60) + if verdict.failure: + print(f"PR #{number}:评审方失败({verdict.failure}),没有评论,保留待评审标签") + return 1 + executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} + github.comment(number, render_comment(verdict, name, model, designer, pr["headRefOid"], seconds, + same_host=name in executors)) + try: + github._run(["gh", "pr", "edit", str(number), "--remove-label", LABEL], agent=True) + except RuntimeError: + pass # 没有该标签 + print(f"PR #{number}:{verdict.verdict}({name}),已评论") + return 0 + + +# ---- 校准 ---- + +def calibration_samples(root: Path = ROOT) -> list[dict]: + """已知有问题:事故回放用例(在 main 上重新注入);已知良好:manifest 中列出的已合并 PR。""" + from replay_cases import CASES + + manifest = json.loads((EVALS / "samples.json").read_text(encoding="utf-8")) + bad = [{"kind": "bad", "id": f"{case.defect}:{index}", "title": manifest["bad_title"], "file": case.file, + "find": case.find, "replace": case.replace} + for index, case in enumerate(CASES) if case.defect not in manifest.get("skip_defects", [])] + good = [{"kind": "good", "id": f"PR{number}", "pr": number} for number in manifest["good_prs"]] + return bad + good + + +def prepare_sample(workspace: Path, sample: dict, main_ref: str, root: Path) -> tuple[str, str]: + """把工作区准备成「PR 的 head」,返回 (base, PR 文本)。""" + if sample["kind"] == "bad": + checkout(workspace, main_ref) + path = workspace / sample["file"] + text = path.read_text(encoding="utf-8") + if text.count(sample["find"]) != 1: + raise ValueError(f"{sample['id']} 的注入点在 {main_ref[:7]} 上不唯一或已过期") + path.write_text(text.replace(sample["find"], sample["replace"]), encoding="utf-8") + # 用底层命令建提交(不经过提交钩子,也不动任何分支):样本只存在于评审工作区,不推送。 + env = {**os.environ, "GIT_AUTHOR_NAME": "calibration", "GIT_AUTHOR_EMAIL": "calibration@example.com", + "GIT_COMMITTER_NAME": "calibration", "GIT_COMMITTER_EMAIL": "calibration@example.com"} + git("add", "--", sample["file"], cwd=workspace) + tree = git("write-tree", cwd=workspace) + commit = subprocess.run(["git", "commit-tree", tree, "-p", "HEAD", "-m", sample["title"]], cwd=workspace, + env=env, capture_output=True, text=True, check=True).stdout.strip() + git("checkout", "--quiet", "--detach", commit, cwd=workspace) + return main_ref, f"# {sample['title']}\n\n整理代码,行为不变。\n" + merge = git("log", "--merges", "--first-parent", "--format=%H %s", "origin/main", cwd=root) + sha = next((line.split()[0] for line in merge.splitlines() if f"#{sample['pr']} " in line + " "), None) + if sha is None: + raise ValueError(f"找不到 PR #{sample['pr']} 的合并提交") + checkout(workspace, f"{sha}^2") + body = git("log", "-1", "--format=%B", sha, cwd=root) + return git("rev-parse", f"{sha}^1", cwd=root), f"# PR #{sample['pr']}\n\n{body}\n" + + +MAX_CONSECUTIVE_FAILURES = 3 + + +def calibrate(reviewer_name: str, limit: int | None = None, root: Path = ROOT, resume: Path | None = None, + reviewer: Reviewer | None = None, jobs: int = 1) -> dict: + """逐个样本评审,每完成一个就写盘;--resume 跳过已有有效结论的样本;--jobs 并行(每个任务独占一个评审 + 工作区);评审方连续失败 3 次即停(如额度用尽)。""" + workspace = review_workspace(root) + git("fetch", "--quiet", "origin", "main", cwd=workspace) + reviewer = reviewer or make_reviewer(reviewer_name) + previous = json.loads(resume.read_text(encoding="utf-8")) if resume else {} + main_ref = previous.get("main") or git("rev-parse", "origin/main", cwd=root) + done = {item["id"]: item for item in previous.get("samples", []) if item.get("parsed") and "error" not in item} + record = {"reviewer": reviewer_name, "model": previous.get("model", ""), "main": main_ref, + "date": previous.get("date") or dt.date.today().isoformat(), "samples": list(done.values())} + out = resume or EVALS / "results" / f"{record['date']}-{reviewer_name}.json" + out.parent.mkdir(parents=True, exist_ok=True) + pending = [sample for sample in calibration_samples(root)[:limit] if sample["id"] not in done] + # 每个并行任务独占一个评审工作区(git worktree 的创建串行进行,避免锁冲突)。 + workspaces = Queue() + for index in range(max(1, min(jobs, len(pending)))): + workspaces.put(workspace if index == 0 else review_workspace(root, index)) + + def review_one(sample: dict) -> dict: + space = workspaces.get() + try: + try: + base, pr_text = prepare_sample(space, sample, main_ref, root) + except (ValueError, RuntimeError, subprocess.CalledProcessError) as error: + return {"kind": sample["kind"], "id": sample["id"], "error": str(error)} + write_materials(space, base, pr_text, "") + verdict, model, seconds = run_reviewer(reviewer, space, 30 * 60) + return {"kind": sample["kind"], "id": sample["id"], "verdict": verdict.verdict, "flagged": verdict.flagged, + "parsed": verdict.parsed, "failure": verdict.failure, "seconds": round(seconds), "model": model} + finally: + workspaces.put(space) + + failures = 0 + with ThreadPoolExecutor(max_workers=workspaces.qsize()) as pool: + futures = [pool.submit(review_one, sample) for sample in pending] + for future in as_completed(futures): + item = future.result() + record["model"] = item.pop("model", "") or record["model"] + record["samples"].append(item) + record.update(score(record["samples"])) + out.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + failure = item.get("failure", "") + print(f"{item['id']}:{item.get('verdict', '出错')}{'(抓住)' if item.get('flagged') else ''}" + f"{f':{failure}' if failure else ''}{item.get('error', '')}", flush=True) + failures = failures + 1 if failure else 0 + if failures >= MAX_CONSECUTIVE_FAILURES: + print(f"评审方连续失败 {failures} 次,停止;恢复后运行 calibrate --resume {out}") + for other in futures: + other.cancel() + break + record.update(score(record["samples"])) + out.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(score(record["samples"]), ensure_ascii=False)) + return record + + +def score(results: list[dict]) -> dict: + """只统计有效结论:样本准备出错与评审方失败都不计入 TPR、TNR(否则失败会被当成「放过」)。""" + valid = [item for item in results if "error" not in item and item.get("parsed", True)] + bad = [item for item in valid if item["kind"] == "bad"] + good = [item for item in valid if item["kind"] == "good"] + tp = sum(item["flagged"] for item in bad) + tn = sum(not item["flagged"] for item in good) + return { + "bad": len(bad), "good": len(good), "errors": sum("error" in item for item in results), + "tpr": round(tp / len(bad), 3) if bad else None, + "tnr": round(tn / len(good), 3) if good else None, + "unparsed": sum(not item.get("parsed", True) for item in results if "error" not in item), + "total": len(results), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + pr = sub.add_parser("pr", help="评审一个 PR") + pr.add_argument("number", type=int) + pr.add_argument("--reviewer", choices=REVIEWERS) + cal = sub.add_parser("calibrate", help="在校准集上统计 TPR 与 TNR") + cal.add_argument("--reviewer", choices=REVIEWERS, required=True) + cal.add_argument("--limit", type=int) + cal.add_argument("--resume", type=Path, help="接着上次的结果文件继续(跳过已有有效结论的样本)") + cal.add_argument("--jobs", type=int, default=1, help="并行评审数(每个占一个评审工作区)") + args = parser.parse_args(argv) + if args.command == "pr": + return review_pr(args.number, args.reviewer) + calibrate(args.reviewer, args.limit, resume=args.resume, jobs=args.jobs) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/review_prompt.md b/harness/review_prompt.md new file mode 100644 index 0000000..46ac363 --- /dev/null +++ b/harness/review_prompt.md @@ -0,0 +1,26 @@ +你是本仓库的独立评审方:这个改动不是你设计的,你也看不到执行过程,只根据下面的材料判断它能否合并。你只读,不修改任何文件,不运行会改动仓库或访问网络的命令。 + +材料(都在当前目录下): + +- `build/review/pr.md`:PR 标题与描述(作者的自述,不能当作证据) +- `build/review/task.md`:任务书(没有任务书时写明「无」) +- `build/review/diff.patch`:相对合并基点的完整改动 +- `build/review/pack.md`:机器生成的证据包(风险等级、修复证据的提交与测试映射、涉及的验收编号) +- 评审清单:`docs/templates/review-checklist.md`;仓库规则:`AGENTS.md`、`docs/specs/delivery-harness.md` + +## 怎么评 + +1. 先读任务书与 PR 描述,弄清这个改动声称要做什么、不做什么。 +2. 逐行读 diff,尤其是删除的行。需要上下文时读仓库里的相关文件。 +3. 对照评审清单中与本次改动相关的类型,重点看机器判定不了的部分:需求偏离(做的不是任务书要的)、口径与边界条件、新增参数或字段是否每个调用方都接上、测试是否真的调用产品代码并断言了这条验收、是否顺手删改了无关代码、日志或文件是否新写入了会话正文或本机路径。 +4. 不采信自述:「已修复」「测试通过」以证据包为准。 + +## 输出 + +先用几段话写评审意见。最后一行只输出一个 JSON 对象(不要代码块标记),格式: + +{"verdict": "通过 | 不通过 | 需用户验收", "findings": [{"severity": "阻断 | 严重 | 一般", "location": "文件:行", "problem": "问题", "fix": "修复要求"}], "summary": "一句话结论"} + +- 有阻断或严重发现时 verdict 为「不通过」。 +- 只有机器判定不了、需要人在真机或真实数据上确认的项时,verdict 为「需用户验收」,并在 findings 里写清验收步骤。 +- 没有发现时 findings 为空数组。不要为了显得认真而编造发现。 diff --git a/harness/rules.toml b/harness/rules.toml index f326e09..f0b6af7 100644 --- a/harness/rules.toml +++ b/harness/rules.toml @@ -49,6 +49,7 @@ r3 = [ ".codex/**", "harness/**", "bin/**", + "evals/review/samples.json", "requirements*.txt", "scripts/build_inbox_app.py", "scripts/migrations.py", @@ -75,6 +76,7 @@ r0 = [ "docs/decisions/**", "docs/images/**", "docs/runs/**", + "evals/review/results/**", "README.md", "README.zh-CN.md", ] @@ -129,6 +131,16 @@ slots = 3 stall_minutes = 15 ci_timeout_minutes = 60 +[review] +# 独立评审(harness/review.py):默认评审方与各评审方的模型、超时。 +# 用户 2026-09-28 指定:默认由 Pi(glm-5.3)评审;Codex 可选(gpt-6-sol、max)。 +# 换评审方、模型或推理强度后须在校准集上重跑 TPR、TNR 再比较(设计 8.3)。 +reviewer = "pi" +pi_model = "zai-coding-cn/glm-5.3" +codex_model = "gpt-6-sol" +codex_reasoning_effort = "max" +timeout_minutes = 30 + [guard] # git 层守卫(.githooks → harness/git_guard.py)。以下操作被拒绝,除非人显式设置覆盖变量: # 在保护分支上提交、推送保护分支 HARNESS_ALLOW_MAIN=1 diff --git a/harness/run_check.py b/harness/run_check.py index 8693422..4dc8796 100644 --- a/harness/run_check.py +++ b/harness/run_check.py @@ -4,6 +4,8 @@ 机器再核对一遍,即使绕过派发脚本手工派发,缺记录或超预算也会被发现: 适用 分支是 task/<名字> 且 docs/plans/task-<名字>.md 存在,或任一提交带 `Task:`(实现某份任务书的 PR) + 自行实现 任务书就在本 PR 中新增或修改:设计方自己实现(用户 2026-09-28 决定,方案 B),不经派发脚本, + 不要求运行记录与 CI 轮次(这类 PR 本就不自动合并),仍要求每个提交带 `Task:` 归属 范围内每个非合并提交都带 `Task: <该任务书的编号>` 运行记录 新增了 docs/runs/<任务书名>/<序号>.json;序号最大的一份格式完整、task/class/branch 与任务书一致、 exit 为 ok,提示词文件存在且 sha256 与记录一致 @@ -50,28 +52,37 @@ class Finding: class Scope: taskbook: str # docs/plans/task-<名字>.md header: dict + in_pr: bool = False # 任务书随本 PR 新增或修改:设计方自行实现 def scope(base: str, head: str, branch: str, cwd: Path = ROOT) -> Scope | None: - """这个 PR 是否在实现某份任务书;任务书从 cwd(auto-merge 中是 main)读取。""" + """这个 PR 是否在实现某份任务书。任务书先从 cwd(auto-merge 中是 main)读取;随本 PR 提交、main 上还没有的, + 从 head 读取。""" shas = git("rev-list", "--no-merges", f"{base}..{head}", cwd=cwd).split() ids = {value.strip() for sha in shas for value in commit_field(sha, "Task", cwd)} candidates = [] if branch.startswith("task/"): candidates.append(f"docs/plans/task-{branch.removeprefix('task/')}.md") + changed = {path: status for status, path in changed_files(base, head, cwd)} for task_id in sorted(ids): match = re.fullmatch(r"T(\d+)", task_id) if match: + prefix = f"docs/plans/task-{int(match[1]):03d}-" candidates += sorted(p.relative_to(cwd).as_posix() for p in (cwd / "docs" / "plans").glob(f"task-{int(match[1]):03d}-*.md")) + candidates += sorted(path for path in changed if path.startswith(prefix) and path.endswith(".md")) for rel in candidates: - path = cwd / rel - if path.is_file(): - try: - header, _ = taskbook.parse_header(path.read_text(encoding="utf-8")) - except taskbook.HeaderError: - continue - return Scope(rel, header) + in_pr = changed.get(rel) in ("A", "M") + text = git("show", f"{head}:{rel}", cwd=cwd, check=False) if in_pr else "" + if not text and (cwd / rel).is_file(): + text = (cwd / rel).read_text(encoding="utf-8") + if not text: + continue + try: + header, _ = taskbook.parse_header(text) + except taskbook.HeaderError: + continue + return Scope(rel, header, in_pr) if ids: # 带了 Task: 却找不到任务书:按适用处理,归属检查会报出来 return Scope("", {"task": min(ids)}) return None @@ -147,7 +158,10 @@ def check(base: str, head: str, branch: str, cwd: Path = ROOT, rounds: int | Non task_id = target.header.get("task", "") findings = [check_trailers(base, head, task_id, cwd)] if not target.taskbook: - findings.append(Finding("任务书", False, f"`Task: {task_id}` 在 {cwd.name} 上找不到对应的任务书")) + findings.append(Finding("任务书", False, f"`Task: {task_id}` 找不到对应的任务书")) + return findings + if target.in_pr: + findings.append(Finding("运行记录", True, f"任务书 `{target.taskbook}` 随本 PR 提交:设计方自行实现,不要求运行记录")) return findings findings.append(check_record(base, head, target, branch, cwd)) if with_ci: diff --git a/tests/test_harness_review_independent.py b/tests/test_harness_review_independent.py new file mode 100644 index 0000000..d447d02 --- /dev/null +++ b/tests/test_harness_review_independent.py @@ -0,0 +1,226 @@ +"""独立评审(harness/review.py):结论解析、分离规则、只读的评审方参数、评论格式、校准样本与打分。 + +设计 8.3 的机器验收中「TPR、TNR 入库」「3 个 R2 PR 有非设计方的评审结论」需真实运行,另行记录。 +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "harness")) +sys.path.insert(0, str(ROOT / "tests")) + +import review +from test_harness import TempRepo + + +class ParseTest(unittest.TestCase): + def test_last_json_line_wins(self): + text = '评审意见……\n{"verdict": "通过", "findings": [], "summary": "旧"}\n更多\n' \ + '{"verdict": "不通过", "findings": [{"severity": "阻断", "location": "a.py:3", "problem": "p", "fix": "f"}], "summary": "s"}' + verdict = review.parse_output(text) + self.assertEqual((verdict.verdict, verdict.summary, len(verdict.findings)), ("不通过", "s", 1)) + self.assertTrue(verdict.flagged) + + def test_code_fence_and_unknown_verdict(self): + self.assertEqual(review.parse_output('```json {"verdict": "通过", "findings": []}').verdict, "通过") + missing = review.parse_output('{"verdict": "好"}\n没有结论') + self.assertEqual((missing.verdict, missing.parsed), ("需用户验收", False)) + + def test_flagged_by_severity_even_if_verdict_passes(self): + self.assertTrue(review.Verdict("通过", [{"severity": "严重"}]).flagged) + self.assertFalse(review.Verdict("需用户验收", [{"severity": "一般"}]).flagged) + + +class SeparationTest(unittest.TestCase): + def setUp(self): + self.repo = TempRepo() + self.addCleanup(self.repo.close) + self.repo.write("docs/plans/task-008-x.md", "---\ntask: T008\nclass: K4\ndesigner: codex\n---\n") + self.base = self.repo.commit("base") + + def commit(self, message): + self.repo.write("scripts/a.py", message) + self.repo.git("add", "-A") + self.repo.git("commit", "-q", "-m", message) + + def test_taskbook_designer_decides(self): + self.commit("fix\n\nTask: T008\nCo-Authored-By: Claude Opus 5.5 ") + self.assertEqual(review.designer_of(self.base, "HEAD", "task/008-x", self.repo.path), "codex") + self.assertEqual(review.OTHER["codex"], "claude-code") + + def test_co_author_trailer_without_taskbook(self): + self.commit("harness\n\nCo-Authored-By: Claude Opus 5.5 ") + self.assertEqual(review.designer_of(self.base, "HEAD", "task/harness-x", self.repo.path), "claude-code") + + def test_unknown_designer(self): + self.commit("plain") + self.assertIsNone(review.designer_of(self.base, "HEAD", "feature", self.repo.path)) + + +class ReviewerTest(unittest.TestCase): + def test_reviewers_are_read_only(self): + codex = review.CodexReviewer("m").argv("p", Path("/w"), Path("/o")) + self.assertEqual(codex[:4], ["codex", "exec", "-s", "read-only"]) + tuned = review.CodexReviewer("gpt-6-sol", "max").argv("p", Path("/w"), Path("/o")) + self.assertIn('model_reasoning_effort="max"', tuned) + self.assertEqual(review.make_reviewer("codex").argv("p", Path("/w"), Path("/o"))[5], "gpt-6-sol") + claude = review.ClaudeReviewer().argv("p", Path("/w"), Path("/o")) + self.assertEqual(claude[claude.index("--allowedTools") + 1], "Read,Grep,Glob") + + def test_reviewer_runs_without_github_credentials(self): + class Fake(review.Reviewer): + name = "fake" + + def argv(self, prompt, workspace, output): + code = ("import os; print('意见');" + "print('{\"verdict\": \"' + ('不通过' if os.environ.get('GH_TOKEN') else '通过') + '\", \"findings\": []}')") + return [sys.executable, "-c", code] + + def read(self, stdout, output): + return stdout, "fake-model" + + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {"GH_TOKEN": "secret"}): + verdict, model, _ = review.run_reviewer(Fake(), Path(tmp), 60) + self.assertEqual((verdict.verdict, model), ("通过", "fake-model")) + + def test_pi_reviewer_is_read_only_and_parses_events(self): + argv = review.PiReviewer("zai-coding-cn/glm-5.3").argv("p", Path("/w"), Path("/o")) + self.assertEqual(argv[:4], ["pi", "-na", "--tools", "read,grep,find,ls"]) + self.assertEqual(argv[argv.index("--model") + 1], "zai-coding-cn/glm-5.3") + events = [ + {"type": "message_end", "message": {"role": "assistant", "model": "glm-5.3", + "content": [{"type": "text", "text": "先看看"}]}}, + {"type": "message_end", "message": {"role": "toolResult", "content": [{"type": "text", "text": "x"}]}}, + {"type": "message_end", "message": {"role": "assistant", "model": "glm-5.3", "content": [ + {"type": "thinking", "thinking": "…"}, {"type": "text", "text": '意见\n{"verdict": "通过", "findings": []}'}]}}, + ] + text, model = review.PiReviewer().read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) + self.assertEqual((review.parse_output(text).verdict, model), ("通过", "glm-5.3")) + self.assertEqual(review.make_reviewer("pi").argv("p", Path("/w"), Path("/o"))[-2], "zai-coding-cn/glm-5.3") + + def test_claude_output_parsing(self): + stdout = json.dumps({"result": '好\n{"verdict": "通过", "findings": []}', "modelUsage": {"claude-x": {}}}) + text, model = review.ClaudeReviewer().read(stdout, Path("/none")) + self.assertEqual(model, "claude-x") + self.assertEqual(review.parse_output(text).verdict, "通过") + + +class CommentAndScoreTest(unittest.TestCase): + def test_comment_has_fixed_fields_and_hidden_data(self): + verdict = review.Verdict("不通过", [{"severity": "阻断", "location": "a.py:1", "problem": "漏传参数", "fix": "补上"}], "有阻断") + text = review.render_comment(verdict, "codex", "gpt-x", "claude-code", "abcdef1234567890", 90) + self.assertNotIn("同为", text) + self.assertIn("评审方与本 PR 的执行方同为 pi", + review.render_comment(verdict, "pi", "glm-5.3", "claude-code", "abc", 1, same_host=True)) + self.assertIn("### 独立评审(试行):不通过", text) + self.assertIn("评审方:codex(gpt-x);设计方:claude-code", text) + self.assertIn("| 阻断 | a.py:1 | 漏传参数 | 补上 |", text) + data = json.loads(text.split("")[0]) + self.assertEqual((data["flagged"], data["reviewer"]), (True, "codex")) + + def test_score_excludes_errors_and_failed_reviews(self): + """2026-09-28 实测:Codex 额度用尽后的失败曾被当成「放过」,会虚高 TNR。""" + results = [ + {"kind": "bad", "flagged": True}, {"kind": "bad", "flagged": False}, + {"kind": "good", "flagged": False}, {"kind": "good", "flagged": False, "parsed": False}, + {"kind": "bad", "error": "注入点过期"}, + ] + self.assertEqual(review.score(results), + {"bad": 2, "good": 1, "errors": 1, "tpr": 0.5, "tnr": 1.0, "unparsed": 1, "total": 5}) + + +class FailureTest(unittest.TestCase): + """评审方报错(如额度用尽)不是结论:记为评审失败,不评论为结论,不计入校准。""" + + class Scripted(review.Reviewer): + name = "scripted" + + def __init__(self, outputs): + self.outputs = list(outputs) + + def argv(self, prompt, workspace, output): + code, text = self.outputs.pop(0) + return [sys.executable, "-c", f"import sys; print({text!r}); sys.exit({code})"] + + def read(self, stdout, output): + return stdout, "m" + + def test_error_exit_is_a_failure_not_a_verdict(self): + quota = "ERROR: You've hit your usage limit. Try again at 12:51 AM." + with tempfile.TemporaryDirectory() as tmp: + verdict, _, _ = review.run_reviewer(self.Scripted([(1, quota)]), Path(tmp), 60) + self.assertEqual(verdict.verdict, "评审失败") + self.assertIn("usage limit", verdict.failure) + self.assertFalse(verdict.flagged) + + def test_calibration_stops_after_three_failures_and_resumes(self): + ok = (0, '{"verdict": "不通过", "findings": []}') + fail = (1, "ERROR: usage limit") + samples = [{"kind": "bad", "id": f"S{n}", "title": "t", "file": "a", "find": "x", "replace": "y"} for n in range(6)] + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "result.json" + with mock.patch.object(review, "calibration_samples", return_value=samples), \ + mock.patch.object(review, "review_workspace", return_value=Path(tmp)), \ + mock.patch.object(review, "git", return_value="abc"), \ + mock.patch.object(review, "prepare_sample", return_value=("abc", "pr")), \ + mock.patch.object(review, "write_materials"), \ + mock.patch.object(review, "EVALS", Path(tmp)): + out.write_text(json.dumps({"main": "abc", "date": "d", "samples": []})) + first = review.calibrate("codex", root=Path(tmp), resume=out, + reviewer=self.Scripted([ok, fail, fail, fail, ok, ok])) + self.assertEqual([item["id"] for item in first["samples"]], ["S0", "S1", "S2", "S3"]) + self.assertEqual((first["bad"], first["tpr"], first["unparsed"]), (1, 1.0, 3)) + saved = json.loads(out.read_text()) + self.assertEqual(len(saved["samples"]), 4) # 每个样本都已落盘 + second = review.calibrate("codex", root=Path(tmp), resume=out, + reviewer=self.Scripted([ok, ok, ok, ok, ok])) + self.assertEqual(sorted(item["id"] for item in second["samples"]), [f"S{n}" for n in range(6)]) + self.assertEqual((second["bad"], second["unparsed"]), (6, 0)) + + +class CalibrationTest(unittest.TestCase): + def test_samples_cover_replay_cases_and_manifest(self): + from replay_cases import CASES + + samples = review.calibration_samples() + manifest = json.loads((ROOT / "evals/review/samples.json").read_text()) + self.assertEqual(sum(s["kind"] == "bad" for s in samples), len(CASES)) + self.assertEqual([s["pr"] for s in samples if s["kind"] == "good"], manifest["good_prs"]) + self.assertGreaterEqual(len(samples), 40) + + def test_bad_sample_is_injected_without_touching_branches(self): + repo = TempRepo() + self.addCleanup(repo.close) + repo.write("scripts/a.py", "def f(x):\n return x > 0\n") + base = repo.commit("base") + sample = {"kind": "bad", "id": "T-1:0", "title": "重构", "file": "scripts/a.py", + "find": "return x > 0", "replace": "return x >= 0"} + got_base, text = review.prepare_sample(repo.path, sample, base, repo.path) + self.assertEqual(got_base, base) + self.assertIn("行为不变", text) + diff = subprocess.run(["git", "diff", f"{base}...HEAD"], cwd=repo.path, capture_output=True, text=True, check=True).stdout + self.assertIn("+ return x >= 0", diff) + self.assertEqual(repo.git("rev-parse", "main"), base) # 分支未动 + stale = dict(sample, find="不存在的代码") + with self.assertRaisesRegex(ValueError, "过期"): + review.prepare_sample(repo.path, stale, base, repo.path) + + +class WorkflowTest(unittest.TestCase): + def test_r2_and_above_are_marked_for_independent_review(self): + workflow = (ROOT / ".github/workflows/auto-merge.yml").read_text() + job = workflow.split("\n request-review:\n", 1)[1].split("\n merge:\n", 1)[0] + self.assertIn('if [ "$RISK" = "R2" ] || [ "$RISK" = "R3" ]; then', job) + self.assertIn("--add-label needs-independent-review", job) + self.assertNotIn("--force", job.split("needs-independent-review")[0].split("R3")[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_harness_run_check.py b/tests/test_harness_run_check.py index 3803b5a..4ea917b 100644 --- a/tests/test_harness_run_check.py +++ b/tests/test_harness_run_check.py @@ -129,6 +129,22 @@ def test_ci_rounds_over_budget_or_unreadable(self): self.assertIn("读不到", self.failed(rounds=None)["CI 轮次"]) self.assertEqual(self.failed(rounds=2), {}) + def test_designer_self_implementation_needs_trailers_but_no_record(self): + """方案 B(用户 2026-09-28):设计方自行实现时任务书与实现同一个 PR,不经派发脚本,不要求运行记录。""" + self.repo.git("checkout", "-q", "-B", "task/harness-x", self.base) + self.repo.write("docs/plans/task-006-harness-x.md", HEADER.replace("T005", "T006").replace("K2", "K7")) + self.repo.write("harness/x.py", "X = 1\n") + self.repo.git("add", "-A") + self.repo.git("commit", "-q", "-m", "harness\n\nTask: T006") + findings = run_check.check(self.base, "HEAD", "task/harness-x", self.repo.path, rounds=None) + self.assertEqual([(item.name, item.ok) for item in findings], [("归属", True), ("运行记录", True)]) + self.assertIn("设计方自行实现", findings[1].reason) + self.repo.write("harness/y.py", "Y = 1\n") + self.repo.git("add", "-A") + self.repo.git("commit", "-q", "-m", "no trailer") + self.assertIn("没有 `Task: T006`", {i.name: i.reason for i in run_check.check( + self.base, "HEAD", "task/harness-x", self.repo.path) if not i.ok}["归属"]) + def test_ci_rounds_count_distinct_completed_heads(self): runs = [ {"headSha": "a", "status": "completed"}, From 231ce97ed27efeec0038ae8a174562c3a59da78d Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:54:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs=EF=BC=9A=E5=BE=85=E5=8A=9E=20B30?= =?UTF-8?q?=E3=80=81B11=20=E7=9A=84=E5=85=B3=E9=97=AD=E8=A1=8C=E5=86=99?= =?UTF-8?q?=E4=B8=8A=20PR=20#63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: T006 Co-Authored-By: Claude Opus 5.5 --- docs/plans/backlog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/backlog.md b/docs/plans/backlog.md index 9ae7869..b72e455 100644 --- a/docs/plans/backlog.md +++ b/docs/plans/backlog.md @@ -62,8 +62,8 @@ | B21 | 派发脚本 `bin/dispatch`(PR #55)与端到端验收:真实 K2 任务 T005 经任务书 #56、实现 #57 全程自动完成,主目录无改动;槽位命名随后修正 | 2026-09-28,PR #58 | | B7 | 按任务书预算比对 CI 轮次:合并路由第 5 条超预算即转用户(`run_check.py`),交付度量列出「CI 轮次 / 预算」 | 2026-09-28,PR #59(目标态设计阶段二 P5) | | B25 | 试跑记录自动汇总:周报「试跑记录」表从运行记录、PR 与议题生成;任务 002–004 以 `docs/runs/history.json` 登记 PR,指标由周报读取;任务模板不再手填 | 2026-09-28,PR #60(目标态设计阶段二 P6) | -| B30 | 每周质量检查的定时:2026-09-28 的 02:17 UTC 定时延迟约 6.4 小时后于 08:41 UTC 运行并成功(GitHub 定时会延迟),不是没有触发;P6 已加第二个定时兜底,周报每周列出定时触发次数 | 2026-09-28,目标态设计阶段二 P7(攒批) | -| B11 | Codex 默认模型 `gpt-6-sol` 不能配 ChatGPT 账号:2026-09-28 实测已可用(`codex exec -m gpt-6-sol`,推理强度 max 与 xhigh 均被接受);独立评审按用户指定改用 gpt-6-sol、max | 2026-09-28,目标态设计阶段二 P7(攒批) | +| B30 | 每周质量检查的定时:2026-09-28 的 02:17 UTC 定时延迟约 6.4 小时后于 08:41 UTC 运行并成功(GitHub 定时会延迟),不是没有触发;P6 已加第二个定时兜底,周报每周列出定时触发次数 | 2026-09-28,PR #63(目标态设计阶段二 P7,攒批) | +| B11 | Codex 默认模型 `gpt-6-sol` 不能配 ChatGPT 账号:2026-09-28 实测已可用(`codex exec -m gpt-6-sol`,推理强度 max 与 xhigh 均被接受);独立评审按用户指定改用 gpt-6-sol、max | 2026-09-28,PR #63(目标态设计阶段二 P7,攒批) | | B26 | 开启 GitHub secret scanning 与 push protection:用户在仓库设置中开启,API 核对两项均为 enabled | 2026-09-28,PR #52 | | B5 | Zcode 守卫的 matcher 补上 MCP 批准类工具(同时补议题、标签类;OpenCode、Pi、Claude Code 一并补齐),重新信任转 B32 | 2026-09-28,PR #51(目标态设计阶段一 P3) | | B20 | 回放强制:PR 中每个非文档类 `Defect` 在 head 上须有注入用例、守卫测试或写明原因的暂缓项(`evidence.py`),缺则 harness job 失败 | 2026-09-28,PR #51(目标态设计阶段一 P3) |