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