@kontourai/flow-agents 3.4.2 → 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 (29) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agents/dev.json +0 -5
  3. package/build/src/builder-flow-runtime.js +18 -12
  4. package/build/src/cli/workflow-sidecar.js +77 -5
  5. package/build/src/tools/build-universal-bundles.js +0 -2
  6. package/context/scripts/hooks/workflow-steering.js +2 -40
  7. package/docs/spec/builder-flow-runtime.md +12 -7
  8. package/docs/spec/runtime-hook-surface.md +4 -4
  9. package/evals/integration/test_builder_entry_enforcement.sh +138 -7
  10. package/evals/integration/test_builder_step_producers.sh +1 -1
  11. package/evals/integration/test_bundle_install.sh +3 -3
  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/evals/integration/test_workflow_steering_hook.sh +0 -72
  22. package/package.json +1 -1
  23. package/schemas/workflow-state.schema.json +1 -0
  24. package/scripts/hooks/workflow-steering.js +2 -40
  25. package/scripts/install-merge.js +2 -0
  26. package/src/builder-flow-runtime.ts +23 -12
  27. package/src/cli/builder-flow-runtime.test.mjs +32 -0
  28. package/src/cli/workflow-sidecar.ts +76 -6
  29. package/src/tools/build-universal-bundles.ts +0 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [3.4.2](https://github.com/kontourai/flow-agents/compare/v3.4.1...v3.4.2) (2026-07-10)
4
11
 
5
12
 
package/agents/dev.json CHANGED
@@ -53,11 +53,6 @@
53
53
  "matcher": "*",
54
54
  "timeout_ms": 3000
55
55
  },
56
- {
57
- "command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:workflow-entry workflow-steering.js standard,strict",
58
- "matcher": "*",
59
- "timeout_ms": 5000
60
- },
61
56
  {
62
57
  "command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:config-protection config-protection.js standard,strict",
63
58
  "matcher": "fs_write",
@@ -26,15 +26,19 @@ 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);
38
42
  }
39
43
  export async function recoverBuilderFlowSession(input) {
40
44
  const context = resolveSessionContext(input.sessionDir);
@@ -44,14 +48,7 @@ export async function recoverBuilderFlowSession(input) {
44
48
  cwd: context.projectRoot,
45
49
  runId: context.slug,
46
50
  });
47
- if (run.state.subject !== subject) {
48
- throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
49
- }
50
- if (isRecord(run.state.params)
51
- && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
52
- && run.state.params.subject !== subject) {
53
- throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
54
- }
51
+ assertRunSubjectBinding(run, subject);
55
52
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
56
53
  writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
57
54
  return {
@@ -76,7 +73,7 @@ export async function syncBuilderFlowSessionIfPresent(sessionDir) {
76
73
  return null;
77
74
  return syncBuilderFlowSession({ sessionDir });
78
75
  }
79
- async function syncAndProject(context, initial) {
76
+ async function syncAndProject(context, initial, sidecarSnapshot) {
80
77
  let run = initial;
81
78
  let attached = false;
82
79
  const gates = openGatesForResult(run);
@@ -104,7 +101,6 @@ async function syncAndProject(context, initial) {
104
101
  }
105
102
  }
106
103
  }
107
- const sidecarSnapshot = readSidecarSnapshot(context);
108
104
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
109
105
  writeProjection(context, projection, sidecarSnapshot.raw, "projection");
110
106
  return {
@@ -115,6 +111,16 @@ async function syncAndProject(context, initial) {
115
111
  attached,
116
112
  };
117
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
+ }
118
124
  function resolveSessionContext(sessionDirInput) {
119
125
  const sessionDir = path.resolve(sessionDirInput);
120
126
  const artifactRoot = path.dirname(sessionDir);
@@ -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) {
@@ -1930,18 +1956,55 @@ function resolveEnsureSessionEntry(p, dir) {
1930
1956
  }
1931
1957
  return { flowId, stepId: explicitStep || firstStep, firstStep };
1932
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
+ }
1933
1992
  function preflightEnsureSession(p) {
1934
1993
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1935
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)"));
1936
1995
  const dir = sessionDirFor(root, slug);
1937
- resolveEnsureSessionEntry(p, dir);
1996
+ const entry = resolveEnsureSessionEntry(p, dir);
1997
+ if (entry.flowId === "builder.build")
1998
+ assertCanonicalBuilderArtifactRoot(root);
1938
1999
  sessionWorkItem(p, slug, dir);
1939
2000
  }
