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

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/SKILL.md +86 -2
  3. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  4. package/docs/CLI.md +1 -1
  5. package/docs/EXECUTION-PLAN.md +2 -2
  6. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  7. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  8. package/docs/RELEASING.md +1 -1
  9. package/docs/V2-D2-DECISION-CARD.md +2 -2
  10. package/docs/V2-DECISIONS.md +2 -2
  11. package/docs/V2-ITERATION-01.md +13 -13
  12. package/docs/V2-ITERATION-06.md +2 -2
  13. package/docs/V2-ITERATION-08.md +62 -0
  14. package/docs/V2-PLAN.md +6 -6
  15. package/docs/V2-PROPOSAL.md +2 -2
  16. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  17. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  19. package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -0
  20. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  21. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  22. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  23. package/docs/v2/RFC-0001-product-definition.md +2 -2
  24. package/docs/v2/SPEC-0001-events-v1.md +7 -6
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +4 -0
  27. package/docs/v2/guide/02-workflow-guide.md +48 -1
  28. package/docs/v2/guide/04-adapter-guide.md +4 -0
  29. package/docs/v2/guide/05-worker-contract.md +10 -0
  30. package/docs/v2/guide/06-evidence-guide.md +4 -0
  31. package/docs/v2/guide/07-approval-guide.md +47 -0
  32. package/docs/v2/guide/10-recovery.md +25 -3
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +37 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/mock.js +9 -2
  38. package/src/v2/adapters/shell.js +87 -14
  39. package/src/v2/cli/run.js +607 -30
  40. package/src/v2/domain/event-registry.js +1 -0
  41. package/src/v2/engine/reducer.js +29 -1
  42. package/src/v2/engine/workflow.js +8 -1
  43. package/src/v2/evidence/collector.js +14 -3
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/runtime/cache.js +124 -0
  47. package/src/v2/runtime/decisions.js +80 -0
  48. package/src/v2/runtime/env-contract.js +35 -1
  49. package/src/v2/runtime/envelope.js +183 -0
  50. package/src/v2/runtime/gc.js +182 -0
  51. package/src/v2/runtime/liveness.js +193 -0
  52. package/src/v2/runtime/metrics.js +8 -0
  53. package/src/v2/runtime/notify.js +223 -0
  54. package/src/v2/runtime/orchestrator.js +267 -19
  55. package/src/v2/runtime/overview.js +301 -0
  56. package/src/v2/runtime/run-record.js +3 -0
  57. package/src/v2/runtime/work-cost.js +147 -0
  58. package/src/v2/workspace/workspace-manager.js +14 -1
  59. package/templates/contracts/PROTOCOL.md +4 -0
  60. package/templates/gitignore.template +5 -0
  61. package/templates/scripts/bus-check.sh +37 -12
  62. package/templates/v2/AGENTS.md +73 -0
  63. package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
