@haiyangbg/buildbeat 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,19 @@
2
2
 
3
3
  > 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
4
4
 
5
+ ## v3.3.0 — 2026-10-02(review 少停人:收敛止损、单一轮数上限、模板关分诊、intent+plan 一次接受)
6
+
7
+ - **review 按收敛止损,不再按 2 轮封顶**:review 发现阻断问题、派 fixer 之前,内核把本轮 P0/P1 与本 Run 之前各轮比较。修过的 finding 又出现(同指纹,已 dismiss 的不算),或本轮阻断数多于上一轮,就停 `enter-fix`(新 kind `review-not-converging`,理由列出又出现的指纹或前后数量,请求不带 grants,批准不动预算);阻断 finding 都是新的、数量不多于上一轮时自动继续。官方预设不再自带每 Run 2 轮的 `budgets.maxAttempts.review`,轮数上限改由下一条的 `reviewRoundsPerWork` 统一负责;run 配置里显式写的值照旧优先。开了 `reviewTriage: required` 时仍停分诊,不收敛理由并入同一请求;`nextReply` 对新 kind 给出 `findings list` / `findings adjudicate` 命令。原因:每 Run 2 轮封顶让正常修两三轮的 Run 反复停下批准扩额;本仓最近 10 个 Run 里中途 9 次批准都由驾驶会话自己批,人只出现在合并决定,审批点没起到人把关的作用。
8
+ - **review 轮数只剩一个上限**:`budgets.reviewRoundsPerWork` 不写时默认 6(此前不写即没有 Work 级上限);review 步没有显式 `maxAttempts` 时,每 Run 上限取同一个值,不会先于 Work 上限触发,官方预设不再自带 `maxAttempts.review`。`doctor` 显示 review 上限来源为 `reviewRoundsPerWork` 并标出默认值;run 配置样板删去 `maxAttempts.review`。显式写的 `maxAttempts.review` 照旧生效。原因:每 Run 与每 Work 两套计数让同一轮 review 要按两层上限解释和放行,止损线本来就按 Work 定。
9
+ - **模板默认不再开发现分诊门**:`templates/v2/run-config.example.yaml`、示例项目、`SKILL.md` 与快速开始里的 run 配置样板从 `reviewTriage: required` 改为 `reviewTriage: off`,P0/P1 finding 直接派 fixer;高风险项目仍可改回 `required`。内核默认值本来就是不分诊,未改动;已有项目的 run 配置不受影响。原因:本仓最近 10 个 Run 中途 9 次停人里 7 次是分诊门的 `enter-fix`,且全部由驾驶会话自己批准,没有起到人把关的作用。
10
+ - **intent 与 plan 一次接受**:`accept --artifact intent,plan` 一条命令接受多份工件,每份仍各记一行 `accept-<artifact>`、各自绑定 digest,policy 与台账格式不变;任一文件缺失则一份都不接受。`overview` 在 intent 未接受且 plan 待接受时提示 `--artifact intent,plan`;`SKILL.md` 改为给用户看完摘要后只问一次「接受」。原因:本仓每个 Work 的 intent 与 plan 都在约 60 毫秒内先后被接受,两次接受实际是一个决定。
11
+
5
12
  ## v3.2.1 — 2026-09-26(补丁:开关参数单写、drive() 拆分、信封脚本测试、英文指南)
6
13
 
