@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
@@ -1,302 +1,235 @@
1
- // Policy engine per docs/v2/RFC-0003-workflow-policy.md §4. Policies are
2
- // declarative YAML documents; rules evaluate under three-valued logic
3
- // (true / false / "unverified") and UNVERIFIED is never treated as PASS.
4
- // Parsing is fail-closed: unknown operators, types, or fields are rejected.
5
-
1
+ // Built-in delivery checks, not a user-programmable rule language.
2
+ // Evidence and acceptance remain candidate/digest scoped and fail closed.
6
3
  import { createHash } from "node:crypto";
7
4
  import { existsSync, readFileSync } from "node:fs";
8
5
  import { join } from "node:path";
9
-
10
- export class PolicyError extends Error {
11
- constructor(message) {
12
- super(message);
13
- this.name = "PolicyError";
14
- }
15
- }
16
-
17
- const POLICY_TYPES = ["pre", "post", "transition", "action"];
18
- const ENFORCEMENTS = ["ADVISORY", "LOCAL_ENFORCED", "SERVER_ENFORCED"];
19
- const ON_FAIL = ["WAIT_HUMAN", "BLOCK"];
20
- const POLICY_FIELDS = new Set([
21
- "kind",
22
- "version",
23
- "name",
24
- "type",
25
- "appliesTo",
26
- "enforcement",
27
- "onFail",
28
- "rule",
29
- ]);
30
- const GRADE_RANK = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 };
31
- const SEVERITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
32
-
33
- const OPERATORS = new Set([
34
- "all",
35
- "any",
36
- "not",
37
- "evidence.exists",
38
- "artifact.accepted",
39
- "attempts.lt",
40
- "budget.remaining",
41
- "candidate.clean",
42
- "human.approved",
43
- "finding.maxSeverity",
44
- ]);
45
-
46
- function validateRule(rule, path = "rule") {
47
- if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
48
- throw new PolicyError(`${path} must be a map with exactly one operator`);
49
- }
50
- const keys = Object.keys(rule);
51
- if (keys.length !== 1) {
52
- throw new PolicyError(`${path} must have exactly one operator, got: ${keys.join(", ")}`);
53
- }
54
- const op = keys[0];
55
- if (!OPERATORS.has(op)) {
56
- throw new PolicyError(`${path}: unknown operator "${op}"`);
57
- }
58
- const arg = rule[op];
59
- if (op === "all" || op === "any") {
60
- if (!Array.isArray(arg) || arg.length === 0) {
61
- throw new PolicyError(`${path}.${op} requires a non-empty list of rules`);
62
- }
63
- arg.forEach((child, index) => validateRule(child, `${path}.${op}[${index}]`));
64
- } else if (op === "not") {
65
- validateRule(arg, `${path}.not`);
66
- }
67
- return rule;
68
- }
69
-
70
- export function parsePolicyDoc(doc) {
71
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
72
- throw new PolicyError("policy document must be a map");
73
- }
74
- for (const key of Object.keys(doc)) {
75
- if (!POLICY_FIELDS.has(key)) {
76
- throw new PolicyError(`unknown field "${key}" in policy ${doc.name ?? "(unnamed)"}`);
77
- }
78
- }
79
- if (doc.kind !== "policy") {
80
- throw new PolicyError(`kind must be "policy", got: ${JSON.stringify(doc.kind)}`);
81
- }
82
- if (doc.version !== 1) {
83
- throw new PolicyError(`unsupported policy version: ${JSON.stringify(doc.version)}`);
84
- }
85
- if (typeof doc.name !== "string" || doc.name.length === 0) {
86
- throw new PolicyError("policy needs a name");
87
- }
88
- if (!POLICY_TYPES.includes(doc.type)) {
89
- throw new PolicyError(`policy ${doc.name}: type must be one of ${POLICY_TYPES.join("|")}`);
90
- }
91
- if (typeof doc.appliesTo !== "string" || doc.appliesTo.length === 0) {
92
- throw new PolicyError(`policy ${doc.name}: appliesTo is required`);
93
- }
94
- if (!ENFORCEMENTS.includes(doc.enforcement)) {
95
- throw new PolicyError(
96
- `policy ${doc.name}: enforcement must be one of ${ENFORCEMENTS.join("|")}`,
97
- );
98
- }
99
- if (doc.onFail !== undefined && !ON_FAIL.includes(doc.onFail)) {
100
- throw new PolicyError(`policy ${doc.name}: onFail must be one of ${ON_FAIL.join("|")}`);
101
- }
102
- validateRule(doc.rule, `policy ${doc.name} rule`);
103
- return {
104
- name: doc.name,
105
- type: doc.type,
106
- appliesTo: doc.appliesTo,
107
- enforcement: doc.enforcement,
108
- onFail: doc.onFail ?? "WAIT_HUMAN",
109
- rule: doc.rule,
110
- };
111
- }
112
-
113
- function combine(values, mode) {
114
- // Three-valued logic: false dominates all(), true dominates any(),
115
- // otherwise any "unverified" makes the result unverified.
116
- if (mode === "all") {
117
- if (values.some((value) => value.ok === false)) {
118
- return values.find((value) => value.ok === false);
119
- }
120
- if (values.some((value) => value.ok === "unverified")) {
121
- return values.find((value) => value.ok === "unverified");
122
- }
123
- return { ok: true, why: "all conditions hold" };
124
- }
125
- if (values.some((value) => value.ok === true)) {
126
- return { ok: true, why: "a condition holds" };
127
- }
128
- if (values.some((value) => value.ok === "unverified")) {
129
- return values.find((value) => value.ok === "unverified");
130
- }
131
- return { ok: false, why: values.map((value) => value.why).join("; ") };
132
- }
133
-
6
+ import { checkScreenshotFile } from "../evidence/image.js";
7
+ import { resolveRepoRef } from "../runtime/repo-ref.js";
8
+ import {
9
+ fingerprintFinding,
10
+ latestAdjudications,
11
+ readFindingsFile,
12
+ } from "../runtime/findings.js";
13
+ export class PolicyError extends Error {}
14
+ const GRADES = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 };
15
+ const SEVERITY = { P0: 0, P1: 1, P2: 2, P3: 3 };
134
16
  export function sha256Text(text) {
135
17
  return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
136
18
  }
