@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.5

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 (63) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/SKILL.md +86 -2
  3. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  4. package/docs/CLI.md +1 -1
  5. package/docs/EXECUTION-PLAN.md +2 -2
  6. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  7. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  8. package/docs/RELEASING.md +1 -1
  9. package/docs/V2-D2-DECISION-CARD.md +2 -2
  10. package/docs/V2-DECISIONS.md +2 -2
  11. package/docs/V2-ITERATION-01.md +13 -13
  12. package/docs/V2-ITERATION-06.md +2 -2
  13. package/docs/V2-ITERATION-08.md +62 -0
  14. package/docs/V2-PLAN.md +6 -6
  15. package/docs/V2-PROPOSAL.md +2 -2
  16. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  17. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  19. package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -0
  20. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  21. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  22. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  23. package/docs/v2/RFC-0001-product-definition.md +2 -2
  24. package/docs/v2/SPEC-0001-events-v1.md +7 -6
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +4 -0
  27. package/docs/v2/guide/02-workflow-guide.md +48 -1
  28. package/docs/v2/guide/04-adapter-guide.md +4 -0
  29. package/docs/v2/guide/05-worker-contract.md +10 -0
  30. package/docs/v2/guide/06-evidence-guide.md +4 -0
  31. package/docs/v2/guide/07-approval-guide.md +47 -0
  32. package/docs/v2/guide/10-recovery.md +25 -3
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +37 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/mock.js +9 -2
  38. package/src/v2/adapters/shell.js +87 -14
  39. package/src/v2/cli/run.js +607 -30
  40. package/src/v2/domain/event-registry.js +1 -0
  41. package/src/v2/engine/reducer.js +29 -1
  42. package/src/v2/engine/workflow.js +8 -1
  43. package/src/v2/evidence/collector.js +14 -3
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/runtime/cache.js +124 -0
  47. package/src/v2/runtime/decisions.js +80 -0
  48. package/src/v2/runtime/env-contract.js +35 -1
  49. package/src/v2/runtime/envelope.js +183 -0
  50. package/src/v2/runtime/gc.js +182 -0
  51. package/src/v2/runtime/liveness.js +193 -0
  52. package/src/v2/runtime/metrics.js +8 -0
  53. package/src/v2/runtime/notify.js +223 -0
  54. package/src/v2/runtime/orchestrator.js +267 -19
  55. package/src/v2/runtime/overview.js +301 -0
  56. package/src/v2/runtime/run-record.js +3 -0
  57. package/src/v2/runtime/work-cost.js +147 -0
  58. package/src/v2/workspace/workspace-manager.js +14 -1
  59. package/templates/contracts/PROTOCOL.md +4 -0
  60. package/templates/gitignore.template +5 -0
  61. package/templates/scripts/bus-check.sh +37 -12
  62. package/templates/v2/AGENTS.md +73 -0
  63. package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
@@ -11,7 +11,7 @@
11
11
  // anything changed goes APPROVAL_STALE and back to WAITING_HUMAN.
12
12
 
13
13
  import { createHash } from "node:crypto";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
 
17
17
  import { nextStep } from "../engine/workflow.js";
@@ -26,7 +26,10 @@ import {
26
26
  releaseLock,
27
27
  } from "../workspace/workspace-manager.js";
28
28
  import { writeRunRecord } from "./run-record.js";
29
+ import { computeWorkCost } from "./work-cost.js";
29
30
  import { assertRequires } from "./env-contract.js";
