@haiyangbg/buildbeat 4.0.0 → 4.1.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,10 +2,17 @@
2
2
 
3
3
  > 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
4
4
 
5
- ## Unreleased
5
+ ## v4.1.0 — 2026-10-07(次版本:跨仓总览;提示里的命令与路径都能照做)
6
+
7
+ - Add read-only `status --all-repos`: discover the main repository, configured target checkouts and immediate child repositories, deduplicate real paths, show pending decisions first and count settled work. JSON retains all work; `--work` filters across repositories, while `--run` requires single-repository status.
8
+ - Fix main-repository status/overview reporting target-repository work as ready to start: read state, findings and decisions from the target, retain the main run configuration for commands, and quote paths relative to the caller. Missing/invalid targets warn without hiding other work. Ship `src/v2/runtime/overview-repos.js` and update the package-file inventory for this runtime helper.
9
+ - Hints give commands that work as written. A merged work whose run configs were all read and none has a `release:` section is told there is nothing left to do, instead of being sent to `buildbeat release`, which fails without that section and left the work unable to close; with a `release:` section the hint names the config that has it. A waiting run with no reply commands points to `status --work`, and `check` names `status` instead of the 3.x `inbox`. The run config template comment (and its copies in the Skill, both quickstarts and the example) names `check` / `run` / `--new`, and the quickstart says where the npm package installs the templates. Found by a first run of a fresh project on 4.0.0 with real codex workers.
10
+ - `status --all-repos` prints English labels like the rest of the CLI (`pending decisions · <repo>`, `repo <repo>:`, `<repo>: settled N (closed a · cancelled b · merged, no release step c)`, `repo:` / `runs in:` under a work), and single-repository status again shows `NO_INTENT` for a work directory without a work description even when it has runs, as 4.0.0 did; a work known only from runtime ledgers still shows its run.
6
11
 
7
12
  ## v4.0.0 — 2026-10-04(主版本:聚焦可恢复交付,精简产品范围与操作入口)
8
13
 
