@haiyangbg/buildbeat 2.0.0-beta.2 → 2.0.0-beta.3

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,10 +2,24 @@
2
2
 
3
3
  > 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
4
4
 
5
+ ## v2.0.0-beta.3 — 2026-09-01
6
+
7
+ > 主题:三十轮部署战役(底座 WORK-C0-HEALTH-NONPROD-01,DEPLOY-01~30 + L4 之夜)的机制回灌。战役复盘:`底座/pm/2026-09-01-BuildBeat三十轮复盘.md`。
8
+ > **发布状态**:同 beta.1/beta.2 流程(OIDC Trusted Publishing,dist-tag `next`;`latest` 保持 v1.21.0)。
9
+
10
+ - **发现分诊门**(复盘改革条 4):run 配置 `reviewTriage: required` 后,review 的 P0/P1 finding 不再自动派 fixer——停 `WAITING_HUMAN`(kind `finding-triage`)待人逐指纹裁决,approve `enter-fix` 才放行。finding 是处方不是事实;自动路由处方在战役振荡期连烧四轮
11
+ - **锚定审查与裁决台账**(改革条 3):finding 全部落 Git 面 `delivery/work/<id>/review-findings.jsonl`(指纹=严重度+正文规范化 hash);`findings list` / `findings adjudicate --action accept|dismiss` 人裁决;`dismiss` 后同指纹不再阻断(重提记 `RE-RAISED` 可见)、严重度升级自动重开;Reviewer input 注入历史裁决锚(`anchor`)、fixer input 注入带裁决状态的工单(`findings`)。裁决记忆在 Git 面,删 runtime 不丢
12
+ - **环境契约 `requires:`**:run 配置声明信封依赖的二进制与最低版本,Run 启动前 fail-closed 全量核验、一次报清(真实事故:vendored-only `rg`、bash 3.2、新 shell 解析 Node 14 各烧整轮才见真因);`doctor` 同步报告
13
+ - **review 轮数预算原生化**(改革条 1 的机制化):官方预设 `budgets.maxAttempts.review: 2`——第三轮 review 启动前即停人批,无需 prompt 约束兜底
14
+ - **`preflight` 预检通道**:`preflight --config <cfg> --step <id>` 在主 checkout 干跑某步 worker 命令,分钟级打到首个失败边界再进 Run;无 worktree、无台账、零证据(横幅明示 dry signal;`BUILDBEAT_PREFLIGHT=1`),Run 必须复现才算数
15
+ - **fix(v2) 崩溃恢复不再误路由**:被中断的步现在重跑自身(丢失尝试仍计预算),不再按步骤失败走 failure 边——真实事故(deploy-18):宿主超时杀掉 verify worker,crash 被路由去 fix,fixer 面对零 verifier 证据白烧一轮。交互式 shell 里 `start` 现在提示脱离启动(nohup/setsid)
16
+ - 战役期间已并入的内核修复一并随本版发布:预算守卫(final attempt 失败不再派 fix,deploy-14)、porcelain 状态列保护、runtime 证据引用仓库相对化、Node <20 清晰报错(各见对应 commit)
17
+ - 指南更新:验证金字塔警示与"真缺陷类清零即升层"(06)、分诊门与锚定审查(07)、环境契约与 review 预算(02)、崩溃重跑语义与启动纪律(10)
18
+
5
19
  ## v2.0.0-beta.2 — 2026-08-28
6
20
 
7
21
  > 主题:aiplatform-meta(底座)v2 迁移试点抓出的内核修复。
