@haiyangbg/buildbeat 3.3.1 → 4.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -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 -58
  14. package/docs/v2/guide/00-how-to-talk.md +12 -58
  15. package/docs/v2/guide/01-quickstart.en.md +41 -112
  16. package/docs/v2/guide/01-quickstart.md +41 -112
  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 +7 -78
  34. package/docs/v2/guide/11-session-handoff.md +7 -78
  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//346/214/207/346/214/245/345/217/260.md +8 -40
  45. package/package.json +2 -1
  46. package/src/v2/adapters/notification-payload.js +40 -0
  47. package/src/v2/adapters/shell.js +3 -1
  48. package/src/v2/cli/run-config-check.js +129 -45
  49. package/src/v2/cli/run.js +821 -252
  50. package/src/v2/engine/reducer.js +2 -0
  51. package/src/v2/engine/risk-preset.js +14 -47
  52. package/src/v2/engine/workflow.js +177 -185
  53. package/src/v2/evidence/collector.js +12 -1
  54. package/src/v2/evidence/image.js +346 -0
  55. package/src/v2/policy/policy.js +219 -286
  56. package/src/v2/presets/software-delivery.yaml +1 -16
  57. package/src/v2/runtime/cache.js +22 -1
  58. package/src/v2/runtime/decisions.js +185 -31
  59. package/src/v2/runtime/findings.js +4 -1
  60. package/src/v2/runtime/notify.js +42 -36
  61. package/src/v2/runtime/orchestrator.js +811 -297
  62. package/src/v2/runtime/overview.js +163 -34
  63. package/src/v2/runtime/release.js +233 -0
  64. package/src/v2/runtime/run-record.js +1 -0
  65. package/src/v2/workspace/workspace-manager.js +8 -0
  66. package/templates/v2/AGENTS.md +15 -69
  67. package/templates/v2/BUILDBEAT.md +2 -13
  68. package/templates/v2/envelope/prompts/builder.md +3 -1
  69. package/templates/v2/envelope/prompts/fixer.md +3 -1
  70. package/templates/v2/envelope/prompts/reviewer.md +3 -1
  71. package/templates/v2/run-config.example.yaml +7 -5
  72. package/templates/v2/work.example.md +16 -0
  73. package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
  74. package/docs/v2/skill/01-principles.md +0 -26
  75. package/docs/v2/skill/02-project-layout.md +0 -31
  76. package/docs/v2/skill/03-collaboration-rules.md +0 -45
  77. package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
  78. package/docs/v2/skill/05-red-lines.md +0 -13
  79. package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
  80. package/docs/v2/skill/07-templates-and-lessons.md +0 -24
  81. package/example/.buildbeat/observe.yaml +0 -31
  82. package/src/v2/observe/observe-config.js +0 -194
  83. package/src/v2/observe/observe-reducer.js +0 -117
  84. package/src/v2/observe/observe.js +0 -427
  85. package/src/v2/presets/observe.yaml +0 -45
  86. package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
  87. package/src/v2/presets/release-readback.yaml +0 -36
  88. package/src/v2/presets/risk/controlled.yaml +0 -39
  89. package/src/v2/presets/risk/fast.yaml +0 -19
  90. package/src/v2/presets/risk/release.yaml +0 -21
  91. package/src/v2/presets/risk/standard.yaml +0 -28
  92. package/templates/ARCHITECTURE.md +0 -50
  93. package/templates/contracts/PROTOCOL.md +0 -28
  94. package/templates/pm/adr/ADR-0000-template.md +0 -25
  95. package/templates/pm/adr/README.md +0 -15
  96. package/templates/pm/decisions.md +0 -11
  97. package/templates/standards/CODE.md +0 -23
  98. package/templates/standards/DESIGN.md +0 -36
  99. package/templates/standards/REVIEW.md +0 -20
  100. package/templates/standards/STACK.md +0 -31
@@ -16,6 +16,7 @@ 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 { readReleases } from "./release.js";
19
20
  import { computeWorkCost, renderWorkCost } from "./work-cost.js";
20
21
 
