@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,26 +1,38 @@
1
1
  import * as fs from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash, randomBytes } from "node:crypto";
2
4
  import * as path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
- import { expectationsForGate, openGates, readJson, runDir, sha256File, } from "@kontourai/flow";
5
- import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, evaluateBuilderBuildRun, loadBuilderBuildRun, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { flowAgentsPackageVersion } from "./lib/package-version.js";
8
+ import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
9
+ import { expectationsForGate, lifecycleRequestMatches, openGates, } from "@kontourai/flow";
10
+ import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
11
+ import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
12
+ import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderFlowRun, evaluateBuilderFlowRun, loadBuilderFlowRun, pauseBuilderFlowRun, resumeBuilderFlowRun, startBuilderFlowRun, } from "./builder-flow-run-adapter.js";
6
13
  export async function startBuilderFlowSession(input) {
7
14
  const context = resolveSessionContext(input.sessionDir);
8
15
  const sidecarSnapshot = readSidecarSnapshot(context);
9
16
  const subject = workflowSubject(sidecarSnapshot.state);
17
+ const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
10
18
  let run;
11
19
  try {
12
- run = await loadBuilderBuildRun({
20
+ run = await loadBuilderFlowRun({
13
21
  cwd: context.projectRoot,
14
22
  runId: context.slug,
15
23
  });
24
+ if (run.definitionId !== requestedFlowId) {
25
+ 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`);
26
+ }
16
27
  }
17
28
  catch (error) {
18
29
  if (!isRunNotFound(error))
19
30
  throw error;
20
- run = await startBuilderBuildRun({
31
+ run = await startBuilderFlowRun({
21
32
  cwd: context.projectRoot,
22
33
  runId: context.slug,
23
34
  subject,
35
+ flowId: requestedFlowId,
24
36
  params: {
25
37
  subject,
26
38
  },
@@ -33,7 +45,7 @@ export async function syncBuilderFlowSession(input) {
33
45
  const context = resolveSessionContext(input.sessionDir);
34
46
  const sidecarSnapshot = readSidecarSnapshot(context);
35
47
  const subject = workflowSubject(sidecarSnapshot.state);
36
- const run = await loadBuilderBuildRun({
48
+ const run = await loadBuilderFlowRun({
37
49
  cwd: context.projectRoot,
38
50
  runId: context.slug,
39
51
  });
@@ -44,7 +56,7 @@ export async function recoverBuilderFlowSession(input) {
44
56
  const context = resolveSessionContext(input.sessionDir);
45
57
  const sidecarSnapshot = readSidecarSnapshot(context);
46
58
  const subject = workflowSubject(sidecarSnapshot.state);
47
- const run = await loadBuilderBuildRun({
59
+ const run = await loadBuilderFlowRun({
48
60
  cwd: context.projectRoot,
49
61
  runId: context.slug,
50
62
  });
@@ -59,19 +71,200 @@ export async function recoverBuilderFlowSession(input) {
59
71
  attached: false,
60
72
  };
61
73
  }
62
- export async function syncBuilderFlowSessionIfPresent(sessionDir) {
63
- let context;
64
- try {
65
- context = resolveSessionContext(sessionDir);
74
+ export async function inspectBuilderFlowSession(input) {
75
+ const context = resolveSessionContext(input.sessionDir);
76
+ const sidecarSnapshot = readSidecarSnapshot(context);
77
+ const subject = workflowSubject(sidecarSnapshot.state);
78
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
79
+ assertRunSubjectBinding(run, subject);
80
+ return {
81
+ sessionDir: context.sessionDir,
82
+ projectRoot: context.projectRoot,
83
+ run,
84
+ projection: projectFlowRun(context, run, sidecarSnapshot.state),
85
+ attached: false,
86
+ };
87
+ }
88
+ export async function pauseBuilderFlowSession(input) {
89
+ return changeBuilderFlowSessionLifecycle(input, "pause");
90
+ }
91
+ export async function resumeBuilderFlowSession(input) {
92
+ return changeBuilderFlowSessionLifecycle(input, "resume");
93
+ }
94
+ export async function cancelBuilderFlowSession(input) {
95
+ const context = resolveSessionContext(input.sessionDir);
96
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
97
+ const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
98
+ assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
99
+ const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
100
+ const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
101
+ actorKey: prepared.authorization.assignment_actor_key,
102
+ reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
103
+ tolerateNoActiveClaim: true,
104
+ });
105
+ const projection = projectFlowRun(prepared.context, changed, prepared.sidecarSnapshot.state);
106
+ writeProjection(prepared.context, projection, prepared.sidecarSnapshot.raw, "cancellation projection");
107
+ recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
108
+ return { sessionDir: prepared.context.sessionDir, projectRoot: prepared.context.projectRoot, run: changed, projection, attached: false, assignmentReleased: released !== null, idempotent: changed.idempotent };
109
+ });
110
+ }
111
+ export async function releaseBuilderFlowAssignment(input) {
112
+ const context = resolveSessionContext(input.sessionDir);
113
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
114
+ const prepared = prepareAgentLifecycleChange(input, context);
115
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
116
+ const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
117
+ return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
118
+ });
119
+ }
120
+ export async function archiveBuilderFlowSession(input) {
121
+ const context = resolveSessionContext(input.sessionDir);
122
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
123
+ const prepared = await prepareAuthorizedLifecycleChange(input, "archive", context);
124
+ const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
125
+ const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
126
+ if (priorConsumption && !recoveringPreparedArchive)
127
+ throw new Error("lifecycle authorization nonce has already been consumed");
128
+ const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
129
+ if (run.state.status !== "completed" && run.state.status !== "canceled") {
130
+ throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
131
+ }
132
+ const archiveRoot = path.join(prepared.context.artifactRoot, "archive");
133
+ const archiveDir = path.join(archiveRoot, prepared.context.slug);
134
+ if (pathExistsNoFollow(archiveDir))
135
+ throw new BuilderBuildRunInputError("archive", "destination already exists");
136
+ fs.mkdirSync(archiveRoot, { recursive: true });
137
+ assertSafeDirectory(archiveRoot, prepared.context.artifactRoot, "archive root");
138
+ assertSafeDirectory(prepared.context.sessionDir, prepared.context.artifactRoot, "sessionDir");
139
+ if (!recoveringPreparedArchive && fs.readFileSync(prepared.context.stateFile, "utf8") !== prepared.sidecarSnapshot.raw) {
140
+ throw new BuilderBuildRunInputError("state.json", "changed during archive preparation");
141
+ }
142
+ const archivedState = recoveringPreparedArchive ? prepared.sidecarSnapshot.state : {
143
+ ...prepared.sidecarSnapshot.state,
144
+ status: "archived",
145
+ phase: "done",
146
+ updated_at: new Date().toISOString(),
147
+ next_action: { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." },
148
+ };
149
+ if (!recoveringPreparedArchive) {
150
+ writeExistingFileNoFollow(prepared.context.stateFile, `${JSON.stringify(archivedState, null, 2)}\n`);
151
+ clearCurrentPointers(prepared.context.artifactRoot, prepared.context.slug);
152
+ recordAuthorizationConsumed(prepared.context.artifactRoot, prepared.authorization);
153
+ }
154
+ fs.renameSync(prepared.context.sessionDir, archiveDir);
155
+ return {
156
+ sessionDir: archiveDir,
157
+ projectRoot: prepared.context.projectRoot,
158
+ run,
159
+ projection: archivedState,
160
+ attached: false,
161
+ archiveDir,
162
+ };
163
+ });
164
+ }
165
+ async function changeBuilderFlowSessionLifecycle(input, operation) {
166
+ const context = resolveSessionContext(input.sessionDir);
167
+ return await withSubjectLock(context.artifactRoot, context.slug, async () => {
168
+ const prepared = prepareAgentLifecycleChange(input, context);
169
+ const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
170
+ const at = new Date().toISOString();
171
+ const run = await change({
172
+ cwd: context.projectRoot,
173
+ runId: context.slug,
174
+ request: { reason: input.reason, authority: { kind: "operator_request", actor: prepared.actorKey, request_ref: `flow-agents://assignment/${context.slug}/${operation}/${at}`, requested_at: at } },
175
+ });
176
+ const projection = projectFlowRun(context, run, prepared.sidecarSnapshot.state);
177
+ writeProjection(context, projection, prepared.sidecarSnapshot.raw, `${operation} projection`);
178
+ return {
179
+ sessionDir: context.sessionDir,
180
+ projectRoot: context.projectRoot,
181
+ run,
182
+ projection,
183
+ attached: false,
184
+ };
185
+ });
186
+ }
187
+ async function prepareAuthorizedLifecycleChange(input, operation, context) {
188
+ const sidecarSnapshot = readSidecarSnapshot(context);
189
+ const subject = workflowSubject(sidecarSnapshot.state);
190
+ const activeAssignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
191
+ const assignmentFile = assignmentFilePath(context.artifactRoot, context.slug);
192
+ const persistedAssignment = pathExistsNoFollow(assignmentFile)
193
+ ? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
194
+ : null;
195
+ const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
196
+ const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
197
+ const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
198
+ if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
199
+ throw new BuilderBuildRunInputError("assignment", "must be actively held by a canonical actor before a lifecycle change");
66
200
  }
