@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.
- package/CHANGELOG.md +19 -0
- package/README.en.md +20 -124
- package/README.md +20 -124
- package/SKILL.md +73 -84
- package/docs/CAPABILITY-MATRIX.md +30 -39
- package/docs/MIGRATION.md +93 -0
- package/docs/README.md +8 -37
- package/docs/RELEASING.md +4 -3
- package/docs/v2/RFC-0001-product-definition.md +3 -0
- package/docs/v2/RFC-0002-domain-model.md +3 -0
- package/docs/v2/RFC-0003-workflow-policy.md +3 -0
- package/docs/v2/SPEC-0001-events-v1.md +3 -0
- package/docs/v2/guide/00-how-to-talk.en.md +12 -58
- package/docs/v2/guide/00-how-to-talk.md +12 -58
- package/docs/v2/guide/01-quickstart.en.md +41 -112
- package/docs/v2/guide/01-quickstart.md +41 -112
- package/docs/v2/guide/02-workflow-guide.en.md +13 -123
- package/docs/v2/guide/02-workflow-guide.md +14 -122
- package/docs/v2/guide/03-policy-guide.en.md +6 -50
- package/docs/v2/guide/03-policy-guide.md +6 -50
- package/docs/v2/guide/04-adapter-guide.en.md +7 -60
- package/docs/v2/guide/04-adapter-guide.md +7 -60
- package/docs/v2/guide/05-worker-contract.en.md +6 -53
- package/docs/v2/guide/05-worker-contract.md +7 -52
- package/docs/v2/guide/06-evidence-guide.en.md +8 -44
- package/docs/v2/guide/06-evidence-guide.md +9 -43
- package/docs/v2/guide/07-approval-guide.en.md +8 -110
- package/docs/v2/guide/07-approval-guide.md +8 -121
- package/docs/v2/guide/09-security-boundaries.en.md +13 -37
- package/docs/v2/guide/09-security-boundaries.md +13 -37
- package/docs/v2/guide/10-recovery.en.md +12 -96
- package/docs/v2/guide/10-recovery.md +12 -96
- package/docs/v2/guide/11-session-handoff.en.md +7 -78
- package/docs/v2/guide/11-session-handoff.md +7 -78
- package/docs/v2/guide/12-without-runtime.en.md +46 -0
- package/docs/v2/guide/12-without-runtime.md +46 -0
- package/docs/v2/guide/README.en.md +16 -36
- package/docs/v2/guide/README.md +16 -36
- package/example/AGENTS.md +15 -69
- package/example/README.md +8 -23
- package/example/delivery/envelope/prompts/builder.md +3 -1
- package/example/delivery/envelope/prompts/fixer.md +3 -1
- package/example/delivery/envelope/prompts/reviewer.md +3 -1
- package/example//346/214/207/346/214/245/345/217/260.md +8 -40
- package/package.json +2 -1
- package/src/v2/adapters/notification-payload.js +40 -0
- package/src/v2/adapters/shell.js +3 -1
- package/src/v2/cli/run-config-check.js +129 -45
- package/src/v2/cli/run.js +821 -252
- package/src/v2/engine/reducer.js +2 -0
- package/src/v2/engine/risk-preset.js +14 -47
- package/src/v2/engine/workflow.js +177 -185
- package/src/v2/evidence/collector.js +12 -1
- package/src/v2/evidence/image.js +346 -0
- package/src/v2/policy/policy.js +219 -286
- package/src/v2/presets/software-delivery.yaml +1 -16
- package/src/v2/runtime/cache.js +22 -1
- package/src/v2/runtime/decisions.js +185 -31
- package/src/v2/runtime/findings.js +4 -1
- package/src/v2/runtime/notify.js +42 -36
- package/src/v2/runtime/orchestrator.js +811 -297
- package/src/v2/runtime/overview.js +163 -34
- package/src/v2/runtime/release.js +233 -0
- package/src/v2/runtime/run-record.js +1 -0
- package/src/v2/workspace/workspace-manager.js +8 -0
- package/templates/v2/AGENTS.md +15 -69
- package/templates/v2/BUILDBEAT.md +2 -13
- package/templates/v2/envelope/prompts/builder.md +3 -1
- package/templates/v2/envelope/prompts/fixer.md +3 -1
- package/templates/v2/envelope/prompts/reviewer.md +3 -1
- package/templates/v2/run-config.example.yaml +7 -5
- package/templates/v2/work.example.md +16 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
- package/docs/v2/skill/01-principles.md +0 -26
- package/docs/v2/skill/02-project-layout.md +0 -31
- package/docs/v2/skill/03-collaboration-rules.md +0 -45
- package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
- package/docs/v2/skill/05-red-lines.md +0 -13
- package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
- package/docs/v2/skill/07-templates-and-lessons.md +0 -24
- package/example/.buildbeat/observe.yaml +0 -31
- package/src/v2/observe/observe-config.js +0 -194
- package/src/v2/observe/observe-reducer.js +0 -117
- package/src/v2/observe/observe.js +0 -427
- package/src/v2/presets/observe.yaml +0 -45
- package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
- package/src/v2/presets/release-readback.yaml +0 -36
- package/src/v2/presets/risk/controlled.yaml +0 -39
- package/src/v2/presets/risk/fast.yaml +0 -19
- package/src/v2/presets/risk/release.yaml +0 -21
- package/src/v2/presets/risk/standard.yaml +0 -28
- package/templates/ARCHITECTURE.md +0 -50
- package/templates/contracts/PROTOCOL.md +0 -28
- package/templates/pm/adr/ADR-0000-template.md +0 -25
- package/templates/pm/adr/README.md +0 -15
- package/templates/pm/decisions.md +0 -11
- package/templates/standards/CODE.md +0 -23
- package/templates/standards/DESIGN.md +0 -36
- package/templates/standards/REVIEW.md +0 -20
- package/templates/standards/STACK.md +0 -31
package/src/v2/policy/policy.js
CHANGED
|
@@ -1,302 +1,235 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
);
|
|
265
|
-
return
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
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:
|
|
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.
|
package/src/v2/runtime/cache.js
CHANGED
|
@@ -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.
|