21
22
  function sha256File(path) {
@@ -45,18 +46,31 @@ export function artifactStatus(workDir, decisions, artifact) {
45
46
  return { exists: false, accepted: false, stale: false };
46
47
  }
47
48
  const digest = sha256File(path);
48
- const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
49
+ const accepts = decisions.filter(
50
+ (row) =>
51
+ row.transition === `accept-${artifact}` && row.decision === "approved",
52
+ );
49
53
  const latest = accepts[accepts.length - 1];
50
54
  if (!latest) {
51
55
  return { exists: true, accepted: false, stale: false };
52
56
  }
53
57
  const stale = latest.subject?.digest !== digest;
54
- return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
58
+ return {
59
+ exists: true,
60
+ accepted: !stale,
61
+ stale,
62
+ by: latest.by,
63
+ at: latest.ts,
64
+ };
55
65
  }
56
66
 
57
67
  function isAncestor(repoRoot, sha, ref) {
58
68
  try {
59
- execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
69
+ execFileSync(
70
+ "git",
71
+ ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
72
+ { stdio: "ignore" },
73
+ );
60
74
  return true;
61
75
  } catch {
62
76
  return false;
@@ -65,14 +79,18 @@ function isAncestor(repoRoot, sha, ref) {
65
79
 
66
80
  function headRef(repoRoot) {
67
81
  try {
68
- return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
82
+ return execFileSync(
83
+ "git",
84
+ ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
85
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
86
+ ).trim();
69
87
  } catch {
70
88
  return "HEAD";
71
89
  }
72
90
  }
73
91
 
74
92
  // Runs for a work: runtime ledgers first, run-records for the rest.
75
- function runsFor(repoRoot, workId) {
93
+ export function runsFor(repoRoot, workId) {
76
94
  const runs = new Map();
77
95
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
78
96
  if (existsSync(runsDir)) {
@@ -131,10 +149,15 @@ function runsFor(repoRoot, workId) {
131
149
  }
132
150
  }
133
151
  }
134
- return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
152
+ return [...runs.values()].sort((a, b) =>
153
+ String(a.createdAt).localeCompare(String(b.createdAt)),
154
+ );
135
155
  }
136
156
 
137
- export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
157
+ export function computeOverview(
158
+ repoRoot,
159
+ { work = null, repoLabel = "." } = {},
160
+ ) {
138
161
  const workRoot = join(repoRoot, "delivery", "work");
139
162
  const rows = [];
140
163
  if (!existsSync(workRoot)) {
@@ -146,17 +169,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
146
169
  continue;
147
170
  }
148
171
  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"))) {
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
+ ) {
150
179
  continue;
151
180
  }
152
181
  const decisions = readJsonl(join(workDir, "decisions.jsonl"));
153
- const intent = artifactStatus(workDir, decisions, "intent");
154
- const plan = artifactStatus(workDir, decisions, "plan");
182
+ const unified = existsSync(join(workDir, "work.md"));
183
+ const intent = artifactStatus(
184
+ workDir,
185
+ decisions,
186
+ unified ? "work" : "intent",
187
+ );
188
+ const plan = unified ? intent : artifactStatus(workDir, decisions, "plan");
155
189
  const envFacts = existsSync(join(workDir, "env-facts.md"));
156
190
  const findingRows = readFindingsAccount(repoRoot, workId);
157
191
  const adjudicated = latestAdjudications(findingRows);
158
192
  const openFindings = findingRows.filter(
159
- (row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
193
+ (row) =>
194
+ row.kind === "finding" &&
195
+ (row.severity === "P0" || row.severity === "P1") &&
196
+ !adjudicated.has(row.fingerprint),
160
197
  ).length;
161
198
  const runs = runsFor(repoRoot, workId);
162
199
  const live = runs.filter((run) => run.status !== "SUPERSEDED");
@@ -166,28 +203,44 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
166
203
  // (its in-run review budget ran out and closure happened elsewhere) and
167
204
  // overview reported the shipped work as STOPPED_CANCELLED.
168
205
  const mergedRun =
169
- [...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
206
+ [...runs]
207
+ .reverse()
208
+ .find(
209
+ (run) =>
210
+ run.candidate && isAncestor(repoRoot, run.candidate, mainRef),
211
+ ) ?? null;
170
212
  const merged = Boolean(mergedRun);
171
213
  const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
172
214
  const isReleaseLane = (run) =>
173
- Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
215
+ Boolean(run) &&
216
+ (run.workflow === "release-readback" ||
217
+ run.steps.some((step) => RELEASE_STEPS.includes(step)));
174
218
 
175
219
  // A Work is closed by an explicit row in decisions.jsonl:
176
220
  // {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
177
221
  // A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
178
222
  // 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");
223
+ const closure = [...decisions]
224
+ .reverse()
225
+ .find((row) => row.transition === "close-work");
226
+ const readback = readReleases(repoRoot, workId).at(-1) ?? null;
227
+ const liveRun =
228
+ latest &&
229
+ (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
181
230
 
182
231
  let stage;
183
232
  let next;
184
233
  if (closure && !liveRun) {
185
234
  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";
235
+ const result =
236
+ typeof closure.subject?.result === "string" &&
237
+ closure.subject.result.length > 0
238
+ ? closure.subject.result
239
+ : "see decisions.jsonl";
187
240
  next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
188
241
  } else if (!intent.exists) {
189
242
  stage = "NO_INTENT";
190
- next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
243
+ next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
191
244
  } else if (!latest) {
192
245
  if (!plan.exists) {
193
246
  stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
@@ -195,23 +248,40 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
195
248
  ? `write delivery/work/${workId}/plan.md, then accept it`
196
249
  : `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
197
250
  } 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" : ""}`;
251
+ stage = unified
252
+ ? plan.stale
253
+ ? "WORK_STALE"
254
+ : "WORK_DRAFT"
255
+ : plan.stale
256
+ ? "PLAN_STALE"
257
+ : "PLAN_DRAFT";
258
+ const artifacts = unified
259
+ ? "work"
260
+ : intent.accepted
261
+ ? "plan"
262
+ : "intent,plan";
263
+ next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
201
264
  } else {
202
265
  stage = "READY_TO_RUN";
203
- const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
266
+ const configs = readdirSync(workDir).filter((name) =>
267
+ /^run-config.*\.ya?ml$/.test(name),
268
+ );
204
269
  next =
205
270
  configs.length > 0
206
- ? `buildbeat start --config delivery/work/${workId}/${configs[0]} --attempt new`
271
+ ? `buildbeat run --config delivery/work/${workId}/${configs[0]}`
207
272
  : `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
273
  }
209
274
  } else if (latest.status === "RUNNING") {
210
275
  stage = "RUNNING";
211
276
  next = `buildbeat status --repo ${repoLabel} --run ${latest.id}`;
212
277
  } 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 }) : [];
278
+ stage =
279
+ latest.pendingHuman?.kind === "final-decision"
280
+ ? "MERGE_DECISION"
281
+ : "WAITING_HUMAN";
282
+ const replies = latest.state
283
+ ? nextReply({ repoLabel, state: latest.state })
284
+ : [];
215
285
  next = replies[0] ?? `buildbeat inbox --repo ${repoLabel}`;
216
286
  } else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
217
287
  // A release-readback lane that reached wait-close and was approved is
@@ -220,9 +290,23 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
220
290
  next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
221
291
  } else if (merged) {
222
292
  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` : "");
293
+ // 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>";
301
+ const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
302
+ 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}`
304
+ : readback.status === "passed"
305
+ ? `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`;
307
+ if (latest.status !== "SUCCEEDED") {
308
+ next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
309
+ }
226
310
  } else if (latest.status === "SUCCEEDED") {
227
311
  stage = "MERGE_READY";
228
312
  next = latest.candidate
@@ -231,23 +315,49 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
231
315
  } else {
232
316
  stage = `STOPPED_${latest.status}`;
233
317
  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`;
318
+ ? `decide: retry (buildbeat run --config <run-config.yaml> --new) or close the work`
319
+ : `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
236
320
  }
237
321
  rows.push({
238
322
  work: workId,
239
323
  stage,
240
324
  intent,
241
325
  plan,
326
+ ...(unified ? { workArtifact: "work" } : {}),
242
327
  envFacts,
243
328
  openFindings,
329
+ findings: findingRows
330
+ .filter((item) => item.kind === "finding")
331
+ .map((item) => ({
332
+ ...item,
333
+ adjudication: adjudicated.get(item.fingerprint)?.action ?? "open",
334
+ })),
244
335
  runs: runs.length,
245
336
  cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
246
337
  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 }
338
+ ? {
339
+ id: latest.id,
340
+ status: latest.status,
341
+ candidate: latest.candidate,
342
+ at: latest.lastAt,
343
+ source: latest.source,
344
+ terminalReason: latest.terminal?.reason ?? null,
345
+ waiting: latest.pendingHuman?.transition ?? null,
346
+ }
248
347
  : null,
249
348
  merged,
250
349
  mergedCandidate: mergedRun?.candidate ?? null,
350
+ ...(readback
351
+ ? {
352
+ readback: {
353
+ status: readback.status,
354
+ at: readback.ts,
355
+ commit: readback.commit,
356
+ digest: readback.digest,
357
+ ...(readback.note ? { note: readback.note } : {}),
358
+ },
359
+ }
360
+ : {}),
251
361
  next,
252
362
  });
253
363
  }
@@ -271,14 +381,29 @@ export function renderOverview(rows) {
271
381
  const lines = [];
272
382
  for (const row of rows) {
273
383
  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);
384
+ const parts =
385
+ row.workArtifact === "work"
386
+ ? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
387
+ : [
388
+ `intent ${mark(row.intent)}`,
389
+ `plan ${mark(row.plan)}`,
390
+ `runs ${row.runs}`,
391
+ ];
392
+ const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(
393
+ row.stage,
394
+ );
276
395
  if (row.openFindings > 0 && !settled) {
277
396
  // Unadjudicated, not necessarily unresolved: a fixer may have closed
278
397
  // them without anyone recording a verdict. The number says "nobody
279
398
  // ruled on these", which is exactly what a human should know.
280
399
  parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
281
400
  }
401
+ if (!settled && row.findings?.length) {
402
+ for (const finding of row.findings)
403
+ lines.push(
404
+ ` [${finding.severity} ${finding.fingerprint}] (${finding.adjudication}) ${finding.summary}`,
405
+ );
406
+ }
282
407
  if (row.envFacts) {
283
408
  parts.push("env-facts ✓");
284
409
  }
@@ -293,8 +418,12 @@ export function renderOverview(rows) {
293
418
  ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
294
419
  : "";
295
420
  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}`);
421
+ const why = row.latest.terminalReason
422
+ ? ` — ${row.latest.terminalReason.slice(0, 100)}`
423
+ : "";
424
+ lines.push(
425
+ ` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`,
426
+ );
298
427
  }
299
428
  lines.push(` next: ${row.next}`);
300
429
  }
@@ -0,0 +1,233 @@
1
+ // Release readback and closeout (4.0; replaces the retired release-readback
2
+ // lane, owner decision 2026-10-04). BuildBeat still performs no deployment
3
+ // (invariant 20): after a person releases a merged candidate, the project's
4
+ // own read-only command proves the release took effect. Every readback is a
5
+ // line in the Work's releases.jsonl, next to its decisions, and the window
6
+ // closes only on a passing readback. Real cost behind it: one pilot go-live
7
+ // was recorded by hand as forty "step N readback" commits.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import {
12
+ appendFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { createShellAdapter } from "../adapters/shell.js";
21
+ import { toRepoRef } from "./repo-ref.js";
22
+
23
+ const TAIL_LINES = 20;
24
+
25
+ export class ReleaseError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "ReleaseError";
29
+ }
30
+ }
31
+
32
+ function workDir(repoRoot, workId) {
33
+ return join(repoRoot, "delivery", "work", workId);
34
+ }
35
+
36
+ function readRows(path) {
37
+ if (!existsSync(path)) {
38
+ return [];
39
+ }
40
+ return readFileSync(path, "utf8")
41
+ .split("\n")
42
+ .filter(Boolean)
43
+ .map((line) => {
44
+ try {
45
+ return JSON.parse(line);
46
+ } catch {
47
+ return null;
48
+ }
49
+ })
50
+ .filter(Boolean);
51
+ }
52
+
53
+ export function readReleases(repoRoot, workId) {
54
+ return readRows(join(workDir(repoRoot, workId), "releases.jsonl"));
55
+ }
56
+
57
+ function isAncestor(repoRoot, sha, commit) {
58
+ try {
59
+ execFileSync(
60
+ "git",
61
+ ["-C", repoRoot, "merge-base", "--is-ancestor", sha, commit],
62
+ { stdio: "ignore" },
63
+ );
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // Runs the configured readback in the main checkout once and records it.
71
+ // `runs` are the Work's runs (overview's runsFor); the newest SUCCEEDED one
72
+ // whose candidate the release ref contains is what this readback is about.
73
+ export function runReadback({
74
+ repoRoot,
75
+ workId,
76
+ spec,
77
+ redact = [],
78
+ runs,
79
+ ref = "HEAD",
80
+ note = null,
81
+ ts = new Date().toISOString(),
82
+ }) {
83
+ const resolveCommit = (name) =>
84
+ execFileSync(
85
+ "git",
86
+ ["-C", repoRoot, "rev-parse", "--verify", `${name}^{commit}`],
87
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
88
+ ).trim();
89
+ let commit;
90
+ try {
91
+ commit = resolveCommit(ref);
92
+ } catch {
93
+ throw new ReleaseError(`--ref ${ref} does not name a commit`);
94
+ }
95
+ // The command runs in the main checkout, which may sit elsewhere than the
96
+ // released ref; both are recorded so neither is claimed for the other.
97
+ const checkout = resolveCommit("HEAD");
98
+ const succeeded = runs
99
+ .filter((run) => run.status === "SUCCEEDED" && run.candidate)
100
+ .sort((a, b) => String(a.lastAt).localeCompare(String(b.lastAt)));
101
+ if (succeeded.length === 0) {
102
+ throw new ReleaseError(
103
+ `${workId} has no succeeded run; approve its merge decision first`,
104
+ );
105
+ }
106
+ const released = [...succeeded]
107
+ .reverse()
108
+ .find((run) => isAncestor(repoRoot, run.candidate, commit));
109
+ if (!released) {
110
+ const latest = succeeded.at(-1);
111
+ throw new ReleaseError(
112
+ `candidate ${latest.candidate.slice(0, 7)} (${latest.id}) is not contained in ${ref} (${commit.slice(0, 7)}); merge it first`,
113
+ );
114
+ }
115
+
116
+ const adapter = createShellAdapter({
117
+ name: "shell:release",
118
+ command: spec.command,
119
+ args: spec.args ?? [],
120
+ timeoutMs: spec.timeoutMs,
121
+ inheritEnv: spec.inheritEnv === true,
122
+ env: spec.env ?? {},
123
+ });
124
+ const exec = adapter.execute({
125
+ step: "release",
126
+ worker: "release",
127
+ workspacePath: repoRoot,
128
+ input: { workId, run: released.id, candidate: released.candidate, ref, commit },
129
+ });
130
+ const scrub = (text) => {
131
+ let out = String(text ?? "");
132
+ for (const pattern of redact) {
133
+ out = out.replace(pattern, "<REDACTED>");
134
+ }
135
+ return out;
136
+ };
137
+ const output = [scrub(exec.stdout), scrub(exec.stderr)]
138
+ .filter((text) => text.length > 0)
139
+ .join("\n");
140
+ const body = [
141
+ `command: ${scrub(exec.command)}`,
142
+ `exitCode: ${exec.exitCode}`,
143
+ `signal: ${exec.signal}`,
144
+ `timedOut: ${exec.timedOut}`,
145
+ `spawnError: ${exec.spawnError}`,
146
+ "--- stdout ---",
147
+ scrub(exec.stdout),
148
+ "--- stderr ---",
149
+ scrub(exec.stderr),
150
+ ].join("\n");
151
+ const logDir = join(repoRoot, ".buildbeat", "runtime", "releases", workId);
152
+ mkdirSync(logDir, { recursive: true });
153
+ // Unique per readback: concurrent readbacks never share a log file.
154
+ const logPath = join(
155
+ logDir,
156
+ `release-${ts.replace(/[^0-9A-Za-z]/g, "")}-${randomUUID().slice(0, 8)}.log`,
157
+ );
158
+ writeFileSync(logPath, body, { encoding: "utf8", flag: "wx" });
159
+ const passed =
160
+ exec.exitCode === 0 && !exec.timedOut && !exec.signal && !exec.spawnError;
161
+ const row = {
162
+ ts,
163
+ work: workId,
164
+ run: released.id,
165
+ candidate: released.candidate,
166
+ ref,
167
+ commit,
168
+ checkout,
169
+ ...(note ? { note } : {}),
170
+ command: scrub(exec.command),
171
+ exitCode: exec.exitCode,
172
+ status: passed ? "passed" : "failed",
173
+ grade: "L4",
174
+ digest: `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`,
175
+ log: toRepoRef(repoRoot, logPath),
176
+ tail: output.split("\n").filter(Boolean).slice(-TAIL_LINES),
177
+ };
178
+ mkdirSync(workDir(repoRoot, workId), { recursive: true });
179
+ appendFileSync(
180
+ join(workDir(repoRoot, workId), "releases.jsonl"),
181
+ `${JSON.stringify(row)}\n`,
182
+ "utf8",
183
+ );
184
+ return row;
185
+ }
186
+
187
+ // Closes the release window: a close-work decision bound to the latest
188
+ // readback, which must have passed.
189
+ export function closeWork({
190
+ repoRoot,
191
+ workId,
192
+ result,
193
+ by = "human",
194
+ ts = new Date().toISOString(),
195
+ }) {
196
+ if (typeof result !== "string" || result.trim() === "") {
197
+ throw new ReleaseError("closing a work needs --result <text>");
198
+ }
199
+ const decisionsPath = join(workDir(repoRoot, workId), "decisions.jsonl");
200
+ const decisions = readRows(decisionsPath);
201
+ if (decisions.some((row) => row.transition === "close-work")) {
202
+ throw new ReleaseError(`${workId} is already closed`);
203
+ }
204
+ const latest = readReleases(repoRoot, workId).at(-1);
205
+ if (!latest) {
206
+ throw new ReleaseError(
207
+ `${workId} has no readback; run buildbeat release --config <run-config.yaml> after the release first`,
208
+ );
209
+ }
210
+ if (latest.status !== "passed") {
211
+ throw new ReleaseError(
212
+ `the latest readback failed (exit ${latest.exitCode} at ${latest.ts}); fix the release and run buildbeat release again before closing`,
213
+ );
214
+ }
215
+ const count = existsSync(decisionsPath)
216
+ ? readFileSync(decisionsPath, "utf8").split("\n").filter(Boolean).length
217
+ : 0;
218
+ const row = {
219
+ ts,
220
+ decisionRef: `C-${workId}-${count + 1}`,
221
+ decision: "closed",
222
+ transition: "close-work",
223
+ subject: {
224
+ result: result.trim(),
225
+ readback: latest.digest,
226
+ commit: latest.commit,
227
+ candidate: latest.candidate,
228
+ },
229
+ by,
230
+ };
231
+ appendFileSync(decisionsPath, `${JSON.stringify(row)}\n`, "utf8");
232
+ return row;
233
+ }
@@ -42,6 +42,7 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
42
42
  run: first.run,
43
43
  work: first.work,
44
44
  workflow: state.run?.workflowRef ?? null,
45
+ ...(state.run?.deliveryChecks ? { deliveryChecks: state.run.deliveryChecks } : {}),
45
46
  terminal: state.terminal,
46
47
  events: { from: first.seq, to: last.seq, lastDigest: last.digest },
47
48
  startedAt: first.ts,
@@ -374,6 +374,14 @@ export function pinCandidate(workspace) {
374
374
  return head;
375
375
  }
376
376
 
377
+ // Undoes createWorkspace for a run that never started (nothing was written
378
+ // to its ledger): the worktree and its branch both go, so the same run id
379
+ // can be created again.
380
+ export function discardWorkspace(workspace) {
381
+ git(workspace.repoRoot, ["worktree", "remove", "--force", workspace.worktreePath]);
382
+ git(workspace.repoRoot, ["branch", "-D", workspace.branch]);
383
+ }
384
+
377
385
  export function removeWorkspace(workspace, { force = false } = {}) {
378
386
  const { dirty } = readback(workspace.worktreePath);
379
387
  if (dirty && !force) {