31
+ import { materialisePrompt } from "./envelope.js";
32
+ import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
30
33
  import {
31
34
  buildAnchor,
32
35
  fingerprintFinding,
@@ -140,11 +143,25 @@ function makeContext(options, ledger, workspace) {
140
143
  ledger,
141
144
  workspace,
142
145
  };
146
+ // Effective cap per step: run config `budgets:` beats the preset, the
147
+ // preset beats the global default, and every BUDGET_EXTENDED a human
148
+ // granted on this ledger adds to it. Real incident: the preset's two
149
+ // review rounds could not be raised from the run config, and approving
150
+ // resume-review re-asked the same question forever.
151
+ context.runBudgets = options.budgets ?? {};
143
152
  context.maxAttemptsFor = (step) =>
144
- workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
153
+ (context.runBudgets.maxAttempts?.[step] ??
154
+ workflow.budgets?.maxAttempts?.[step] ??
155
+ maxAttemptsPerStep) +
156
+ (ledger.state.budgetExtensions?.[step] ?? 0) +
157
+ (ledger.state.steps[step]?.infraAttempts ?? 0);
145
158
  context.policies = options.policies ?? [];
146
159
  context.allowedPaths = options.allowedPaths ?? null;
147
160
  context.reviewTriage = options.reviewTriage ?? null;
161
+ context.envelope = options.envelope ?? null;
162
+ context.cache = options.cache ?? {};
163
+ context.redact = options.redact ?? [];
164
+ context.adapterConfigs = options.adapterConfigs ?? {};
148
165
  context.policyCtx = () => ({
149
166
  state: ledger.state,
150
167
  candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
@@ -234,6 +251,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
234
251
  if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
235
252
  context.waitHuman(`resume-${step}`, [
236
253
  `budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
254
+ `approving resume-${step} grants one more attempt; rejecting ends the run`,
237
255
  ]);
238
256
  return null;
239
257
  }
@@ -260,13 +278,13 @@ function settleOutcome(context, step, outcome, tree, exec) {
260
278
  },
261
279
  });
262
280
  if (!to) {
263
- ledger.append({
264
- type: "RUN_TERMINAL",
265
- actor: KERNEL,
266
- ts: now(),
267
- data: { status: "FAILED", reason: `no transition for (${step}, ${outcome})` },
268
- });
269
- writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
281
+ // A workflow without an edge for this outcome is not a verdict on the
282
+ // candidate; the person decides whether to rerun the step or end the
283
+ // run. Terminal FAILED here used to kill runs whose reviewer had merely
284
+ // errored out.
285
+ context.waitHuman(`resume-${step}`, [
286
+ `no transition for (${step}, ${outcome}); approve resume-${step} to rerun the step, reject to end the run`,
287
+ ]);
270
288
  return null;
271
289
  }
272
290
  ledger.append({
@@ -314,6 +332,30 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
314
332
  return;
315
333
  }
316
334
 
335
+ // Work-level review cap (iteration 09): review rounds are counted across
336
+ // every run of the work, superseded ones included, so "one run per
337
+ // round" cannot slip past the per-run budget. Reaching the cap is a
338
+ // human decision (review once more, or merge/close as-is), not a stop.
339
+ const workCap = context.runBudgets.reviewRoundsPerWork;
340
+ if (workCap !== undefined && (stepDef.worker === "reviewer" || step === "review")) {
341
+ const prior = computeWorkCost(context.repoRoot, ledger.state.run.work, {
342
+ excludeRun: ledger.state.run.id,
343
+ });
344
+ const rounds = prior.reviewRounds + (ledger.state.steps[step]?.attempts ?? 0);
345
+ const allowed = workCap + (ledger.state.workReviewGrants ?? 0);
346
+ if (rounds >= allowed) {
347
+ context.waitHuman(
348
+ `enter-${step}`,
349
+ [
350
+ `work review cap reached: ${rounds} review round(s) across ${prior.runs + 1} run(s) of ${ledger.state.run.work} (budgets.reviewRoundsPerWork=${workCap})`,
351
+ `approve enter-${step} to review once more, or reject and merge/close the work on the evidence you have`,
352
+ ],
353
+ "work-review-cap",
354
+ );
355
+ return;
356
+ }
357
+ }
358
+
317
359
  const preGate = runPolicyGate(context, "pre", step);
318
360
  if (preGate.action === "block") {
319
361
  ledger.append({
@@ -335,6 +377,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
335
377
  if (attempt > maxAttempts) {
336
378
  context.waitHuman(`resume-${step}`, [
337
379
  `budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
380
+ `approving resume-${step} grants one more attempt; rejecting ends the run`,
338
381
  ]);
339
382
  return;
340
383
  }
@@ -379,14 +422,76 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
379
422
  }));
