@kontourai/flow-agents 3.4.1 → 3.4.3

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 (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/build/src/builder-flow-runtime.d.ts +1 -0
  3. package/build/src/builder-flow-runtime.js +192 -30
  4. package/build/src/cli/builder-run.js +13 -4
  5. package/build/src/cli/workflow-sidecar.js +90 -7
  6. package/docs/spec/builder-flow-runtime.md +35 -2
  7. package/docs/spec/runtime-hook-surface.md +2 -2
  8. package/docs/workflow-usage-guide.md +12 -0
  9. package/evals/integration/test_builder_entry_enforcement.sh +139 -5
  10. package/evals/integration/test_builder_step_producers.sh +1 -1
  11. package/evals/integration/test_bundle_install.sh +2 -1
  12. package/evals/integration/test_current_json_per_actor.sh +2 -2
  13. package/evals/integration/test_dual_emit_flow_step.sh +41 -9
  14. package/evals/integration/test_flowdef_session_activation.sh +11 -5
  15. package/evals/integration/test_install_merge.sh +24 -0
  16. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
  17. package/evals/integration/test_resolvefirststep_security.sh +1 -1
  18. package/evals/integration/test_sidecar_field_preservation.sh +4 -2
  19. package/evals/integration/test_takeover_protocol.sh +1 -1
  20. package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
  21. package/kits/builder/skills/continue-work/SKILL.md +7 -0
  22. package/package.json +1 -1
  23. package/schemas/workflow-state.schema.json +8 -0
  24. package/scripts/install-merge.js +4 -1
  25. package/src/builder-flow-runtime.ts +231 -29
  26. package/src/cli/builder-flow-runtime.test.mjs +374 -0
  27. package/src/cli/builder-run.ts +13 -4
  28. package/src/cli/workflow-sidecar.ts +91 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.4.3](https://github.com/kontourai/flow-agents/compare/v3.4.2...v3.4.3) (2026-07-10)
4
+
5
+
6
+ ### Fixes
7
+
8
+ * start Builder Flow during session creation ([#539](https://github.com/kontourai/flow-agents/issues/539)) ([10214b4](https://github.com/kontourai/flow-agents/commit/10214b478530e522f0dc50c24175d1516c113431))
9
+
10
+ ## [3.4.2](https://github.com/kontourai/flow-agents/compare/v3.4.1...v3.4.2) (2026-07-10)
11
+
12
+
13
+ ### Fixes
14
+
15
+ * enforce canonical Builder entry action ([#536](https://github.com/kontourai/flow-agents/issues/536)) ([bab5dfe](https://github.com/kontourai/flow-agents/commit/bab5dfe419a42b137dcb79bd276edf3268c5fb23))
16
+
3
17
  ## [3.4.1](https://github.com/kontourai/flow-agents/compare/v3.4.0...v3.4.1) (2026-07-10)
4
18
 
5
19
 
@@ -12,5 +12,6 @@ export interface BuilderFlowSessionResult {
12
12
  }
13
13
  export declare function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
14
14
  export declare function syncBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
15
+ export declare function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult>;
15
16
  export declare function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null>;
16
17
  export {};
@@ -5,8 +5,8 @@ import { expectationsForGate, openGates, readJson, runDir, sha256File, } from "@
5
5
  import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, evaluateBuilderBuildRun, loadBuilderBuildRun, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
6
6
  export async function startBuilderFlowSession(input) {
7
7
  const context = resolveSessionContext(input.sessionDir);
8
- const sidecarState = readSidecarState(context);
9
- const subject = workflowSubject(sidecarState);
8
+ const sidecarSnapshot = readSidecarSnapshot(context);
9
+ const subject = workflowSubject(sidecarSnapshot.state);
10
10
  let run;
11
11
  try {
12
12
  run = await loadBuilderBuildRun({
@@ -26,15 +26,38 @@ export async function startBuilderFlowSession(input) {
26
26
  },
27
27
  });
28
28
  }
29
- return syncAndProject(context, run);
29
+ assertRunSubjectBinding(run, subject);
30
+ return syncAndProject(context, run, sidecarSnapshot);
30
31
  }
31
32
  export async function syncBuilderFlowSession(input) {
32
33
  const context = resolveSessionContext(input.sessionDir);
34
+ const sidecarSnapshot = readSidecarSnapshot(context);
35
+ const subject = workflowSubject(sidecarSnapshot.state);
33
36
  const run = await loadBuilderBuildRun({
34
37
  cwd: context.projectRoot,
35
38
  runId: context.slug,
36
39
  });
37
- return syncAndProject(context, run);
40
+ assertRunSubjectBinding(run, subject);
41
+ return syncAndProject(context, run, sidecarSnapshot);
42
+ }
43
+ export async function recoverBuilderFlowSession(input) {
44
+ const context = resolveSessionContext(input.sessionDir);
45
+ const sidecarSnapshot = readSidecarSnapshot(context);
46
+ const subject = workflowSubject(sidecarSnapshot.state);
47
+ const run = await loadBuilderBuildRun({
48
+ cwd: context.projectRoot,
49
+ runId: context.slug,
50
+ });
51
+ assertRunSubjectBinding(run, subject);
52
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
53
+ writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
54
+ return {
55
+ sessionDir: context.sessionDir,
56
+ projectRoot: context.projectRoot,
57
+ run,
58
+ projection,
59
+ attached: false,
60
+ };
38
61
  }
39
62
  export async function syncBuilderFlowSessionIfPresent(sessionDir) {
40
63
  let context;
@@ -50,7 +73,7 @@ export async function syncBuilderFlowSessionIfPresent(sessionDir) {
50
73
  return null;
51
74
  return syncBuilderFlowSession({ sessionDir });
52
75
  }
53
- async function syncAndProject(context, initial) {
76
+ async function syncAndProject(context, initial, sidecarSnapshot) {
54
77
  let run = initial;
55
78
  let attached = false;
56
79
  const gates = openGatesForResult(run);
@@ -78,8 +101,8 @@ async function syncAndProject(context, initial) {
78
101
  }
79
102
  }
80
103
  }
81
- const projection = projectFlowRun(context, run);
82
- writeProjection(context, projection);
104
+ const projection = projectFlowRun(context, run, sidecarSnapshot.state);
105
+ writeProjection(context, projection, sidecarSnapshot.raw, "projection");
83
106
  return {
84
107
  sessionDir: context.sessionDir,
85
108
  projectRoot: context.projectRoot,
@@ -88,6 +111,16 @@ async function syncAndProject(context, initial) {
88
111
  attached,
89
112
  };
90
113
  }
114
+ function assertRunSubjectBinding(run, subject) {
115
+ if (run.state.subject !== subject) {
116
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
117
+ }
118
+ if (isRecord(run.state.params)
119
+ && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
120
+ && run.state.params.subject !== subject) {
121
+ throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
122
+ }
123
+ }
91
124
  function resolveSessionContext(sessionDirInput) {
92
125
  const sessionDir = path.resolve(sessionDirInput);
93
126
  const artifactRoot = path.dirname(sessionDir);
@@ -95,6 +128,7 @@ function resolveSessionContext(sessionDirInput) {
95
128
  if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
96
129
  throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
97
130
  }
131
+ assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
98
132
  const slug = path.basename(sessionDir);
99
133
  if (!slug || slug === "." || slug === "..") {
100
134
  throw new BuilderBuildRunInputError("sessionDir", "must name a session");
@@ -103,6 +137,7 @@ function resolveSessionContext(sessionDirInput) {
103
137
  if (!fs.existsSync(stateFile)) {
104
138
  throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
105
139
  }
140
+ assertSafeFile(stateFile, sessionDir, "state.json");
106
141
  return {
107
142
  sessionDir,
108
143
  artifactRoot,
@@ -112,18 +147,21 @@ function resolveSessionContext(sessionDirInput) {
112
147
  bundleFile: path.join(sessionDir, "trust.bundle"),
113
148
  };
114
149
  }
115
- function readSidecarState(context) {
116
- const value = JSON.parse(fs.readFileSync(context.stateFile, "utf8"));
150
+ function readSidecarSnapshot(context) {
151
+ assertSafeFile(context.stateFile, context.sessionDir, "state.json");
152
+ const raw = fs.readFileSync(context.stateFile, "utf8");
153
+ const value = JSON.parse(raw);
117
154
  if (!isRecord(value) || value.task_slug !== context.slug) {
118
155
  throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
119
156
  }
120
- return value;
157
+ return { state: value, raw };
121
158
  }
122
159
  function workflowSubject(state) {
123
- const refs = Array.isArray(state.work_item_refs)
124
- ? state.work_item_refs.filter((value) => typeof value === "string" && value.length > 0)
125
- : [];
126
- if (refs.length !== 1) {
160
+ const refs = state.work_item_refs;
161
+ if (!Array.isArray(refs)
162
+ || refs.length !== 1
163
+ || typeof refs[0] !== "string"
164
+ || refs[0].trim().length === 0) {
127
165
  throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
128
166
  }
129
167
  return refs[0];
@@ -174,8 +212,7 @@ function workflowSubjectRef(claim) {
174
212
  function manifestEvidence(manifest) {
175
213
  return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
176
214
  }
177
- function projectFlowRun(context, run) {
178
- const sidecar = readSidecarState(context);
215
+ function projectFlowRun(context, run, sidecar) {
179
216
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
180
217
  const gates = openGates(definition, run.state);
181
218
  const complete = run.state.status === "completed";
@@ -227,25 +264,150 @@ function projectFlowRun(context, run) {
227
264
  next_action: nextAction,
228
265
  };
229
266
  }
230
- function writeProjection(context, projection) {
231
- fs.writeFileSync(context.stateFile, `${JSON.stringify(projection, null, 2)}\n`);
232
- const pointerFiles = [path.join(context.artifactRoot, "current.json")];
267
+ function writeProjection(context, projection, expectedStateRaw, operation) {
268
+ const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
269
+ assertProjectionTargetsUnchanged(context, prepared, operation);
270
+ for (const write of prepared.writes)
271
+ writeExistingFileNoFollow(write.file, write.content);
272
+ }
273
+ function prepareProjectionWrites(context, projection, expectedStateRaw, operation) {
274
+ const targets = [];
275
+ const writes = [];
276
+ const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
277
+ targets.push(stateTarget);
278
+ if (stateTarget.raw !== expectedStateRaw) {
279
+ throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
280
+ }
281
+ writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
282
+ const pointerFiles = [];
283
+ const globalPointer = path.join(context.artifactRoot, "current.json");
284
+ const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
285
+ targets.push(globalTarget);
286
+ if (globalTarget.raw !== null)
287
+ pointerFiles.push(globalPointer);
233
288
  const actorRoot = path.join(context.artifactRoot, "current");
234
- if (fs.existsSync(actorRoot)) {
235
- pointerFiles.push(...fs.readdirSync(actorRoot)
236
- .filter((name) => name.endsWith(".json"))
237
- .map((name) => path.join(actorRoot, name)));
289
+ let actorEntries = null;
290
+ if (pathExistsNoFollow(actorRoot)) {
291
+ assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
292
+ actorEntries = fs.readdirSync(actorRoot).sort();
293
+ for (const name of actorEntries) {
294
+ const file = path.join(actorRoot, name);
295
+ const stat = fs.lstatSync(file);
296
+ if (stat.isSymbolicLink()) {
297
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
298
+ }
299
+ if (!name.endsWith(".json"))
300
+ continue;
301
+ if (!stat.isFile()) {
302
+ throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
303
+ }
304
+ const target = readProjectionTarget(file, actorRoot, `current/${name}`);
305
+ targets.push(target);
306
+ pointerFiles.push(file);
307
+ }
238
308
  }
239
309
  for (const file of pointerFiles) {
240
- if (!fs.existsSync(file))
241
- continue;
242
- const pointer = JSON.parse(fs.readFileSync(file, "utf8"));
310
+ const target = targets.find((candidate) => candidate.file === file);
311
+ const pointer = parseProjectionTarget(target);
243
312
  if (!isRecord(pointer) || pointer.active_slug !== context.slug)
244
313
  continue;
245
- pointer.active_flow_id = BUILDER_BUILD_FLOW_ID;
246
- pointer.active_step_id = projection.flow_run.current_step;
247
- pointer.updated_at = projection.updated_at;
248
- fs.writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`);
314
+ const output = {
315
+ ...pointer,
316
+ active_flow_id: BUILDER_BUILD_FLOW_ID,
317
+ active_step_id: projection.flow_run.current_step,
318
+ updated_at: projection.updated_at,
319
+ };
320
+ writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
321
+ }
322
+ return { targets, actorEntries, writes };
323
+ }
324
+ function assertProjectionTargetsUnchanged(context, prepared, operation) {
325
+ const actorRoot = path.join(context.artifactRoot, "current");
326
+ const currentActorEntries = pathExistsNoFollow(actorRoot)
327
+ ? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
328
+ : null;
329
+ if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
330
+ throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
331
+ }
332
+ for (const target of prepared.targets) {
333
+ const current = readOptionalProjectionTarget(target.file, target.root, target.field);
334
+ if (current.raw !== target.raw) {
335
+ throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
336
+ }
337
+ }
338
+ }
339
+ function readOptionalProjectionTarget(file, root, field) {
340
+ if (!pathExistsNoFollow(file))
341
+ return { file, raw: null, root, field };
342
+ return readProjectionTarget(file, root, field);
343
+ }
344
+ function readProjectionTarget(file, root, field) {
345
+ assertSafeFile(file, root, field);
346
+ return { file, raw: fs.readFileSync(file, "utf8"), root, field };
347
+ }
348
+ function parseProjectionTarget(target) {
349
+ try {
350
+ return JSON.parse(target.raw);
351
+ }
352
+ catch (error) {
353
+ throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
354
+ }
355
+ }
356
+ function assertSafeDirectory(directory, root, field) {
357
+ if (!pathExistsNoFollow(directory)) {
358
+ throw new BuilderBuildRunInputError(field, "must exist");
359
+ }
360
+ const stat = fs.lstatSync(directory);
361
+ if (stat.isSymbolicLink()) {
362
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
363
+ }
364
+ if (!stat.isDirectory()) {
365
+ throw new BuilderBuildRunInputError(field, "must be a directory");
366
+ }
367
+ assertContainedPath(directory, root, field);
368
+ }
369
+ function pathExistsNoFollow(candidate) {
370
+ try {
371
+ fs.lstatSync(candidate);
372
+ return true;
373
+ }
374
+ catch (error) {
375
+ if (isRecord(error) && error.code === "ENOENT")
376
+ return false;
377
+ throw error;
378
+ }
379
+ }
380
+ function assertSafeFile(file, root, field) {
381
+ const stat = fs.lstatSync(file);
382
+ if (stat.isSymbolicLink()) {
383
+ throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
384
+ }
385
+ if (!stat.isFile()) {
386
+ throw new BuilderBuildRunInputError(field, "must be a regular file");
387
+ }
388
+ assertContainedPath(file, root, field);
389
+ }
390
+ function assertContainedPath(candidate, root, field) {
391
+ if (!pathIsWithin(candidate, root)) {
392
+ throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
393
+ }
394
+ const realCandidate = fs.realpathSync(candidate);
395
+ const realRoot = fs.realpathSync(root);
396
+ if (!pathIsWithin(realCandidate, realRoot)) {
397
+ throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
398
+ }
399
+ }
400
+ function pathIsWithin(candidate, root) {
401
+ const relative = path.relative(root, candidate);
402
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
403
+ }
404
+ function writeExistingFileNoFollow(file, content) {
405
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
406
+ try {
407
+ fs.writeFileSync(descriptor, content);
408
+ }
409
+ finally {
410
+ fs.closeSync(descriptor);
249
411
  }
250
412
  }
251
413
  function stepAction(stepId) {
@@ -1,20 +1,29 @@
1
1
  import { flagString, parseArgs } from "../lib/args.js";
2
- import { startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
2
+ import { recoverBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
3
3
  export async function main(argv) {
4
4
  const parsed = parseArgs(argv);
5
5
  const action = parsed.positionals[0];
6
6
  const sessionDir = flagString(parsed.flags, "session-dir");
7
+ const validRecoveryArguments = parsed.positionals.length === 1
8
+ && Object.keys(parsed.flags).length === 1
9
+ && typeof parsed.flags["session-dir"] === "string";
10
+ if (action === "recover" && !validRecoveryArguments) {
11
+ console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
12
+ return 64;
13
+ }
7
14
  if (!sessionDir) {
8
15
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
9
16
  return 64;
10
17
  }
11
- if (action !== "start" && action !== "sync") {
12
- console.error("Usage: flow-agents builder-run <start|sync> --session-dir <path>");
18
+ if (action !== "start" && action !== "sync" && action !== "recover") {
19
+ console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
13
20
  return 64;
14
21
  }
15
22
  const result = action === "start"
16
23
  ? await startBuilderFlowSession({ sessionDir })
17
- : await syncBuilderFlowSession({ sessionDir });
24
+ : action === "sync"
25
+ ? await syncBuilderFlowSession({ sessionDir })
26
+ : await recoverBuilderFlowSession({ sessionDir });
18
27
  console.log(JSON.stringify({
19
28
  run_id: result.run.runId,
20
29
  definition_id: result.run.definitionId,
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
10
10
  import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
- import { syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
12
+ import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
13
13
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
14
14
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
15
15
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
@@ -1292,9 +1292,13 @@ async function withLock(dir, create, command, body) {
1292
1292
  const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
1293
1293
  const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
1294
1294
  const deadline = Date.now() + 30000;
1295
+ let acquiredRoot = null;
1296
+ let acquiredLock = null;
1295
1297
  while (true) {
1296
1298
  try {
1297
1299
  fs.mkdirSync(lockDir);
1300
+ acquiredRoot = fs.lstatSync(dir);
1301
+ acquiredLock = fs.lstatSync(lockDir);
1298
1302
  break;
1299
1303
  }
1300
1304
  catch (error) {
@@ -1326,7 +1330,29 @@ async function withLock(dir, create, command, body) {
1326
1330
  return await body();
1327
1331
  }
1328
1332
  finally {
1329
- fs.rmSync(lockDir, { recursive: true, force: true });
1333
+ try {
1334
+ const currentRoot = fs.lstatSync(dir);
1335
+ const currentLock = fs.lstatSync(lockDir);
1336
+ const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
1337
+ if (acquiredRoot
1338
+ && acquiredLock
1339
+ && !currentRoot.isSymbolicLink()
1340
+ && currentRoot.isDirectory()
1341
+ && !currentLock.isSymbolicLink()
1342
+ && currentLock.isDirectory()
1343
+ && sameIdentity(currentRoot, acquiredRoot)
1344
+ && sameIdentity(currentLock, acquiredLock)) {
1345
+ fs.rmdirSync(lockDir);
1346
+ }
1347
+ else {
1348
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
1349
+ }
1350
+ }
1351
+ catch (error) {
1352
+ if (error.code !== "ENOENT") {
1353
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
1354
+ }
1355
+ }
1330
1356
  }
1331
1357
  }
1332
1358
  function section(text, heading) {
@@ -1692,12 +1718,16 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1692
1718
  // existing state.json's created_at when present; stamp only on true first-creation.
1693
1719
  // updated_at still reflects "now" on every call — that field is intentionally mutable.
1694
1720
  const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
1721
+ const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
1722
+ const nextActionPayload = typeof nextAction === "string"
1723
+ ? { status: "continue", summary: nextAction || summary }
1724
+ : { status: "continue", ...nextAction, summary: nextActionSummary };
1695
1725
  writeJson(path.join(dir, "state.json"), {
1696
1726
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1697
1727
  ...(branch ? { branch } : {}),
1698
1728
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1699
1729
  artifact_paths: relArtifacts(dir),
1700
- next_action: { status: "continue", summary: nextAction || summary },
1730
+ next_action: nextActionPayload,
1701
1731
  });
1702
1732
  writeJson(path.join(dir, "acceptance.json"), {
1703
1733
  ...sidecarBase(slug), source_request: sourceRequest,
@@ -1705,7 +1735,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1705
1735
  goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
1706
1736
  });
1707
1737
  writeJson(path.join(dir, "handoff.json"), {
1708
- ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextAction ? [nextAction] : [], blockers: [], warnings: [],
1738
+ ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
1709
1739
  });
1710
1740
  }
1711
1741
  /** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
@@ -1926,18 +1956,55 @@ function resolveEnsureSessionEntry(p, dir) {
1926
1956
  }
1927
1957
  return { flowId, stepId: explicitStep || firstStep, firstStep };
1928
1958
  }
1959
+ function assertCanonicalBuilderArtifactRoot(root) {
1960
+ const kontouraiRoot = path.dirname(root);
1961
+ const projectRoot = path.dirname(kontouraiRoot);
1962
+ if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
1963
+ die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
1964
+ }
1965
+ const statIfPresent = (candidate) => {
1966
+ try {
1967
+ return fs.lstatSync(candidate);
1968
+ }
1969
+ catch (error) {
1970
+ if (error.code === "ENOENT")
1971
+ return null;
1972
+ throw error;
1973
+ }
1974
+ };
1975
+ const projectStat = statIfPresent(projectRoot);
1976
+ if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
1977
+ die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
1978
+ }
1979
+ const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
1980
+ for (const [candidate, expected, label] of [
1981
+ [kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
1982
+ [root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
1983
+ ]) {
1984
+ const stat = statIfPresent(candidate);
1985
+ if (!stat)
1986
+ continue;
1987
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
1988
+ die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
1989
+ }
1990
+ }
1991
+ }
1929
1992
  function preflightEnsureSession(p) {
1930
1993
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1931
1994
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
1932
1995
  const dir = sessionDirFor(root, slug);
1933
- resolveEnsureSessionEntry(p, dir);
1996
+ const entry = resolveEnsureSessionEntry(p, dir);
1997
+ if (entry.flowId === "builder.build")
1998
+ assertCanonicalBuilderArtifactRoot(root);
1934
1999
  sessionWorkItem(p, slug, dir);
1935
2000
  }
1936
- function ensureSession(p) {
2001
+ async function ensureSession(p) {
1937
2002
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1938
2003
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
1939
2004
  const dir = sessionDirFor(root, slug);
1940
2005
  const entry = resolveEnsureSessionEntry(p, dir);
2006
+ if (entry.flowId === "builder.build")
2007
+ assertCanonicalBuilderArtifactRoot(root);
1941
2008
  const workItem = sessionWorkItem(p, slug, dir);
1942
2009
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1943
2010
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -1964,9 +2031,15 @@ function ensureSession(p) {
1964
2031
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
1965
2032
  const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
1966
2033
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2034
+ const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
1967
2035
  const nextAction = entry.flowId
1968
2036
  ? entry.flowId === "builder.build"
1969
- ? `Start the canonical Flow run with \`flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}\`; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`
2037
+ ? {
2038
+ status: "continue",
2039
+ summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
2040
+ skills: ["pull-work"],
2041
+ command: startCommand,
2042
+ }
1970
2043
  : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1971
2044
  : opt(p, "next-action", "Continue.");
1972
2045
  initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), nextAction, timestamp, md, [workItem.ref], initialPhase ? { status: "new", phase: initialPhase } : undefined);
@@ -1986,6 +2059,16 @@ function ensureSession(p) {
1986
2059
  ? persistedCurrent.active_step_id
1987
2060
  : entry.stepId;
1988
2061
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2062
+ if (entry.flowId === "builder.build") {
2063
+ try {
2064
+ await startBuilderFlowSession({ sessionDir: dir });
2065
+ }
2066
+ catch (error) {
2067
+ const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2068
+ process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
2069
+ throw error;
2070
+ }
2071
+ }
1989
2072
  console.log(dir);
1990
2073
  return 0;
1991
2074
  }
@@ -28,13 +28,24 @@ expectations, and Builder Kit's structured action map.
28
28
 
29
29
  ## Entry And Synchronization
30
30
 
31
- A Builder session must first be created at the Flow Definition's entry step by the
32
- workflow sidecar. Start the canonical run with:
31
+ A Builder session is created at the Flow Definition's entry step by the workflow
32
+ sidecar. If automatic startup reports a failure, retry the canonical operation with:
33
33
 
34
34
  ```bash
35
35
  flow-agents builder-run start --session-dir .kontourai/flow-agents/<slug>
36
36
  ```
37
37
 
38
+ `ensure-session --flow-id builder.build` starts or loads this canonical run before
39
+ returning, then projects the first Flow step into the sidecar. The command remains
40
+ the explicit recovery path if Flow startup fails after sidecar creation; the
41
+ failure is returned to the caller and no substitute run state is invented. Runtime
42
+ hooks keep projected actions advisory while the agent performs their declared
43
+ skills and operations.
44
+
45
+ Sidecars written by 3.4.2 may still contain `next_action.enforcement`. The 1.0
46
+ schema accepts that deprecated field for artifact compatibility, but current
47
+ runtime steering ignores it and does not install a PreToolUse bootstrap hook.
48
+
38
49
  `start` requires exactly one `state.work_item_refs` entry and uses that stable Work
39
50
  Item reference as the Flow run subject. It is idempotent for an existing canonical
40
51
  run. A direct primitive session without a Builder Flow stamp remains independent and
@@ -51,6 +62,28 @@ Synchronization is digest-idempotent. The same trust bundle is not attached twic
51
62
  Inspection loads the run without evaluating it, so invalid evidence is rejected
52
63
  before Flow mutation.
53
64
 
65
+ ## Recovery
66
+
67
+ Recover an interrupted canonical Builder session with:
68
+
69
+ ```bash
70
+ flow-agents builder-run recover --session-dir .kontourai/flow-agents/<slug>
71
+ ```
72
+
73
+ `recover` derives the Flow run id from the session directory slug; callers cannot
74
+ select a run id or step. It requires exactly one non-empty
75
+ `state.work_item_refs` entry, loads the existing run through Flow's canonical load
76
+ API, and verifies that the ref matches persisted `state.subject` and, when present,
77
+ `state.params.subject`. A missing, foreign, or corrupt run fails closed and is not
78
+ created or repaired.
79
+
80
+ Recovery is load/validate/project only. It computes the complete projection before
81
+ updating `state.json`, the matching global `current.json`, and matching per-actor
82
+ current pointers. It does not inspect or attach `trust.bundle`, evaluate gates, or
83
+ write any file in `.kontourai/flow/runs/<slug>/`; the complete Flow run tree remains
84
+ byte-identical. Use `sync`, not `recover`, to attach recorded evidence and evaluate
85
+ the current gate.
86
+
54
87
  ## Trust Binding
55
88
 
56
89
  Claims relevant to the current gate must carry
@@ -130,9 +130,9 @@ Flow Agents currently ships five canonical policy classes. Each policy class has
130
130
  - `.kontourai/flow-agents/<slug>/critique.json` — open critique findings
131
131
  - `docs/context-map.md` — structure hint for repo navigation
132
132
 
133
- **Decision contract**: Non-blocking. Always exits 0. Appends steering text to the agent's context via `additionalContext` in the hook response. Does not block any action. It re-grounds the active workflow goal (status, phase, recorded next step) at the start of every user turn — not only for flagged/blocked states — and on `SessionStart`, which fires after context compaction and on resume. This is the mechanism that keeps an in-flight goal alive across context loss instead of relying on the model voluntarily re-reading the sidecar.
133
+ **Decision contract**: Non-blocking. Always exits 0. Appends steering text to the agent's context via `additionalContext`. It re-grounds the active workflow goal (status, phase, recorded next step) at the start of every user turn — not only for flagged/blocked states — and on `SessionStart`, which fires after context compaction and on resume. Canonical Builder run creation is part of session orchestration rather than a model-mediated hook action.
134
134
 
135
- **Degradation when host lacks trigger**: If the host has no `userPromptSubmit`-equivalent hook, workflow steering is silent. The agent receives no ambient phase reminders at turn start. This is a capability loss, not a blocking failure. Log the gap in the adapter's conformance declaration as `userPromptSubmit: no native equivalent — steering context injection unavailable`.
135
+ **Degradation when host lacks trigger**: If the host has no `userPromptSubmit`-equivalent hook, workflow steering is silent. The agent receives no ambient phase reminders at turn start. This is a capability loss, not a blocking failure. Log the gap in the adapter's conformance declaration.
136
136
 
137
137
  **Codex live hook influence caveat**: Codex hook influence on live sessions is limited — the agent may not honor all injected context. The hook influence behavioral cases in `evals/fixtures/hook-influence/cases.json` document which behaviors are expected (`agent_must_do`) versus which may only be soft guidance. Adapters on similar runtimes should apply the same classification.
138
138
 
@@ -591,6 +591,18 @@ Start a newly selected session with `flow-agents builder-run start --session-dir
591
591
  [`docs/spec/builder-flow-runtime.md`](spec/builder-flow-runtime.md) for the ownership,
592
592
  trust-binding, route-back, and artifact-root contract.
593
593
 
594
+ If the same Builder slice is interrupted and its sidecar/current pointers may be
595
+ stale, restore the projection from the existing canonical Flow run with:
596
+
597
+ ```bash
598
+ flow-agents builder-run recover --session-dir .kontourai/flow-agents/<slug>
599
+ ```
600
+
601
+ The session slug is the run identity; recovery accepts no caller-selected run or
602
+ step. It validates the sole Work Item binding and updates only the sidecar/current
603
+ projection, leaving the entire Flow run byte-identical. Recovery never attaches or
604
+ evaluates `trust.bundle`; use `builder-run sync` for that separate operation.
605
+
594
606
  When a session resumes (after context compaction, an agent restart, or a cross-session
595
607
  handoff), the workflow-steering hook emits a `RESUME:` block on `SessionStart` that
596
608
  gives the resuming agent immediate situational awareness without blocking or auto-deciding.