8
- > **发布状态**:同 beta.1 流程(OIDC Trusted Publishing,dist-tag `next`;`latest` 保持 v1.21.0)。
22
+ > **发布状态**:`@haiyangbg/buildbeat@2.0.0-beta.2` 已于 2026-08-28 经 OIDC Trusted Publishing 发布到 dist-tag `next`(run 33175013599,双 job success);`latest` 保持 v1.21.0。独立回读:dist-tag 路由、integrity、SLSA provenance、隔离安装全过,证据见 [`docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md)。
9
23
 
10
24
  - **fix(v2) 范围门中文路径误拦**:git `core.quotepath` 默认把非 ASCII 路径转义为带引号的八进制串,`listChangedPaths` 直接喂给 allowedPaths 前缀检查导致范围内中文文件被判越界(真实事故:底座 `RUN-META-V2-01` 被 `pm/登录二期看板.md` 阻断)。读回改用 `core.quotepath=off`,中文路径永久回归进 `tests/v2-invariants.test.js`
11
25
 
@@ -2,5 +2,17 @@
2
2
 
3
3
  // v2 runtime CLI entry. The v1 `buildbeat` bin stays frozen on src/cli.js;
4
4
  // v2 ships as a separate entry until it takes over `latest`.
5
+ //
6
+ // Guard before loading any module: the kernel uses Node>=20 syntax, and on a
7
+ // machine whose default node drifted older the raw SyntaxError stack hides
8
+ // the actual problem (real incident: default node v14 during the meta pilot).
9
+ const major = Number(process.versions.node.split(".")[0]);
10
+ if (major < 20) {
11
+ console.error(
12
+ `buildbeat-v2 needs Node >= 20; this shell resolved v${process.versions.node}.\n` +
13
+ "Check `which node` / nvm default, then rerun (e.g. `nvm use 23`).",
14
+ );
15
+ process.exit(1);
16
+ }
5
17
 
6
- import "../src/v2/cli/run.js";
18
+ import("../src/v2/cli/run.js");
package/docs/CLI.md CHANGED
@@ -128,7 +128,7 @@ Schema 2 is the first write-capable shape targeted by Wave 1:
128
128
  {
129
129
  "schemaVersion": 2,
130
130
  "scaffoldVersion": "v1.21",
131
- "cliVersion": "2.0.0-beta.2",
131
+ "cliVersion": "2.0.0-beta.3",
132
132
  "layout": "default",
133
133
  "installedAt": "2026-08-24T00:00:00.000Z",
134
134
  "files": {
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 canonical executable is `buildbeat`, while `solobaton` remains an executable alias. The old `solobaton` npm package is a frozen legacy distribution ID and must not receive the scoped write/upgrade command surface.
4
4
 
5
- Release evidence at source package version `@haiyangbg/buildbeat@2.0.0-beta.2` (pending publication verification); latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@2.0.0-beta.1` (dist-tag `next`; `latest` remains `1.21.0`), anchored by annotated tag `v2.0.0-beta.1` at commit `946d43f`, workflow run [33171071539](https://github.com/HaiYangBG1/BuildBeat/actions/runs/33171071539), and archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). The latest stable distribution stays `@haiyangbg/buildbeat@1.21.0`, anchored by annotated tag `v1.21.0` at commit `ce69a05`, workflow run [32864438692](https://github.com/HaiYangBG1/BuildBeat/actions/runs/32864438692), and the matching [GitHub Release](https://github.com/HaiYangBG1/BuildBeat/releases/tag/v1.21.0). Exact registry identity, provenance, signatures, isolated-install readback, Environment approval, and immutable-artifact boundary are archived in [`V1.21-RELEASE-EVIDENCE-2026-08-25.md`](V1.21-RELEASE-EVIDENCE-2026-08-25.md). First-scoped-release bootstrap behavior and legacy deprecation remain archived in [`WP4.3-RELEASE-EVIDENCE-2026-08-25.md`](WP4.3-RELEASE-EVIDENCE-2026-08-25.md). The legacy distribution remains `solobaton@1.16.3`; all three published legacy versions are retained and deprecated toward the scoped package.
5
+ Release evidence at source package version `@haiyangbg/buildbeat@2.0.0-beta.3` (pending publication verification); latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@2.0.0-beta.2` (dist-tag `next`; `latest` remains `1.21.0`), anchored by annotated tag `v2.0.0-beta.2` at commit `d7a9ab9`, workflow run [33175013599](https://github.com/HaiYangBG1/BuildBeat/actions/runs/33175013599), and archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](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`](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). The latest stable distribution stays `@haiyangbg/buildbeat@1.21.0`, anchored by annotated tag `v1.21.0` at commit `ce69a05`, workflow run [32864438692](https://github.com/HaiYangBG1/BuildBeat/actions/runs/32864438692), and the matching [GitHub Release](https://github.com/HaiYangBG1/BuildBeat/releases/tag/v1.21.0). Exact registry identity, provenance, signatures, isolated-install readback, Environment approval, and immutable-artifact boundary are archived in [`V1.21-RELEASE-EVIDENCE-2026-08-25.md`](V1.21-RELEASE-EVIDENCE-2026-08-25.md). First-scoped-release bootstrap behavior and legacy deprecation remain archived in [`WP4.3-RELEASE-EVIDENCE-2026-08-25.md`](WP4.3-RELEASE-EVIDENCE-2026-08-25.md). The legacy distribution remains `solobaton@1.16.3`; all three published legacy versions are retained and deprecated toward the scoped package.
6
6
 
7
7
  ## Release invariants
8
8
 
@@ -0,0 +1,8 @@
1
+ # v2.0.0-beta.2 发布证据(2026-08-28)
2
+
3
+ > 授权:所有者会话内 "4. OK"(beta.2 发布 + 部署审批);流程与 [beta.1](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md) 完全一致。
4
+
5
+ - 内容:单一内核修复——范围门中文路径误拦(git quotepath 转义;aiplatform-meta 迁移试点真实事故 `RUN-META-V2-01`),修复 + 中文路径永久回归(`3f39189`)。
6
+ - 候选:`d7a9ab9`(`v2` tip),tag `v2.0.0-beta.2`;本地 prepublishOnly 全链 `GATE_EXIT=0`(node 143/143);CI 两个 run(`3f39189` / `d7a9ab9`)conclusion=success。
7
+ - 发布:workflow run [33175013599](https://github.com/HaiYangBG1/BuildBeat/actions/runs/33175013599) 双 job success(publish + verify:exact integrity、dist-tag 路由、provenance、隔离安装、签名审计)。
8
+ - 本地独立回读:dist-tags `{bootstrap: 0.0.0, latest: 1.21.0, next: 2.0.0-beta.2}`(`latest` 未动);`dist.integrity = sha512-Lt90fCFnNCvnMJK/cs/lUrau4uESf5iDCmy9GFYD6sFdGSHbwlcOM4afBlziHTuoz3rVKpgw8b/+bP+bjarh6w==`;provenance `https://slsa.dev/provenance/v1`;所有者机器全局安装已切换为该官方工件(quotepath 修复在包内核验)。
@@ -80,6 +80,8 @@ buildbeat-v2 approve --repo . --run RUN-DEMO-1 --transition enter-wait-merge --b
80
80
 
81
81
  批准即 merge-ready;合并/推送/发布永远是你的动作,Runner 不代劳。被 findings 阻断时会自动路由 fix→verify 重走,超预算或指纹重复则停下交还给你([Approval 指南](07-approval-guide.md)、[Recovery](10-recovery.md))。
82
82
 
83
+ 想让 finding 先过你的手再派 fixer:run 配置加 `reviewTriage: required`,配套 `findings list` / `findings adjudicate` 逐指纹裁决(dismiss 后同指纹不再阻断);正式起 Run 前可用 `preflight --step <id>` 在主 checkout 分钟级干跑单步(不产证据);信封的环境依赖用 `requires:` 声明,启动前 fail-closed 核验。详见 [Approval 指南](07-approval-guide.md)、[Evidence 指南](06-evidence-guide.md)、[Workflow 指南](02-workflow-guide.md)。
84
+
83
85
  ## 5. observe:让系统盯生产(v0)
84
86
 
85
87
  ```bash
@@ -37,6 +37,23 @@ terminal:
37
37
 
38
38
  run 配置里 `entry` 可覆盖 workflow 的 `entry`(例如从 `build` 起步、跳过 intent/plan 步——digest 仍会绑进批准对象);`stopAt` 指定停点。workflow 文件整体做 sha256 → `RUN_CREATED.workflowDigest`,事后可证明当时跑的是哪份流程。
39
39
 
40
+ run 配置还可声明(beta.3,皆来自三十轮部署战役的真实事故):
41
+
42
+ - **`requires:` 环境契约**——信封隐式依赖的二进制与最低版本,Run 启动前 fail-closed 全量核验,一次报清所有问题(真实事故:`rg` 只在某会话 vendored PATH、`/bin/bash` 3.2、新 shell 解析到 Node 14,各烧掉整轮 Run 才见真因):
43
+
44
+ ```yaml
45
+ requires:
46
+ - command: bash
47
+ min: 4
48
+ - command: rg
49
+ ```
50
+
51
+ - **`reviewTriage: required` 发现分诊门**——review 的阻断性 finding 先停人分诊、再派 fixer(见 [Approval 指南](07-approval-guide.md))。
52
+
53
+ ## review 轮数预算
54
+
55
+ 官方预设自带 `budgets.maxAttempts.review: 2`(战役章程"每 Run 2 轮 review 封顶"的原生化):第三轮 review 在启动前即停 `WAITING_HUMAN`,理由写明预算耗尽。项目可用自己的 workflow 文件覆盖;机制就是每步 `maxAttempts`,无需新概念。
56
+
40
57
  ## 修改纪律
41
58
 
42
59
  预设是产品的一部分:改 `software-delivery.yaml` 前先想清是不是项目差异——项目差异用自己的 workflow 文件(run 配置 `workflow:` 指过去),不改官方预设。schema additive-only,破坏性改法升 `version`。
@@ -20,6 +20,12 @@
20
20
 
21
21
  `L0` 自述 → `L1` 静态检查 → `L2` 本地真实执行(命令回读默认档)→ `L3` 部署后验证 → `L4` 生产实测。门用 `minGrade` 提要求(如 merge 底线 L2;生产切换收口要 L4)。
22
22
 
23
+ **验证金字塔警示(三十轮战役最贵一课)**:低层验证追理论完备的边际价值远低于早一层真机。实战中 7400 行 L3 套件打磨到极限,而 4 个真正的上线阻断(systemd 解析行为、部署/服务身份分裂、探针预算按替身标定、TLS ref 格式)**全部是 L3 结构性测不到的**,一晚 L4 找齐。经验法则:**模拟层"真缺陷类清零"即升层,不追理论完备**。同理,verify 全量重跑在候选只动局部时是纯重复——按内容哈希缓存/裁剪属于信封层优化(战役实测 25→13 分钟),内核不代做:缓存正确性依赖环境稳定假设,由信封所有者自己承担。
24
+
25
+ ## 预检 ≠ 证据
26
+
27
+ `buildbeat-v2 preflight --config <run-config> --step <id>`:在主 checkout 直接干跑某步的 worker 命令——无 worktree、无台账、不落任何证据(输出自带 `PREFLIGHT (dry signal, never evidence)` 横幅,环境变量 `BUILDBEAT_PREFLIGHT=1`)。用途是分钟级循环打到首个失败边界再进 Run(战役里 harness 缺陷每个要烧一整轮 Run,预检模式一晚拆完);**预检发现的任何东西必须由 Run 复现才算数**。
28
+
23
29
  ## 候选作用域
24
30
 
25
31
  证据以 `subject` 绑定候选:merge 门只统计当前 candidate 的证据,旧候选/旧 review 轮次的记录不混入(真实事故回归,见 evals `fix-loop`)。
@@ -32,3 +32,20 @@ merge 批准只表示 **merge-ready**:真正的合并、push、发布是你在
32
32
  ## 人批点由 Risk Preset 决定
33
33
 
34
34
  `fast` 仅 Merge;`standard` Plan+Merge;`controlled` Intent+Plan+Merge+Release;`legacy-four-gates` 为 v1 四 Gate 完整形态(迁移期用,见 [迁移指南](08-migration-v1.md))。待批项强制携带 findings 摘要与理由——防"秒批"退化;人批等待时长进 `metrics`。
35
+
36
+ ## 发现分诊门与锚定审查(beta.3)
37
+
38
+ 来自三十轮部署战役最大的结构性教训:**finding 是处方不是事实**,无记忆 fresh reviewer 会开出互斥处方并翻案早已接受的设计,自动路由 fixer 让振荡直接烧钱。两个机制配套:
39
+
40
+ 1. **分诊门**:run 配置 `reviewTriage: required` 后,review 产出 P0/P1 finding 不再自动派 fixer,而是停 `WAITING_HUMAN`(kind `finding-triage`),待批理由逐条列出 finding 指纹。人先裁决、再 `approve --transition enter-fix` 放行(或 `reject` 终止 Run)。
41
+ 2. **裁决台账**:finding 全部落 Git 面 `delivery/work/<id>/review-findings.jsonl`(指纹 = 严重度+正文规范化 hash):
42
+
43
+ ```bash
44
+ buildbeat-v2 findings list --repo . --work WORK-X
45
+ buildbeat-v2 findings adjudicate --repo . --work WORK-X --fingerprint <fp> --action dismiss --by <名字> --note "<为什么>"
46
+ ```
47
+
48
+ `dismiss` 后同指纹不再阻断(重提会以 `RE-RAISED` 记账可见,但不重启循环);**严重度升级=新指纹,自动重新阻断**——压噪不压真信号,与 observe 的 dismiss 回调同一原则。
49
+ 3. **锚定注入**:Reviewer(readonly 步)的 `BUILDBEAT_INPUT` 带 `anchor`(历史 finding+裁决全表),信封 prompt 应告知 reviewer"已裁决的结论不得翻案";fixer 等写入步的 input 带 `findings`(上一轮 review 的 finding 及其裁决状态)——fixer 只修 accepted/open,不猜。
50
+
51
+ 裁决记忆在 Git 面,删 runtime 不丢(不变量 23 同款测试覆盖)。
@@ -18,7 +18,9 @@
18
18
  buildbeat-v2 resume --config <run-config.yaml>
19
19
  ```
20
20
 
21
- 在途步会以 `crashed` 关闭(事实落账),从最近 checkpoint 继续;带批准恢复时会做 candidate/plan 新鲜度检查,变了即 `APPROVAL_STALE` 转人工。恢复不了就删 runtime 重跑——候选分支与 Git 面记录不丢。
21
+ 在途步会以 `crashed` 关闭(事实落账),然后**重跑该步本身**(beta.3 改):进程死掉不说明候选有问题,丢失的那次尝试照常计入该步预算,预算耗尽即停人工。此前的语义是把 crash 当步骤失败走 failure 边——真实事故(deploy-18):宿主工具超时杀掉 verify worker,crash 被路由去 fix,fixer 面对零 verifier 证据白烧一轮。工作树脏了仍然先停人工。带批准恢复时会做 candidate/plan 新鲜度检查,变了即 `APPROVAL_STALE` 转人工。恢复不了就删 runtime 重跑——候选分支与 Git 面记录不丢。
22
+
23
+ **启动纪律**(同一事故的另一半):长于分钟级的 Run 必须以脱离宿主工具超时的方式启动(`nohup`/`setsid`),交互式 shell 里 `start` 会打印这条提醒。
22
24
 
23
25
  ### 锁卡住("another run is active")
24
26
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "scaffoldVersion": "v1.21",
4
- "cliVersion": "2.0.0-beta.2",
4
+ "cliVersion": "2.0.0-beta.3",
5
5
  "layout": "default",
6
6
  "installedAt": "2026-08-25T00:00:00.000Z",
7
7
  "files": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haiyangbg/buildbeat",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.3",
4
4
  "description": "BuildBeat: a Git-based, human-gated engineering delivery protocol for humans and AI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/v2/cli/run.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // Deliberately thin — all facts live in the event ledger; this file only
4
4
  // parses input, wires adapters, and renders derived state.
5
5
 
6
- import { execFileSync } from "node:child_process";
6
+ import { execFileSync, spawnSync } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
9
+ import { dirname, isAbsolute, join, resolve } from "node:path";
10
10
 
11
11
  import { createShellAdapter } from "../adapters/shell.js";
12
12
  import { loadRiskPreset } from "../engine/risk-preset.js";
@@ -15,9 +15,17 @@ import { parseYamlSubset } from "../engine/yaml-subset.js";
15
15
  import { parsePolicyDoc } from "../policy/policy.js";
16
16
  import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
17
17
  import { acceptArtifact, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
18
+ import { checkRequires } from "../runtime/env-contract.js";
19
+ import {
20
+ adjudicateFinding,
21
+ findingsAccountRef,
22
+ latestAdjudications,
23
+ readFindingsAccount,
24
+ } from "../runtime/findings.js";
18
25
  import { computeMetrics, renderMetrics } from "../runtime/metrics.js";
19
26
  import { writeRunRecord } from "../runtime/run-record.js";
20
27
  import { resumeRun, startRun } from "../runtime/orchestrator.js";
28
+ import { toRepoRef } from "../runtime/repo-ref.js";
21
29
  import { EventLedger } from "../storage/event-ledger.js";
22
30
  import { acquireLock, releaseLock } from "../workspace/workspace-manager.js";
23
31
 
@@ -41,6 +49,9 @@ Usage:
41
49
  run.js observe run --config <observe.yaml>
42
50
  run.js observe status --repo <path>
43
51
  run.js observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
52
+ run.js preflight --config <run-config.yaml> --step <id>
53
+ run.js findings list --repo <path> --work <WORK-ID>
54
+ run.js findings adjudicate --repo <path> --work <WORK-ID> --fingerprint <fp> --action <accept|dismiss> [--by <name>] [--note <text>]
44
55
  `;
45
56
 
46
57
  function parseFlags(argv) {
@@ -82,7 +93,8 @@ function printState(state, ledger) {
82
93
  console.log(`step ${step}: ${info.status} (attempts ${info.attempts})`);
83
94
  }
84
95
  for (const item of state.evidence) {
85
- console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${item.ref}`);
96
+ const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
97
+ console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}`);
86
98
  }
87
99
  if (state.pendingHuman) {
88
100
  console.log(`waiting on human: ${state.pendingHuman.transition}`);
@@ -141,6 +153,10 @@ function loadRunConfig(flags, command) {
141
153
  policies.push(parsePolicyDoc(parseYamlSubset(readFileSync(resolve(configDir, policyPath), "utf8"))));
142
154
  }
143
155
 
156
+ if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
157
+ throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
158
+ }
159
+
144
160
  return {
145
161
  repoRoot,
146
162
  workflow,
@@ -157,6 +173,8 @@ function loadRunConfig(flags, command) {
157
173
  maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
158
174
  stepTimeoutMs: config.stepTimeoutMs,
159
175
  allowedPaths: config.allowedPaths,
176
+ requires: config.requires ?? [],
177
+ reviewTriage: config.reviewTriage === "required" ? "required" : null,
160
178
  planDigest: digestOfWorkFile("plan.md"),
161
179
  intentDigest: digestOfWorkFile("intent.md"),
162
180
  };
@@ -164,8 +182,14 @@ function loadRunConfig(flags, command) {
164
182
 
165
183
  function commandStart(flags) {
166
184
  const options = loadRunConfig(flags, "start");
185
+ if (process.stdout.isTTY) {
186
+ // Run launch discipline (real incident: a host-tool timeout killed a
187
+ // verify worker mid-run): anything longer than minutes belongs in a
188
+ // detached process, not an interactive foreground shell.
189
+ console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
190
+ }
167
191
  const result = startRun(options);
168
- console.log(`ledger: ${result.ledgerPath}`);
192
+ console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
169
193
  printState(result.state, { corruption: null });
170
194
  }
171
195
 
@@ -175,7 +199,7 @@ function commandResume(flags) {
175
199
  if (!result.resumed) {
176
200
  console.log(`nothing to resume: ${result.reason}`);
177
201
  }
178
- console.log(`ledger: ${result.ledgerPath}`);
202
+ console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
179
203
  printState(result.state, { corruption: null });
180
204
  }
181
205
 
@@ -299,6 +323,116 @@ function commandDoctor(flags) {
299
323
  console.log("push protection: repository has no remotes (nothing to protect)");
300
324
  }
301
325
  console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
326
+ if (options.requires.length > 0) {
327
+ console.log("environment contract (requires):");
328
+ const check = checkRequires(options.requires);
329
+ for (const row of check.checked) {
330
+ console.log(` ${row.command}: OK${row.version ? ` (${row.version})` : ""}`);
331
+ }
332
+ for (const problem of check.problems) {
333
+ console.log(` PROBLEM ${problem}`);
334
+ }
335
+ } else {
336
+ console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
337
+ }
338
+ }
339
+
340
+ // Preflight: run one step's configured worker command directly in the main
341
+ // checkout — no worktree, no ledger, no evidence. Minute-level dry loops
342
+ // against the first failure boundary before a full run is what turned the
343
+ // deploy campaign's idle phase around; the output is a dry signal only and a
344
+ // Run must reproduce anything it finds.
345
+ function commandPreflight(flags) {
346
+ const options = loadRunConfig(flags, "preflight");
347
+ if (!flags.step) {
348
+ throw new Error("preflight requires --step");
349
+ }
350
+ const stepDef = options.workflow.steps.find((candidate) => candidate.id === flags.step);
351
+ if (!stepDef) {
352
+ throw new Error(`step not in workflow: ${flags.step}`);
353
+ }
354
+ const spec = stepDef.worker ? options.adapterConfigs[stepDef.worker] : null;
355
+ if (!spec) {
356
+ throw new Error(`step ${flags.step} has no configured worker command to preflight`);
357
+ }
358
+ if (options.requires.length > 0) {
359
+ const check = checkRequires(options.requires);
360
+ for (const problem of check.problems) {
361
+ console.log(`requires PROBLEM: ${problem}`);
362
+ }
363
+ }
364
+ const fill = (text) =>
365
+ String(text)
366
+ .replaceAll("{workspace}", options.repoRoot)
367
+ .replaceAll("{step}", flags.step)
368
+ .replaceAll("{worker}", stepDef.worker);
369
+ const args = (spec.args ?? []).map(fill);
370
+ let env;
371
+ if (spec.inheritEnv === true) {
372
+ env = { ...process.env };
373
+ } else {
374
+ env = {};
375
+ for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"]) {
376
+ if (process.env[key] !== undefined) {
377
+ env[key] = process.env[key];
378
+ }
379
+ }
380
+ }
381
+ Object.assign(env, spec.env ?? {});
382
+ env.BUILDBEAT_PREFLIGHT = "1";
383
+ console.log(`PREFLIGHT (dry signal, never evidence): step ${flags.step} -> ${spec.command} ${args.join(" ")}`);
384
+ console.log(`cwd: main checkout (no worktree, no ledger, no evidence written)`);
385
+ const result = spawnSync(spec.command, args, {
386
+ cwd: options.repoRoot,
387
+ stdio: "inherit",
388
+ env,
389
+ timeout: spec.timeoutMs,
390
+ });
391
+ if (result.error) {
392
+ throw new Error(`preflight could not run the command: ${result.error.message}`);
393
+ }
394
+ const exitCode = result.status ?? 1;
395
+ console.log(`preflight exit=${exitCode} — a Run must reproduce this before it counts`);
396
+ process.exitCode = exitCode;
397
+ }
398
+
399
+ function commandFindings(rest) {
400
+ const [sub, ...args] = rest;
401
+ const flags = parseFlags(args);
402
+ if (sub === "list") {
403
+ if (!flags.repo || !flags.work) {
404
+ throw new Error("findings list requires --repo and --work");
405
+ }
406
+ const rows = readFindingsAccount(resolve(flags.repo), flags.work);
407
+ const findings = rows.filter((row) => row.kind === "finding");
408
+ if (findings.length === 0) {
409
+ console.log(`no recorded findings (${findingsAccountRef(flags.work)})`);
410
+ return;
411
+ }
412
+ const adjudicated = latestAdjudications(rows);
413
+ for (const row of findings) {
414
+ const verdict = adjudicated.get(row.fingerprint);
415
+ const status = verdict ? `${verdict.action} by ${verdict.by}` : "open";
416
+ const reRaised = row.reRaised ? " RE-RAISED" : "";
417
+ console.log(`[${row.severity} ${row.fingerprint}] (${status})${reRaised} ${row.summary}`);
418
+ }
419
+ } else if (sub === "adjudicate") {
420
+ if (!flags.repo || !flags.work || !flags.fingerprint || !flags.action) {
421
+ throw new Error("findings adjudicate requires --repo, --work, --fingerprint and --action");
422
+ }
423
+ const row = adjudicateFinding(resolve(flags.repo), flags.work, {
424
+ fingerprint: flags.fingerprint,
425
+ action: flags.action,
426
+ by: flags.by,
427
+ note: flags.note,
428
+ });
429
+ console.log(`adjudicated ${row.fingerprint} -> ${row.action} ([${row.severity}] ${row.summary})`);
430
+ if (row.action === "dismiss") {
431
+ console.log("dismissed: this fingerprint no longer blocks; an escalated severity reopens on its own");
432
+ }
433
+ } else {
434
+ throw new Error(`findings subcommand must be list|adjudicate, got: ${sub ?? "(none)"}`);
435
+ }
302
436
  }
303
437
 
304
438
  function commandEvents(flags) {
@@ -393,7 +527,7 @@ function commandObserve(rest) {
393
527
  throw new Error("observe run requires --config <observe.yaml>");
394
528
  }
395
529
  const result = runObserveCycle({ configPath: flags.config });
396
- console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerPath})`);
530
+ console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerRef})`);
397
531
  for (const row of result.results) {
398
532
  const bands = row.bands.length > 0 ? ` bands=${row.bands.join(",")}` : "";
399
533
  const intent = row.intent ? ` intent=${row.intent.outcome}:${row.intent.intentRef}` : "";
@@ -445,9 +579,9 @@ function commandObserve(rest) {
445
579
 
446
580
  function main() {
447
581
  const [command, ...rest] = process.argv.slice(2);
448
- if (command === "observe") {
582
+ if (command === "observe" || command === "findings") {
449
583
  try {
450
- commandObserve(rest);
584
+ (command === "observe" ? commandObserve : commandFindings)(rest);
451
585
  } catch (error) {
452
586
  console.error(`error: ${error.message}`);
453
587
  process.exitCode = 1;
@@ -480,6 +614,8 @@ function main() {
480
614
  commandStatus(flags);
481
615
  } else if (command === "stop") {
482
616
  commandStop(flags);
617
+ } else if (command === "preflight") {
618
+ commandPreflight(flags);
483
619
  } else {
484
620
  process.stdout.write(USAGE);
485
621
  process.exitCode = command ? 2 : 0;
@@ -1,6 +1,8 @@
1
1
  // Initial event type registry v1 per docs/v2/SPEC-0001-events-v1.md §4.
2
2
  // Semantics are frozen; the registry and per-type data may only grow additively.
3
3
 
4
+ import { isAbsolute } from "node:path";
5
+
4
6
  import {
5
7
  ACTOR_KINDS,
6
8
  BUDGET_KINDS,
@@ -58,6 +60,23 @@ const EVENT_ENUMS = {
58
60
  TRIAGE_RECORDED: { action: TRIAGE_ACTIONS },
59
61
  };
60
62
 
63
+ const REPO_REF_FIELDS = {
64
+ WORKSPACE_BOUND: ["repo", "worktreePath"],
65
+ EVIDENCE_RECORDED: ["evidenceRef"],
66
+ RUN_COMPACTED: ["runRecordRef"],
67
+ INTENT_DRAFTED: ["intentRef"],
68
+ TRIAGE_RECORDED: ["intentRef"],
69
+ };
70
+
71
+ function unsafeRepoRef(value) {
72
+ return (
73
+ typeof value !== "string" ||
74
+ value.length === 0 ||
75
+ isAbsolute(value) ||
76
+ /(^|[\\/])\.\.([\\/]|$)/.test(value)
77
+ );
78
+ }
79
+
61
80
  export class EventInputError extends Error {
62
81
  constructor(message) {
63
82
  super(message);
@@ -87,6 +106,11 @@ export function validateEventInput(type, actor, data) {
87
106
  throw new EventInputError(`event ${type} missing required data field: ${field}`);
88
107
  }
89
108
  }
109
+ for (const field of REPO_REF_FIELDS[type] ?? []) {
110
+ if (unsafeRepoRef(data[field])) {
111
+ throw new EventInputError(`event ${type} field ${field} must be a repository-relative reference`);
112
+ }
113
+ }
90
114
  const enums = EVENT_ENUMS[type];
91
115
  if (enums) {
92
116
  for (const [field, allowed] of Object.entries(enums)) {
@@ -140,6 +140,9 @@ export function applyEvent(state, event) {
140
140
  status: data.status,
141
141
  grade: data.grade,
142
142
  ...(data.findings ? { findings: data.findings } : {}),
143
+ ...(data.suppressedFingerprints
144
+ ? { suppressedFingerprints: data.suppressedFingerprints }
145
+ : {}),
143
146
  });
144
147
  break;
145
148
  }
@@ -22,6 +22,7 @@ import { collectCommandEvidence } from "../evidence/collector.js";
22
22
  import { EventLedger } from "../storage/event-ledger.js";
23
23
  import { loadObserveConfig, severityRank } from "./observe-config.js";
24
24
  import { OBSERVE_REDUCER } from "./observe-reducer.js";
25
+ import { toRepoRef } from "../runtime/repo-ref.js";
25
26
 
26
27
  const OBSERVE_IDS = { run: "OBSERVE", work: "OBSERVE" };
27
28
  const KERNEL_ACTOR = { kind: "kernel", id: "observe" };
@@ -142,7 +143,7 @@ function recordEvidence({ ledger, config, cycle, provider, execResult, kind, ste
142
143
  type: "EVIDENCE_RECORDED",
143
144
  actor: { kind: "provider", id: provider.id },
144
145
  data: {
145
- evidenceRef: record.location,
146
+ evidenceRef: toRepoRef(config.repoRoot, record.location),
146
147
  kind: record.kind,
147
148
  subject: record.subject,
148
149
  digest: record.digest,
@@ -350,7 +351,13 @@ export function runObserveCycle({ configPath, now = new Date().toISOString() })
350
351
  data: { cycle, providersRun: config.providers.length },
351
352
  ...OBSERVE_IDS,
352
353
  });
353
- return { cycle, ledgerPath: ledger.path, results, state: ledger.state };
354
+ return {
355
+ cycle,
356
+ ledgerPath: ledger.path,
357
+ ledgerRef: toRepoRef(config.repoRoot, ledger.path),
358
+ results,
359
+ state: ledger.state,
360
+ };
354
361
  }
355
362
 
356
363
  export function triageIntent({ repoRoot, intentRef, action, by = "unknown", note, now = new Date().toISOString() }) {
@@ -37,3 +37,8 @@ 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
@@ -13,6 +13,7 @@ import { evaluatePolicies, sha256Text } from "../policy/policy.js";
13
13
  import { EventLedger } from "../storage/event-ledger.js";
14
14
  import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
15
15
  import { writeRunRecord } from "./run-record.js";
16
+ import { resolveRepoRef } from "./repo-ref.js";
16
17
 
17
18
  const KERNEL = { kind: "kernel", id: "orchestrator" };
18
19
 
@@ -68,10 +69,11 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
68
69
  acquireLock(repoRoot, runId);
69
70
  try {
70
71
  const bound = ledger.state.workspaces[runId];
71
- if (!bound || !existsSync(bound.worktreePath)) {
72
+ const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
73
+ if (!bound || !existsSync(worktreePath)) {
72
74
  throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
73
75
  }
74
- const tree = readback(bound.worktreePath);
76
+ const tree = readback(worktreePath);
75
77
  const when = ts ?? new Date().toISOString();
76
78
  if (tree.dirty || tree.head !== pending.subject.candidate) {
77
79
  const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
@@ -102,8 +104,8 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
102
104
  state: ledger.state,
103
105
  candidate: pending.subject.candidate,
104
106
  workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
105
- worktreePath: bound.worktreePath,
106
- readWorktree: () => readback(bound.worktreePath),
107
+ worktreePath,
108
+ readWorktree: () => readback(worktreePath),
107
109
  },
108
110
  );
109
111
  for (const row of policyRows) {
@@ -0,0 +1,101 @@
1
+ // Environment contract: a run config may declare the binaries (and minimum
2
+ // versions) its frozen envelope silently depends on, and the kernel checks
3
+ // them fail-closed before a run starts. Absorbed from real incidents: a
4
+ // frozen verifier needed `rg` that only a vendored PATH provided, candidate
5
+ // scripts assumed bash >= 4 on a /bin/bash 3.2 host, and a fresh shell
6
+ // resolved Node 14 — each burned runs before anyone saw the real cause.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ export class EnvContractError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "EnvContractError";
14
+ }
15
+ }
16
+
17
+ // Version detection in --version output needs at least major.minor (a bare
18
+ // integer in arbitrary output is too easy to false-match); a declared min
19
+ // may be major-only ("20").
20
+ function parseVersion(text) {
21
+ const match = String(text).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
22
+ if (!match) {
23
+ return null;
24
+ }
25
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
26
+ }
27
+
28
+ function parseMin(value) {
29
+ const match = String(value).trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
30
+ if (!match) {
31
+ return null;
32
+ }
33
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
34
+ }
35
+
36
+ function compareVersions(left, right) {
37
+ for (let index = 0; index < 3; index += 1) {
38
+ if ((left[index] ?? 0) !== (right[index] ?? 0)) {
39
+ return (left[index] ?? 0) - (right[index] ?? 0);
40
+ }
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ // Checks every entry and reports all problems at once (a run that dies on
46
+ // the first missing binary hides the second). Non-zero --version exits are
47
+ // tolerated; only a spawn failure means "not runnable".
48
+ export function checkRequires(requires) {
49
+ const problems = [];
50
+ const checked = [];
51
+ for (const entry of requires ?? []) {
52
+ if (!entry || typeof entry !== "object" || typeof entry.command !== "string" || entry.command.length === 0) {
53
+ problems.push(`requires entries need a command name, got: ${JSON.stringify(entry)}`);
54
+ continue;
55
+ }
56
+ // A generous timeout: a missing binary fails instantly (ENOENT), while a
57
+ // loaded host must not turn a present binary into a false "not runnable".
58
+ const probe = spawnSync(entry.command, [entry.versionFlag ?? "--version"], {
59
+ encoding: "utf8",
60
+ timeout: 30_000,
61
+ });
62
+ if (probe.error) {
63
+ problems.push(
64
+ `${entry.command}: not runnable in this environment (${probe.error.code ?? probe.error.message})`,
65
+ );
66
+ continue;
67
+ }
68
+ const version = parseVersion(`${probe.stdout ?? ""}\n${probe.stderr ?? ""}`);
69
+ if (entry.min !== undefined) {
70
+ const min = parseMin(entry.min);
71
+ if (!min) {
72
+ problems.push(`${entry.command}: min ${JSON.stringify(entry.min)} is not a version`);
73
+ continue;
74
+ }
75
+ if (!version) {
76
+ problems.push(
77
+ `${entry.command}: version undetectable but min ${entry.min} is required (fail closed)`,
78
+ );
79
+ continue;
80
+ }
81
+ if (compareVersions(version, min) < 0) {
82
+ problems.push(
83
+ `${entry.command}: resolves to ${version.join(".")}, below required ${entry.min}`,
84
+ );
85
+ continue;
86
+ }
87
+ }
88
+ checked.push({ command: entry.command, version: version ? version.join(".") : null });
89
+ }
90
+ return { ok: problems.length === 0, problems, checked };
91
+ }
92
+
93
+ export function assertRequires(requires) {
94
+ const result = checkRequires(requires);
95
+ if (!result.ok) {
96
+ throw new EnvContractError(
97
+ `environment contract not satisfied:\n - ${result.problems.join("\n - ")}`,
98
+ );
99
+ }
100
+ return result;
101
+ }
@@ -0,0 +1,158 @@
1
+ // Review findings account per Work (Git plane, invariant 23: runtime stays
2
+ // deletable). Every reviewer finding lands as a row keyed by a fingerprint;
3
+ // human adjudications (accept/dismiss) append rows bound to that fingerprint.
4
+ // A dismissed fingerprint no longer blocks and later reviewers receive the
5
+ // adjudicated history as an anchor — re-litigating a settled verdict takes a
6
+ // human decision, not a louder fresh reviewer. (Absorbed from the 30-run
7
+ // deploy campaign: memoryless fresh reviewers oscillated between mutually
8
+ // exclusive prescriptions and re-litigated accepted designs.)
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ export const ADJUDICATION_ACTIONS = ["accept", "dismiss"];
15
+
16
+ export class FindingsError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "FindingsError";
20
+ }
21
+ }
22
+
23
+ export function findingsAccountRef(workId) {
24
+ return `delivery/work/${workId}/review-findings.jsonl`;
25
+ }
26
+
27
+ function accountPath(repoRoot, workId) {
28
+ return join(repoRoot, "delivery", "work", workId, "review-findings.jsonl");
29
+ }
30
+
31
+ // Same-text findings from different fresh reviewers must collide; severity is
32
+ // part of the identity so an escalation (P2 -> P0) reopens on its own.
33
+ export function fingerprintFinding(finding) {
34
+ const normalized = `${finding.severity}|${finding.summary}`
35
+ .toLowerCase()
36
+ .replace(/\s+/g, " ")
37
+ .trim();
38
+ return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16);
39
+ }
40
+
41
+ export function readFindingsAccount(repoRoot, workId) {
42
+ const filePath = accountPath(repoRoot, workId);
43
+ if (!existsSync(filePath)) {
44
+ return [];
45
+ }
46
+ const rows = [];
47
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
48
+ if (line.length === 0) {
49
+ continue;
50
+ }
51
+ try {
52
+ rows.push(JSON.parse(line));
53
+ } catch {
54
+ throw new FindingsError(`findings account has an invalid line: ${filePath}`);
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ function appendRow(repoRoot, workId, row) {
61
+ const dir = join(repoRoot, "delivery", "work", workId);
62
+ mkdirSync(dir, { recursive: true });
63
+ appendFileSync(join(dir, "review-findings.jsonl"), `${JSON.stringify(row)}\n`, "utf8");
64
+ }
65
+
66
+ // Latest adjudication wins per fingerprint.
67
+ export function latestAdjudications(rows) {
68
+ const map = new Map();
69
+ for (const row of rows) {
70
+ if (row.kind === "adjudication") {
71
+ map.set(row.fingerprint, row);
72
+ }
73
+ }
74
+ return map;
75
+ }
76
+
77
+ // Records one review attempt's findings into the account. Fingerprints that
78
+ // already have a finding row are not duplicated; a finding whose fingerprint
79
+ // a human dismissed is recorded as a re-raise attempt so the oscillation is
80
+ // visible in the account, not just absent from the routing.
81
+ export function recordReviewFindings(repoRoot, workId, { run, step, attempt, findings, ts }) {
82
+ const rows = readFindingsAccount(repoRoot, workId);
83
+ const adjudicated = latestAdjudications(rows);
84
+ const known = new Set(rows.filter((row) => row.kind === "finding").map((row) => row.fingerprint));
85
+ const recorded = [];
86
+ for (const [index, finding] of findings.entries()) {
87
+ const fingerprint = fingerprintFinding(finding);
88
+ const dismissed = adjudicated.get(fingerprint)?.action === "dismiss";
89
+ if (known.has(fingerprint) && !dismissed) {
90
+ continue;
91
+ }
92
+ const row = {
93
+ ts,
94
+ kind: "finding",
95
+ run,
96
+ step,
97
+ attempt,
98
+ id: `F-${run}-${step}-${attempt}-${index + 1}`,
99
+ severity: finding.severity,
100
+ summary: finding.summary,
101
+ fingerprint,
102
+ ...(dismissed ? { reRaised: true } : {}),
103
+ };
104
+ appendRow(repoRoot, workId, row);
105
+ known.add(fingerprint);
106
+ recorded.push(row);
107
+ }
108
+ return recorded;
109
+ }
110
+
111
+ export function adjudicateFinding(repoRoot, workId, { fingerprint, action, by, note, ts }) {
112
+ if (!ADJUDICATION_ACTIONS.includes(action)) {
113
+ throw new FindingsError(`action must be one of ${ADJUDICATION_ACTIONS.join("|")}, got: ${action}`);
114
+ }
115
+ const rows = readFindingsAccount(repoRoot, workId);
116
+ const finding = rows.find((row) => row.kind === "finding" && row.fingerprint === fingerprint);
117
+ if (!finding) {
118
+ throw new FindingsError(
119
+ `no recorded finding with fingerprint ${fingerprint}; adjudications bind to recorded findings only`,
120
+ );
121
+ }
122
+ const row = {
123
+ ts: ts ?? new Date().toISOString(),
124
+ kind: "adjudication",
125
+ fingerprint,
126
+ action,
127
+ by: by ?? "human",
128
+ ...(note ? { note } : {}),
129
+ };
130
+ appendRow(repoRoot, workId, row);
131
+ return { ...row, summary: finding.summary, severity: finding.severity };
132
+ }
133
+
134
+ // Compact anchor for worker input (BUILDBEAT_INPUT): the full adjudicated
135
+ // history plus open findings, capped so the env payload stays small.
136
+ const ANCHOR_CAP = 50;
137
+ const SUMMARY_CAP = 300;
138
+
139
+ export function buildAnchor(repoRoot, workId) {
140
+ const rows = readFindingsAccount(repoRoot, workId);
141
+ if (rows.length === 0) {
142
+ return null;
143
+ }
144
+ const adjudicated = latestAdjudications(rows);
145
+ const entries = rows
146
+ .filter((row) => row.kind === "finding")
147
+ .map((row) => ({
148
+ fingerprint: row.fingerprint,
149
+ severity: row.severity,
150
+ summary:
151
+ row.summary.length > SUMMARY_CAP ? `${row.summary.slice(0, SUMMARY_CAP)}…` : row.summary,
152
+ adjudication: adjudicated.get(row.fingerprint)?.action ?? "open",
153
+ }));
154
+ return {
155
+ account: findingsAccountRef(workId),
156
+ findings: entries.slice(-ANCHOR_CAP),
157
+ };
158
+ }
@@ -26,6 +26,15 @@ import {
26
26
  releaseLock,
27
27
  } from "../workspace/workspace-manager.js";
28
28
  import { writeRunRecord } from "./run-record.js";
29
+ import { assertRequires } from "./env-contract.js";
30
+ import {
31
+ buildAnchor,
32
+ fingerprintFinding,
33
+ latestAdjudications,
34
+ readFindingsAccount,
35
+ recordReviewFindings,
36
+ } from "./findings.js";
37
+ import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
29
38
 
30
39
  const KERNEL = { kind: "kernel", id: "orchestrator" };
31
40
 
@@ -135,6 +144,7 @@ function makeContext(options, ledger, workspace) {
135
144
  workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
136
145
  context.policies = options.policies ?? [];
137
146
  context.allowedPaths = options.allowedPaths ?? null;
147
+ context.reviewTriage = options.reviewTriage ?? null;
138
148
  context.policyCtx = () => ({
139
149
  state: ledger.state,
140
150
  candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
@@ -219,6 +229,14 @@ function settleOutcome(context, step, outcome, tree, exec) {
219
229
  ]);
220
230
  return null;
221
231
  }
232
+ // A step that failed its final attempt can never run again, so routing
233
+ // to fix would spend a worker on a candidate nothing can verify.
234
+ if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
235
+ context.waitHuman(`resume-${step}`, [
236
+ `budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
237
+ ]);
238
+ return null;
239
+ }
222
240
  }