14
+ > **发布状态**:`@haiyangbg/buildbeat@4.0.0` 已于 2026-10-05 从 `main`(PR #70、#71 内容,release PR #72,merge commit `c15fbe0`,tag `v4.0.0`)经 OIDC Trusted Publishing 发布到 dist-tag **`latest`**(run 37262387333,publish 与 verify 双 job 一次 success;所有者授权「合并并发 4.0」)。独立回读(直连 npmjs.org):`latest` = 4.0.0、`dist.integrity` 与本地用 Node 24 + npm 11.19.0 打的候选逐字一致、SLSA v1 provenance、隔离安装 `--version` = 4.0.0、裸调用零写入、用法含 `release` 与 `decide --action close`、`npm audit signatures` 通过;GitHub Release v4.0.0 标 Latest;本机两份全局安装均为 4.0.0;证据见 [`docs/releases/V4.0.0-RELEASE-EVIDENCE-2026-10-05.md`](docs/releases/V4.0.0-RELEASE-EVIDENCE-2026-10-05.md)。同日所有者本人经 npm 两步验证把 dist-tag `next` 从 3.3.1 挪到 4.0.0;直连 npmjs.org 回读 `next` = `latest` = 4.0.0。
15
+
9
16
  - Owner decisions before 4.0 (WORK-4.0-INTENT-FOLLOWUPS): `requireScreenshot: true` makes verify write PNG screenshots of the real render to `BUILDBEAT_SCREENSHOT_DIR`, records them as candidate-bound evidence after checking each decodes, fails a verify that leaves none, requires them in the merge check and lists them for the reviewer, the decision card and notifications; `buildbeat release` runs the project's read-only readback after a merged release and records it in `delivery/work/<ID>/releases.jsonl`, and `decide --action close` closes the window only on a passing readback; a one-page guide states what people and tools without the runtime may read and write; the governance-template rationale is corrected.
10
17
 
11
18
  - Focus the product on portable work context, the recoverable delivery loop, and evidence-backed human decisions.
package/SKILL.md CHANGED
@@ -20,7 +20,7 @@ description: BuildBeat 用项目文件接续上下文,在隔离工作树内自
20
20
 
21
21
  | 用户意图 | 操作 |
22
22
  |---|---|
23
- | 接手、查看进度、有什么待批 | `buildbeat status --repo .`;指定 `--work` 或 `--run` 查看细节 |
23
+ | 接手、查看进度、有什么待批 | `buildbeat status --repo .`;主仓的 run 配置中 `repo:` 指向其他仓时,查看全部代码仓加 `--all-repos`(运行时 4.1 起;可加 `--work`、`--json`);单仓指定 `--work` 或 `--run` 查看细节 |
24
24
  | 准备一项工作 | 写 `delivery/work/<ID>/work.md`,包含目标、范围、验收、实施计划;复制 run-config 样板并填真实命令 |
25
25
  | 接受工作说明 | `buildbeat accept --repo . --work <ID> --by <owner>`;用户已有明确授权时直接记录,不重复问同一个决定 |
26
26
  | 开工、继续 | `buildbeat run --config <config>`;首次编号,已有运行则恢复;明确新一轮时加 `--new` |
@@ -54,10 +54,10 @@ description: BuildBeat 用项目文件接续上下文,在隔离工作树内自
54
54
  ```yaml
55
55
  # BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
56
56
  # 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
57
- # 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
57
+ # 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
58
58
  repo: ../../..
59
59
  work: WORK-X
60
- # 家族名;--attempt new 自动编成 RUN-X-01/02…
60
+ # 家族名;run 自动编成 RUN-X-01/02…,--new 开新一轮
61
61
  run: RUN-X
62
62
  # builder / fixer 只能改这些目录;越界改动不成为候选
63
63
  allowedPaths:
@@ -1,6 +1,6 @@
1
1
  # BuildBeat capability matrix / 能力矩阵
2
2
 
3
- Current release: 4.0.0. One product path: Skill → local runtime →
3
+ Current release: 4.1.0. One product path: Skill → local runtime →
4
4
  project records. The Claude Code plugin distributes the Skill and references;
5
5
  install the runtime separately. Files remain readable without the runtime, but
6
6
  manual work has no automatic loop, isolation, budget or approval enforcement;
@@ -15,7 +15,7 @@ may be written by hand.
15
15
  | Trusted result | Real command evidence, current candidate, read-only review | Script tests do not prove real AI business capability |
16
16
  | Decisions | Acceptance, exact approval, frozen checks, budget/convergence stops | No automatic merge, push or deployment |
17
17
  | Efficiency | Verify cache, review range hints, parallel Work isolation | External test state must be isolated and stable |
18
- | Feedback | Unified status/cost/decisions, liveness, webhook/DingTalk | Notifications do not approve |
18
+ | Feedback | Unified status/cost/decisions, also across a main repository and its code repositories (`--all-repos`), liveness, webhook/DingTalk | Notifications do not approve |
19
19
  | UI evidence | `requireScreenshot`: screenshots from verify, bound to the candidate and required at merge | The project's verify renders and captures |
20
20
  | Release closeout | `release` records the project's readback in the Work; `decide --action close` on a passing readback | No deployment; observation stays with project monitoring |
21
21
  | Maintenance | Check, history/replay, safe gc | Not a production monitoring service |
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@4.0.0` (release candidate awaiting publication; the dated verified distribution below remains current until its readback); latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@3.3.1` (dist-tags `latest` and `next`; the owner moved `next` from `3.3.0` to `3.3.1` with npm 2FA on 2026-10-02), anchored by annotated tag `v3.3.1` at commit `cc7a6db`, workflow run [36982501415](https://github.com/HaiYangBG1/BuildBeat/actions/runs/36982501415), and archived in [`V3.3.1-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.1-RELEASE-EVIDENCE-2026-10-02.md). The 3.3.0 chain (`latest` on 2026-10-02 until 3.3.1 took over the same day; the owner moved `next` from `3.2.1` to `3.3.0` with npm 2FA that day) stays archived in [`V3.3.0-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.0-RELEASE-EVIDENCE-2026-10-02.md). The 3.2.1 chain (`latest` from 2026-09-26 until 3.3.0 took over on 2026-10-02; the owner moved `next` from `3.0.1` to `3.2.1` with npm 2FA on 2026-09-26) stays 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/`.
5
+ Release evidence at source package version `@haiyangbg/buildbeat@4.1.0`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@4.0.0` (dist-tags `latest` and `next`; the owner moved `next` from `3.3.1` to `4.0.0` with npm 2FA on 2026-10-05), anchored by annotated tag `v4.0.0` at commit `c15fbe0`, workflow run [37262387333](https://github.com/HaiYangBG1/BuildBeat/actions/runs/37262387333), and archived in [`V4.0.0-RELEASE-EVIDENCE-2026-10-05.md`](releases/V4.0.0-RELEASE-EVIDENCE-2026-10-05.md). The 3.3.1 chain (`latest` from 2026-10-02 until 4.0.0 took over on 2026-10-05; the owner moved `next` from `3.3.0` to `3.3.1` with npm 2FA on 2026-10-02) stays archived in [`V3.3.1-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.1-RELEASE-EVIDENCE-2026-10-02.md). The 3.3.0 chain (`latest` on 2026-10-02 until 3.3.1 took over the same day; the owner moved `next` from `3.2.1` to `3.3.0` with npm 2FA that day) stays archived in [`V3.3.0-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.0-RELEASE-EVIDENCE-2026-10-02.md). The 3.2.1 chain (`latest` from 2026-09-26 until 3.3.0 took over on 2026-10-02; the owner moved `next` from `3.0.1` to `3.2.1` with npm 2FA on 2026-09-26) stays 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
 
@@ -13,3 +13,5 @@
13
13
  | Switch sessions | Save missing facts and retain active runtime files; continue from project records |
14
14
 
15
15
  The loop ends at the merge decision. Merge, push, deployment and publication each require their corresponding authorization.
16
+
17
+ For a main repository with code repositories, inspect them together with `buildbeat status --repo . --all-repos`. See [Session handoff](11-session-handoff.en.md) for discovery, filters and settled counts.
@@ -13,3 +13,5 @@
13
13
  | 换会话 | 保存未落盘事实,保留活动运行现场;新会话按项目记录接续 |
14
14
 
15
15
  工作流默认停在合并决定。合并、推送、部署和发布分别需要对应授权。
16
+
17
+ 主仓有多个代码仓时,用 `buildbeat status --repo . --all-repos` 一起查看;发现范围、筛选与已了结计数见[跨会话接续](11-session-handoff.md)。
@@ -3,7 +3,7 @@
3
3
  Install the runtime: `npm install --global @haiyangbg/buildbeat@latest` (Node ≥ 20). Before upgrading from 3.x, finish or cancel runs still in progress with their original 3.x runtime, see [Migration](../../MIGRATION.md); never replace a runtime underneath an active run.
4
4
 
5
5
  1. Check Node ≥20, Git, Bash and an authenticated AI tool. Keep project-owned instructions.
6
- 2. Install templates/v2/AGENTS.md and CLAUDE.md according to the tool's loading rules; copy runtime exclusions from gitignore.template.
6
+ 2. The templates ship with the npm package under `$(npm root -g)/@haiyangbg/buildbeat/templates/`. Install `v2/AGENTS.md` and `v2/CLAUDE.md` according to the tool's loading rules; copy runtime exclusions from `gitignore.template` (in `templates/`, not in `v2/`).
7
7
  3. Create delivery/work/WORK-X/work.md using the goal, scope, acceptance and implementation sections in work.example.md.
8
8
  4. Copy templates/v2/envelope/ to delivery/envelope/ and configure real tool and verification commands. New work needs no workflow.yaml copy.
9
9
  5. After the user accepts the work, use the commands below. Detach long runs from short host timeouts and handle the decision shown by status.
@@ -18,10 +18,10 @@ buildbeat status --repo . --work WORK-X
18
18
  ```yaml
19
19
  # BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
20
20
  # 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
21
- # 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
21
+ # 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
22
22
  repo: ../../..
23
23
  work: WORK-X
24
- # 家族名;--attempt new 自动编成 RUN-X-01/02…
24
+ # 家族名;run 自动编成 RUN-X-01/02…,--new 开新一轮
25
25
  run: RUN-X
26
26
  # builder / fixer 只能改这些目录;越界改动不成为候选
27
27
  allowedPaths:
@@ -92,3 +92,5 @@ workers:
92
92
  [Migration](../../MIGRATION.md) · [Worker contract](05-worker-contract.en.md) · [Recovery](10-recovery.en.md)
93
93
 
94
94
  Before starting, commit work.md and worker scripts into the selected base so the isolated checkout sees the accepted scope. Acceptance records may be committed afterward.
95
+
96
+ For a main repository with code repositories, use `buildbeat status --repo . --all-repos` for pending decisions and open work across repositories; add `--work <ID>` or `--json` as needed. See [Session handoff](11-session-handoff.en.md).
@@ -3,7 +3,7 @@
3
3
  安装运行时:`npm install --global @haiyangbg/buildbeat@latest`(Node ≥ 20)。从 3.x 升级前,先用原来的 3.x 运行时完成或取消仍在进行的 Run,见[迁移说明](../../MIGRATION.md);不要在 Run 进行中替换运行时。
4
4
 
5
5
  1. 检查 Node ≥20、Git、Bash 和已鉴权的 AI 工具;保留项目自己的规范。
6
- 2. 将 templates/v2/AGENTS.md、CLAUDE.md 按工具装载方式放入项目;复制 gitignore.template 的运行时排除项。
6
+ 2. 模板随 npm 包安装在 `$(npm root -g)/@haiyangbg/buildbeat/templates/`。将其中 `v2/AGENTS.md`、`v2/CLAUDE.md` 按工具装载方式放入项目;从 `gitignore.template`(在 `templates/` 下,不在 `v2/` 里)复制运行时排除项。
7
7
  3. 建 delivery/work/WORK-X/work.md,使用 work.example.md 的目标、范围、验收、实施计划结构。
8
8
  4. 把 templates/v2/envelope/ 复制为 delivery/envelope/;配置真实工具和验证命令。新工作不用复制 workflow.yaml。
9
9
  5. 用户接受工作说明后运行下面命令。长运行使用宿主支持的脱离方式启动;根据 status 的待决定对象处理例外。
@@ -18,10 +18,10 @@ buildbeat status --repo . --work WORK-X
18
18
  ```yaml
19
19
  # BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
20
20
  # 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
21
- # 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
21
+ # 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
22
22
  repo: ../../..
23
23
  work: WORK-X
24
- # 家族名;--attempt new 自动编成 RUN-X-01/02…
24
+ # 家族名;run 自动编成 RUN-X-01/02…,--new 开新一轮
25
25
  run: RUN-X
26
26
  # builder / fixer 只能改这些目录;越界改动不成为候选
27
27
  allowedPaths:
@@ -92,3 +92,5 @@ workers:
92
92
  [Migration](../../MIGRATION.md) · [Worker contract](05-worker-contract.md) · [Recovery](10-recovery.md)
93
93
 
94
94
  启动前将 work.md 和 worker 脚本提交到所选 base,确保隔离工作树能读到同一份已确认范围;接受记录可以随后提交。
95
+
96
+ 主仓管理多个代码仓时,用 `buildbeat status --repo . --all-repos` 查看跨仓待批与未了结工作;可加 `--work <ID>` 或 `--json`。详见[跨会话接续](11-session-handoff.md)。
@@ -10,6 +10,6 @@ Use decide --action accept|dismiss --work ... --fingerprint ... for findings. Ex
10
10
 
11
11
  Normal repairs continue automatically. Repeated issues, increased blockers, infrastructure failures and exhausted budgets create exception decisions. One budget grant covers its repair/reverification/review plan and can be replayed. The driving session cannot approve on behalf of the user without authorization.
12
12
 
13
- Merging and releasing stay human actions. After a release, run `buildbeat release --config <config> [--note <text>]`: it checks that the Work's succeeded candidate is contained in `--ref` (default: the current HEAD), runs the read-only readback command from the run config's `release:` section in the main checkout (same shape, environment allowlist and redaction as a worker), and appends the outcome, exit code, commit, output digest and a redacted tail to `delivery/work/<ID>/releases.jsonl`. Once the latest readback has passed, close the window with `decide --repo . --work <ID> --action close --result <text>`, which records a close-work decision bound to that readback; a failed readback cannot close it.
13
+ Merging and releasing stay human actions. After a release, run `buildbeat release --config <config> [--note <text>]`: it checks that the Work's succeeded candidate is contained in `--ref` (default: the current HEAD), runs the read-only readback command from the run config's `release:` section in the main checkout (same shape, environment allowlist and redaction as a worker), and appends the outcome, exit code, commit, output digest and a redacted tail to `delivery/work/<ID>/releases.jsonl`. Once the latest readback has passed, close the window with `decide --repo . --work <ID> --action close --result <text>`, which records a close-work decision bound to that readback; a failed readback cannot close it. A Work whose run configs have no `release:` section ends at the merge, and status says nothing is left to do.
14
14
 
15
15
  Notifications report waiting, termination and stalls; they do not accept approvals. Webhook/DingTalk URLs come only from environment variables, and delivery failure does not affect the run. The watcher is an internal feedback mechanism.
@@ -10,6 +10,6 @@ status 的决定卡包含 transition、候选、计划摘要、证据、原因
10
10
 
11
11
  正常修复自动继续;重复问题、阻断数增加、环境故障或预算耗尽才形成例外决定。预算批准一次放行对应修复、重验和再审;扩额记录可以重放,驾驶会话不能自行冒充用户批准。
12
12
 
13
- 合并和上线仍由人完成。上线后运行 `buildbeat release --config <config> [--note <text>]`:它确认该 Work 已成功 Run 的候选包含在 `--ref`(默认当前 HEAD)中,在主检出执行 run-config `release:` 段配置的只读回读命令(形状、环境白名单与脱敏同 worker),把结果、退出码、提交、输出摘要和末尾若干行追加到 `delivery/work/<ID>/releases.jsonl`。最近一次回读通过后,用 `decide --repo . --work <ID> --action close --result <text>` 关窗,写入绑定该回读的 close-work 决定;回读失败时不能关窗。
13
+ 合并和上线仍由人完成。上线后运行 `buildbeat release --config <config> [--note <text>]`:它确认该 Work 已成功 Run 的候选包含在 `--ref`(默认当前 HEAD)中,在主检出执行 run-config `release:` 段配置的只读回读命令(形状、环境白名单与脱敏同 worker),把结果、退出码、提交、输出摘要和末尾若干行追加到 `delivery/work/<ID>/releases.jsonl`。最近一次回读通过后,用 `decide --repo . --work <ID> --action close --result <text>` 关窗,写入绑定该回读的 close-work 决定;回读失败时不能关窗。run 配置都没有 `release:` 段的 Work 到合并为止,status 提示没有剩余必做步骤。
14
14
 
15
15
  通知只报告待批、结束或疑似卡顿,不能接收审批;webhook/钉钉 URL 仅从环境变量读取,发送失败不影响 Run。看门进程是运行反馈的内部实现。
@@ -6,6 +6,12 @@ Before closing a session, save the goal, decisions, unverified scope and next st
6
6
 
7
7
  A new session reads AGENTS.md, work.md (or legacy intent/plan), decisions and status. Verify available facts and reuse still-valid authorization. A new conversation is not permission to start a duplicate run.
8
8
 
9
+ For a main repository managing several code repositories, start with `buildbeat status --repo . --all-repos`. This reads the main checkout, targets named by `repo:` in its `delivery/work/` run configurations, and immediate child Git checkouts containing `delivery/work/`. Discovery deduplicates real paths and never recurses. Missing or invalid targets and unreadable configurations produce warnings without hiding other repositories.
10
+
11
+ Pending decisions come first, followed by open work grouped by repository. CLOSED, CANCELLED and merged work whose configuration has no `release:` are counted only. Merged work with `release:` remains visible until closed. `--json` returns `{repos: [{repo, works, settled}], pending, warnings}` including settled work. `--work <ID>` filters the merged results; `--run` cannot be combined with `--all-repos`.
12
+
13
+ When a configured target has the matching work directory or a runtime ledger, its state, findings and decisions are authoritative and the work appears only under that repository. Suggested commands retain the main repository's configuration and use paths relative to the caller; fill in person/reason placeholders before executing. Work without target records remains under the main repository with its runtime target shown. Single-repository `status` also reads this one level of target state, preventing duplicate-start suggestions.
14
+
9
15
  Changing tools requires command, authentication and output-contract configuration. Records remain reusable; execution capability needs validation. Handoffs between people require candidate, records, access and a named next owner/action.
10
16
 
11
17
  Git transfers long-lived facts between machines. Active processes, runtime files, worktrees and host resources do not migrate with clone. Inspect the original environment before choosing how to continue.
@@ -6,6 +6,12 @@
6
6
 
7
7
  新会话读取项目 AGENTS.md、Work 的 work.md(或旧 intent/plan)、决定与 status。能从记录查到的事实自行核对,沿用仍有效的授权;不把开新会话等同于重开 Run。
8
8
 
9
+ 主仓管理多个代码仓时,先运行 `buildbeat status --repo . --all-repos`。它只读查看主仓、主仓 `delivery/work/` 下 run 配置的 `repo:` 目标仓,以及主仓下一层含 `delivery/work/` 的 Git 仓,按真实路径去重,不递归。不存在或无效的目标、读不了的配置会给出警告,其余仓继续显示。
10
+
11
+ 待人决定先列出,随后按仓显示未了结工作;CLOSED、CANCELLED 和已合并且配置没有 `release:` 的工作只计数。已合并但配有 `release:`、尚未关窗的工作仍显示。`--json` 返回 `{repos: [{repo, works, settled}], pending, warnings}`,包含已了结工作;`--work <ID>` 筛选各仓合并后的这项工作,`--run` 不能与 `--all-repos` 同用。
12
+
13
+ 主仓配置指向的代码仓已有同名工作目录或运行台账时,状态、审查问题和决定取自代码仓,只在代码仓下显示;提示命令保留主仓配置,仓路径和配置路径相对当前目录,可填入人名、理由等占位内容后执行。目标仓还没有记录时,工作留在主仓并注明运行目标。不加 `--all-repos` 的主仓 `status` 也会读取这一层目标仓的真实状态,避免提示重复开跑。
14
+
9
15
  换工具时重新配置命令、鉴权和输出合同;接手事实可以复用,具体工具执行能力需要验证。换人时同步候选、记录和权限,说明当前推进人及下一步。
10
16
 
11
17
  换机器只能通过 Git 同步长期事实。活动进程、运行时文件、工作树和宿主资源不会随 clone 迁移,先核对原环境再选择接手方式。
@@ -1,5 +1,5 @@
1
1
  # 简账 · WORK-EXPORT-DATE-FILTER 的 run 配置。路径相对本文件解析。
2
- # 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
2
+ # 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
3
3
  repo: ../../..
4
4
  work: WORK-EXPORT-DATE-FILTER
5
5
  run: RUN-EXPORT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haiyangbg/buildbeat",
3
- "version": "4.0.0",
3
+ "version": "4.1.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
@@ -45,10 +45,12 @@ import { applyGc, planGc } from "../runtime/gc.js";
45
45
  import {
46
46
  artifactStatus,
47
47
  computeOverview,
48
+ computeRepositoryOverview,
48
49
  readJsonl,
49
50
  renderOverview,
50
51
  runsFor,
51
52
  } from "../runtime/overview.js";
53
+ import { pathLabel, shellArg } from "../runtime/overview-repos.js";
52
54
  import { closeWork, runReadback } from "../runtime/release.js";
53
55
  import {
54
56
  DEFAULT_STALL_AFTER_MS,
@@ -94,7 +96,7 @@ Usage:
94
96
  buildbeat --version
95
97
  buildbeat accept --repo <path> --work <WORK-ID> [--by <name>]
96
98
  buildbeat run --config <run-config.yaml> [--run <RUN-ID>] [--new] [--adopt <sha> --by <name>]
97
- buildbeat status --repo <path> [--work <WORK-ID> | --run <RUN-ID>] [--json]
99
+ buildbeat status --repo <path> [--work <WORK-ID> | --run <RUN-ID>] [--all-repos] [--json]
98
100
  buildbeat decide --repo <path> --run <RUN-ID> --action approve|reject --transition <t> [--by <name>] [--reason <text>]
99
101
  buildbeat decide --repo <path> --work <WORK-ID> --action accept|dismiss --fingerprint <fp> [--by <name>] [--note <text>]
100
102
  buildbeat release --config <run-config.yaml> [--ref <ref>] [--note <text>]
@@ -110,7 +112,7 @@ check --step executes that worker in the main checkout, outside the evidence led
110
112
 
111
113
  // Switches may stand alone (`--json`) or take an explicit true/false
112
114
  // (`--json true`, the older spelling); every other flag needs a value.
113
- const SWITCHES = new Set(["json", "apply", "force", "once", "new", "verify"]);
115
+ const SWITCHES = new Set(["json", "apply", "force", "once", "new", "verify", "all-repos"]);
114
116
 
115
117
  function parseFlags(argv) {
116
118
  const flags = {};
@@ -971,6 +973,7 @@ function commandInbox(flags, rows = null) {
971
973
  );
972
974
  let lastWork = null;
973
975
  for (const row of sorted) {
976
+ const rowRepo = row.repo ?? repoRoot;
974
977
  if (row.corrupted) {
975
978
  console.log(
976
979
  `${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`,
@@ -981,7 +984,7 @@ function commandInbox(flags, rows = null) {
981
984
  console.log(`work ${row.work}:`);
982
985
  lastWork = row.work;
983
986
  }
984
- const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
987
+ const ledger = EventLedger.open(ledgerPathFor(rowRepo, row.run));
985
988
  const requested = [...ledger.events]
986
989
  .reverse()
987
990
  .find((event) => event.type === "HUMAN_REQUESTED");
@@ -1004,10 +1007,10 @@ function commandInbox(flags, rows = null) {
1004
1007
  console.log(` reason: ${reason}`);
1005
1008
  }
1006
1009
  for (const line of nextReply({
1007
- repoLabel: repoLabelFor(repoRoot, flags.repo),
1010
+ repoLabel: row.repo ? shellArg(pathLabel(process.cwd(), rowRepo)) : repoLabelFor(repoRoot, flags.repo),
1008
1011
  state: ledger.state,
1009
1012
  })) {
1010
- console.log(` next: ${line}`);
1013
+ console.log(` next: ${row.config ? line.replaceAll("--config <run-config.yaml>", `--config ${shellArg(pathLabel(process.cwd(), row.config))}`) : line}`);
1011
1014
  }
1012
1015
  }
1013
1016
  }
@@ -1257,7 +1260,7 @@ function commandDoctor(flags) {
1257
1260
  console.log(`notify: PROBLEM ${notifyError}`);
1258
1261
  } else if (!notify) {
1259
1262
  console.log(
1260
- `notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`,
1263
+ `notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs status)`,
1261
1264
  );
1262
1265
  } else {
1263
1266
  console.log("notify channels:");
@@ -1455,30 +1458,57 @@ function commandMetrics(flags) {
1455
1458
  function commandStatus(flags) {
1456
1459
  if (!flags.repo) throw new Error("status requires --repo");
1457
1460
  const repoRoot = resolve(flags.repo);
1461
+ if (flags["all-repos"] === "true" && flags.run)
1462
+ throw new Error("status: --all-repos cannot be used with --run; use --work instead");
1458
1463
  if (flags.run && flags.work)
1459
1464
  throw new Error("status: choose --run or --work");
1460
1465
  if (!flags.run) {
1461
- const rows = computeOverview(repoRoot, {
1462
- work: flags.work ?? null,
1463
- repoLabel: repoLabelFor(repoRoot, flags.repo),
1464
- });
1465
- const pending = pendingFor(repoRoot, flags.work);
1466
- if (flags.json === "true")
1467
- console.log(
1468
- JSON.stringify(
1469
- {
1470
- works: rows,
1471
- pending,
1472
- metrics: flags.work ? null : computeMetrics(repoRoot),
1473
- },
1474
- null,
1475
- 2,
1476
- ),
1477
- );
1478
- else {
1479
- console.log(renderOverview(rows));
1480
- if (pending.length) commandInbox(flags, pending);
1481
- if (!flags.work) console.log(renderMetrics(computeMetrics(repoRoot)));
1466
+ const allRepos = flags["all-repos"] === "true";
1467
+ const result = computeRepositoryOverview(repoRoot, { work: flags.work ?? null, allRepos, localRepoLabel: repoLabelFor(repoRoot, flags.repo) });
1468
+ const rows = result.repos.flatMap((group) => group.works);
1469
+ if (flags.json === "true") {
1470
+ console.log(JSON.stringify(allRepos ? result : {
1471
+ works: rows.map((row) => {
1472
+ if (row.repo !== result.repos[0].repo || row.targetRepo) return row;
1473
+ const { config, hasRelease, repo, repoLabel, settled, ...local } = row;
1474
+ return local;
1475
+ }),
1476
+ pending: result.pending.map((row) => {
1477
+ if (row.repo !== result.repos[0].repo) return row;
1478
+ const { repo, config, ...local } = row;
1479
+ return local;
1480
+ }),
1481
+ metrics: flags.work ? null : computeMetrics(repoRoot),
1482
+ ...(result.warnings.length ? { warnings: result.warnings } : {}),
1483
+ }, null, 2));
1484
+ } else {
1485
+ for (const warning of result.warnings) console.log(`warning: ${warning}`);
1486
+ if (allRepos) {
1487
+ for (const group of result.repos) {
1488
+ const pending = result.pending.filter((row) => row.repo === group.repo);
1489
+ if (pending.length) {
1490
+ console.log(`pending decisions · ${pathLabel(process.cwd(), group.repo)}`);
1491
+ commandInbox(flags, pending);
1492
+ }
1493
+ }
1494
+ for (const group of result.repos) {
1495
+ const open = group.works.filter((row) => !row.settled);
1496
+ if (open.length) {
1497
+ console.log(`repo ${pathLabel(process.cwd(), group.repo)}:`);
1498
+ console.log(renderOverview(open));
1499
+ }
1500
+ }
1501
+ for (const group of result.repos) {
1502
+ const count = group.settled;
1503
+ console.log(`${pathLabel(process.cwd(), group.repo)}: settled ${count.total} (closed ${count.closed} · cancelled ${count.cancelled} · merged, no release step ${count.merged})`);
1504
+ }
1505
+ } else {
1506
+ // Keep the single-repo surface while forwarding work facts and waits.
1507
+ console.log(renderOverview(rows.map((row) => row.repo === result.repos[0].repo
1508
+ ? { ...row, repo: undefined } : row)));
1509
+ if (result.pending.length) commandInbox(flags, result.pending);
1510
+ if (!flags.work) console.log(renderMetrics(computeMetrics(repoRoot)));
1511
+ }
1482
1512
  }
1483
1513
  return;
1484
1514
  }
@@ -0,0 +1,79 @@
1
+ // Read-only repository/config discovery shared by single- and multi-repo status.
2
+ import { execFileSync } from "node:child_process";
3
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { parseYamlSubset } from "../engine/yaml-subset.js";
6
+
7
+ export const pathLabel = (cwd, path) => relative(cwd, path) || ".";
8
+ export const shellArg = (value) => /^[a-zA-Z0-9_./:-]+$/.test(value)
9
+ ? value : `'${value.replaceAll("'", "'\\''")}'`;
10
+ export const directory = (path) => {
11
+ try { return statSync(path).isDirectory(); } catch { return false; }
12
+ };
13
+
14
+ // Require an actual checkout root, not a directory inside a parent checkout.
15
+ export function repositoryRoot(path, cache = new Map()) {
16
+ const real = realpathSync(path);
17
+ if (cache.has(real)) {
18
+ const cached = cache.get(real);
19
+ if (cached instanceof Error) throw cached;
20
+ return cached;
21
+ }
22
+ try {
23
+ const top = execFileSync("git", ["-C", real, "rev-parse", "--show-toplevel"], {
24
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
25
+ }).trim();
26
+ if (realpathSync(top) !== real) throw new Error("not a git repository root");
27
+ cache.set(real, real);
28
+ return real;
29
+ } catch (error) {
30
+ cache.set(real, error);
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ export function workConfigs(repoRoot, workId, warnings = []) {
36
+ const dir = join(repoRoot, "delivery", "work", workId);
37
+ if (!directory(dir)) return [];
38
+ return readdirSync(dir).sort().filter((name) => /^run-config.*\.ya?ml$/.test(name)).map((name) => {
39
+ const path = join(dir, name);
40
+ try {
41
+ const doc = parseYamlSubset(readFileSync(path, "utf8"));
42
+ if (typeof doc.repo !== "string" || !doc.repo.trim()) throw new Error("repo must be a non-empty path");
43
+ const target = resolve(dirname(path), doc.repo);
44
+ return { path, target, release: Object.hasOwn(doc, "release") };
45
+ } catch (error) {
46
+ warnings.push(`${path}: ${error.message.replaceAll("\n", " ")}`);
47
+ return { path, error: true, release: null };
48
+ }
49
+ });
50
+ }
51
+
52
+ export function configTargets(configs, warnings, roots = new Map()) {
53
+ return configs.map((config) => {
54
+ if (!config.target) return config;
55
+ try { return { ...config, repo: repositoryRoot(config.target, roots) }; }
56
+ catch (error) {
57
+ warnings.push(`${config.path}: ${config.target}: ${error.message.replaceAll("\n", " ")}`);
58
+ return { ...config, error: true };
59
+ }
60
+ });
61
+ }
62
+
63
+ export function discoverRepos(repoRoot, warnings, roots = new Map()) {
64
+ const repos = new Set([repoRoot]);
65
+ const workRoot = join(repoRoot, "delivery", "work");
66
+ if (directory(workRoot)) {
67
+ for (const id of readdirSync(workRoot).sort()) {
68
+ for (const config of configTargets(workConfigs(repoRoot, id, warnings), warnings, roots)) {
69
+ if (config.repo) repos.add(config.repo);
70
+ }
71
+ }
72
+ }
73
+ for (const name of readdirSync(repoRoot).sort()) {
74
+ const child = join(repoRoot, name);
75
+ if (!directory(join(child, "delivery", "work"))) continue;
76
+ try { repos.add(repositoryRoot(child, roots)); } catch { /* only discover git roots */ }
77
+ }
78
+ return [...repos];
79
+ }
@@ -10,13 +10,16 @@
10
10
 
11
11
  import { execFileSync } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
- import { existsSync, readFileSync, readdirSync } from "node:fs";
13
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ import { listInbox } from "./decisions.js";
16
+ import { configTargets, directory, discoverRepos, pathLabel, shellArg, workConfigs } from "./overview-repos.js";
15
17
 
16
18
  import { EventLedger } from "../storage/event-ledger.js";
17
19
  import { latestAdjudications, readFindingsAccount } from "./findings.js";
18
20
  import { nextReply } from "./notify.js";
19
21
  import { readReleases } from "./release.js";
22
+ import { describeLiveness } from "./liveness.js";
20
23
  import { computeWorkCost, renderWorkCost } from "./work-cost.js";
21
24
 
22
25
  function sha256File(path) {
@@ -64,27 +67,35 @@ export function artifactStatus(workDir, decisions, artifact) {
64
67
  };
65
68
  }
66
69
 
67
- function isAncestor(repoRoot, sha, ref) {
70
+ function isAncestor(repoRoot, sha, ref, context) {
71
+ const key = JSON.stringify([repoRoot, sha, ref]);
72
+ if (context.ancestry.has(key)) return context.ancestry.get(key);
68
73
  try {
69
74
  execFileSync(
70
75
  "git",
71
76
  ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
72
77
  { stdio: "ignore" },
73
78
  );
79
+ context.ancestry.set(key, true);
74
80
  return true;
75
81
  } catch {
82
+ context.ancestry.set(key, false);
76
83
  return false;
77
84
  }
78
85
  }
79
86
 
80
- function headRef(repoRoot) {
87
+ function headRef(repoRoot, context) {
88
+ if (context.heads.has(repoRoot)) return context.heads.get(repoRoot);
81
89
  try {
82
- return execFileSync(
90
+ const ref = execFileSync(
83
91
  "git",
84
92
  ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
85
93
  { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
86
94
  ).trim();
95
+ context.heads.set(repoRoot, ref);
96
+ return ref;
87
97
  } catch {
98
+ context.heads.set(repoRoot, "HEAD");
88
99
  return "HEAD";
89
100
  }
90
101
  }
@@ -154,30 +165,64 @@ export function runsFor(repoRoot, workId) {
154
165
  );
155
166
  }
156
167
 
168
+ // Per status invocation: known local roots need no validation subprocess,
169
+ // and forwarded works share repository facts with the later target scan.
170
+ function overviewContext(repoRoot) {
171
+ return { roots: new Map([[realpathSync(repoRoot), realpathSync(repoRoot)]]), heads: new Map(), ancestry: new Map() };
172
+ }
173
+
157
174
  export function computeOverview(
158
175
  repoRoot,
159
- { work = null, repoLabel = "." } = {},
176
+ { work = null, repoLabel: defaultRepoLabel = ".", cwd = null, followTargets = true, warnings = [], configsOverride = null, context = overviewContext(repoRoot), skipWorks = new Set(), localCompatibility = false } = {},
160
177
  ) {
161
178
  const workRoot = join(repoRoot, "delivery", "work");
162
179
  const rows = [];
163
- if (!existsSync(workRoot)) {
164
- return rows;
180
+ const workIds = new Set(directory(workRoot) ? readdirSync(workRoot).filter((id) => directory(join(workRoot, id))) : []);
181
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
182
+ if (directory(runsDir)) {
183
+ for (const id of readdirSync(runsDir)) {
184
+ const file = join(runsDir, id, "events.jsonl");
185
+ if (!existsSync(file)) continue;
186
+ const run = EventLedger.open(file).state.run;
187
+ if (run?.work) workIds.add(run.work);
188
+ }
165
189
  }
166
- const mainRef = headRef(repoRoot);
167
- for (const workId of readdirSync(workRoot).sort()) {
168
- if (work && workId !== work) {
190
+ const mainRef = headRef(repoRoot, context);
191
+ // Resolve once; do not spawn another Git process for each local work.
192
+ const currentRoot = realpathSync(repoRoot);
193
+ for (const workId of [...workIds].sort()) {
194
+ if ((work && workId !== work) || skipWorks.has(workId)) {
169
195
  continue;
170
196
  }
171
197
  const workDir = join(workRoot, workId);
172
- if (
173
- !existsSync(join(workDir, "work.md")) &&
174
- !existsSync(join(workDir, "intent.md")) &&
175
- !existsSync(join(workDir, "plan.md")) &&
176
- !existsSync(join(workDir, "decisions.jsonl")) &&
177
- !existsSync(join(workDir, "runs"))
178
- ) {
179
- continue;
198
+ const configs = configsOverride ?? workConfigs(repoRoot, workId, warnings);
199
+ const resolved = followTargets ? configTargets(configs, warnings, context.roots) : configs;
200
+ const owner = followTargets ? resolved.find((item) => item.repo && item.repo !== currentRoot &&
201
+ (directory(join(item.repo, "delivery", "work", workId)) || runsFor(item.repo, workId).length)) : null;
202
+ const usable = resolved.filter((item) => !item.error);
203
+ const selected = owner ?? usable[0] ?? resolved[0] ?? null;
204
+ const applicable = selected?.repo ? resolved.filter((item) => item.repo === selected.repo) : usable;
205
+ const local = localCompatibility && (!selected?.target || selected.repo === currentRoot);
206
+ const config = (local ? null : applicable.find((item) => item.release)) ?? selected;
207
+ if (owner) {
208
+ const targetRows = computeOverview(config.repo, {
209
+ work: workId, repoLabel: shellArg(pathLabel(cwd ?? process.cwd(), config.repo)),
210
+ cwd: cwd ?? process.cwd(), followTargets: false, warnings, configsOverride: applicable, context,
211
+ });
212
+ if (targetRows.length) {
213
+ rows.push({ ...targetRows[0], repo: config.repo, repoLabel: pathLabel(cwd ?? process.cwd(), config.repo) });
214
+ continue;
215
+ }
180
216
  }
217
+ // Cross-repo work still belongs here until the target has records.
218
+ // Its commands need a copyable owner path, not the legacy local label.
219
+ const repoLabel = followTargets && config?.target && config.repo !== currentRoot
220
+ ? shellArg(pathLabel(cwd ?? process.cwd(), repoRoot)) : defaultRepoLabel;
221
+ const pathFor = (item) =>
222
+ shellArg(cwd && !local ? pathLabel(cwd, item.path) : `delivery/work/${workId}/${item.path.split("/").at(-1)}`);
223
+ const configPath = config ? pathFor(config) : "<run-config.yaml>";
224
+ const runs = runsFor(repoRoot, workId);
225
+ if (!configs.length && !runs.length && !["work.md", "intent.md", "plan.md", "decisions.jsonl", "runs"].some((name) => existsSync(join(workDir, name)))) continue;
181
226
  const decisions = readJsonl(join(workDir, "decisions.jsonl"));
182
227
  const unified = existsSync(join(workDir, "work.md"));
183
228
  const intent = artifactStatus(
@@ -195,7 +240,6 @@ export function computeOverview(
195
240
  (row.severity === "P0" || row.severity === "P1") &&
196
241
  !adjudicated.has(row.fingerprint),
197
242
  ).length;
198
- const runs = runsFor(repoRoot, workId);
199
243
  const live = runs.filter((run) => run.status !== "SUPERSEDED");
200
244
  const latest = live[live.length - 1] ?? null;
201
245
  // "Merged" is a fact about any candidate of the work, not only the
@@ -207,7 +251,7 @@ export function computeOverview(
207
251
  .reverse()
208
252
  .find(
209
253
  (run) =>
210
- run.candidate && isAncestor(repoRoot, run.candidate, mainRef),
254
+ run.candidate && isAncestor(repoRoot, run.candidate, mainRef, context),
211
255
  ) ?? null;
212
256
  const merged = Boolean(mergedRun);
213
257
  const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
@@ -238,7 +282,10 @@ export function computeOverview(
238
282
  ? closure.subject.result
239
283
  : "see decisions.jsonl";
240
284
  next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
241
- } else if (!intent.exists) {
285
+ } else if (!intent.exists && (!latest || directory(workDir))) {
286
+ // A work directory without a work description stays NO_INTENT even
287
+ // when it has runs, as in 4.0.0; only a work known solely from
288
+ // runtime ledgers (no directory here) shows its run state.
242
289
  stage = "NO_INTENT";
243
290
  next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
244
291
  } else if (!latest) {
@@ -263,12 +310,9 @@ export function computeOverview(
263
310
  next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
264
311
  } else {
265
312
  stage = "READY_TO_RUN";
266
- const configs = readdirSync(workDir).filter((name) =>
267
- /^run-config.*\.ya?ml$/.test(name),
268
- );
269
313
  next =
270
314
  configs.length > 0
271
- ? `buildbeat run --config delivery/work/${workId}/${configs[0]}`
315
+ ? `buildbeat run --config ${configPath}`
272
316
  : `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
273
317
  }