380
423
  }
381
424
  }
382
- const exec = adapter.execute({
383
- step,
425
+ // Envelope (C6): the worker's prompt, materialised into the run
426
+ // directory and handed over as BUILDBEAT_PROMPT / input.envelope.
427
+ const prompt = materialisePrompt({
428
+ envelope: context.envelope,
384
429
  worker: stepDef.worker,
385
- workspacePath: workspace.worktreePath,
386
- input,
387
- timeoutMs: context.stepTimeoutMs,
388
- outputPath,
430
+ runtimeDir: context.runtimeDir,
431
+ runId: ledger.state.run.id,
432
+ step,
433
+ attempt,
434
+ repoRoot: context.repoRoot,
389
435
  });
436
+ if (prompt) {
437
+ input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
438
+ }
439
+ // Incremental review (C7): tell a reviewer which candidate the last
440
+ // review saw when it is an ancestor of this one.
441
+ if (stepDef.readonly) {
442
+ const head = before.head;
443
+ const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
444
+ if (lastReviewed) {
445
+ input.lastReviewed = lastReviewed;
446
+ }
447
+ }
448
+ // Verification reuse (C7): same tree + same worker + same envelope that
449
+ // already passed is referenced, not re-run. Failures always re-run.
450
+ let stepCacheKey = null;
451
+ let reused = null;
452
+ if (context.cache[step] === "tree") {
453
+ const current = readback(workspace.worktreePath);
454
+ if (!current.dirty) {
455
+ stepCacheKey = cacheKey({
456
+ tree: treeHash(workspace.worktreePath),
457
+ worker: stepDef.worker,
458
+ adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
459
+ adapterName: adapter.name,
460
+ envelopeDigest: context.envelope?.digest ?? null,
461
+ });
462
+ reused = findReusableEvidence(context.repoRoot, stepCacheKey);
463
+ }
464
+ }
465
+ let exec;
466
+ if (reused) {
467
+ const at = now();
468
+ exec = {
469
+ adapter: "cache",
470
+ command: `reuse ${reused.run} ${reused.evidenceRef}`,
471
+ exitCode: 0,
472
+ signal: null,
473
+ stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
474
+ stderr: "",
475
+ timedOut: false,
476
+ spawnError: null,
477
+ startedAt: at,
478
+ finishedAt: at,
479
+ };
480
+ } else {
481
+ exec = adapter.execute({
482
+ step,
483
+ worker: stepDef.worker,
484
+ workspacePath: workspace.worktreePath,
485
+ input,
486
+ timeoutMs: context.stepTimeoutMs,
487
+ outputPath,
488
+ // Live output streams + marker land in the run directory so `status`
489
+ // can answer "is it still doing something" while the step runs.
490
+ liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
491
+ promptPath: prompt?.path ?? null,
492
+ vars: context.envelope?.vars ?? null,
493
+ });
494
+ }
390
495
  const tree = readback(workspace.worktreePath);
391
496
  const evidence = collectCommandEvidence({
392
497
  runtimeDir: context.runtimeDir,
@@ -395,6 +500,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
395
500
  attempt,
396
501
  execResult: exec,
397
502
  subject: tree.head,
503
+ grade: reused ? reused.grade : stepDef.grade ?? "L2",
504
+ redact: context.redact,
398
505
  });
399
506
  ledger.append({
400
507
  type: "EVIDENCE_RECORDED",
@@ -407,6 +514,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
407
514
  digest: evidence.digest,
408
515
  status: evidence.status,
409
516
  grade: evidence.grade,
517
+ ...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
518
+ ...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
410
519
  },
411
520
  });
412
521
 
@@ -459,18 +568,53 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
459
568
  } else {
460
569
  stepStatus = "succeeded";
461
570
  }
