@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
@@ -0,0 +1,346 @@
1
+ // Screenshot files (requireScreenshot). Only PNG is accepted, checked to
2
+ // the point where every pixel can be reconstructed, without a dependency:
3
+ // the chunk layout a decoder relies on (one IHDR first, at most one PLTE
4
+ // before the image data, consecutive IDAT, IEND last, no unknown critical
5
+ // chunk, every CRC), a legal header and palette, and image data that
6
+ // inflates to exactly the scanlines the header declares (Adam7 passes
7
+ // included), each with a valid filter that reconstructs, palette indices
8
+ // inside the palette. Owner decision 2026-10-04: JPEG and WebP cannot be
9
+ // checked that far without a decoder, and headless browser screenshots are
10
+ // PNG by default. A decodable image still proves only a whole picture, not
11
+ // the right page: the reviewer and the person deciding judge the content.
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { lstatSync, readFileSync } from "node:fs";
15
+ import { basename } from "node:path";
16
+ import { inflateSync } from "node:zlib";
17
+
18
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
19
+ // Colour type -> channels and the bit depths the PNG specification allows.
20
+ const PNG_COLOR = {
21
+ 0: { channels: 1, depths: [1, 2, 4, 8, 16] },
22
+ 2: { channels: 3, depths: [8, 16] },
23
+ 3: { channels: 1, depths: [1, 2, 4, 8] },
24
+ 4: { channels: 2, depths: [8, 16] },
25
+ 6: { channels: 4, depths: [8, 16] },
26
+ };
27
+ const CRITICAL = new Set(["IHDR", "PLTE", "IDAT", "IEND"]);
28
+
29
+ // Ancillary chunks that change how pixels decode or display, checked
30
+ // against the specification's sizes, values and placement (at most once,
31
+ // before the image data; `afterPalette` ones after PLTE, `beforePalette`
32
+ // ones before it). Other ancillary chunks (text, compressed text, time,
33
+ // metadata) are not checked: some decoders parse them, and differences
34
+ // between decoders are outside this check (owner decision, review round 6).
35
+ function ancillaryValid(type, body, header, paletteSize, seenPalette) {
36
+ const sizeFor = (byColor) => byColor[header.color];
37
+ switch (type) {
38
+ case "tRNS":
39
+ if (header.color === 3) {
40
+ return seenPalette && body.length >= 1 && body.length <= paletteSize;
41
+ }
42
+ return body.length === sizeFor({ 0: 2, 2: 6 });
43
+ case "gAMA":
44
+ return !seenPalette && body.length === 4 && body.readUInt32BE(0) > 0;
45
+ case "cHRM":
46
+ return !seenPalette && body.length === 32;
47
+ case "sRGB":
48
+ return !seenPalette && body.length === 1 && body[0] <= 3;
49
+ case "sBIT":
50
+ return !seenPalette && body.length === sizeFor({ 0: 1, 2: 3, 3: 3, 4: 2, 6: 4 });
51
+ case "iCCP": {
52
+ const nul = body.indexOf(0);
53
+ if (seenPalette || nul < 1 || nul > 79 || body[nul + 1] !== 0) {
54
+ return false;
55
+ }
56
+ try {
57
+ return inflateSync(body.subarray(nul + 2), { maxOutputLength: 16 * 1024 * 1024 }).length > 0;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ case "bKGD":
63
+ if (header.color === 3) {
64
+ return seenPalette && body.length === 1 && body[0] < paletteSize;
65
+ }
66
+ return body.length === sizeFor({ 0: 2, 4: 2, 2: 6, 6: 6 });
67
+ case "hIST":
68
+ return seenPalette && body.length === 2 * paletteSize;
69
+ case "pHYs":
70
+ return body.length === 9 && body[8] <= 1;
71
+ default:
72
+ return true;
73
+ }
74
+ }
75
+ const ONCE_BEFORE_DATA = new Set(["tRNS", "gAMA", "cHRM", "sRGB", "sBIT", "iCCP", "bKGD", "hIST", "pHYs"]);
76
+ // Adam7 passes: x start, y start, x step, y step.
77
+ const ADAM7 = [
78
+ [0, 0, 8, 8],
79
+ [4, 0, 8, 8],
80
+ [0, 4, 4, 8],
81
+ [2, 0, 4, 4],
82
+ [0, 2, 2, 4],
83
+ [1, 0, 2, 2],
84
+ [0, 1, 1, 2],
85
+ ];
86
+
87
+ const CRC_TABLE = (() => {
88
+ const table = new Uint32Array(256);
89
+ for (let n = 0; n < 256; n += 1) {
90
+ let c = n;
91
+ for (let k = 0; k < 8; k += 1) {
92
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
93
+ }
94
+ table[n] = c >>> 0;
95
+ }
96
+ return table;
97
+ })();
98
+
99
+ function crc32(bytes) {
100
+ let c = 0xffffffff;
101
+ for (const byte of bytes) {
102
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
103
+ }
104
+ return (c ^ 0xffffffff) >>> 0;
105
+ }
106
+
107
+ // The images (width x height) the data holds: one for a plain image, up to
108
+ // seven reduced ones for an interlaced image.
109
+ function subImages(header) {
110
+ if (header.interlace === 0) {
111
+ return [[header.width, header.height]];
112
+ }
113
+ return ADAM7.map(([x0, y0, dx, dy]) => [
114
+ header.width > x0 ? Math.ceil((header.width - x0) / dx) : 0,
115
+ header.height > y0 ? Math.ceil((header.height - y0) / dy) : 0,
116
+ ]).filter(([w, h]) => w > 0 && h > 0);
117
+ }
118
+
119
+ function paeth(a, b, c) {
120
+ const p = a + b - c;
121
+ const pa = Math.abs(p - a);
122
+ const pb = Math.abs(p - b);
123
+ const pc = Math.abs(p - c);
124
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
125
+ }
126
+
127
+ // The exact byte length of the decompressed image data, or Infinity when
128
+ // it exceeds the screenshot size limit. Computed from the header alone, so
129
+ // nothing is allocated or inflated for an implausible declaration.
130
+ const MAX_IMAGE_BYTES = 256 * 1024 * 1024;
131
+ function expectedDataLength(header) {
132
+ const bits = header.depth * PNG_COLOR[header.color].channels;
133
+ let total = 0;
134
+ for (const [width, height] of subImages(header)) {
135
+ total += height * (1 + Math.ceil((width * bits) / 8));
136
+ if (total > MAX_IMAGE_BYTES) {
137
+ return Infinity;
138
+ }
139
+ }
140
+ return total;
141
+ }
142
+
143
+ // Reconstructs every scanline (filters 0-4) and, for an indexed image,
144
+ // checks each pixel's palette index. `raw` already has the expected length.
145
+ function scanlinesValid(raw, header, paletteSize) {
146
+ const bits = header.depth * PNG_COLOR[header.color].channels;
147
+ const bpp = Math.max(1, bits / 8);
148
+ let offset = 0;
149
+ for (const [width, height] of subImages(header)) {
150
+ const rowBytes = Math.ceil((width * bits) / 8);
151
+ let prev = Buffer.alloc(rowBytes);
152
+ for (let row = 0; row < height; row += 1) {
153
+ if (offset + 1 + rowBytes > raw.length) {
154
+ return false;
155
+ }
156
+ const filter = raw[offset];
157
+ if (filter > 4) {
158
+ return false;
159
+ }
160
+ const line = Buffer.from(raw.subarray(offset + 1, offset + 1 + rowBytes));
161
+ for (let i = 0; i < rowBytes; i += 1) {
162
+ const a = i >= bpp ? line[i - bpp] : 0;
163
+ const b = prev[i];
164
+ const c = i >= bpp ? prev[i - bpp] : 0;
165
+ const predictor =
166
+ filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : paeth(a, b, c);
167
+ line[i] = (line[i] + predictor) & 0xff;
168
+ }
169
+ if (header.color === 3) {
170
+ for (let x = 0; x < width; x += 1) {
171
+ const bit = x * header.depth;
172
+ const index =
173
+ (line[bit >> 3] >> (8 - header.depth - (bit & 7))) & ((1 << header.depth) - 1);
174
+ if (index >= paletteSize) {
175
+ return false;
176
+ }
177
+ }
178
+ }
179
+ prev = line;
180
+ offset += 1 + rowBytes;
181
+ }
182
+ }
183
+ return offset === raw.length;
184
+ }
185
+
186
+ function validPng(bytes) {
187
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
188
+ return false;
189
+ }
190
+ let offset = 8;
191
+ let header = null;
192
+ let paletteSize = 0;
193
+ let dataState = "none"; // none -> open -> closed
194
+ const seen = new Set();
195
+ const data = [];
196
+ while (offset + 12 <= bytes.length) {
197
+ const length = bytes.readUInt32BE(offset);
198
+ const end = offset + 12 + length;
199
+ if (end > bytes.length) {
200
+ return false;
201
+ }
202
+ const type = bytes.toString("latin1", offset + 4, offset + 8);
203
+ if (!/^[A-Za-z]{4}$/.test(type)) {
204
+ return false;
205
+ }
206
+ const body = bytes.subarray(offset + 8, offset + 8 + length);
207
+ if (crc32(bytes.subarray(offset + 4, offset + 8 + length)) !== bytes.readUInt32BE(offset + 8 + length)) {
208
+ return false;
209
+ }
210
+ const critical = type[0] === type[0].toUpperCase();
211
+ if (critical && !CRITICAL.has(type)) {
212
+ return false;
213
+ }
214
+ if (!header) {
215
+ if (type !== "IHDR" || length !== 13) {
216
+ return false;
217
+ }
218
+ header = {
219
+ width: body.readUInt32BE(0),
220
+ height: body.readUInt32BE(4),
221
+ depth: body[8],
222
+ color: body[9],
223
+ compression: body[10],
224
+ filter: body[11],
225
+ interlace: body[12],
226
+ };
227
+ const color = PNG_COLOR[header.color];
228
+ if (
229
+ !header.width ||
230
+ !header.height ||
231
+ header.width > 0x7fffffff ||
232
+ header.height > 0x7fffffff ||
233
+ !color ||
234
+ !color.depths.includes(header.depth) ||
235
+ header.compression !== 0 ||
236
+ header.filter !== 0 ||
237
+ header.interlace > 1
238
+ ) {
239
+ return false;
240
+ }
241
+ } else if (type === "IHDR") {
242
+ return false;
243
+ } else if (type === "PLTE") {
244
+ const entries = length / 3;
245
+ if (
246
+ paletteSize > 0 ||
247
+ dataState !== "none" ||
248
+ header.color === 0 ||
249
+ header.color === 4 ||
250
+ length % 3 !== 0 ||
251
+ entries < 1 ||
252
+ entries > 256 ||
253
+ (header.color === 3 && entries > 1 << header.depth)
254
+ ) {
255
+ return false;
256
+ }
257
+ paletteSize = entries;
258
+ } else if (type === "IDAT") {
259
+ if (dataState === "closed") {
260
+ return false;
261
+ }
262
+ dataState = "open";
263
+ data.push(body);
264
+ } else if (type === "IEND") {
265
+ if (
266
+ length !== 0 ||
267
+ end !== bytes.length ||
268
+ data.length === 0 ||
269
+ (header.color === 3 && paletteSize === 0)
270
+ ) {
271
+ return false;
272
+ }
273
+ const expected = expectedDataLength(header);
274
+ if (!Number.isFinite(expected)) {
275
+ return false;
276
+ }
277
+ let raw;
278
+ try {
279
+ // A decompression bomb stops at one byte past the declared size.
280
+ raw = inflateSync(Buffer.concat(data), { maxOutputLength: expected + 1 });
281
+ } catch {
282
+ return false;
283
+ }
284
+ return raw.length === expected && scanlinesValid(raw, header, paletteSize);
285
+ } else {
286
+ if (ONCE_BEFORE_DATA.has(type)) {
287
+ if (seen.has(type) || dataState !== "none") {
288
+ return false;
289
+ }
290
+ seen.add(type);
291
+ }
292
+ if (!ancillaryValid(type, body, header, paletteSize, paletteSize > 0)) {
293
+ return false;
294
+ }
295
+ if (dataState === "open") {
296
+ dataState = "closed";
297
+ }
298
+ }
299
+ offset = end;
300
+ }
301
+ return false;
302
+ }
303
+
304
+ const OTHER_IMAGE = /\.(jpe?g|webp|gif|bmp|tiff?|avif|heic)$/i;
305
+
306
+ // What a file in BUILDBEAT_SCREENSHOT_DIR is: a PNG to check, another image
307
+ // format to reject by name, or null for files that are not screenshots.
308
+ export function screenshotFormat(name) {
309
+ if (/\.png$/i.test(name)) {
310
+ return { label: "png", valid: validPng };
311
+ }
312
+ if (OTHER_IMAGE.test(name)) {
313
+ return { unsupported: true };
314
+ }
315
+ return null;
316
+ }
317
+
318
+ // One screenshot file on disk, judged the same way at capture, at cache
319
+ // reuse and at approval: { ok, digest } or { ok: false, reason }.
320
+ export function checkScreenshotFile(path) {
321
+ const name = basename(path);
322
+ const format = screenshotFormat(name);
323
+ if (!format) {
324
+ return { ok: false, reason: `${name} (not a screenshot)` };
325
+ }
326
+ let stat;
327
+ try {
328
+ stat = lstatSync(path);
329
+ } catch {
330
+ return { ok: false, reason: `${name} (missing)` };
331
+ }
332
+ if (!stat.isFile()) {
333
+ return { ok: false, reason: `${name} (not a regular file)` };
334
+ }
335
+ if (format.unsupported) {
336
+ return { ok: false, reason: `${name} (only PNG screenshots are accepted)` };
337
+ }
338
+ const bytes = readFileSync(path);
339
+ if (!format.valid(bytes)) {
340
+ return { ok: false, reason: `${name} (not a decodable PNG)` };
341
+ }
342
+ return {
343
+ ok: true,
344
+ digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
345
+ };
346
+ }