@kontourai/flow-agents 3.5.0 → 3.7.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/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +15 -0
- package/build/src/builder-flow-run-adapter.d.ts +32 -3
- package/build/src/builder-flow-run-adapter.js +113 -20
- package/build/src/builder-flow-runtime.d.ts +26 -3
- package/build/src/builder-flow-runtime.js +502 -49
- package/build/src/builder-lifecycle-authority.d.ts +35 -0
- package/build/src/builder-lifecycle-authority.js +219 -0
- package/build/src/cli/assignment-provider.d.ts +14 -7
- package/build/src/cli/assignment-provider.js +128 -65
- package/build/src/cli/builder-run.js +46 -9
- package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
- package/build/src/cli/workflow-sidecar.d.ts +30 -3
- package/build/src/cli/workflow-sidecar.js +825 -99
- package/build/src/cli/workflow.d.ts +2 -0
- package/build/src/cli/workflow.js +769 -0
- package/build/src/cli.js +2 -0
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.d.ts +4 -0
- package/build/src/index.js +7 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/lib/package-version.d.ts +2 -0
- package/build/src/lib/package-version.js +13 -0
- package/build/src/lib/pinned-cli-command.d.ts +6 -0
- package/build/src/lib/pinned-cli-command.js +21 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/contracts/artifact-contract.md +1 -1
- package/context/scripts/hooks/config-protection.js +8 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +79 -10
- package/docs/context-map.md +24 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +132 -0
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +78 -40
- package/docs/workflow-usage-guide.md +110 -38
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +57 -45
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +258 -55
- 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_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 +573 -0
- 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_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/schemas/builder-lifecycle-authorization.schema.json +57 -0
- package/schemas/lifecycle-authority-keys.schema.json +25 -0
- package/schemas/workflow-state.schema.json +1 -1
- package/scripts/hooks/config-protection.js +8 -1
- package/scripts/hooks/lib/config-protection-remedies.js +3 -0
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +79 -10
- package/src/builder-flow-run-adapter.ts +156 -23
- package/src/builder-flow-runtime.ts +535 -53
- package/src/builder-lifecycle-authority.ts +218 -0
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +91 -22
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +597 -8
- package/src/cli/builder-run.ts +54 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +748 -99
- package/src/cli/workflow.ts +708 -0
- package/src/cli.ts +2 -0
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +20 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/lib/package-version.ts +15 -0
- package/src/lib/pinned-cli-command.ts +23 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -1,36 +1,54 @@
|
|
|
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";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { flowAgentsPackageVersion } from "./lib/package-version.js";
|
|
8
|
+
import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
|
|
4
9
|
import {
|
|
5
10
|
expectationsForGate,
|
|
11
|
+
lifecycleRequestMatches,
|
|
6
12
|
openGates,
|
|
7
|
-
readJson,
|
|
8
|
-
runDir,
|
|
9
|
-
sha256File,
|
|
10
13
|
type FlowGate,
|
|
11
14
|
type FlowExpectation,
|
|
12
15
|
type FlowRunState,
|
|
13
16
|
type JsonObject,
|
|
14
17
|
} from "@kontourai/flow";
|
|
18
|
+
import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
|
|
19
|
+
import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock, type ActorStruct } from "./cli/assignment-provider.js";
|
|
15
20
|
import {
|
|
16
21
|
BUILDER_BUILD_FLOW_ID,
|
|
22
|
+
type BuilderFlowId,
|
|
17
23
|
BuilderBuildRunInputError,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
cancelBuilderFlowRun,
|
|
25
|
+
evaluateBuilderFlowRun,
|
|
26
|
+
loadBuilderFlowRun,
|
|
27
|
+
pauseBuilderFlowRun,
|
|
28
|
+
resumeBuilderFlowRun,
|
|
29
|
+
startBuilderFlowRun,
|
|
30
|
+
type BuilderFlowRunResult,
|
|
22
31
|
} from "./builder-flow-run-adapter.js";
|
|
23
32
|
|
|
24
33
|
type AnyRecord = Record<string, any>;
|
|
25
34
|
|
|
26
35
|
export interface BuilderFlowSessionInput {
|
|
27
36
|
sessionDir: string;
|
|
37
|
+
flowId?: BuilderFlowId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BuilderFlowAuthorizedLifecycleInput extends BuilderFlowSessionInput {
|
|
41
|
+
authorizationFile: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BuilderFlowAgentLifecycleInput extends BuilderFlowSessionInput {
|
|
45
|
+
reason: string;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
export interface BuilderFlowSessionResult {
|
|
31
49
|
sessionDir: string;
|
|
32
50
|
projectRoot: string;
|
|
33
|
-
run:
|
|
51
|
+
run: BuilderFlowRunResult;
|
|
34
52
|
projection: AnyRecord;
|
|
35
53
|
attached: boolean;
|
|
36
54
|
}
|
|
@@ -62,22 +80,33 @@ type PreparedProjectionWrites = {
|
|
|
62
80
|
writes: Array<{ file: string; content: string }>;
|
|
63
81
|
};
|
|
64
82
|
|
|
83
|
+
type TrustBundleSnapshot = {
|
|
84
|
+
file: string;
|
|
85
|
+
raw: Buffer;
|
|
86
|
+
sha256: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
65
89
|
export async function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
|
|
66
90
|
const context = resolveSessionContext(input.sessionDir);
|
|
67
91
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
68
92
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
69
|
-
|
|
93
|
+
const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
|
|
94
|
+
let run: BuilderFlowRunResult;
|
|
70
95
|
try {
|
|
71
|
-
run = await
|
|
96
|
+
run = await loadBuilderFlowRun({
|
|
72
97
|
cwd: context.projectRoot,
|
|
73
98
|
runId: context.slug,
|
|
74
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
|
+
}
|
|
75
103
|
} catch (error) {
|
|
76
104
|
if (!isRunNotFound(error)) throw error;
|
|
77
|
-
run = await
|
|
105
|
+
run = await startBuilderFlowRun({
|
|
78
106
|
cwd: context.projectRoot,
|
|
79
107
|
runId: context.slug,
|
|
80
108
|
subject,
|
|
109
|
+
flowId: requestedFlowId,
|
|
81
110
|
params: {
|
|
82
111
|
subject,
|
|
83
112
|
},
|
|
@@ -91,7 +120,7 @@ export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Pr
|
|
|
91
120
|
const context = resolveSessionContext(input.sessionDir);
|
|
92
121
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
93
122
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
94
|
-
const run = await
|
|
123
|
+
const run = await loadBuilderFlowRun({
|
|
95
124
|
cwd: context.projectRoot,
|
|
96
125
|
runId: context.slug,
|
|
97
126
|
});
|
|
@@ -103,7 +132,7 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
|
|
|
103
132
|
const context = resolveSessionContext(input.sessionDir);
|
|
104
133
|
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
105
134
|
const subject = workflowSubject(sidecarSnapshot.state);
|
|
106
|
-
const run = await
|
|
135
|
+
const run = await loadBuilderFlowRun({
|
|
107
136
|
cwd: context.projectRoot,
|
|
108
137
|
runId: context.slug,
|
|
109
138
|
});
|
|
@@ -119,21 +148,213 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
|
|
|
119
148
|
};
|
|
120
149
|
}
|
|
121
150
|
|
|
122
|
-
export async function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
|
|
166
|
+
export async function pauseBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
|
|
167
|
+
return changeBuilderFlowSessionLifecycle(input, "pause");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function resumeBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
|
|
171
|
+
return changeBuilderFlowSessionLifecycle(input, "resume");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function cancelBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & { assignmentReleased: boolean; idempotent: boolean }> {
|
|
175
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
176
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
177
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
|
|
178
|
+
assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
|
|
179
|
+
const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
|
|
180
|
+
const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
|
|
181
|
+
actorKey: prepared.authorization.assignment_actor_key,
|
|
182
|
+
reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
|
|
183
|
+
tolerateNoActiveClaim: true,
|
|
184
|
+
});
|
|
185
|
+
const projection = projectFlowRun(prepared.context, changed, prepared.sidecarSnapshot.state);
|
|
186
|
+
writeProjection(prepared.context, projection, prepared.sidecarSnapshot.raw, "cancellation projection");
|
|
187
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
188
|
+
return { sessionDir: prepared.context.sessionDir, projectRoot: prepared.context.projectRoot, run: changed, projection, attached: false, assignmentReleased: released !== null, idempotent: changed.idempotent };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function releaseBuilderFlowAssignment(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult & { assignmentReleased: boolean }> {
|
|
193
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
194
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
195
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
196
|
+
const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
197
|
+
const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
|
|
198
|
+
return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function archiveBuilderFlowSession(input: BuilderFlowAuthorizedLifecycleInput): Promise<BuilderFlowSessionResult & { archiveDir: string }> {
|
|
203
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
204
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
205
|
+
const prepared = await prepareAuthorizedLifecycleChange(input, "archive", context);
|
|
206
|
+
const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
|
|
207
|
+
const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
|
|
208
|
+
if (priorConsumption && !recoveringPreparedArchive) throw new Error("lifecycle authorization nonce has already been consumed");
|
|
209
|
+
const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
|
|
210
|
+
if (run.state.status !== "completed" && run.state.status !== "canceled") {
|
|
211
|
+
throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
|
|
212
|
+
}
|
|
213
|
+
const archiveRoot = path.join(prepared.context.artifactRoot, "archive");
|
|
214
|
+
const archiveDir = path.join(archiveRoot, prepared.context.slug);
|
|
215
|
+
if (pathExistsNoFollow(archiveDir)) throw new BuilderBuildRunInputError("archive", "destination already exists");
|
|
216
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
217
|
+
assertSafeDirectory(archiveRoot, prepared.context.artifactRoot, "archive root");
|
|
218
|
+
assertSafeDirectory(prepared.context.sessionDir, prepared.context.artifactRoot, "sessionDir");
|
|
219
|
+
if (!recoveringPreparedArchive && fs.readFileSync(prepared.context.stateFile, "utf8") !== prepared.sidecarSnapshot.raw) {
|
|
220
|
+
throw new BuilderBuildRunInputError("state.json", "changed during archive preparation");
|
|
221
|
+
}
|
|
222
|
+
const archivedState = recoveringPreparedArchive ? prepared.sidecarSnapshot.state : {
|
|
223
|
+
...prepared.sidecarSnapshot.state,
|
|
224
|
+
status: "archived",
|
|
225
|
+
phase: "done",
|
|
226
|
+
updated_at: new Date().toISOString(),
|
|
227
|
+
next_action: { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." },
|
|
228
|
+
};
|
|
229
|
+
if (!recoveringPreparedArchive) {
|
|
230
|
+
writeExistingFileNoFollow(prepared.context.stateFile, `${JSON.stringify(archivedState, null, 2)}\n`);
|
|
231
|
+
clearCurrentPointers(prepared.context.artifactRoot, prepared.context.slug);
|
|
232
|
+
recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
|
|
233
|
+
}
|
|
234
|
+
fs.renameSync(prepared.context.sessionDir, archiveDir);
|
|
235
|
+
return {
|
|
236
|
+
sessionDir: archiveDir,
|
|
237
|
+
projectRoot: prepared.context.projectRoot,
|
|
238
|
+
run,
|
|
239
|
+
projection: archivedState,
|
|
240
|
+
attached: false,
|
|
241
|
+
archiveDir,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function changeBuilderFlowSessionLifecycle(
|
|
247
|
+
input: BuilderFlowAgentLifecycleInput,
|
|
248
|
+
operation: "pause" | "resume",
|
|
249
|
+
): Promise<BuilderFlowSessionResult> {
|
|
250
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
251
|
+
return await withSubjectLock(context.artifactRoot, context.slug, async () => {
|
|
252
|
+
const prepared = prepareAgentLifecycleChange(input, context);
|
|
253
|
+
const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
|
|
254
|
+
const at = new Date().toISOString();
|
|
255
|
+
const run = await change({
|
|
256
|
+
cwd: context.projectRoot,
|
|
257
|
+
runId: context.slug,
|
|
258
|
+
request: { reason: input.reason, authority: { kind: "operator_request", actor: prepared.actorKey, request_ref: `flow-agents://assignment/${context.slug}/${operation}/${at}`, requested_at: at } },
|
|
259
|
+
});
|
|
260
|
+
const projection = projectFlowRun(context, run, prepared.sidecarSnapshot.state);
|
|
261
|
+
writeProjection(context, projection, prepared.sidecarSnapshot.raw, `${operation} projection`);
|
|
262
|
+
return {
|
|
263
|
+
sessionDir: context.sessionDir,
|
|
264
|
+
projectRoot: context.projectRoot,
|
|
265
|
+
run,
|
|
266
|
+
projection,
|
|
267
|
+
attached: false,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function prepareAuthorizedLifecycleChange(input: BuilderFlowAuthorizedLifecycleInput, operation: "cancel" | "archive", context: SessionContext): Promise<{
|
|
273
|
+
context: SessionContext;
|
|
274
|
+
sidecarSnapshot: SidecarSnapshot;
|
|
275
|
+
authorization: ReturnType<typeof loadBuilderLifecycleAuthorization>;
|
|
276
|
+
}> {
|
|
277
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
278
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
279
|
+
const activeAssignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
280
|
+
const assignmentFile = assignmentFilePath(context.artifactRoot, context.slug);
|
|
281
|
+
const persistedAssignment = pathExistsNoFollow(assignmentFile)
|
|
282
|
+
? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")) as AnyRecord)
|
|
283
|
+
: null;
|
|
284
|
+
const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
|
|
285
|
+
const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
|
|
286
|
+
const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
|
|
287
|
+
if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
|
|
288
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by a canonical actor before a lifecycle change");
|
|
289
|
+
}
|
|
290
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject) {
|
|
291
|
+
throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
292
|
+
}
|
|
293
|
+
const authorization = loadBuilderLifecycleAuthorization(input.authorizationFile, {
|
|
294
|
+
projectRoot: context.projectRoot,
|
|
295
|
+
operation,
|
|
296
|
+
runId: context.slug,
|
|
297
|
+
subject,
|
|
298
|
+
actorKey: assignment.actor_key,
|
|
299
|
+
...(operation === "cancel" && canonicalRun.state.status === "canceled" ? { allowExpired: true } : {}),
|
|
300
|
+
...(operation === "archive" && sidecarSnapshot.state.status === "archived" ? { allowExpired: true } : {}),
|
|
301
|
+
});
|
|
302
|
+
if (operation === "cancel" && canonicalRun.state.status === "canceled") {
|
|
303
|
+
const terminalEvent = canonicalRun.state.lifecycle?.at(-1);
|
|
304
|
+
if (!terminalEvent || terminalEvent.action !== "cancel" || !lifecycleRequestMatches(terminalEvent, authorization.request)) {
|
|
305
|
+
throw new BuilderBuildRunInputError("authorization.request", "does not match the canonical cancellation being recovered");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!sameActor(authorization.assignment_actor, assignment.actor)) {
|
|
309
|
+
throw new BuilderBuildRunInputError("authorization.assignment_actor", "must match the active assignment holder");
|
|
310
|
+
}
|
|
311
|
+
return { context, sidecarSnapshot, authorization };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function prepareAgentLifecycleChange(input: BuilderFlowAgentLifecycleInput, context: SessionContext): { sidecarSnapshot: SidecarSnapshot; actor: ActorStruct; actorKey: string } {
|
|
315
|
+
if (!input.reason.trim()) throw new BuilderBuildRunInputError("reason", "must be non-empty");
|
|
316
|
+
const resolved = resolveCurrentAssignmentActor();
|
|
317
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
318
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
319
|
+
const assignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
|
|
320
|
+
if (!assignment || assignment.status !== "claimed" || assignment.actor_key !== resolved.actorKey || !sameActor(assignment.actor, resolved.actor)) {
|
|
321
|
+
throw new BuilderBuildRunInputError("assignment", "must be actively held by the current workflow actor");
|
|
322
|
+
}
|
|
323
|
+
if (assignment.work_item_ref && assignment.work_item_ref !== subject) throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
|
|
324
|
+
return { sidecarSnapshot, actor: resolved.actor, actorKey: resolved.actorKey };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sameActor(left: ActorStruct, right: ActorStruct): boolean {
|
|
328
|
+
return isDeepStrictEqual({ ...left, human: left.human ?? null }, { ...right, human: right.human ?? null });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function clearCurrentPointers(artifactRoot: string, slug: string): void {
|
|
332
|
+
const candidates = [path.join(artifactRoot, "current.json")];
|
|
333
|
+
const actorRoot = path.join(artifactRoot, "current");
|
|
334
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
335
|
+
assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
336
|
+
candidates.push(...fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).map((name) => path.join(actorRoot, name)));
|
|
337
|
+
}
|
|
338
|
+
for (const file of candidates) {
|
|
339
|
+
if (!pathExistsNoFollow(file) || !fs.lstatSync(file).isFile()) continue;
|
|
340
|
+
const root = file === candidates[0] ? artifactRoot : actorRoot;
|
|
341
|
+
if (root === actorRoot) assertSafeDirectory(actorRoot, artifactRoot, "current directory");
|
|
342
|
+
assertSafeFile(file, root, "current pointer");
|
|
343
|
+
let pointer: AnyRecord;
|
|
344
|
+
try {
|
|
345
|
+
pointer = JSON.parse(fs.readFileSync(file, "utf8")) as AnyRecord;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
348
|
+
// Archival retains malformed unrelated pointers for explicit repair.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (pointer.active_slug === slug) fs.unlinkSync(file);
|
|
129
352
|
}
|
|
130
|
-
if (!fs.existsSync(runDir(context.slug, context.projectRoot))) return null;
|
|
131
|
-
return syncBuilderFlowSession({ sessionDir });
|
|
132
353
|
}
|
|
133
354
|
|
|
134
355
|
async function syncAndProject(
|
|
135
356
|
context: SessionContext,
|
|
136
|
-
initial:
|
|
357
|
+
initial: BuilderFlowRunResult,
|
|
137
358
|
sidecarSnapshot: SidecarSnapshot,
|
|
138
359
|
): Promise<BuilderFlowSessionResult> {
|
|
139
360
|
let run = initial;
|
|
@@ -143,26 +364,35 @@ async function syncAndProject(
|
|
|
143
364
|
throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
|
|
144
365
|
}
|
|
145
366
|
if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
367
|
+
const snapshot = stageTrustBundleSnapshot(context);
|
|
368
|
+
try {
|
|
369
|
+
const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
|
|
370
|
+
const gateEvidence = await bundleGateEvidence(rawBundle, gates[0]!, run.state.subject, context.projectRoot);
|
|
371
|
+
if (gateEvidence) {
|
|
372
|
+
const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
|
|
373
|
+
entry.gate_id === gates[0]!.id && entry.sha256 === snapshot.sha256
|
|
374
|
+
);
|
|
375
|
+
if (!alreadyAttached) {
|
|
376
|
+
const supersede = manifestEvidence(run.manifest)
|
|
377
|
+
.filter((entry) => entry.gate_id === gates[0]!.id && typeof entry.superseded_by !== "string")
|
|
378
|
+
.map((entry) => String(entry.id));
|
|
379
|
+
run = await evaluateBuilderFlowRun({
|
|
380
|
+
cwd: context.projectRoot,
|
|
381
|
+
runId: context.slug,
|
|
382
|
+
evidence: {
|
|
383
|
+
gate: gates[0]!.id,
|
|
384
|
+
file: path.relative(context.projectRoot, snapshot.file),
|
|
385
|
+
expectedSha256: snapshot.sha256,
|
|
386
|
+
...(supersede.length > 0 ? { supersede } : {}),
|
|
387
|
+
...(gateEvidence.failed ? { status: "failed" } : {}),
|
|
388
|
+
...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
attached = true;
|
|
392
|
+
}
|
|
165
393
|
}
|
|
394
|
+
} finally {
|
|
395
|
+
removeTrustBundleSnapshot(snapshot);
|
|
166
396
|
}
|
|
167
397
|
}
|
|
168
398
|
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
@@ -176,7 +406,7 @@ async function syncAndProject(
|
|
|
176
406
|
};
|
|
177
407
|
}
|
|
178
408
|
|
|
179
|
-
function assertRunSubjectBinding(run:
|
|
409
|
+
function assertRunSubjectBinding(run: BuilderFlowRunResult, subject: string): void {
|
|
180
410
|
if (run.state.subject !== subject) {
|
|
181
411
|
throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
|
|
182
412
|
}
|
|
@@ -235,14 +465,20 @@ function workflowSubject(state: AnyRecord): string {
|
|
|
235
465
|
return refs[0]!;
|
|
236
466
|
}
|
|
237
467
|
|
|
238
|
-
function
|
|
468
|
+
function persistedFlowId(state: AnyRecord): BuilderFlowId | null {
|
|
469
|
+
const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
|
|
470
|
+
const flowId = flowRun?.definition_id;
|
|
471
|
+
return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: string }> {
|
|
239
475
|
return openGates(
|
|
240
476
|
JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")),
|
|
241
477
|
run.state,
|
|
242
478
|
) as Array<FlowGate & { id: string }>;
|
|
243
479
|
}
|
|
244
480
|
|
|
245
|
-
function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): { failed: boolean; routeReason: string | null } | null {
|
|
481
|
+
async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string, projectRoot: string): Promise<{ failed: boolean; routeReason: string | null } | null> {
|
|
246
482
|
if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
|
|
247
483
|
const selectors = (expectationsForGate(gate) as FlowExpectation[]).map((expectation) => expectation.bundle_claim);
|
|
248
484
|
const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
|
|
@@ -273,9 +509,249 @@ function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): {
|
|
|
273
509
|
if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
|
|
274
510
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String((gate as AnyRecord).id ?? "<unknown>")}`);
|
|
275
511
|
}
|
|
512
|
+
if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
513
|
+
await assertVerifiedTestsTrust(bundle.claims, projectRoot);
|
|
514
|
+
}
|
|
276
515
|
return { failed, routeReason };
|
|
277
516
|
}
|
|
278
517
|
|
|
518
|
+
async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
|
|
519
|
+
const testClaims = claims.filter((claim): claim is AnyRecord => isRecord(claim)
|
|
520
|
+
&& claim.claimType === "builder.verify.tests"
|
|
521
|
+
&& claim.value === "pass"
|
|
522
|
+
&& isRecord(claim.metadata)
|
|
523
|
+
&& isRecord(claim.metadata.gate_claim)
|
|
524
|
+
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
525
|
+
if (testClaims.length === 0) throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
526
|
+
const liveCritiques = claims.filter((claim): claim is AnyRecord => isRecord(claim)
|
|
527
|
+
&& isRecord(claim.metadata)
|
|
528
|
+
&& claim.metadata.origin === "critique"
|
|
529
|
+
&& typeof claim.metadata.superseded_by !== "string");
|
|
530
|
+
if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
|
|
531
|
+
throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
|
|
532
|
+
}
|
|
533
|
+
const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor): actor is string => typeof actor === "string" && actor.length > 0));
|
|
534
|
+
if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
|
|
535
|
+
throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
|
|
536
|
+
}
|
|
537
|
+
await Promise.all(liveCritiques.flatMap(async (claim) => {
|
|
538
|
+
const artifacts = reviewedArtifacts(claim);
|
|
539
|
+
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
540
|
+
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
541
|
+
}));
|
|
542
|
+
const criteria = claims.filter((claim): claim is AnyRecord => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
543
|
+
if (criteria.length === 0 || criteria.some((claim) => {
|
|
544
|
+
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
545
|
+
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
546
|
+
})) {
|
|
547
|
+
throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
|
|
548
|
+
}
|
|
549
|
+
for (const testClaim of testClaims) assertObservedTestsEvidence(testClaim, criteria);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function assertObservedTestsEvidence(testClaim: AnyRecord, criteria: AnyRecord[]): void {
|
|
553
|
+
const observed = testClaim.metadata.observed_commands;
|
|
554
|
+
if (!Array.isArray(observed) || observed.length === 0) {
|
|
555
|
+
throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
|
|
556
|
+
}
|
|
557
|
+
const commands = new Set<string>();
|
|
558
|
+
for (const entry of observed) {
|
|
559
|
+
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)) {
|
|
560
|
+
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");
|
|
561
|
+
}
|
|
562
|
+
commands.add(entry.command);
|
|
563
|
+
}
|
|
564
|
+
const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
|
|
565
|
+
const topCommands = new Set(topRefs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
|
|
566
|
+
if ([...commands].some((command) => !topCommands.has(command))) {
|
|
567
|
+
throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
|
|
568
|
+
}
|
|
569
|
+
const criterionCommands = new Set<string>();
|
|
570
|
+
for (const claim of criteria) {
|
|
571
|
+
const criterion = claim.metadata.criterion as AnyRecord;
|
|
572
|
+
const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
|
|
573
|
+
const matched = refs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
|
|
574
|
+
if (matched.length === 0) throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
|
|
575
|
+
matched.forEach((command) => criterionCommands.add(command));
|
|
576
|
+
}
|
|
577
|
+
if ([...commands].some((command) => !criterionCommands.has(command))) {
|
|
578
|
+
throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isSubstantivePassingCritique(claim: AnyRecord): boolean {
|
|
583
|
+
if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding: unknown) => isRecord(finding) && finding.status === "open"))) return false;
|
|
584
|
+
const lanes = claim.metadata.lanes;
|
|
585
|
+
return Array.isArray(lanes)
|
|
586
|
+
&& lanes.length > 0
|
|
587
|
+
&& lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
|
|
588
|
+
&& reviewedArtifacts(claim).length > 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function reviewedArtifacts(claim: AnyRecord): AnyRecord[] {
|
|
592
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
593
|
+
const artifacts = reviewTarget?.artifacts;
|
|
594
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) return [];
|
|
595
|
+
if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256))) return [];
|
|
596
|
+
return artifacts as AnyRecord[];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function assertReviewedWorkspaceSnapshot(claim: AnyRecord, artifacts: AnyRecord[], projectRoot: string): void {
|
|
600
|
+
const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
|
|
601
|
+
const expected = reviewTarget?.workspace_snapshot;
|
|
602
|
+
if (!isRecord(expected)) {
|
|
603
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
|
|
604
|
+
}
|
|
605
|
+
const current = captureReviewWorkspaceSnapshot(
|
|
606
|
+
projectRoot,
|
|
607
|
+
expected.kind === "reviewed-files"
|
|
608
|
+
? reviewedWorkspaceFiles(expected)
|
|
609
|
+
: artifacts.map((artifact) => ({ file: artifact.file as string, sha256: artifact.sha256 as string })),
|
|
610
|
+
);
|
|
611
|
+
if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
|
|
612
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
|
|
613
|
+
}
|
|
614
|
+
if (expected.digest !== current.digest) {
|
|
615
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
|
|
616
|
+
}
|
|
617
|
+
if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
|
|
618
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
|
|
619
|
+
}
|
|
620
|
+
if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
|
|
621
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function gitWorktreeSnapshot(projectRoot: string): AnyRecord | null {
|
|
626
|
+
const root = fs.realpathSync(projectRoot);
|
|
627
|
+
const hasGitMarker = fs.existsSync(path.join(root, ".git"));
|
|
628
|
+
try {
|
|
629
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
630
|
+
if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
|
|
631
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
|
|
632
|
+
}
|
|
633
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
634
|
+
const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
|
|
635
|
+
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
|
|
636
|
+
.toString("utf8").split("\0").filter(Boolean).sort();
|
|
637
|
+
const hash = createHash("sha256");
|
|
638
|
+
hash.update("flow-agents:git-worktree:v1\0");
|
|
639
|
+
hash.update(headSha).update("\0");
|
|
640
|
+
hash.update(trackedDiff).update("\0");
|
|
641
|
+
for (const file of untracked) {
|
|
642
|
+
const absolute = path.resolve(root, file);
|
|
643
|
+
if (!pathIsWithin(absolute, root)) throw new Error("untracked file escapes repository root");
|
|
644
|
+
const stat = fs.lstatSync(absolute);
|
|
645
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("untracked entry is not a regular file");
|
|
646
|
+
hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
647
|
+
}
|
|
648
|
+
return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
|
|
651
|
+
if (error instanceof BuilderBuildRunInputError) throw error;
|
|
652
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function captureReviewWorkspaceSnapshot(
|
|
659
|
+
projectRoot: string,
|
|
660
|
+
reviewedFiles: Array<{ file: string; sha256: string }>,
|
|
661
|
+
): AnyRecord {
|
|
662
|
+
return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function reviewedWorkspaceFiles(snapshot: AnyRecord): Array<{ file: string; sha256: string }> {
|
|
666
|
+
if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
|
|
667
|
+
|| !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
|
|
668
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
|
|
669
|
+
}
|
|
670
|
+
const files = snapshot.files.map((file) => ({ file: file.file as string, sha256: file.sha256 as string })).sort((left, right) => left.file.localeCompare(right.file));
|
|
671
|
+
if (new Set(files.map((file) => file.file)).size !== files.length) {
|
|
672
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
|
|
673
|
+
}
|
|
674
|
+
return files;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function reviewedFilesSnapshot(projectRoot: string, reviewedFiles: Array<{ file: string; sha256: string }>): AnyRecord {
|
|
678
|
+
const files = reviewedFiles.map((file) => ({ ...file }));
|
|
679
|
+
const hash = createHash("sha256");
|
|
680
|
+
hash.update("flow-agents:reviewed-files:v1\0");
|
|
681
|
+
for (const artifact of files) {
|
|
682
|
+
const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
683
|
+
hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
|
|
684
|
+
}
|
|
685
|
+
return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function assertReviewedArtifactDigest(artifact: AnyRecord, projectRoot: string): Promise<void> {
|
|
689
|
+
const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
|
|
690
|
+
if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
|
|
691
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function safeReviewedArtifactPath(projectRoot: string, file: string): string {
|
|
696
|
+
const canonicalRoot = fs.realpathSync(projectRoot);
|
|
697
|
+
const candidate = path.resolve(canonicalRoot, file);
|
|
698
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
699
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
700
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
|
|
701
|
+
}
|
|
702
|
+
let canonicalArtifact: string;
|
|
703
|
+
try {
|
|
704
|
+
canonicalArtifact = fs.realpathSync(candidate);
|
|
705
|
+
} catch {
|
|
706
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
|
|
707
|
+
}
|
|
708
|
+
const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
|
|
709
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
|
|
710
|
+
throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
|
|
711
|
+
}
|
|
712
|
+
return canonicalArtifact;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function stageTrustBundleSnapshot(context: SessionContext): TrustBundleSnapshot {
|
|
716
|
+
assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
|
|
717
|
+
const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
718
|
+
let raw: Buffer;
|
|
719
|
+
try {
|
|
720
|
+
const stat = fs.fstatSync(source);
|
|
721
|
+
if (!stat.isFile()) throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
|
|
722
|
+
raw = fs.readFileSync(source);
|
|
723
|
+
} finally {
|
|
724
|
+
fs.closeSync(source);
|
|
725
|
+
}
|
|
726
|
+
const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
|
|
727
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
728
|
+
assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
|
|
729
|
+
fs.chmodSync(directory, 0o700);
|
|
730
|
+
const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
|
|
731
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
732
|
+
try {
|
|
733
|
+
fs.writeFileSync(descriptor, raw);
|
|
734
|
+
fs.fsyncSync(descriptor);
|
|
735
|
+
} finally {
|
|
736
|
+
fs.closeSync(descriptor);
|
|
737
|
+
}
|
|
738
|
+
fs.chmodSync(file, 0o400);
|
|
739
|
+
return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function removeTrustBundleSnapshot(snapshot: TrustBundleSnapshot): void {
|
|
743
|
+
try {
|
|
744
|
+
fs.unlinkSync(snapshot.file);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (!isRecord(error) || error.code !== "ENOENT") throw error;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
fs.rmdirSync(path.dirname(snapshot.file));
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY")) throw error;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
279
755
|
function workflowSubjectRef(claim: AnyRecord): string | null {
|
|
280
756
|
const metadata = isRecord(claim.metadata) ? claim.metadata : null;
|
|
281
757
|
return metadata && typeof metadata.workflow_subject_ref === "string"
|
|
@@ -287,11 +763,13 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
|
|
|
287
763
|
return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
|
|
288
764
|
}
|
|
289
765
|
|
|
290
|
-
function projectFlowRun(context: SessionContext, run:
|
|
766
|
+
function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, sidecar: AnyRecord): AnyRecord {
|
|
291
767
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
292
768
|
const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
|
|
293
769
|
const complete = run.state.status === "completed";
|
|
294
|
-
const
|
|
770
|
+
const paused = run.state.status === "paused";
|
|
771
|
+
const canceled = run.state.status === "canceled";
|
|
772
|
+
const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
295
773
|
if (!action) {
|
|
296
774
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
297
775
|
}
|
|
@@ -300,7 +778,7 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
|
|
|
300
778
|
.map((expectation: FlowExpectation) => `${expectation.id} (${expectation.bundle_claim.claimType}/${expectation.bundle_claim.subjectType ?? "any"})`));
|
|
301
779
|
const skills = action?.skills ?? [];
|
|
302
780
|
const operations = action?.operations ?? [];
|
|
303
|
-
const syncCommand =
|
|
781
|
+
const syncCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), ["workflow", "status", "--session-dir", `.kontourai/flow-agents/${context.slug}`, "--json"]);
|
|
304
782
|
const routeBack = latestRouteBack(run.state);
|
|
305
783
|
const skillText = skills.length ? `Activate ${skills.map((skill) => `\`${skill}\``).join(" then ")}.` : "No Builder skill is required.";
|
|
306
784
|
const operationText = operations.length ? ` Perform ${operations.map((operation) => `\`${operation}\``).join(" then ")}.` : "";
|
|
@@ -312,6 +790,10 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
|
|
|
312
790
|
: "";
|
|
313
791
|
const nextAction = complete
|
|
314
792
|
? { status: "done", summary: "Canonical Flow run is complete." }
|
|
793
|
+
: canceled
|
|
794
|
+
? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
|
|
795
|
+
: paused
|
|
796
|
+
? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
|
|
315
797
|
: {
|
|
316
798
|
status: "continue",
|
|
317
799
|
summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
|
|
@@ -322,8 +804,8 @@ function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sid
|
|
|
322
804
|
const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
|
|
323
805
|
return {
|
|
324
806
|
...sidecar,
|
|
325
|
-
status: complete ? "delivered" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
326
|
-
phase: complete ? "done" : phase,
|
|
807
|
+
status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
808
|
+
phase: complete || canceled ? "done" : phase,
|
|
327
809
|
updated_at: run.state.updated_at,
|
|
328
810
|
flow_run: {
|
|
329
811
|
run_id: run.runId,
|
|
@@ -394,7 +876,7 @@ function prepareProjectionWrites(
|
|
|
394
876
|
if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
|
|
395
877
|
const output = {
|
|
396
878
|
...pointer,
|
|
397
|
-
active_flow_id:
|
|
879
|
+
active_flow_id: projection.flow_run.definition_id,
|
|
398
880
|
active_step_id: projection.flow_run.current_step,
|
|
399
881
|
updated_at: projection.updated_at,
|
|
400
882
|
};
|
|
@@ -501,12 +983,12 @@ function writeExistingFileNoFollow(file: string, content: string): void {
|
|
|
501
983
|
}
|
|
502
984
|
}
|
|
503
985
|
|
|
504
|
-
function stepAction(stepId: string): { skills: string[]; operations: string[] } | null {
|
|
986
|
+
function stepAction(flowId: BuilderFlowId, stepId: string): { skills: string[]; operations: string[] } | null {
|
|
505
987
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
|
|
506
988
|
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
507
989
|
const action = actions.find((candidate: unknown) =>
|
|
508
990
|
isRecord(candidate)
|
|
509
|
-
&& candidate.flow_id ===
|
|
991
|
+
&& candidate.flow_id === flowId
|
|
510
992
|
&& candidate.step_id === stepId
|
|
511
993
|
);
|
|
512
994
|
if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill: unknown) => typeof skill === "string")) {
|