@haiyangbg/buildbeat 2.0.0-beta.2 → 2.0.0-beta.4

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 (62) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/SKILL.md +76 -2
  3. package/bin/buildbeat-v2.js +13 -1
  4. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  5. package/docs/CLI.md +1 -1
  6. package/docs/EXECUTION-PLAN.md +2 -2
  7. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  8. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  9. package/docs/RELEASING.md +1 -1
  10. package/docs/V2-D2-DECISION-CARD.md +2 -2
  11. package/docs/V2-DECISIONS.md +2 -2
  12. package/docs/V2-ITERATION-01.md +13 -13
  13. package/docs/V2-ITERATION-06.md +2 -2
  14. package/docs/V2-ITERATION-08.md +62 -0
  15. package/docs/V2-PLAN.md +6 -6
  16. package/docs/V2-PROPOSAL.md +2 -2
  17. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
  19. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -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 +2 -2
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +6 -0
  27. package/docs/v2/guide/02-workflow-guide.md +31 -0
  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 +10 -0
  31. package/docs/v2/guide/07-approval-guide.md +50 -0
  32. package/docs/v2/guide/10-recovery.md +25 -2
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +12 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/shell.js +87 -14
  38. package/src/v2/cli/run.js +602 -30
  39. package/src/v2/domain/event-registry.js +24 -0
  40. package/src/v2/engine/reducer.js +5 -0
  41. package/src/v2/engine/workflow.js +8 -1
  42. package/src/v2/evidence/collector.js +14 -3
  43. package/src/v2/observe/observe.js +9 -2
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/presets/software-delivery.yaml +5 -0
  47. package/src/v2/runtime/cache.js +124 -0
  48. package/src/v2/runtime/decisions.js +6 -4
  49. package/src/v2/runtime/env-contract.js +135 -0
  50. package/src/v2/runtime/envelope.js +183 -0
  51. package/src/v2/runtime/findings.js +158 -0
  52. package/src/v2/runtime/gc.js +182 -0
  53. package/src/v2/runtime/liveness.js +193 -0
  54. package/src/v2/runtime/metrics.js +8 -0
  55. package/src/v2/runtime/notify.js +223 -0
  56. package/src/v2/runtime/orchestrator.js +252 -24
  57. package/src/v2/runtime/overview.js +264 -0
  58. package/src/v2/runtime/repo-ref.js +38 -0
  59. package/src/v2/runtime/run-record.js +18 -3
  60. package/src/v2/workspace/workspace-manager.js +4 -1
  61. package/templates/v2/AGENTS.md +72 -0
  62. 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,6 +26,17 @@ import {
26
26
  releaseLock,
27
27
  } from "../workspace/workspace-manager.js";
28
28
  import { writeRunRecord } from "./run-record.js";
29
+ import { assertRequires } from "./env-contract.js";
30
+ import { materialisePrompt } from "./envelope.js";
31
+ import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
32
+ import {
33
+ buildAnchor,
34
+ fingerprintFinding,
35
+ latestAdjudications,
36
+ readFindingsAccount,
37
+ recordReviewFindings,
38
+ } from "./findings.js";
39
+ import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
29
40
 
30
41
  const KERNEL = { kind: "kernel", id: "orchestrator" };
31
42
 
@@ -135,6 +146,11 @@ function makeContext(options, ledger, workspace) {
135
146
  workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
136
147
  context.policies = options.policies ?? [];
137
148
  context.allowedPaths = options.allowedPaths ?? null;
149
+ context.reviewTriage = options.reviewTriage ?? null;
150
+ context.envelope = options.envelope ?? null;
151
+ context.cache = options.cache ?? {};
152
+ context.redact = options.redact ?? [];
153
+ context.adapterConfigs = options.adapterConfigs ?? {};
138
154
  context.policyCtx = () => ({
139
155
  state: ledger.state,
140
156
  candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
@@ -219,6 +235,14 @@ function settleOutcome(context, step, outcome, tree, exec) {
219
235
  ]);
220
236
  return null;
221
237
  }
238
+ // A step that failed its final attempt can never run again, so routing
239
+ // to fix would spend a worker on a candidate nothing can verify.
240
+ if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
241
+ context.waitHuman(`resume-${step}`, [
242
+ `budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
243
+ ]);
244
+ return null;
245
+ }
222
246
  }
223
247
  const to = nextStep(workflow, step, outcome);
224
248
  let result = "PASS";
@@ -337,14 +361,100 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
337
361
  const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
338
362
  mkdirSync(outputsDir, { recursive: true });
339
363
  const outputPath = join(outputsDir, `${step}-${attempt}.json`);
340
- const exec = adapter.execute({
341
- step,
364
+ // Anchored review: readonly (reviewer) steps receive the adjudicated
365
+ // findings history so a fresh reviewer inherits settled verdicts instead
366
+ // of re-litigating them; writing steps get the latest review findings
367
+ // with their adjudication status (the fixer's worklist).
368
+ const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
369
+ const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
370
+ if (anchor && stepDef.readonly) {
371
+ input.anchor = anchor;
372
+ } else if (anchor) {
373
+ const lastReview = [...ledger.state.evidence]
374
+ .reverse()
375
+ .find((item) => item.kind === "review");
376
+ if (lastReview?.findings?.length) {
377
+ const adjudicated = latestAdjudications(
378
+ readFindingsAccount(context.repoRoot, ledger.state.run.work),
379
+ );
380
+ input.findings = lastReview.findings.map((finding) => ({
381
+ severity: finding.severity,
382
+ summary: finding.summary,
383
+ fingerprint: fingerprintFinding(finding),
384
+ adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
385
+ }));
386
+ }
387
+ }
388
+ // Envelope (C6): the worker's prompt, materialised into the run
389
+ // directory and handed over as BUILDBEAT_PROMPT / input.envelope.
390
+ const prompt = materialisePrompt({
391
+ envelope: context.envelope,
342
392
  worker: stepDef.worker,
343
- workspacePath: workspace.worktreePath,
344
- input: { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt },
345
- timeoutMs: context.stepTimeoutMs,
346
- outputPath,
393
+ runtimeDir: context.runtimeDir,
394
+ runId: ledger.state.run.id,
395
+ step,
396
+ attempt,
397
+ repoRoot: context.repoRoot,
347
398
  });
399
+ if (prompt) {
400
+ input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
401
+ }
402
+ // Incremental review (C7): tell a reviewer which candidate the last
403
+ // review saw when it is an ancestor of this one.
404
+ if (stepDef.readonly) {
405
+ const head = before.head;
406
+ const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
407
+ if (lastReviewed) {
408
+ input.lastReviewed = lastReviewed;
409
+ }
410
+ }
411
+ // Verification reuse (C7): same tree + same worker + same envelope that
412
+ // already passed is referenced, not re-run. Failures always re-run.
413
+ let stepCacheKey = null;
414
+ let reused = null;
415
+ if (context.cache[step] === "tree") {
416
+ const current = readback(workspace.worktreePath);
417
+ if (!current.dirty) {
418
+ stepCacheKey = cacheKey({
419
+ tree: treeHash(workspace.worktreePath),
420
+ worker: stepDef.worker,
421
+ adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
422
+ adapterName: adapter.name,
423
+ envelopeDigest: context.envelope?.digest ?? null,
424
+ });
425
+ reused = findReusableEvidence(context.repoRoot, stepCacheKey);
426
+ }
427
+ }
428
+ let exec;
429
+ if (reused) {
430
+ const at = now();
431
+ exec = {
432
+ adapter: "cache",
433
+ command: `reuse ${reused.run} ${reused.evidenceRef}`,
434
+ exitCode: 0,
435
+ signal: null,
436
+ stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
437
+ stderr: "",
438
+ timedOut: false,
439
+ spawnError: null,
440
+ startedAt: at,
441
+ finishedAt: at,
442
+ };
443
+ } else {
444
+ exec = adapter.execute({
445
+ step,
446
+ worker: stepDef.worker,
447
+ workspacePath: workspace.worktreePath,
448
+ input,
449
+ timeoutMs: context.stepTimeoutMs,
450
+ outputPath,
451
+ // Live output streams + marker land in the run directory so `status`
452
+ // can answer "is it still doing something" while the step runs.
453
+ liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
454
+ promptPath: prompt?.path ?? null,
455
+ vars: context.envelope?.vars ?? null,
456
+ });
457
+ }
348
458
  const tree = readback(workspace.worktreePath);
349
459
  const evidence = collectCommandEvidence({
350
460
  runtimeDir: context.runtimeDir,
@@ -353,18 +463,22 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
353
463
  attempt,
354
464
  execResult: exec,
355
465
  subject: tree.head,
466
+ grade: reused ? reused.grade : stepDef.grade ?? "L2",
467
+ redact: context.redact,
356
468
  });
357
469
  ledger.append({
358
470
  type: "EVIDENCE_RECORDED",
359
471
  actor: KERNEL,
360
472
  ts: now(),
361
473
  data: {
362
- evidenceRef: evidence.location,
474
+ evidenceRef: toRepoRef(context.repoRoot, evidence.location),
363
475
  kind: evidence.kind,
364
476
  subject: evidence.subject,
365
477
  digest: evidence.digest,
366
478
  status: evidence.status,
367
479
  grade: evidence.grade,
480
+ ...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
481
+ ...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
368
482
  },
369
483
  });
370
484
 
@@ -432,21 +546,39 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
432
546
 
433
547
  let blockingFindings = [];
434
548
  if (envelope?.findings) {
435
- blockingFindings = envelope.findings.filter(
436
- (finding) => finding.severity === "P0" || finding.severity === "P1",
549
+ recordReviewFindings(context.repoRoot, ledger.state.run.work, {
550
+ run: ledger.state.run.id,
551
+ step,
552
+ attempt,
553
+ findings: envelope.findings,
554
+ ts: now(),
555
+ });
556
+ // A fingerprint a human dismissed stays visible in the evidence but no
557
+ // longer blocks: settled verdicts do not reopen without a human.
558
+ const adjudicated = latestAdjudications(
559
+ readFindingsAccount(context.repoRoot, ledger.state.run.work),
437
560
  );
561
+ const suppressed = [];
562
+ blockingFindings = envelope.findings.filter((finding) => {
563
+ if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
564
+ suppressed.push(fingerprintFinding(finding));
565
+ return false;
566
+ }
567
+ return finding.severity === "P0" || finding.severity === "P1";
568
+ });
438
569
  ledger.append({
439
570
  type: "EVIDENCE_RECORDED",
440
571
  actor: KERNEL,
441
572
  ts: now(),
442
573
  data: {
443
- evidenceRef: outputPath,
574
+ evidenceRef: toRepoRef(context.repoRoot, outputPath),
444
575
  kind: "review",
445
576
  subject: tree.head,
446
577
  digest: sha256(canonicalJson(envelope)),
447
578
  status: blockingFindings.length > 0 ? "failed" : "passed",
448
579
  grade: "L2",
449
580
  findings: envelope.findings,
581
+ ...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
450
582
  },
451
583
  });
452
584
  }
@@ -531,8 +663,81 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
531
663
  } else {
532
664
  outcome = "succeeded";
533
665
  }
534
- step = settleOutcome(context, step, outcome, tree, exec);
666
+ const routed = settleOutcome(context, step, outcome, tree, exec);
667
+ // Finding triage gate (reviewTriage: required): blocking findings stop
668
+ // for a human verdict before any fixer runs. Findings are prescriptions,
669
+ // not facts — auto-routing them to a fixer burned four oscillation
670
+ // rounds in the deploy campaign before a human stopped the loop.
671
+ if (routed && outcome === "findings-blocking" && context.reviewTriage === "required") {
672
+ context.waitHuman(
673
+ `enter-${routed}`,
674
+ [
675
+ `review found ${blockingFindings.length} blocking finding(s); triage before ${routed} runs`,
676
+ ...blockingFindings
677
+ .slice(0, 5)
678
+ .map(
679
+ (finding) =>
680
+ `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
681
+ ),
682
+ `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
683
+ ],
684
+ "finding-triage",
685
+ );
686
+ return;
687
+ }
688
+ step = routed;
689
+ }
690
+ }
691
+
692
+ // Supersede (iteration 08, C2): a new Run for the same Work makes any older
693
+ // Run still waiting on a human moot — the human would be approving a
694
+ // candidate nobody intends to merge. Real incident: two WAITING_HUMAN runs
695
+ // sat in a pilot repo's inbox for a day after their successor had already
696
+ // shipped. Only WAITING_HUMAN runs are touched; RUNNING ones are protected
697
+ // by the active lock, terminal ones are already settled.
698
+ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
699
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
700
+ const superseded = [];
701
+ const skipped = [];
702
+ if (!existsSync(runsDir)) {
703
+ return { superseded, skipped };
704
+ }
705
+ for (const entry of readdirSync(runsDir).sort()) {
706
+ if (entry === newRunId) {
707
+ continue;
708
+ }
709
+ const ledgerPath = join(runsDir, entry, "events.jsonl");
710
+ if (!existsSync(ledgerPath)) {
711
+ continue;
712
+ }
713
+ const ledger = EventLedger.open(ledgerPath);
714
+ const state = ledger.state;
715
+ if (ledger.corruption || !state.run || state.run.work !== workId) {
716
+ continue;
717
+ }
718
+ if (state.terminal || state.run.status !== "WAITING_HUMAN") {
719
+ continue;
720
+ }
721
+ try {
722
+ acquireLock(repoRoot, entry);
723
+ } catch {
724
+ skipped.push({ run: entry, reason: "locked by another process" });
725
+ continue;
726
+ }
727
+ try {
728
+ ledger.append({
729
+ type: "RUN_TERMINAL",
730
+ actor: KERNEL,
731
+ ts: now(),
732
+ data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
733
+ });
734
+ writeRunRecord({ repoRoot, ledger, ts: now() });
735
+ superseded.push(entry);
736
+ } finally {
737
+ releaseLock(repoRoot, entry);
738
+ }
535
739
  }
740
+ return { superseded, skipped };
536
741
  }
537
742
 
538
743
  function openLedgerFor(repoRoot, runId) {
@@ -568,6 +773,11 @@ export function startRun(options) {
568
773
  if (!workflowDigest) {
569
774
  throw new OrchestratorError("workflowDigest is required (pin what you run)");
570
775
  }
776
+ // Environment contract first: a missing or too-old binary fails the start
777
+ // with a readable cause instead of burning a run on an implicit PATH fact.
778
+ if (options.requires?.length) {
779
+ assertRequires(options.requires);
780
+ }
571
781
  const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
572
782
  if (ledger.events.length > 0) {
573
783
  throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
@@ -577,6 +787,10 @@ export function startRun(options) {
577
787
  const workspace = createWorkspace({ repoRoot, runId, base });
578
788
  const context = makeContext(options, ledger, workspace);
579
789
  const now = context.now;
790
+ const supersession =
791
+ options.supersede === "off"
792
+ ? { superseded: [], skipped: [] }
793
+ : supersedeWaitingRuns(repoRoot, workId, runId, now);
580
794
  ledger.append({
581
795
  type: "RUN_CREATED",
582
796
  actor: KERNEL,
@@ -591,6 +805,8 @@ export function startRun(options) {
591
805
  entry,
592
806
  planDigest: planDigest ?? "UNVERIFIED",
593
807
  intentDigest: intentDigest ?? "UNVERIFIED",
808
+ ...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
809
+ ...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
594
810
  },
595
811
  });
596
812
  ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
@@ -600,14 +816,22 @@ export function startRun(options) {
600
816
  ts: now(),
601
817
  data: {
602
818
  workspaceId: workspace.workspaceId,
603
- repo: repoRoot,
819
+ repo: toRepoRef(repoRoot, repoRoot),
604
820
  branch: workspace.branch,
605
- worktreePath: workspace.worktreePath,
821
+ worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
606
822
  base: workspace.base,
607
823
  },
608
824
  });
609
825
  drive(context, entry);
610
- return { runId, workId, ledgerPath, state: ledger.state, workspace };
826
+ return {
827
+ runId,
828
+ workId,
829
+ ledgerPath,
830
+ state: ledger.state,
831
+ workspace,
832
+ superseded: supersession.superseded,
833
+ supersedeSkipped: supersession.skipped,
834
+ };
611
835
  });
612
836
  }
613
837
 
@@ -626,6 +850,9 @@ export function resumeRun(options) {
626
850
  if (!repoRoot || !runId) {
627
851
  throw new OrchestratorError("repoRoot and runId are required");
628
852
  }
853
+ if (options.requires?.length) {
854
+ assertRequires(options.requires);
855
+ }
629
856
  const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
630
857
  const state = ledger.state;
631
858
  if (!state.run) {
@@ -647,15 +874,16 @@ export function resumeRun(options) {
647
874
  if (!bound) {
648
875
  throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
649
876
  }
650
- if (!existsSync(bound.worktreePath)) {
877
+ const worktreePath = resolveRepoRef(repoRoot, bound.worktreePath);
878
+ if (!existsSync(worktreePath)) {
651
879
  throw new OrchestratorError(
652
- `worktree missing: ${bound.worktreePath}; recovery requires a human decision`,
880
+ "worktree missing; recovery requires a human decision",
653
881
  );
654
882
  }
655
883
  const workspace = {
656
884
  workspaceId: runId,
657
885
  repoRoot,
658
- worktreePath: bound.worktreePath,
886
+ worktreePath,
659
887
  branch: bound.branch,
660
888
  base: bound.base,
661
889
  };
@@ -727,12 +955,12 @@ export function resumeRun(options) {
727
955
  ]);
728
956
  return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
729
957
  }
730
- startStep = settleOutcome(context, step, "failed", tree, {
731
- command: "(interrupted)",
732
- exitCode: null,
733
- stdout: "",
734
- stderr: "process lost before completion",
735
- });
958
+ // An interrupted attempt says nothing about the candidate, so the step
959
+ // itself reruns (the lost attempt still counts against its budget)
960
+ // instead of settling as a step failure — routing a crash through the
961
+ // failure edge dispatched a fixer with no verifier evidence (real
962
+ // incident: deploy-18's verify worker was killed by a host timeout).
963
+ startStep = step;
736
964
  } else if (tree.dirty) {
737
965
  context.waitHuman("resume-run", [
738
966
  "worktree is dirty at resume with no step in flight; human triage required",
@@ -0,0 +1,264 @@
1
+ // Work-level overview (iteration 08, C5): "where is this thing, and who
2
+ // moves next" — the question the owner opened three sessions with
3
+ // ("X 上线了吗 / 离上线还差多远 / 从每个系统说待办") and that inbox,
4
+ // which only knows about runs waiting on a human, cannot answer.
5
+ //
6
+ // Everything here is derived: work directories and decision ledgers in the
7
+ // Git plane, run ledgers in the runtime plane (run-records fill in for runs
8
+ // whose runtime was wiped), and git ancestry for "merged". Nothing is
9
+ // written.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { EventLedger } from "../storage/event-ledger.js";
17
+ import { latestAdjudications, readFindingsAccount } from "./findings.js";
18
+ import { nextReply } from "./notify.js";
19
+
20
+ function sha256File(path) {
21
+ return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
22
+ }
23
+
24
+ function readJsonl(path) {
25
+ if (!existsSync(path)) {
26
+ return [];
27
+ }
28
+ return readFileSync(path, "utf8")
29
+ .split("\n")
30
+ .filter(Boolean)
31
+ .map((line) => {
32
+ try {
33
+ return JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ })
38
+ .filter(Boolean);
39
+ }
40
+
41
+ function artifactStatus(workDir, decisions, artifact) {
42
+ const path = join(workDir, `${artifact}.md`);
43
+ if (!existsSync(path)) {
44
+ return { exists: false, accepted: false, stale: false };
45
+ }
46
+ const digest = sha256File(path);
47
+ const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
48
+ const latest = accepts[accepts.length - 1];
49
+ if (!latest) {
50
+ return { exists: true, accepted: false, stale: false };
51
+ }
52
+ const stale = latest.subject?.digest !== digest;
53
+ return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
54
+ }
55
+
56
+ function isAncestor(repoRoot, sha, ref) {
57
+ try {
58
+ execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function headRef(repoRoot) {
66
+ try {
67
+ return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
68
+ } catch {
69
+ return "HEAD";
70
+ }
71
+ }
72
+
73
+ // Runs for a work: runtime ledgers first, run-records for the rest.
74
+ function runsFor(repoRoot, workId) {
75
+ const runs = new Map();
76
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
77
+ if (existsSync(runsDir)) {
78
+ for (const entry of readdirSync(runsDir)) {
79
+ const path = join(runsDir, entry, "events.jsonl");
80
+ if (!existsSync(path)) {
81
+ continue;
82
+ }
83
+ const ledger = EventLedger.open(path);
84
+ const state = ledger.state;
85
+ if (!state.run || state.run.work !== workId) {
86
+ continue;
87
+ }
88
+ runs.set(state.run.id, {
89
+ id: state.run.id,
90
+ status: state.run.status,
91
+ terminal: state.terminal,
92
+ pendingHuman: state.pendingHuman,
93
+ candidate: state.workspaces[state.run.id]?.candidate ?? null,
94
+ createdAt: ledger.events[0]?.ts ?? null,
95
+ lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
96
+ state,
97
+ source: "runtime",
98
+ });
99
+ }
100
+ }
101
+ const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
102
+ if (existsSync(recordsDir)) {
103
+ for (const entry of readdirSync(recordsDir)) {
104
+ if (runs.has(entry)) {
105
+ continue;
106
+ }
107
+ const path = join(recordsDir, entry, "run-record.json");
108
+ if (!existsSync(path)) {
109
+ continue;
110
+ }
111
+ try {
112
+ const record = JSON.parse(readFileSync(path, "utf8"));
113
+ runs.set(entry, {
114
+ id: entry,
115
+ status: record.terminal?.status ?? "UNKNOWN",
116
+ terminal: record.terminal ?? null,
117
+ pendingHuman: null,
118
+ candidate: record.workspaces?.[entry]?.candidate ?? null,
119
+ createdAt: record.startedAt ?? null,
120
+ lastAt: record.finishedAt ?? null,
121
+ state: null,
122
+ source: "run-record",
123
+ });
124
+ } catch {
125
+ // an unreadable record is reported as absent, not guessed
126
+ }
127
+ }
128
+ }
129
+ return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
130
+ }
131
+
132
+ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
133
+ const workRoot = join(repoRoot, "delivery", "work");
134
+ const rows = [];
135
+ if (!existsSync(workRoot)) {
136
+ return rows;
137
+ }
138
+ const mainRef = headRef(repoRoot);
139
+ for (const workId of readdirSync(workRoot).sort()) {
140
+ if (work && workId !== work) {
141
+ continue;
142
+ }
143
+ const workDir = join(workRoot, workId);
144
+ if (!existsSync(join(workDir, "intent.md")) && !existsSync(join(workDir, "plan.md")) && !existsSync(join(workDir, "decisions.jsonl")) && !existsSync(join(workDir, "runs"))) {
145
+ continue;
146
+ }
147
+ const decisions = readJsonl(join(workDir, "decisions.jsonl"));
148
+ const intent = artifactStatus(workDir, decisions, "intent");
149
+ const plan = artifactStatus(workDir, decisions, "plan");
150
+ const envFacts = existsSync(join(workDir, "env-facts.md"));
151
+ const findingRows = readFindingsAccount(repoRoot, workId);
152
+ const adjudicated = latestAdjudications(findingRows);
153
+ const openFindings = findingRows.filter(
154
+ (row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
155
+ ).length;
156
+ const runs = runsFor(repoRoot, workId);
157
+ const live = runs.filter((run) => run.status !== "SUPERSEDED");
158
+ const latest = live[live.length - 1] ?? null;
159
+ let merged = false;
160
+ if (latest?.candidate) {
161
+ merged = isAncestor(repoRoot, latest.candidate, mainRef);
162
+ }
163
+
164
+ let stage;
165
+ let next;
166
+ if (!intent.exists) {
167
+ stage = "NO_INTENT";
168
+ next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
169
+ } else if (!latest) {
170
+ if (!plan.exists) {
171
+ stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
172
+ next = intent.accepted
173
+ ? `write delivery/work/${workId}/plan.md, then accept it`
174
+ : `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
175
+ } else if (!plan.accepted) {
176
+ stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
177
+ next = `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact plan --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
178
+ } else {
179
+ stage = "READY_TO_RUN";
180
+ const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
181
+ next =
182
+ configs.length > 0
183
+ ? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
184
+ : `no run-config in delivery/work/${workId}: write one, or record the work as closed in decisions.jsonl if it was doc-only`;
185
+ }
186
+ } else if (latest.status === "RUNNING") {
187
+ stage = "RUNNING";
188
+ next = `buildbeat-v2 status --repo ${repoLabel} --run ${latest.id}`;
189
+ } else if (latest.status === "WAITING_HUMAN") {
190
+ stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
191
+ const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
192
+ next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
193
+ } else if (latest.status === "SUCCEEDED") {
194
+ if (merged) {
195
+ stage = "MERGED";
196
+ next = `release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}`;
197
+ } else {
198
+ stage = "MERGE_READY";
199
+ next = latest.candidate
200
+ ? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
201
+ : "run succeeded without a candidate; nothing to merge";
202
+ }
203
+ } else {
204
+ stage = `STOPPED_${latest.status}`;
205
+ next = plan.accepted
206
+ ? `decide: retry (buildbeat-v2 start ... --attempt new) or close the work`
207
+ : `plan not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
208
+ }
209
+ rows.push({
210
+ work: workId,
211
+ stage,
212
+ intent,
213
+ plan,
214
+ envFacts,
215
+ openFindings,
216
+ runs: runs.length,
217
+ latest: latest
218
+ ? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
219
+ : null,
220
+ merged,
221
+ next,
222
+ });
223
+ }
224
+ return rows;
225
+ }
226
+
227
+ function mark(status) {
228
+ if (!status.exists) {
229
+ return "–";
230
+ }
231
+ if (status.stale) {
232
+ return "stale";
233
+ }
234
+ return status.accepted ? "✓" : "draft";
235
+ }
236
+
237
+ export function renderOverview(rows) {
238
+ if (rows.length === 0) {
239
+ return "overview: no work items under delivery/work";
240
+ }
241
+ const lines = [];
242
+ for (const row of rows) {
243
+ lines.push(`${row.work} ${row.stage}`);
244
+ const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
245
+ if (row.openFindings > 0) {
246
+ // Unadjudicated, not necessarily unresolved: a fixer may have closed
247
+ // them without anyone recording a verdict. The number says "nobody
248
+ // ruled on these", which is exactly what a human should know.
249
+ parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
250
+ }
251
+ if (row.envFacts) {
252
+ parts.push("env-facts ✓");
253
+ }
254
+ lines.push(` ${parts.join(" · ")}`);
255
+ if (row.latest) {
256
+ const cand = row.latest.candidate ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.merged ? " (merged)" : ""}` : "";
257
+ const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
258
+ const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
259
+ lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
260
+ }
261
+ lines.push(` next: ${row.next}`);
262
+ }
263
+ return lines.join("\n");
264
+ }