@haiyangbg/buildbeat 3.3.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.en.md +20 -124
  3. package/README.md +20 -124
  4. package/SKILL.md +73 -84
  5. package/docs/CAPABILITY-MATRIX.md +30 -39
  6. package/docs/MIGRATION.md +93 -0
  7. package/docs/README.md +8 -37
  8. package/docs/RELEASING.md +4 -3
  9. package/docs/v2/RFC-0001-product-definition.md +3 -0
  10. package/docs/v2/RFC-0002-domain-model.md +3 -0
  11. package/docs/v2/RFC-0003-workflow-policy.md +3 -0
  12. package/docs/v2/SPEC-0001-events-v1.md +3 -0
  13. package/docs/v2/guide/00-how-to-talk.en.md +12 -56
  14. package/docs/v2/guide/00-how-to-talk.md +12 -56
  15. package/docs/v2/guide/01-quickstart.en.md +42 -111
  16. package/docs/v2/guide/01-quickstart.md +42 -111
  17. package/docs/v2/guide/02-workflow-guide.en.md +13 -123
  18. package/docs/v2/guide/02-workflow-guide.md +14 -122
  19. package/docs/v2/guide/03-policy-guide.en.md +6 -50
  20. package/docs/v2/guide/03-policy-guide.md +6 -50
  21. package/docs/v2/guide/04-adapter-guide.en.md +7 -60
  22. package/docs/v2/guide/04-adapter-guide.md +7 -60
  23. package/docs/v2/guide/05-worker-contract.en.md +6 -53
  24. package/docs/v2/guide/05-worker-contract.md +7 -52
  25. package/docs/v2/guide/06-evidence-guide.en.md +8 -44
  26. package/docs/v2/guide/06-evidence-guide.md +9 -43
  27. package/docs/v2/guide/07-approval-guide.en.md +8 -110
  28. package/docs/v2/guide/07-approval-guide.md +8 -121
  29. package/docs/v2/guide/09-security-boundaries.en.md +13 -37
  30. package/docs/v2/guide/09-security-boundaries.md +13 -37
  31. package/docs/v2/guide/10-recovery.en.md +12 -96
  32. package/docs/v2/guide/10-recovery.md +12 -96
  33. package/docs/v2/guide/11-session-handoff.en.md +10 -75
  34. package/docs/v2/guide/11-session-handoff.md +10 -75
  35. package/docs/v2/guide/12-without-runtime.en.md +46 -0
  36. package/docs/v2/guide/12-without-runtime.md +46 -0
  37. package/docs/v2/guide/README.en.md +16 -36
  38. package/docs/v2/guide/README.md +16 -36
  39. package/example/AGENTS.md +15 -69
  40. package/example/README.md +8 -23
  41. package/example/delivery/envelope/prompts/builder.md +3 -1
  42. package/example/delivery/envelope/prompts/fixer.md +3 -1
  43. package/example/delivery/envelope/prompts/reviewer.md +3 -1
  44. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +1 -1
  45. package/example//346/214/207/346/214/245/345/217/260.md +8 -40
  46. package/package.json +2 -1
  47. package/src/v2/adapters/notification-payload.js +40 -0
  48. package/src/v2/adapters/shell.js +3 -1
  49. package/src/v2/cli/run-config-check.js +129 -45
  50. package/src/v2/cli/run.js +853 -254
  51. package/src/v2/engine/reducer.js +2 -0
  52. package/src/v2/engine/risk-preset.js +14 -47
  53. package/src/v2/engine/workflow.js +177 -185
  54. package/src/v2/evidence/collector.js +12 -1
  55. package/src/v2/evidence/image.js +346 -0
  56. package/src/v2/policy/policy.js +219 -286
  57. package/src/v2/presets/software-delivery.yaml +1 -16
  58. package/src/v2/runtime/cache.js +22 -1
  59. package/src/v2/runtime/decisions.js +185 -31
  60. package/src/v2/runtime/findings.js +4 -1
  61. package/src/v2/runtime/notify.js +42 -36
  62. package/src/v2/runtime/orchestrator.js +811 -297
  63. package/src/v2/runtime/overview-repos.js +79 -0
  64. package/src/v2/runtime/overview.js +279 -47
  65. package/src/v2/runtime/release.js +233 -0
  66. package/src/v2/runtime/run-record.js +1 -0
  67. package/src/v2/workspace/workspace-manager.js +8 -0
  68. package/templates/v2/AGENTS.md +20 -73
  69. package/templates/v2/BUILDBEAT.md +2 -13
  70. package/templates/v2/envelope/prompts/builder.md +3 -1
  71. package/templates/v2/envelope/prompts/fixer.md +3 -1
  72. package/templates/v2/envelope/prompts/reviewer.md +3 -1
  73. package/templates/v2/run-config.example.yaml +9 -7
  74. package/templates/v2/work.example.md +16 -0
  75. package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
  76. package/docs/v2/skill/01-principles.md +0 -26
  77. package/docs/v2/skill/02-project-layout.md +0 -31
  78. package/docs/v2/skill/03-collaboration-rules.md +0 -45
  79. package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
  80. package/docs/v2/skill/05-red-lines.md +0 -13
  81. package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
  82. package/docs/v2/skill/07-templates-and-lessons.md +0 -24
  83. package/example/.buildbeat/observe.yaml +0 -31
  84. package/src/v2/observe/observe-config.js +0 -194
  85. package/src/v2/observe/observe-reducer.js +0 -117
  86. package/src/v2/observe/observe.js +0 -427
  87. package/src/v2/presets/observe.yaml +0 -45
  88. package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
  89. package/src/v2/presets/release-readback.yaml +0 -36
  90. package/src/v2/presets/risk/controlled.yaml +0 -39
  91. package/src/v2/presets/risk/fast.yaml +0 -19
  92. package/src/v2/presets/risk/release.yaml +0 -21
  93. package/src/v2/presets/risk/standard.yaml +0 -28
  94. package/templates/ARCHITECTURE.md +0 -50
  95. package/templates/contracts/PROTOCOL.md +0 -28
  96. package/templates/pm/adr/ADR-0000-template.md +0 -25
  97. package/templates/pm/adr/README.md +0 -15
  98. package/templates/pm/decisions.md +0 -11
  99. package/templates/standards/CODE.md +0 -23
  100. package/templates/standards/DESIGN.md +0 -36
  101. package/templates/standards/REVIEW.md +0 -20
  102. package/templates/standards/STACK.md +0 -31