1940
- function ensureSession(p) {
2001
+ async function ensureSession(p) {
1941
2002
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1942
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)"));
1943
2004
  const dir = sessionDirFor(root, slug);
1944
2005
  const entry = resolveEnsureSessionEntry(p, dir);
2006
+ if (entry.flowId === "builder.build")
2007
+ assertCanonicalBuilderArtifactRoot(root);
1945
2008
  const workItem = sessionWorkItem(p, slug, dir);
1946
2009
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1947
2010
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -1976,7 +2039,6 @@ function ensureSession(p) {
1976
2039
  summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
1977
2040
  skills: ["pull-work"],
1978
2041
  command: startCommand,
1979
- enforcement: "before_tool_use",
1980
2042
  }
1981
2043
  : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1982
2044
  : opt(p, "next-action", "Continue.");
@@ -1997,6 +2059,16 @@ function ensureSession(p) {
1997
2059
  ? persistedCurrent.active_step_id
1998
2060
  : entry.stepId;
1999
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
+ }
2000
2072
  console.log(dir);
2001
2073
  return 0;
2002
2074
  }
@@ -363,7 +363,6 @@ function exportClaudeSettings() {
363
363
  hooks.UserPromptSubmit.push({ hooks: [shellHook(claudePolicy("UserPromptSubmit", "workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
364
364
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "quality-gate.js"), 30, "Running Flow Agents hook policy")] });
365
365
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
366
- hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
367
366
  hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
368
367
  return `${JSON.stringify({
369
368
  statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
@@ -381,7 +380,6 @@ function exportCodexHooks() {
381
380
  hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
382
381
  hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
383
382
  hooks.PostToolUse.push({ hooks: [shellHook(codexPolicy("evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
384
- hooks.PreToolUse.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
385
383
  return `${JSON.stringify({ hooks }, null, 2)}\n`;
386
384
  }
387
385
  function copySharedContent(targetRoot, targetName, token) {
@@ -9,8 +9,7 @@
9
9
  * survive context loss instead of relying on the model voluntarily re-reading
10
10
  * the sidecar.
11
11
  *
12
- * Advisory by default. A structured next action may explicitly require its
13
- * projected command before unrelated tool use; only that PreToolUse case blocks.
12
+ * Non-blocking always exits 0.
14
13
  */
15
14
 
16
15
  'use strict';
@@ -283,32 +282,6 @@ function stateSteering(root) {
283
282
  return parts.join(' ');
284
283
  }
285
284
 
286
- function normalizedCommand(value) {
287
- return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
288
- }
289
-
290
- function toolCommands(input) {
291
- const toolInput = input && input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
292
- return [
293
- toolInput.command,
294
- toolInput.content && toolInput.content.command,
295
- toolInput.args && toolInput.args.command,
296
- ].map(normalizedCommand).filter(Boolean);
297
- }
298
-
299
- function beforeToolUseEnforcement(input, current) {
300
- if (!input || input.hook_event_name !== 'PreToolUse' || !current) return null;
301
- const next = current.payload && current.payload.next_action;
302
- if (!next || next.status !== 'continue' || next.enforcement !== 'before_tool_use') return null;
303
- const expected = normalizedCommand(next.command);
304
- if (!expected) return null;
305
- if (toolCommands(input).some(command => command === expected)) return null;
306
- return {
307
- exitCode: 2,
308
- stderr: `[workflow-entry] Run the required projected action before using other tools. Run exactly: ${safeStateText(expected, 500)}`,
309
- };
310
- }
311
-
312
285
  function contextMapSteering(root) {
313
286
  const mapPath = path.join(root, 'docs', 'context-map.md');
314
287
  if (!fs.existsSync(mapPath)) return '';
@@ -599,8 +572,6 @@ function run(rawInput) {
599
572
  const toolInput = input.tool_input || {};
600
573
  const root = findRepoRoot(input.cwd || process.cwd());
601
574
  const current = latestWorkflowState(root);
602
- const enforcement = beforeToolUseEnforcement(input, current);
603
- if (enforcement) return enforcement;
604
575
  const hints = [];
605
576
  let shouldAppendWorkflowContext = false;
606
577
 
@@ -677,14 +648,7 @@ if (require.main === module) {
677
648
  data += chunk;
678
649
  });
679
650
  process.stdin.on('end', () => {
680
- const result = run(data);
681
- if (result && typeof result === 'object') {
682
- if (result.stderr) process.stderr.write(String(result.stderr) + (String(result.stderr).endsWith('\n') ? '' : '\n'));
683
- if (Object.prototype.hasOwnProperty.call(result, 'stdout')) process.stdout.write(String(result.stdout || ''));
684
- process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
685
- } else {
686
- process.stdout.write(String(result));
687
- }
651
+ process.stdout.write(String(run(data)));
688
652
  });
689
653
  }
690
654
 
@@ -703,6 +667,4 @@ module.exports = {
703
667
  promptText,
704
668
  looksLikeImplementationWork,
705
669
  kitWorkflowSteering,
706
- beforeToolUseEnforcement,
707
- toolCommands,
708
670
  };
@@ -28,18 +28,23 @@ 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
- The initial sidecar projects this as `next_action.command` with
39
- `enforcement: "before_tool_use"`. Runtime adapters deny unrelated tool calls until
40
- the projected command runs. Starting the canonical run replaces that bootstrap
41
- action with the ordinary Flow-step projection; later actions remain advisory while
42
- the agent performs their declared skills and operations.
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.
43
48
 
44
49
  `start` requires exactly one `state.work_item_refs` entry and uses that stable Work
45
50
  Item reference as the Flow run subject. It is idempotent for an existing canonical
@@ -123,16 +123,16 @@ Flow Agents currently ships five canonical policy classes. Each policy class has
123
123
 
124
124
  **Canonical script**: `scripts/hooks/workflow-steering.js`
125
125
 
126
- **Canonical trigger event**: `userPromptSubmit` and `agentSpawn`/`SessionStart` (active-goal re-grounding), `postToolUse` (after `InvokeSubagents` tool calls), and `preToolUse` when the projected `next_action` explicitly declares `enforcement: "before_tool_use"`
126
+ **Canonical trigger event**: `userPromptSubmit` and `agentSpawn`/`SessionStart` (active-goal re-grounding), `postToolUse` (after `InvokeSubagents` tool calls)
127
127
 
128
128
  **Inputs consumed**:
129
129
  - `.kontourai/flow-agents/<slug>/state.json` — current workflow phase and status
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**: Advisory by default. It appends steering text to the agent's context via `additionalContext` and 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. A structured `next_action` may explicitly declare a non-empty `command` with `enforcement: "before_tool_use"`; at `PreToolUse`, the canonical script then exits 2 for every unrelated call and includes the exact projected command in the denial reason. The exact projected command is allowed. This narrow bootstrap policy keeps workflow authority in the projected state instead of relying on the model voluntarily following prompt text; ordinary Flow-step projections remain advisory.
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 and the agent receives no ambient phase reminders at turn start. If the host has no block-capable `preToolUse` equivalent, explicit projected-action enforcement degrades to the same advisory command guidance. These are capability losses, not reasons to invent a second lifecycle authority. Record each missing capability in the adapter's conformance declaration.
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
 
@@ -550,7 +550,7 @@ For structured `run()` responses (native import form), the return value is:
550
550
  | config-protection | Fail-closed (exit 2 on protected file) | Yes — hook runtime errors exit 0 | Yes (preToolUse) |
551
551
  | quality-gate | Fail-open (exit 0 always) | Yes | No |
552
552
  | stop-goal-fit | Engine default warn (fail-open); blocks in `FLOW_AGENTS_GOAL_FIT_MODE=block` (shipped L2 default) | Yes — hook runtime errors exit 0 | Yes (stop, block mode) |
553
- | workflow-steering | Advisory; exit 2 only for an explicit `before_tool_use` projected command | Yes — hook runtime and malformed-state errors exit 0 | Yes (preToolUse, explicit projection only) |
553
+ | workflow-steering | Fail-open (exit 0 always) | Yes | No |
554
554
  | evidence-capture | Fail-open (exit 0 always) | Yes — capture errors never block or corrupt the log | No |
555
555
 
556
556
  **Telemetry**: Always fail-open. Hook runtime errors in telemetry scripts must never block agent work.
@@ -17,6 +17,100 @@ WRITER="workflow-sidecar"
17
17
 
18
18
  echo "=== Builder workflow entry enforcement ==="
19
19
 
20
+ NONCANONICAL_ROOT="$TMP/noncanonical-root"
21
+ if flow_agents_node "$WRITER" ensure-session \
22
+ --artifact-root "$NONCANONICAL_ROOT" \
23
+ --task-slug noncanonical \
24
+ --title "Noncanonical root" \
25
+ --summary "Builder state must use the product artifact root." \
26
+ --flow-id builder.build >"$TMP/noncanonical.out" 2>&1; then
27
+ fail "noncanonical Builder artifact root should be rejected"
28
+ elif [[ ! -e "$NONCANONICAL_ROOT" ]] \
29
+ && grep -q 'requires --artifact-root <project>/.kontourai/flow-agents' "$TMP/noncanonical.out"; then
30
+ pass "noncanonical Builder artifact root is rejected before any sidecar write"
31
+ else
32
+ fail "noncanonical Builder root left partial state or the wrong diagnostic: $(cat "$TMP/noncanonical.out")"
33
+ fi
34
+
35
+ SYMLINK_PROJECT="$TMP/symlink-project"
36
+ SYMLINK_EXTERNAL="$TMP/symlink-external"
37
+ mkdir -p "$SYMLINK_PROJECT/.kontourai" "$SYMLINK_EXTERNAL"
38
+ ln -s "$SYMLINK_EXTERNAL" "$SYMLINK_PROJECT/.kontourai/flow-agents"
39
+ if flow_agents_node "$WRITER" ensure-session \
40
+ --artifact-root "$SYMLINK_PROJECT/.kontourai/flow-agents" \
41
+ --task-slug symlink-root \
42
+ --title "Symlink root" \
43
+ --summary "Builder state must not escape the project." \
44
+ --flow-id builder.build >"$TMP/symlink-root.out" 2>&1; then
45
+ fail "symlinked Builder artifact root should be rejected"
46
+ elif [[ -z "$(find "$SYMLINK_EXTERNAL" -mindepth 1 -print -quit)" ]] \
47
+ && grep -q 'requires a non-symlink Flow Agents artifact root' "$TMP/symlink-root.out"; then
48
+ pass "symlinked Builder artifact root is rejected before any external write"
49
+ else
50
+ fail "symlinked Builder root wrote externally or returned the wrong diagnostic: $(cat "$TMP/symlink-root.out")"
51
+ fi
52
+
53
+ RACE_PROJECT="$TMP/race-project"
54
+ RACE_ROOT="$RACE_PROJECT/.kontourai/flow-agents"
55
+ RACE_MOVED="$RACE_PROJECT/.kontourai/flow-agents-acquired"
56
+ RACE_EXTERNAL="$TMP/race-external"
57
+ mkdir -p "$RACE_ROOT" "$RACE_EXTERNAL"
58
+ FLOW_AGENTS_WORKFLOW_SIDECAR_LOCK_DELAY=1 flow_agents_node "$WRITER" ensure-session \
59
+ --artifact-root "$RACE_ROOT" \
60
+ --task-slug lock-swap \
61
+ --title "Lock swap" \
62
+ --summary "Lock cleanup must remain on the acquired root." \
63
+ --flow-id builder.build >"$TMP/lock-swap.out" 2>&1 &
64
+ RACE_PID=$!
65
+ node - "$RACE_ROOT/.workflow-sidecar.lockdir" <<'NODE'
66
+ const fs = require('node:fs');
67
+ const lock = process.argv[2];
68
+ const deadline = Date.now() + 5000;
69
+ (function wait() {
70
+ if (fs.existsSync(lock)) process.exit(0);
71
+ if (Date.now() > deadline) process.exit(1);
72
+ setTimeout(wait, 10);
73
+ })();
74
+ NODE
75
+ mv "$RACE_ROOT" "$RACE_MOVED"
76
+ mkdir -p "$RACE_EXTERNAL/.workflow-sidecar.lockdir"
77
+ printf 'outside must survive\n' >"$RACE_EXTERNAL/.workflow-sidecar.lockdir/sentinel"
78
+ ln -s "$RACE_EXTERNAL" "$RACE_ROOT"
79
+ set +e
80
+ wait "$RACE_PID"
81
+ RACE_STATUS=$?
82
+ set -e
83
+ if [[ "$RACE_STATUS" -ne 0 ]] \
84
+ && [[ -f "$RACE_EXTERNAL/.workflow-sidecar.lockdir/sentinel" ]] \
85
+ && [[ -d "$RACE_MOVED/.workflow-sidecar.lockdir" ]] \
86
+ && grep -q 'lock cleanup skipped because root or lock identity changed' "$TMP/lock-swap.out"; then
87
+ pass "lock cleanup refuses a swapped root and preserves external content"
88
+ else
89
+ fail "lock cleanup followed a swapped root or lost its identity diagnostic: $(cat "$TMP/lock-swap.out")"
90
+ fi
91
+
92
+ RELEASE_STATE="$TMP/release-3.4.2-state.json"
93
+ cat >"$RELEASE_STATE" <<'JSON'
94
+ {
95
+ "schema_version": "1.0",
96
+ "task_slug": "release-3-4-2",
97
+ "status": "new",
98
+ "phase": "pickup",
99
+ "updated_at": "2026-07-10T00:00:00Z",
100
+ "next_action": {
101
+ "status": "continue",
102
+ "summary": "Start the canonical Flow run.",
103
+ "command": "flow-agents builder-run start --session-dir .kontourai/flow-agents/release-3-4-2",
104
+ "enforcement": "before_tool_use"
105
+ }
106
+ }
107
+ JSON
108
+ if flow_agents_node "validate-workflow-artifacts" --skip-markdown-validation "$RELEASE_STATE" >/dev/null 2>&1; then
109
+ pass "3.4.2 sidecars remain schema-valid while deprecated enforcement is ignored"
110
+ else
111
+ fail "3.4.2 sidecar compatibility regressed"
112
+ fi
113
+
20
114
  REFUSED_ROOT="$TMP/refused/.kontourai/flow-agents"
21
115
  if flow_agents_node "$WRITER" ensure-session \
22
116
  --artifact-root "$REFUSED_ROOT" \
@@ -63,18 +157,20 @@ const root = process.argv[2];
63
157
  const current = JSON.parse(fs.readFileSync(path.join(root, 'current.json'), 'utf8'));
64
158
  const state = JSON.parse(fs.readFileSync(path.join(root, 'local-request', 'state.json'), 'utf8'));
65
159
  const workItem = JSON.parse(fs.readFileSync(path.join(root, 'local-request', 'work-item.json'), 'utf8'));
160
+ const flowState = JSON.parse(fs.readFileSync(path.join(path.dirname(root), 'flow', 'runs', 'local-request', 'state.json'), 'utf8'));
66
161
  if (current.active_flow_id !== 'builder.build' || current.active_step_id !== 'pull-work') process.exit(1);
67
162
  if (state.status !== 'new' || state.phase !== 'pickup') process.exit(1);
68
163
  if (JSON.stringify(state.work_item_refs) !== JSON.stringify(['local:local-request'])) process.exit(1);
69
164
  if (workItem.id !== 'local-request' || workItem.title !== 'Local request') process.exit(1);
70
165
  if (workItem.source_provider?.kind !== 'local' || workItem.source_provider?.path !== 'work-item.json') process.exit(1);
71
- if (state.next_action?.command !== 'flow-agents builder-run start --session-dir .kontourai/flow-agents/local-request') process.exit(1);
72
- if (state.next_action?.enforcement !== 'before_tool_use') process.exit(1);
166
+ if (flowState.current_step !== 'pull-work' || flowState.subject !== 'local:local-request') process.exit(1);
167
+ if (state.flow_run?.current_step !== 'pull-work') process.exit(1);
73
168
  if (JSON.stringify(state.next_action?.skills) !== JSON.stringify(['pull-work'])) process.exit(1);
74
- if (!state.next_action?.summary?.includes('`pull-work`')) process.exit(1);
169
+ if (!state.next_action?.command?.includes('builder-run sync')) process.exit(1);
170
+ if ('enforcement' in state.next_action) process.exit(1);
75
171
  NODE
76
172
  then
77
- pass "providerless request creates a local Work Item and starts at pull-work"
173
+ pass "providerless request creates a local Work Item and canonical Flow run at pull-work"
78
174
  else
79
175
  fail "local Work Item or first-step state is invalid"
80
176
  fi
@@ -83,7 +179,15 @@ else
83
179
  fi
84
180
 
85
181
  LOCAL_SESSION="$LOCAL_ROOT/local-request"
86
- if flow_agents_node builder-run start --session-dir "$LOCAL_SESSION" >"$TMP/builder-start.out" 2>&1 \
182
+ FLOW_DIGEST_BEFORE="$(find "$TMP/local/.kontourai/flow/runs/local-request" -type f -print0 | sort -z | xargs -0 shasum -a 256)"
183
+ if flow_agents_node "$WRITER" ensure-session \
184
+ --artifact-root "$LOCAL_ROOT" \
185
+ --task-slug local-request \
186
+ --title "Local request" \
187
+ --summary "Providerless work still needs an anchor." \
188
+ --flow-id builder.build \
189
+ --timestamp "2026-07-10T00:00:00Z" >"$TMP/builder-ensure-again.out" 2>&1 \
190
+ && [[ "$FLOW_DIGEST_BEFORE" == "$(find "$TMP/local/.kontourai/flow/runs/local-request" -type f -print0 | sort -z | xargs -0 shasum -a 256)" ]] \
87
191
  && node - "$TMP/local" "$LOCAL_SESSION" <<'NODE'
88
192
  const fs = require('node:fs');
89
193
  const path = require('node:path');
@@ -97,9 +201,36 @@ if (JSON.stringify(sidecar.next_action?.skills) !== JSON.stringify(['pull-work']
97
201
  if (!sidecar.next_action?.command?.includes('builder-run sync')) process.exit(1);
98
202
  NODE
99
203
  then
100
- pass "small-model entry command creates canonical Flow run and projects pull-work action"
204
+ pass "repeated ensure-session loads the canonical Flow run without resetting its history"
205
+ else
206
+ fail "canonical Builder ensure was not idempotent: $(cat "$TMP/builder-ensure-again.out")"
207
+ fi
208
+
209
+ BROKEN_PROJECT="$TMP/broken-start"
210
+ BROKEN_ROOT="$BROKEN_PROJECT/.kontourai/flow-agents"
211
+ mkdir -p "$BROKEN_PROJECT/.kontourai"
212
+ printf 'not a run-store directory\n' > "$BROKEN_PROJECT/.kontourai/flow"
213
+ if flow_agents_node "$WRITER" ensure-session \
214
+ --artifact-root "$BROKEN_ROOT" \
215
+ --task-slug broken-start \
216
+ --title "Broken start" \
217
+ --summary "Flow startup must fail visibly." \
218
+ --flow-id builder.build >"$TMP/broken-start.out" 2>&1; then
219
+ fail "invalid canonical Flow startup should fail ensure-session"
220
+ elif [[ -f "$BROKEN_ROOT/broken-start/state.json" ]] \
221
+ && [[ ! -e "$BROKEN_PROJECT/.kontourai/flow/runs/broken-start" ]] \
222
+ && grep -q 'canonical Builder Flow run did not start' "$TMP/broken-start.out" \
223
+ && grep -q 'flow-agents builder-run start --session-dir .kontourai/flow-agents/broken-start' "$TMP/broken-start.out" \
224
+ && node - "$BROKEN_ROOT/broken-start/state.json" <<'NODE'
225
+ const fs = require('node:fs');
226
+ const state = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
227
+ if (state.flow_run) process.exit(1);
228
+ if (!state.next_action?.command?.includes('builder-run start')) process.exit(1);
229
+ NODE
230
+ then
231
+ pass "failed canonical startup is visible and leaves only retryable sidecar guidance"
101
232
  else
102
- fail "canonical Builder run did not start or project correctly: $(cat "$TMP/builder-start.out")"
233
+ fail "failed canonical startup forged state or lost recovery guidance: $(cat "$TMP/broken-start.out")"
103
234
  fi
104
235
 
105
236
  if flow_agents_node "$WRITER" record-gate-claim "$LOCAL_SESSION" \
@@ -139,7 +139,7 @@ test_produce_claim() {
139
139
 
140
140
  local slug
141
141
  slug="$(echo "prod-$step-$expectation" | tr '/' '-' | tr '.' '-')"
142
- local aroot="$TMP/$slug"
142
+ local aroot="$TMP/$slug/.kontourai/flow-agents"
143
143
  setup_session_for_produce "$aroot" "$slug" "$step"
144
144
 
145
145
  if flow_agents_node "workflow-sidecar" record-gate-claim "$aroot/$slug" \
@@ -592,14 +592,14 @@ function hasWorkflowSteering(file, ...eventNames) {
592
592
  }
593
593
  for (const file of process.argv.slice(2)) {
594
594
  if (!hasWorkflowSteering(file, "UserPromptSubmit", "userPromptSubmit")) throw new Error(`missing prompt-submit workflow steering: ${file}`);
595
- if (!hasWorkflowSteering(file, "PreToolUse", "preToolUse")) throw new Error(`missing pre-tool workflow entry enforcement: ${file}`);
595
+ if (hasWorkflowSteering(file, "PreToolUse", "preToolUse")) throw new Error(`workflow bootstrap must not be model-mediated at pre-tool time: ${file}`);
596
596
  }
597
597
  console.log("ok");
598
598
  NODE
599
599
  then
600
- _pass "installed bundles wire prompt-submit steering and pre-tool entry enforcement across Claude Code, Codex, and Kiro"
600
+ _pass "installed bundles keep workflow steering advisory and session bootstrap product-owned"
601
601
  else
602
- _fail "installed bundles do not wire workflow steering consistently"
602
+ _fail "installed bundles do not wire prompt-submit workflow steering consistently"
603
603
  fi
604
604
 
605
605
  if [[ -f "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" ]] && node - "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" <<'NODE'
@@ -117,7 +117,7 @@ fi
117
117
  # --- 2. actor A's own current resolution is unaffected by actor B's later call (AC7) ----------
118
118
  echo "--- 2. actor A's current resolution survives actor B's later, unrelated ensure-session (AC7) ---"
119
119
 
120
- AC7_ROOT="$TMPDIR_EVAL/ac7-artifact-root"
120
+ AC7_ROOT="$TMPDIR_EVAL/ac7-project/.kontourai/flow-agents"
121
121
 
122
122
  flow_agents_node "workflow-sidecar" ensure-session \
123
123
  --artifact-root "$AC7_ROOT" \
@@ -370,7 +370,7 @@ set -e
370
370
  # --- 5. record-gate-claim resolves A's own per-actor flow/step, not B's legacy pointer (AC11) --
371
371
  echo "--- 5. record-gate-claim resolves A's own per-actor flow/step, not B's legacy pointer (AC11) ---"
372
372
 
373
- AC11_ROOT="$TMPDIR_EVAL/ac11-artifact-root"
373
+ AC11_ROOT="$TMPDIR_EVAL/ac11-project/.kontourai/flow-agents"
374
374
 
375
375
  flow_agents_node "workflow-sidecar" ensure-session \
376
376
  --artifact-root "$AC11_ROOT" \