223
241
  const to = nextStep(workflow, step, outcome);
224
242
  let result = "PASS";
@@ -337,11 +355,35 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
337
355
  const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
338
356
  mkdirSync(outputsDir, { recursive: true });
339
357
  const outputPath = join(outputsDir, `${step}-${attempt}.json`);
358
+ // Anchored review: readonly (reviewer) steps receive the adjudicated
359
+ // findings history so a fresh reviewer inherits settled verdicts instead
360
+ // of re-litigating them; writing steps get the latest review findings
361
+ // with their adjudication status (the fixer's worklist).
362
+ const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
363
+ const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
364
+ if (anchor && stepDef.readonly) {
365
+ input.anchor = anchor;
366
+ } else if (anchor) {
367
+ const lastReview = [...ledger.state.evidence]
368
+ .reverse()
369
+ .find((item) => item.kind === "review");
370
+ if (lastReview?.findings?.length) {
371
+ const adjudicated = latestAdjudications(
372
+ readFindingsAccount(context.repoRoot, ledger.state.run.work),
373
+ );
374
+ input.findings = lastReview.findings.map((finding) => ({
375
+ severity: finding.severity,
376
+ summary: finding.summary,
377
+ fingerprint: fingerprintFinding(finding),
378
+ adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
379
+ }));
380
+ }
381
+ }
340
382
  const exec = adapter.execute({
341
383
  step,
342
384
  worker: stepDef.worker,
343
385
  workspacePath: workspace.worktreePath,
344
- input: { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt },
386
+ input,
345
387
  timeoutMs: context.stepTimeoutMs,
346
388
  outputPath,
347
389
  });