@@ -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,12 +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";
21
+ import { readReleases } from "./release.js";
22
+ import { describeLiveness } from "./liveness.js";
19
23
  import { computeWorkCost, renderWorkCost } from "./work-cost.js";
20
24
 
21
25
  function sha256File(path) {
@@ -45,34 +49,59 @@ export function artifactStatus(workDir, decisions, artifact) {
45
49
  return { exists: false, accepted: false, stale: false };
46
50
  }
47
51
  const digest = sha256File(path);
48
- const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
52
+ const accepts = decisions.filter(
53
+ (row) =>
54
+ row.transition === `accept-${artifact}` && row.decision === "approved",
55
+ );
49
56
  const latest = accepts[accepts.length - 1];
50
57
  if (!latest) {
51
58
  return { exists: true, accepted: false, stale: false };
52
59
  }
53
60
  const stale = latest.subject?.digest !== digest;
54
- return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
61
+ return {
62
+ exists: true,
63
+ accepted: !stale,
64
+ stale,
65
+ by: latest.by,
66
+ at: latest.ts,
67
+ };
55
68
  }
56
69
 
57
- 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);
58
73
  try {
59
- execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
74
+ execFileSync(
75
+ "git",
76
+ ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
77
+ { stdio: "ignore" },
78
+ );
79
+ context.ancestry.set(key, true);
60
80
  return true;
61
81
  } catch {
82
+ context.ancestry.set(key, false);
62
83
  return false;
63
84
  }
