@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
package/src/v2/cli/run.js CHANGED
@@ -17,11 +17,22 @@ import { fileURLToPath } from "node:url";
17
17
 
18
18
  import { createShellAdapter } from "../adapters/shell.js";
19
19
  import { loadRiskPreset } from "../engine/risk-preset.js";
20
- import { loadWorkflow, nextStep } from "../engine/workflow.js";
20
+ import {
21
+ DELIVERY_TEXT,
22
+ loadWorkflow,
23
+ deliveryWorkflow,
24
+ nextStep,
25
+ } from "../engine/workflow.js";
21
26
  import { parseYamlSubset } from "../engine/yaml-subset.js";
22
- import { parsePolicyDoc } from "../policy/policy.js";
23
- import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
24
- import { acceptArtifacts, adoptCandidate, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
27
+ import { deliveryChecks } from "../policy/policy.js";
28
+ import {
29
+ acceptArtifacts,
30
+ adoptCandidate,
31
+ boundArtifacts,
32
+ approveRun,
33
+ listInbox,
34
+ rejectRun,
35
+ } from "../runtime/decisions.js";
25
36
  import { checkRequires } from "../runtime/env-contract.js";
26
37
  import {
27
38
  adjudicateFinding,
@@ -31,7 +42,16 @@ import {
31
42
  } from "../runtime/findings.js";
32
43
  import { loadEnvelope, nextAttemptId } from "../runtime/envelope.js";
33
44
  import { applyGc, planGc } from "../runtime/gc.js";
34
- import { artifactStatus, computeOverview, readJsonl, renderOverview } from "../runtime/overview.js";
45
+ import {
46
+ artifactStatus,
47
+ computeOverview,
48
+ computeRepositoryOverview,
49
+ readJsonl,
50
+ renderOverview,
51
+ runsFor,
52
+ } from "../runtime/overview.js";
53
+ import { pathLabel, shellArg } from "../runtime/overview-repos.js";
54
+ import { closeWork, runReadback } from "../runtime/release.js";
35
55
  import {
36
56
  DEFAULT_STALL_AFTER_MS,
37
57
  describeLiveness,
@@ -42,17 +62,31 @@ import { computeMetrics, renderMetrics } from "../runtime/metrics.js";
42
62
  import {
43
63
  NOTIFY_CONFIG,
44
64
  buildNotification,
65
+ candidateScreenshots,
45
66
  dispatchNotification,
46
67
  loadNotifyConfig,
47
68
  nextReply,
48
69
  subscribes,
49
70
  } from "../runtime/notify.js";
50
71
  import { writeRunRecord } from "../runtime/run-record.js";
51
- import { DEFAULT_REVIEW_ROUNDS_PER_WORK, resumeRun, startRun } from "../runtime/orchestrator.js";
72
+ import {
73
+ DEFAULT_REVIEW_ROUNDS_PER_WORK,
74
+ assertRunConfiguration,
75
+ resumeRun,
76
+ startRun,
77
+ } from "../runtime/orchestrator.js";
52
78
  import { toRepoRef } from "../runtime/repo-ref.js";
53
79
  import { EventLedger } from "../storage/event-ledger.js";
54
- import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
55
- import { checkRunConfigAgainstWorkflow, checkRunConfigShape, RunConfigError } from "./run-config-check.js";
80
+ import {
81
+ acquireLock,
82
+ listHeldRunLocks,
83
+ releaseLock,
84
+ } from "../workspace/workspace-manager.js";
85
+ import {
86
+ checkRunConfigAgainstWorkflow,
87
+ checkRunConfigShape,
88
+ RunConfigError,
89
+ } from "./run-config-check.js";
56
90
 
57
91
  const KERNEL = { kind: "kernel", id: "cli" };
58
92
 
@@ -60,32 +94,25 @@ const USAGE = `BuildBeat runtime
60
94
 
61
95
  Usage:
62
96
  buildbeat --version
63
- buildbeat start --config <run-config.yaml> [--attempt new]
64
- buildbeat resume --config <run-config.yaml> [--run <RUN-ID>] [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
65
- buildbeat status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
66
- buildbeat inbox --repo <path>
67
- buildbeat overview --repo <path> [--work <WORK-ID>] [--json]
68
- buildbeat approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
69
- buildbeat reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
70
- buildbeat accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec>[,<more>] [--by <name>]
71
- buildbeat doctor --config <run-config.yaml>
72
- buildbeat events --repo <path> --run <RUN-ID>
73
- buildbeat replay --repo <path> --run <RUN-ID>
74
- buildbeat metrics --repo <path> [--json]
97
+ buildbeat accept --repo <path> --work <WORK-ID> [--by <name>]
98
+ buildbeat run --config <run-config.yaml> [--run <RUN-ID>] [--new] [--adopt <sha> --by <name>]
99
+ buildbeat status --repo <path> [--work <WORK-ID> | --run <RUN-ID>] [--all-repos] [--json]
100
+ buildbeat decide --repo <path> --run <RUN-ID> --action approve|reject --transition <t> [--by <name>] [--reason <text>]
101
+ buildbeat decide --repo <path> --work <WORK-ID> --action accept|dismiss --fingerprint <fp> [--by <name>] [--note <text>]
102
+ buildbeat release --config <run-config.yaml> [--ref <ref>] [--note <text>]
103
+ buildbeat decide --repo <path> --work <WORK-ID> --action close --result <text> [--by <name>]
104
+ buildbeat check --config <run-config.yaml> [--step <id>]
75
105
  buildbeat stop --repo <path> --run <RUN-ID> --reason <text>
76
106
  buildbeat gc --repo <path> [--apply] [--force]
77
- buildbeat watch --repo <path> --run <RUN-ID> [--stall-after <minutes>] [--interval <seconds>] [--once]
78
- buildbeat observe run --config <observe.yaml>
79
- buildbeat observe status --repo <path>
80
- buildbeat observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
81
- buildbeat preflight --config <run-config.yaml> --step <id>
82
- buildbeat findings list --repo <path> --work <WORK-ID>
83
- buildbeat findings adjudicate --repo <path> --work <WORK-ID> --fingerprint <fp> --action <accept|dismiss> [--by <name>] [--note <text>]
107
+ buildbeat history --repo <path> --run <RUN-ID> [--verify]
108
+
109
+ Core legacy command spellings remain aliases; see docs/MIGRATION.md.
110
+ check --step executes that worker in the main checkout, outside the evidence ledger.
84
111
  `;
85
112
 
86
113
  // Switches may stand alone (`--json`) or take an explicit true/false
87
114
  // (`--json true`, the older spelling); every other flag needs a value.
88
- const SWITCHES = new Set(["json", "apply", "force", "once"]);
115
+ const SWITCHES = new Set(["json", "apply", "force", "once", "new", "verify", "all-repos"]);
89
116
 
90
117
  function parseFlags(argv) {
91
118
  const flags = {};
@@ -115,7 +142,14 @@ function parseFlags(argv) {
115
142
  }
116
143
 
117
144
  function ledgerPathFor(repo, runId) {
118
- return join(resolve(repo), ".buildbeat", "runtime", "runs", runId, "events.jsonl");
145
+ return join(
146
+ resolve(repo),
147
+ ".buildbeat",
148
+ "runtime",
149
+ "runs",
150
+ runId,
151
+ "events.jsonl",
152
+ );
119
153
  }
120
154
 
121
155
  function stallAfterFromFlags(flags, fallbackMs = DEFAULT_STALL_AFTER_MS) {
@@ -177,7 +211,9 @@ function printState(state, ledger, view = {}) {
177
211
  : { steps: {}, inFlight: null };
178
212
  console.log(`run: ${state.run.id} (work ${state.run.work})`);
179
213
  console.log(`status: ${state.run.status}`);
180
- console.log(`workflow: ${state.run.workflowRef} @ ${state.run.workflowDigest}`);
214
+ console.log(
215
+ `workflow: ${state.run.workflowRef} @ ${state.run.workflowDigest}`,
216
+ );
181
217
  for (const [id, workspace] of Object.entries(state.workspaces)) {
182
218
  console.log(
183
219
  `workspace ${id}: base ${workspace.base.slice(0, 7)} candidate ${workspace.candidate ? workspace.candidate.slice(0, 7) : "(none)"}`,
@@ -201,11 +237,16 @@ function printState(state, ledger, view = {}) {
201
237
  suffix = ` [${parts.join(", ")}]`;
202
238
  }
203
239
  }
204
- console.log(`step ${step}: ${info.status} (attempts ${info.attempts})${suffix}`);
240
+ console.log(
241
+ `step ${step}: ${info.status} (attempts ${info.attempts})${suffix}`,
242
+ );
205
243
  }
206
244
  const live = liveness.inFlight;
207
245
  if (live) {
208
- const typical = live.typicalMs !== null ? `, typical ${formatMs(live.typicalMs)} n=${live.samples}` : "";
246
+ const typical =
247
+ live.typicalMs !== null
248
+ ? `, typical ${formatMs(live.typicalMs)} n=${live.samples}`
249
+ : "";
209
250
  console.log(
210
251
  `in flight: ${live.step} attempt ${live.attempt} since ${live.startedAt} (elapsed ${formatMs(live.elapsedMs)}${typical})`,
211
252
  );
@@ -213,9 +254,13 @@ function printState(state, ledger, view = {}) {
213
254
  console.log(` worker: ${live.command}`);
214
255
  }
215
256
  if (live.lastOutputAt) {
216
- console.log(` last output: ${live.lastOutputAt} (${formatMs(live.sinceOutputMs)} ago, ${live.bytes} bytes so far)`);
257
+ console.log(
258
+ ` last output: ${live.lastOutputAt} (${formatMs(live.sinceOutputMs)} ago, ${live.bytes} bytes so far)`,
259
+ );
217
260
  } else {
218
- console.log(` last output: (none yet, ${formatMs(live.sinceOutputMs)} since start)`);
261
+ console.log(
262
+ ` last output: (none yet, ${formatMs(live.sinceOutputMs)} since start)`,
263
+ );
219
264
  }
220
265
  if (live.stalled) {
221
266
  console.log(
@@ -228,9 +273,15 @@ function printState(state, ledger, view = {}) {
228
273
  }
229
274
  }
230
275
  for (const item of state.evidence) {
231
- const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
276
+ const ref = isAbsolute(item.ref)
277
+ ? "<legacy-absolute-evidence-ref>"
278
+ : item.ref;
232
279
  const reused = item.reused ? ` (reused from ${item.reused.run})` : "";
233
- console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}${reused}`);
280
+ // A screenshot is judged by the file its digest names.
281
+ const digest = item.kind === "screenshot" ? ` ${item.digest}` : "";
282
+ console.log(
283
+ `evidence [${item.status}/${item.grade}] ${item.kind} ${ref}${digest}${reused}`,
284
+ );
234
285
  }
235
286
  if (state.pendingHuman) {
236
287
  console.log(`waiting on human: ${state.pendingHuman.transition}`);
@@ -242,7 +293,9 @@ function printState(state, ledger, view = {}) {
242
293
  }
243
294
  }
244
295
  if (state.terminal) {
245
- console.log(`terminal: ${state.terminal.status} (${state.terminal.reason})`);
296
+ console.log(
297
+ `terminal: ${state.terminal.status} (${state.terminal.reason})`,
298
+ );
246
299
  }
247
300
  if (state.compacted) {
248
301
  console.log(`compacted: ${state.compacted.runRecordRef}`);
@@ -254,7 +307,9 @@ function workerEnvOverrides(worker, env) {
254
307
  return {};
255
308
  }
256
309
  if (typeof env !== "object" || Array.isArray(env)) {
257
- throw new Error(`workers.${worker}.env must be a mapping of variable names to scalar values`);
310
+ throw new Error(
311
+ `workers.${worker}.env must be a mapping of variable names to scalar values`,
312
+ );
258
313
  }
259
314
  const out = {};
260
315
  for (const [key, value] of Object.entries(env)) {
@@ -288,9 +343,15 @@ function loadRunConfig(flags, command) {
288
343
  workflowText = readFileSync(workflowPath, "utf8");
289
344
  workflow = loadWorkflow(workflowPath);
290
345
  } catch (error) {
291
- problems.push(`workflow: cannot load ${config.workflow}: ${error.message}`);
346
+ problems.push(
347
+ `workflow: cannot load ${config.workflow}: ${error.message}`,
348
+ );
292
349
  }
293
350
  }
351
+ if (config?.workflow === undefined) {
352
+ workflow = deliveryWorkflow();
353
+ workflowPath = null;
354
+ }
294
355
  if (workflow) {
295
356
  problems.push(...checkRunConfigAgainstWorkflow(config, workflow));
296
357
  }
@@ -298,6 +359,7 @@ function loadRunConfig(flags, command) {
298
359
  throw new RunConfigError(flags.config, problems);
299
360
  }
300
361
  const repoRoot = resolve(configDir, config.repo);
362
+ workflowText ??= DELIVERY_TEXT;
301
363
  const workflowDigest = `sha256:${createHash("sha256").update(workflowText, "utf8").digest("hex")}`;
302
364
 
303
365
  const adapters = {};
@@ -323,45 +385,89 @@ function loadRunConfig(flags, command) {
323
385
  return `sha256:${createHash("sha256").update(readFileSync(filePath, "utf8"), "utf8").digest("hex")}`;
324
386
  };
325
387
 
326
- const policies = [];
327
- let presetStopAt = null;
328
- let riskPreset = "standard";
329
- if (config.riskPreset) {
330
- const preset = loadRiskPreset(config.riskPreset);
331
- policies.push(...preset.policies);
332
- presetStopAt = preset.stopAt;
333
- riskPreset = preset.name;
334
- }
335
- for (const policyPath of config.policies ?? []) {
336
- policies.push(parsePolicyDoc(parseYamlSubset(readFileSync(resolve(configDir, policyPath), "utf8"))));
388
+ const workDir = join(repoRoot, "delivery", "work", config.work);
389
+ const artifact =
390
+ existsSync(join(workDir, "work.md")) || !config.workflow ? "work" : "plan";
391
+ // 3.3.1 ran a workflow config without riskPreset with no artifact gate;
392
+ // keep that (the merge evidence floor is now always on). New configs
393
+ // (no workflow) default to standard.
394
+ const riskPreset =
395
+ config.riskPreset ?? (config.workflow === undefined ? "standard" : "fast");
396
+ const preset = loadRiskPreset(riskPreset, { artifact });
397
+ const safeguards = { ...preset.checks };
398
+ if (config.maxReviewSeverity) {
399
+ if (safeguards.maxSeverity === "P3" && config.maxReviewSeverity === "P2") {
400
+ throw new Error(
401
+ "maxReviewSeverity cannot weaken the legacy controlled safeguards",
402
+ );
403
+ }
404
+ safeguards.maxSeverity = config.maxReviewSeverity;
405
+ }
406
+ // Frozen with the run like every other safeguard; absent unless enabled,
407
+ // so runs created before the switch existed compare equal.
408
+ if (config.requireScreenshot === true) {
409
+ safeguards.requireScreenshot = true;
410
+ }
411
+ const policies = deliveryChecks(safeguards);
412
+ const presetStopAt = [];
413
+ const entry = config.entry ?? workflow.entry;
414
+ // Legacy planner steps would write the artifact inside the run, but an
415
+ // accepted artifact is bound (and checked in base) before the run starts.
416
+ if (
417
+ safeguards.requireAcceptance &&
418
+ ["intent", "spec", "plan"].includes(entry)
419
+ ) {
420
+ throw new Error(
421
+ `entry ${entry} starts at the retired planner steps, but risk preset ${riskPreset} binds an accepted ${artifact}.md before the run starts; write, accept and commit ${artifact}.md, then set entry: build (docs/MIGRATION.md)`,
422
+ );
337
423
  }
338
424
 
339
- if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
340
- throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
425
+ if (
426
+ config.reviewTriage !== undefined &&
427
+ !["required", "off"].includes(config.reviewTriage)
428
+ ) {
429
+ throw new Error(
430
+ `reviewTriage must be "required" or "off", got: ${config.reviewTriage}`,
431
+ );
341
432
  }
342
- if (config.supersede !== undefined && !["waiting", "off"].includes(config.supersede)) {
343
- throw new Error(`supersede must be "waiting" or "off", got: ${config.supersede}`);
433
+ if (
434
+ config.supersede !== undefined &&
435
+ !["waiting", "off"].includes(config.supersede)
436
+ ) {
437
+ throw new Error(
438
+ `supersede must be "waiting" or "off", got: ${config.supersede}`,
439
+ );
344
440
  }
345
441
  if (config.stallAfterMs !== undefined && !(Number(config.stallAfterMs) > 0)) {
346
- throw new Error(`stallAfterMs must be a positive number, got: ${config.stallAfterMs}`);
442
+ throw new Error(
443
+ `stallAfterMs must be a positive number, got: ${config.stallAfterMs}`,
444
+ );
347
445
  }
348
446
  // budgets: run config beats the preset (a preset cap that could not be
349
447
  // raised per run once ended a pilot's shipped candidates as CANCELLED
350
448
  // runs).
351
449
  const budgets = {};
352
450
  if (config.budgets !== undefined) {
353
- if (!config.budgets || typeof config.budgets !== "object" || Array.isArray(config.budgets)) {
451
+ if (
452
+ !config.budgets ||
453
+ typeof config.budgets !== "object" ||
454
+ Array.isArray(config.budgets)
455
+ ) {
354
456
  throw new Error("budgets must be a map");
355
457
  }
356
458
  for (const key of Object.keys(config.budgets)) {
357
459
  if (!["maxAttempts", "reviewRoundsPerWork"].includes(key)) {
358
- throw new Error(`unknown budgets key: ${key} (known: maxAttempts, reviewRoundsPerWork)`);
460
+ throw new Error(
461
+ `unknown budgets key: ${key} (known: maxAttempts, reviewRoundsPerWork)`,
462
+ );
359
463
  }
360
464
  }
361
465
  if (config.budgets.maxAttempts !== undefined) {
362
466
  const map = config.budgets.maxAttempts;
363
467
  if (!map || typeof map !== "object" || Array.isArray(map)) {
364
- throw new Error("budgets.maxAttempts must be a map of step -> positive integer");
468
+ throw new Error(
469
+ "budgets.maxAttempts must be a map of step -> positive integer",
470
+ );
365
471
  }
366
472
  budgets.maxAttempts = {};
367
473
  for (const [step, value] of Object.entries(map)) {
@@ -369,7 +475,9 @@ function loadRunConfig(flags, command) {
369
475
  throw new Error(`budgets.maxAttempts.${step}: step not in workflow`);
370
476
  }
371
477
  if (!Number.isInteger(Number(value)) || Number(value) < 1) {
372
- throw new Error(`budgets.maxAttempts.${step} must be a positive integer, got: ${value}`);
478
+ throw new Error(
479
+ `budgets.maxAttempts.${step} must be a positive integer, got: ${value}`,
480
+ );
373
481
  }
374
482
  budgets.maxAttempts[step] = Number(value);
375
483
  }
@@ -377,7 +485,9 @@ function loadRunConfig(flags, command) {
377
485
  if (config.budgets.reviewRoundsPerWork !== undefined) {
378
486
  const value = Number(config.budgets.reviewRoundsPerWork);
379
487
  if (!Number.isInteger(value) || value < 1) {
380
- throw new Error(`budgets.reviewRoundsPerWork must be a positive integer, got: ${config.budgets.reviewRoundsPerWork}`);
488
+ throw new Error(
489
+ `budgets.reviewRoundsPerWork must be a positive integer, got: ${config.budgets.reviewRoundsPerWork}`,
490
+ );
381
491
  }
382
492
  budgets.reviewRoundsPerWork = value;
383
493
  }
@@ -396,10 +506,16 @@ function loadRunConfig(flags, command) {
396
506
  try {
397
507
  return new RegExp(String(pattern), "g");
398
508
  } catch {
399
- throw new Error(`redact pattern is not a valid regular expression: ${pattern}`);
509
+ throw new Error(
510
+ `redact pattern is not a valid regular expression: ${pattern}`,
511
+ );
400
512
  }
401
513
  });
402
- const envelope = loadEnvelope(config, configDir, Object.keys(config.workers ?? {}));
514
+ const envelope = loadEnvelope(
515
+ config,
516
+ configDir,
517
+ Object.keys(config.workers ?? {}),
518
+ );
403
519
 
404
520
  return {
405
521
  envelope,
@@ -411,11 +527,19 @@ function loadRunConfig(flags, command) {
411
527
  workId: config.work,
412
528
  runId: config.run,
413
529
  base: config.base ?? "HEAD",
414
- entry: config.entry ?? workflow.entry,
530
+ entry,
415
531
  stopAt: config.stopAt ?? presetStopAt ?? [],
416
532
  adapters,
417
533
  adapterConfigs: config.workers ?? {},
534
+ release: config.release
535
+ ? {
536
+ ...config.release,
537
+ env: workerEnvOverrides("release", config.release.env),
538
+ }
539
+ : null,
418
540
  policies,
541
+ deliveryChecks: safeguards,
542
+ workArtifact: artifact,
419
543
  riskPreset,
420
544
  maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
421
545
  budgets,
@@ -425,8 +549,11 @@ function loadRunConfig(flags, command) {
425
549
  reviewTriage: config.reviewTriage === "required" ? "required" : null,
426
550
  supersede: config.supersede ?? "waiting",
427
551
  parallel: config.parallel === true,
428
- stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
429
- planDigest: digestOfWorkFile("plan.md"),
552
+ stallAfterMs:
553
+ config.stallAfterMs !== undefined
554
+ ? Number(config.stallAfterMs)
555
+ : DEFAULT_STALL_AFTER_MS,
556
+ planDigest: digestOfWorkFile(`${artifact}.md`),
430
557
  intentDigest: digestOfWorkFile("intent.md"),
431
558
  };
432
559
  }
@@ -461,9 +588,17 @@ async function notifyForState(repoRoot, repoLabel, state) {
461
588
  if (!subscribes(config, kind)) {
462
589
  continue;
463
590
  }
464
- const results = await dispatchNotification(config, buildNotification(kind, { repoLabel, state }), { repoRoot });
591
+ const results = await dispatchNotification(
592
+ config,
593
+ buildNotification(kind, { repoLabel, state }),
594
+ { repoRoot },
595
+ );
465
596
  for (const row of results) {
466
- const outcome = row.ok ? "sent" : row.skipped ? `skipped (${row.error})` : `FAILED (${row.error})`;
597
+ const outcome = row.ok
598
+ ? "sent"
599
+ : row.skipped
600
+ ? `skipped (${row.error})`
601
+ : `FAILED (${row.error})`;
467
602
  console.log(`notify ${kind} -> ${row.channel}: ${outcome}`);
468
603
  }
469
604
  }
@@ -498,29 +633,108 @@ function spawnStallWatcher(repoRoot, runId, stallAfterMs) {
498
633
  return true;
499
634
  }
500
635
 
501
- async function commandStart(flags) {
502
- const options = loadRunConfig(flags, "start");
636
+ const textDigest = (text) =>
637
+ `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
638
+
639
+ async function commandStart(flags, options = loadRunConfig(flags, "start")) {
640
+ // Pin the base first: the artifacts are checked in, and the workspace is
641
+ // created from, the same commit even if the ref moves meanwhile.
642
+ try {
643
+ options.base = execFileSync(
644
+ "git",
645
+ [
646
+ "-C",
647
+ options.repoRoot,
648
+ "rev-parse",
649
+ "--verify",
650
+ `${options.base}^{commit}`,
651
+ ],
652
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
653
+ ).trim();
654
+ } catch {
655
+ throw new Error(`base ${options.base} does not name a commit`);
656
+ }
657
+ const creation = {
658
+ deliveryChecks: options.deliveryChecks,
659
+ intentDigest: options.intentDigest ?? "UNVERIFIED",
660
+ };
661
+ const committed = boundArtifacts(
662
+ creation,
663
+ options.planDigest ?? "UNVERIFIED",
664
+ ).filter((entry) => entry.committed);
665
+ const mismatch = (ref, text, digest) => {
666
+ if (text === null) {
667
+ throw new Error(
668
+ `commit ${ref} into the selected base before starting; workers read that isolated checkout`,
669
+ );
670
+ }
671
+ if (textDigest(text) !== digest) {
672
+ throw new Error(
673
+ `the selected base contains a different ${ref}; commit the accepted work artifact before starting`,
674
+ );
675
+ }
676
+ };
677
+ // Presence is checked before anything is created. Content is compared on
678
+ // the isolated checkout itself: Git applies that checkout's attributes and
679
+ // filters there, which no pre-read reproduces on every Git version.
680
+ for (const { artifact } of committed) {
681
+ const ref = `delivery/work/${options.workId}/${artifact}.md`;
682
+ try {
683
+ execFileSync(
684
+ "git",
685
+ ["-C", options.repoRoot, "cat-file", "-e", `${options.base}:${ref}`],
686
+ { stdio: "ignore" },
687
+ );
688
+ } catch {
689
+ mismatch(ref, null);
690
+ }
691
+ }
692
+ options.verifyCheckout = (worktreePath) => {
693
+ for (const { artifact, digest } of committed) {
694
+ const ref = `delivery/work/${options.workId}/${artifact}.md`;
695
+ const file = join(worktreePath, ref);
696
+ const text = existsSync(file) ? readFileSync(file, "utf8") : null;
697
+ mismatch(ref, text, digest);
698
+ }
699
+ };
700
+
503
701
  if (flags.attempt !== undefined) {
504
702
  if (flags.attempt !== "new") {
505
- throw new Error(`--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`);
703
+ throw new Error(
704
+ `--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`,
705
+ );
506
706
  }
507
707
  // The run config names the family; the kernel numbers the attempt. One
508
708
  // config per work, not one per retry (the campaign hand-numbered -01..-30).
509
- options.runId = nextAttemptId(options.repoRoot, options.workId, options.runId);
709
+ options.runId = nextAttemptId(
710
+ options.repoRoot,
711
+ options.workId,
712
+ options.runId,
713
+ );
510
714
  console.log(`attempt: ${options.runId}`);
511
715
  }
512
716
  if (options.envelope) {
513
- console.log(`envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`);
717
+ console.log(
718
+ `envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`,
719
+ );
514
720
  }
515
721
  if (process.stdout.isTTY) {
516
722
  // Run launch discipline (real incident: a host-tool timeout killed a
517
723
  // verify worker mid-run): anything longer than minutes belongs in a
518
724
  // detached process, not an interactive foreground shell.
519
- console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
725
+ console.log(
726
+ "tip: long runs should be started detached (nohup/setsid); interactive shells die with their host",
727
+ );
520
728
  }
521
- const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
729
+ const watching = spawnStallWatcher(
730
+ options.repoRoot,
731
+ options.runId,
732
+ options.stallAfterMs,
733
+ );
522
734
  if (watching) {
523
- console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
735
+ console.log(
736
+ `stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`,
737
+ );
524
738
  }
525
739
  let result;
526
740
  try {
@@ -535,28 +749,45 @@ async function commandStart(flags) {
535
749
  if (holders.length === 0) {
536
750
  // A dead owner would already have been reclaimed; the error below
537
751
  // names who holds the lock and what to do.
538
- console.error("blocked by: the active-run lock alone (no run lock beside it); its owner is named below");
752
+ console.error(
753
+ "blocked by: the active-run lock alone (no run lock beside it); its owner is named below",
754
+ );
539
755
  }
540
756
  for (const holder of holders) {
541
- const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
757
+ const ledgerPath = join(
758
+ options.repoRoot,
759
+ ".buildbeat",
760
+ "runtime",
761
+ "runs",
762
+ holder,
763
+ "events.jsonl",
764
+ );
542
765
  let summary = "(no ledger found)";
543
766
  if (existsSync(ledgerPath)) {
544
767
  const ledger = EventLedger.open(ledgerPath);
545
768
  const state = ledger.state;
546
- const step = state.currentStep ? `step ${state.currentStep} attempt ${state.steps[state.currentStep]?.attempts ?? "?"}` : "between steps";
769
+ const step = state.currentStep
770
+ ? `step ${state.currentStep} attempt ${state.steps[state.currentStep]?.attempts ?? "?"}`
771
+ : "between steps";
547
772
  const since = ledger.events[ledger.events.length - 1]?.ts;
548
773
  summary = `${state.run?.work ?? "?"} ${state.run?.status ?? "?"} ${step}${since ? `, last event ${formatMs(Date.now() - Date.parse(since))} ago` : ""}`;
549
774
  }
550
775
  console.error(`blocked by ${holder}: ${summary}`);
551
- console.error(` watch it: buildbeat status --repo ${label} --run ${holder}`);
776
+ console.error(
777
+ ` watch it: buildbeat status --repo ${label} --run ${holder}`,
778
+ );
552
779
  }
553
- console.error("queue position: next after the holder(s) above stop or wait on a human (by default one run drives a repository at a time; works whose run configs both set parallel: true can drive together)");
780
+ console.error(
781
+ "queue position: next after the holder(s) above stop or wait on a human (by default one run drives a repository at a time; works whose run configs both set parallel: true can drive together)",
782
+ );
554
783
  }
555
784
  throw error;
556
785
  }
557
786
  const repoLabel = repoLabelFor(options.repoRoot);
558
787
  for (const run of result.superseded ?? []) {
559
- console.log(`superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`);
788
+ console.log(
789
+ `superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`,
790
+ );
560
791
  }
561
792
  for (const row of result.supersedeSkipped ?? []) {
562
793
  console.log(`could not supersede ${row.run}: ${row.reason}`);
@@ -571,27 +802,52 @@ async function commandStart(flags) {
571
802
  await notifyForState(options.repoRoot, repoLabel, ledger.state);
572
803
  }
573
804
 
805
+ // A run family is the configured run id plus its numbered attempts
806
+ // (<run>-01, <run>-02, ...); a longer id that merely shares the prefix
807
+ // belongs to another family.
808
+ function inRunFamily(family, id) {
809
+ const escaped = family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
810
+ return id === family || new RegExp(`^${escaped}-\\d{2,}$`).test(id);
811
+ }
812
+
574
813
  // Resolve only from runtime ledgers. Reading candidates does not acquire
575
- // their locks; resumeRun still owns the lock and freshness checks.
576
- function resolveResumeRun(repoRoot, family, explicitRun) {
577
- const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d{2,}$`);
814
+ // their locks; resumeRun still owns the lock and freshness checks. The
815
+ // legacy resume spelling keeps 3.3.1's exact-id precedence; `run` picks the
816
+ // unique unfinished run across the whole family (exact id included), and
817
+ // with none unfinished reports the latest one so the user sees --new.
818
+ function resolveResumeRun(
819
+ repoRoot,
820
+ family,
821
+ explicitRun,
822
+ { familyWide = false } = {},
823
+ ) {
578
824
  if (explicitRun !== undefined) {
579
- if (explicitRun !== family && !pattern.test(explicitRun)) {
580
- throw new Error(`--run ${explicitRun} is not in run family ${family} of this config`);
825
+ if (!inRunFamily(family, explicitRun)) {
826
+ throw new Error(
827
+ `--run ${explicitRun} is not in run family ${family} of this config`,
828
+ );
581
829
  }
582
830
  if (!existsSync(ledgerPathFor(repoRoot, explicitRun))) {
583
831
  throw new Error(`no ledger for run ${explicitRun}`);
584
832
  }
585
833
  return explicitRun;
586
834
  }
587
- if (existsSync(ledgerPathFor(repoRoot, family))) {
835
+ if (!familyWide && existsSync(ledgerPathFor(repoRoot, family))) {
588
836
  return family;
589
837
  }
590
838
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
591
839
  const runs = (existsSync(runsDir) ? readdirSync(runsDir) : [])
592
- .filter((id) => pattern.test(id) && existsSync(ledgerPathFor(repoRoot, id)))
840
+ .filter(
841
+ (id) =>
842
+ (familyWide || id !== family) &&
843
+ inRunFamily(family, id) &&
844
+ existsSync(ledgerPathFor(repoRoot, id)),
845
+ )
593
846
  .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
594
- .map((id) => ({ id, state: EventLedger.open(ledgerPathFor(repoRoot, id)).state }));
847
+ .map((id) => ({
848
+ id,
849
+ state: EventLedger.open(ledgerPathFor(repoRoot, id)).state,
850
+ }));
595
851
  const open = runs.filter(({ state }) => !state.terminal);
596
852
  if (open.length === 1) {
597
853
  console.log(`resuming ${open[0].id} (the open run of family ${family})`);
@@ -599,14 +855,60 @@ function resolveResumeRun(repoRoot, family, explicitRun) {
599
855
  }
600
856
  if (open.length === 0) {
601
857
  const latest = runs.at(-1);
602
- throw new Error(`no open run in family ${family}; ${latest ? `latest run ${latest.id}: ${latest.state.terminal.status}` : "no ledgers found"}; use --run <RUN-ID> to select an existing run explicitly`);
858
+ if (familyWide && latest) {
859
+ return latest.id;
860
+ }
861
+ throw new Error(
862
+ `no open run in family ${family}; ${latest ? `latest run ${latest.id}: ${latest.state.terminal.status}` : "no ledgers found"}; use --run <RUN-ID> to select an existing run explicitly, or run --new to start another attempt`,
863
+ );
603
864
  }
604
- throw new Error(`multiple open runs in family ${family}: ${open.map(({ id }) => id).join(", ")}; use --run <RUN-ID> to select one`);
865
+ throw new Error(
866
+ `multiple open runs in family ${family}: ${open.map(({ id }) => id).join(", ")}; use --run <RUN-ID> to select one`,
867
+ );
605
868
  }
606
869
 
607
- async function commandResume(flags) {
608
- const options = loadRunConfig(flags, "resume");
609
- options.runId = resolveResumeRun(options.repoRoot, options.runId, flags.run);
870
+ async function commandResume(
871
+ flags,
872
+ options = loadRunConfig(flags, "resume"),
873
+ { familyWide = false } = {},
874
+ ) {
875
+ options.runId = resolveResumeRun(options.repoRoot, options.runId, flags.run, {
876
+ familyWide,
877
+ });
878
+ const prior = EventLedger.open(
879
+ ledgerPathFor(options.repoRoot, options.runId),
880
+ );
881
+ if (
882
+ prior.state.run &&
883
+ !prior.state.terminal &&
884
+ !prior.state.run.deliveryChecks
885
+ ) {
886
+ throw new Error(
887
+ "legacy active run: finish or cancel with its 3.3.1 runtime; new attempts can use this version (docs/MIGRATION.md)",
888
+ );
889
+ }
890
+ // A finished run only reports its outcome: a config migrated since it ran
891
+ // must not turn "already terminal" into a configuration error.
892
+ if (!prior.state.terminal) {
893
+ assertRunConfiguration(prior.state, options);
894
+ }
895
+ // A frozen run continues only under the artifacts it was created with;
896
+ // its final approval would refuse a changed scope anyway.
897
+ if (!prior.state.terminal && prior.state.run?.deliveryChecks) {
898
+ const now = {
899
+ [prior.state.run.deliveryChecks.artifact]:
900
+ options.planDigest ?? "UNVERIFIED",
901
+ intent: options.intentDigest ?? "UNVERIFIED",
902
+ };
903
+ const changed = boundArtifacts(prior.state.run)
904
+ .filter(({ artifact, digest }) => now[artifact] !== digest)
905
+ .map(({ artifact }) => `${artifact}.md`);
906
+ if (changed.length > 0) {
907
+ throw new Error(
908
+ `work artifact changed since the run was created (${changed.join(", ")}); cannot ${flags.adopt !== undefined ? "adopt a candidate" : "resume"} under a different scope; accept it and start a new attempt with run --new`,
909
+ );
910
+ }
911
+ }
610
912
  if (flags.adopt !== undefined) {
611
913
  const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
612
914
  const adopted = adoptCandidate(options.repoRoot, options.runId, {
@@ -614,16 +916,29 @@ async function commandResume(flags) {
614
916
  by: flags.by ?? "human",
615
917
  resumeAt,
616
918
  });
617
- console.log(`adopted ${adopted.adopted} as candidate (${adopted.decisionRef}, answers ${adopted.transition}); resuming at ${adopted.resumeAt}`);
919
+ console.log(
920
+ `adopted ${adopted.adopted} as candidate (${adopted.decisionRef}, answers ${adopted.transition}); resuming at ${adopted.resumeAt}`,
921
+ );
618
922
  }
619
- const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
923
+ const watching = spawnStallWatcher(
924
+ options.repoRoot,
925
+ options.runId,
926
+ options.stallAfterMs,
927
+ );
620
928
  if (watching) {
621
- console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
929
+ console.log(
930
+ `stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`,
931
+ );
622
932
  }
623
933
  const result = resumeRun(options);
624
934
  const repoLabel = repoLabelFor(options.repoRoot);
625
935
  if (!result.resumed) {
626
936
  console.log(`nothing to resume: ${result.reason}`);
937
+ if (result.state?.terminal) {
938
+ console.log(
939
+ "start another attempt with: buildbeat run --config <run-config.yaml> --new",
940
+ );
941
+ }
627
942
  }
628
943
  console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
629
944
  const ledger = EventLedger.open(result.ledgerPath);
@@ -637,39 +952,65 @@ async function commandResume(flags) {
637
952
  }
638
953
  }
639
954
 
640
- function commandInbox(flags) {
955
+ function pendingFor(repoRoot, work) {
956
+ return listInbox(repoRoot).filter(
957
+ (row) => !work || row.work === work || row.corrupted,
958
+ );
959
+ }
960
+
961
+ function commandInbox(flags, rows = null) {
641
962
  if (!flags.repo) {
642
963
  throw new Error("inbox requires --repo");
643
964
  }
644
965
  const repoRoot = resolve(flags.repo);
645
- const rows = listInbox(repoRoot);
966
+ rows ??= pendingFor(repoRoot, flags.work);
646
967
  if (rows.length === 0) {
647
968
  console.log("inbox empty: no runs waiting on a human");
648
969
  return;
649
970
  }
650
- const sorted = [...rows].sort((a, b) => `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`));
971
+ const sorted = [...rows].sort((a, b) =>
972
+ `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`),
973
+ );
651
974
  let lastWork = null;
652
975
  for (const row of sorted) {
976
+ const rowRepo = row.repo ?? repoRoot;
653
977
  if (row.corrupted) {
654
- console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
978
+ console.log(
979
+ `${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`,
980
+ );
655
981
  continue;
656
982
  }
657
983
  if (row.work !== lastWork) {
658
984
  console.log(`work ${row.work}:`);
659
985
  lastWork = row.work;
660
986
  }
661
- const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
662
- const requested = [...ledger.events].reverse().find((event) => event.type === "HUMAN_REQUESTED");
663
- const age = requested ? formatMs(Date.now() - Date.parse(requested.ts)) : "?";
664
- console.log(` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`);
987
+ const ledger = EventLedger.open(ledgerPathFor(rowRepo, row.run));
988
+ const requested = [...ledger.events]
989
+ .reverse()
990
+ .find((event) => event.type === "HUMAN_REQUESTED");
991
+ const age = requested
992
+ ? formatMs(Date.now() - Date.parse(requested.ts))
993
+ : "?";
994
+ console.log(
995
+ ` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`,
996
+ );
665
997
  console.log(` candidate: ${row.subject.candidate}`);
666
998
  console.log(` planDigest: ${row.subject.planDigest}`);
667
999
  console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
1000
+ for (const shot of candidateScreenshots(
1001
+ ledger.state,
1002
+ row.subject.candidate,
1003
+ )) {
1004
+ console.log(` screenshot: ${shot.ref} ${shot.digest}`);
1005
+ }
668
1006
  for (const reason of row.reasons) {
669
1007
  console.log(` reason: ${reason}`);
670
1008
  }
671
- for (const line of nextReply({ repoLabel: repoLabelFor(repoRoot, flags.repo), state: ledger.state })) {
672
- console.log(` next: ${line}`);
1009
+ for (const line of nextReply({
1010
+ repoLabel: row.repo ? shellArg(pathLabel(process.cwd(), rowRepo)) : repoLabelFor(repoRoot, flags.repo),
1011
+ state: ledger.state,
1012
+ })) {
1013
+ console.log(` next: ${row.config ? line.replaceAll("--config <run-config.yaml>", `--config ${shellArg(pathLabel(process.cwd(), row.config))}`) : line}`);
673
1014
  }
674
1015
  }
675
1016
  }
@@ -678,6 +1019,16 @@ function commandApprove(flags) {
678
1019
  if (!flags.repo || !flags.run || !flags.transition) {
679
1020
  throw new Error("approve requires --repo, --run and --transition");
680
1021
  }
1022
+ const prior = EventLedger.open(ledgerPathFor(resolve(flags.repo), flags.run));
1023
+ if (
1024
+ prior.state.run &&
1025
+ !prior.state.terminal &&
1026
+ !prior.state.run.deliveryChecks
1027
+ ) {
1028
+ throw new Error(
1029
+ "legacy active run: approve with its original 3.3.1 runtime (docs/MIGRATION.md)",
1030
+ );
1031
+ }
681
1032
  const policies = flags.config ? loadRunConfig(flags, "approve").policies : [];
682
1033
  const result = approveRun(resolve(flags.repo), flags.run, {
683
1034
  by: flags.by ?? "human",
@@ -685,7 +1036,9 @@ function commandApprove(flags) {
685
1036
  policies,
686
1037
  });
687
1038
  if (!result.approved) {
688
- console.log("NOT approved: the subject changed since the request; a refreshed request was filed");
1039
+ console.log(
1040
+ "NOT approved: the subject changed since the request; a refreshed request was filed",
1041
+ );
689
1042
  console.log(` new candidate: ${result.subject.candidate}`);
690
1043
  return;
691
1044
  }
@@ -696,9 +1049,13 @@ function commandApprove(flags) {
696
1049
  console.log(` advisory: ${warning}`);
697
1050
  }
698
1051
  if (result.terminal) {
699
- console.log("run is terminal: SUCCEEDED (merge itself stays a manual external action)");
1052
+ console.log(
1053
+ "run is terminal: SUCCEEDED (merge itself stays a manual external action)",
1054
+ );
700
1055
  } else {
701
- console.log(`decision recorded; continue with: buildbeat resume --config <run-config.yaml> --run ${flags.run}`);
1056
+ console.log(
1057
+ `decision recorded; continue with: buildbeat run --config <run-config.yaml> --run ${flags.run}`,
1058
+ );
702
1059
  }
703
1060
  }
704
1061
 
@@ -715,10 +1072,17 @@ function commandReject(flags) {
715
1072
  }
716
1073
 
717
1074
  function commandAccept(flags) {
718
- if (!flags.repo || !flags.work || !flags.artifact) {
719
- throw new Error("accept requires --repo, --work and --artifact");
720
- }
721
- const artifacts = String(flags.artifact).split(",").map((name) => name.trim()).filter(Boolean);
1075
+ if (!flags.repo || !flags.work) {
1076
+ throw new Error("accept requires --repo and --work");
1077
+ }
1078
+ const workDir = join(resolve(flags.repo), "delivery", "work", flags.work);
1079
+ const selection =
1080
+ flags.artifact ??
1081
+ (existsSync(join(workDir, "work.md")) ? "work" : "intent,plan");
1082
+ const artifacts = String(selection)
1083
+ .split(",")
1084
+ .map((name) => name.trim())
1085
+ .filter(Boolean);
722
1086
  const results = acceptArtifacts(resolve(flags.repo), flags.work, artifacts, {
723
1087
  by: flags.by ?? "human",
724
1088
  });
@@ -726,33 +1090,17 @@ function commandAccept(flags) {
726
1090
  console.log(`accepted ${result.artifact} as ${result.decisionRef}`);
727
1091
  console.log(` digest: ${result.digest}`);
728
1092
  }
729
- console.log(" note: editing an artifact after acceptance makes its acceptance stale");
730
- }
731
-
732
- // Artifacts a policy rule requires to be accepted (artifact.accepted leaves
733
- // anywhere under all/any/not).
734
- function artifactsRequiredBy(rule, found = []) {
735
- if (!rule || typeof rule !== "object") {
736
- return found;
737
- }
738
- for (const [key, value] of Object.entries(rule)) {
739
- if (key === "artifact.accepted" && value && typeof value.artifact === "string") {
740
- found.push(value.artifact);
741
- } else if (Array.isArray(value)) {
742
- for (const item of value) {
743
- artifactsRequiredBy(item, found);
744
- }
745
- } else if (value && typeof value === "object") {
746
- artifactsRequiredBy(value, found);
747
- }
748
- }
749
- return found;
1093
+ console.log(
1094
+ " note: editing an artifact after acceptance makes its acceptance stale",
1095
+ );
750
1096
  }
751
1097
 
752
1098
  function commandDoctor(flags) {
753
1099
  const options = loadRunConfig(flags, "doctor");
754
1100
  console.log(`risk preset: ${options.riskPreset}`);
755
- console.log(`stopAt boundaries: ${options.stopAt.length > 0 ? options.stopAt.join(", ") : "(none)"}`);
1101
+ console.log(
1102
+ `stopAt boundaries: ${options.stopAt.length > 0 ? options.stopAt.join(", ") : "(none)"}`,
1103
+ );
756
1104
  console.log("policies (declared vs actually achieved enforcement):");
757
1105
  if (options.policies.length === 0) {
758
1106
  console.log(" (none configured)");
@@ -762,24 +1110,36 @@ function commandDoctor(flags) {
762
1110
  if (policy.enforcement === "ADVISORY") {
763
1111
  actual = "ADVISORY (prompt-level only; nothing stops a worker)";
764
1112
  } else if (policy.enforcement === "SERVER_ENFORCED") {
765
- actual = "UNVERIFIED (branch protection/CI cannot be verified from here; not claiming it)";
1113
+ actual =
1114
+ "UNVERIFIED (branch protection/CI cannot be verified from here; not claiming it)";
766
1115
  } else if (policy.type === "transition") {
767
1116
  actual = "LOCAL_ENFORCED (approve gate refuses the stamp)";
768
1117
  } else {
769
1118
  actual = "LOCAL_ENFORCED (runner gate before/after the step)";
770
1119
  }
771
- console.log(` ${policy.name}: type=${policy.type} appliesTo=${policy.appliesTo} declared=${policy.enforcement} actual=${actual}`);
1120
+ console.log(
1121
+ ` ${policy.name}: type=${policy.type} appliesTo=${policy.appliesTo} declared=${policy.enforcement} actual=${actual}`,
1122
+ );
772
1123
  }
1124
+ console.log(
1125
+ `screenshot evidence: ${options.deliveryChecks.requireScreenshot ? "required (verify writes images to BUILDBEAT_SCREENSHOT_DIR)" : "not required"}`,
1126
+ );
1127
+ console.log(
1128
+ `release readback: ${options.release ? `${options.release.command} ${(options.release.args ?? []).join(" ")}`.trim() : "none configured (release: section)"}`,
1129
+ );
773
1130
  console.log("worker isolation:");
774
1131
  for (const [worker, spec] of Object.entries(options.adapterConfigs)) {
775
- const mode = spec.inheritEnv === true
776
- ? "WARNING inherit (worker sees the full host env; credential isolation is ADVISORY only)"
777
- : "env allowlist (host credential env vars withheld from the worker)";
1132
+ const mode =
1133
+ spec.inheritEnv === true
1134
+ ? "WARNING inherit (worker sees the full host env; credential isolation is ADVISORY only)"
1135
+ : "env allowlist (host credential env vars withheld from the worker)";
778
1136
  console.log(` ${worker}: ${mode}`);
779
1137
  }
780
1138
  let remotes = [];
781
1139
  try {
782
- remotes = execFileSync("git", ["-C", options.repoRoot, "remote"], { encoding: "utf8" })
1140
+ remotes = execFileSync("git", ["-C", options.repoRoot, "remote"], {
1141
+ encoding: "utf8",
1142
+ })
783
1143
  .split("\n")
784
1144
  .filter(Boolean);
785
1145
  } catch {
@@ -790,11 +1150,16 @@ function commandDoctor(flags) {
790
1150
  `push protection: worktree pushurl override is applied at workspace creation for: ${remotes.join(", ")}`,
791
1151
  );
792
1152
  } else {
793
- console.log("push protection: repository has no remotes (nothing to protect)");
1153
+ console.log(
1154
+ "push protection: repository has no remotes (nothing to protect)",
1155
+ );
794
1156
  }
795
- console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
1157
+ console.log(
1158
+ "kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)",
1159
+ );
796
1160
  const budgetLines = [];
797
- const reviewRounds = options.budgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
1161
+ const reviewRounds =
1162
+ options.budgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
798
1163
  for (const step of options.workflow.steps) {
799
1164
  if (!step.worker) {
800
1165
  continue;
@@ -802,20 +1167,38 @@ function commandDoctor(flags) {
802
1167
  const fromRun = options.budgets.maxAttempts?.[step.id];
803
1168
  const fromPreset = options.workflow.budgets?.maxAttempts?.[step.id];
804
1169
  const review = step.id === "review" || step.worker === "reviewer";
805
- const effective = fromRun ?? fromPreset ?? (review ? reviewRounds : options.maxAttemptsPerStep);
806
- const source = fromRun !== undefined ? "run config" : fromPreset !== undefined ? "workflow preset" : review ? "reviewRoundsPerWork" : "default";
1170
+ const effective =
1171
+ fromRun ??
1172
+ fromPreset ??
1173
+ (review ? reviewRounds : options.maxAttemptsPerStep);
1174
+ const source =
1175
+ fromRun !== undefined
1176
+ ? "run config"
1177
+ : fromPreset !== undefined
1178
+ ? "workflow preset"
1179
+ : review
1180
+ ? "reviewRoundsPerWork"
1181
+ : "default";
807
1182
  budgetLines.push(`${step.id}=${effective} (${source})`);
808
1183
  }
809
- console.log(`budgets (maxAttempts per step; approving resume-<step> after exhaustion grants +1): ${budgetLines.join(", ")}`);
810
- console.log(`budgets.reviewRoundsPerWork: ${reviewRounds}${options.budgets.reviewRoundsPerWork === undefined ? " (default)" : ""} (counted across every run of the work, superseded ones included)`);
1184
+ console.log(
1185
+ `budgets (maxAttempts per step; approving resume-<step> after exhaustion grants +1): ${budgetLines.join(", ")}`,
1186
+ );
1187
+ console.log(
1188
+ `budgets.reviewRoundsPerWork: ${reviewRounds}${options.budgets.reviewRoundsPerWork === undefined ? " (default)" : ""} (counted across every run of the work, superseded ones included)`,
1189
+ );
811
1190
  // Same preconditions start's first gate will read (real incident, twice:
812
1191
  // doctor passed, start stopped at build because plan.md was not mirrored
813
1192
  // into the repository the run was started in).
814
1193
  const workDir = join(options.repoRoot, "delivery", "work", options.workId);
815
1194
  const decisions = readJsonl(join(workDir, "decisions.jsonl"));
816
- console.log(`work artifacts in this repository (delivery/work/${options.workId}):`);
1195
+ console.log(
1196
+ `work artifacts in this repository (delivery/work/${options.workId}):`,
1197
+ );
817
1198
  const artifactState = {};
818
- for (const artifact of ["intent", "plan"]) {
1199
+ for (const artifact of options.workArtifact === "work"
1200
+ ? ["work"]
1201
+ : ["intent", "plan"]) {
819
1202
  const status = artifactStatus(workDir, decisions, artifact);
820
1203
  artifactState[artifact] = status;
821
1204
  const label = !status.exists
@@ -828,12 +1211,17 @@ function commandDoctor(flags) {
828
1211
  console.log(` ${artifact}.md: ${label}`);
829
1212
  }
830
1213
  for (const policy of options.policies) {
831
- for (const artifact of artifactsRequiredBy(policy.rule)) {
832
- const status = artifactState[artifact] ?? artifactStatus(workDir, decisions, artifact);
1214
+ for (const artifact of policy.kind === "artifact"
1215
+ ? [policy.artifact]
1216
+ : []) {
1217
+ const status =
1218
+ artifactState[artifact] ?? artifactStatus(workDir, decisions, artifact);
833
1219
  if (!status.accepted) {
834
1220
  console.log(
835
1221
  ` WARNING policy ${policy.name} (${policy.type} ${policy.appliesTo}) needs an accepted ${artifact}.md; start will stop at ${policy.appliesTo}` +
836
- (!status.exists ? " (file missing here: mirror it into this repository, then accept)" : " (accept it first)"),
1222
+ (!status.exists
1223
+ ? " (file missing here: mirror it into this repository, then accept)"
1224
+ : " (accept it first)"),
837
1225
  );
838
1226
  }
839
1227
  }
@@ -842,31 +1230,47 @@ function commandDoctor(flags) {
842
1230
  console.log("environment contract (requires):");
843
1231
  const check = checkRequires(options.requires);
844
1232
  for (const row of check.checked) {
845
- console.log(` ${row.command}: OK${row.version ? ` (${row.version})` : ""}`);
1233
+ console.log(
1234
+ ` ${row.command}: OK${row.version ? ` (${row.version})` : ""}`,
1235
+ );
846
1236
  }
847
1237
  for (const problem of check.problems) {
848
1238
  console.log(` PROBLEM ${problem}`);
849
1239
  }
850
1240
  } else {
851
- console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
1241
+ console.log(
1242
+ "environment contract: none declared (implicit PATH facts stay unchecked)",
1243
+ );
852
1244
  }
853
- console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
1245
+ console.log(
1246
+ `supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`,
1247
+ );
854
1248
  console.log(
855
1249
  options.parallel
856
1250
  ? "concurrency: parallel (runs of other works with parallel: true may drive at the same time; runs of this work stay exclusive; verifiers must not share fixed ports or databases)"
857
1251
  : "concurrency: exclusive (default: one driving run per repository; set parallel: true to drive alongside other works)",
858
1252
  );
859
- console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
860
- const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
1253
+ console.log(
1254
+ `stall threshold: ${formatMs(options.stallAfterMs)} without worker output`,
1255
+ );
1256
+ const { config: notify, error: notifyError } = notifyConfigFor(
1257
+ options.repoRoot,
1258
+ );
861
1259
  if (notifyError) {
862
1260
  console.log(`notify: PROBLEM ${notifyError}`);
863
1261
  } else if (!notify) {
864
- console.log(`notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`);
1262
+ console.log(
1263
+ `notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs status)`,
1264
+ );
865
1265
  } else {
866
1266
  console.log("notify channels:");
867
1267
  for (const channel of notify.channels) {
868
- const urlState = process.env[channel.urlEnv] ? "url env set" : `WARNING env ${channel.urlEnv} not set in this shell`;
869
- console.log(` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`);
1268
+ const urlState = process.env[channel.urlEnv]
1269
+ ? "url env set"
1270
+ : `WARNING env ${channel.urlEnv} not set in this shell`;
1271
+ console.log(
1272
+ ` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`,
1273
+ );
870
1274
  }
871
1275
  }
872
1276
  }
@@ -881,13 +1285,17 @@ function commandPreflight(flags) {
881
1285
  if (!flags.step) {
882
1286
  throw new Error("preflight requires --step");
883
1287
  }
884
- const stepDef = options.workflow.steps.find((candidate) => candidate.id === flags.step);
1288
+ const stepDef = options.workflow.steps.find(
1289
+ (candidate) => candidate.id === flags.step,
1290
+ );
885
1291
  if (!stepDef) {
886
1292
  throw new Error(`step not in workflow: ${flags.step}`);
887
1293
  }
888
1294
  const spec = stepDef.worker ? options.adapterConfigs[stepDef.worker] : null;
889
1295
  if (!spec) {
890
- throw new Error(`step ${flags.step} has no configured worker command to preflight`);
1296
+ throw new Error(
1297
+ `step ${flags.step} has no configured worker command to preflight`,
1298
+ );
891
1299
  }
892
1300
  if (options.requires.length > 0) {
893
1301
  const check = checkRequires(options.requires);
@@ -906,7 +1314,16 @@ function commandPreflight(flags) {
906
1314
  env = { ...process.env };
907
1315
  } else {
908
1316
  env = {};
909
- for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"]) {
1317
+ for (const key of [
1318
+ "PATH",
1319
+ "HOME",
1320
+ "LANG",
1321
+ "LC_ALL",
1322
+ "TMPDIR",
1323
+ "TERM",
1324
+ "USER",
1325
+ "SHELL",
1326
+ ]) {
910
1327
  if (process.env[key] !== undefined) {
911
1328
  env[key] = process.env[key];
912
1329
  }
@@ -914,8 +1331,12 @@ function commandPreflight(flags) {
914
1331
  }
915
1332
  Object.assign(env, spec.env ?? {});
916
1333
  env.BUILDBEAT_PREFLIGHT = "1";
917
- console.log(`PREFLIGHT (dry signal, never evidence): step ${flags.step} -> ${spec.command} ${args.join(" ")}`);
918
- console.log(`cwd: main checkout (no worktree, no ledger, no evidence written)`);
1334
+ console.log(
1335
+ `PREFLIGHT (dry signal, never evidence): step ${flags.step} -> ${spec.command} ${args.join(" ")}`,
1336
+ );
1337
+ console.log(
1338
+ `cwd: main checkout (no worktree, no ledger, no evidence written)`,
1339
+ );
919
1340
  const result = spawnSync(spec.command, args, {
920
1341
  cwd: options.repoRoot,
921
1342
  stdio: "inherit",
@@ -923,10 +1344,14 @@ function commandPreflight(flags) {
923
1344
  timeout: spec.timeoutMs,
924
1345
  });
925
1346
  if (result.error) {
926
- throw new Error(`preflight could not run the command: ${result.error.message}`);
1347
+ throw new Error(
1348
+ `preflight could not run the command: ${result.error.message}`,
1349
+ );
927
1350
  }
928
1351
  const exitCode = result.status ?? 1;
929
- console.log(`preflight exit=${exitCode} — a Run must reproduce this before it counts`);
1352
+ console.log(
1353
+ `preflight exit=${exitCode} — a Run must reproduce this before it counts`,
1354
+ );
930
1355
  process.exitCode = exitCode;
931
1356
  }
932
1357
 
@@ -948,11 +1373,15 @@ function commandFindings(rest) {
948
1373
  const verdict = adjudicated.get(row.fingerprint);
949
1374
  const status = verdict ? `${verdict.action} by ${verdict.by}` : "open";
950
1375
  const reRaised = row.reRaised ? " RE-RAISED" : "";
951
- console.log(`[${row.severity} ${row.fingerprint}] (${status})${reRaised} ${row.summary}`);
1376
+ console.log(
1377
+ `[${row.severity} ${row.fingerprint}] (${status})${reRaised} ${row.summary}`,
1378
+ );
952
1379
  }
953
1380
  } else if (sub === "adjudicate") {
954
1381
  if (!flags.repo || !flags.work || !flags.fingerprint || !flags.action) {
955
- throw new Error("findings adjudicate requires --repo, --work, --fingerprint and --action");
1382
+ throw new Error(
1383
+ "findings adjudicate requires --repo, --work, --fingerprint and --action",
1384
+ );
956
1385
  }
957
1386
  const row = adjudicateFinding(resolve(flags.repo), flags.work, {
958
1387
  fingerprint: flags.fingerprint,
@@ -960,12 +1389,18 @@ function commandFindings(rest) {
960
1389
  by: flags.by,
961
1390
  note: flags.note,
962
1391
  });
963
- console.log(`adjudicated ${row.fingerprint} -> ${row.action} ([${row.severity}] ${row.summary})`);
1392
+ console.log(
1393
+ `adjudicated ${row.fingerprint} -> ${row.action} ([${row.severity}] ${row.summary})`,
1394
+ );
964
1395
  if (row.action === "dismiss") {
965
- console.log("dismissed: this fingerprint no longer blocks; an escalated severity reopens on its own");
1396
+ console.log(
1397
+ "dismissed: this fingerprint no longer blocks; an escalated severity reopens on its own",
1398
+ );
966
1399
  }
967
1400
  } else {
968
- throw new Error(`findings subcommand must be list|adjudicate, got: ${sub ?? "(none)"}`);
1401
+ throw new Error(
1402
+ `findings subcommand must be list|adjudicate, got: ${sub ?? "(none)"}`,
1403
+ );
969
1404
  }
970
1405
  }
971
1406
 
@@ -1001,7 +1436,9 @@ function commandReplay(flags) {
1001
1436
  "state below reflects the valid prefix only",
1002
1437
  );
1003
1438
  } else {
1004
- console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
1439
+ console.log(
1440
+ `chain OK: ${ledger.events.length} events verified (digest/prev/seq)`,
1441
+ );
1005
1442
  }
1006
1443
  printState(ledger.state, { corruption: null }, {});
1007
1444
  }
@@ -1019,16 +1456,193 @@ function commandMetrics(flags) {
1019
1456
  }
1020
1457
 
1021
1458
  function commandStatus(flags) {
1022
- if (!flags.repo || !flags.run) {
1023
- throw new Error("status requires --repo and --run");
1024
- }
1459
+ if (!flags.repo) throw new Error("status requires --repo");
1025
1460
  const repoRoot = resolve(flags.repo);
1026
- const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
1027
- printState(ledger.state, ledger, {
1028
- repoRoot,
1029
- repoLabel: repoLabelFor(repoRoot, flags.repo),
1030
- stallAfterMs: stallAfterFromFlags(flags),
1461
+ if (flags["all-repos"] === "true" && flags.run)
1462
+ throw new Error("status: --all-repos cannot be used with --run; use --work instead");
1463
+ if (flags.run && flags.work)
1464
+ throw new Error("status: choose --run or --work");
1465
+ if (!flags.run) {
1466
+ const allRepos = flags["all-repos"] === "true";
1467
+ const result = computeRepositoryOverview(repoRoot, { work: flags.work ?? null, allRepos, localRepoLabel: repoLabelFor(repoRoot, flags.repo) });
1468
+ const rows = result.repos.flatMap((group) => group.works);
1469
+ if (flags.json === "true") {
1470
+ console.log(JSON.stringify(allRepos ? result : {
1471
+ works: rows.map((row) => {
1472
+ if (row.repo !== result.repos[0].repo || row.targetRepo) return row;
1473
+ const { config, hasRelease, repo, repoLabel, settled, ...local } = row;
1474
+ return local;
1475
+ }),
1476
+ pending: result.pending.map((row) => {
1477
+ if (row.repo !== result.repos[0].repo) return row;
1478
+ const { repo, config, ...local } = row;
1479
+ return local;
1480
+ }),
1481
+ metrics: flags.work ? null : computeMetrics(repoRoot),
1482
+ ...(result.warnings.length ? { warnings: result.warnings } : {}),
1483
+ }, null, 2));
1484
+ } else {
1485
+ for (const warning of result.warnings) console.log(`warning: ${warning}`);
1486
+ if (allRepos) {
1487
+ for (const group of result.repos) {
1488
+ const pending = result.pending.filter((row) => row.repo === group.repo);
1489
+ if (pending.length) {
1490
+ console.log(`pending decisions · ${pathLabel(process.cwd(), group.repo)}`);
1491
+ commandInbox(flags, pending);
1492
+ }
1493
+ }
1494
+ for (const group of result.repos) {
1495
+ const open = group.works.filter((row) => !row.settled);
1496
+ if (open.length) {
1497
+ console.log(`repo ${pathLabel(process.cwd(), group.repo)}:`);
1498
+ console.log(renderOverview(open));
1499
+ }
1500
+ }
1501
+ for (const group of result.repos) {
1502
+ const count = group.settled;
1503
+ console.log(`${pathLabel(process.cwd(), group.repo)}: settled ${count.total} (closed ${count.closed} · cancelled ${count.cancelled} · merged, no release step ${count.merged})`);
1504
+ }
1505
+ } else {
1506
+ // Keep the single-repo surface while forwarding work facts and waits.
1507
+ console.log(renderOverview(rows.map((row) => row.repo === result.repos[0].repo
1508
+ ? { ...row, repo: undefined } : row)));
1509
+ if (result.pending.length) commandInbox(flags, result.pending);
1510
+ if (!flags.work) console.log(renderMetrics(computeMetrics(repoRoot)));
1511
+ }
1512
+ }
1513
+ return;
1514
+ }
1515
+ const path = ledgerPathFor(repoRoot, flags.run);
1516
+ if (!existsSync(path))
1517
+ throw new Error(
1518
+ `no runtime ledger for ${flags.run}; use status --work to read archived records`,
1519
+ );
1520
+ const ledger = EventLedger.open(path);
1521
+ if (flags.json === "true")
1522
+ console.log(
1523
+ JSON.stringify(
1524
+ { state: ledger.state, corruption: ledger.corruption },
1525
+ null,
1526
+ 2,
1527
+ ),
1528
+ );
1529
+ else
1530
+ printState(ledger.state, ledger, {
1531
+ repoRoot,
1532
+ repoLabel: repoLabelFor(repoRoot, flags.repo),
1533
+ stallAfterMs: stallAfterFromFlags(flags),
1534
+ });
1535
+ }
1536
+
1537
+ async function commandRun(flags) {
1538
+ const options = loadRunConfig(flags, "run");
1539
+ if (flags.new === "true" && (flags.run || flags.adopt))
1540
+ throw new Error("--new cannot be combined with --run or --adopt");
1541
+ if (flags.new === "true")
1542
+ return commandStart({ ...flags, attempt: "new" }, options);
1543
+ if (flags.run || flags.adopt)
1544
+ return commandResume(flags, options, { familyWide: true });
1545
+ // Resume an existing family, including its exact ID. Never interpret an
1546
+ // ambiguous, corrupt, or terminal run as permission to start another one.
1547
+ const dir = join(options.repoRoot, ".buildbeat", "runtime", "runs");
1548
+ const found =
1549
+ existsSync(dir) &&
1550
+ readdirSync(dir).some(
1551
+ (id) =>
1552
+ inRunFamily(options.runId, id) &&
1553
+ existsSync(ledgerPathFor(options.repoRoot, id)),
1554
+ );
1555
+ const records = join(
1556
+ options.repoRoot,
1557
+ "delivery",
1558
+ "work",
1559
+ options.workId,
1560
+ "runs",
1561
+ );
1562
+ const archived =
1563
+ existsSync(records) &&
1564
+ readdirSync(records).some((id) => inRunFamily(options.runId, id));
1565
+ if (found) return commandResume(flags, options, { familyWide: true });
1566
+ if (archived)
1567
+ throw new Error(
1568
+ "this work has archived runs; inspect status, then use --new explicitly for a new attempt",
1569
+ );
1570
+ return commandStart({ ...flags, attempt: "new" }, options);
1571
+ }
1572
+
1573
+ // Runs the project's release readback once and records it in the Work.
1574
+ function commandRelease(flags) {
1575
+ const options = loadRunConfig(flags, "release");
1576
+ if (!options.release) {
1577
+ throw new Error(
1578
+ "release needs a release: section in the run config (command, args, timeoutMs, env, like a worker)",
1579
+ );
1580
+ }
1581
+ const row = runReadback({
1582
+ repoRoot: options.repoRoot,
1583
+ workId: options.workId,
1584
+ spec: options.release,
1585
+ redact: options.redact,
1586
+ runs: runsFor(options.repoRoot, options.workId),
1587
+ ref: flags.ref ?? "HEAD",
1588
+ note: flags.note ?? null,
1031
1589
  });
1590
+ const repoLabel = repoLabelFor(options.repoRoot);
1591
+ console.log(
1592
+ `readback ${row.status} (exit ${row.exitCode}) for ${row.ref} at ${row.commit.slice(0, 7)}, candidate ${row.candidate.slice(0, 7)} (${row.run})${row.note ? ` — ${row.note}` : ""}`,
1593
+ );
1594
+ if (row.checkout !== row.commit) {
1595
+ console.log(
1596
+ ` note: the command ran in the main checkout at ${row.checkout.slice(0, 7)}, not at ${row.ref}`,
1597
+ );
1598
+ }
1599
+ console.log(` log: ${row.log}`);
1600
+ console.log(` recorded: delivery/work/${options.workId}/releases.jsonl`);
1601
+ for (const line of row.tail.slice(-5)) {
1602
+ console.log(` | ${line}`);
1603
+ }
1604
+ console.log(
1605
+ row.status === "passed"
1606
+ ? `next: buildbeat decide --repo ${repoLabel} --work ${options.workId} --action close --result <what was released> --by <you>`
1607
+ : "next: fix the release, then run buildbeat release again; the window cannot close on a failed readback",
1608
+ );
1609
+ if (row.status !== "passed") {
1610
+ process.exitCode = 1;
1611
+ }
1612
+ }
1613
+
1614
+ function commandDecide(flags) {
1615
+ if (flags.action === "close") {
1616
+ if (!flags.repo || !flags.work)
1617
+ throw new Error("closing a work requires --repo and --work");
1618
+ const row = closeWork({
1619
+ repoRoot: resolve(flags.repo),
1620
+ workId: flags.work,
1621
+ result: flags.result,
1622
+ by: flags.by ?? "human",
1623
+ });
1624
+ console.log(
1625
+ `closed ${flags.work} as ${row.decisionRef} (readback ${row.subject.readback.slice(0, 19)} at ${row.subject.commit.slice(0, 7)})`,
1626
+ );
1627
+ return;
1628
+ }
1629
+ if (flags.action === "approve") return commandApprove(flags);
1630
+ if (flags.action === "reject") return commandReject(flags);
1631
+ if (["accept", "dismiss"].includes(flags.action) && flags.fingerprint) {
1632
+ if (!flags.repo || !flags.work)
1633
+ throw new Error("finding decisions require --repo and --work");
1634
+ const result = adjudicateFinding(resolve(flags.repo), flags.work, {
1635
+ fingerprint: flags.fingerprint,
1636
+ action: flags.action,
1637
+ by: flags.by ?? "human",
1638
+ note: flags.note,
1639
+ });
1640
+ console.log(JSON.stringify(result));
1641
+ return;
1642
+ }
1643
+ throw new Error(
1644
+ "decide requires --action approve|reject, accept|dismiss with --work and --fingerprint, or close with --work and --result",
1645
+ );
1032
1646
  }
1033
1647
 
1034
1648
  function commandStop(flags) {
@@ -1068,7 +1682,10 @@ function commandOverview(flags) {
1068
1682
  throw new Error("overview requires --repo");
1069
1683
  }
1070
1684
  const repoRoot = resolve(flags.repo);
1071
- const rows = computeOverview(repoRoot, { work: flags.work ?? null, repoLabel: repoLabelFor(repoRoot, flags.repo) });
1685
+ const rows = computeOverview(repoRoot, {
1686
+ work: flags.work ?? null,
1687
+ repoLabel: repoLabelFor(repoRoot, flags.repo),
1688
+ });
1072
1689
  if (flags.json === "true") {
1073
1690
  console.log(JSON.stringify(rows, null, 2));
1074
1691
  return;
@@ -1095,12 +1712,16 @@ function commandGc(flags) {
1095
1712
  if (action.kind === "delete-branch") {
1096
1713
  return `delete branch ${action.branch} (${action.reason})`;
1097
1714
  }
1098
- return action.owner ? "remove active-run lock (owner process is gone)" : "remove stale lock";
1715
+ return action.owner
1716
+ ? "remove active-run lock (owner process is gone)"
1717
+ : "remove stale lock";
1099
1718
  });
1100
1719
  actionable += row.actions.length;
1101
1720
  const keep = row.keep.map((reason) => `keep: ${reason}`);
1102
1721
  const parts = [...summary, ...keep];
1103
- console.log(`${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`);
1722
+ console.log(
1723
+ `${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`,
1724
+ );
1104
1725
  }
1105
1726
  if (flags.apply !== "true") {
1106
1727
  console.log(
@@ -1113,7 +1734,8 @@ function commandGc(flags) {
1113
1734
  const results = applyGc(repoRoot, rows, { force: flags.force === "true" });
1114
1735
  let done = 0;
1115
1736
  for (const result of results) {
1116
- const target = result.kind === "delete-branch" ? result.branch : result.path;
1737
+ const target =
1738
+ result.kind === "delete-branch" ? result.branch : result.path;
1117
1739
  if (result.done) {
1118
1740
  done += 1;
1119
1741
  console.log(` ${result.kind} ${target}: done`);
@@ -1133,7 +1755,8 @@ function processAlive(pid) {
1133
1755
  }
1134
1756
  }
1135
1757
 
1136
- const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
1758
+ const sleep = (ms) =>
1759
+ new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
1137
1760
 
1138
1761
  // Watches one RUNNING run and reports a stall once per step attempt. Exits
1139
1762
  // when the run leaves RUNNING, the parent process (if given) dies, or after a
@@ -1159,7 +1782,12 @@ async function commandWatch(flags) {
1159
1782
  console.log(`watch: run is ${ledger.state.run.status}; done`);
1160
1783
  return;
1161
1784
  } else {
1162
- const liveness = describeLiveness({ repoRoot, runId: flags.run, ledger, stallAfterMs });
1785
+ const liveness = describeLiveness({
1786
+ repoRoot,
1787
+ runId: flags.run,
1788
+ ledger,
1789
+ stallAfterMs,
1790
+ });
1163
1791
  const live = liveness.inFlight;
1164
1792
  if (live?.stalled) {
1165
1793
  const key = `${live.step}#${live.attempt}`;
@@ -1183,14 +1811,20 @@ async function commandWatch(flags) {
1183
1811
  command: live.command,
1184
1812
  },
1185
1813
  });
1186
- const results = await dispatchNotification(config, notification, { repoRoot });
1814
+ const results = await dispatchNotification(config, notification, {
1815
+ repoRoot,
1816
+ });
1187
1817
  for (const row of results) {
1188
- console.log(`watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`);
1818
+ console.log(
1819
+ `watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`,
1820
+ );
1189
1821
  }
1190
1822
  }
1191
1823
  }
1192
1824
  } else if (live) {
1193
- console.log(`watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`);
1825
+ console.log(
1826
+ `watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`,
1827
+ );
1194
1828
  }
1195
1829
  }
1196
1830
  if (flags.once === "true") {
@@ -1204,66 +1838,14 @@ async function commandWatch(flags) {
1204
1838
  }
1205
1839
  }
1206
1840
 
1207
- function commandObserve(rest) {
1208
- const [sub, ...args] = rest;
1209
- const flags = parseFlags(args);
1210
- if (sub === "run") {
1211
- if (!flags.config) {
1212
- throw new Error("observe run requires --config <observe.yaml>");
1213
- }
1214
- const result = runObserveCycle({ configPath: flags.config });
1215
- console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerRef})`);
1216
- for (const row of result.results) {
1217
- const bands = row.bands.length > 0 ? ` bands=${row.bands.join(",")}` : "";
1218
- const intent = row.intent ? ` intent=${row.intent.outcome}:${row.intent.intentRef}` : "";
1219
- console.log(
1220
- ` ${row.provider}: ${row.status}${row.severity ? ` severity=${row.severity}` : ""}${bands}${intent}`,
1221
- );
1222
- }
1223
- } else if (sub === "status") {
1224
- if (!flags.repo) {
1225
- throw new Error("observe status requires --repo");
1226
- }
1227
- const status = observeStatus({ repoRoot: flags.repo });
1228
- if (status.corruption) {
1229
- console.log(
1230
- `WARNING: observe ledger corrupted after seq=${status.corruption.afterSeq}: ${status.corruption.reason}`,
1231
- );
1232
- }
1233
- console.log(`cycles: ${status.cycles}`);
1234
- for (const [id, info] of Object.entries(status.providers)) {
1235
- console.log(`provider ${id}: ${info.lastStatus} (${info.lastKind} on ${info.lastSubject}) at ${info.lastTs}`);
1236
- }
1237
- if (status.intents.length === 0) {
1238
- console.log("intents: none");
1239
- }
1240
- for (const intent of status.intents) {
1241
- console.log(
1242
- `intent [${intent.status}] ${intent.file} (${intent.provider}, severity ${intent.severity})`,
1243
- );
1244
- }
1245
- } else if (sub === "triage") {
1246
- if (!flags.repo || !flags.intent || !flags.action) {
1247
- throw new Error("observe triage requires --repo, --intent and --action");
1248
- }
1249
- const result = triageIntent({
1250
- repoRoot: flags.repo,
1251
- intentRef: flags.intent,
1252
- action: flags.action,
1253
- by: flags.by,
1254
- note: flags.note,
1255
- });
1256
- console.log(`intent ${result.intentRef} -> ${result.status}`);
1257
- if (result.suggestion) {
1258
- console.log(result.suggestion);
1259
- }
1260
- } else {
1261
- throw new Error(`observe subcommand must be run|status|triage, got: ${sub ?? "(none)"}`);
1262
- }
1263
- }
1264
-
1265
1841
  function packageVersion() {
1266
- const packageJson = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
1842
+ const packageJson = join(
1843
+ dirname(fileURLToPath(import.meta.url)),
1844
+ "..",
1845
+ "..",
1846
+ "..",
1847
+ "package.json",
1848
+ );
1267
1849
  return JSON.parse(readFileSync(packageJson, "utf8")).version;
1268
1850
  }
1269
1851
 
@@ -1273,9 +1855,16 @@ async function main() {
1273
1855
  process.stdout.write(`${packageVersion()}\n`);
1274
1856
  return;
1275
1857
  }
1276
- if (command === "observe" || command === "findings") {
1858
+ if (command === "observe") {
1859
+ console.error(
1860
+ "error: observe execution is retired; existing records are preserved. See docs/MIGRATION.md",
1861
+ );
1862
+ process.exitCode = 1;
1863
+ return;
1864
+ }
1865
+ if (command === "findings") {
1277
1866
  try {
1278
- (command === "observe" ? commandObserve : commandFindings)(rest);
1867
+ commandFindings(rest);
1279
1868
  } catch (error) {
1280
1869
  console.error(`error: ${error.message}`);
1281
1870
  process.exitCode = 1;
@@ -1284,7 +1873,17 @@ async function main() {
1284
1873
  }
1285
1874
  try {
1286
1875
  const flags = parseFlags(rest);
1287
- if (command === "start") {
1876
+ if (command === "run") {
1877
+ await commandRun(flags);
1878
+ } else if (command === "decide") {
1879
+ commandDecide(flags);
1880
+ } else if (command === "release") {
1881
+ commandRelease(flags);
1882
+ } else if (command === "check") {
1883
+ flags.step ? commandPreflight(flags) : commandDoctor(flags);
1884
+ } else if (command === "history") {
1885
+ flags.verify === "true" ? commandReplay(flags) : commandEvents(flags);
1886
+ } else if (command === "start") {
1288
1887
  await commandStart(flags);
1289
1888
  } else if (command === "resume") {
1290
1889
  await commandResume(flags);