@@ -359,7 +401,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
359
401
  actor: KERNEL,
360
402
  ts: now(),
361
403
  data: {
362
- evidenceRef: evidence.location,
404
+ evidenceRef: toRepoRef(context.repoRoot, evidence.location),
363
405
  kind: evidence.kind,
364
406
  subject: evidence.subject,
365
407
  digest: evidence.digest,
@@ -432,21 +474,39 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
432
474
 
433
475
  let blockingFindings = [];
434
476
  if (envelope?.findings) {
435
- blockingFindings = envelope.findings.filter(
436
- (finding) => finding.severity === "P0" || finding.severity === "P1",
477
+ recordReviewFindings(context.repoRoot, ledger.state.run.work, {
478
+ run: ledger.state.run.id,
479
+ step,
480
+ attempt,
481
+ findings: envelope.findings,
482
+ ts: now(),
483
+ });
484
+ // A fingerprint a human dismissed stays visible in the evidence but no
485
+ // longer blocks: settled verdicts do not reopen without a human.
486
+ const adjudicated = latestAdjudications(
487
+ readFindingsAccount(context.repoRoot, ledger.state.run.work),
437
488
  );
489
+ const suppressed = [];
490
+ blockingFindings = envelope.findings.filter((finding) => {
491
+ if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
492
+ suppressed.push(fingerprintFinding(finding));
493
+ return false;
494
+ }
495
+ return finding.severity === "P0" || finding.severity === "P1";
496
+ });
438
497
  ledger.append({
439
498
  type: "EVIDENCE_RECORDED",
440
499
  actor: KERNEL,
441
500
  ts: now(),
442
501
  data: {
443
- evidenceRef: outputPath,
502
+ evidenceRef: toRepoRef(context.repoRoot, outputPath),
444
503
  kind: "review",
445
504
  subject: tree.head,
446
505
  digest: sha256(canonicalJson(envelope)),
447
506
  status: blockingFindings.length > 0 ? "failed" : "passed",
448
507
  grade: "L2",
449
508
  findings: envelope.findings,
509
+ ...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
450
510
  },
451
511
  });
452
512
  }
@@ -531,7 +591,29 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
531
591
  } else {
532
592
  outcome = "succeeded";
533
593
  }