64
85
  }
65
86
 
66
- function headRef(repoRoot) {
87
+ function headRef(repoRoot, context) {
88
+ if (context.heads.has(repoRoot)) return context.heads.get(repoRoot);
67
89
  try {
68
- return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
90
+ const ref = execFileSync(
91
+ "git",
92
+ ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
93
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
94
+ ).trim();
95
+ context.heads.set(repoRoot, ref);
96
+ return ref;
69
97
  } catch {
98
+ context.heads.set(repoRoot, "HEAD");
70
99
  return "HEAD";
71
100
  }
72
101
  }
73
102
 
74
103
  // Runs for a work: runtime ledgers first, run-records for the rest.
75
- function runsFor(repoRoot, workId) {
104
+ export function runsFor(repoRoot, workId) {
76
105
  const runs = new Map();
77
106
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
78
107
  if (existsSync(runsDir)) {
@@ -131,34 +160,86 @@ function runsFor(repoRoot, workId) {
131
160
  }
132
161
  }
133
162
  }
134
- return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
163
+ return [...runs.values()].sort((a, b) =>
164
+ String(a.createdAt).localeCompare(String(b.createdAt)),
165
+ );
135
166
  }
136
167
 
137
- export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
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
+
174
+ export function computeOverview(
175
+ repoRoot,
176
+ { work = null, repoLabel: defaultRepoLabel = ".", cwd = null, followTargets = true, warnings = [], configsOverride = null, context = overviewContext(repoRoot), skipWorks = new Set(), localCompatibility = false } = {},
177
+ ) {
138
178
  const workRoot = join(repoRoot, "delivery", "work");
139
179
  const rows = [];
140
- if (!existsSync(workRoot)) {
141
- 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
+ }
142
189
  }
143
- const mainRef = headRef(repoRoot);
144
- for (const workId of readdirSync(workRoot).sort()) {
145
- 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)) {
146
195
  continue;
147
196
  }
148
197
  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;
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
+ }
151
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;
152
226
  const decisions = readJsonl(join(workDir, "decisions.jsonl"));
153
- const intent = artifactStatus(workDir, decisions, "intent");
154
- const plan = artifactStatus(workDir, decisions, "plan");
227
+ const unified = existsSync(join(workDir, "work.md"));
228
+ const intent = artifactStatus(
229
+ workDir,
230
+ decisions,
231
+ unified ? "work" : "intent",
232
+ );
233
+ const plan = unified ? intent : artifactStatus(workDir, decisions, "plan");
155
234
  const envFacts = existsSync(join(workDir, "env-facts.md"));
156
235
  const findingRows = readFindingsAccount(repoRoot, workId);
157
236
  const adjudicated = latestAdjudications(findingRows);
