@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.
- package/CHANGELOG.md +7 -0
- package/agents/dev.json +0 -5
- package/build/src/builder-flow-runtime.js +18 -12
- package/build/src/cli/workflow-sidecar.js +77 -5
- package/build/src/tools/build-universal-bundles.js +0 -2
- package/context/scripts/hooks/workflow-steering.js +2 -40
- package/docs/spec/builder-flow-runtime.md +12 -7
- package/docs/spec/runtime-hook-surface.md +4 -4
- package/evals/integration/test_builder_entry_enforcement.sh +138 -7
- package/evals/integration/test_builder_step_producers.sh +1 -1
- package/evals/integration/test_bundle_install.sh +3 -3
- package/evals/integration/test_current_json_per_actor.sh +2 -2
- package/evals/integration/test_dual_emit_flow_step.sh +41 -9
- package/evals/integration/test_flowdef_session_activation.sh +11 -5
- package/evals/integration/test_install_merge.sh +24 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
- package/evals/integration/test_resolvefirststep_security.sh +1 -1
- package/evals/integration/test_sidecar_field_preservation.sh +4 -2
- package/evals/integration/test_takeover_protocol.sh +1 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
- package/evals/integration/test_workflow_steering_hook.sh +0 -72
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +1 -0
- package/scripts/hooks/workflow-steering.js +2 -40
- package/scripts/install-merge.js +2 -0
- package/src/builder-flow-runtime.ts +23 -12
- package/src/cli/builder-flow-runtime.test.mjs +32 -0
- package/src/cli/workflow-sidecar.ts +76 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
the
|
|
41
|
-
|
|
42
|
-
the agent performs their declared
|
|
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)
|
|
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**:
|
|
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
|
|
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 |
|
|
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 (
|
|
72
|
-
if (state.
|
|
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?.
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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 (
|
|
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
|
|
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-
|
|
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-
|
|
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" \
|