137
-
138
- // ctx: { state, workDir, worktreePath, readWorktree() -> {head, dirty} | null }
139
- export function evaluateRule(rule, ctx) {
140
- const op = Object.keys(rule)[0];
141
- const arg = rule[op];
142
- switch (op) {
143
- case "all":
144
- return combine(arg.map((child) => evaluateRule(child, ctx)), "all");
145
- case "any":
146
- return combine(arg.map((child) => evaluateRule(child, ctx)), "any");
147
- case "not": {
148
- const inner = evaluateRule(arg, ctx);
149
- if (inner.ok === "unverified") {
150
- return inner;
151
- }
152
- return { ok: !inner.ok, why: `not(${inner.why})` };
153
- }
154
- case "evidence.exists": {
155
- const minRank = arg.minGrade !== undefined ? GRADE_RANK[arg.minGrade] : 0;
156
- if (minRank === undefined) {
157
- return { ok: "unverified", why: `unknown grade ${arg.minGrade}` };
158
- }
159
- // When the context names a candidate (e.g. a merge approval), only
160
- // evidence about that candidate counts — facts about superseded
161
- // candidates must not satisfy a gate for the current one.
162
- const scoped = ctx.candidate
163
- ? ctx.state.evidence.filter((item) => item.subject === ctx.candidate)
164
- : ctx.state.evidence;
165
- const hit = scoped.some(
166
- (item) =>
167
- item.kind === arg.kind &&
168
- item.status === "passed" &&
169
- (GRADE_RANK[item.grade] ?? -1) >= minRank,
170
- );
171
- return hit
172
- ? { ok: true, why: `evidence ${arg.kind} present` }
173
- : {
174
- ok: false,
175
- why: `no passed evidence of kind ${arg.kind}${arg.minGrade ? ` at grade >= ${arg.minGrade}` : ""}${ctx.candidate ? " for the current candidate" : ""}`,
176
- };
177
- }
178
- case "artifact.accepted": {
179
- const artifact = arg.artifact;
180
- const filePath = join(ctx.workDir, `${artifact}.md`);
181
- if (!existsSync(filePath)) {
182
- return { ok: "unverified", why: `artifact file missing: ${artifact}.md` };
183
- }
184
- const decisionsPath = join(ctx.workDir, "decisions.jsonl");
185
- if (!existsSync(decisionsPath)) {
186
- return { ok: false, why: `artifact ${artifact} has never been accepted` };
187
- }
188
- const accepts = readFileSync(decisionsPath, "utf8")
19
+ export function artifactAccepted(artifact, ctx) {
20
+ if (!["work", "intent", "plan", "spec"].includes(artifact))
21
+ throw new PolicyError("invalid artifact");
22
+ const file = join(ctx.workDir, `${artifact}.md`);
23
+ if (!existsSync(file))
24
+ return { ok: "unverified", why: `artifact file missing: ${artifact}.md` };
25
+ const path = join(ctx.workDir, "decisions.jsonl");
26
+ const rows = existsSync(path)
27
+ ? readFileSync(path, "utf8")
189
28
  .split("\n")
190
29
  .filter(Boolean)
191
30
  .map((line) => JSON.parse(line))
192
- .filter(
193
- (line) => line.transition === `accept-${artifact}` && line.decision === "approved",
194
- );
195
- if (accepts.length === 0) {
196
- return { ok: false, why: `artifact ${artifact} has never been accepted` };
197
- }
198
- const latest = accepts[accepts.length - 1];
199
- const currentDigest = sha256Text(readFileSync(filePath, "utf8"));
200
- if (latest.subject?.digest !== currentDigest) {
201
- return {
202
- ok: false,
203
- why: `artifact ${artifact} changed after its acceptance (stale acceptance)`,
204
- };
205
- }
206
- return { ok: true, why: `artifact ${artifact} accepted at current digest` };
207
- }
208
- case "attempts.lt": {
209
- const attempts = ctx.state.steps[arg.step]?.attempts ?? 0;
210
- return attempts < arg.max
211
- ? { ok: true, why: `${arg.step} attempts ${attempts} < ${arg.max}` }
212
- : { ok: false, why: `${arg.step} attempts ${attempts} reached ${arg.max}` };
213
- }
214
- case "budget.remaining": {
215
- const budget = ctx.state.budgets[arg.kind];
216
- if (!budget) {
217
- return { ok: true, why: `budget ${arg.kind} untouched` };
218
- }
219
- return budget.remaining > 0
220
- ? { ok: true, why: `budget ${arg.kind} remaining ${budget.remaining}` }
221
- : { ok: false, why: `budget ${arg.kind} exhausted` };
222
- }
223
- case "candidate.clean": {
224
- const tree = ctx.readWorktree();
225
- if (!tree) {
226
- return { ok: "unverified", why: "worktree not available for readback" };
227
- }
228
- return tree.dirty
229
- ? { ok: false, why: "worktree is dirty" }
230
- : { ok: true, why: "worktree is clean" };
231
- }
232
- case "human.approved": {
233
- const hit = ctx.state.approvals.some(
234
- (approval) => !approval.stale && approval.transition === arg.transition,
235
- );
236
- return hit
237
- ? { ok: true, why: `transition ${arg.transition} approved` }
238
- : { ok: false, why: `no active approval for transition ${arg.transition}` };
239
- }
240
- case "finding.maxSeverity": {
241
- const atMostRank = SEVERITY_RANK[arg.atMost];
242
- if (atMostRank === undefined) {
243
- return { ok: "unverified", why: `unknown severity ${arg.atMost}` };
244
- }
245
- // Findings against superseded candidates are history, not verdicts:
246
- // a blocked round-1 review that the fix loop already addressed must
247
- // not poison the gate for the final candidate. When the context
248
- // names a candidate, judge only reviews of that candidate.
249
- const reviews = ctx.state.evidence.filter(
250
- (item) =>
251
- item.kind === "review" && (!ctx.candidate || item.subject === ctx.candidate),
252
- );
253
- if (reviews.length === 0) {
254
- return {
255
- ok: "unverified",
256
- why: ctx.candidate
257
- ? "no review evidence for the current candidate"
258
- : "no review evidence to judge findings",
259
- };
260
- }
261
- const findings = reviews.flatMap((item) => item.findings ?? []);
262
- const tooSevere = findings.filter(
263
- (finding) => (SEVERITY_RANK[finding.severity] ?? 0) < atMostRank,
264
- );
265
- return tooSevere.length === 0
266
- ? { ok: true, why: `no findings above ${arg.atMost}` }
267
- : {
268
- ok: false,
269
- why: `${tooSevere.length} finding(s) more severe than ${arg.atMost}`,
270
- };
271
- }
272
- default:
273
- return { ok: "unverified", why: `unknown operator ${op}` };
31
+ : [];
32
+ const accepted = rows
33
+ .filter(
34
+ (row) =>
35
+ row.transition === `accept-${artifact}` && row.decision === "approved",
36
+ )
37
+ .at(-1);
38
+ if (!accepted)
39
+ return { ok: false, why: `artifact ${artifact} has never been accepted` };
40
+ return accepted.subject?.digest === sha256Text(readFileSync(file, "utf8"))
41
+ ? { ok: true, why: `artifact ${artifact} accepted at current digest` }
42
+ : {
43
+ ok: false,
44
+ why: `artifact ${artifact} changed after its acceptance (stale acceptance)`,
45
+ };
46
+ }
47
+ export function evidencePresent(
48
+ { kind = "command", minGrade = "L2", step = null },
49
+ ctx,
50
+ ) {
51
+ if (GRADES[minGrade] === undefined)
52
+ return { ok: "unverified", why: `unknown grade ${minGrade}` };
53
+ const hit = ctx.state.evidence.some(
54
+ (e) =>
55
+ e.kind === kind &&
56
+ e.status === "passed" &&
57
+ (GRADES[e.grade] ?? -1) >= GRADES[minGrade] &&
58
+ (!ctx.candidate || e.subject === ctx.candidate) &&
59
+ (!step || new RegExp(`(?:^|/)${step}-[0-9]+\\.log$`).test(e.ref ?? "")),
60
+ );
61
+ return {
62
+ ok: hit,
63
+ why: hit
64
+ ? `evidence ${kind} present`
65
+ : `no passed evidence of kind ${kind} at grade >= ${minGrade} for the current candidate`,
66
+ };
67
+ }
68
+ // The screenshot set of a candidate is what its latest passed verify left
69
+ // (screenshot evidence links to that verify through `source`); screenshots
70
+ // of earlier verifies of the same candidate are history, not requirements.
71
+ export function currentScreenshots(evidence, candidate) {
72
+ const verify = [...evidence]
73
+ .reverse()
74
+ .find(
75
+ (e) =>
76
+ e.kind === "command" &&
77
+ e.status === "passed" &&
78
+ (!candidate || e.subject === candidate) &&
79
+ /(?:^|\/)verify-[0-9]+\.log$/.test(e.ref ?? ""),
80
+ );
81
+ if (!verify) return [];
82
+ return evidence.filter(
83
+ (e) =>
84
+ e.kind === "screenshot" &&
85
+ e.status === "passed" &&
86
+ e.source === verify.ref &&
87
+ (!candidate || e.subject === candidate),
88
+ );
89
+ }
90
+ // Screenshots of the current candidate's latest verify; when the repository
91
+ // is known, each file must still hold the bytes its digest names.
92
+ export function screenshotsPresent(ctx) {
93
+ const shots = currentScreenshots(ctx.state.evidence, ctx.candidate);
94
+ if (!shots.length)
95
+ return {
96
+ ok: false,
97
+ why: "no screenshot evidence for the current candidate",
98
+ };
99
+ if (ctx.repoRoot) {
100
+ // Today's screenshot check, not only the digest: evidence recorded
101
+ // under looser rules cannot carry a merge.
102
+ const changed = shots.filter((e) => {
103
+ const checked = checkScreenshotFile(resolveRepoRef(ctx.repoRoot, e.ref));
104
+ return !checked.ok || checked.digest !== e.digest;
105
+ });
106
+ if (changed.length)
107
+ return {
108
+ ok: false,
109
+ why: `screenshot evidence no longer matches its file: ${changed.map((e) => e.ref).join(", ")}`,
110
+ };
274
111
  }
112
+ return {
113
+ ok: true,
114
+ why: `${shots.length} screenshot(s) for the current candidate`,
115
+ };
275
116
  }
276
-
277
- // Evaluates every policy of `type` whose appliesTo matches; returns rows of
278
- // { policy, result, reason, enforcement } where result is a GateResult.
279
- export function evaluatePolicies(policies, { type, appliesTo }, ctx) {
117
+ export function reviewClear(atMost, ctx) {
118
+ if (SEVERITY[atMost] === undefined)
119
+ return { ok: "unverified", why: `unknown severity ${atMost}` };
120
+ const reviews = ctx.state.evidence.filter(
121
+ (e) =>
122
+ e.kind === "review" && (!ctx.candidate || e.subject === ctx.candidate),
123
+ );
124
+ if (!reviews.length)
125
+ return {
126
+ ok: "unverified",
127
+ why: "no review evidence for the current candidate",
128
+ };
129
+ // Adjudication is current Work-level authority. A no-op fix may keep the
130
+ // candidate SHA, so an older unsuppressed review must not undo a later
131
+ // dismissal. Conversely, a later accept must reopen a formerly suppressed
132
+ // finding; historical suppression alone cannot override that decision.
133
+ const rows = ctx.workDir
134
+ ? readFindingsFile(join(ctx.workDir, "review-findings.jsonl"))
135
+ : [];
136
+ const adjudications = latestAdjudications(rows);
137
+ const severe = reviews
138
+ .flatMap((e) =>
139
+ (e.findings ?? []).filter((f) => {
140
+ const fingerprint = fingerprintFinding(f);
141
+ const verdict = adjudications.get(fingerprint);
142
+ return verdict
143
+ ? verdict.action !== "dismiss"
144
+ : !(e.suppressedFingerprints ?? []).includes(fingerprint);
145
+ }),
146
+ )
147
+ .filter((f) => (SEVERITY[f.severity] ?? 0) < SEVERITY[atMost]);
148
+ return {
149
+ ok: severe.length === 0,
150
+ why: severe.length
151
+ ? `${severe.length} finding(s) more severe than ${atMost}`
152
+ : `no findings above ${atMost}`,
153
+ };
154
+ }
155
+ export function deliveryChecks({
156
+ artifact = "plan",
157
+ requireAcceptance = true,
158
+ requireIntent = false,
159
+ maxSeverity = "P2",
160
+ requireScreenshot = false,
161
+ } = {}) {
162
+ if (
163
+ !["work", "plan"].includes(artifact) ||
164
+ !["P2", "P3"].includes(maxSeverity) ||
165
+ typeof requireScreenshot !== "boolean"
166
+ )
167
+ throw new PolicyError("invalid delivery safeguards");
280
168
  const rows = [];
281
- for (const policy of policies ?? []) {
282
- if (policy.type !== type || policy.appliesTo !== appliesTo) {
283
- continue;
284
- }
285
- const verdict = evaluateRule(policy.rule, ctx);
286
- let result;
287
- if (verdict.ok === true) {
288
- result = "PASS";
289
- } else if (verdict.ok === "unverified") {
290
- result = "UNVERIFIED";
291
- } else {
292
- result = policy.onFail;
293
- }
169
+ const add = (name, type, appliesTo, check) =>
294
170
  rows.push({
295
- policy: policy.name,
296
- result,
297
- reason: verdict.why,
298
- enforcement: policy.enforcement,
171
+ name,
172
+ type,
173
+ appliesTo,
174
+ enforcement: "LOCAL_ENFORCED",
175
+ onFail: "WAIT_HUMAN",
176
+ ...check,
299
177
  });
300
- }
178
+ if (requireIntent && artifact !== "work")
179
+ add("intent-accepted", "pre", "build", {
180
+ kind: "artifact",
181
+ artifact: "intent",
182
+ });
183
+ if (requireAcceptance)
184
+ add(`${artifact}-accepted`, "pre", "build", { kind: "artifact", artifact });
185
+ add("merge-evidence-floor", "transition", "enter-wait-merge", {
186
+ kind: "merge",
187
+ maxSeverity,
188
+ artifact,
189
+ requireAcceptance,
190
+ requireIntent,
191
+ ...(requireScreenshot ? { requireScreenshot } : {}),
192
+ });
301
193
  return rows;
302
194
  }
195
+ export function evaluatePolicies(policies, { type, appliesTo }, ctx) {
196
+ return (policies ?? [])
197
+ .filter((p) => p.type === type && p.appliesTo === appliesTo)
198
+ .map((p) => {
199
+ let v;
200
+ if (p.kind === "artifact") v = artifactAccepted(p.artifact, ctx);
201
+ else if (p.kind === "evidence")
202
+ v = evidencePresent(
203
+ { kind: p.evidenceKind, minGrade: p.minGrade },
204
+ ctx,
205
+ );
206
+ else if (p.kind === "merge") {
207
+ v = evidencePresent(
208
+ { kind: "command", minGrade: "L2", step: "verify" },
209
+ ctx,
210
+ );
211
+ if (v.ok === true && p.requireAcceptance)
212
+ v = artifactAccepted(p.artifact, ctx);
213
+ if (v.ok === true && p.requireIntent && p.artifact !== "work")
214
+ v = artifactAccepted("intent", ctx);
215
+ // UI work proves its real render: screenshots the verify step left
216
+ // for this candidate (lessons.md "静态稿拍板 → 返工螺旋").
217
+ if (v.ok === true && p.requireScreenshot) v = screenshotsPresent(ctx);
218
+ if (v.ok === true) v = reviewClear(p.maxSeverity ?? "P2", ctx);
219
+ } else
220
+ throw new PolicyError(
221
+ "custom policy rules are retired; migrate using docs/MIGRATION.md",
222
+ );
223
+ return {
224
+ policy: p.name,
225
+ result:
226
+ v.ok === true
227
+ ? "PASS"
228
+ : v.ok === "unverified"
229
+ ? "UNVERIFIED"
230
+ : (p.onFail ?? "WAIT_HUMAN"),
231
+ reason: v.why,
232
+ enforcement: p.enforcement ?? "LOCAL_ENFORCED",
233
+ };
234
+ });
235
+ }
@@ -1,20 +1,8 @@
1
- # Official software-delivery preset per docs/v2/RFC-0003-workflow-policy.md §2.
2
- # Step order defines the happy path (each step chains to the next on
3
- # "succeeded"); fix sits off-path at the end and is reached only via the
4
- # explicit failure/findings transitions.
5
1
  kind: workflow
6
2
  version: 1
7
3
  name: software-delivery
8
- entry: intent
4
+ entry: build
9
5
  steps:
10
- - id: intent
11
- worker: planner
12
- - id: spec
13
- worker: planner
14
- optional: true
15
- requiredWhen: ui-delivery
16
- - id: plan
17
- worker: planner
18
6
  - id: build
19
7
  worker: builder
20
8
  - id: verify
@@ -37,6 +25,3 @@ transitions:
37
25
  to: fix
38
26
  terminal:
39
27
  - wait-merge
40
- # Review rounds are capped per work (run config budgets.reviewRoundsPerWork,
41
- # default 6); a round whose blocking findings come back after fix, or
42
- # outnumber the last round's, stops for a human before the cap.
@@ -30,12 +30,15 @@ export function treeHash(worktreePath) {
30
30
  return git(worktreePath, ["rev-parse", "HEAD^{tree}"]);
31
31
  }
32
32
 
33
- export function cacheKey({ tree, worker, adapterSpec, adapterName, envelopeDigest }) {
33
+ export function cacheKey({ tree, worker, adapterSpec, adapterName, envelopeDigest, screenshots = false }) {
34
+ // Only a run that requires screenshots adds the field, so existing keys
35
+ // (and the evidence they point at) stay valid.
34
36
  const body = canonicalJson({
35
37
  tree,
36
38
  worker,
37
39
  adapter: adapterSpec ?? adapterName ?? null,
38
40
  envelope: envelopeDigest ?? null,
41
+ ...(screenshots ? { screenshots: true } : {}),
39
42
  });
40
43
  return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
41
44
  }
@@ -85,6 +88,24 @@ export function findReusableEvidence(repoRoot, key) {
85
88
  return best;
86
89
  }
87
90
 
91
+ // Screenshot evidence a verify recorded alongside its command evidence
92
+ // (`source`), so a reused verify carries the same screenshots.
93
+ export function linkedScreenshots(repoRoot, runId, evidenceRef) {
94
+ const path = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
95
+ if (!existsSync(path)) {
96
+ return [];
97
+ }
98
+ return EventLedger.open(path)
99
+ .events.filter(
100
+ (event) =>
101
+ event.type === "EVIDENCE_RECORDED" &&
102
+ event.data.kind === "screenshot" &&
103
+ event.data.source === evidenceRef &&
104
+ !event.data.reused,
105
+ )
106
+ .map((event) => ({ evidenceRef: event.data.evidenceRef, digest: event.data.digest }));
107
+ }
108
+
88
109
  // The most recent review evidence for this work whose subject is an ancestor
89
110
  // of (and not equal to) the current head — the anchor for an incremental
90
111
  // review. Null when there is no such review.