@kontourai/flow-agents 3.6.0 → 3.8.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/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +407 -51
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +80 -14
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +825 -103
- package/build/src/cli/workflow.js +301 -43
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +75 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +330 -6
- package/evals/integration/test_bundle_install.sh +318 -65
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +334 -14
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_universal_bundles.sh +44 -1
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +426 -64
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +75 -14
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +436 -18
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +746 -103
- package/src/cli/workflow.ts +288 -41
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/build-universal-bundles.ts +51 -3
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -8,9 +10,6 @@ import {
|
|
|
8
10
|
expectationsForGate,
|
|
9
11
|
lifecycleRequestMatches,
|
|
10
12
|
openGates,
|
|
11
|
-
readJson,
|
|
12
|
-
runDir,
|
|
13
|
-
sha256File,
|
|
14
13
|
type FlowGate,
|
|
15
14
|
type FlowExpectation,
|
|
16
15
|
type FlowRunState,
|
|
@@ -20,20 +19,22 @@ import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAutho
|
|
|
20
19
|
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock, type ActorStruct } from "./cli/assignment-provider.js";
|
|
21
20
|
import {
|
|
22
21
|
BUILDER_BUILD_FLOW_ID,
|
|
22
|
+
type BuilderFlowId,
|
|
23
23
|
BuilderBuildRunInputError,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
type
|
|
24
|
+
cancelBuilderFlowRun,
|
|
25
|
+
evaluateBuilderFlowRun,
|
|
26
|
+
loadBuilderFlowRun,
|
|
27
|
+
pauseBuilderFlowRun,
|
|
28
|
+
resumeBuilderFlowRun,
|
|
29
|
+
startBuilderFlowRun,
|
|
30
|
+
type BuilderFlowRunResult,
|
|
31
31
|
} from "./builder-flow-run-adapter.js";
|
|
32
32
|
|
|
33
33
|
type AnyRecord = Record<string, any>;
|
|
34
34
|
|
|
35
35
|
export interface BuilderFlowSessionInput {
|
|
36
36
|
sessionDir: string;
|
|
37
|
+
flowId?: BuilderFlowId;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export interface BuilderFlowAuthorizedLifecycleInput extends BuilderFlowSessionInput {
|
|
@@ -47,7 +48,7 @@ export interface BuilderFlowAgentLifecycleInput extends BuilderFlowSessionInput
|
|
|
47
48
|
export interface BuilderFlowSessionResult {
|
|
48
49
|
sessionDir: string;
|
|
49
50
|
projectRoot: string;
|
|
50
|
-
run:
|
|
51
|
+
run: BuilderFlowRunResult;
|
|
51
52
|
projection: AnyRecord;
|
|
52
53
|
attached: boolean;
|
|
53
54
|
}
|
|
@@ -79,22 +80,33 @@ type PreparedProjectionWrites = {
|
|
|
79
80
|
writes: Array<{ file: string; content: string }>;
|
|
80
81
|
};
|
|
81
82
|
|
|
83
|
+
type TrustBundleSnapshot = {
|
|
84
|
+
file: string;
|
|
85
|
+
raw: Buffer;
|
|
86
|
+
sha256: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
82
89
|
export async function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
83
90
|
const context = resolveSessionContext(input.sessionDir);
|
|
84
91
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
85
92
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
86
|
-
|
|
93
|
+
const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
|
|
94
|
+
let run: BuilderFlowRunResult;
|
|
87
95
|
try {
|
|
88
|
-
run = await
|
|
96
|
+
run = await loadBuilderFlowRun({
|
|
89
97
|
cwd: context.projectRoot,
|
|
90
98
|
runId: context.slug,
|
|
91
99
|
});
|
|
100
|
+
if (run.definitionId !== requestedFlowId) {
|
|
101
|
+
throw new BuilderBuildRunInputError("flowId", `requested ${requestedFlowId} does not match the existing ${run.definitionId} run; start builder.build from a provider Work Item instead of retrying a local shape session`);
|
|
102
|
+
}
|
|
92
103
|
} catch (error) {
|
|
93
104
|
if (!isRunNotFound(error)) throw error;
|
|
94
|
-
run = await
|
|
105
|
+
run = await startBuilderFlowRun({
|
|
95
106
|
cwd: context.projectRoot,
|
|
96
107
|
runId: context.slug,
|
|
97
108
|
subject,
|
|
109
|
+
flowId: requestedFlowId,
|
|
98
110
|
params: {
|
|
99
111
|
subject,
|
|
100
112
|
},
|
|
@@ -108,7 +120,7 @@ export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Pr
|
|
|
108
120
|
const context = resolveSessionContext(input.sessionDir);
|
|
109
121
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
110
122
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
111
|
-
const run = await
|
|
123
|
+
const run = await loadBuilderFlowRun({
|
|
112
124
|
cwd: context.projectRoot,
|
|
113
125
|
runId: context.slug,
|
|
114
126
|
});
|
|
@@ -120,7 +132,7 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
|
|
|
120
132
|
const context = resolveSessionContext(input.sessionDir);
|
|
121
133
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
122
134
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
123
|
-
const run = await
|
|
135
|
+
const run = await loadBuilderFlowRun({
|
|
124
136
|
cwd: context.projectRoot,
|
|
125
137
|
runId: context.slug,
|
|
126
138
|
});
|
|
@@ -136,6 +148,21 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
|
|
|
136
148
|
};
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
export async function inspectBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
152
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
153
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
154
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
155
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
156
|
+
assertRunSubjectBinding(run, subject);
|
|
157
|
+
return {
|
|
158
|
+
sessionDir: context.sessionDir,
|
|
159
|
+
projectRoot: context.projectRoot,
|
|
160
|
+
run,
|
|
161
|
+
projection: projectFlowRun(context, run, sidecarSnapshot.state),
|
|
162
|
+
attached: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
export async function pauseBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
|
|
140
167
|
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
141
168
|
}
|
|
@@ -149,7 +176,7 @@ export async function cancelBuilderFlowSession(input: BuilderFlowAuthorizedLifec
|
|
|
149
176
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
150
177
|
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
151
178
|
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
152
|
-
const changed = await
|
|
179
|
+
const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
153
180
|
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
154
181
|
actorKey: prepared.authorization.assignment_actor_key,
|
|
155
182
|
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
@@ -166,7 +193,7 @@ export async function releaseBuilderFlowAssignment(input: BuilderFlowAgentLifecy
|
|
|
166
193
|
const context = resolveSessionContext(input.sessionDir);
|
|
167
194
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
168
195
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
169
|
-
const run = await
|
|
196
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
170
197
|
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
171
198
|
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
172
199
|
});
|
|
@@ -179,7 +206,7 @@ export async function archiveBuilderFlowSession(input: BuilderFlowAuthorizedLife
|
|
|
179
206
|
const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
|
|
180
207
|
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
181
208
|
if (priorConsumption && !recoveringPreparedArchive) throw new Error("lifecycle authorization nonce has already been consumed");
|
|
182
|
-
const run = await
|
|
209
|
+
const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
183
210
|
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
184
211
|
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
185
212
|
}
|
|
@@ -223,7 +250,7 @@ async function changeBuilderFlowSessionLifecycle(
|
|
|
223
250
|
const context = resolveSessionContext(input.sessionDir);
|
|
224
251
|
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
225
252
|
const prepared = prepareAgentLifecycleChange(input, context);
|
|
226
|
-
const change = operation === "pause" ?
|
|
253
|
+
const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
|
|
227
254
|
const at = new Date().toISOString();
|
|
228
255
|
const run = await change({
|
|
229
256
|
cwd: context.projectRoot,
|
|
@@ -254,7 +281,7 @@ async function prepareAuthorizedLifecycleChange(input: BuilderFlowAuthorizedLife
|
|
|
254
281
|
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
255
282
|
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")) as AnyRecord)
|
|
256
283
|
: null;
|
|
257
|
-
const canonicalRun = await
|
|
284
|
+
const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
258
285
|
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
259
286
|
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
260
287
|
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
@@ -325,21 +352,9 @@ function clearCurrentPointers(artifactRoot: string, slug: string): void {
|
|
|
325
352
|
}
|
|
326
353
|
}
|
|
327
354
|
|
|
328
|
-
export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
|
|
329
|
-
let context: SessionContext;
|
|
330
|
-
try {
|
|
331
|
-
context = resolveSessionContext(sessionDir);
|
|
332
|
-
} catch (error) {
|
|
333
|
-
if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir") return null;
|
|
334
|
-
throw error;
|
|
335
|
-
}
|
|
336
|
-
if (!fs.existsSync(runDir(context.slug, context.projectRoot))) return null;
|
|
337
|
-
return syncBuilderFlowSession({ sessionDir });
|
|
338
|
-
}
|
|
339
|
-
|
|
340
355
|
async function syncAndProject(
|
|
341
356
|
context: SessionContext,
|
|
342
|
-
initial:
|
|
357
|
+
initial: BuilderFlowRunResult,
|
|
343
358
|
sidecarSnapshot: SidecarSnapshot,
|
|
344
359
|
): Promise<BuilderFlowSessionResult> {
|
|
345
360
|
let run = initial;
|
|
@@ -349,26 +364,47 @@ async function syncAndProject(
|
|
|
349
364
|
throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
|
|
350
365
|
}
|
|
351
366
|
if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
367
|
+
const snapshot = stageTrustBundleSnapshot(context);
|
|
368
|
+
try {
|
|
369
|
+
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
370
|
+
const gateEvidence = await bundleGateEvidence(
|
|
371
|
+
rawBundle,
|
|
372
|
+
gates[0]!,
|
|
373
|
+
run.state,
|
|
374
|
+
run.state.subject,
|
|
375
|
+
context.projectRoot,
|
|
376
|
+
manifestEvidence(run.manifest),
|
|
358
377
|
);
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
378
|
+
if (gateEvidence) {
|
|
379
|
+
const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
|
|
380
|
+
entry.gate_id === gates[0]!.id
|
|
381
|
+
&& entry.sha256 === snapshot.sha256
|
|
382
|
+
&& typeof entry.superseded_by !== "string"
|
|
383
|
+
&& timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
|
|
384
|
+
&& gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId))
|
|
385
|
+
);
|
|
386
|
+
if (!alreadyAttached) {
|
|
387
|
+
const supersede = manifestEvidence(run.manifest)
|
|
388
|
+
.filter((entry) => entry.gate_id === gates[0]!.id && typeof entry.superseded_by !== "string")
|
|
389
|
+
.map((entry) => String(entry.id));
|
|
390
|
+
run = await evaluateBuilderFlowRun({
|
|
391
|
+
cwd: context.projectRoot,
|
|
392
|
+
runId: context.slug,
|
|
393
|
+
evidence: {
|
|
394
|
+
gate: gates[0]!.id,
|
|
395
|
+
file: path.relative(context.projectRoot, snapshot.file),
|
|
396
|
+
expectedSha256: snapshot.sha256,
|
|
397
|
+
...(supersede.length > 0 ? { supersede } : {}),
|
|
398
|
+
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
399
|
+
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
400
|
+
expectationIds: gateEvidence.expectationIds,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
attached = true;
|
|
404
|
+
}
|
|
371
405
|
}
|
|
406
|
+
} finally {
|
|
407
|
+
removeTrustBundleSnapshot(snapshot);
|
|
372
408
|
}
|
|
373
409
|
}
|
|
374
410
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
@@ -382,7 +418,7 @@ async function syncAndProject(
|
|
|
382
418
|
};
|
|
383
419
|
}
|
|
384
420
|
|
|
385
|
-
function assertRunSubjectBinding(run:
|
|
421
|
+
function assertRunSubjectBinding(run: BuilderFlowRunResult, subject: string): void {
|
|
386
422
|
if (run.state.subject !== subject) {
|
|
387
423
|
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
388
424
|
}
|
|
@@ -441,28 +477,85 @@ function workflowSubject(state: AnyRecord): string {
|
|
|
441
477
|
return refs[0]!;
|
|
442
478
|
}
|
|
443
479
|
|
|
444
|
-
function
|
|
480
|
+
function persistedFlowId(state: AnyRecord): BuilderFlowId | null {
|
|
481
|
+
const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
|
|
482
|
+
const flowId = flowRun?.definition_id;
|
|
483
|
+
return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: string }> {
|
|
445
487
|
return openGates(
|
|
446
488
|
JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")),
|
|
447
489
|
run.state,
|
|
448
490
|
) as Array<FlowGate & { id: string }>;
|
|
449
491
|
}
|
|
450
492
|
|
|
451
|
-
function bundleGateEvidence(
|
|
493
|
+
async function bundleGateEvidence(
|
|
494
|
+
bundle: unknown,
|
|
495
|
+
gate: FlowGate,
|
|
496
|
+
state: FlowRunState,
|
|
497
|
+
subject: string,
|
|
498
|
+
projectRoot: string,
|
|
499
|
+
manifest: AnyRecord[],
|
|
500
|
+
): Promise<{ failed: boolean; routeReason: string | null; expectationIds: string[]; visitEnteredAt: number } | null> {
|
|
452
501
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
|
|
453
|
-
const
|
|
502
|
+
const expectations = expectationsForGate(gate) as FlowExpectation[];
|
|
503
|
+
const visit = currentGateVisit(state, String((gate as AnyRecord).step));
|
|
504
|
+
const enteredAt = visit.enteredAt;
|
|
505
|
+
const synchronizedAt = Date.now();
|
|
506
|
+
const maxClockSkewMs = 30_000;
|
|
507
|
+
const priorVisitClaimIds = new Set<string>();
|
|
508
|
+
const priorVisitEvidenceIds = new Set<string>();
|
|
509
|
+
for (const entry of manifest) {
|
|
510
|
+
if (entry.gate_id !== String((gate as AnyRecord).id)) continue;
|
|
511
|
+
const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
|
|
512
|
+
for (const historical of claims) {
|
|
513
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitClaimIds.add(historical.id);
|
|
514
|
+
}
|
|
515
|
+
const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
|
|
516
|
+
for (const historical of evidence) {
|
|
517
|
+
if (isRecord(historical) && typeof historical.id === "string") priorVisitEvidenceIds.add(historical.id);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const claimIsCurrent = (claim: AnyRecord): boolean => {
|
|
521
|
+
if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id)) return false;
|
|
522
|
+
const timestamps: number[] = [];
|
|
523
|
+
const createdAt = parseTimestamp(claim.createdAt);
|
|
524
|
+
if (createdAt !== null) timestamps.push(createdAt);
|
|
525
|
+
if (Array.isArray((bundle as AnyRecord).evidence)) for (const evidence of (bundle as AnyRecord).evidence) {
|
|
526
|
+
if (!isRecord(evidence) || evidence.claimId !== claim.id) continue;
|
|
527
|
+
if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id)) return false;
|
|
528
|
+
const observedAt = parseTimestamp(evidence.observedAt);
|
|
529
|
+
if (observedAt !== null) timestamps.push(observedAt);
|
|
530
|
+
}
|
|
531
|
+
const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
|
|
532
|
+
return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
|
|
533
|
+
&& timestamp <= synchronizedAt + maxClockSkewMs);
|
|
534
|
+
};
|
|
454
535
|
const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
|
|
455
536
|
if (!isRecord(claim)) return false;
|
|
456
|
-
|
|
537
|
+
if (claim.producerStatus === "superseded") return false;
|
|
538
|
+
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
539
|
+
if (metadata && typeof metadata.superseded_by === "string") return false;
|
|
540
|
+
return expectations.some((expectation) => {
|
|
541
|
+
const candidate = expectation.bundle_claim;
|
|
542
|
+
return candidate
|
|
543
|
+
&& claimIsCurrent(claim)
|
|
544
|
+
&&
|
|
457
545
|
candidate.claimType === claim.claimType
|
|
458
546
|
&& (!candidate.subjectType || candidate.subjectType === claim.subjectType)
|
|
459
|
-
);
|
|
547
|
+
});
|
|
460
548
|
});
|
|
461
549
|
if (relevant.length === 0) return null;
|
|
462
550
|
if (relevant.some((claim) => workflowSubjectRef(claim) !== subject)) {
|
|
463
551
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
|
|
464
552
|
}
|
|
465
553
|
const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
|
|
554
|
+
const expectationIds = expectations.filter((expectation) => relevant.some((claim: AnyRecord) => {
|
|
555
|
+
const selector = expectation.bundle_claim;
|
|
556
|
+
return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
|
|
557
|
+
})).map((expectation) => expectation.id);
|
|
558
|
+
const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
|
|
466
559
|
const routeReasons = [...new Set(relevant.flatMap((claim) => {
|
|
467
560
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
468
561
|
const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
|
|
@@ -472,6 +565,11 @@ function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): {
|
|
|
472
565
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
|
|
473
566
|
}
|
|
474
567
|
const routeReason = routeReasons[0] ?? null;
|
|
568
|
+
if (failed && !routeReason) return null;
|
|
569
|
+
// Passing evidence waits for the complete expectation set. A failing
|
|
570
|
+
// snapshot is complete only when a gate producer explicitly declares its
|
|
571
|
+
// route reason; report-only disputed critique state remains pending.
|
|
572
|
+
if (!failed && missingRequired.length > 0) return null;
|
|
475
573
|
if (routeReason && !failed) {
|
|
476
574
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
|
|
477
575
|
}
|
|
@@ -479,7 +577,271 @@ function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): {
|
|
|
479
577
|
if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
|
|
480
578
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String((gate as AnyRecord).id ?? "<unknown>")}`);
|
|
481
579
|
}
|
|
482
|
-
|
|
580
|
+
if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
581
|
+
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
582
|
+
}
|
|
583
|
+
return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function currentGateVisit(state: FlowRunState, step: string): { enteredAt: number; initial: boolean } {
|
|
587
|
+
let enteredAt: number | null = null;
|
|
588
|
+
for (const transition of state.transitions ?? []) {
|
|
589
|
+
if (transition.to_step !== step) continue;
|
|
590
|
+
const parsed = parseTimestamp(transition.at);
|
|
591
|
+
if (parsed !== null) enteredAt = parsed;
|
|
592
|
+
}
|
|
593
|
+
const initial = parseTimestamp(state.updated_at);
|
|
594
|
+
if (enteredAt !== null) return { enteredAt, initial: false };
|
|
595
|
+
if (initial !== null) return { enteredAt: initial, initial: true };
|
|
596
|
+
throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseTimestamp(value: unknown): number | null {
|
|
600
|
+
if (typeof value !== "string") return null;
|
|
601
|
+
const parsed = Date.parse(value);
|
|
602
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function timestampAtOrAfter(value: unknown, boundary: number): boolean {
|
|
606
|
+
const parsed = parseTimestamp(value);
|
|
607
|
+
return parsed !== null && parsed >= boundary;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
|
|
611
|
+
const testClaims = claims.filter((claim): claim is AnyRecord => isRecord(claim)
|
|
612
|
+
&& claim.claimType === "builder.verify.tests"
|
|
613
|
+
&& claim.value === "pass"
|
|
614
|
+
&& isRecord(claim.metadata)
|
|
615
|
+
&& isRecord(claim.metadata.gate_claim)
|
|
616
|
+
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
617
|
+
if (testClaims.length === 0) throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
618
|
+
const liveCritiques = claims.filter((claim): claim is AnyRecord => isRecord(claim)
|
|
619
|
+
&& isRecord(claim.metadata)
|
|
620
|
+
&& claim.metadata.origin === "critique"
|
|
621
|
+
&& typeof claim.metadata.superseded_by !== "string");
|
|
622
|
+
if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
|
|
623
|
+
throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
|
|
624
|
+
}
|
|
625
|
+
const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor): actor is string => typeof actor === "string" && actor.length > 0));
|
|
626
|
+
if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
|
|
627
|
+
throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
|
|
628
|
+
}
|
|
629
|
+
await Promise.all(liveCritiques.flatMap(async (claim) => {
|
|
630
|
+
const artifacts = reviewedArtifacts(claim);
|
|
631
|
+
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
632
|
+
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
633
|
+
}));
|
|
634
|
+
const criteria = claims.filter((claim): claim is AnyRecord => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
635
|
+
if (criteria.length === 0 || criteria.some((claim) => {
|
|
636
|
+
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
637
|
+
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
638
|
+
})) {
|
|
639
|
+
throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
|
|
640
|
+
}
|
|
641
|
+
for (const testClaim of testClaims) assertObservedTestsEvidence(testClaim, criteria);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function assertObservedTestsEvidence(testClaim: AnyRecord, criteria: AnyRecord[]): void {
|
|
645
|
+
const observed = testClaim.metadata.observed_commands;
|
|
646
|
+
if (!Array.isArray(observed) || observed.length === 0) {
|
|
647
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
|
|
648
|
+
}
|
|
649
|
+
const commands = new Set<string>();
|
|
650
|
+
for (const entry of observed) {
|
|
651
|
+
if (!isRecord(entry) || typeof entry.command !== "string" || entry.exit_code !== 0 || !Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256) || commands.has(entry.command)) {
|
|
652
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain unique commands with exit_code 0, a positive executed-test count, and SHA-256 output digests");
|
|
653
|
+
}
|
|
654
|
+
commands.add(entry.command);
|
|
655
|
+
}
|
|
656
|
+
const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
|
|
657
|
+
const topCommands = new Set(topRefs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
|
|
658
|
+
if ([...commands].some((command) => !topCommands.has(command))) {
|
|
659
|
+
throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
|
|
660
|
+
}
|
|
661
|
+
const criterionCommands = new Set<string>();
|
|
662
|
+
for (const claim of criteria) {
|
|
663
|
+
const criterion = claim.metadata.criterion as AnyRecord;
|
|
664
|
+
const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
|
|
665
|
+
const matched = refs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
|
|
666
|
+
if (matched.length === 0) throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
|
|
667
|
+
matched.forEach((command) => criterionCommands.add(command));
|
|
668
|
+
}
|
|
669
|
+
if ([...commands].some((command) => !criterionCommands.has(command))) {
|
|
670
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function isSubstantivePassingCritique(claim: AnyRecord): boolean {
|
|
675
|
+
if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding: unknown) => isRecord(finding) && finding.status === "open"))) return false;
|
|
676
|
+
const lanes = claim.metadata.lanes;
|
|
677
|
+
return Array.isArray(lanes)
|
|
678
|
+
&& lanes.length > 0
|
|
679
|
+
&& lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
|
|
680
|
+
&& reviewedArtifacts(claim).length > 0;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function reviewedArtifacts(claim: AnyRecord): AnyRecord[] {
|
|
684
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
685
|
+
const artifacts = reviewTarget?.artifacts;
|
|
686
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) return [];
|
|
687
|
+
if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256))) return [];
|
|
688
|
+
return artifacts as AnyRecord[];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function assertReviewedWorkspaceSnapshot(claim: AnyRecord, artifacts: AnyRecord[], projectRoot: string): void {
|
|
692
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
693
|
+
const expected = reviewTarget?.workspace_snapshot;
|
|
694
|
+
if (!isRecord(expected)) {
|
|
695
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
|
|
696
|
+
}
|
|
697
|
+
const current = captureReviewWorkspaceSnapshot(
|
|
698
|
+
projectRoot,
|
|
699
|
+
expected.kind === "reviewed-files"
|
|
700
|
+
? reviewedWorkspaceFiles(expected)
|
|
701
|
+
: artifacts.map((artifact) => ({ file: artifact.file as string, sha256: artifact.sha256 as string })),
|
|
702
|
+
);
|
|
703
|
+
if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
|
|
704
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
|
|
705
|
+
}
|
|
706
|
+
if (expected.digest !== current.digest) {
|
|
707
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
|
|
708
|
+
}
|
|
709
|
+
if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
|
|
710
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
|
|
711
|
+
}
|
|
712
|
+
if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
|
|
713
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function gitWorktreeSnapshot(projectRoot: string): AnyRecord | null {
|
|
718
|
+
const root = fs.realpathSync(projectRoot);
|
|
719
|
+
const hasGitMarker = fs.existsSync(path.join(root, ".git"));
|
|
720
|
+
try {
|
|
721
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
722
|
+
if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
|
|
723
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
|
|
724
|
+
}
|
|
725
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
726
|
+
const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
|
|
727
|
+
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
|
|
728
|
+
.toString("utf8").split("\0").filter(Boolean).sort();
|
|
729
|
+
const hash = createHash("sha256");
|
|
730
|
+
hash.update("flow-agents:git-worktree:v1\0");
|
|
731
|
+
hash.update(headSha).update("\0");
|
|
732
|
+
hash.update(trackedDiff).update("\0");
|
|
733
|
+
for (const file of untracked) {
|
|
734
|
+
const absolute = path.resolve(root, file);
|
|
735
|
+
if (!pathIsWithin(absolute, root)) throw new Error("untracked file escapes repository root");
|
|
736
|
+
const stat = fs.lstatSync(absolute);
|
|
737
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("untracked entry is not a regular file");
|
|
738
|
+
hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
739
|
+
}
|
|
740
|
+
return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
|
|
741
|
+
} catch (error) {
|
|
742
|
+
if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
|
|
743
|
+
if (error instanceof BuilderBuildRunInputError) throw error;
|
|
744
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function captureReviewWorkspaceSnapshot(
|
|
751
|
+
projectRoot: string,
|
|
752
|
+
reviewedFiles: Array<{ file: string; sha256: string }>,
|
|
753
|
+
): AnyRecord {
|
|
754
|
+
return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function reviewedWorkspaceFiles(snapshot: AnyRecord): Array<{ file: string; sha256: string }> {
|
|
758
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
|
|
759
|
+
|| !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
|
|
760
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
|
|
761
|
+
}
|
|
762
|
+
const files = snapshot.files.map((file) => ({ file: file.file as string, sha256: file.sha256 as string })).sort((left, right) => left.file.localeCompare(right.file));
|
|
763
|
+
if (new Set(files.map((file) => file.file)).size !== files.length) {
|
|
764
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
|
|
765
|
+
}
|
|
766
|
+
return files;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function reviewedFilesSnapshot(projectRoot: string, reviewedFiles: Array<{ file: string; sha256: string }>): AnyRecord {
|
|
770
|
+
const files = reviewedFiles.map((file) => ({ ...file }));
|
|
771
|
+
const hash = createHash("sha256");
|
|
772
|
+
hash.update("flow-agents:reviewed-files:v1\0");
|
|
773
|
+
for (const artifact of files) {
|
|
774
|
+
const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
775
|
+
hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
776
|
+
}
|
|
777
|
+
return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function assertReviewedArtifactDigest(artifact: AnyRecord, projectRoot: string): Promise<void> {
|
|
781
|
+
const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
782
|
+
if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
|
|
783
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function safeReviewedArtifactPath(projectRoot: string, file: string): string {
|
|
788
|
+
const canonicalRoot = fs.realpathSync(projectRoot);
|
|
789
|
+
const candidate = path.resolve(canonicalRoot, file);
|
|
790
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
791
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
792
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
|
|
793
|
+
}
|
|
794
|
+
let canonicalArtifact: string;
|
|
795
|
+
try {
|
|
796
|
+
canonicalArtifact = fs.realpathSync(candidate);
|
|
797
|
+
} catch {
|
|
798
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
|
|
799
|
+
}
|
|
800
|
+
const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
|
|
801
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
|
|
802
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
|
|
803
|
+
}
|
|
804
|
+
return canonicalArtifact;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function stageTrustBundleSnapshot(context: SessionContext): TrustBundleSnapshot {
|
|
808
|
+
assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
|
|
809
|
+
const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
810
|
+
let raw: Buffer;
|
|
811
|
+
try {
|
|
812
|
+
const stat = fs.fstatSync(source);
|
|
813
|
+
if (!stat.isFile()) throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
|
|
814
|
+
raw = fs.readFileSync(source);
|
|
815
|
+
} finally {
|
|
816
|
+
fs.closeSync(source);
|
|
817
|
+
}
|
|
818
|
+
const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
|
|
819
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
820
|
+
assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
|
|
821
|
+
fs.chmodSync(directory, 0o700);
|
|
822
|
+
const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
|
|
823
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
824
|
+
try {
|
|
825
|
+
fs.writeFileSync(descriptor, raw);
|
|
826
|
+
fs.fsyncSync(descriptor);
|
|
827
|
+
} finally {
|
|
828
|
+
fs.closeSync(descriptor);
|
|
829
|
+
}
|
|
830
|
+
fs.chmodSync(file, 0o400);
|
|
831
|
+
return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function removeTrustBundleSnapshot(snapshot: TrustBundleSnapshot): void {
|
|
835
|
+
try {
|
|
836
|
+
fs.unlinkSync(snapshot.file);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (!isRecord(error) || error.code !== "ENOENT") throw error;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
fs.rmdirSync(path.dirname(snapshot.file));
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY")) throw error;
|
|
844
|
+
}
|
|
483
845
|
}
|
|
484
846
|
|
|
485
847
|
function workflowSubjectRef(claim: AnyRecord): string | null {
|
|
@@ -493,13 +855,13 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
|
|
|
493
855
|
return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
|
|
494
856
|
}
|
|
495
857
|
|
|
496
|
-
function projectFlowRun(context: SessionContext, run:
|
|
858
|
+
function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, sidecar: AnyRecord): AnyRecord {
|
|
497
859
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
498
860
|
const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
|
|
499
861
|
const complete = run.state.status === "completed";
|
|
500
862
|
const paused = run.state.status === "paused";
|
|
501
863
|
const canceled = run.state.status === "canceled";
|
|
502
|
-
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
|
|
864
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
503
865
|
if (!action) {
|
|
504
866
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
505
867
|
}
|
|
@@ -606,7 +968,7 @@ function prepareProjectionWrites(
|
|
|
606
968
|
if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
|
|
607
969
|
const output = {
|
|
608
970
|
...pointer,
|
|
609
|
-
active_flow_id:
|
|
971
|
+
active_flow_id: projection.flow_run.definition_id,
|
|
610
972
|
active_step_id: projection.flow_run.current_step,
|
|
611
973
|
updated_at: projection.updated_at,
|
|
612
974
|
};
|
|
@@ -713,12 +1075,12 @@ function writeExistingFileNoFollow(file: string, content: string): void {
|
|
|
713
1075
|
}
|
|
714
1076
|
}
|
|
715
1077
|
|
|
716
|
-
function stepAction(stepId: string): { skills: string[]; operations: string[] } | null {
|
|
1078
|
+
function stepAction(flowId: BuilderFlowId, stepId: string): { skills: string[]; operations: string[] } | null {
|
|
717
1079
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
|
|
718
1080
|
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
719
1081
|
const action = actions.find((candidate: unknown) =>
|
|
720
1082
|
isRecord(candidate)
|
|
721
|
-
&& candidate.flow_id ===
|
|
1083
|
+
&& candidate.flow_id === flowId
|
|
722
1084
|
&& candidate.step_id === stepId
|
|
723
1085
|
);
|
|
724
1086
|
if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill: unknown) => typeof skill === "string")) {
|