67
- catch (error) {
68
- if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir")
69
- return null;
70
- throw error;
201
+ if (assignment.work_item_ref && assignment.work_item_ref !== subject) {
202
+ throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
203
+ }
204
+ const authorization = loadBuilderLifecycleAuthorization(input.authorizationFile, {
205
+ projectRoot: context.projectRoot,
206
+ operation,
207
+ runId: context.slug,
208
+ subject,
209
+ actorKey: assignment.actor_key,
210
+ ...(operation === "cancel" && canonicalRun.state.status === "canceled" ? { allowExpired: true } : {}),
211
+ ...(operation === "archive" && sidecarSnapshot.state.status === "archived" ? { allowExpired: true } : {}),
212
+ });
213
+ if (operation === "cancel" && canonicalRun.state.status === "canceled") {
214
+ const terminalEvent = canonicalRun.state.lifecycle?.at(-1);
215
+ if (!terminalEvent || terminalEvent.action !== "cancel" || !lifecycleRequestMatches(terminalEvent, authorization.request)) {
216
+ throw new BuilderBuildRunInputError("authorization.request", "does not match the canonical cancellation being recovered");
217
+ }
218
+ }
219
+ if (!sameActor(authorization.assignment_actor, assignment.actor)) {
220
+ throw new BuilderBuildRunInputError("authorization.assignment_actor", "must match the active assignment holder");
221
+ }
222
+ return { context, sidecarSnapshot, authorization };
223
+ }
224
+ function prepareAgentLifecycleChange(input, context) {
225
+ if (!input.reason.trim())
226
+ throw new BuilderBuildRunInputError("reason", "must be non-empty");
227
+ const resolved = resolveCurrentAssignmentActor();
228
+ const sidecarSnapshot = readSidecarSnapshot(context);
229
+ const subject = workflowSubject(sidecarSnapshot.state);
230
+ const assignment = readLocalAssignmentStatus(context.artifactRoot, context.slug).record;
231
+ if (!assignment || assignment.status !== "claimed" || assignment.actor_key !== resolved.actorKey || !sameActor(assignment.actor, resolved.actor)) {
232
+ throw new BuilderBuildRunInputError("assignment", "must be actively held by the current workflow actor");
233
+ }
234
+ if (assignment.work_item_ref && assignment.work_item_ref !== subject)
235
+ throw new BuilderBuildRunInputError("assignment.work_item_ref", "must match the selected Work Item");
236
+ return { sidecarSnapshot, actor: resolved.actor, actorKey: resolved.actorKey };
237
+ }
238
+ function sameActor(left, right) {
239
+ return isDeepStrictEqual({ ...left, human: left.human ?? null }, { ...right, human: right.human ?? null });
240
+ }
241
+ function clearCurrentPointers(artifactRoot, slug) {
242
+ const candidates = [path.join(artifactRoot, "current.json")];
243
+ const actorRoot = path.join(artifactRoot, "current");
244
+ if (pathExistsNoFollow(actorRoot)) {
245
+ assertSafeDirectory(actorRoot, artifactRoot, "current directory");
246
+ candidates.push(...fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).map((name) => path.join(actorRoot, name)));
247
+ }
248
+ for (const file of candidates) {
249
+ if (!pathExistsNoFollow(file) || !fs.lstatSync(file).isFile())
250
+ continue;
251
+ const root = file === candidates[0] ? artifactRoot : actorRoot;
252
+ if (root === actorRoot)
253
+ assertSafeDirectory(actorRoot, artifactRoot, "current directory");
254
+ assertSafeFile(file, root, "current pointer");
255
+ let pointer;
256
+ try {
257
+ pointer = JSON.parse(fs.readFileSync(file, "utf8"));
258
+ }
259
+ catch (error) {
260
+ if (!(error instanceof SyntaxError))
261
+ throw error;
262
+ // Archival retains malformed unrelated pointers for explicit repair.
263
+ continue;
264
+ }
265
+ if (pointer.active_slug === slug)
266
+ fs.unlinkSync(file);
71
267
  }
72
- if (!fs.existsSync(runDir(context.slug, context.projectRoot)))
73
- return null;
74
- return syncBuilderFlowSession({ sessionDir });
75
268
  }
76
269
  async function syncAndProject(context, initial, sidecarSnapshot) {
77
270
  let run = initial;
@@ -81,25 +274,35 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
81
274
  throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
82
275
  }
83
276
  if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
84
- const rawBundle = await readJson(context.bundleFile);
85
- const gateEvidence = bundleGateEvidence(rawBundle, gates[0], run.state.subject);
86
- if (gateEvidence) {
87
- const digest = await sha256File(context.bundleFile);
88
- const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === digest);
89
- if (!alreadyAttached) {
90
- run = await evaluateBuilderBuildRun({
91
- cwd: context.projectRoot,
92
- runId: context.slug,
93
- evidence: {
94
- gate: gates[0].id,
95
- file: path.relative(context.projectRoot, context.bundleFile),
96
- ...(gateEvidence.failed ? { status: "failed" } : {}),
97
- ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
98
- },
99
- });
100
- attached = true;
277
+ const snapshot = stageTrustBundleSnapshot(context);
278
+ try {
279
+ const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
280
+ const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state.subject, context.projectRoot);
281
+ if (gateEvidence) {
282
+ const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === snapshot.sha256);
283
+ if (!alreadyAttached) {
284
+ const supersede = manifestEvidence(run.manifest)
285
+ .filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
286
+ .map((entry) => String(entry.id));
287
+ run = await evaluateBuilderFlowRun({
288
+ cwd: context.projectRoot,
289
+ runId: context.slug,
290
+ evidence: {
291
+ gate: gates[0].id,
292
+ file: path.relative(context.projectRoot, snapshot.file),
293
+ expectedSha256: snapshot.sha256,
294
+ ...(supersede.length > 0 ? { supersede } : {}),
295
+ ...(gateEvidence.failed ? { status: "failed" } : {}),
296
+ ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
297
+ },
298
+ });
299
+ attached = true;
300
+ }
101
301
  }
102
302
  }
303
+ finally {
304
+ removeTrustBundleSnapshot(snapshot);
305
+ }
103
306
  }
104
307
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
105
308
  writeProjection(context, projection, sidecarSnapshot.raw, "projection");
@@ -166,10 +369,15 @@ function workflowSubject(state) {
166
369
  }
167
370
  return refs[0];
168
371
  }
372
+ function persistedFlowId(state) {
373
+ const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
374
+ const flowId = flowRun?.definition_id;
375
+ return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
376
+ }
169
377
  function openGatesForResult(run) {
170
378
  return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
171
379
  }
172
- function bundleGateEvidence(bundle, gate, subject) {
380
+ async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
173
381
  if (!isRecord(bundle) || !Array.isArray(bundle.claims))
174
382
  return null;
175
383
  const selectors = expectationsForGate(gate).map((expectation) => expectation.bundle_claim);
@@ -201,8 +409,247 @@ function bundleGateEvidence(bundle, gate, subject) {
201
409
  if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
202
410
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
203
411
  }
412
+ if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
413
+ await assertVerifiedTestsTrust(bundle.claims, projectRoot);
414
+ }
204
415
  return { failed, routeReason };
205
416
  }
417
+ async function assertVerifiedTestsTrust(claims, projectRoot) {
418
+ const testClaims = claims.filter((claim) => isRecord(claim)
419
+ && claim.claimType === "builder.verify.tests"
420
+ && claim.value === "pass"
421
+ && isRecord(claim.metadata)
422
+ && isRecord(claim.metadata.gate_claim)
423
+ && claim.metadata.gate_claim.expectation_id === "tests-evidence");
424
+ if (testClaims.length === 0)
425
+ throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
426
+ const liveCritiques = claims.filter((claim) => isRecord(claim)
427
+ && isRecord(claim.metadata)
428
+ && claim.metadata.origin === "critique"
429
+ && typeof claim.metadata.superseded_by !== "string");
430
+ if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
431
+ throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
432
+ }
433
+ const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor) => typeof actor === "string" && actor.length > 0));
434
+ if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
435
+ throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
436
+ }
437
+ await Promise.all(liveCritiques.flatMap(async (claim) => {
438
+ const artifacts = reviewedArtifacts(claim);
439
+ await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
440
+ assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
441
+ }));
442
+ const criteria = claims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
443
+ if (criteria.length === 0 || criteria.some((claim) => {
444
+ const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
445
+ return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
446
+ })) {
447
+ throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
448
+ }
449
+ for (const testClaim of testClaims)
450
+ assertObservedTestsEvidence(testClaim, criteria);
451
+ }
452
+ function assertObservedTestsEvidence(testClaim, criteria) {
453
+ const observed = testClaim.metadata.observed_commands;
454
+ if (!Array.isArray(observed) || observed.length === 0) {
455
+ throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
456
+ }
457
+ const commands = new Set();
458
+ for (const entry of observed) {
459
+ 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)) {
460
+ 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");
461
+ }
462
+ commands.add(entry.command);
463
+ }
464
+ const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
465
+ const topCommands = new Set(topRefs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
466
+ if ([...commands].some((command) => !topCommands.has(command))) {
467
+ throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
468
+ }
469
+ const criterionCommands = new Set();
470
+ for (const claim of criteria) {
471
+ const criterion = claim.metadata.criterion;
472
+ const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
473
+ const matched = refs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
474
+ if (matched.length === 0)
475
+ throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
476
+ matched.forEach((command) => criterionCommands.add(command));
477
+ }
478
+ if ([...commands].some((command) => !criterionCommands.has(command))) {
479
+ throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
480
+ }
481
+ }
482
+ function isSubstantivePassingCritique(claim) {
483
+ if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding) => isRecord(finding) && finding.status === "open")))
484
+ return false;
485
+ const lanes = claim.metadata.lanes;
486
+ return Array.isArray(lanes)
487
+ && lanes.length > 0
488
+ && lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
489
+ && reviewedArtifacts(claim).length > 0;
490
+ }
491
+ function reviewedArtifacts(claim) {
492
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
493
+ const artifacts = reviewTarget?.artifacts;
494
+ if (!Array.isArray(artifacts) || artifacts.length === 0)
495
+ return [];
496
+ if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256)))
497
+ return [];
498
+ return artifacts;
499
+ }
500
+ function assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot) {
501
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
502
+ const expected = reviewTarget?.workspace_snapshot;
503
+ if (!isRecord(expected)) {
504
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
505
+ }
506
+ const current = captureReviewWorkspaceSnapshot(projectRoot, expected.kind === "reviewed-files"
507
+ ? reviewedWorkspaceFiles(expected)
508
+ : artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })));
509
+ if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
510
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
511
+ }
512
+ if (expected.digest !== current.digest) {
513
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
514
+ }
515
+ if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
516
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
517
+ }
518
+ if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
519
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
520
+ }
521
+ }
522
+ function gitWorktreeSnapshot(projectRoot) {
523
+ const root = fs.realpathSync(projectRoot);
524
+ const hasGitMarker = fs.existsSync(path.join(root, ".git"));
525
+ try {
526
+ const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
527
+ if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
528
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
529
+ }
530
+ const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
531
+ const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
532
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
533
+ .toString("utf8").split("\0").filter(Boolean).sort();
534
+ const hash = createHash("sha256");
535
+ hash.update("flow-agents:git-worktree:v1\0");
536
+ hash.update(headSha).update("\0");
537
+ hash.update(trackedDiff).update("\0");
538
+ for (const file of untracked) {
539
+ const absolute = path.resolve(root, file);
540
+ if (!pathIsWithin(absolute, root))
541
+ throw new Error("untracked file escapes repository root");
542
+ const stat = fs.lstatSync(absolute);
543
+ if (!stat.isFile() || stat.isSymbolicLink())
544
+ throw new Error("untracked entry is not a regular file");
545
+ hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
546
+ }
547
+ return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
548
+ }
549
+ catch (error) {
550
+ if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
551
+ if (error instanceof BuilderBuildRunInputError)
552
+ throw error;
553
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
554
+ }
555
+ return null;
556
+ }
557
+ }
558
+ export function captureReviewWorkspaceSnapshot(projectRoot, reviewedFiles) {
559
+ return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
560
+ }
561
+ function reviewedWorkspaceFiles(snapshot) {
562
+ if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
563
+ || !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
564
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
565
+ }
566
+ const files = snapshot.files.map((file) => ({ file: file.file, sha256: file.sha256 })).sort((left, right) => left.file.localeCompare(right.file));
567
+ if (new Set(files.map((file) => file.file)).size !== files.length) {
568
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
569
+ }
570
+ return files;
571
+ }
572
+ function reviewedFilesSnapshot(projectRoot, reviewedFiles) {
573
+ const files = reviewedFiles.map((file) => ({ ...file }));
574
+ const hash = createHash("sha256");
575
+ hash.update("flow-agents:reviewed-files:v1\0");
576
+ for (const artifact of files) {
577
+ const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
578
+ hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
579
+ }
580
+ return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
581
+ }
582
+ async function assertReviewedArtifactDigest(artifact, projectRoot) {
583
+ const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
584
+ if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
585
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
586
+ }
587
+ }
588
+ function safeReviewedArtifactPath(projectRoot, file) {
589
+ const canonicalRoot = fs.realpathSync(projectRoot);
590
+ const candidate = path.resolve(canonicalRoot, file);
591
+ const relative = path.relative(canonicalRoot, candidate);
592
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
593
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
594
+ }
595
+ let canonicalArtifact;
596
+ try {
597
+ canonicalArtifact = fs.realpathSync(candidate);
598
+ }
599
+ catch {
600
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
601
+ }
602
+ const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
603
+ if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
604
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
605
+ }
606
+ return canonicalArtifact;
607
+ }
608
+ function stageTrustBundleSnapshot(context) {
609
+ assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
610
+ const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
611
+ let raw;
612
+ try {
613
+ const stat = fs.fstatSync(source);
614
+ if (!stat.isFile())
615
+ throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
616
+ raw = fs.readFileSync(source);
617
+ }
618
+ finally {
619
+ fs.closeSync(source);
620
+ }
621
+ const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
622
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
623
+ assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
624
+ fs.chmodSync(directory, 0o700);
625
+ const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
626
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
627
+ try {
628
+ fs.writeFileSync(descriptor, raw);
629
+ fs.fsyncSync(descriptor);
630
+ }
631
+ finally {
632
+ fs.closeSync(descriptor);
633
+ }
634
+ fs.chmodSync(file, 0o400);
635
+ return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
636
+ }
637
+ function removeTrustBundleSnapshot(snapshot) {
638
+ try {
639
+ fs.unlinkSync(snapshot.file);
640
+ }
641
+ catch (error) {
642
+ if (!isRecord(error) || error.code !== "ENOENT")
643
+ throw error;
644
+ }
645
+ try {
646
+ fs.rmdirSync(path.dirname(snapshot.file));
647
+ }
648
+ catch (error) {
649
+ if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY"))
650
+ throw error;
651
+ }
652
+ }
206
653
  function workflowSubjectRef(claim) {
207
654
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
208
655
  return metadata && typeof metadata.workflow_subject_ref === "string"
@@ -216,7 +663,9 @@ function projectFlowRun(context, run, sidecar) {
216
663
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
217
664
  const gates = openGates(definition, run.state);
218
665
  const complete = run.state.status === "completed";
219
- const action = complete ? { skills: [], operations: [] } : stepAction(run.state.current_step);
666
+ const paused = run.state.status === "paused";
667
+ const canceled = run.state.status === "canceled";
668
+ const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
220
669
  if (!action) {
221
670
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
222
671
  }
@@ -225,7 +674,7 @@ function projectFlowRun(context, run, sidecar) {
225
674
  .map((expectation) => `${expectation.id} (${expectation.bundle_claim.claimType}/${expectation.bundle_claim.subjectType ?? "any"})`));
226
675
  const skills = action?.skills ?? [];
227
676
  const operations = action?.operations ?? [];
228
- const syncCommand = `flow-agents builder-run sync --session-dir .kontourai/flow-agents/${context.slug}`;
677
+ const syncCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), ["workflow", "status", "--session-dir", `.kontourai/flow-agents/${context.slug}`, "--json"]);
229
678
  const routeBack = latestRouteBack(run.state);
230
679
  const skillText = skills.length ? `Activate ${skills.map((skill) => `\`${skill}\``).join(" then ")}.` : "No Builder skill is required.";
231
680
  const operationText = operations.length ? ` Perform ${operations.map((operation) => `\`${operation}\``).join(" then ")}.` : "";
@@ -237,18 +686,22 @@ function projectFlowRun(context, run, sidecar) {
237
686
  : "";
238
687
  const nextAction = complete
239
688
  ? { status: "done", summary: "Canonical Flow run is complete." }
240
- : {
241
- status: "continue",
242
- summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
243
- skills,
244
- operations,
245
- command: syncCommand,
246
- };
689
+ : canceled
690
+ ? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
691
+ : paused
692
+ ? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
693
+ : {
694
+ status: "continue",
695
+ summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
696
+ skills,
697
+ operations,
698
+ command: syncCommand,
699
+ };
247
700
  const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
248
701
  return {
249
702
  ...sidecar,
250
- status: complete ? "delivered" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
251
- phase: complete ? "done" : phase,
703
+ status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
704
+ phase: complete || canceled ? "done" : phase,
252
705
  updated_at: run.state.updated_at,
253
706
  flow_run: {
254
707
  run_id: run.runId,
@@ -313,7 +766,7 @@ function prepareProjectionWrites(context, projection, expectedStateRaw, operatio
313
766
  continue;
314
767
  const output = {
315
768
  ...pointer,
316
- active_flow_id: BUILDER_BUILD_FLOW_ID,
769
+ active_flow_id: projection.flow_run.definition_id,
317
770
  active_step_id: projection.flow_run.current_step,
318
771
  updated_at: projection.updated_at,
319
772
  };
@@ -410,11 +863,11 @@ function writeExistingFileNoFollow(file, content) {
410
863
  fs.closeSync(descriptor);
411
864
  }
412
865
  }
413
- function stepAction(stepId) {
866
+ function stepAction(flowId, stepId) {
414
867
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
415
868
  const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
416
869
  const action = actions.find((candidate) => isRecord(candidate)
417
- && candidate.flow_id === BUILDER_BUILD_FLOW_ID
870
+ && candidate.flow_id === flowId
418
871
  && candidate.step_id === stepId);
419
872
  if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill) => typeof skill === "string")) {
420
873
  return null;