diff --git a/docs/plans/backlog.md b/docs/plans/backlog.md index eda467f..23669a1 100644 --- a/docs/plans/backlog.md +++ b/docs/plans/backlog.md @@ -31,7 +31,8 @@ | 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 | 独立评审试行:工具与校准集随阶段二 P7 合并;剩在 3 个 R2 PR 上试行(`bin/dispatch review `),再与用户判断对照,由用户决定是否常态化 | 小;P7 合并后,随出现的 R2 PR 进行 | 评审方执行,用户决定 | 全链路审计 G5、2.4,设计 8.3 | +| B27 | 独立评审试行:工具与校准集随阶段二 P7 合并;剩在 3 个 R2 PR 上试行(`bin/dispatch review `)。试行 1/3:#62(2026-09-28,OpenCode,需用户验收,4 条一般发现;首次因工具缺陷材料为空,已修复并重评),再与用户判断对照,由用户决定是否常态化 | 小;P7 合并后,随出现的 R2 PR 进行 | 评审方执行,用户决定 | 全链路审计 G5、2.4,设计 8.3 | +| B33 | Swift 质量棘轮解析的两处已知限制(#62 独立评审发现):协议中的计算属性声明(`var x: T { get }`)被计为函数,与规范「协议要求不算函数」的表述不一致;字符串插值内嵌字符串(`"\("x")"`)会让解析提前结束字符串、漏算同行大括号。对当前基线无影响 | 小;R3 | 评审方 | PR #62 独立评审(2026-09-28) | ## 3. 持续进行或暂缓 diff --git a/docs/plans/task-007-review-background.md b/docs/plans/task-007-review-background.md new file mode 100644 index 0000000..45af307 --- /dev/null +++ b/docs/plans/task-007-review-background.md @@ -0,0 +1,53 @@ +--- +task: T007 +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: 240 + ci_rounds: 3 + retries: 2 + tokens: null +rollback: git revert(评审方回到 PR #63 合并时的状态) +--- + +# 任务:独立评审补齐、只读锁定与后台自动评审 + +状态:设计评审方自行实现(2026-09-29,方案 B)。来源:PR #63 合并时漏掉的提交 `87acfcf`;#62 试行中发现的评审方可执行 bash;用户 2026-09-29 同意「只读锁定 + 撤回材料写进提示词 + 后台自动评审」。 + +## 目标终态 + +- PR #63 合并的是 `83972c9`,此后推送的 `87acfcf`(OpenCode 评审方、已合并 PR 的评审基点、CI 材料、B33、试行 1/3 记录)未进入 main;本任务把它补上。 +- OpenCode 评审方真正只读:关掉 bash、子代理、网页与写入类工具(`plan` 代理本身只禁止编辑,2026-09-28 实测它在评审中运行了 PR 的测试与 `gh`)。 +- 评审移到后台:`bin/dispatch review --pending` 逐个评审「带 needs-independent-review、CI 已全部通过、当前 head 尚无独立评审结论」的开着的 PR;`--watch` 每隔若干分钟评审一轮,派发的停机标记存在时退出。不并行(多个 OpenCode 同时运行会冲突)。 +- 材料仍由评审方按文件读取(「材料写进提示词」实测没有变快,已撤回)。 + +## 非目标与禁止动作 + +- 不把评审放进 GitHub Actions(评审方与模型账号在本机);不在本任务中安装 launchd 常驻(持久的本机配置,需用户另行决定)。 +- 不改变评审结论的地位:仍只供用户参考。 + +## 前置条件(不满足就停下报告) + +- OpenCode 能在本机无界面运行(标准输入关闭时,2026-09-28 实测可用)。 + +## 验收 + +| 编号 | 验收内容 | 证据类型 | 覆盖(测试名或验证步骤) | +|---|---|---|---| +| 不挂规格:护栏 | OpenCode 评审方关掉命令与写入类工具 | 单测 | `tests.test_harness_review_independent.ReviewerTest.test_opencode_tools_are_locked_to_read_only` | +| 不挂规格:护栏 | 待评审的判定:CI 全部通过、当前 head 未评过;逐个评审;停机标记即退出 | 单测 | `tests.test_harness_review_independent.BackgroundTest` | +| 不挂规格:护栏 | 已合并 PR 的评审基点与 CI 材料(随 87acfcf 补上) | 单测 | `tests.test_harness_review_independent.ReviewBaseTest` | +| 不挂规格:护栏 | 实际查询线上待评审 PR | 真实数据 | `bin/dispatch review --pending` | + +## 步骤与提交顺序 + +| # | 改动 | 涉及文件 | 验证方式 | 对应验收 | +|---|---|---|---|---| +| 1 | 补上 87acfcf | `harness/review.py`、`harness/review_prompt.md`、`harness/rules.toml`、`harness/dispatch.py` | `bin/verify` | 第 3 行 | +| 2 | 只读锁定与后台评审 | `harness/review.py`、`harness/dispatch.py` | `bin/verify` | 第 1、2、4 行 | +| 3 | 规范、README、待办同步 | `docs/specs/delivery-harness.md`、`harness/README.md`、`docs/plans/backlog.md` | `bin/verify` | — | diff --git a/docs/specs/delivery-harness.md b/docs/specs/delivery-harness.md index 115941c..3efb26e 100644 --- a/docs/specs/delivery-harness.md +++ b/docs/specs/delivery-harness.md @@ -31,7 +31,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 进入待评审后 | +| `bin/dispatch review [--reviewer opencode\|pi\|codex\|claude-code]` | 独立评审:在只读的评审工作区(`<仓库名>-review`)中由非设计方评审任务书、PR 描述、diff、证据包与 CI 结论,结论以固定格式评论到 PR,并去掉 `needs-independent-review` 标签 | 设计评审方;通常由后台评审自动运行 | +| `bin/dispatch review --pending` / `--watch [--interval 5]` | 后台评审:逐个评审带 `needs-independent-review`、CI 已全部通过、当前 head 尚无独立评审结论的开着的 PR;`--watch` 每隔若干分钟一轮,停机标记存在即退出。不并行(多个 OpenCode 同时运行会冲突) | 本机常驻(终端中运行 `--watch`) | | `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 自动运行 | @@ -51,7 +52,7 @@ harness 只依赖 Python 标准库,放在仓库顶层 `harness/`,不进发 - **任务书即合同**(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 仍按实际改动路径判级。 -- **独立评审**(试行,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) 进行。 +- **独立评审**(试行,2026-09-28 决定 5;设计 8.3):R2 及以上的 PR 在 CI 通过后由 auto-merge 打上 `needs-independent-review`,本机的后台评审(`bin/dispatch review --watch`)在 CI 通过后自动评审,不占用用户等待时间;PR 有新推送时 auto-merge 在 build 通过后重新打标签,自动重评。评审方不能是设计方:有任务书时看头部 `designer`,没有时看提交的 `Co-Authored-By`。默认评审方由 `rules.toml [review] reviewer` 指定:用户 2026-09-28 定为 OpenCode(`zai-coding-plan/glm-5.3`,只读的 plan 代理,避开执行方 Pi);Pi、Codex(`gpt-6-sol`、推理强度 max)与 Claude Code 可用 `--reviewer` 选。评审方与该 PR 的执行方同一宿主时,评论里会标出独立性较弱。评审方只看任务书、PR 描述、diff 与 `review_pack.py` 证据包,不看执行过程;结构上只读(OpenCode 用 plan 代理、`--pure` 不加载插件,并以配置关掉 bash、子代理、网页与写入类工具:plan 代理本身只禁止编辑,2026-09-28 实测它曾在评审中运行 PR 的测试与 `gh`;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` 的提交即可。 @@ -162,7 +163,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) | +| 独立评审试行(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 路被拒)。评审方改为 OpenCode(plan 代理只读):此前「卡在初始化」实为调用时标准输入未关闭,`opencode run` 一直等管道结束,现所有评审方关闭标准输入。试行 1/3:#62(OpenCode,需用户验收,4 条一般发现,其中 2 条为 P8 解析的真实限制,登记 B33);首次试行暴露工具缺陷:已合并 PR 按 main 的合并基点比较得到空材料,现改用合并提交的第一个父提交,并把 CI 结论放进评审材料。评审提示词因此有改动,校准结果为改动前的版本 。2026-09-29:PR #63 合并的是 `83972c9`,此后推送的 OpenCode 评审方与上述修复(`87acfcf`)未进入 main,由 T007 补上;评审耗时实测 4 到 5 分钟,约八成是模型推理,把材料写进提示词未见加快(已撤回),改为后台自动评审 | ✅ 单测与校准;试行 1/3(B27) | | 熵治理(P8) | 单测(`tests/test_harness_entropy.py`):字符串(含多行、原始、转义)与注释(含嵌套块注释)里的大括号不计入;协议要求不算函数;人为加长的 Swift 函数(`test_lengthened_swift_function_fails_ratchet`)、加深嵌套与加长文件使棘轮失败,缩减通过;断链(相对、引用式、以 / 开头)被拦,外链、邮件、纯锚点、行内代码与代码块不误报,`文件#锚点` 缺文件仍拦;陈旧的未完成状态只报告不失败,近期的与正文顺带提到的不报。Swift 基线(2026-09-28,332 个函数):`swift_files_over_500` 2、`swift_long_functions` 9、`swift_deep_functions` 13。首跑本仓库修正 4 处断链,陈旧状态无 | ✅ 单测;CI 以本 PR 为准 | | 验收映射 | 6 份规格 69 条编号:可自动化 54 条中 53 条有测试、1 条登记缺口,19 条进入人工清单;检查器在 verify 各档运行 | ✅ | | Swift 回放 | macOS CI `--strict --full`(run 36152060546)通过,Swift 注入在 strict 下不可跳过 | ✅ | diff --git a/harness/README.md b/harness/README.md index 75f9692..da9cf67 100644 --- a/harness/README.md +++ b/harness/README.md @@ -55,7 +55,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 | +| `review.py`、`review_prompt.md`(`bin/dispatch review`,后台 `--watch`) | 独立评审:非设计方、只读、只看任务书与 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 86596f1..e4cca26 100644 --- a/harness/dispatch.py +++ b/harness/dispatch.py @@ -19,7 +19,8 @@ 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) + bin/dispatch review [--reviewer opencode|pi|codex|claude-code] 独立评审(harness/review.py) + bin/dispatch review --pending | --watch [--interval 5] 评审全部待评审的 PR(后台常驻用 --watch) """ from __future__ import annotations @@ -579,8 +580,11 @@ def main(argv: list[str] | None = None) -> int: 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"]) + review.add_argument("pr", type=int, nargs="?", help="评审这个 PR;不给时配合 --pending 或 --watch") + review.add_argument("--reviewer", choices=["opencode", "pi", "codex", "claude-code"]) + review.add_argument("--pending", action="store_true", help="评审全部待评审且 CI 已通过的 PR,然后退出") + review.add_argument("--watch", action="store_true", help="后台常驻:每隔 --interval 分钟评审一轮") + review.add_argument("--interval", type=float, default=5, help="--watch 的间隔(分钟)") sub.add_parser("status", help="查看槽位") stop = sub.add_parser("stop", help="停机:终止所有正在运行的执行方") stop.add_argument("--all", action="store_true", required=True) @@ -590,6 +594,14 @@ def main(argv: list[str] | None = None) -> int: if args.command == "review": import review as review_module # review 依赖本模块,按需导入 + if args.watch: + return review_module.watch(args.interval, args.reviewer) + if args.pending: + reviewed = review_module.review_pending(args.reviewer) + print(f"已评审 {len(reviewed)} 个 PR" + (f":{'、'.join(f'#{n}' for n in reviewed)}" if reviewed else "")) + return 0 + if args.pr is None: + parser.error("review 需要 PR 编号,或 --pending、--watch") return review_module.review_pr(args.pr, args.reviewer) if args.command == "stop": return stop_all() diff --git a/harness/review.py b/harness/review.py index 12d9473..0ddda88 100644 --- a/harness/review.py +++ b/harness/review.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from pathlib import Path from queue import Queue +from typing import ClassVar sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -42,7 +43,7 @@ EVALS = ROOT / "evals" / "review" VERDICTS = ("通过", "不通过", "需用户验收") OTHER = {"claude-code": "codex", "codex": "claude-code"} -REVIEWERS = ("pi", "codex", "claude-code") +REVIEWERS = ("opencode", "pi", "codex", "claude-code") LABEL = "needs-independent-review" @@ -81,6 +82,7 @@ def parse_output(text: str) -> Verdict: class Reviewer: name = "" + env: ClassVar[dict[str, str]] = {} # 评审方专用的环境变量(如 OpenCode 的权限配置) def argv(self, prompt: str, workspace: Path, output: Path) -> list[str]: raise NotImplementedError @@ -156,8 +158,44 @@ def read(self, stdout, output): return text, model +class OpenCodeReviewer(Reviewer): + """OpenCode 自带的 plan 代理只读(拒绝编辑与写入);--pure 不加载插件(含 PR 自带的项目插件)。""" + + name = "opencode" + # plan 代理只禁止编辑,不禁止 bash(2026-09-28 实测它在评审中运行了 PR 的测试与 gh):这里关掉命令、 + # 子代理、网页与写入类工具,只留读取与搜索。 + env: ClassVar[dict[str, str]] = {"OPENCODE_CONFIG_CONTENT": json.dumps({ + "permission": {"bash": "deny", "edit": "deny", "webfetch": "deny"}, + "tools": {"bash": False, "task": False, "webfetch": False, "write": False, "edit": False, "patch": False}, + })} + + def __init__(self, model: str | None = None): + self.model = model + + def argv(self, prompt, workspace, output): + command = ["opencode", "run", "--pure", "--agent", "plan", "--format", "json", "--dir", str(workspace)] + if self.model: + command += ["-m", self.model] + return command + [prompt] + + def read(self, stdout, output): + """取事件流里的全部文本片段(结论 JSON 在最后);模型取配置值(事件里不报模型名)。""" + texts = [] + for line in stdout.splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + part = event.get("part") or {} + if event.get("type") == "text" and part.get("type") == "text": + texts.append(part.get("text", "")) + return "\n".join(texts), self.model or "" + + def make_reviewer(name: str) -> Reviewer: config = load_rules().get("review", {}) + if name == "opencode": + return OpenCodeReviewer(config.get("opencode_model")) if name == "pi": return PiReviewer(config.get("pi_model")) if name == "codex": @@ -173,10 +211,13 @@ def run_reviewer(reviewer: Reviewer, workspace: Path, timeout: float) -> tuple[V with tempfile.TemporaryDirectory(prefix="review-") as tmp: output = Path(tmp) / "last.txt" env = dispatch_host.executor_env(dict(os.environ), {}, Path(tmp) / "gh") # 没有 GitHub 凭据 + env.update(reviewer.env) (Path(tmp) / "gh").mkdir() try: + # 标准输入必须关闭:opencode run 在标准输入是管道时会一直等它结束(2026-09-28 实测卡住的原因)。 result = subprocess.run(reviewer.argv(prompt, workspace, output), cwd=workspace, env=env, - capture_output=True, text=True, timeout=timeout, check=False) + stdin=subprocess.DEVNULL, 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: @@ -222,11 +263,30 @@ def checkout(workspace: Path, ref: str) -> None: git("clean", "-ffdxq", "-e", dispatch.VENV, cwd=workspace) -def write_materials(workspace: Path, base: str, pr_text: str, task_text: str) -> None: +def review_base(pr: dict, workspace: Path) -> str: + """评审的比较基点。已合并的 PR 用合并提交的第一个父提交:此时 head 已在 main 上,与 main 的合并基点 + 就是 head 本身,diff 为空(2026-09-28 对 #62 的首次试行即因此拿到空材料,被评审方指出)。""" + merge = (pr.get("mergeCommit") or {}).get("oid") + if pr.get("state") == "MERGED" and merge: + return git("rev-parse", f"{merge}^1", cwd=workspace) + return git("merge-base", "origin/main", "HEAD", cwd=workspace) + + +def ci_summary(number: int, github) -> str: + """当前 head 的 CI 检查结论与链接(评审方据此核对 PR 描述中「CI 通过」的说法)。""" + try: + checks = json.loads(github._run(["gh", "pr", "checks", str(number), "--json", "name,state,link"])) + except (RuntimeError, json.JSONDecodeError): + return "读不到 CI 检查结果。" + return "\n".join(f"- {item['name']}:{item['state']}({item['link']})" for item in checks) or "没有 CI 检查。" + + +def write_materials(workspace: Path, base: str, pr_text: str, task_text: str, ci_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") + (folder / "ci.md").write_text(ci_text or "(校准样本没有 CI 运行)", 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"], @@ -270,11 +330,12 @@ def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | 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"])) + pr = json.loads(github._run(["gh", "pr", "view", str(number), "--json", + "title,body,headRefName,headRefOid,baseRefName,state,mergeCommit"])) 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) + base = review_base(pr, 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: @@ -285,7 +346,7 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= 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) + write_materials(workspace, base, f"# {pr['title']}\n\n{pr['body']}", task_text, ci_summary(number, github)) verdict, model, seconds = run_reviewer(make_reviewer(name), workspace, load_rules().get("review", {}).get( "timeout_minutes", 30) * 60) if verdict.failure: @@ -302,6 +363,73 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= return 0 +# ---- 后台自动评审 ---- + +REVIEW_MARK = "", 1)[0])["head"]) + except (ValueError, KeyError): + continue + return heads + + +def pending_prs(github) -> list[int]: + """待评审:开着、带 needs-independent-review、CI 已全部完成且通过、当前 head 还没有独立评审结论。""" + found = json.loads(github._run(["gh", "pr", "list", "--state", "open", "--label", LABEL, "--limit", "50", + "--json", "number,headRefOid,comments"])) + pending = [] + for pr in sorted(found, key=lambda item: item["number"]): + if pr["headRefOid"] in reviewed_heads(pr.get("comments", [])): + continue + try: + checks = json.loads(github._run(["gh", "pr", "checks", str(pr["number"]), "--json", "state"])) + except (RuntimeError, json.JSONDecodeError): + continue # 检查未完成时 gh 以非零退出:下一轮再看 + if checks and all(check.get("state") in DONE_CHECKS for check in checks): + pending.append(pr["number"]) + return pending + + +def review_pending(reviewer_name: str | None = None, root: Path = ROOT, github=None, review=None) -> list[int]: + """逐个评审(多个 OpenCode 同时运行会互相冲突,2026-09-28 实测)。返回评审过的 PR。""" + github = github or dispatch.GitHub(root) + review = review or review_pr + done = [] + for number in pending_prs(github): + review(number, reviewer_name, root, github) + done.append(number) + return done + + +def watch(interval_minutes: float = 5, reviewer_name: str | None = None, root: Path = ROOT, github=None, + review=None, sleep=time.sleep, rounds: int | None = None) -> int: + """后台常驻:每隔 interval 分钟评审一轮待评审的 PR;派发的停机标记存在时退出(规范 §8)。""" + stop_flag = dispatch.state_dir(root) / "stop" + count = 0 + while rounds is None or count < rounds: + if stop_flag.exists(): + print("停机标记存在,停止后台评审") + return 0 + try: + reviewed = review_pending(reviewer_name, root, github, review) + if reviewed: + print(f"已评审:{'、'.join(f'#{number}' for number in reviewed)}", flush=True) + except RuntimeError as error: + print(f"本轮查询失败({error}),下一轮重试", flush=True) + count += 1 + if rounds is None or count < rounds: + sleep(interval_minutes * 60) + return 0 + + # ---- 校准 ---- def calibration_samples(root: Path = ROOT) -> list[dict]: diff --git a/harness/review_prompt.md b/harness/review_prompt.md index 46ac363..c7fd2bc 100644 --- a/harness/review_prompt.md +++ b/harness/review_prompt.md @@ -6,6 +6,8 @@ - `build/review/task.md`:任务书(没有任务书时写明「无」) - `build/review/diff.patch`:相对合并基点的完整改动 - `build/review/pack.md`:机器生成的证据包(风险等级、修复证据的提交与测试映射、涉及的验收编号) +- `build/review/ci.md`:当前 head 的 CI 检查结论与链接(核对 PR 描述中「CI 通过」一类说法以它为准) +- 材料为空或与 PR 描述明显不符时,结论为「需用户验收」并在发现里写明,不要在空材料上给出通过 - 评审清单:`docs/templates/review-checklist.md`;仓库规则:`AGENTS.md`、`docs/specs/delivery-harness.md` ## 怎么评 diff --git a/harness/rules.toml b/harness/rules.toml index f0b6af7..431d870 100644 --- a/harness/rules.toml +++ b/harness/rules.toml @@ -133,9 +133,10 @@ ci_timeout_minutes = 60 [review] # 独立评审(harness/review.py):默认评审方与各评审方的模型、超时。 -# 用户 2026-09-28 指定:默认由 Pi(glm-5.3)评审;Codex 可选(gpt-6-sol、max)。 +# 用户 2026-09-28 指定:默认由 OpenCode(glm-5.3,只读的 plan 代理)评审,避开执行方 Pi;Pi、Codex(gpt-6-sol、max)可选。 # 换评审方、模型或推理强度后须在校准集上重跑 TPR、TNR 再比较(设计 8.3)。 -reviewer = "pi" +reviewer = "opencode" +opencode_model = "zai-coding-plan/glm-5.3" pi_model = "zai-coding-cn/glm-5.3" codex_model = "gpt-6-sol" codex_reasoning_effort = "max" diff --git a/tests/test_harness_review_independent.py b/tests/test_harness_review_independent.py index d447d02..8bbb81a 100644 --- a/tests/test_harness_review_independent.py +++ b/tests/test_harness_review_independent.py @@ -105,6 +105,43 @@ def test_pi_reviewer_is_read_only_and_parses_events(self): 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_opencode_reviewer_is_read_only_and_parses_events(self): + argv = review.OpenCodeReviewer("zai-coding-plan/glm-5.3").argv("p", Path("/w"), Path("/o")) + self.assertEqual(argv[:6], ["opencode", "run", "--pure", "--agent", "plan", "--format"]) + events = [ + {"type": "step_start", "part": {"type": "step-start"}}, + {"type": "tool_use", "part": {"type": "tool", "tool": "read"}}, + {"type": "text", "part": {"type": "text", "text": "意见"}}, + {"type": "text", "part": {"type": "text", "text": '```json\n{"verdict": "不通过", "findings": [{"severity": "严重"}]}\n```'}}, + ] + text, model = review.OpenCodeReviewer("m").read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) + verdict = review.parse_output(text) + self.assertEqual((verdict.verdict, verdict.flagged, model), ("不通过", True, "m")) + self.assertIsInstance(review.make_reviewer("opencode"), review.OpenCodeReviewer) + + def test_opencode_tools_are_locked_to_read_only(self): + """plan 代理本身不禁止 bash(2026-09-28 实测评审中运行了测试与 gh):配置里关掉命令与写入类工具。""" + config = json.loads(review.OpenCodeReviewer.env["OPENCODE_CONFIG_CONTENT"]) + self.assertEqual(config["permission"]["bash"], "deny") + self.assertFalse(any(config["tools"][name] for name in ("bash", "task", "webfetch", "write", "edit", "patch"))) + + def test_reviewer_stdin_is_closed(self): + """opencode run 在标准输入是管道时会一直等(2026-09-28 实测):评审方的标准输入必须关闭。""" + class ReadsStdin(review.Reviewer): + name = "stdin" + + def argv(self, prompt, workspace, output): + code = ("import sys; data = sys.stdin.read(); " + "print('{\"verdict\": \"通过\", \"findings\": []}' if data == '' else 'stdin')") + return [sys.executable, "-c", code] + + def read(self, stdout, output): + return stdout, "m" + + with tempfile.TemporaryDirectory() as tmp: + verdict, _, _ = review.run_reviewer(ReadsStdin(), Path(tmp), 30) + self.assertEqual(verdict.verdict, "通过") + def test_claude_output_parsing(self): stdout = json.dumps({"result": '好\n{"verdict": "通过", "findings": []}', "modelUsage": {"claude-x": {}}}) text, model = review.ClaudeReviewer().read(stdout, Path("/none")) @@ -185,6 +222,78 @@ def test_calibration_stops_after_three_failures_and_resumes(self): self.assertEqual((second["bad"], second["unparsed"]), (6, 0)) +class ReviewBaseTest(unittest.TestCase): + def test_merged_pr_is_compared_against_its_merge_parent(self): + """2026-09-28 #62 首次试行:PR 已合并时按 main 的合并基点比较,diff 为空。""" + repo = TempRepo() + self.addCleanup(repo.close) + repo.write("a.txt", "1\n") + base = repo.commit("base") + repo.git("checkout", "-q", "-b", "feature") + repo.write("a.txt", "2\n") + head = repo.commit("change") + repo.git("checkout", "-q", "main") + repo.git("merge", "-q", "--no-ff", "-m", "merge", "feature") + merge = repo.git("rev-parse", "HEAD") + repo.git("update-ref", "refs/remotes/origin/main", merge) + repo.git("checkout", "-q", "--detach", head) + self.assertEqual(review.review_base({"state": "MERGED", "mergeCommit": {"oid": merge}}, repo.path), base) + self.assertEqual(review.review_base({"state": "OPEN"}, repo.path), head) # 已在 main 上:合并基点即 head + + +class BackgroundTest(unittest.TestCase): + """后台自动评审:CI 通过后评审待评审的 PR,已评过当前 head 的跳过,停机标记存在即退出。""" + + class FakeGitHub: + def __init__(self, prs, checks): + self.prs, self.checks = prs, checks + + def _run(self, argv, **kwargs): + if argv[:3] == ["gh", "pr", "list"]: + return json.dumps(self.prs) + if argv[:3] == ["gh", "pr", "checks"]: + state = self.checks[int(argv[3])] + if state is None: + raise RuntimeError("checks pending") + return json.dumps([{"state": value} for value in state]) + raise AssertionError(argv) + + def github(self): + mark = '' + prs = [ + {"number": 1, "headRefOid": "h1", "comments": []}, + {"number": 2, "headRefOid": "h2", "comments": []}, + {"number": 3, "headRefOid": "h3", "comments": [{"body": "评审\n" + mark}]}, + {"number": 4, "headRefOid": "h4", "comments": [{"body": mark}]}, # 评过的是旧 head + {"number": 5, "headRefOid": "h5", "comments": []}, + ] + checks = {1: ["SUCCESS", "SKIPPED"], 2: ["SUCCESS", "FAILURE"], 3: ["SUCCESS"], 4: ["SUCCESS"], 5: None} + return self.FakeGitHub(prs, checks) + + def test_pending_needs_green_ci_and_an_unreviewed_head(self): + self.assertEqual(review.pending_prs(self.github()), [1, 4]) + + def test_review_pending_runs_one_at_a_time(self): + seen = [] + done = review.review_pending(github=self.github(), review=lambda n, *rest: seen.append(n)) + self.assertEqual((done, seen), ([1, 4], [1, 4])) + + def test_watch_repeats_and_stops_on_stop_flag(self): + repo = TempRepo() + self.addCleanup(repo.close) + seen, sleeps = [], [] + with mock.patch.object(review.dispatch, "state_dir", return_value=repo.path / "state"): + review.watch(1, github=self.github(), review=lambda n, *rest: seen.append(n), + sleep=sleeps.append, rounds=2, root=repo.path) + self.assertEqual((seen, sleeps), ([1, 4, 1, 4], [60])) + (repo.path / "state").mkdir() + (repo.path / "state" / "stop").write_text("now") + seen.clear() + review.watch(1, github=self.github(), review=lambda n, *rest: seen.append(n), sleep=sleeps.append, + root=repo.path) + self.assertEqual(seen, []) + + class CalibrationTest(unittest.TestCase): def test_samples_cover_replay_cases_and_manifest(self): from replay_cases import CASES