@@ -0,0 +1,301 @@
1
+ // Work-level overview (iteration 08, C5): "where is this thing, and who
2
+ // moves next" — the question the owner opened three sessions with
3
+ // ("X 上线了吗 / 离上线还差多远 / 从每个系统说待办") and that inbox,
4
+ // which only knows about runs waiting on a human, cannot answer.
5
+ //
6
+ // Everything here is derived: work directories and decision ledgers in the
7
+ // Git plane, run ledgers in the runtime plane (run-records fill in for runs
8
+ // whose runtime was wiped), and git ancestry for "merged". Nothing is
9
+ // written.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { EventLedger } from "../storage/event-ledger.js";
17
+ import { latestAdjudications, readFindingsAccount } from "./findings.js";
18
+ import { nextReply } from "./notify.js";
19
+ import { computeWorkCost, renderWorkCost } from "./work-cost.js";
20
+
21
+ function sha256File(path) {
22
+ return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
23
+ }
24
+
25
+ export function readJsonl(path) {
26
+ if (!existsSync(path)) {
27
+ return [];
28
+ }
29
+ return readFileSync(path, "utf8")
30
+ .split("\n")
31
+ .filter(Boolean)
32
+ .map((line) => {
33
+ try {
34
+ return JSON.parse(line);
35
+ } catch {
36
+ return null;
37
+ }
38
+ })
39
+ .filter(Boolean);
40
+ }
41
+
42
+ export function artifactStatus(workDir, decisions, artifact) {
43
+ const path = join(workDir, `${artifact}.md`);
44
+ if (!existsSync(path)) {
45
+ return { exists: false, accepted: false, stale: false };
46
+ }
47
+ const digest = sha256File(path);
48
+ const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
49
+ const latest = accepts[accepts.length - 1];
50
+ if (!latest) {
51
+ return { exists: true, accepted: false, stale: false };
52
+ }
53
+ const stale = latest.subject?.digest !== digest;
54
+ return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
55
+ }
56
+
57
+ function isAncestor(repoRoot, sha, ref) {
58
+ try {
59
+ execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function headRef(repoRoot) {
67
+ try {
68
+ return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
69
+ } catch {
70
+ return "HEAD";
71
+ }
72
+ }
73
+
74
+ // Runs for a work: runtime ledgers first, run-records for the rest.
75
+ function runsFor(repoRoot, workId) {
76
+ const runs = new Map();
77
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
78
+ if (existsSync(runsDir)) {
79
+ for (const entry of readdirSync(runsDir)) {
80
+ const path = join(runsDir, entry, "events.jsonl");
81
+ if (!existsSync(path)) {
82
+ continue;
83
+ }
84
+ const ledger = EventLedger.open(path);
85
+ const state = ledger.state;
86
+ if (!state.run || state.run.work !== workId) {
87
+ continue;
88
+ }
89
+ runs.set(state.run.id, {
90
+ id: state.run.id,
91
+ status: state.run.status,
92
+ terminal: state.terminal,
93
+ pendingHuman: state.pendingHuman,
94
+ candidate: state.workspaces[state.run.id]?.candidate ?? null,
95
+ createdAt: ledger.events[0]?.ts ?? null,
96
+ lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
97
+ workflow: state.run.workflowRef ?? null,
98
+ steps: Object.keys(state.steps),
99
+ state,
100
+ source: "runtime",
101
+ });
102
+ }
103
+ }
104
+ const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
105
+ if (existsSync(recordsDir)) {
106
+ for (const entry of readdirSync(recordsDir)) {
107
+ if (runs.has(entry)) {
108
+ continue;
109
+ }
110
+ const path = join(recordsDir, entry, "run-record.json");
111
+ if (!existsSync(path)) {
112
+ continue;
113
+ }
114
+ try {
115
+ const record = JSON.parse(readFileSync(path, "utf8"));
116
+ runs.set(entry, {
117
+ id: entry,
118
+ status: record.terminal?.status ?? "UNKNOWN",
119
+ terminal: record.terminal ?? null,
120
+ pendingHuman: null,
121
+ candidate: record.workspaces?.[entry]?.candidate ?? null,
122
+ createdAt: record.startedAt ?? null,
123
+ lastAt: record.finishedAt ?? null,
124
+ workflow: record.workflow ?? null,
125
+ steps: Object.keys(record.attempts ?? {}),
126
+ state: null,
127
+ source: "run-record",
128
+ });
129
+ } catch {
130
+ // an unreadable record is reported as absent, not guessed
131
+ }
132
+ }
133
+ }
134
+ return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
135
+ }
136
+
137
+ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
138
+ const workRoot = join(repoRoot, "delivery", "work");
139
+ const rows = [];
140
+ if (!existsSync(workRoot)) {
141
+ return rows;
142
+ }
143
+ const mainRef = headRef(repoRoot);
144
+ for (const workId of readdirSync(workRoot).sort()) {
145
+ if (work && workId !== work) {
146
+ continue;
147
+ }
148
+ const workDir = join(workRoot, workId);
149
+ if (!existsSync(join(workDir, "intent.md")) && !existsSync(join(workDir, "plan.md")) && !existsSync(join(workDir, "decisions.jsonl")) && !existsSync(join(workDir, "runs"))) {
150
+ continue;
151
+ }
152
+ const decisions = readJsonl(join(workDir, "decisions.jsonl"));
153
+ const intent = artifactStatus(workDir, decisions, "intent");
154
+ const plan = artifactStatus(workDir, decisions, "plan");
155
+ const envFacts = existsSync(join(workDir, "env-facts.md"));
156
+ const findingRows = readFindingsAccount(repoRoot, workId);
157
+ const adjudicated = latestAdjudications(findingRows);
158
+ const openFindings = findingRows.filter(
159
+ (row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
160
+ ).length;
161
+ const runs = runsFor(repoRoot, workId);
162
+ const live = runs.filter((run) => run.status !== "SUPERSEDED");
163
+ const latest = live[live.length - 1] ?? null;
164
+ // "Merged" is a fact about any candidate of the work, not only the
165
+ // latest run's: a pilot's shipped candidate sat behind a CANCELLED run
166
+ // (its in-run review budget ran out and closure happened elsewhere) and
167
+ // overview reported the shipped work as STOPPED_CANCELLED.
168
+ const mergedRun =
169
+ [...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
170
+ const merged = Boolean(mergedRun);
171
+ const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
172
+ const isReleaseLane = (run) =>
173
+ Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
174
+
175
+ // A Work is closed by an explicit row in decisions.jsonl:
176
+ // {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
177
+ // A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
178
+ // stale close row can never hide something that still needs a human.
179
+ const closure = [...decisions].reverse().find((row) => row.transition === "close-work");
180
+ const liveRun = latest && (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
181
+
182
+ let stage;
183
+ let next;
184
+ if (closure && !liveRun) {
185
+ stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
186
+ const result = typeof closure.subject?.result === "string" && closure.subject.result.length > 0 ? closure.subject.result : "see decisions.jsonl";
187
+ next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
188
+ } else if (!intent.exists) {
189
+ stage = "NO_INTENT";
190
+ next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
191
+ } else if (!latest) {
192
+ if (!plan.exists) {
193
+ stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
194
+ next = intent.accepted
195
+ ? `write delivery/work/${workId}/plan.md, then accept it`
196
+ : `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
197
+ } else if (!plan.accepted) {
198
+ stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
199
+ next = `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact plan --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
200
+ } else {
201
+ stage = "READY_TO_RUN";
202
+ const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
203
+ next =
204
+ configs.length > 0
205
+ ? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
206
+ : `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`;
207
+ }
208
+ } else if (latest.status === "RUNNING") {
209
+ stage = "RUNNING";
210
+ next = `buildbeat-v2 status --repo ${repoLabel} --run ${latest.id}`;
211
+ } else if (latest.status === "WAITING_HUMAN") {
212
+ stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
213
+ const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
214
+ next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
215
+ } else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
216
+ // A release-readback lane that reached wait-close and was approved is
217
+ // a closed release window, not "nothing to merge".
218
+ stage = "RELEASED";
219
+ next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
220
+ } else if (merged) {
221
+ stage = "MERGED";
222
+ next =
223
+ `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}` +
224
+ (latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
225
+ } else if (latest.status === "SUCCEEDED") {
226
+ stage = "MERGE_READY";
227
+ next = latest.candidate
228
+ ? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
229
+ : "run succeeded without a candidate; nothing to merge";
230
+ } else {
231
+ stage = `STOPPED_${latest.status}`;
232
+ next = plan.accepted
233
+ ? `decide: retry (buildbeat-v2 start ... --attempt new) or close the work`
234
+ : `plan not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
235
+ }
236
+ rows.push({
237
+ work: workId,
238
+ stage,
239
+ intent,
240
+ plan,
241
+ envFacts,
242
+ openFindings,
243
+ runs: runs.length,
244
+ cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
245
+ latest: latest
246
+ ? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
247
+ : null,
248
+ merged,
249
+ mergedCandidate: mergedRun?.candidate ?? null,
250
+ next,
251
+ });
252
+ }
253
+ return rows;
254
+ }
255
+
256
+ function mark(status) {
257
+ if (!status.exists) {
258
+ return "–";
259
+ }
260
+ if (status.stale) {
261
+ return "stale";
262
+ }
263
+ return status.accepted ? "✓" : "draft";
264
+ }
265
+
266
+ export function renderOverview(rows) {
267
+ if (rows.length === 0) {
268
+ return "overview: no work items under delivery/work";
269
+ }
270
+ const lines = [];
271
+ for (const row of rows) {
272
+ lines.push(`${row.work} ${row.stage}`);
273
+ const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
274
+ const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
275
+ if (row.openFindings > 0 && !settled) {
276
+ // Unadjudicated, not necessarily unresolved: a fixer may have closed
277
+ // them without anyone recording a verdict. The number says "nobody
278
+ // ruled on these", which is exactly what a human should know.
279
+ parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
280
+ }
281
+ if (row.envFacts) {
282
+ parts.push("env-facts ✓");
283
+ }
284
+ lines.push(` ${parts.join(" · ")}`);
285
+ if (row.cost) {
286
+ // What this work has already consumed across every run, superseded
287
+ // ones included: the number a "continue or cut" decision needs.
288
+ lines.push(` cost: ${renderWorkCost(row.cost)}`);
289
+ }
290
+ if (row.latest) {
291
+ const cand = row.latest.candidate
292
+ ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
293
+ : "";
294
+ const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
295
+ const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
296
+ lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
297
+ }
298
+ lines.push(` next: ${row.next}`);
299
+ }
300
+ return lines.join("\n");
301
+ }
@@ -9,6 +9,7 @@ import { join, relative } from "node:path";
9
9
 
10
10
  import { canonicalJson } from "../storage/event-ledger.js";
11
11
  import { normalizeRepoRef } from "./repo-ref.js";
12
+ import { ledgerCost } from "./work-cost.js";
12
13
 
13
14
  const KERNEL = { kind: "kernel", id: "orchestrator" };
14
15
 
@@ -40,12 +41,14 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
40
41
  const record = {
41
42
  run: first.run,
42
43
  work: first.work,
44
+ workflow: state.run?.workflowRef ?? null,
43
45
  terminal: state.terminal,
44
46
  events: { from: first.seq, to: last.seq, lastDigest: last.digest },
45
47
  startedAt: first.ts,
46
48
  finishedAt: last.ts,
47
49
  attempts,
48
50
  budgets: state.budgets,
51
+ cost: ledgerCost(ledger),
49
52
  workspaces,
50
53
  evidence,
51
54
  decisions: state.decisions,
@@ -0,0 +1,147 @@
1
+ // Work-level cost (iteration 09): what a work has already consumed across
2
+ // every run of it, superseded ones included. Per-run budgets were bypassed
3
+ // by "one run per review round" (a pilot work ran 21 runs and 9 review
4
+ // rounds while the preset's two-round cap never fired), and a work that ate
5
+ // ten runs and a day was cut by the owner as "cost > benefit" with no
6
+ // number in front of them. Derived only: runtime ledgers first, run-records
7
+ // for runs whose runtime was wiped. Nothing is written.
8
+
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { EventLedger } from "../storage/event-ledger.js";
13
+ import { readFindingsAccount } from "./findings.js";
14
+
15
+ function emptyCost() {
16
+ return {
17
+ runs: 0,
18
+ reviewRounds: 0,
19
+ findings: 0,
20
+ humanWaits: 0,
21
+ infraFailures: 0,
22
+ workerMs: 0,
23
+ firstAt: null,
24
+ lastAt: null,
25
+ };
26
+ }
27
+
28
+ // Cost facts of one ledger: review rounds, human waits, infra failures and
29
+ // worker wall time (STEP_STARTED → STEP_FINISHED per attempt).
30
+ export function ledgerCost(ledger) {
31
+ const cost = { reviewRounds: 0, humanWaits: 0, infraFailures: 0, workerMs: 0 };
32
+ const open = new Map();
33
+ for (const event of ledger.events) {
34
+ if (event.type === "STEP_STARTED") {
35
+ open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
36
+ } else if (event.type === "STEP_FINISHED") {
37
+ const key = `${event.data.step}#${event.data.attempt}`;
38
+ const startedAt = open.get(key);
39
+ if (startedAt !== undefined) {
40
+ const ms = Date.parse(event.ts) - startedAt;
41
+ if (Number.isFinite(ms) && ms > 0) {
42
+ cost.workerMs += ms;
43
+ }
44
+ open.delete(key);
45
+ }
46
+ if (event.data.step === "review") {
47
+ cost.reviewRounds += 1;
48
+ }
49
+ if (event.data.infra === true) {
50
+ cost.infraFailures += 1;
51
+ }
52
+ } else if (event.type === "HUMAN_REQUESTED") {
53
+ cost.humanWaits += 1;
54
+ }
55
+ }
56
+ return cost;
57
+ }
58
+
59
+ export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
60
+ const cost = emptyCost();
61
+ const seen = new Set();
62
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
63
+ if (existsSync(runsDir)) {
64
+ for (const entry of readdirSync(runsDir)) {
65
+ const path = join(runsDir, entry, "events.jsonl");
66
+ if (!existsSync(path)) {
67
+ continue;
68
+ }
69
+ const ledger = EventLedger.open(path);
70
+ const state = ledger.state;
71
+ if (ledger.corruption || !state.run || state.run.work !== workId) {
72
+ continue;
73
+ }
74
+ seen.add(state.run.id);
75
+ if (state.run.id === excludeRun) {
76
+ continue;
77
+ }
78
+ const row = ledgerCost(ledger);
79
+ cost.runs += 1;
80
+ cost.reviewRounds += row.reviewRounds;
81
+ cost.humanWaits += row.humanWaits;
82
+ cost.infraFailures += row.infraFailures;
83
+ cost.workerMs += row.workerMs;
84
+ track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
85
+ }
86
+ }
87
+ const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
88
+ if (existsSync(recordsDir)) {
89
+ for (const entry of readdirSync(recordsDir)) {
90
+ if (seen.has(entry) || entry === excludeRun) {
91
+ continue;
92
+ }
93
+ const path = join(recordsDir, entry, "run-record.json");
94
+ if (!existsSync(path)) {
95
+ continue;
96
+ }
97
+ let record;
98
+ try {
99
+ record = JSON.parse(readFileSync(path, "utf8"));
100
+ } catch {
101
+ continue;
102
+ }
103
+ cost.runs += 1;
104
+ // Records written before iteration 09 carry attempts and decisions
105
+ // only; the cost block is preferred when present.
106
+ cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
107
+ cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
108
+ cost.infraFailures += record.cost?.infraFailures ?? 0;
109
+ cost.workerMs += record.cost?.workerMs ?? 0;
110
+ track(cost, record.startedAt, record.finishedAt);
111
+ }
112
+ }
113
+ cost.findings = readFindingsAccount(repoRoot, workId).filter((row) => row.kind === "finding").length;
114
+ return cost;
115
+ }
116
+
117
+ function track(cost, firstAt, lastAt) {
118
+ if (firstAt && (!cost.firstAt || firstAt < cost.firstAt)) {
119
+ cost.firstAt = firstAt;
120
+ }
121
+ if (lastAt && (!cost.lastAt || lastAt > cost.lastAt)) {
122
+ cost.lastAt = lastAt;
123
+ }
124
+ }
125
+
126
+ export function formatWorkerMs(ms) {
127
+ if (!ms || ms < 1000) {
128
+ return "0s";
129
+ }
130
+ if (ms < 60000) {
131
+ return `${Math.round(ms / 1000)}s`;
132
+ }
133
+ const totalMinutes = Math.round(ms / 60000);
134
+ const hours = Math.floor(totalMinutes / 60);
135
+ const minutes = totalMinutes % 60;
136
+ return hours > 0 ? `${hours}h${String(minutes).padStart(2, "0")}m` : `${minutes}m`;
137
+ }
138
+
139
+ export function renderWorkCost(cost) {
140
+ return [
141
+ `review rounds ${cost.reviewRounds}`,
142
+ `findings ${cost.findings}`,
143
+ `human waits ${cost.humanWaits}`,
144
+ ...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
145
+ `worker ${formatWorkerMs(cost.workerMs)}`,
146
+ ].join(" · ");
147
+ }
@@ -4,7 +4,7 @@
4
4
  // deleted here — the pinned candidate must stay reachable for evidence.
5
5
 
6
6
  import { execFileSync } from "node:child_process";
7
- import { existsSync, mkdirSync, rmSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
10
10
  export class WorkspaceError extends Error {
@@ -44,6 +44,19 @@ export function acquireLock(repoRoot, runId) {
44
44
  return lockPath;
45
45
  }
46
46
 
47
+ // Run ids currently holding a lock in this repository (the repository-wide
48
+ // active-run marker excluded): who a blocked `start` is queued behind.
49
+ export function listHeldRunLocks(repoRoot) {
50
+ const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
51
+ if (!existsSync(lockDir)) {
52
+ return [];
53
+ }
54
+ return readdirSync(lockDir)
55
+ .filter((entry) => entry.endsWith(".lock") && entry !== "active-run.lock")
56
+ .map((entry) => entry.slice(0, -".lock".length))
57
+ .sort();
58
+ }
59
+
47
60
  export function releaseLock(repoRoot, runId) {
48
61
  const lockPath = join(repoRoot, ".buildbeat", "runtime", "locks", `${runId}.lock`);
49
62
  rmSync(lockPath, { recursive: true, force: true });
@@ -13,6 +13,10 @@
13
13
  <!-- buildbeat-multirepo-map:v1
14
14
  repo=<代码子仓1>|contract=contracts/PROTOCOL.md|deployment=<bus-baseline.json app 名或 n/a>
15
15
  -->
16
+ <!-- map 行格式:repo=<子仓路径>|contract=<contracts/*.md 或 n/a>|deployment=<bus-baseline.json app 名或 n/a>[|changelog=<该仓内模块 CHANGELOG 路径>]
17
+ · changelog= 给多模块仓用(根下没有 CHANGELOG,由某个模块 CHANGELOG 承载契约版本);缺省 <repo>/CHANGELOG.md。
18
+ · contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),只登记不核对;不得拿它掩盖真实的契约关系。
19
+ · 被核对的 CHANGELOG 首个已发布 H2 须以契约快照版本开头,如 `## [v1.3 · Deployed 2026-09-05 · <sha> · <流水线>]`。 -->
16
20
 
17
21
  ---
18
22
 
@@ -14,6 +14,11 @@
14
14
  # verify-status 的「上次全绿」标记(本地实查产物,不入 git)
15
15
  .last-green-*
16
16
 
17
+ # BuildBeat v2 运行时面与隔离工作树(可随时整删重建;不入 git)
18
+ # 同时让 rg / 尊重 .gitignore 的工具不再走进旧工作树;vitest / jest 等要另配 exclude,见 docs/v2/guide/02-workflow-guide.md
19
+ .buildbeat/runtime/
20
+ .buildbeat/worktrees/
21
+
17
22
  # 代码子仓(各自独立 git,meta 仓不跟踪;按实际仓名替换)
18
23
  /<代码仓1>/
19
24
  /<代码仓2>/
@@ -438,14 +438,18 @@ check_multirepo_drift() {
438
438
  multirepo_line="${multirepo_line%$'\r'}"
439
439
  [ -n "$multirepo_line" ] || continue
440
440
  if ! printf '%s\n' "$multirepo_line" \
441
- | grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]+$'; then
441
+ | grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]+(\|changelog=[^|]+)?$'; then
442
442
  multirepo_map_invalid=1
443
443
  continue
444
444
  fi
445
- IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field <<< "$multirepo_line"
445
+ IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field multirepo_changelog_field <<< "$multirepo_line"
446
446
  multirepo_repo="${multirepo_repo_field#repo=}"
447
447
  multirepo_contract="${multirepo_contract_field#contract=}"
448
448
  multirepo_deployment="${multirepo_deployment_field#deployment=}"
449
+ # 可选第 4 字段 changelog=<repo 内的 CHANGELOG 路径>:多模块仓没有根 CHANGELOG 时,
450
+ # 由 map 显式指定承载契约版本的模块 CHANGELOG;缺省仍为 <repo>/CHANGELOG.md。
451
+ multirepo_changelog_override="${multirepo_changelog_field#changelog=}"
452
+ [ -n "$multirepo_changelog_field" ] || multirepo_changelog_override=""
449
453
  multirepo_repo_trimmed="$(printf '%s' "$multirepo_repo" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
450
454
  multirepo_contract_trimmed="$(printf '%s' "$multirepo_contract" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
451
455
  multirepo_deployment_trimmed="$(printf '%s' "$multirepo_deployment" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
@@ -454,12 +458,23 @@ check_multirepo_drift() {
454
458
  || [ "$multirepo_deployment" != "$multirepo_deployment_trimmed" ] \
455
459
  || ! multirepo_repo_path_safe "$multirepo_repo" \
456
460
  || ! multirepo_map_value_safe "$multirepo_contract" 240 \
457
- || ! printf '%s\n' "$multirepo_contract" | grep -Eq '^contracts/[^/].*\.md$' \
461
+ || ! { [ "$multirepo_contract" = "n/a" ] \
462
+ || printf '%s\n' "$multirepo_contract" | grep -Eq '^contracts/[^/].*\.md$'; } \
458
463
  || ! multirepo_map_value_safe "$multirepo_deployment" 100; then
459
464
  multirepo_map_invalid=1
460
465
  continue
461
466
  fi
462
- printf '%s\t%s\t%s\n' "$multirepo_repo" "$multirepo_contract" "$multirepo_deployment" >> "$multirepo_records"
467
+ # contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),
468
+ # 只登记入 inventory、不做版本核对;不得用它掩盖真实存在的契约版本关系。
469
+ if [ -n "$multirepo_changelog_override" ]; then
470
+ if ! multirepo_repo_path_safe "$multirepo_changelog_override" \
471
+ || [ "${#multirepo_changelog_override}" -gt 240 ] \
472
+ || ! printf '%s\n' "$multirepo_changelog_override" | grep -Eq "^$(printf '%s' "$multirepo_repo" | sed 's/[.[\*^$]/\\&/g')/.+/CHANGELOG\.md$"; then
473
+ multirepo_map_invalid=1
474
+ continue
475
+ fi
476
+ fi
477
+ printf '%s\t%s\t%s\t%s\n' "$multirepo_repo" "$multirepo_contract" "$multirepo_deployment" "$multirepo_changelog_override" >> "$multirepo_records"
463
478
  printf '%s\n' "$multirepo_repo" >> "$multirepo_expected"
464
479
  done < "$multirepo_map_raw"
465
480
 
@@ -485,9 +500,9 @@ check_multirepo_drift() {
485
500
  add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo is absent from buildbeat-multirepo-map:v1." "$multirepo_repo"
486
501
  done < "$multirepo_discovered"
487
502
 
488
- while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment; do
503
+ while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment multirepo_changelog_override; do
489
504
  [ -n "$multirepo_repo" ] || continue
490
- multirepo_changelog="$multirepo_repo/CHANGELOG.md"
505
+ multirepo_changelog="${multirepo_changelog_override:-$multirepo_repo/CHANGELOG.md}"
491
506
  multirepo_issue=0
492
507
  multirepo_drift=0
493
508
  multirepo_changelog_ok=0
@@ -544,6 +559,10 @@ check_multirepo_drift() {
544
559
  ;;
545
560
  esac
546
561
 
562
+ if [ "$multirepo_contract" = "n/a" ] && [ "$multirepo_deployment" = "n/a" ]; then
563
+ echo " · $multirepo_repo 已登记,无契约/部署版本域(contract=n/a, deployment=n/a),不做版本核对"
564
+ continue
565
+ fi
547
566
  if [ -L "$multirepo_changelog" ] \
548
567
  || { [ -e "$multirepo_changelog" ] \
549
568
  && path_uses_symlink_component "$ROOT_PHYS/$multirepo_changelog"; }; then
@@ -557,8 +576,8 @@ check_multirepo_drift() {
557
576
  "the mapped CHANGELOG version source was not readable"
558
577
  multirepo_issue=1
559
578
  elif [ ! -f "$multirepo_changelog" ]; then
560
- echo " ⚠️ $multirepo_repo 缺少可读 regular CHANGELOG.md"
561
- add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG.md version source." "$multirepo_changelog"
579
+ echo " ⚠️ $multirepo_repo 缺少可读 regular $multirepo_changelog"
580
+ add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG version source: $multirepo_changelog." "$multirepo_changelog"
562
581
  multirepo_issue=1
563
582
  else
564
583
  multirepo_changelog_version="$(read_changelog_head_version "$multirepo_changelog" || true)"
@@ -572,8 +591,14 @@ check_multirepo_drift() {
572
591
  fi
573
592
 
574
593
  multirepo_contract_rc=0
575
- validate_reference "$multirepo_contract" "$multirepo_map_path" || multirepo_contract_rc=$?
576
- if [ "$multirepo_contract_rc" -ne 0 ]; then
594
+ if [ "$multirepo_contract" = "n/a" ]; then
595
+ multirepo_contract_rc=0
596
+ else
597
+ validate_reference "$multirepo_contract" "$multirepo_map_path" || multirepo_contract_rc=$?
598
+ fi
599
+ if [ "$multirepo_contract" = "n/a" ]; then
600
+ :
601
+ elif [ "$multirepo_contract_rc" -ne 0 ]; then
577
602
  echo " ⚠️ $multirepo_repo 的契约版本来源不可读:$multirepo_contract"
578
603
  if [ "$multirepo_contract_rc" -eq 4 ]; then
579
604
  add_scan_boundary "symlink" "$multirepo_contract" \
@@ -672,9 +697,9 @@ check_multirepo_drift() {
672
697
  add_finding "sync.multirepo_drift" "conflict" "Version sources disagree for repo=$multirepo_repo: $multirepo_changelog=$multirepo_changelog_fact; $multirepo_contract=$multirepo_contract_fact; $multirepo_deployment_fact." "$multirepo_changelog"
673
698
  elif [ "$multirepo_issue" -eq 0 ]; then
674
699
  if [ "$multirepo_deployment" = "n/a" ]; then
675
- echo " ✅ $multirepo_repo 多仓版本一致: CHANGELOG.md ↔ $multirepo_contract (deployment=n/a)"
700
+ echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract (deployment=n/a)"
676
701
  else
677
- echo " ✅ $multirepo_repo 多仓版本一致: CHANGELOG.md ↔ $multirepo_contract ↔ $multirepo_baseline#apps.$multirepo_deployment.imageTag"
702
+ echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract ↔ $multirepo_baseline#apps.$multirepo_deployment.imageTag"
678
703
  fi
679
704
  fi
680
705
  done < "$multirepo_records"