@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
|
@@ -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 {
|
|
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 {
|
|
18
|
+
import {
|
|
19
|
+
deliveryChecks,
|
|
20
|
+
evaluatePolicies,
|
|
21
|
+
sha256Text,
|
|
22
|
+
} from "../policy/policy.js";
|
|
13
23
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
14
|
-
import {
|
|
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(
|
|
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(
|
|
98
|
+
appendFileSync(
|
|
99
|
+
join(dir, "decisions.jsonl"),
|
|
100
|
+
`${JSON.stringify(line)}\n`,
|
|
101
|
+
"utf8",
|
|
102
|
+
);
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
export function approveRun(
|
|
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
|
|
127
|
+
const worktreePath = bound
|
|
128
|
+
? resolveRepoRef(repoRoot, bound.worktreePath)
|
|
129
|
+
: null;
|
|
75
130
|
if (!bound || !existsSync(worktreePath)) {
|
|
76
|
-
throw new DecisionError(
|
|
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 =
|
|
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: [
|
|
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 {
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
316
|
+
const worktreePath = bound
|
|
317
|
+
? resolveRepoRef(repoRoot, bound.worktreePath)
|
|
318
|
+
: null;
|
|
214
319
|
if (!bound || !existsSync(worktreePath)) {
|
|
215
|
-
throw new DecisionError(
|
|
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(
|
|
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(
|
|
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 =
|
|
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: {
|
|
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 {
|
|
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(
|
|
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(
|
|
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) =>
|
|
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) => ({
|
|
450
|
+
return unique.map((artifact) => ({
|
|
451
|
+
artifact,
|
|
452
|
+
...acceptArtifact(repoRoot, workId, artifact, options),
|
|
453
|
+
}));
|
|
311
454
|
}
|
|
312
455
|
|
|
313
|
-
export function rejectRun(
|
|
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: {
|
|
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 (
|
|
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
|
-
|
|
112
|
+
return readFindingsFile(accountPath(repoRoot, workId));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readFindingsFile(filePath) {
|
|
113
116
|
if (!existsSync(filePath)) {
|
|
114
117
|
return [];
|
|
115
118
|
}
|
package/src/v2/runtime/notify.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
|
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:
|
|
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;
|