158
237
  const openFindings = findingRows.filter(
159
- (row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
238
+ (row) =>
239
+ row.kind === "finding" &&
240
+ (row.severity === "P0" || row.severity === "P1") &&
241
+ !adjudicated.has(row.fingerprint),
160
242
  ).length;
161
- const runs = runsFor(repoRoot, workId);
162
243
  const live = runs.filter((run) => run.status !== "SUPERSEDED");
163
244
  const latest = live[live.length - 1] ?? null;
164
245
  // "Merged" is a fact about any candidate of the work, not only the
@@ -166,28 +247,47 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
166
247
  // (its in-run review budget ran out and closure happened elsewhere) and
167
248
  // overview reported the shipped work as STOPPED_CANCELLED.
168
249
  const mergedRun =
169
- [...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
250
+ [...runs]
251
+ .reverse()
252
+ .find(
253
+ (run) =>
254
+ run.candidate && isAncestor(repoRoot, run.candidate, mainRef, context),
255
+ ) ?? null;
170
256
  const merged = Boolean(mergedRun);
171
257
  const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
172
258
  const isReleaseLane = (run) =>
173
- Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
259
+ Boolean(run) &&
260
+ (run.workflow === "release-readback" ||
261
+ run.steps.some((step) => RELEASE_STEPS.includes(step)));
174
262
 
175
263
  // A Work is closed by an explicit row in decisions.jsonl:
176
264
  // {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
177
265
  // A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
178
266
  // 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");
267
+ const closure = [...decisions]
268
+ .reverse()
269
+ .find((row) => row.transition === "close-work");
270
+ const readback = readReleases(repoRoot, workId).at(-1) ?? null;
271
+ const liveRun =
272
+ latest &&
273
+ (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
181
274
 
182
275
  let stage;
183
276
  let next;
184
277
  if (closure && !liveRun) {
185
278
  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";
279
+ const result =
280
+ typeof closure.subject?.result === "string" &&
281
+ closure.subject.result.length > 0
282
+ ? closure.subject.result
283
+ : "see decisions.jsonl";
187
284
  next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
188
- } 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.
189
289
  stage = "NO_INTENT";
190
- next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
290
+ next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
191
291
  } else if (!latest) {
192
292
  if (!plan.exists) {
193
293
  stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
@@ -195,24 +295,38 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
195
295
  ? `write delivery/work/${workId}/plan.md, then accept it`
196
296
  : `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
197
297
  } else if (!plan.accepted) {
198
- stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
199
- const artifacts = intent.accepted ? "plan" : "intent,plan";
200
- next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
298
+ stage = unified
299
+ ? plan.stale
300
+ ? "WORK_STALE"
301
+ : "WORK_DRAFT"
302
+ : plan.stale
303
+ ? "PLAN_STALE"
304
+ : "PLAN_DRAFT";
305
+ const artifacts = unified
306
+ ? "work"
307
+ : intent.accepted
308
+ ? "plan"
309
+ : "intent,plan";
310
+ next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
201
311
  } else {
202
312
  stage = "READY_TO_RUN";
203
- const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
204
313
  next =
205
314
  configs.length > 0
206
- ? `buildbeat start --config delivery/work/${workId}/${configs[0]} --attempt new`
315
+ ? `buildbeat run --config ${configPath}`
207
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`;
208
317
  }
209
318
  } else if (latest.status === "RUNNING") {
210
319
  stage = "RUNNING";
211
320
  next = `buildbeat status --repo ${repoLabel} --run ${latest.id}`;
212
321
  } else if (latest.status === "WAITING_HUMAN") {
213
- stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
214
- const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
215
- next = replies[0] ?? `buildbeat inbox --repo ${repoLabel}`;
322
+ stage =
323
+ latest.pendingHuman?.kind === "final-decision"
324
+ ? "MERGE_DECISION"
325
+ : "WAITING_HUMAN";
326
+ const replies = latest.state
327
+ ? nextReply({ repoLabel, state: latest.state })
328
+ : [];
329
+ next = replies[0] ?? `buildbeat status --repo ${repoLabel} --work ${workId}`;
216
330
  } else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
217
331
  // A release-readback lane that reached wait-close and was approved is
218
332
  // a closed release window, not "nothing to merge".
@@ -220,9 +334,27 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
220
334
  next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
221
335
  } else if (merged) {
222
336
  stage = "MERGED";
223
- next =
224
- `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat gc --repo ${repoLabel}` +
225
- (latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
337
+ // After the merge: the person releases, the project's readback proves
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);
347
+ const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
348
+ next = !readback
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}`
352
+ : readback.status === "passed"
353
+ ? `readback passed @ ${readback.ts} (${readback.commit.slice(0, 7)}); close the window: ${close}`
354
+ : `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${releasePath} again`;
355
+ if (latest.status !== "SUCCEEDED") {
356
+ next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
357
+ }
226
358
  } else if (latest.status === "SUCCEEDED") {
227
359
  stage = "MERGE_READY";
228
360
  next = latest.candidate
@@ -231,24 +363,52 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
231
363
  } else {
232
364
  stage = `STOPPED_${latest.status}`;
233
365
  next = plan.accepted
234
- ? `decide: retry (buildbeat start ... --attempt new) or close the work`
235
- : `plan not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
366
+ ? `decide: retry (buildbeat run --config ${configPath} --new) or close the work`
367
+ : `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
236
368
  }
237
369
  rows.push({
238
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) } : {}),
239
373
  stage,
240
374
  intent,
241
375
  plan,
376
+ ...(unified ? { workArtifact: "work" } : {}),
242
377
  envFacts,
243
378
  openFindings,
379
+ findings: findingRows
380
+ .filter((item) => item.kind === "finding")
381
+ .map((item) => ({
382
+ ...item,
383
+ adjudication: adjudicated.get(item.fingerprint)?.action ?? "open",
384
+ })),
244
385
  runs: runs.length,
245
386
  cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
246
387
  latest: latest
247
- ? { 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 }
388
+ ? {
389
+ id: latest.id,
390
+ status: latest.status,
391
+ candidate: latest.candidate,
392
+ at: latest.lastAt,
393
+ source: latest.source,
394
+ terminalReason: latest.terminal?.reason ?? null,
395
+ waiting: latest.pendingHuman?.transition ?? null,
396
+ }
248
397
  : null,
249
398
  merged,
250
399
  mergedCandidate: mergedRun?.candidate ?? null,
251
- next,
400
+ ...(readback
401
+ ? {
402
+ readback: {
403
+ status: readback.status,
404
+ at: readback.ts,
405
+ commit: readback.commit,
406
+ digest: readback.digest,
407
+ ...(readback.note ? { note: readback.note } : {}),
408
+ },
409
+ }
410
+ : {}),
411
+ next: local ? next : next.replaceAll("--config <run-config.yaml>", `--config ${configPath}`),
252
412
  });
253
413
  }
254
414
  return rows;
@@ -271,14 +431,31 @@ export function renderOverview(rows) {
271
431
  const lines = [];
272
432
  for (const row of rows) {
273
433
  lines.push(`${row.work} ${row.stage}`);
274
- const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
275
- const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(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}`);
436
+ const parts =
437
+ row.workArtifact === "work"
438
+ ? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
439
+ : [
440
+ `intent ${mark(row.intent)}`,
441
+ `plan ${mark(row.plan)}`,
442
+ `runs ${row.runs}`,
443
+ ];
444
+ const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(
445
+ row.stage,
446
+ );
276
447
  if (row.openFindings > 0 && !settled) {
277
448
  // Unadjudicated, not necessarily unresolved: a fixer may have closed
278
449
  // them without anyone recording a verdict. The number says "nobody
279
450
  // ruled on these", which is exactly what a human should know.
280
451
  parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
281
452
  }
453
+ if (!settled && row.findings?.length) {
454
+ for (const finding of row.findings)
455
+ lines.push(
456
+ ` [${finding.severity} ${finding.fingerprint}] (${finding.adjudication}) ${finding.summary}`,
457
+ );
458
+ }
282
459
  if (row.envFacts) {
283
460
  parts.push("env-facts ✓");
284
461
  }
@@ -293,10 +470,65 @@ export function renderOverview(rows) {
293
470
  ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
294
471
  : "";
295
472
  const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
296
- const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
297
- lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
473
+ const why = row.latest.terminalReason
474
+ ? ` — ${row.latest.terminalReason.slice(0, 100)}`
475
+ : "";
476
+ lines.push(
477
+ ` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`,
478
+ );
298
479
  }
299
480
  lines.push(` next: ${row.next}`);
300
481
  }
301
482
  return lines.join("\n");
302
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
+ }