571
+ // Infrastructure failure vs candidate failure. A timeout, a crash,
572
+ // garbage output or the worker's own "environment unavailable" signal
573
+ // (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
574
+ // fingerprint, no fixer, the attempt is refunded, and a human decides
575
+ // when the backend is back. Real incidents: a worker backend outage
576
+ // (review exit 97) and non-JSON reviewer output killed five runs in two
577
+ // days as "no transition for (review, failed)"; PATH, port and host-load
578
+ // verify failures dispatched fixers five times.
579
+ const infra =
580
+ stepStatus === "timeout" ||
581
+ stepStatus === "crashed" ||
582
+ stepStatus === "invalid-output" ||
583
+ (stepStatus === "failed" && exec.exitCode === 75);
462
584
  ledger.append({
463
585
  type: "STEP_FINISHED",
464
586
  actor: KERNEL,
465
587
  ts: now(),
466
- data: { step, attempt, status: stepStatus, exitCode: exec.exitCode },
588
+ data: { step, attempt, status: stepStatus, exitCode: exec.exitCode, ...(infra ? { infra: true } : {}) },
467
589
  });
468
590
  ledger.append({
469
591
  type: "BUDGET_CONSUMED",
470
592
  actor: KERNEL,
471
593
  ts: now(),
472
- data: { kind: "attempts", amount: 1, remaining: maxAttempts - attempt },
594
+ data: {
595
+ kind: "attempts",
596
+ amount: infra ? 0 : 1,
597
+ remaining: context.maxAttemptsFor(step) - attempt,
598
+ },
473
599
  });
600
+ if (infra) {
601
+ const cause =
602
+ stepStatus === "failed"
603
+ ? "exit 75 (worker reports its environment unavailable)"
604
+ : stepStatus === "invalid-output"
605
+ ? "output is not a worker envelope"
606
+ : stepStatus;
607
+ context.waitHuman(
608
+ `resume-${step}`,
609
+ [
610
+ `worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
611
+ ...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
612
+ `approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
613
+ ],
614
+ "infra",
615
+ );
616
+ return;
617
+ }
474
618
 
475
619
  let blockingFindings = [];
476
620
  if (envelope?.findings) {
@@ -617,6 +761,57 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
617
761
  }
618
762
  }
619
763
 
764
+ // Supersede (iteration 08, C2): a new Run for the same Work makes any older
765
+ // Run still waiting on a human moot — the human would be approving a
766
+ // candidate nobody intends to merge. Real incident: two WAITING_HUMAN runs
767
+ // sat in a pilot repo's inbox for a day after their successor had already
768
+ // shipped. Only WAITING_HUMAN runs are touched; RUNNING ones are protected
769
+ // by the active lock, terminal ones are already settled.
770
+ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
771
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
772
+ const superseded = [];
773
+ const skipped = [];
774
+ if (!existsSync(runsDir)) {
775
+ return { superseded, skipped };
776
+ }
777
+ for (const entry of readdirSync(runsDir).sort()) {
778
+ if (entry === newRunId) {
779
+ continue;
780
+ }
781
+ const ledgerPath = join(runsDir, entry, "events.jsonl");
782
+ if (!existsSync(ledgerPath)) {
783
+ continue;
784
+ }
785
+ const ledger = EventLedger.open(ledgerPath);
786
+ const state = ledger.state;
787
+ if (ledger.corruption || !state.run || state.run.work !== workId) {
788
+ continue;
789
+ }
790
+ if (state.terminal || state.run.status !== "WAITING_HUMAN") {
791
+ continue;
792
+ }
793
+ try {
794
+ acquireLock(repoRoot, entry);
795
+ } catch {
796
+ skipped.push({ run: entry, reason: "locked by another process" });
797
+ continue;
798
+ }
799
+ try {
800
+ ledger.append({
801
+ type: "RUN_TERMINAL",
802
+ actor: KERNEL,
803
+ ts: now(),
804
+ data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
805
+ });
806
+ writeRunRecord({ repoRoot, ledger, ts: now() });
807
+ superseded.push(entry);
808
+ } finally {
809
+ releaseLock(repoRoot, entry);
810
+ }
811
+ }
812
+ return { superseded, skipped };
813
+ }
814
+
620
815
  function openLedgerFor(repoRoot, runId) {
621
816
  const ledgerPath = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
622
817
  const ledger = EventLedger.open(ledgerPath);
@@ -664,6 +859,10 @@ export function startRun(options) {
664
859
  const workspace = createWorkspace({ repoRoot, runId, base });
665
860
  const context = makeContext(options, ledger, workspace);
666
861
  const now = context.now;
862
+ const supersession =
863
+ options.supersede === "off"
864
+ ? { superseded: [], skipped: [] }
865
+ : supersedeWaitingRuns(repoRoot, workId, runId, now);
667
866
  ledger.append({
668
867
  type: "RUN_CREATED",
669
868
  actor: KERNEL,
@@ -678,6 +877,8 @@ export function startRun(options) {
678
877
  entry,
679
878
  planDigest: planDigest ?? "UNVERIFIED",
680
879
  intentDigest: intentDigest ?? "UNVERIFIED",
880
+ ...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
881
+ ...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
681
882
  },
682
883
  });
683
884
  ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
@@ -694,7 +895,15 @@ export function startRun(options) {
694
895
  },
695
896
  });