274
318
  } else if (latest.status === "RUNNING") {
@@ -282,7 +326,7 @@ export function computeOverview(
282
326
  const replies = latest.state
283
327
  ? nextReply({ repoLabel, state: latest.state })
284
328
  : [];
285
- next = replies[0] ?? `buildbeat inbox --repo ${repoLabel}`;
329
+ next = replies[0] ?? `buildbeat status --repo ${repoLabel} --work ${workId}`;
286
330
  } else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
287
331
  // A release-readback lane that reached wait-close and was approved is
288
332
  // a closed release window, not "nothing to merge".
@@ -291,19 +335,23 @@ export function computeOverview(
291
335
  } else if (merged) {
292
336
  stage = "MERGED";
293
337
  // After the merge: the person releases, the project's readback proves
294
- // it, and the window closes on a passing readback.
295
- const configs = readdirSync(workDir).filter((name) =>
296
- /^run-config.*\.ya?ml$/.test(name),
297
- );
298
- const config = configs.length
299
- ? `delivery/work/${workId}/${configs[0]}`
300
- : "<run-config.yaml>";
338
+ // it, and the window closes on a passing readback. The readback lives
339
+ // in whichever run config has a release: section; when every config
340
+ // was read and none has one, `buildbeat release` could only fail, so
341
+ // the merge is the end of the work. Unreadable or absent configs keep
342
+ // the generic hint.
343
+ const releaseConfig = configs.find((item) => item.release) ?? null;
344
+ const releasePath = releaseConfig ? pathFor(releaseConfig) : configPath;
345
+ const noReleaseStep =
346
+ configs.length > 0 && !releaseConfig && !configs.some((item) => item.error);
301
347
  const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
302
348
  next = !readback
303
- ? `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release it (a human action), then buildbeat release --config ${config}; then ${close}`
349
+ ? noReleaseStep
350
+ ? "merged; no release readback configured, nothing left to do (add a release: section to the run config to record a readback and close the window)"
351
+ : `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release it (a human action), then buildbeat release --config ${releasePath}; then ${close}`
304
352
  : readback.status === "passed"
305
353
  ? `readback passed @ ${readback.ts} (${readback.commit.slice(0, 7)}); close the window: ${close}`
306
- : `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${config} again`;
354
+ : `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${releasePath} again`;
307
355
  if (latest.status !== "SUCCEEDED") {
308
356
  next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
309
357
  }
@@ -315,11 +363,13 @@ export function computeOverview(
315
363
  } else {
316
364
  stage = `STOPPED_${latest.status}`;
317
365
  next = plan.accepted
318
- ? `decide: retry (buildbeat run --config <run-config.yaml> --new) or close the work`
366
+ ? `decide: retry (buildbeat run --config ${configPath} --new) or close the work`
319
367
  : `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
320
368
  }
321
369
  rows.push({
322
370
  work: workId,
371
+ ...(config ? { config: config.path, hasRelease: configs.some((item) => item.release) ? true : configs.some((item) => item.error) ? null : false } : { hasRelease: false }),
372
+ ...(config?.target && (!config.repo || config.repo !== currentRoot) && followTargets ? { targetRepo: config.repo ?? config.target, targetLabel: pathLabel(cwd ?? process.cwd(), config.repo ?? config.target) } : {}),
323
373
  stage,
324
374
  intent,
325
375
  plan,
@@ -358,7 +408,7 @@ export function computeOverview(
358
408
  },
359
409
  }
360
410
  : {}),
361
- next,
411
+ next: local ? next : next.replaceAll("--config <run-config.yaml>", `--config ${configPath}`),
362
412
  });
363
413
  }
364
414
  return rows;
@@ -381,6 +431,8 @@ export function renderOverview(rows) {
381
431
  const lines = [];
382
432
  for (const row of rows) {
383
433
  lines.push(`${row.work} ${row.stage}`);
434
+ if (row.repo) lines.push(` repo: ${row.repoLabel ?? row.repo}`);
435
+ if (row.targetRepo) lines.push(` runs in: ${row.targetLabel ?? row.targetRepo}`);
384
436
  const parts =
385
437
  row.workArtifact === "work"
386
438
  ? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
@@ -429,3 +481,54 @@ export function renderOverview(rows) {
429
481
  }
430
482
  return lines.join("\n");
431
483
  }
484
+
485
+ // The main repo's configuration remains the command source; all state and
486
+ // decisions come from the checkout that owns the work. Only the main repo's
487
+ // pointers are followed, so targets cannot recursively expand this view.
488
+ export function computeRepositoryOverview(repoRoot, { work = null, allRepos = false, cwd = process.cwd(), localRepoLabel = shellArg(pathLabel(cwd, repoRoot)) } = {}) {
489
+ repoRoot = realpathSync(repoRoot);
490
+ const context = overviewContext(repoRoot);
491
+ const warnings = [];
492
+ const roots = allRepos ? discoverRepos(repoRoot, warnings, context.roots) : [repoRoot];
493
+ const primary = computeOverview(repoRoot, { work, cwd, repoLabel: allRepos ? shellArg(pathLabel(cwd, repoRoot)) : localRepoLabel, warnings, context, localCompatibility: !allRepos });
494
+ const groups = new Map(roots.map((repo) => [repo, new Map()]));
495
+ for (const row of primary) {
496
+ const owner = row.repo ?? repoRoot;
497
+ if (!groups.has(owner)) groups.set(owner, new Map());
498
+ groups.get(owner).set(row.work, row);
499
+ }
500
+ if (allRepos) {
501
+ for (const root of roots.filter((root) => root !== repoRoot)) {
502
+ for (const row of computeOverview(root, { work, cwd, followTargets: false, warnings, context, skipWorks: new Set(groups.get(root).keys()), repoLabel: shellArg(pathLabel(cwd, root)) })) {
503
+ if (!groups.get(root).has(row.work)) groups.get(root).set(row.work, row);
504
+ }
505
+ }
506
+ }
507
+ const pending = [];
508
+ const repos = [];
509
+ for (const [repo, byWork] of groups) {
510
+ const works = [...byWork.values()].sort((a, b) => a.work.localeCompare(b.work)).map((row) => ({
511
+ ...row, repo, repoLabel: pathLabel(cwd, repo),
512
+ ...(row.targetRepo ? { targetLabel: pathLabel(cwd, row.targetRepo) } : {}),
513
+ }));
514
+ const settled = { total: 0, closed: 0, cancelled: 0, merged: 0 };
515
+ for (const row of works) {
516
+ if (allRepos && row.stage === "RUNNING" && row.latest?.source === "runtime") {
517
+ const ledger = EventLedger.open(join(repo, ".buildbeat", "runtime", "runs", row.latest.id, "events.jsonl"));
518
+ const live = describeLiveness({ repoRoot: repo, runId: row.latest.id, ledger });
519
+ if (live.inFlight?.stalled) row.stage = "STALLED";
520
+ }
521
+ const kind = row.stage === "CLOSED" ? "closed" : row.stage === "CANCELLED" ? "cancelled" : row.stage === "MERGED" && row.hasRelease === false ? "merged" : null;
522
+ row.settled = Boolean(kind);
523
+ if (kind) { settled[kind]++; settled.total++; }
524
+ }
525
+ repos.push({ repo, works, settled });
526
+ for (const row of listInbox(repo)) {
527
+ if (work && row.work !== work && !row.corrupted) continue;
528
+ if (!allRepos && repo !== repoRoot && !byWork.has(row.work) && !row.corrupted) continue;
529
+ if (repo === repoRoot && primary.some((item) => item.work === row.work && item.repo && item.repo !== repoRoot)) continue;
530
+ pending.push({ ...row, repo, ...(byWork.get(row.work)?.config ? { config: byWork.get(row.work).config } : {}) });
531
+ }
532
+ }
533
+ return { repos, pending, warnings: [...new Set(warnings)] };
534
+ }
@@ -6,7 +6,8 @@ One Work owns one user-visible result. Continue safe authorized work to its
6
6
  acceptance condition; intermediate commits are progress, not completion.
7
7
 
8
8
  Use accept once the user has accepted the scope, run to execute/continue, status
9
- to inspect, decide for a real human decision, and check for diagnostics. Keep
9
+ to inspect (add --all-repos in a main repository to include its code repositories),
10
+ decide for a real human decision, and check for diagnostics. Keep
10
11
  candidate/evidence/approval binding, allowedPaths, read-only review, and failure
11
12
  budgets. Do not impersonate a human approval or silently weaken requirements.
12
13
 
@@ -1,9 +1,9 @@
1
1
  # BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
2
2
  # 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
3
- # 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
3
+ # 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
4
4
  repo: ../../..
5
5
  work: WORK-X
6
- # 家族名;--attempt new 自动编成 RUN-X-01/02…
6
+ # 家族名;run 自动编成 RUN-X-01/02…,--new 开新一轮
7
7
  run: RUN-X
8
8
  # builder / fixer 只能改这些目录;越界改动不成为候选
9
9
  allowedPaths: