@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.
Files changed (111) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +32 -3
  4. package/build/src/builder-flow-run-adapter.js +113 -20
  5. package/build/src/builder-flow-runtime.d.ts +26 -3
  6. package/build/src/builder-flow-runtime.js +502 -49
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +14 -7
  10. package/build/src/cli/assignment-provider.js +128 -65
  11. package/build/src/cli/builder-run.js +46 -9
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +30 -3
  14. package/build/src/cli/workflow-sidecar.js +825 -99
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +769 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/flow-kit/validate.d.ts +17 -0
  19. package/build/src/flow-kit/validate.js +340 -2
  20. package/build/src/index.d.ts +4 -0
  21. package/build/src/index.js +7 -5
  22. package/build/src/lib/observed-command.d.ts +7 -0
  23. package/build/src/lib/observed-command.js +100 -0
  24. package/build/src/lib/package-version.d.ts +2 -0
  25. package/build/src/lib/package-version.js +13 -0
  26. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  27. package/build/src/lib/pinned-cli-command.js +21 -0
  28. package/build/src/tools/generate-context-map.js +5 -3
  29. package/context/contracts/artifact-contract.md +1 -1
  30. package/context/scripts/hooks/config-protection.js +8 -1
  31. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  32. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  33. package/context/scripts/hooks/stop-goal-fit.js +79 -10
  34. package/docs/context-map.md +24 -20
  35. package/docs/developer-architecture.md +1 -1
  36. package/docs/public-workflow-cli.md +132 -0
  37. package/docs/skills-map.md +51 -27
  38. package/docs/spec/builder-flow-runtime.md +78 -40
  39. package/docs/workflow-usage-guide.md +110 -38
  40. package/evals/ci/run-baseline.sh +2 -0
  41. package/evals/fixtures/hook-influence/cases.json +2 -2
  42. package/evals/integration/test_builder_entry_enforcement.sh +57 -45
  43. package/evals/integration/test_builder_step_producers.sh +297 -6
  44. package/evals/integration/test_bundle_install.sh +258 -55
  45. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  46. package/evals/integration/test_current_json_per_actor.sh +12 -0
  47. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  48. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  49. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  50. package/evals/integration/test_gate_lockdown.sh +3 -5
  51. package/evals/integration/test_goal_fit_hook.sh +60 -2
  52. package/evals/integration/test_liveness_verdict.sh +14 -17
  53. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  54. package/evals/integration/test_public_workflow_cli.sh +573 -0
  55. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  56. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  57. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  58. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  59. package/evals/run.sh +2 -0
  60. package/evals/static/test_builder_skill_coherence.sh +247 -0
  61. package/evals/static/test_library_exports.sh +5 -2
  62. package/evals/static/test_workflow_skills.sh +13 -325
  63. package/kits/builder/flows/build.flow.json +22 -0
  64. package/kits/builder/flows/shape.flow.json +9 -9
  65. package/kits/builder/kit.json +70 -16
  66. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  67. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  68. package/kits/builder/skills/deliver/SKILL.md +96 -442
  69. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  70. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  71. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  72. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  73. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  74. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  75. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  76. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  77. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  78. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  79. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  80. package/kits/builder/skills/review-work/SKILL.md +89 -176
  81. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  82. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  83. package/package.json +2 -2
  84. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  85. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  86. package/schemas/workflow-state.schema.json +1 -1
  87. package/scripts/hooks/config-protection.js +8 -1
  88. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  89. package/scripts/hooks/lib/kit-catalog.js +1 -1
  90. package/scripts/hooks/stop-goal-fit.js +79 -10
  91. package/src/builder-flow-run-adapter.ts +156 -23
  92. package/src/builder-flow-runtime.ts +535 -53
  93. package/src/builder-lifecycle-authority.ts +218 -0
  94. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  95. package/src/cli/assignment-provider.ts +91 -22
  96. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  97. package/src/cli/builder-flow-runtime.test.mjs +597 -8
  98. package/src/cli/builder-run.ts +54 -9
  99. package/src/cli/kit-metadata-security.test.mjs +232 -6
  100. package/src/cli/public-api.test.mjs +15 -0
  101. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  102. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  103. package/src/cli/workflow-sidecar.ts +748 -99
  104. package/src/cli/workflow.ts +708 -0
  105. package/src/cli.ts +2 -0
  106. package/src/flow-kit/validate.ts +320 -2
  107. package/src/index.ts +20 -5
  108. package/src/lib/observed-command.ts +96 -0
  109. package/src/lib/package-version.ts +15 -0
  110. package/src/lib/pinned-cli-command.ts +23 -0
  111. 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
- evaluateBuilderBuildRun,
19
- loadBuilderBuildRun,
20
- startBuilderBuildRun,
21
- type BuilderBuildRunResult,
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: BuilderBuildRunResult;
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
- let run: BuilderBuildRunResult;
93
+ const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
94
+ let run: BuilderFlowRunResult;
70
95
  try {
71
- run = await loadBuilderBuildRun({
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 startBuilderBuildRun({
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 loadBuilderBuildRun({
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 loadBuilderBuildRun({
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 syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
123
- let context: SessionContext;
124
- try {
125
- context = resolveSessionContext(sessionDir);
126
- } catch (error) {
127
- if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir") return null;
128
- throw error;
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: BuilderBuildRunResult,
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 rawBundle = await readJson(context.bundleFile);
147
- const gateEvidence = bundleGateEvidence(rawBundle, gates[0]!, run.state.subject);
148
- if (gateEvidence) {
149
- const digest = await sha256File(context.bundleFile);
150
- const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
151
- entry.gate_id === gates[0]!.id && entry.sha256 === digest
152
- );
153
- if (!alreadyAttached) {
154
- run = await evaluateBuilderBuildRun({
155
- cwd: context.projectRoot,
156
- runId: context.slug,
157
- evidence: {
158
- gate: gates[0]!.id,
159
- file: path.relative(context.projectRoot, context.bundleFile),
160
- ...(gateEvidence.failed ? { status: "failed" } : {}),
161
- ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
162
- },
163
- });
164
- attached = true;
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: BuilderBuildRunResult, subject: string): void {
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 openGatesForResult(run: BuilderBuildRunResult): Array<FlowGate & { id: string }> {
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: BuilderBuildRunResult, sidecar: AnyRecord): AnyRecord {
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 action = complete ? { skills: [], operations: [] } : stepAction(run.state.current_step);
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 = `flow-agents builder-run sync --session-dir .kontourai/flow-agents/${context.slug}`;
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: BUILDER_BUILD_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 === BUILDER_BUILD_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")) {