696
897
  drive(context, entry);
697
- return { runId, workId, ledgerPath, state: ledger.state, workspace };
898
+ return {
899
+ runId,
900
+ workId,
901
+ ledgerPath,
902
+ state: ledger.state,
903
+ workspace,
904
+ superseded: supersession.superseded,
905
+ supersedeSkipped: supersession.skipped,
906
+ };
698
907
  });
699
908
  }
700
909
 
@@ -783,12 +992,51 @@ export function resumeRun(options) {
783
992
  });
784
993
  return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
785
994
  }
786
- const step = resumeStepFromTransition(approval.transition);
995
+ // An adopted candidate names where to resume (verify, by convention):
996
+ // the fixer step the request was waiting on has nothing left to do.
997
+ const step = approval.resumeAt ?? resumeStepFromTransition(approval.transition);
787
998
  if (!step || !context.workflow.stepIds.has(step)) {
788
999
  throw new OrchestratorError(
789
1000
  `cannot derive a resume step from approved transition ${approval.transition}`,
790
1001
  );
791
1002
  }
1003
+ // An approved resume-<step> on an exhausted budget is the human saying
1004
+ // "one more"; record the grant before driving or the same request
1005
+ // comes straight back (the pilot's app-login runs ended CANCELLED
1006
+ // with their candidates in production because of exactly that).
1007
+ const requestKind = [...ledger.events]
1008
+ .reverse()
1009
+ .find((event) => event.type === "HUMAN_REQUESTED" && event.data.transition === approval.transition)
1010
+ ?.data.kind;
1011
+ if (requestKind === "work-review-cap") {
1012
+ ledger.append({
1013
+ type: "BUDGET_EXTENDED",
1014
+ actor: KERNEL,
1015
+ ts: now(),
1016
+ data: {
1017
+ step,
1018
+ amount: 1,
1019
+ scope: "work",
1020
+ maxAttempts: (context.runBudgets.reviewRoundsPerWork ?? 0) + (state.workReviewGrants ?? 0) + 1,
1021
+ approvalRef: approval.decisionRef,
1022
+ },
1023
+ });
1024
+ } else if (
1025
+ approval.transition.startsWith("resume-") &&
1026
+ (state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)
1027
+ ) {
1028
+ ledger.append({
1029
+ type: "BUDGET_EXTENDED",
1030
+ actor: KERNEL,
1031
+ ts: now(),
1032
+ data: {
1033
+ step,
1034
+ amount: 1,
1035
+ maxAttempts: context.maxAttemptsFor(step) + 1,
1036
+ approvalRef: approval.decisionRef,
1037
+ },
1038
+ });
1039
+ }
792
1040
  ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
793
1041
  drive(context, step, { skipBoundaryOnce: true });
794
1042
  return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };