@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
@@ -6,12 +6,26 @@
6
6
  // as a DECISION_RECORDED event and as a line in the Git plane
7
7
  // (delivery/work/<work>/decisions.jsonl).
8
8
 
9
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
9
+ import {
10
+ appendFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ } from "node:fs";
10
16
  import { join } from "node:path";
11
17
 
12
- import { evaluatePolicies, sha256Text } from "../policy/policy.js";
18
+ import {
19
+ deliveryChecks,
20
+ evaluatePolicies,
21
+ sha256Text,
22
+ } from "../policy/policy.js";
13
23
  import { EventLedger } from "../storage/event-ledger.js";
14
- import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
24
+ import {
25
+ acquireLock,
26
+ readback,
27
+ releaseLock,
28
+ } from "../workspace/workspace-manager.js";
15
29
  import { writeRunRecord } from "./run-record.js";
16
30
  import { resolveRepoRef } from "./repo-ref.js";
17
31
 
@@ -39,7 +53,9 @@ function openWaiting(repoRoot, runId) {
39
53
  throw new DecisionError(`no ledger for run ${runId}`);
40
54
  }
41
55
  if (ledger.state.terminal) {
42
- throw new DecisionError(`run ${runId} is already terminal (${ledger.state.terminal.status})`);
56
+ throw new DecisionError(
57
+ `run ${runId} is already terminal (${ledger.state.terminal.status})`,
58
+ );
43
59
  }
44
60
  if (!ledger.state.pendingHuman) {
45
61
  throw new DecisionError(`run ${runId} has no pending human request`);
@@ -47,13 +63,50 @@ function openWaiting(repoRoot, runId) {
47
63
  return ledger;
48
64
  }
49
65
 
66
+ // Work artifacts whose digests a run froze at creation: the plan or work.md
67
+ // it was started on and, for the legacy controlled safeguards, intent.md.
68
+ // `committed` marks the ones start verified in the base checkout, so the
69
+ // candidate must still carry them unchanged.
70
+ export function boundArtifacts(run, planDigest = run.planDigest) {
71
+ const checks = run.deliveryChecks;
72
+ if (!checks) return [];
73
+ const bound = [];
74
+ if (checks.requireAcceptance || planDigest !== "UNVERIFIED") {
75
+ bound.push({
76
+ artifact: checks.artifact,
77
+ digest: planDigest,
78
+ committed: checks.requireAcceptance,
79
+ });
80
+ }
81
+ if (checks.requireIntent && checks.artifact !== "work") {
82
+ bound.push({
83
+ artifact: "intent",
84
+ digest: run.intentDigest ?? "UNVERIFIED",
85
+ committed: true,
86
+ });
87
+ }
88
+ return bound;
89
+ }
90
+
91
+ function digestOf(file) {
92
+ return existsSync(file) ? sha256Text(readFileSync(file, "utf8")) : null;
93
+ }
94
+
50
95
  function recordDecisionFile(repoRoot, work, line) {
51
96
  const dir = join(repoRoot, "delivery", "work", work);
52
97
  mkdirSync(dir, { recursive: true });
53
- appendFileSync(join(dir, "decisions.jsonl"), `${JSON.stringify(line)}\n`, "utf8");
98
+ appendFileSync(
99
+ join(dir, "decisions.jsonl"),
100
+ `${JSON.stringify(line)}\n`,
101
+ "utf8",
102
+ );
54
103
  }
55
104
 
56
- export function approveRun(repoRoot, runId, { by = "human", transition, ts, policies } = {}) {
105
+ export function approveRun(
106
+ repoRoot,
107
+ runId,
108
+ { by = "human", transition, ts, policies } = {},
109
+ ) {
57
110
  // Read, check and write under the run lock: a ledger read before the
58
111
  // lock may be stale by the time it is written to.
59
112
  acquireLock(repoRoot, runId);
@@ -71,14 +124,19 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
71
124
  );
72
125
  }
73
126
  const bound = ledger.state.workspaces[runId];
74
- const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
127
+ const worktreePath = bound
128
+ ? resolveRepoRef(repoRoot, bound.worktreePath)
129
+ : null;
75
130
  if (!bound || !existsSync(worktreePath)) {
76
- throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
131
+ throw new DecisionError(
132
+ `worktree missing for ${runId}; cannot verify the approval subject`,
133
+ );
77
134
  }
78
135
  const tree = readback(worktreePath);
79
136
  const when = ts ?? new Date().toISOString();
80
137
  if (tree.dirty || tree.head !== pending.subject.candidate) {
81
- const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
138
+ const lastEvidence =
139
+ ledger.state.evidence[ledger.state.evidence.length - 1];
82
140
  ledger.append({
83
141
  type: "HUMAN_REQUESTED",
84
142
  actor: KERNEL,
@@ -90,19 +148,53 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
90
148
  planDigest: ledger.state.run.planDigest,
91
149
  evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
92
150
  },
93
- reasons: ["subject changed since the request; review the new state before approving"],
151
+ reasons: [
152
+ "subject changed since the request; review the new state before approving",
153
+ ],
94
154
  kind: pending.kind,
95
155
  },
96
156
  });
97
- return { approved: false, refreshed: true, subject: ledger.state.pendingHuman.subject };
157
+ return {
158
+ approved: false,
159
+ refreshed: true,
160
+ subject: ledger.state.pendingHuman.subject,
161
+ };
162
+ }
163
+
164
+ // Both the accepted copy (main checkout) and the candidate's copy must
165
+ // still be the bytes bound at creation: a builder or fixer that edits
166
+ // the work artifact in its worktree must not carry a different scope
167
+ // into a final approval.
168
+ const frozenChecks = ledger.state.run.deliveryChecks;
169
+ for (const { artifact, digest, committed } of boundArtifacts(
170
+ ledger.state.run,
171
+ pending.subject.planDigest,
172
+ )) {
173
+ const rel = join(
174
+ "delivery",
175
+ "work",
176
+ ledger.state.run.work,
177
+ `${artifact}.md`,
178
+ );
179
+ if (digestOf(join(repoRoot, rel)) !== digest) {
180
+ throw new DecisionError(
181
+ `work artifact changed since the run was created (${artifact}.md); accept the new scope and start a new attempt`,
182
+ );
183
+ }
184
+ if (committed && digestOf(join(worktreePath, rel)) !== digest) {
185
+ throw new DecisionError(
186
+ `the candidate changed the bound ${artifact}.md; restore the accepted version in the candidate, or accept the new scope and start a new attempt`,
187
+ );
188
+ }
98
189
  }
99
190
 
100
191
  // Transition policies are enforced at the moment of stamping: an
101
192
  // approval that a LOCAL_ENFORCED policy forbids is refused, not logged.
102
193
  const policyRows = evaluatePolicies(
103
- policies ?? [],
194
+ frozenChecks ? deliveryChecks(frozenChecks) : (policies ?? []),
104
195
  { type: "transition", appliesTo: pending.transition },
105
196
  {
197
+ repoRoot,
106
198
  state: ledger.state,
107
199
  candidate: pending.subject.candidate,
108
200
  workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
@@ -167,7 +259,8 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
167
259
  ts: when,
168
260
  data: {
169
261
  status: "SUCCEEDED",
170
- reason: "final decision approved; the external action (merge) stays manual",
262
+ reason:
263
+ "final decision approved; the external action (merge) stays manual",
171
264
  },
172
265
  });
173
266
  writeRunRecord({ repoRoot, ledger, ts: when });
@@ -195,40 +288,64 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
195
288
  // each time (a frontend run reached verify #5 and fix #3 for three hand
196
289
  // fixes). The commit must already be the worktree HEAD: git is read back,
197
290
  // the claim is not trusted.
198
- export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
291
+ export function adoptCandidate(
292
+ repoRoot,
293
+ runId,
294
+ { sha, by = "human", resumeAt, ts } = {},
295
+ ) {
199
296
  acquireLock(repoRoot, runId);
200
297
  try {
201
298
  if (!sha || typeof sha !== "string" || sha.length < 7) {
202
- throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
299
+ throw new DecisionError(
300
+ "adopt requires a commit sha (at least 7 characters)",
301
+ );
203
302
  }
204
303
  if (!resumeAt) {
205
- throw new DecisionError("adopt requires the step to resume at (resumeAt)");
304
+ throw new DecisionError(
305
+ "adopt requires the step to resume at (resumeAt)",
306
+ );
206
307
  }
207
308
  const ledger = openWaiting(repoRoot, runId);
208
309
  const pending = ledger.state.pendingHuman;
209
310
  if (pending.kind === "final-decision") {
210
- throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
311
+ throw new DecisionError(
312
+ "adopt is for a run waiting before fix/verify, not at the merge decision",
313
+ );
211
314
  }
212
315
  const bound = ledger.state.workspaces[runId];
213
- const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
316
+ const worktreePath = bound
317
+ ? resolveRepoRef(repoRoot, bound.worktreePath)
318
+ : null;
214
319
  if (!bound || !existsSync(worktreePath)) {
215
- throw new DecisionError(`worktree missing for ${runId}; cannot adopt a candidate`);
320
+ throw new DecisionError(
321
+ `worktree missing for ${runId}; cannot adopt a candidate`,
322
+ );
216
323
  }
217
324
  const tree = readback(worktreePath);
218
325
  if (tree.dirty) {
219
- throw new DecisionError("worktree is dirty; commit the hand fix before adopting it");
326
+ throw new DecisionError(
327
+ "worktree is dirty; commit the hand fix before adopting it",
328
+ );
220
329
  }
221
330
  if (!tree.head.startsWith(sha)) {
222
- throw new DecisionError(`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`);
331
+ throw new DecisionError(
332
+ `worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`,
333
+ );
223
334
  }
224
335
  const when = ts ?? new Date().toISOString();
225
- const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
336
+ const lastEvidence =
337
+ ledger.state.evidence[ledger.state.evidence.length - 1];
226
338
  if (bound.candidate !== tree.head) {
227
339
  ledger.append({
228
340
  type: "CANDIDATE_PINNED",
229
341
  actor: { kind: "human", id: by },
230
342
  ts: when,
231
- data: { workspaceId: runId, base: bound.base, candidate: tree.head, adopted: true },
343
+ data: {
344
+ workspaceId: runId,
345
+ base: bound.base,
346
+ candidate: tree.head,
347
+ adopted: true,
348
+ },
232
349
  });
233
350
  }
234
351
  const subject = {
@@ -261,7 +378,13 @@ export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, t
261
378
  adopted: tree.head,
262
379
  resumeAt,
263
380
  });
264
- return { adopted: tree.head, decisionRef, transition: pending.transition, resumeAt, state: ledger.state };
381
+ return {
382
+ adopted: tree.head,
383
+ decisionRef,
384
+ transition: pending.transition,
385
+ resumeAt,
386
+ state: ledger.state,
387
+ };
265
388
  } finally {
266
389
  releaseLock(repoRoot, runId);
267
390
  }
@@ -271,13 +394,26 @@ export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, t
271
394
  // file's current digest in the Git plane. If the file changes afterwards,
272
395
  // artifact.accepted evaluates false again — acceptance cannot go stale
273
396
  // silently (closes MVP DoD #1 with staleness included).
