@haiyangbg/buildbeat 4.0.0 → 4.2.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.
@@ -14,6 +14,8 @@ import {
14
14
  readdirSync,
15
15
  } from "node:fs";
16
16
  import { join } from "node:path";
17
+ import { spawnSync } from "node:child_process";
18
+ import { adjudicateFinding, fingerprintFinding, recordReviewFindings } from "./findings.js";
17
19
 
18
20
  import {
19
21
  deliveryChecks,
@@ -23,6 +25,7 @@ import {
23
25
  import { EventLedger } from "../storage/event-ledger.js";
24
26
  import {
25
27
  acquireLock,
28
+ listChangedPaths,
26
29
  readback,
27
30
  releaseLock,
28
31
  } from "../workspace/workspace-manager.js";
@@ -105,7 +108,7 @@ function recordDecisionFile(repoRoot, work, line) {
105
108
  export function approveRun(
106
109
  repoRoot,
107
110
  runId,
108
- { by = "human", transition, ts, policies } = {},
111
+ { by = "human", transition, ts, policies, candidate } = {},
109
112
  ) {
110
113
  // Read, check and write under the run lock: a ledger read before the
111
114
  // lock may be stale by the time it is written to.
@@ -113,6 +116,12 @@ export function approveRun(
113
116
  try {
114
117
  const ledger = openWaiting(repoRoot, runId);
115
118
  const pending = ledger.state.pendingHuman;
119
+ const repaired = ledger.events.some((event) => event.type === "DECISION_RECORDED" &&
120
+ event.data.transition === "enter-wait-merge" && (event.data.adopted || event.data.decision === "fix"));
121
+ if ((candidate && candidate !== pending.subject.candidate) ||
122
+ (pending.kind === "final-decision" && repaired && !candidate)) {
123
+ throw new DecisionError("approval stale: read the current decision card and bind approval to its candidate");
124
+ }
116
125
  if (!transition) {
117
126
  throw new DecisionError(
118
127
  `an approval must name its transition explicitly (pending: ${pending.transition})`,
@@ -291,7 +300,7 @@ export function approveRun(
291
300
  export function adoptCandidate(
292
301
  repoRoot,
293
302
  runId,
294
- { sha, by = "human", resumeAt, ts } = {},
303
+ { sha, by = "human", resumeAt, ts, allowedPaths } = {},
295
304
  ) {
296
305
  acquireLock(repoRoot, runId);
297
306
  try {
@@ -307,10 +316,12 @@ export function adoptCandidate(
307
316
  }
308
317
  const ledger = openWaiting(repoRoot, runId);
309
318
  const pending = ledger.state.pendingHuman;
310
- if (pending.kind === "final-decision") {
311
- throw new DecisionError(
312
- "adopt is for a run waiting before fix/verify, not at the merge decision",
313
- );
319
+ const final = pending.kind === "final-decision" && pending.transition === "enter-wait-merge";
320
+ if (pending.kind === "final-decision" && (!final || !ledger.state.run.deliveryChecks)) {
321
+ throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
322
+ }
323
+ if (final && resumeAt !== "verify") {
324
+ throw new DecisionError("a final-decision repair must resume at verify");
314
325
  }
315
326
  const bound = ledger.state.workspaces[runId];
316
327
  const worktreePath = bound
@@ -332,6 +343,19 @@ export function adoptCandidate(
332
343
  `worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`,
333
344
  );
334
345
  }
346
+ if (final) {
347
+ const candidate = bound.candidate ?? bound.base;
348
+ const ancestor = spawnSync("git", ["-C", worktreePath, "merge-base", "--is-ancestor", candidate, tree.head]);
349
+ if (tree.head === candidate || ancestor.status !== 0) {
350
+ throw new DecisionError("adopt requires a new descendant of the current candidate; history rewrites are refused");
351
+ }
352
+ }
353
+ const scope = ledger.state.run.repair?.allowedPaths ?? allowedPaths;
354
+ if (scope) {
355
+ const violations = listChangedPaths(worktreePath, bound.base).filter((path) =>
356
+ !scope.some((prefix) => path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`)));
357
+ if (violations.length) throw new DecisionError(`out-of-scope changes: ${violations.join(", ")}`);
358
+ }
335
359
  const when = ts ?? new Date().toISOString();
336
360
  const lastEvidence =
337
361
  ledger.state.evidence[ledger.state.evidence.length - 1];
@@ -390,6 +414,51 @@ export function adoptCandidate(
390
414
  }
391
415
  }
392
416
 
417
+ // Queue a human-requested repair without fabricating a review or a budget
418
+ // grant. The ordinary resume loop consumes this decision at the fix step.
419
+ export function requestFix(repoRoot, runId, { by = "human", reason, ts, hasFixer } = {}) {
420
+ acquireLock(repoRoot, runId);
421
+ try {
422
+ const ledger = openWaiting(repoRoot, runId);
423
+ const pending = ledger.state.pendingHuman;
424
+ if (!ledger.state.run.deliveryChecks)
425
+ throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
426
+ if (pending.kind !== "final-decision" || pending.transition !== "enter-wait-merge")
427
+ throw new DecisionError("fix is only available at the merge decision");
428
+ if (!(ledger.state.run.repair?.hasFixer ?? hasFixer))
429
+ throw new DecisionError("no fixer configured; commit a manual repair and use run --adopt <sha> --by <name>");
430
+ if (typeof reason !== "string" || !reason.trim())
431
+ throw new DecisionError("fix requires a non-empty reason describing what to repair");
432
+ const bound = ledger.state.workspaces[runId];
433
+ const worktreePath = bound && resolveRepoRef(repoRoot, bound.worktreePath);
434
+ if (!worktreePath || !existsSync(worktreePath))
435
+ throw new DecisionError(`worktree missing for ${runId}`);
436
+ const tree = readback(worktreePath);
437
+ if (tree.dirty || tree.head !== (bound.candidate ?? bound.base) || tree.head !== pending.subject.candidate)
438
+ throw new DecisionError("worktree changed since the request; commit the manual repair and use run --adopt");
439
+ const when = ts ?? new Date().toISOString();
440
+ const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
441
+ const finding = { severity: "P1", summary: reason.trim() };
442
+ recordReviewFindings(repoRoot, ledger.state.run.work, {
443
+ run: runId, step: "enter-wait-merge", attempt: ledger.state.decisions.length + 1,
444
+ findings: [finding], ts: when,
445
+ });
446
+ adjudicateFinding(repoRoot, ledger.state.run.work, {
447
+ fingerprint: fingerprintFinding(finding), action: "accept", by,
448
+ note: decisionRef, ts: when,
449
+ });
450
+ const data = {
451
+ decision: "fix", transition: pending.transition, subject: pending.subject,
452
+ decisionRef, reason: finding.summary, resumeAt: "fix",
453
+ };
454
+ ledger.append({ type: "DECISION_RECORDED", actor: { kind: "human", id: by }, ts: when, data });
455
+ recordDecisionFile(repoRoot, ledger.state.run.work, { ts: when, run: runId, ...data, by });
456
+ return { decisionRef, state: ledger.state };
457
+ } finally {
458
+ releaseLock(repoRoot, runId);
459
+ }
460
+ }
461
+
393
462
  // Accepts a work artifact (plan, intent, spec) by binding a decision to the
394
463
  // file's current digest in the Git plane. If the file changes afterwards,
395
464
  // artifact.accepted evaluates false again — acceptance cannot go stale
@@ -10,9 +10,10 @@
10
10
 
11
11
  import { payloadFor } from "../adapters/notification-payload.js";
12
12
  import { currentScreenshots } from "../policy/policy.js";
13
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
 
16
+ import { configTargets, workConfigs } from "./overview-repos.js";
16
17
  import { parseYamlSubset } from "../engine/yaml-subset.js";
17
18
 
18
19
  export const NOTIFY_CONFIG = join(".buildbeat", "notify.yaml");
@@ -89,7 +90,22 @@ export function candidateScreenshots(state, candidate) {
89
90
 
90
91
  // The exact commands a human can copy to answer a pending request. Shared by
91
92
  // status, inbox and notifications so "what do I say now" has one answer.
92
- export function nextReply({ repoLabel, state }) {
93
+ function configuredFixer(repoRoot, state) {
94
+ if (state.run.repair) return state.run.repair.hasFixer;
95
+ if (!repoRoot) return false;
96
+ // Existing 4.x runs have no repair snapshot. Hints are conservative when
97
+ // their configuration is missing, ambiguous or unreadable.
98
+ try {
99
+ const configs = configTargets(workConfigs(repoRoot, state.run.work), [])
100
+ .filter((item) => item.repo === realpathSync(repoRoot))
101
+ .map((item) => parseYamlSubset(readFileSync(item.path, "utf8")))
102
+ .filter((doc) => doc.run === state.run.id || (typeof doc.run === "string" &&
103
+ state.run.id.startsWith(`${doc.run}-`) && /^\d+$/.test(state.run.id.slice(doc.run.length + 1))));
104
+ return configs.length === 1 && Boolean(configs[0].workers?.fixer?.command);
105
+ } catch { return false; }
106
+ }
107
+
108
+ export function nextReply({ repoLabel, state, repoRoot }) {
93
109
  const pending = state.pendingHuman;
94
110
  if (!pending || !state.run) {
95
111
  return [];
@@ -120,14 +136,21 @@ export function nextReply({ repoLabel, state }) {
120
136
  );
121
137
  }
122
138
  lines.push(
123
- `buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition} --by <you>` +
139
+ `buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition}` +
140
+ (pending.kind === "final-decision" ? ` --candidate ${pending.subject.candidate}` : "") + " --by <you>" +
124
141
  (pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: run --config <run-config.yaml>"),
125
142
  );
126
143
  lines.push(`buildbeat decide --action reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
144
+ if (pending.kind === "final-decision") {
145
+ lines.push(`buildbeat run --config <run-config.yaml> --run ${runId} --adopt <sha> --by <you> # commit a manual repair in the run worktree; verify and review again`);
146
+ if (configuredFixer(repoRoot, state)) {
147
+ lines.push(`buildbeat decide --repo ${repoLabel} --run ${runId} --action fix --reason <what to repair> --by <you> # then: run --config <run-config.yaml> --run ${runId}`);
148
+ }
149
+ }
127
150
  return lines;
128
151
  }
129
152
 
130
- export function buildNotification(kind, { repoLabel, state, detail = {} }) {
153
+ export function buildNotification(kind, { repoLabel, state, repoRoot, detail = {} }) {
131
154
  const run = state.run;
132
155
  const base = {
133
156
  kind,
@@ -149,7 +172,7 @@ export function buildNotification(kind, { repoLabel, state, detail = {} }) {
149
172
  reasons: pending?.reasons ?? [],
150
173
  candidate: pending?.subject?.candidate ?? null,
151
174
  ...(screenshots.length > 0 ? { screenshots } : {}),
152
- nextReply: nextReply({ repoLabel, state }),
175
+ nextReply: nextReply({ repoLabel, state, repoRoot }),
153
176
  };
154
177
  }
155
178
  if (kind === "RUN_TERMINAL") {
@@ -744,6 +744,21 @@ function beginStep(context, step, stepDef, attempt) {
744
744
  }));
745
745
  }
746
746
  }
747
+ // A human's final-decision repair is separate from reviewer evidence: it
748
+ // must not spend a review round or pretend that a reviewer reported it.
749
+ if (step === "fix") {
750
+ const decision = [...ledger.events].reverse().find((event) =>
751
+ event.type === "DECISION_RECORDED" && event.data.decision === "fix");
752
+ const priorFix = [...ledger.events].reverse().find((event) =>
753
+ event.type === "STEP_FINISHED" && event.data.step === "fix" && event.data.status === "succeeded");
754
+ if (decision && (!priorFix || decision.seq > priorFix.seq)) {
755
+ const finding = { severity: "P1", summary: decision.data.reason };
756
+ input.findings = [...(input.findings ?? []).filter((item) =>
757
+ item.fingerprint !== fingerprintFinding(finding)), {
758
+ ...finding, fingerprint: fingerprintFinding(finding), adjudication: "accept",
759
+ }];
760
+ }
761
+ }
747
762
  // Envelope (C6): the worker's prompt, materialised into the run
748
763
  // directory and handed over as BUILDBEAT_PROMPT / input.envelope.
749
764
  const prompt = materialisePrompt({
@@ -1506,6 +1521,9 @@ export function startRun(options) {
1506
1521
  run: runId,
1507
1522
  work: workId,
1508
1523
  data: {
1524
+ ...(options.deliveryChecks ? {
1525
+ repair: { hasFixer: Boolean(options.adapters?.fixer), allowedPaths: options.allowedPaths ?? null },
1526
+ } : {}),
1509
1527
  workflowRef: workflow.name,
1510
1528
  workflowDigest,
1511
1529
  base: workspace.base,
@@ -1574,6 +1592,10 @@ export function assertRunConfiguration(state, options) {
1574
1592
  if (!state.run) throw new OrchestratorError("run has no creation record");
1575
1593
  if (options.workId && options.workId !== state.run.work)
1576
1594
  throw new OrchestratorError("run belongs to a different work");
1595
+ if (state.run.repair && (
1596
+ canonicalJson(state.run.repair.allowedPaths) !== canonicalJson(options.allowedPaths ?? null) ||
1597
+ state.run.repair.hasFixer !== Boolean(options.adapters?.fixer)
1598
+ )) throw new OrchestratorError("repair configuration changed since the run was created; refusing to resume");
1577
1599
  const { workflowDigest } = options;
1578
1600
  if (
1579
1601
  state.run.deliveryChecks &&
@@ -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") {
@@ -280,9 +324,9 @@ export function computeOverview(
280
324
  ? "MERGE_DECISION"
281
325
  : "WAITING_HUMAN";
282
326
  const replies = latest.state
283
- ? nextReply({ repoLabel, state: latest.state })
327
+ ? nextReply({ repoLabel, state: latest.state, repoRoot })
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: