@haiyangbg/buildbeat 2.0.0-beta.4 → 2.0.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.
@@ -16,12 +16,13 @@ import { join } from "node:path";
16
16
  import { EventLedger } from "../storage/event-ledger.js";
17
17
  import { latestAdjudications, readFindingsAccount } from "./findings.js";
18
18
  import { nextReply } from "./notify.js";
19
+ import { computeWorkCost, renderWorkCost } from "./work-cost.js";
19
20
 
20
21
  function sha256File(path) {
21
22
  return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
22
23
  }
23
24
 
24
- function readJsonl(path) {
25
+ export function readJsonl(path) {
25
26
  if (!existsSync(path)) {
26
27
  return [];
27
28
  }
@@ -38,7 +39,7 @@ function readJsonl(path) {
38
39
  .filter(Boolean);
39
40
  }
40
41
 
41
- function artifactStatus(workDir, decisions, artifact) {
42
+ export function artifactStatus(workDir, decisions, artifact) {
42
43
  const path = join(workDir, `${artifact}.md`);
43
44
  if (!existsSync(path)) {
44
45
  return { exists: false, accepted: false, stale: false };
@@ -93,6 +94,8 @@ function runsFor(repoRoot, workId) {
93
94
  candidate: state.workspaces[state.run.id]?.candidate ?? null,
94
95
  createdAt: ledger.events[0]?.ts ?? null,
95
96
  lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
97
+ workflow: state.run.workflowRef ?? null,
98
+ steps: Object.keys(state.steps),
96
99
  state,
97
100
  source: "runtime",
98
101
  });
@@ -118,6 +121,8 @@ function runsFor(repoRoot, workId) {
118
121
  candidate: record.workspaces?.[entry]?.candidate ?? null,
119
122
  createdAt: record.startedAt ?? null,
120
123
  lastAt: record.finishedAt ?? null,
124
+ workflow: record.workflow ?? null,
125
+ steps: Object.keys(record.attempts ?? {}),
121
126
  state: null,
122
127
  source: "run-record",
123
128
  });
@@ -156,14 +161,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
156
161
  const runs = runsFor(repoRoot, workId);
157
162
  const live = runs.filter((run) => run.status !== "SUPERSEDED");
158
163
  const latest = live[live.length - 1] ?? null;
159
- let merged = false;
160
- if (latest?.candidate) {
161
- merged = isAncestor(repoRoot, latest.candidate, mainRef);
162
- }
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");
163
181
 
164
182
  let stage;
165
183
  let next;
166
- if (!intent.exists) {
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) {
167
189
  stage = "NO_INTENT";
168
190
  next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
169
191
  } else if (!latest) {
@@ -181,7 +203,7 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
181
203
  next =
182
204
  configs.length > 0
183
205
  ? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
184
- : `no run-config in delivery/work/${workId}: write one, or record the work as closed in decisions.jsonl if it was doc-only`;
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`;
185
207
  }
186
208
  } else if (latest.status === "RUNNING") {
187
209
  stage = "RUNNING";
@@ -190,16 +212,21 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
190
212
  stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
191
213
  const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
192
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` : "");
193
225
  } else if (latest.status === "SUCCEEDED") {
194
- if (merged) {
195
- stage = "MERGED";
196
- next = `release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}`;
197
- } else {
198
- stage = "MERGE_READY";
199
- next = latest.candidate
200
- ? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
201
- : "run succeeded without a candidate; nothing to merge";
202
- }
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";
203
230
  } else {
204
231
  stage = `STOPPED_${latest.status}`;
205
232
  next = plan.accepted
@@ -214,10 +241,12 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
214
241
  envFacts,
215
242
  openFindings,
216
243
  runs: runs.length,
244
+ cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
217
245
  latest: latest
218
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 }
219
247
  : null,
220
248
  merged,
249
+ mergedCandidate: mergedRun?.candidate ?? null,
221
250
  next,
222
251
  });
223
252
  }