14
+ > **发布状态**:`@haiyangbg/buildbeat@3.2.1` 已于 2026-09-26 从 `main`(PR #54 内容、release PR #55,merge commit `8d5f7bc`,tag `v3.2.1`)经 OIDC Trusted Publishing 发布到 dist-tag **`latest`**(run 36241955690,publish 与 verify 双 job 一次 success;所有者授权「推送并发 3.2.1 / 继续」)。独立回读(直连 npmjs.org):`latest` = 3.2.1、integrity 与发布前本地候选逐字一致、SLSA v1 provenance、隔离安装 `--version` = 3.2.1、裸调用零写入、已发布 CLI 接受单写 `--apply` / `--json`、包内 12 份英文指南在位且无 pilot / 历史文档、`npm audit signatures` 通过;GitHub Release v3.2.1 标 Latest,证据见 [`docs/releases/V3.2.1-RELEASE-EVIDENCE-2026-09-26.md`](docs/releases/V3.2.1-RELEASE-EVIDENCE-2026-09-26.md)。
15
+ >
16
+ > 同日所有者本人经 npm 两步验证把 dist-tag `next` 从 3.0.1 挪到 3.2.1(此前 `@next` 比 `@latest` 旧);直连 npmjs.org 回读 `next` = `latest` = 3.2.1。
17
+
7
18
  - CLI 开关参数可单写:`--json`、`--apply`、`--force`、`--once` 不必再跟 `true`(旧写法照旧有效);缺值时报错点名参数(`--config needs a value`),多余的裸词报 `unexpected argument`。
8
19
  - `drive()` 从 459 行的单一循环体拆成五个有名字的阶段函数(原代码按原顺序搬移,循环本身 19 行),review 步判定收成一处 `isReviewStep()`;行为零变化——未改动任何测试,全量通过。
9
20
  - CodeQL 也分析推到 `v2` 的提交。
package/SKILL.md CHANGED
@@ -28,7 +28,7 @@ description: BuildBeat —— 面向人和 AI 会话的工程交付工作流,上
28
28
  | 「换会话」「删旧会话」「继续这个项目」「同事接手」 | 按 [接续指南](docs/v2/guide/11-session-handoff.md) 核对项目入口、Work、Git、`overview` / `inbox` / `status`;离开前补齐未落盘事实,接手后区分活动 Run / 中断 / 待批 / 终态 | 「已保存哪些上下文、工作停在哪、下一步」;不把删聊天当删工作树,不重启仍活动的 Run,不伪造或代批决定 |
29
29
  | 「当前进度」「待办是什么」「X 上线了吗」「离上线还差多远」 | `buildbeat overview --repo .`(每个 Work 的阶段 + 下一步该谁 + `cost:` 已花的 Run/review 轮/等人次数/worker 时长)+ `observe status --repo .` | 每件事一句:走到哪、卡在谁、下一步;**不列命令**;花费超过 intent 止损线的 Work 要主动说「已 N 轮 review / N 小时,继续还是砍」 |
30
30
  | 「有什么要我拍板」 | `buildbeat inbox --repo .` | 逐项:等什么、证据在哪、推荐 A/B;用户回「批准/拒绝」后会话调 `approve`/`reject` |
31
- | 「开个 Work:〔目标〕」 | 写 `delivery/work/<ID>/intent.md`(为什么做 + **止损线**:最多几个 Run / 几轮 review / 几小时,越线先问人)+ `plan.md`(怎么做)+ `run-config.yaml`(`budgets.reviewRoundsPerWork` 对应止损线);给用户看摘要 | 「看完说接受」;用户说「接受」→ `accept --artifact intent` / `--artifact plan`(digest 绑定) |
31
+ | 「开个 Work:〔目标〕」 | 写 `delivery/work/<ID>/intent.md`(为什么做 + **止损线**:最多几个 Run / 几轮 review / 几小时,越线先问人)+ `plan.md`(怎么做)+ `run-config.yaml`(`budgets.reviewRoundsPerWork` 对应止损线);给用户看摘要 | 「看完说接受」;用户说一次「接受」→ `accept --artifact intent,plan`(一条命令,两份各自 digest 绑定;不要分两次问) |
32
32
  | 「开工」「再来一轮」 | 先 `buildbeat doctor --config <run-config.yaml>`(会报本仓 intent/plan 是否存在且已接受、哪条 policy 会把 start 挡在哪步、每步预算),再 `start --config <run-config.yaml> --attempt new`(自动编号 RUN-X-01/02…,自动作废同 Work 的旧等待;**用 nohup/setsid 脱离启动**) | 「已起 RUN-X-02,停在合并决定时会通知/我会告诉你」 |
33
33
  | 「怎么样了」「卡住了吗」「正常吗」 | `buildbeat status --repo . --run <RUN>` | 一句:在跑第几步、跑了多久、历史通常多久、最后一次输出几分钟前;`STALLED` 就说「疑似卡住,建议停/等」;停在 kind `infra` 就说「worker 环境/后端故障,不是代码问题,恢复后我重跑,预算不扣」 |
34
34
  | 「批准 RUN-X」「拒绝,原因…」 | 先看 `inbox` 该 Run 等的是哪条 transition,再 `approve --transition <t> --by <用户名>` / `reject --reason`;非终态转换(`enter-fix` / `resume-<step>` / `enter-review`)批准后再 `resume --config <cfg>` 续跑;用 `--attempt new` 自动编号时会选择该家族唯一未终态 Run,也可 `--run <RUN-ID>` 指定;多个候选或没有未终态 Run 时按报错处理 | 说清批的是哪一步:「放行 fixer,续跑中」/「再跑一次,续跑中」/「合并决定已落,候选 <sha> 具备合并条件;合并/push/部署要你另说」。`SUCCEEDED` 不等于已合并 |
@@ -63,14 +63,12 @@ entry: build
63
63
  allowedPaths:
64
64
  - src
65
65
  - tests
66
- # P0/P1 先过人分诊再派 fixer
67
- reviewTriage: required
66
+ # off = P0/P1 直接派 fixer;高风险项目改 required,每轮先停人分诊
67
+ reviewTriage: off
68
68
  # 可省;run 配置 > 预设 > 默认。非只读步成功不扣次数;review 仍按轮计费。
69
- # review 到顶时在 enter-fix 一次批准修复、重验、再审;Run/Work 上限同时放行;
70
- # reviewRoundsPerWork 跨本 Work 所有 Run 累计 review 轮数,超了新 Run 起跑前先问人
69
+ # review 不收敛(修过的 finding 又出现/阻断数变多)才在 enter-fix 停人;
70
+ # reviewRoundsPerWork(默认 6)是 review 轮数唯一上限,跨本 Work 所有 Run 累计,到顶一次批准修复、重验、再审
71
71
  budgets:
72
- maxAttempts:
73
- review: 2
74
72
  reviewRoundsPerWork: 6
75
73
  # 同树+同命令+同信封已通过就复用证据(标 REUSED)
76
74
  cache:
@@ -16,7 +16,7 @@
16
16
 
17
17
  | 能力 | Skill-only / 手工路径 | 运行时 `buildbeat` | 权威与边界 |
18
18
  |---|---|---|---|
19
- | 工件接受 | 会话记一行到 `decisions.jsonl` | `accept --artifact intent\|plan\|spec`:digest 绑定;改过即 `stale`,`doctor` / `overview` 报出 | 接受不是开工;policy 按 riskPreset 决定 build 前要求哪些工件已接受 |
19
+ | 工件接受 | 会话记一行到 `decisions.jsonl` | `accept --artifact intent\|plan\|spec`(逗号分隔可一次接受多份,如 `intent,plan`):每份各自 digest 绑定;改过即 `stale`,`doctor` / `overview` 报出 | 接受不是开工;policy 按 riskPreset 决定 build 前要求哪些工件已接受 |
20
20
  | 自动闭环 | 无 | `start --config <run-config> [--attempt new]`:隔离 worktree、builder→verify→review→fix 自动路由、停 `WAITING_HUMAN` | 一仓同时只有一个活动 Run;worker 是配置的任意命令,内核不内置模型 |
21
21
  | 进度与等待 | 会话读目录 | `overview`(每 Work 阶段 / 下一步 / 成本)、`inbox`(等人的 Run + 下一句命令)、`status`(步、耗时、STALLED、证据、findings)、`metrics` | 全部只读;本机绝对路径不进输出 |
22
22
  | 人批 | 会话记一行 | `approve --transition <t>` / `reject`:绑定 transition + candidate + planDigest + evidenceDigest,盖章前重读实况;非终态转换后 `resume` | 合并决定 = 候选具备合并条件;合并 / push / 部署无调用路径(不变量 20) |
package/docs/RELEASING.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This runbook governs BuildBeat's public npm distribution. The canonical package is `@haiyangbg/buildbeat` in `HaiYangBG1/BuildBeat`; the only executable is `buildbeat`. No other package name or executable alias receives publications.
4
4
 
5
- Release evidence at source package version `@haiyangbg/buildbeat@3.2.1`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@3.2.0` (dist-tag `latest`; `next` stays `3.0.1`), anchored by annotated tag `v3.2.0` at commit `cda4bd6`, workflow run [36233928546](https://github.com/HaiYangBG1/BuildBeat/actions/runs/36233928546), and archived in [`V3.2.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.2.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.1.0 chain (`latest` on 2026-09-26 until 3.2.0 took over the same day) stays archived in [`V3.1.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.1.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.0.1 chain (`latest` from 2026-09-09 until 3.1.0 took over on 2026-09-26) stays archived in [`V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`](releases/V3.0.1-RELEASE-EVIDENCE-2026-09-09.md). The 3.0.0 chain (`latest` from 2026-09-09 until 3.0.1 took over the same day; the first version without the removed generation) stays archived in [`V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`](releases/V3.0.0-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.2 chain (`latest` from 2026-09-09 until 3.0.0 took over the same day; the last version carrying the removed generation) stays archived in [`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`](releases/V2.0.2-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.1 chain (`latest` from 2026-09-06 until 2.0.2 took over on 2026-09-09) stays archived in [`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`](releases/V2.0.1-RELEASE-EVIDENCE-2026-09-06.md). The 2.0.0 chain (`latest` from 2026-09-05 until 2.0.1 took over on 2026-09-06) stays archived in [`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`](releases/V2.0.0-RELEASE-EVIDENCE-2026-09-05.md). The beta.5 chain stays archived in [`V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md`](releases/V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md). The beta.4 chain stays archived in [`V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md`](releases/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md); the beta.3 chain in [`V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md`](releases/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md). The beta.2 chain stays archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](releases/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md); the beta.1 chain stays archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](releases/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). Earlier distributions and the retired legacy package name are archived in the dated evidence files under `docs/releases/`.
5
+ Release evidence at source package version `@haiyangbg/buildbeat@3.3.0`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@3.2.1` (dist-tags `latest` and `next`; the owner moved `next` from `3.0.1` to `3.2.1` with npm 2FA on 2026-09-26), anchored by annotated tag `v3.2.1` at commit `8d5f7bc`, workflow run [36241955690](https://github.com/HaiYangBG1/BuildBeat/actions/runs/36241955690), and archived in [`V3.2.1-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.2.1-RELEASE-EVIDENCE-2026-09-26.md). The 3.2.0 chain (`latest` on 2026-09-26 until 3.2.1 took over the same day) stays archived in [`V3.2.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.2.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.1.0 chain (`latest` on 2026-09-26 until 3.2.0 took over the same day) stays archived in [`V3.1.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.1.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.0.1 chain (`latest` from 2026-09-09 until 3.1.0 took over on 2026-09-26) stays archived in [`V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`](releases/V3.0.1-RELEASE-EVIDENCE-2026-09-09.md). The 3.0.0 chain (`latest` from 2026-09-09 until 3.0.1 took over the same day; the first version without the removed generation) stays archived in [`V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`](releases/V3.0.0-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.2 chain (`latest` from 2026-09-09 until 3.0.0 took over the same day; the last version carrying the removed generation) stays archived in [`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`](releases/V2.0.2-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.1 chain (`latest` from 2026-09-06 until 2.0.2 took over on 2026-09-09) stays archived in [`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`](releases/V2.0.1-RELEASE-EVIDENCE-2026-09-06.md). The 2.0.0 chain (`latest` from 2026-09-05 until 2.0.1 took over on 2026-09-06) stays archived in [`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`](releases/V2.0.0-RELEASE-EVIDENCE-2026-09-05.md). The beta.5 chain stays archived in [`V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md`](releases/V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md). The beta.4 chain stays archived in [`V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md`](releases/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md); the beta.3 chain in [`V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md`](releases/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md). The beta.2 chain stays archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](releases/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md); the beta.1 chain stays archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](releases/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). Earlier distributions and the retired legacy package name are archived in the dated evidence files under `docs/releases/`.
6
6
 
7
7
  ## Channels and branches
8
8
 
@@ -69,7 +69,7 @@ npm publish --access public --tag bootstrap --registry=https://registry.npmjs.or
69
69
  npm view @haiyangbg/buildbeat dist-tags --json --registry=https://registry.npmjs.org/
70
70
  ```
71
71
 
72
- The intended bootstrap readback is `bootstrap: 0.0.0` without a stable `latest` claim. During the first BuildBeat package creation, npm also attached `latest: 0.0.0` despite the explicit non-default tag and rejected the authenticated delete with HTTP 400. Treat any such first-package `latest` as temporary bootstrap exposure: do not activate launch documentation, do not republish or unpublish the immutable version, and complete the reviewed OIDC release so the real version takes over `latest`. The readback at the time of that first scoped release (2026-08-25) was `bootstrap: 0.0.0` and `latest: 1.21.0`; since 2026-09-05 `latest` is `2.0.0` and `next` is `2.0.0-beta.5` (see the current-state paragraph at the top of this runbook). The bootstrap package page is registry-creation evidence only: it is not a BuildBeat release, is not tagged in Git, and has no provenance claim. The npm documentation requires 2FA or an allowed granular token for direct publication; no long-lived publish token belongs in Git, shell history, logs, or a chat transcript. See npm's guides for [scoped public packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) and [Trusted Publishing](https://docs.npmjs.com/trusted-publishers/).
72
+ The intended bootstrap readback is `bootstrap: 0.0.0` without a stable `latest` claim. During the first BuildBeat package creation, npm also attached `latest: 0.0.0` despite the explicit non-default tag and rejected the authenticated delete with HTTP 400. Treat any such first-package `latest` as temporary bootstrap exposure: do not activate launch documentation, do not republish or unpublish the immutable version, and complete the reviewed OIDC release so the real version takes over `latest`. The readback at the time of that first scoped release (2026-08-25) was `bootstrap: 0.0.0` and `latest: 1.21.0`; on 2026-09-05 `latest` moved to `2.0.0` with `next` at `2.0.0-beta.5`; the current tags are in the current-state paragraph at the top of this runbook. The bootstrap package page is registry-creation evidence only: it is not a BuildBeat release, is not tagged in Git, and has no provenance claim. The npm documentation requires 2FA or an allowed granular token for direct publication; no long-lived publish token belongs in Git, shell history, logs, or a chat transcript. See npm's guides for [scoped public packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) and [Trusted Publishing](https://docs.npmjs.com/trusted-publishers/).
73
73
 
74
74
  ## Independent readback
75
75
 
@@ -71,7 +71,7 @@
71
71
  | `FAILURE_FINGERPRINT` | kernel | `step, command, exitCode, errorDigest, diffDigest` | 无进展/相同失败检测的输入 |
72
72
  | `BUDGET_CONSUMED` | kernel | `kind ∈ {attempts,tokens,cost,time}, amount, remaining` | 预算台账(卡点 1:token/费用不再 `UNVERIFIED`) |
73
73
  | `BUDGET_EXTENDED` | kernel | `step, amount, maxAttempts, approvalRef, scope?` | additive(迭代 09):人批准了预算耗尽的 `resume-<step>`,该步上限 +`amount`;状态 `budgetExtensions[step]` 累加,`maxAttemptsFor(step)` 据此重放。`scope: work` 时是 Work 级 review 轮数上限(`budgets.reviewRoundsPerWork`)被人放行一轮,累加到 `workReviewGrants` |
74
- | `HUMAN_REQUESTED` | kernel | `transition, subject{candidate,planDigest,evidenceDigest}, reasons`;additive:`kind ∈ {boundary,final-decision,finding-triage,stale,infra}` | 进入 WAITING_HUMAN |
74
+ | `HUMAN_REQUESTED` | kernel | `transition, subject{candidate,planDigest,evidenceDigest}, reasons`;additive:`kind ∈ {boundary,final-decision,finding-triage,stale,infra,budget,work-review-cap,review-not-converging}`(`review-not-converging`:review 修过的 finding 又出现或阻断数多于上一轮,停 `enter-fix`) | 进入 WAITING_HUMAN |
75
75
  | `DECISION_RECORDED` | human | `decision ∈ {approved,rejected}, transition, subject, decisionRef`;additive(迭代 09):`adopted?: <sha>`、`resumeAt?: <step>`(adopt 时 subject 即该提交,恢复从 `resumeAt` 起而非 transition 所指的步) | 同步落 Git 决策记录 |
76
76
  | `APPROVAL_STALE` | kernel | `approvalRef, changed ⊆ {candidate,plan,evidence}` | F6 的机器化 |
77
77
  | `CHECKPOINT` | kernel | `resumePoint{step,attempt}, workspaceStates[]` | F5 的机器化:恢复只允许从最近 CHECKPOINT 或安全推导点继续 |
@@ -47,7 +47,7 @@ entry: build
47
47
  allowedPaths:
48
48
  - src
49
49
  - tests
50
- reviewTriage: required
50
+ reviewTriage: off
51
51
  envelope:
52
52
  prompts: ../../envelope/prompts
53
53
  workers:
@@ -91,7 +91,7 @@ workers:
91
91
  - `workers.<role>` is any CLI: to switch tools, change only what follows `--` (`claude -p`, any script), see the [Adapter guide](04-adapter-guide.md) (Chinese); the reviewer's output format is in the [Worker contract](05-worker-contract.md) (Chinese) and already spelled out in the prompt template.
92
92
  - **`fixer` is not optional**: without it, a failed verify or a blocking review stops the Run at `WAITING_HUMAN` (reason `no adapter configured for worker fixer`) waiting for you to fix by hand; nothing gets fixed automatically.
93
93
  - Worker subprocesses receive only `PATH HOME LANG LC_ALL TMPDIR TERM USER SHELL` by default; inject anything else by name with `env:` ([Adapter guide](04-adapter-guide.md), Chinese).
94
- - `reviewTriage: required` routes P0/P1 findings through you before a fixer is dispatched; delete the line if you do not want that.
94
+ - `reviewTriage: off` (the default) sends P0/P1 findings straight to the fixer, and review rounds stay bounded by `budgets`. Set it to `required` to see every round before a fixer runs, which suits high-risk projects.
95
95
 
96
96
  ## 3. Accept the plan
97
97
 
@@ -47,7 +47,7 @@ entry: build
47
47
  allowedPaths:
48
48
  - src
49
49
  - tests
50
- reviewTriage: required
50
+ reviewTriage: off
51
51
  envelope:
52
52
  prompts: ../../envelope/prompts
53
53
  workers:
@@ -91,7 +91,7 @@ workers:
91
91
  - `workers.<角色>` 是任意 CLI:换工具只改 `--` 后面的命令(`claude -p`、任意脚本都行),见 [Adapter 指南](04-adapter-guide.md);reviewer 的输出格式见 [Worker 合同](05-worker-contract.md),prompt 模板已写明。
92
92
  - **`fixer` 不是可选项**:没配它,verify 失败或 review 阻断时 Run 会停 `WAITING_HUMAN`(理由 `no adapter configured for worker fixer`)等你手修,不会自动修。
93
93
  - worker 子进程默认只拿到 `PATH HOME LANG LC_ALL TMPDIR TERM USER SHELL`;需要别的变量用 `env:` 点名注入([Adapter 指南](04-adapter-guide.md))。
94
- - `reviewTriage: required` 让 P0/P1 finding 先过你的手再派 fixer;不想要就删掉这行。
94
+ - `reviewTriage: off`(默认)时 P0/P1 finding 直接派 fixer,review 轮数仍受 `budgets` 上限约束。想每轮都先过你的手,改成 `required`,适合高风险项目。
95
95
 
96
96
  ## 3. 接受计划
97
97
 
@@ -69,7 +69,9 @@ The run config can also declare (beta.3, all from real incidents in a thirty-rou
69
69
 
70
70
  ## Review round budget
71
71
 
72
- The official preset carries `budgets.maxAttempts.review: 2` (the campaign charter's "at most 2 review rounds per Run", made native): a third review round stops at `WAITING_HUMAN` before it starts, with the reason stating that the budget is exhausted. The mechanism is simply the per-step `maxAttempts`; no new concept.
72
+ Review rounds have a single cap: `budgets.reviewRoundsPerWork` (default 6), counted across every Run of this Work (see "Review rounds counted per Work" below). A review step without an explicit `maxAttempts` takes that value as its per-Run cap, so the per-Run cap never fires before the Work cap; at the cap the Run stops at `WAITING_HUMAN` with the reason stating that the budget is exhausted. `budgets.maxAttempts.review` in the run config still sets a separate per-Run cap. (The official preset used to carry the campaign charter's "at most 2 review rounds per Run", which made a Run that needed two or three ordinary fix rounds ask for an extension again and again, and two caps had to be lifted together.)
73
+
74
+ **Stop on non-convergence**: below the cap, each time review reports blocking findings, before a fixer is dispatched, the kernel compares this round's P0/P1 findings with the earlier rounds of this Run. If a finding comes back after it was already sent to a fixer (same fingerprint; dismissed ones do not count), or this round has more blocking findings than the last one, the Run stops at `enter-fix` (kind `review-not-converging`) and the reason names the returning fingerprints or the two counts. Approving means fix + re-verify + one more review round and leaves the budget alone; `findings adjudicate --action dismiss` first stops a finding that should not block. While every blocking finding is new and there are no more of them than last round, the loop continues without asking anyone. With `reviewTriage: required` the triage stop happens as before and carries the non-convergence reason; at the cap the stop is still `budget`, with the same reason added.
73
75
 
74
76
  For the `resume-<step>` a Run stops at once the budget is exhausted, **a human approval grants one more attempt**: the kernel records a `BUDGET_EXTENDED` (a ledger fact, replayable), raises that step's limit by one and runs it; a rejection ends the Run. Before this, approving only made the same request come straight back (two pilot app-login Runs ended CANCELLED because of it, although their candidates were already in production).
75
77
 
@@ -85,7 +87,7 @@ budgets:
85
87
 
86
88
  `doctor` prints each step's effective limit and where it comes from (run config / workflow preset / default).
87
89
 
88
- **Review rounds counted per Work (iteration 09)**: a per-Run budget cannot stop "a new Run for every round" — one pilot Work ran 21 Runs and 9 review rounds without the 2-round cap ever triggering. `budgets.reviewRoundsPerWork: N` makes the kernel count, before a review step starts, the review rounds of **every** Run of this Work (superseded ones and ones already compacted into a run-record included); at N it stops at `WAITING_HUMAN` (kind `work-review-cap`, transition `enter-review`): approving grants one more review round (ledger `BUDGET_EXTENDED scope=work`), rejecting means merging or closing on the evidence at hand. Each Work in `overview` gets a line `cost: review rounds · findings · human waits · worker time`, and the run-record carries a `cost` block — read that line before deciding "continue or cut"; the stop-loss line in the intent (at most so many Runs / review rounds / hours) is checked against it.
90
+ **Review rounds counted per Work (iteration 09)**: a per-Run budget cannot stop "a new Run for every round" — one pilot Work ran 21 Runs and 9 review rounds without the 2-round cap ever triggering. `budgets.reviewRoundsPerWork: N` (N = 6 when unset) makes the kernel count, before a review step starts, the review rounds of **every** Run of this Work (superseded ones and ones already compacted into a run-record included); at N it stops at `WAITING_HUMAN` (kind `work-review-cap`, transition `enter-review`): approving grants one more review round (ledger `BUDGET_EXTENDED scope=work`), rejecting means merging or closing on the evidence at hand. Each Work in `overview` gets a line `cost: review rounds · findings · human waits · worker time`, and the run-record carries a `cost` block — read that line before deciding "continue or cut"; the stop-loss line in the intent (at most so many Runs / review rounds / hours) is checked against it.
89
91
 
90
92
  ## Worktrees live inside the repository: keep `.buildbeat/` out of test collection
91
93
 
@@ -69,7 +69,9 @@ run 配置还可声明(beta.3,皆来自三十轮部署战役的真实事故
69
69
 
70
70
  ## review 轮数预算
71
71
 
72
- 官方预设自带 `budgets.maxAttempts.review: 2`(战役章程"每 Run 2 轮 review 封顶"的原生化):第三轮 review 在启动前即停 `WAITING_HUMAN`,理由写明预算耗尽。机制就是每步 `maxAttempts`,无需新概念。
72
+ review 轮数只有一个上限:`budgets.reviewRoundsPerWork`(默认 6),跨本 Work 所有 Run 累计(见下文"按 Work 累计的 review 轮数")。review 步没有显式 `maxAttempts` 时,它在单个 Run 内的上限就取这个值,所以不会先于 Work 上限触发;到顶时停 `WAITING_HUMAN`,理由写明预算耗尽。仍可在 run 配置里写 `budgets.maxAttempts.review` 另设每 Run 上限。(官方预设曾自带战役章程的"每 Run 2 轮封顶",正常修两三轮的 Run 也要一次次批准扩额,两层上限还要合并放行。)
73
+
74
+ **按收敛止损**:上限之内,review 每次发现阻断问题、派 fixer 之前,内核把本轮 P0/P1 与本 Run 之前各轮比较。只要有 finding 在修过之后又出现(指纹相同;已 dismiss 的不算),或者本轮阻断数多于上一轮,就停 `enter-fix`(kind `review-not-converging`),理由列出又出现的指纹或前后两轮的数量。批准 = 修复 + 重验 + 再审一轮,不动预算;也可以先 `findings adjudicate --action dismiss`,让不该阻断的 finding 不再阻断。阻断 finding 都是新的、数量也不多于上一轮时,自动继续,不问人。开了 `reviewTriage: required` 时照常停分诊,不收敛的理由并入同一条请求;到顶时仍是 `budget` 停车,同样附上不收敛的理由。
73
75
 
74
76
  预算耗尽后停的那次 `resume-<step>`,**人批准即多给一次**:内核落一条 `BUDGET_EXTENDED`(台账事实,可重放),该步上限 +1 再跑;拒绝即终止 Run。此前批准只会让同一请求立刻回来(试点两条应用登录 Run 因此以 CANCELLED 收场,候选却已在生产)。
75
77
 
@@ -85,7 +87,7 @@ budgets:
85
87
 
86
88
  `doctor` 打印每步生效的上限与来源(run config / workflow preset / default)。
87
89
 
88
- **按 Work 累计的 review 轮数(迭代 09)**:每 Run 的预算挡不住"每轮一个新 Run"——试点一个 Work 跑了 21 个 Run、9 轮 review,2 轮封顶从未触发。`budgets.reviewRoundsPerWork: N` 让内核在 review 步起跑前统计本 Work **所有** Run(含已作废、含已压成 run-record 的)的 review 轮数,达到 N 即停 `WAITING_HUMAN`(kind `work-review-cap`,transition `enter-review`):批准即再审一轮(台账 `BUDGET_EXTENDED scope=work`),拒绝则按手头证据合并或关闭。`overview` 每个 Work 多一行 `cost: review rounds · findings · human waits · worker 时长`,run-record 也带 `cost` 块——"继续还是砍"之前先看这一行;intent 里的止损线(最多几个 Run / 几轮 review / 几小时)就对着它核。
90
+ **按 Work 累计的 review 轮数(迭代 09)**:每 Run 的预算挡不住"每轮一个新 Run"——试点一个 Work 跑了 21 个 Run、9 轮 review,2 轮封顶从未触发。`budgets.reviewRoundsPerWork: N`(不写时 N = 6)让内核在 review 步起跑前统计本 Work **所有** Run(含已作废、含已压成 run-record 的)的 review 轮数,达到 N 即停 `WAITING_HUMAN`(kind `work-review-cap`,transition `enter-review`):批准即再审一轮(台账 `BUDGET_EXTENDED scope=work`),拒绝则按手头证据合并或关闭。`overview` 每个 Work 多一行 `cost: review rounds · findings · human waits · worker 时长`,run-record 也带 `cost` 块——"继续还是砍"之前先看这一行;intent 里的止损线(最多几个 Run / 几轮 review / 几小时)就对着它核。
89
91
 
90
92
  ## 工作树在仓内:把 `.buildbeat/` 排除出测试收集
91
93
 
@@ -33,8 +33,8 @@ Sessions and documents used to mix "accept / approve / resume / succeeded / merg
33
33
 
34
34
  | Word | Command | Meaning | Is not |
35
35
  |---|---|---|---|
36
- | **Accept** | `accept --artifact intent\|plan` | A human endorses one artifact's digest; editing it makes the acceptance `stale` | Starting work; it creates no Run |
37
- | **Approve a transition** | `approve --transition <t>` | Lets the Run take **this one** transition: `enter-fix` (release the fixer after triage; when the review budget is spent, this one approval also grants the next review round, and so does answering it with a hand fix via `resume --adopt <sha>`), `resume-<step>` (run the step once more after a budget or infra stop; records `BUDGET_EXTENDED`; successful build / verify / fix attempts are not charged, and a budget stop that hits both the run and the work review cap is lifted by one approval), `enter-review` (one more review round after the Work-level cap), `enter-apply-readback` ("I have done it" on the release lane) | Approving any other transition; after a non-terminal transition is approved the Run **does not move by itself**: `resume --config <run-config>` continues it (the `next:` line printed by `approve` says so) |
36
+ | **Accept** | `accept --artifact intent\|plan`, or `--artifact intent,plan` for both at once | A human endorses each artifact's digest, one record per artifact; editing an artifact makes only its acceptance `stale` | Starting work; it creates no Run |
37
+ | **Approve a transition** | `approve --transition <t>` | Lets the Run take **this one** transition: `enter-fix` (release the fixer after triage or after a review that is not converging, kind `review-not-converging`; when the review budget is spent, this one approval also grants the next review round, and so does answering it with a hand fix via `resume --adopt <sha>`), `resume-<step>` (run the step once more after a budget or infra stop; records `BUDGET_EXTENDED`; successful build / verify / fix attempts are not charged, and a budget stop that hits both the run and the work review cap is lifted by one approval), `enter-review` (one more review round after the Work-level cap), `enter-apply-readback` ("I have done it" on the release lane) | Approving any other transition; after a non-terminal transition is approved the Run **does not move by itself**: `resume --config <run-config>` continues it (the `next:` line printed by `approve` says so) |
38
38
  | **Merge decision** (final approval) | `approve --transition enter-wait-merge` | The candidate is fit to merge: candidate + planDigest + evidenceDigest all hold at this instant; the Run reaches the terminal state `SUCCEEDED` and the run-record is compacted into the Git plane | Code merged, pushed or deployed: those three remain your actions outside the Runner, always |
39
39
  | **Run SUCCEEDED** | — | The Run stopped where it should and the evidence is complete | The Work is finished. `overview` shows `MERGED` only after reading back that the candidate is on the current branch |
40
40
  | **Reject** | `reject --reason` | The Run ends (`FAILED`, reason recorded) | The artifacts are invalidated; the acceptance state of intent/plan does not change |
@@ -33,8 +33,8 @@ buildbeat accept --repo . --work WORK-X --artifact plan --by <名字> # 工
33
33
 
34
34
  | 词 | 命令 | 含义 | 不等于 |
35
35
  |---|---|---|---|
36
- | **接受**(accept) | `accept --artifact intent\|plan` | 一份工件的 digest 被人认可;改过即 `stale` | 开工;不产生任何 Run |
37
- | **批准某转换**(approve) | `approve --transition <t>` | 允许 Run 走**这一条** transition:`enter-fix`(放行 fixer;附带 grants 时还放行重验后的下一轮 review)、`resume-<step>`(预算耗尽后扩额,或 infra 退款后重试)、`enter-review`(Work 级 review 上限后再审一轮)、`enter-apply-readback`(上线车道"我做完了") | 批准了别的转换;非终态转换批准后 Run **不会自己动**,要 `resume --config <run-config>` 续跑(`approve` 输出的 `next:` 行会写明) |
36
+ | **接受**(accept) | `accept --artifact intent\|plan`,或 `--artifact intent,plan` 一次接受两份 | 每份工件的 digest 各自被人认可、各记一行;改过哪份哪份 `stale` | 开工;不产生任何 Run |
37
+ | **批准某转换**(approve) | `approve --transition <t>` | 允许 Run 走**这一条** transition:`enter-fix`(分诊后或 review 不收敛时放行 fixer;附带 grants 时还放行重验后的下一轮 review)、`resume-<step>`(预算耗尽后扩额,或 infra 退款后重试)、`enter-review`(Work 级 review 上限后再审一轮)、`enter-apply-readback`(上线车道"我做完了") | 批准了别的转换;非终态转换批准后 Run **不会自己动**,要 `resume --config <run-config>` 续跑(`approve` 输出的 `next:` 行会写明) |
38
38
  | **合并决定**(最终批准) | `approve --transition enter-wait-merge` | 候选已具备合并条件:candidate + planDigest + evidenceDigest 此刻全部成立;Run 进终态 `SUCCEEDED`,run-record 压进 Git 面 | 代码已合并、已 push、已部署——这三件永远是你在 Runner 之外的动作 |
39
39
  | **Run SUCCEEDED** | — | Run 停在了它该停的地方,证据齐 | Work 完成。`overview` 只有回读到候选在当前分支上才显示 `MERGED` |
40
40
  | **拒绝**(reject) | `reject --reason` | Run 终止(`FAILED`,理由入账) | 工件失效;intent/plan 的接受状态不变 |
@@ -50,8 +50,9 @@ buildbeat accept --repo . --work WORK-X --artifact plan --by <名字> # 工
50
50
  ## 预算停车:成功不扣次数,一轮一问
51
51
 
52
52
  - 非只读步(build / verify / fix)成功的 attempt 不扣 `maxAttempts`,只读步仍消耗次数,review 按轮计费。`STEP_FINISHED.free: true` 与 `BUDGET_CONSUMED.amount: 0` 记录退款;没有新字段的旧台账保持原有回放结果。
53
- - 真失败到顶仍停 `resume-<step>`,同指纹两次仍停,release 预设的 `maxAttempts: 1` 仍有效。infra 故障仍不扣次数。默认预算数值不变。
53
+ - 真失败到顶仍停 `resume-<step>`,同指纹两次仍停,release 预设的 `maxAttempts: 1` 仍有效。infra 故障仍不扣次数。review 轮数只受 Work 级 `reviewRoundsPerWork`(默认 6)约束,只作兜底。
54
54
  - review 发现阻断问题时,若下一轮会超过 Run 或 Work 上限,立即停 `enter-fix`,在花费修复、重验之前问一次。有分诊时 kind 为 `finding-triage`,无分诊时为 `budget`。批准表示「修复 + 重新验证 + 再审一轮」;拒绝结束本 Run,由人按现有证据决定是否合并。
55
+ - 上限之内只在 review 不收敛时停 `enter-fix`:本轮有 finding 在修过之后又出现(同指纹,已 dismiss 的不算),或本轮阻断数多于上一轮。kind 为 `review-not-converging`(有分诊时仍是 `finding-triage`,理由里多一条不收敛),请求不带 grants,批准不动预算。阻断 finding 都是新的、数量不多于上一轮时自动继续。见 [Workflow 指南](02-workflow-guide.md) 的「按收敛止损」。
55
56
  - 每一次预算停车(`enter-fix`、`resume-<step>`、Work 级 `enter-review`)都在请求上记可选 `grants`,列出下一次执行会撞到的 Run/Work 上限;批准一次即同时放行两层,不再连问两次。`resume` 校验批准仍有效后逐条落 `BUDGET_EXTENDED`,并把整份放行计划钉在第一条上:放行落到一半进程被杀,再次 `resume` 按钉住的计划补齐剩余项,不从已被抬高的状态重算。过期批准、新请求不继承旧 grants。会话在 worktree 里手修并用 `resume --adopt <sha>` 回答该请求时,候选虽换成新提交,仍继承请求上的 grants(grants 属于这一轮,不属于某个候选;计划变了则不继承),修完重验后直接进入下一轮 review,不再二次停车。
56
57
  - 防止自定义 workflow 的成功循环失控:同一步总 attempt 达到有效上限(配置预算 + 人批扩额)的 **3 倍**后,在下一次执行前仍以 kind `budget` 兜底停人。成功/infra 退款不增加兜底上限;批准扩额会提高它。
57
58
 
@@ -11,7 +11,7 @@
11
11
  3. **交接靠 candidate hash + 台账**:Run 停在合并决定时 candidate 已由 Git 回读固定,`resume --adopt <sha>` 要求树干净且 HEAD 就是该 sha;hash 不得编造。
12
12
  4. **护栏与不可逆动作**:开工 `overview`;部署/改契约/migration 等不可逆动作前再核一次并走人批;exit 0 不消除 `warning/unverified`。
13
13
  5. **风险分轨**:Risk Preset 决定人批点(§5);别用牛刀杀鸡,也别借 `fast` 绕过高风险 delta 的独立核查。
14
- 6. **核查门**:Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 每 Run 默认 2 轮封顶。**完成 = hash + 可核验证据**;证据分 L0 声称 / L1 `文件:行` / L2 编译·类型 / L3 自动化测试 / L4 线上实测,`standard` 最低 L3,上线必须 L4;`UNVERIFIED` 永不当作通过。
14
+ 6. **核查门**:Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 不收敛(修过的 finding 又出现,或阻断数多于上一轮)即停人,每 Work 默认 6 轮兜底(`reviewRoundsPerWork`)。**完成 = hash + 可核验证据**;证据分 L0 声称 / L1 `文件:行` / L2 编译·类型 / L3 自动化测试 / L4 线上实测,`standard` 最低 L3,上线必须 L4;`UNVERIFIED` 永不当作通过。
15
15
  7. **状态单点**:事实进 Run 证据与 Work 记录;进度看 `overview`,度量看 `metrics`。
16
16
  8. **视觉问题带图对比**:提 UI bug 必附『实现截图 ⟷ 设计稿截图』并排 + 标注差异点。
17
17
  9. **单点事实**:线上版本只信实查(`observe status` / 部署平台),任何文档不写「当前线上 vX」;每个收敛后的真实决策包只在 `pm/decisions.md` 记一行;历史台账不回改。
@@ -18,7 +18,9 @@
18
18
 
19
19
  机器闸(gitleaks pre-commit)、证据制与合并候选一次核查任何预设都不跳;高风险 delta 不得借 `fast` 绕过独立核查。预算耗尽是停人不是失败。非只读步(build / verify / fix)成功不扣 `maxAttempts`,只读步仍按尝试次数计费,review 按轮计费;基础设施故障(超时 / 崩溃 / 非 JSON / exit 75)判 `infra` 停人、不派 fixer、不扣预算。真失败到顶仍停 `resume-<step>`,批准多给一次。同一步总 attempt 达到有效预算上限(配置值 + 人批扩额)的 3 倍后,下一次执行前以 kind `budget` 兜底停人,防止成功循环失控;退款不抬高该兜底上限。
20
20
 
21
- **一轮一问**:review 发现阻断问题且下一轮会超 Run 或 Work 上限时,提前停 `enter-fix`;有分诊用 `finding-triage`,无分诊用 `budget`。批准覆盖「修复 + 重新验证 + 再审一轮」,所需扩额随请求的可选 `grants` 落账,Run/Work 同时到顶只问一次;拒绝结束本 Run,由人按现有证据决定是否合并。批准旧的 `enter-review` / `resume-review` 预算停车时也同时放行已到顶的另一层上限。新候选或过期批准不能沿用旧请求的 grants。默认上限不变。
21
+ **一轮一问**:review 发现阻断问题且下一轮会超 Run 或 Work 上限时,提前停 `enter-fix`;有分诊用 `finding-triage`,无分诊用 `budget`。批准覆盖「修复 + 重新验证 + 再审一轮」,所需扩额随请求的可选 `grants` 落账,Run/Work 同时到顶只问一次;拒绝结束本 Run,由人按现有证据决定是否合并。批准旧的 `enter-review` / `resume-review` 预算停车时也同时放行已到顶的另一层上限。新候选或过期批准不能沿用旧请求的 grants。
22
+
23
+ **按收敛止损**:上限(`reviewRoundsPerWork`,默认 6,跨本 Work 所有 Run 累计)之内,review 只在不收敛时停 `enter-fix`(kind `review-not-converging`):修过的 finding 又出现(同指纹,已 dismiss 的不算),或本轮阻断数多于上一轮。批准即修复 + 重验 + 再审一轮,不动预算;阻断 finding 都是新的、数量不多于上一轮时自动继续。停在这里时先给用户看又出现的指纹,问是继续修、dismiss,还是按现有证据结束本 Run。
22
24
 
23
25
  ## 6. 三个仪式(防腐烂的关键,缺了机制必朽)
24
26
 
@@ -56,7 +56,7 @@
56
56
  - [ ] 4. 第一个 Work:`delivery/work/<WORK-ID>/` 写 `intent.md`(为什么 + 止损线)、`plan.md`;`templates/v2/run-config.example.yaml` → `run-config.yaml`(改 work / run / allowedPaths / verifier / 把 `--` 后的工具命令换成用户实际用的);`$(npm root -g)/@haiyangbg/buildbeat/src/v2/presets/software-delivery.yaml` → `workflow.yaml`
57
57
  - [ ] 5. 通知(问题 E):要就写 `.buildbeat/notify.yaml`,URL 只能来自环境变量;不要就在收尾说明"等待只在 inbox 里"。有生产环境就再放一份 `.buildbeat/observe.yaml`(只读探针)
58
58
  - [ ] 6. 机器闸:各代码仓装 gitleaks pre-commit(`command -v gitleaks` 查无则提醒安装,并记入收尾报告);meta 仓 git init + 远端,代码子仓各自独立 git
59
- - [ ] 7. 首跑验收:用户说「接受」→ `accept --artifact intent` / `plan`;`buildbeat doctor --config …` 全段读一遍(intent/plan 接受状态、env 姿态、预算、start 会停在哪);`start --config … --attempt new`(脱离启动)→ 停 `WAITING_HUMAN`;`overview` / `status` 把候选、verify 退出码、findings 读给用户。**这一次 Run 停在合并决定之前,不宣布"接入完成"**
59
+ - [ ] 7. 首跑验收:用户说「接受」→ `accept --artifact intent,plan`(一次接受两份);`buildbeat doctor --config …` 全段读一遍(intent/plan 接受状态、env 姿态、预算、start 会停在哪);`start --config … --attempt new`(脱离启动)→ 停 `WAITING_HUMAN`;`overview` / `status` 把候选、verify 退出码、findings 读给用户。**这一次 Run 停在合并决定之前,不宣布"接入完成"**
60
60
  - [ ] 8. 收尾一屏:生成了什么 / 默认拿主意的项 / 首跑停在哪、证据在哪 / 下一步由谁做(合并是人的动作)
61
61
  ```
62
62
 
package/example/AGENTS.md CHANGED
@@ -44,7 +44,7 @@
44
44
  **③ 交接靠 candidate hash + 台账** —— Run 停在合并决定时 candidate 已由 Git 回读固定;跨会话接力读 `delivery/work/<id>/` 即知全部事实,hash 不得编造。
45
45
  **④ 护栏与不可逆动作** —— 开工 `overview`;部署/改契约/migration 等不可逆动作前再核一次并走人批;exit 0 不消除 `warning/unverified`。
46
46
  **⑤ 风险分轨** —— Risk Preset:`fast`(仅 merge 人批)/ `standard`(plan+merge,默认)/ `controlled`(intent+plan+merge+release)/ `release`(上线回读车道)。
47
- **⑥ 核查门** —— Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 每 Run 默认 2 轮封顶。**完成 = hash + 可核验证据**;标准轨最低 L3,上线必须 L4。`UNVERIFIED` 永不当作通过。
47
+ **⑥ 核查门** —— Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 不收敛(修过的 finding 又出现,或阻断数多于上一轮)即停人,每 Work 默认 6 轮兜底(`reviewRoundsPerWork`)。**完成 = hash + 可核验证据**;标准轨最低 L3,上线必须 L4。`UNVERIFIED` 永不当作通过。
48
48
  **⑦ 状态单点** —— 事实进 Run 证据与 Work 记录;进度看 `overview`,度量看 `metrics`(本地只读)。
49
49
  **⑧ 视觉问题带图对比** —— 提 UI bug 必附『实现截图 ⟷ 设计稿截图』并排 + 标注差异点。
50
50
  **⑨ 单点事实** —— 线上版本只信实查(`observe status` / 部署平台);任何文档不写「当前线上 vX」;每个收敛后的真实决策包只在 `pm/decisions.md` 记一行;历史台账不回改。
@@ -10,11 +10,9 @@ entry: build
10
10
  allowedPaths:
11
11
  - src
12
12
  - tests
13
- reviewTriage: required
13
+ reviewTriage: off
14
14
  # 对应 intent 的止损线:3 轮 review
15
15
  budgets:
16
- maxAttempts:
17
- review: 2
18
16
  reviewRoundsPerWork: 3
19
17
  cache:
20
18
  verify: tree
@@ -37,8 +37,6 @@ transitions:
37
37
  to: fix
38
38
  terminal:
39
39
  - wait-merge
40
- # Review rounds are capped by default (deploy-campaign charter: two review
41
- # rounds per run, then a human). Override per project via budgets.maxAttempts.
42
- budgets:
43
- maxAttempts:
44
- review: 2
40
+ # Review rounds are capped per work (run config budgets.reviewRoundsPerWork,
41
+ # default 6); a round whose blocking findings come back after fix, or
42
+ # outnumber the last round's, stops for a human before the cap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haiyangbg/buildbeat",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "BuildBeat: Git-based AI delivery across models, tools, sessions, and people, with context in project files, build-test-review-fix loops, and human decisions.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/v2/cli/run.js CHANGED
@@ -21,7 +21,7 @@ import { loadWorkflow, nextStep } from "../engine/workflow.js";
21
21
  import { parseYamlSubset } from "../engine/yaml-subset.js";
22
22
  import { parsePolicyDoc } from "../policy/policy.js";
23
23
  import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
24
- import { acceptArtifact, adoptCandidate, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
24
+ import { acceptArtifacts, adoptCandidate, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
25
25
  import { checkRequires } from "../runtime/env-contract.js";
26
26
  import {
27
27
  adjudicateFinding,
@@ -48,7 +48,7 @@ import {
48
48
  subscribes,
49
49
  } from "../runtime/notify.js";
50
50
  import { writeRunRecord } from "../runtime/run-record.js";
51
- import { resumeRun, startRun } from "../runtime/orchestrator.js";
51
+ import { DEFAULT_REVIEW_ROUNDS_PER_WORK, resumeRun, startRun } from "../runtime/orchestrator.js";
52
52
  import { toRepoRef } from "../runtime/repo-ref.js";
53
53
  import { EventLedger } from "../storage/event-ledger.js";
54
54
  import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
@@ -67,7 +67,7 @@ Usage:
67
67
  buildbeat overview --repo <path> [--work <WORK-ID>] [--json]
68
68
  buildbeat approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
69
69
  buildbeat reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
70
- buildbeat accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec> [--by <name>]
70
+ buildbeat accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec>[,<more>] [--by <name>]
71
71
  buildbeat doctor --config <run-config.yaml>
72
72
  buildbeat events --repo <path> --run <RUN-ID>
73
73
  buildbeat replay --repo <path> --run <RUN-ID>
@@ -718,12 +718,15 @@ function commandAccept(flags) {
718
718
  if (!flags.repo || !flags.work || !flags.artifact) {
719
719
  throw new Error("accept requires --repo, --work and --artifact");
720
720
  }
721
- const result = acceptArtifact(resolve(flags.repo), flags.work, flags.artifact, {
721
+ const artifacts = String(flags.artifact).split(",").map((name) => name.trim()).filter(Boolean);
722
+ const results = acceptArtifacts(resolve(flags.repo), flags.work, artifacts, {
722
723
  by: flags.by ?? "human",
723
724
  });
724
- console.log(`accepted ${flags.artifact} as ${result.decisionRef}`);
725
- console.log(` digest: ${result.digest}`);
726
- console.log(" note: editing the artifact after acceptance makes this acceptance stale");
725
+ for (const result of results) {
726
+ console.log(`accepted ${result.artifact} as ${result.decisionRef}`);
727
+ console.log(` digest: ${result.digest}`);
728
+ }
729
+ console.log(" note: editing an artifact after acceptance makes its acceptance stale");
727
730
  }
728
731
 
729
732
  // Artifacts a policy rule requires to be accepted (artifact.accepted leaves
@@ -791,20 +794,20 @@ function commandDoctor(flags) {
791
794
  }
792
795
  console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
793
796
  const budgetLines = [];
797
+ const reviewRounds = options.budgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
794
798
  for (const step of options.workflow.steps) {
795
799
  if (!step.worker) {
796
800
  continue;
797
801
  }
798
802
  const fromRun = options.budgets.maxAttempts?.[step.id];
799
803
  const fromPreset = options.workflow.budgets?.maxAttempts?.[step.id];
800
- const effective = fromRun ?? fromPreset ?? options.maxAttemptsPerStep;
801
- const source = fromRun !== undefined ? "run config" : fromPreset !== undefined ? "workflow preset" : "default";
804
+ const review = step.id === "review" || step.worker === "reviewer";
805
+ const effective = fromRun ?? fromPreset ?? (review ? reviewRounds : options.maxAttemptsPerStep);
806
+ const source = fromRun !== undefined ? "run config" : fromPreset !== undefined ? "workflow preset" : review ? "reviewRoundsPerWork" : "default";
802
807
  budgetLines.push(`${step.id}=${effective} (${source})`);
803
808
  }
804
809
  console.log(`budgets (maxAttempts per step; approving resume-<step> after exhaustion grants +1): ${budgetLines.join(", ")}`);
805
- if (options.budgets.reviewRoundsPerWork !== undefined) {
806
- console.log(`budgets.reviewRoundsPerWork: ${options.budgets.reviewRoundsPerWork} (counted across every run of the work, superseded ones included)`);
807
- }
810
+ console.log(`budgets.reviewRoundsPerWork: ${reviewRounds}${options.budgets.reviewRoundsPerWork === undefined ? " (default)" : ""} (counted across every run of the work, superseded ones included)`);
808
811
  // Same preconditions start's first gate will read (real incident, twice:
809
812
  // doctor passed, start stopped at build because plan.md was not mirrored
810
813
  // into the repository the run was started in).
@@ -37,8 +37,6 @@ transitions:
37
37
  to: fix
38
38
  terminal:
39
39
  - wait-merge
40
- # Review rounds are capped by default (deploy-campaign charter: two review
41
- # rounds per run, then a human). Override per project via budgets.maxAttempts.
42
- budgets:
43
- maxAttempts:
44
- review: 2
40
+ # Review rounds are capped per work (run config budgets.reviewRoundsPerWork,
41
+ # default 6); a round whose blocking findings come back after fix, or
42
+ # outnumber the last round's, stops for a human before the cap.
@@ -294,6 +294,22 @@ export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts }
294
294
  return { decisionRef, digest };
295
295
  }
296
296
 
297
+ // One human "accept" for several artifacts: every file must exist before any
298
+ // acceptance is written, and each artifact keeps its own digest-bound line.
299
+ export function acceptArtifacts(repoRoot, workId, artifacts, options = {}) {
300
+ const unique = [...new Set(artifacts)];
301
+ if (unique.length === 0) {
302
+ throw new DecisionError("no artifact to accept");
303
+ }
304
+ const missing = unique
305
+ .map((artifact) => join(repoRoot, "delivery", "work", workId, `${artifact}.md`))
306
+ .filter((filePath) => !existsSync(filePath));
307
+ if (missing.length > 0) {
308
+ throw new DecisionError(`artifact file missing: ${missing.join(", ")}`);
309
+ }
310
+ return unique.map((artifact) => ({ artifact, ...acceptArtifact(repoRoot, workId, artifact, options) }));
311
+ }
312
+
297
313
  export function rejectRun(repoRoot, runId, { by = "human", transition, reason, ts } = {}) {
298
314
  acquireLock(repoRoot, runId);
299
315
  try {
@@ -82,7 +82,7 @@ export function nextReply({ repoLabel, state }) {
82
82
  }
83
83
  const runId = state.run.id;
84
84
  const lines = [];
85
- if (pending.kind === "finding-triage") {
85
+ if (pending.kind === "finding-triage" || pending.kind === "review-not-converging") {
86
86
  lines.push(`buildbeat findings list --repo ${repoLabel} --work ${state.run.work}`);
87
87
  lines.push(
88
88
  `buildbeat findings adjudicate --repo ${repoLabel} --work ${state.run.work} --fingerprint <fp> --action accept|dismiss --by <you>`,
@@ -43,6 +43,7 @@ import {
43
43
  import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
44
44
 
45
45
  const KERNEL = { kind: "kernel", id: "orchestrator" };
46
+ export const DEFAULT_REVIEW_ROUNDS_PER_WORK = 6;
46
47
 
47
48
  export class OrchestratorError extends Error {
48
49
  constructor(message) {
@@ -225,11 +226,16 @@ function makeContext(options, ledger, workspace) {
225
226
  // granted on this ledger adds to it. Real incident: the preset's two
226
227
  // review rounds could not be raised from the run config, and approving
227
228
  // resume-review re-asked the same question forever.
229
+ // A review step without an explicit cap takes the work-level cap as its run
230
+ // cap, so review rounds are counted in one unit: the work.
228
231
  context.runBudgets = options.budgets ?? {};
232
+ context.reviewRoundsPerWork = context.runBudgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
229
233
  context.budgetLimitFor = (step) =>
230
234
  (context.runBudgets.maxAttempts?.[step] ??
231
235
  workflow.budgets?.maxAttempts?.[step] ??
232
- maxAttemptsPerStep) +
236
+ (isReviewStep(step, workflow.steps.find((item) => item.id === step))
237
+ ? context.reviewRoundsPerWork
238
+ : maxAttemptsPerStep)) +
233
239
  (ledger.state.budgetExtensions?.[step] ?? 0);
234
240
  context.maxAttemptsFor = (step) => context.budgetLimitFor(step) +
235
241
  (ledger.state.steps[step]?.infraAttempts ?? 0) +
@@ -297,8 +303,8 @@ function budgetReasons(context, step, safeguard = false) {
297
303
 
298
304
  function workReviewBudget(context, step) {
299
305
  const stepDef = context.workflow.steps.find((item) => item.id === step);
300
- const cap = context.runBudgets.reviewRoundsPerWork;
301
- if (cap === undefined || !isReviewStep(step, stepDef)) return null;
306
+ const cap = context.reviewRoundsPerWork;
307
+ if (!isReviewStep(step, stepDef)) return null;
302
308
  const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
303
309
  excludeRun: context.ledger.state.run.id,
304
310
  });
@@ -319,6 +325,42 @@ function budgetGrants(context, step) {
319
325
  return grants;
320
326
  }
321
327
 
328
+ function reviewRoundAttempt(ref, step) {
329
+ const name = ref.split(/[\\/]/).pop();
330
+ if (!name.startsWith(`${step}-`)) return null;
331
+ const match = /^(\d+)\.json$/.exec(name.slice(step.length + 1));
332
+ return match ? Number(match[1]) : null;
333
+ }
334
+
335
+ // Compares this round's blocking findings with the earlier review rounds of
336
+ // the same step in this run. A finding a fixer was already sent after that
337
+ // comes back, or more blocking findings than last round, means another
338
+ // automatic round would not converge; a shrinking set of new findings keeps
339
+ // going without asking anyone. Returns the stop reason, or null.
340
+ function reviewNotConverging(context, step, blockingFindings) {
341
+ const rounds = context.ledger.state.evidence.filter((item) =>
342
+ item.kind === "review" && reviewRoundAttempt(item.ref ?? "", step) !== null);
343
+ if (rounds.length < 2) return null;
344
+ const adjudicated = latestAdjudications(
345
+ readFindingsAccount(context.repoRoot, context.ledger.state.run.work),
346
+ );
347
+ const blocking = (round) => (round.findings ?? [])
348
+ .filter((finding) => finding.severity === "P0" || finding.severity === "P1")
349
+ .map(fingerprintFinding)
350
+ .filter((fingerprint) => adjudicated.get(fingerprint)?.action !== "dismiss");
351
+ const earlier = new Set(rounds.slice(0, -1).flatMap(blocking));
352
+ const lastCount = new Set(blocking(rounds.at(-2))).size;
353
+ const current = [...new Set(blockingFindings.map(fingerprintFinding))];
354
+ const repeated = current.filter((fingerprint) => earlier.has(fingerprint));
355
+ if (repeated.length > 0) {
356
+ return `${step} is not converging: ${repeated.length} blocking finding(s) came back after fix (${repeated.join(", ")}); approving runs fix + re-verify + one more review round, dismissing a finding stops it blocking`;
357
+ }
358
+ if (current.length > lastCount) {
359
+ return `${step} is not converging: ${current.length} blocking finding(s) this round, ${lastCount} last round; approving runs fix + re-verify + one more review round`;
360
+ }
361
+ return null;
362
+ }
363
+
322
364
  // Evaluates configured policies of `type` for `appliesTo`, records every
323
365
  // verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
324
366
  // ADVISORY failures are recorded but never gate (doctor reports the gap).
@@ -886,8 +928,8 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
886
928
  }
887
929
 
888
930
  // Settles the outcome and picks the next step; blocking findings stop once
889
- // for triage or for the review budget before any fixer runs. Returns the
890
- // next step, or null when the run stopped.
931
+ // for triage, the review budget or a review that is not converging before
932
+ // any fixer runs. Returns the next step, or null when the run stopped.
891
933
  function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings, tree, exec }) {
892
934
  let outcome;
893
935
  if (stepStatus !== "succeeded") {
@@ -901,9 +943,11 @@ function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings,
901
943
  // Ask before spending fix/verify workers: one approval covers the next
902
944
  // round and both review caps, with the grant bound to this request.
903
945
  if (routed && outcome === "findings-blocking") {
904
- const grants = isReviewStep(step, stepDef) ? budgetGrants(context, step) : [];
946
+ const review = isReviewStep(step, stepDef);
947
+ const grants = review ? budgetGrants(context, step) : [];
948
+ const stalled = review ? reviewNotConverging(context, step, blockingFindings) : null;
905
949
  const triage = context.reviewTriage === "required";
906
- if (triage || grants.length) {
950
+ if (triage || grants.length || stalled) {
907
951
  const { used, limit, failures } = budgetUsage(context, step);
908
952
  const workBudget = workReviewBudget(context, step);
909
953
  context.waitHuman(
@@ -912,12 +956,13 @@ function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings,
912
956
  ...(grants.length ? [
913
957
  `${step} budget exhausted: ${used}/${limit} review round(s) used in this run${workBudget ? `, ${workBudget.rounds}/${workBudget.allowed} across the work` : ""}, ${failures} real failure(s); approve enter-${routed} = fix + re-verify + one more review round; reject = end this run and decide the merge on the evidence you have`,
914
958
  ] : []),
959
+ ...(stalled ? [stalled] : []),
915
960
  `review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
916
961
  ...blockingFindings.slice(0, 5).map((finding) =>
917
962
  `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
918
963
  `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
919
964
  ],
920
- triage ? "finding-triage" : "budget",
965
+ triage ? "finding-triage" : grants.length ? "budget" : "review-not-converging",
921
966
  grants,
922
967
  );
923
968
  return null;
@@ -1287,7 +1332,7 @@ export function resumeRun(options) {
1287
1332
  amount: 1,
1288
1333
  ...(grant.scope === "work" ? { scope: "work" } : {}),
1289
1334
  maxAttempts: grant.scope === "work"
1290
- ? (context.runBudgets.reviewRoundsPerWork ?? 0) + (ledger.state.workReviewGrants ?? 0) + 1
1335
+ ? context.reviewRoundsPerWork + (ledger.state.workReviewGrants ?? 0) + 1
1291
1336
  : context.maxAttemptsFor(grant.step) + 1,
1292
1337
  approvalRef: approval.decisionRef,
1293
1338
  grants: plan,
@@ -196,7 +196,8 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
196
196
  : `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
197
197
  } else if (!plan.accepted) {
198
198
  stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
199
- next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact plan --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
199
+ const artifacts = intent.accepted ? "plan" : "intent,plan";
200
+ next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
200
201
  } else {
201
202
  stage = "READY_TO_RUN";
202
203
  const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
@@ -44,7 +44,7 @@
44
44
  **③ 交接靠 candidate hash + 台账** —— Run 停在合并决定时 candidate 已由 Git 回读固定;跨会话接力读 `delivery/work/<id>/` 即知全部事实,hash 不得编造。
45
45
  **④ 护栏与不可逆动作** —— 开工 `overview`;部署/改契约/migration 等不可逆动作前再核一次并走人批;exit 0 不消除 `warning/unverified`。
46
46
  **⑤ 风险分轨** —— Risk Preset:`fast`(仅 merge 人批)/ `standard`(plan+merge,默认)/ `controlled`(intent+plan+merge+release)/ `release`(上线回读车道)。
47
- **⑥ 核查门** —— Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 每 Run 默认 2 轮封顶。**完成 = hash + 可核验证据**;标准轨最低 L3,上线必须 L4。`UNVERIFIED` 永不当作通过。
47
+ **⑥ 核查门** —— Run 内 reviewer 只读、结构化 findings;`reviewTriage: required` 时 P0/P1 先过人分诊再派 fixer;review 不收敛(修过的 finding 又出现,或阻断数多于上一轮)即停人,每 Work 默认 6 轮兜底(`reviewRoundsPerWork`)。**完成 = hash + 可核验证据**;标准轨最低 L3,上线必须 L4。`UNVERIFIED` 永不当作通过。
48
48
  **⑦ 状态单点** —— 事实进 Run 证据与 Work 记录;进度看 `overview`,度量看 `metrics`(本地只读)。
49
49
  **⑧ 视觉问题带图对比** —— 提 UI bug 必附『实现截图 ⟷ 设计稿截图』并排 + 标注差异点。
50
50
  **⑨ 单点事实** —— 线上版本只信实查(`observe status` / 部署平台);任何文档不写「当前线上 vX」;每个收敛后的真实决策包只在 `pm/decisions.md` 记一行;历史台账不回改。
@@ -14,14 +14,12 @@ entry: build
14
14
  allowedPaths:
15
15
  - src
16
16
  - tests
17
- # P0/P1 finding 先过人分诊再派 fixer;不需要就删掉这行
18
- reviewTriage: required
19
- # 非只读步成功不扣次数;review 按轮计费。到顶的阻断 review 在 enter-fix 一次批准修复、重验、再审。
20
- # Run/Work 上限同时到顶只问一次;总 attempt 超过(配置值 + 人批扩额)的 3 倍前仍兜底停人。
21
- # 可省;run 配置 > 预设 > 默认。reviewRoundsPerWork 是跨本 Work 所有 Run 累计的 review 轮数上限(对应 intent 的止损线)
17
+ # off = P0/P1 finding 直接派 fixer;高风险项目改成 required,每轮先停人分诊再派 fixer
18
+ reviewTriage: off
19
+ # 非只读步成功不扣次数;review 按轮计费。review 不收敛(修过的 finding 又出现,或阻断数多于上一轮)才在 enter-fix 停人。
20
+ # reviewRoundsPerWork 是 review 轮数唯一的上限(默认 6),跨本 Work 所有 Run 累计,对应 intent 的止损线;
21
+ # 到顶的阻断 review 在 enter-fix 一次批准修复、重验、再审。总 attempt 超过(配置值 + 人批扩额)的 3 倍前仍兜底停人。
22
22
  budgets:
23
- maxAttempts:
24
- review: 2
25
23
  reviewRoundsPerWork: 6
26
24
  # 默认一个仓库同时只驱动一个 Run。确认本项目的测试不抢固定端口、不共用数据库后,
27
25
  # 可加 parallel: true,让本 Work 的 Run 与其他同样打开开关的 Work 并行(同一 Work 仍互斥)