274
- export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts } = {}) {
397
+ export function acceptArtifact(
398
+ repoRoot,
399
+ workId,
400
+ artifact,
401
+ { by = "human", ts } = {},
402
+ ) {
403
+ if (!["work", "intent", "plan", "spec"].includes(artifact))
404
+ throw new DecisionError("artifact must be work, intent, plan or spec");
275
405
  const filePath = join(repoRoot, "delivery", "work", workId, `${artifact}.md`);
276
406
  if (!existsSync(filePath)) {
277
407
  throw new DecisionError(`artifact file missing: ${filePath}`);
278
408
  }
279
409
  const digest = sha256Text(readFileSync(filePath, "utf8"));
280
- const decisionsPath = join(repoRoot, "delivery", "work", workId, "decisions.jsonl");
410
+ const decisionsPath = join(
411
+ repoRoot,
412
+ "delivery",
413
+ "work",
414
+ workId,
415
+ "decisions.jsonl",
416
+ );
281
417
  const count = existsSync(decisionsPath)
282
418
  ? readFileSync(decisionsPath, "utf8").split("\n").filter(Boolean).length
283
419
  : 0;
@@ -298,19 +434,30 @@ export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts }
298
434
  // acceptance is written, and each artifact keeps its own digest-bound line.
299
435
  export function acceptArtifacts(repoRoot, workId, artifacts, options = {}) {
300
436
  const unique = [...new Set(artifacts)];
437
+ if (unique.some((name) => !["work", "intent", "plan", "spec"].includes(name)))
438
+ throw new DecisionError("artifact must be work, intent, plan or spec");
301
439
  if (unique.length === 0) {
302
440
  throw new DecisionError("no artifact to accept");
303
441
  }
304
442
  const missing = unique
305
- .map((artifact) => join(repoRoot, "delivery", "work", workId, `${artifact}.md`))
443
+ .map((artifact) =>
444
+ join(repoRoot, "delivery", "work", workId, `${artifact}.md`),
445
+ )
306
446
  .filter((filePath) => !existsSync(filePath));
307
447
  if (missing.length > 0) {
308
448
  throw new DecisionError(`artifact file missing: ${missing.join(", ")}`);
309
449
  }
310
- return unique.map((artifact) => ({ artifact, ...acceptArtifact(repoRoot, workId, artifact, options) }));
450
+ return unique.map((artifact) => ({
451
+ artifact,
452
+ ...acceptArtifact(repoRoot, workId, artifact, options),
453
+ }));
311
454
  }
312
455
 
313
- export function rejectRun(repoRoot, runId, { by = "human", transition, reason, ts } = {}) {
456
+ export function rejectRun(
457
+ repoRoot,
458
+ runId,
459
+ { by = "human", transition, reason, ts } = {},
460
+ ) {
314
461
  acquireLock(repoRoot, runId);
315
462
  try {
316
463
  const ledger = openWaiting(repoRoot, runId);
@@ -347,7 +494,10 @@ export function rejectRun(repoRoot, runId, { by = "human", transition, reason, t
347
494
  type: "RUN_TERMINAL",
348
495
  actor: KERNEL,
349
496
  ts: when,
350
- data: { status: "CANCELLED", reason: `rejected by ${by}${reason ? `: ${reason}` : ""}` },
497
+ data: {
498
+ status: "CANCELLED",
499
+ reason: `rejected by ${by}${reason ? `: ${reason}` : ""}`,
500
+ },
351
501
  });
352
502
  writeRunRecord({ repoRoot, ledger, ts: when });
353
503
  return { rejected: true, decisionRef, state: ledger.state };
@@ -373,7 +523,11 @@ export function listInbox(repoRoot) {
373
523
  continue;
374
524
  }
375
525
  const state = ledger.state;
376
- if (state.run && state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
526
+ if (
527
+ state.run &&
528
+ state.run.status === "WAITING_HUMAN" &&
529
+ state.pendingHuman
530
+ ) {
377
531
  rows.push({
378
532
  run: state.run.id,
379
533
  work: state.run.work,
@@ -109,7 +109,10 @@ export function sameIssue(a, b) {
109
109
  }
110
110
 
111
111
  export function readFindingsAccount(repoRoot, workId) {
112
- const filePath = accountPath(repoRoot, workId);
112
+ return readFindingsFile(accountPath(repoRoot, workId));
113
+ }
114
+
115
+ export function readFindingsFile(filePath) {
113
116
  if (!existsSync(filePath)) {
114
117
  return [];
115
118
  }
@@ -8,6 +8,8 @@
8
8
  // config), payloads carry identifiers and the next reply — never candidate
9
9
  // content, never logs.
10
10
 
11
+ import { payloadFor } from "../adapters/notification-payload.js";
12
+ import { currentScreenshots } from "../policy/policy.js";
11
13
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
14
  import { join } from "node:path";
13
15
 
@@ -73,6 +75,18 @@ export function subscribes(config, event) {
73
75
  return Boolean(config?.channels.some((channel) => channel.events.includes(event)));
74
76
  }
75
77
 
78
+ // Screenshot evidence recorded for a candidate: what a person deciding on a
79
+ // UI change should look at. References and digests only, never the images.
80
+ export function candidateScreenshots(state, candidate) {
81
+ if (!candidate) {
82
+ return [];
83
+ }
84
+ return currentScreenshots(state.evidence ?? [], candidate).map((item) => ({
85
+ ref: item.ref,
86
+ digest: item.digest,
87
+ }));
88
+ }
89
+
76
90
  // The exact commands a human can copy to answer a pending request. Shared by
77
91
  // status, inbox and notifications so "what do I say now" has one answer.
78
92
  export function nextReply({ repoLabel, state }) {
@@ -82,17 +96,34 @@ export function nextReply({ repoLabel, state }) {
82
96
  }
83
97
  const runId = state.run.id;
84
98
  const lines = [];
85
- if (pending.kind === "finding-triage" || pending.kind === "review-not-converging") {
86
- lines.push(`buildbeat findings list --repo ${repoLabel} --work ${state.run.work}`);
99
+ const triage = pending.kind === "finding-triage" || pending.kind === "review-not-converging";
100
+ // A ledger without frozen safeguards was written by 3.x: this runtime
101
+ // refuses to approve or resume it, so offer the spellings its original
102
+ // runtime understands (reject works on both).
103
+ if (!state.run.deliveryChecks) {
104
+ const legacy = " # legacy 3.x run: use its original 3.3.1 runtime (docs/MIGRATION.md)";
105
+ if (triage) {
106
+ lines.push(
107
+ `buildbeat findings adjudicate --repo ${repoLabel} --work ${state.run.work} --fingerprint <fp> --action accept|dismiss --by <you>${legacy}`,
108
+ );
109
+ }
87
110
  lines.push(
88
- `buildbeat findings adjudicate --repo ${repoLabel} --work ${state.run.work} --fingerprint <fp> --action accept|dismiss --by <you>`,
111
+ `buildbeat approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition} --by <you>${legacy}`,
112
+ );
113
+ lines.push(`buildbeat reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
114
+ return lines;
115
+ }
116
+ if (triage) {
117
+ lines.push(`buildbeat status --repo ${repoLabel} --work ${state.run.work}`);
118
+ lines.push(
119
+ `buildbeat decide --repo ${repoLabel} --work ${state.run.work} --fingerprint <fp> --action accept|dismiss --by <you>`,
89
120
  );
90
121
  }
91
122
  lines.push(
92
- `buildbeat approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition} --by <you>` +
93
- (pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: resume --config <run-config.yaml>"),
123
+ `buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition} --by <you>` +
124
+ (pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: run --config <run-config.yaml>"),
94
125
  );
95
- lines.push(`buildbeat reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
126
+ lines.push(`buildbeat decide --action reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
96
127
  return lines;
97
128
  }
98
129
 
@@ -108,11 +139,16 @@ export function buildNotification(kind, { repoLabel, state, detail = {} }) {
108
139
  };
109
140
  if (kind === "HUMAN_REQUESTED") {
110
141
  const pending = state.pendingHuman;
142
+ const screenshots = candidateScreenshots(
143
+ state,
144
+ pending?.subject?.candidate ?? null,
145
+ );
111
146
  return {
112
147
  ...base,
113
148
  title: `[BuildBeat] ${run.id} 等你拍板:${pending?.transition ?? "?"}`,
114
149
  reasons: pending?.reasons ?? [],
115
150
  candidate: pending?.subject?.candidate ?? null,
151
+ ...(screenshots.length > 0 ? { screenshots } : {}),
116
152
  nextReply: nextReply({ repoLabel, state }),
117
153
  };
118
154
  }
@@ -141,36 +177,6 @@ export function buildNotification(kind, { repoLabel, state, detail = {} }) {
141
177
  throw new NotifyConfigError(`unknown notification kind: ${kind}`);
142
178
  }
143
179
 
144
- function renderText(notification) {
145
- const lines = [notification.title, `work: ${notification.work} status: ${notification.status}`];
146
- for (const reason of notification.reasons ?? []) {
147
- lines.push(`- ${reason}`);
148
- }
149
- if (notification.candidate) {
150
- lines.push(`candidate: ${notification.candidate}`);
151
- }
152
- if (notification.nextReply?.length) {
153
- lines.push("next:");
154
- for (const line of notification.nextReply) {
155
- lines.push(` ${line}`);
156
- }
157
- }
158
- return lines.join("\n");
159
- }
160
-
161
- function payloadFor(channel, notification) {
162
- if (channel.type === "dingtalk") {
163
- // DingTalk custom robots require a configured keyword in the text; the
164
- // title carries it (default "BuildBeat").
165
- const text = renderText(notification);
166
- return {
167
- msgtype: "text",
168
- text: { content: text.includes(channel.keyword) ? text : `${channel.keyword}\n${text}` },
169
- };
170
- }
171
- return { ...notification, text: renderText(notification) };
172
- }
173
-
174
180
  function logLine(repoRoot, runId, line) {
175
181
  if (!runId) {
176
182
  return;