@@ -242,7 +271,8 @@ export function renderOverview(rows) {
242
271
  for (const row of rows) {
243
272
  lines.push(`${row.work} ${row.stage}`);
244
273
  const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
245
- if (row.openFindings > 0) {
274
+ const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
275
+ if (row.openFindings > 0 && !settled) {
246
276
  // Unadjudicated, not necessarily unresolved: a fixer may have closed
247
277
  // them without anyone recording a verdict. The number says "nobody
248
278
  // ruled on these", which is exactly what a human should know.
@@ -252,8 +282,15 @@ export function renderOverview(rows) {
252
282
  parts.push("env-facts ✓");
253
283
  }
254
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
+ }
255
290
  if (row.latest) {
256
- const cand = row.latest.candidate ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.merged ? " (merged)" : ""}` : "";
291
+ const cand = row.latest.candidate
292
+ ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
293
+ : "";
257
294
  const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
258
295
  const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
259
296
  lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
@@ -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"
@@ -7,14 +7,15 @@
7
7
 
8
8
  ## 0. v2 下工作怎么发生(一页流程)
9
9
 
10
- 1. **工作项**:每件事一个 `delivery/work/<WORK-ID>/`(`intent.md` 为什么做 + `plan.md` 怎么做,可选 `env-facts.md` 记踩出来的环境事实);被 digest 绑定接受(`buildbeat-v2 accept`)前只是草稿、不产生义务。
10
+ 1. **工作项**:每件事一个 `delivery/work/<WORK-ID>/`(`intent.md` 为什么做 + **止损线**(最多几个 Run / 几轮 review / 几小时,越线先问所有者"继续还是砍")+ `plan.md` 怎么做,可选 `env-facts.md` 记踩出来的环境事实);被 digest 绑定接受(`buildbeat-v2 accept`)前只是草稿、不产生义务。`overview` 的 `cost:` 行就是止损线的读数。
11
11
  2. **代码工作跑 Run**:`buildbeat-v2 start --config <run-config.yaml> --attempt new` → 隔离 worktree 内 Build→Verify→Fix→Review 自动闭环 → **停在合并决定**。push、合并、部署永远是人批之后的人类动作。
12
12
  3. **人怎么知道该做什么**:`buildbeat-v2 overview --repo .` 回答「每件事走到哪、下一步该谁」;`inbox` 只列等人批的 Run,每条后面附可复制的下一句命令;`status --run <RUN>` 回答「还在动吗、动了多久、卡没卡」。
13
13
  4. **上线**:生产动作是人的;`release-readback` 预设 + `release` 风险预设把「做之前回读 → 人做 → 做之后回读 → 观察 → 人关窗」记成 L4 证据,任一步失败即停人批。
14
14
  5. **observe 盯生产**:`buildbeat-v2 observe run --config .buildbeat/observe.yaml` 一次=一轮只读体检;异常分层(落账→只读诊断→intent 草稿入队 `delivery/observe/intents/`),草稿**绝不自动执行**,人用 `observe triage` 分诊。
15
15
  6. **拍板台账**:平台级真实决策包一行进 `pm/decisions.md`;Run 级批准落各 Work 的 `decisions.jsonl`;finding 裁决落 `review-findings.jsonl`。契约在 `contracts/`。
16
16
  7. **通知**:`.buildbeat/notify.yaml` 配一条通道(URL 只能来自环境变量),Run 停在人批 / 终态 / 疑似卡住会来找人。
17
- 8. **打扫**:终态 Run 留下的工作树用 `buildbeat-v2 gc --repo .` 清(默认只出计划)。
17
+ 8. **打扫**:终态 Run 留下的工作树用 `buildbeat-v2 gc --repo .` 清(默认只出计划)。工作树在仓内 `.buildbeat/worktrees/`:`.gitignore` 排除 `.buildbeat/runtime/` 与 `.buildbeat/worktrees/`,测试框架的收集范围也要排除 `**/.buildbeat/**`(vitest `exclude`、jest `testPathIgnorePatterns`、pytest `norecursedirs`),否则主干测试会把旧候选的用例一起跑。
18
+ 9. **worker 环境事实(写进 worker prompt / 信封)**:worker 的沙箱通常**不能监听端口**,需要起服务或绑定 loopback 的集成测试交给 verify 步,worker 只跑单测与静态检查,不要反复尝试;PATH 只认 POSIX 工具(`grep -E` 不用 `rg`,`find` 不用 `fd`)或在 `requires:` 里声明;verify / 包装脚本发现环境不满足(命令不在 PATH、端口被占、后端 404)就 `exit 75`,内核会当基础设施故障停人、不派 fixer、不扣预算。
18
19
 
19
20
  ## 1. 工作包路由 —— Builder 端到端负责,会话按 AI 视角隔离
20
21