534
- step = settleOutcome(context, step, outcome, tree, exec);
594
+ const routed = settleOutcome(context, step, outcome, tree, exec);
595
+ // Finding triage gate (reviewTriage: required): blocking findings stop
596
+ // for a human verdict before any fixer runs. Findings are prescriptions,
597
+ // not facts — auto-routing them to a fixer burned four oscillation
598
+ // rounds in the deploy campaign before a human stopped the loop.
599
+ if (routed && outcome === "findings-blocking" && context.reviewTriage === "required") {
600
+ context.waitHuman(
601
+ `enter-${routed}`,
602
+ [
603
+ `review found ${blockingFindings.length} blocking finding(s); triage before ${routed} runs`,
604
+ ...blockingFindings
605
+ .slice(0, 5)
606
+ .map(
607
+ (finding) =>
608
+ `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
609
+ ),
610
+ `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
611
+ ],
612
+ "finding-triage",
613
+ );
614
+ return;
615
+ }
616
+ step = routed;
535
617
  }
536
618
  }
537
619
 
@@ -568,6 +650,11 @@ export function startRun(options) {
568
650
  if (!workflowDigest) {
569
651
  throw new OrchestratorError("workflowDigest is required (pin what you run)");
570
652
  }
653
+ // Environment contract first: a missing or too-old binary fails the start
654
+ // with a readable cause instead of burning a run on an implicit PATH fact.
655
+ if (options.requires?.length) {
656
+ assertRequires(options.requires);
657
+ }
571
658
  const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
572
659
  if (ledger.events.length > 0) {
573
660
  throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
@@ -600,9 +687,9 @@ export function startRun(options) {
600
687
  ts: now(),
601
688
  data: {
602
689
  workspaceId: workspace.workspaceId,
603
- repo: repoRoot,
690
+ repo: toRepoRef(repoRoot, repoRoot),
604
691
  branch: workspace.branch,
605
- worktreePath: workspace.worktreePath,
692
+ worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
606
693
  base: workspace.base,
607
694
  },
608
695
  });
@@ -626,6 +713,9 @@ export function resumeRun(options) {
626
713
  if (!repoRoot || !runId) {
627
714
  throw new OrchestratorError("repoRoot and runId are required");
628
715
  }
716
+ if (options.requires?.length) {
717
+ assertRequires(options.requires);
718
+ }
629
719
  const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
630
720
  const state = ledger.state;
631
721
  if (!state.run) {
@@ -647,15 +737,16 @@ export function resumeRun(options) {
647
737
  if (!bound) {
648
738
  throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
649
739
  }
650
- if (!existsSync(bound.worktreePath)) {
740
+ const worktreePath = resolveRepoRef(repoRoot, bound.worktreePath);
741
+ if (!existsSync(worktreePath)) {
651
742
  throw new OrchestratorError(
652
- `worktree missing: ${bound.worktreePath}; recovery requires a human decision`,
743
+ "worktree missing; recovery requires a human decision",
653
744
  );
654
745
  }
655
746
  const workspace = {
656
747
  workspaceId: runId,
657
748
  repoRoot,
658
- worktreePath: bound.worktreePath,
749
+ worktreePath,
659
750
  branch: bound.branch,
660
751
  base: bound.base,
661
752
  };
@@ -727,12 +818,12 @@ export function resumeRun(options) {
727
818
  ]);
728
819
  return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
729
820
  }
730
- startStep = settleOutcome(context, step, "failed", tree, {
731
- command: "(interrupted)",
732
- exitCode: null,
733
- stdout: "",
734
- stderr: "process lost before completion",
735
- });
821
+ // An interrupted attempt says nothing about the candidate, so the step
822
+ // itself reruns (the lost attempt still counts against its budget)
823
+ // instead of settling as a step failure — routing a crash through the
824
+ // failure edge dispatched a fixer with no verifier evidence (real
825
+ // incident: deploy-18's verify worker was killed by a host timeout).
826
+ startStep = step;
736
827
  } else if (tree.dirty) {
737
828
  context.waitHuman("resume-run", [
738
829
  "worktree is dirty at resume with no step in flight; human triage required",
@@ -0,0 +1,38 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ function outsideRepo(ref) {
4
+ return ref === ".." || ref.startsWith(`..${sep}`) || isAbsolute(ref);
5
+ }
6
+
7
+ // Runtime operations use absolute paths, but ledger/run-record references are
8
+ // durable evidence and must not capture host-specific checkout locations.
9
+ // Keep those references repository-relative and fail closed if a caller tries
10
+ // to publish something outside the repository.
11
+ export function toRepoRef(repoRoot, targetPath) {
12
+ const root = resolve(repoRoot);
13
+ const target = isAbsolute(targetPath) ? resolve(targetPath) : resolve(root, targetPath);
14
+ const ref = relative(root, target);
15
+ if (outsideRepo(ref)) {
16
+ throw new Error("runtime reference is outside the repository");
17
+ }
18
+ return ref === "" ? "." : ref.split(sep).join("/");
19
+ }
20
+
21
+ // Compatibility boundary: ledgers written before repo-relative references
22
+ // contain absolute paths. They remain readable, while all newly written
23
+ // events and compacted records use toRepoRef().
24
+ export function resolveRepoRef(repoRoot, ref) {
25
+ if (typeof ref !== "string" || ref === "") {
26
+ throw new Error("runtime reference must be a non-empty string");
27
+ }
28
+ const root = resolve(repoRoot);
29
+ const target = isAbsolute(ref) ? resolve(ref) : resolve(root, ref);
30
+ if (outsideRepo(relative(root, target))) {
31
+ throw new Error("runtime reference is outside the repository");
32
+ }
33
+ return target;
34
+ }
35
+
36
+ export function normalizeRepoRef(repoRoot, ref) {
37
+ return toRepoRef(repoRoot, resolveRepoRef(repoRoot, ref));
38
+ }
@@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
8
8
  import { join, relative } from "node:path";
9
9
 
10
10
  import { canonicalJson } from "../storage/event-ledger.js";
11
+ import { normalizeRepoRef } from "./repo-ref.js";
11
12
 
12
13
  const KERNEL = { kind: "kernel", id: "orchestrator" };
13
14
 
@@ -22,6 +23,20 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
22
23
  for (const [step, info] of Object.entries(state.steps)) {
23
24
  attempts[step] = info.attempts;
24
25
  }
26
+ const workspaces = Object.fromEntries(
27
+ Object.entries(state.workspaces).map(([id, workspace]) => [
28
+ id,
29
+ {
30
+ ...workspace,
31
+ repo: normalizeRepoRef(repoRoot, workspace.repo),
32
+ worktreePath: normalizeRepoRef(repoRoot, workspace.worktreePath),
33
+ },
34
+ ]),
35
+ );
36
+ const evidence = state.evidence.map((item) => ({
37
+ ...item,
38
+ ref: normalizeRepoRef(repoRoot, item.ref),
39
+ }));
25
40
  const record = {
26
41
  run: first.run,
27
42
  work: first.work,
@@ -31,11 +46,11 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
31
46
  finishedAt: last.ts,
32
47
  attempts,
33
48
  budgets: state.budgets,
34
- workspaces: state.workspaces,
35
- evidence: state.evidence,
49
+ workspaces,
50
+ evidence,
36
51
  decisions: state.decisions,
37
52
  approvals: state.approvals,
38
- unverified: state.evidence
53
+ unverified: evidence
39
54
  .filter((item) => item.status === "unverified")
40
55
  .map((item) => item.ref),
41
56
  };
@@ -19,7 +19,10 @@ function git(cwd, args) {
19
19
  return execFileSync("git", ["-C", cwd, ...args], {
20
20
  encoding: "utf8",
21
21
  stdio: ["ignore", "pipe", "pipe"],
22
- }).trim();
22
+ // Preserve porcelain's fixed-width leading status columns. Trimming the
23
+ // first leading space turns " M file" into "M file" and makes the
24
+ // downstream slice(3) drop the first path character.
25
+ }).trimEnd();
23
26
  } catch (error) {
24
27
  const stderr = error.stderr ? String(error.stderr).trim() : error.message;
25
28
  throw new WorkspaceError(`git ${args.join(" ")} failed: ${stderr}`);