@kontourai/flow-agents 3.4.1 → 3.4.2
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 +5 -0
- package/build/src/builder-flow-runtime.d.ts +1 -0
- package/build/src/builder-flow-runtime.js +183 -27
- package/build/src/cli/builder-run.js +13 -4
- package/build/src/cli/workflow-sidecar.js +14 -3
- package/build/src/tools/build-universal-bundles.js +2 -0
- package/context/scripts/hooks/workflow-steering.js +40 -2
- package/docs/spec/builder-flow-runtime.md +28 -0
- package/docs/spec/runtime-hook-surface.md +4 -4
- package/docs/workflow-usage-guide.md +12 -0
- package/evals/integration/test_builder_entry_enforcement.sh +4 -1
- package/evals/integration/test_bundle_install.sh +3 -2
- package/evals/integration/test_workflow_steering_hook.sh +72 -0
- package/kits/builder/skills/continue-work/SKILL.md +7 -0
- package/package.json +1 -1
- package/schemas/workflow-state.schema.json +7 -0
- package/scripts/hooks/workflow-steering.js +40 -2
- package/scripts/install-merge.js +2 -1
- package/src/builder-flow-runtime.ts +217 -26
- package/src/cli/builder-flow-runtime.test.mjs +342 -0
- package/src/cli/builder-run.ts +13 -4
- package/src/cli/workflow-sidecar.ts +16 -5
- package/src/tools/build-universal-bundles.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.2](https://github.com/kontourai/flow-agents/compare/v3.4.1...v3.4.2) (2026-07-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixes
|
|
7
|
+
|
|
8
|
+
* enforce canonical Builder entry action ([#536](https://github.com/kontourai/flow-agents/issues/536)) ([bab5dfe](https://github.com/kontourai/flow-agents/commit/bab5dfe419a42b137dcb79bd276edf3268c5fb23))
|
|
9
|
+
|
|
3
10
|
## [3.4.1](https://github.com/kontourai/flow-agents/compare/v3.4.0...v3.4.1) (2026-07-10)
|
|
4
11
|
|
|
5
12
|
|
package/agents/dev.json
CHANGED
|
@@ -53,6 +53,11 @@
|
|
|
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
|
+
},
|
|
56
61
|
{
|
|
57
62
|
"command": "node ~/.flow-agents/scripts/hooks/run-hook.js pre:config-protection config-protection.js standard,strict",
|
|
58
63
|
"matcher": "fs_write",
|
|
@@ -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
|
|
9
|
-
const subject = workflowSubject(
|
|
8
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
9
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
10
10
|
let run;
|
|
11
11
|
try {
|
|
12
12
|
run = await loadBuilderBuildRun({
|
|
@@ -36,6 +36,32 @@ export async function syncBuilderFlowSession(input) {
|
|
|
36
36
|
});
|
|
37
37
|
return syncAndProject(context, run);
|
|
38
38
|
}
|
|
39
|
+
export async function recoverBuilderFlowSession(input) {
|
|
40
|
+
const context = resolveSessionContext(input.sessionDir);
|
|
41
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
42
|
+
const subject = workflowSubject(sidecarSnapshot.state);
|
|
43
|
+
const run = await loadBuilderBuildRun({
|
|
44
|
+
cwd: context.projectRoot,
|
|
45
|
+
runId: context.slug,
|
|
46
|
+
});
|
|
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
|
+
}
|
|
55
|
+
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
56
|
+
writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
|
|
57
|
+
return {
|
|
58
|
+
sessionDir: context.sessionDir,
|
|
59
|
+
projectRoot: context.projectRoot,
|
|
60
|
+
run,
|
|
61
|
+
projection,
|
|
62
|
+
attached: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
39
65
|
export async function syncBuilderFlowSessionIfPresent(sessionDir) {
|
|
40
66
|
let context;
|
|
41
67
|
try {
|
|
@@ -78,8 +104,9 @@ async function syncAndProject(context, initial) {
|
|
|
78
104
|
}
|
|
79
105
|
}
|
|
80
106
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
107
|
+
const sidecarSnapshot = readSidecarSnapshot(context);
|
|
108
|
+
const projection = projectFlowRun(context, run, sidecarSnapshot.state);
|
|
109
|
+
writeProjection(context, projection, sidecarSnapshot.raw, "projection");
|
|
83
110
|
return {
|
|
84
111
|
sessionDir: context.sessionDir,
|
|
85
112
|
projectRoot: context.projectRoot,
|
|
@@ -95,6 +122,7 @@ function resolveSessionContext(sessionDirInput) {
|
|
|
95
122
|
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
96
123
|
throw new BuilderBuildRunInputError("sessionDir", "must be .kontourai/flow-agents/<slug>");
|
|
97
124
|
}
|
|
125
|
+
assertSafeDirectory(sessionDir, artifactRoot, "sessionDir");
|
|
98
126
|
const slug = path.basename(sessionDir);
|
|
99
127
|
if (!slug || slug === "." || slug === "..") {
|
|
100
128
|
throw new BuilderBuildRunInputError("sessionDir", "must name a session");
|
|
@@ -103,6 +131,7 @@ function resolveSessionContext(sessionDirInput) {
|
|
|
103
131
|
if (!fs.existsSync(stateFile)) {
|
|
104
132
|
throw new BuilderBuildRunInputError("sessionDir", "must contain state.json");
|
|
105
133
|
}
|
|
134
|
+
assertSafeFile(stateFile, sessionDir, "state.json");
|
|
106
135
|
return {
|
|
107
136
|
sessionDir,
|
|
108
137
|
artifactRoot,
|
|
@@ -112,18 +141,21 @@ function resolveSessionContext(sessionDirInput) {
|
|
|
112
141
|
bundleFile: path.join(sessionDir, "trust.bundle"),
|
|
113
142
|
};
|
|
114
143
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
144
|
+
function readSidecarSnapshot(context) {
|
|
145
|
+
assertSafeFile(context.stateFile, context.sessionDir, "state.json");
|
|
146
|
+
const raw = fs.readFileSync(context.stateFile, "utf8");
|
|
147
|
+
const value = JSON.parse(raw);
|
|
117
148
|
if (!isRecord(value) || value.task_slug !== context.slug) {
|
|
118
149
|
throw new BuilderBuildRunInputError("sessionDir", "state.json task_slug must match the session directory");
|
|
119
150
|
}
|
|
120
|
-
return value;
|
|
151
|
+
return { state: value, raw };
|
|
121
152
|
}
|
|
122
153
|
function workflowSubject(state) {
|
|
123
|
-
const refs =
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
const refs = state.work_item_refs;
|
|
155
|
+
if (!Array.isArray(refs)
|
|
156
|
+
|| refs.length !== 1
|
|
157
|
+
|| typeof refs[0] !== "string"
|
|
158
|
+
|| refs[0].trim().length === 0) {
|
|
127
159
|
throw new BuilderBuildRunInputError("state.work_item_refs", "must contain exactly one selected Work Item for builder.build");
|
|
128
160
|
}
|
|
129
161
|
return refs[0];
|
|
@@ -174,8 +206,7 @@ function workflowSubjectRef(claim) {
|
|
|
174
206
|
function manifestEvidence(manifest) {
|
|
175
207
|
return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
|
|
176
208
|
}
|
|
177
|
-
function projectFlowRun(context, run) {
|
|
178
|
-
const sidecar = readSidecarState(context);
|
|
209
|
+
function projectFlowRun(context, run, sidecar) {
|
|
179
210
|
const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
|
|
180
211
|
const gates = openGates(definition, run.state);
|
|
181
212
|
const complete = run.state.status === "completed";
|
|
@@ -227,25 +258,150 @@ function projectFlowRun(context, run) {
|
|
|
227
258
|
next_action: nextAction,
|
|
228
259
|
};
|
|
229
260
|
}
|
|
230
|
-
function writeProjection(context, projection) {
|
|
231
|
-
|
|
232
|
-
|
|
261
|
+
function writeProjection(context, projection, expectedStateRaw, operation) {
|
|
262
|
+
const prepared = prepareProjectionWrites(context, projection, expectedStateRaw, operation);
|
|
263
|
+
assertProjectionTargetsUnchanged(context, prepared, operation);
|
|
264
|
+
for (const write of prepared.writes)
|
|
265
|
+
writeExistingFileNoFollow(write.file, write.content);
|
|
266
|
+
}
|
|
267
|
+
function prepareProjectionWrites(context, projection, expectedStateRaw, operation) {
|
|
268
|
+
const targets = [];
|
|
269
|
+
const writes = [];
|
|
270
|
+
const stateTarget = readProjectionTarget(context.stateFile, context.sessionDir, "state.json");
|
|
271
|
+
targets.push(stateTarget);
|
|
272
|
+
if (stateTarget.raw !== expectedStateRaw) {
|
|
273
|
+
throw new BuilderBuildRunInputError("state.json", `changed during ${operation}`);
|
|
274
|
+
}
|
|
275
|
+
writes.push({ file: context.stateFile, content: `${JSON.stringify(projection, null, 2)}\n` });
|
|
276
|
+
const pointerFiles = [];
|
|
277
|
+
const globalPointer = path.join(context.artifactRoot, "current.json");
|
|
278
|
+
const globalTarget = readOptionalProjectionTarget(globalPointer, context.artifactRoot, "current.json");
|
|
279
|
+
targets.push(globalTarget);
|
|
280
|
+
if (globalTarget.raw !== null)
|
|
281
|
+
pointerFiles.push(globalPointer);
|
|
233
282
|
const actorRoot = path.join(context.artifactRoot, "current");
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
283
|
+
let actorEntries = null;
|
|
284
|
+
if (pathExistsNoFollow(actorRoot)) {
|
|
285
|
+
assertSafeDirectory(actorRoot, context.artifactRoot, "current directory");
|
|
286
|
+
actorEntries = fs.readdirSync(actorRoot).sort();
|
|
287
|
+
for (const name of actorEntries) {
|
|
288
|
+
const file = path.join(actorRoot, name);
|
|
289
|
+
const stat = fs.lstatSync(file);
|
|
290
|
+
if (stat.isSymbolicLink()) {
|
|
291
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must not be a symbolic link`);
|
|
292
|
+
}
|
|
293
|
+
if (!name.endsWith(".json"))
|
|
294
|
+
continue;
|
|
295
|
+
if (!stat.isFile()) {
|
|
296
|
+
throw new BuilderBuildRunInputError("projection target", `current/${name} must be a regular file`);
|
|
297
|
+
}
|
|
298
|
+
const target = readProjectionTarget(file, actorRoot, `current/${name}`);
|
|
299
|
+
targets.push(target);
|
|
300
|
+
pointerFiles.push(file);
|
|
301
|
+
}
|
|
238
302
|
}
|
|
239
303
|
for (const file of pointerFiles) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const pointer = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
304
|
+
const target = targets.find((candidate) => candidate.file === file);
|
|
305
|
+
const pointer = parseProjectionTarget(target);
|
|
243
306
|
if (!isRecord(pointer) || pointer.active_slug !== context.slug)
|
|
244
307
|
continue;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
308
|
+
const output = {
|
|
309
|
+
...pointer,
|
|
310
|
+
active_flow_id: BUILDER_BUILD_FLOW_ID,
|
|
311
|
+
active_step_id: projection.flow_run.current_step,
|
|
312
|
+
updated_at: projection.updated_at,
|
|
313
|
+
};
|
|
314
|
+
writes.push({ file, content: `${JSON.stringify(output, null, 2)}\n` });
|
|
315
|
+
}
|
|
316
|
+
return { targets, actorEntries, writes };
|
|
317
|
+
}
|
|
318
|
+
function assertProjectionTargetsUnchanged(context, prepared, operation) {
|
|
319
|
+
const actorRoot = path.join(context.artifactRoot, "current");
|
|
320
|
+
const currentActorEntries = pathExistsNoFollow(actorRoot)
|
|
321
|
+
? (assertSafeDirectory(actorRoot, context.artifactRoot, "current directory"), fs.readdirSync(actorRoot).sort())
|
|
322
|
+
: null;
|
|
323
|
+
if (JSON.stringify(currentActorEntries) !== JSON.stringify(prepared.actorEntries)) {
|
|
324
|
+
throw new BuilderBuildRunInputError("current", `directory changed during ${operation}`);
|
|
325
|
+
}
|
|
326
|
+
for (const target of prepared.targets) {
|
|
327
|
+
const current = readOptionalProjectionTarget(target.file, target.root, target.field);
|
|
328
|
+
if (current.raw !== target.raw) {
|
|
329
|
+
throw new BuilderBuildRunInputError(target.field, `changed during ${operation}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function readOptionalProjectionTarget(file, root, field) {
|
|
334
|
+
if (!pathExistsNoFollow(file))
|
|
335
|
+
return { file, raw: null, root, field };
|
|
336
|
+
return readProjectionTarget(file, root, field);
|
|
337
|
+
}
|
|
338
|
+
function readProjectionTarget(file, root, field) {
|
|
339
|
+
assertSafeFile(file, root, field);
|
|
340
|
+
return { file, raw: fs.readFileSync(file, "utf8"), root, field };
|
|
341
|
+
}
|
|
342
|
+
function parseProjectionTarget(target) {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(target.raw);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
throw new BuilderBuildRunInputError("projection target", `${target.field} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function assertSafeDirectory(directory, root, field) {
|
|
351
|
+
if (!pathExistsNoFollow(directory)) {
|
|
352
|
+
throw new BuilderBuildRunInputError(field, "must exist");
|
|
353
|
+
}
|
|
354
|
+
const stat = fs.lstatSync(directory);
|
|
355
|
+
if (stat.isSymbolicLink()) {
|
|
356
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
357
|
+
}
|
|
358
|
+
if (!stat.isDirectory()) {
|
|
359
|
+
throw new BuilderBuildRunInputError(field, "must be a directory");
|
|
360
|
+
}
|
|
361
|
+
assertContainedPath(directory, root, field);
|
|
362
|
+
}
|
|
363
|
+
function pathExistsNoFollow(candidate) {
|
|
364
|
+
try {
|
|
365
|
+
fs.lstatSync(candidate);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
370
|
+
return false;
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function assertSafeFile(file, root, field) {
|
|
375
|
+
const stat = fs.lstatSync(file);
|
|
376
|
+
if (stat.isSymbolicLink()) {
|
|
377
|
+
throw new BuilderBuildRunInputError(field, "must not be a symbolic link");
|
|
378
|
+
}
|
|
379
|
+
if (!stat.isFile()) {
|
|
380
|
+
throw new BuilderBuildRunInputError(field, "must be a regular file");
|
|
381
|
+
}
|
|
382
|
+
assertContainedPath(file, root, field);
|
|
383
|
+
}
|
|
384
|
+
function assertContainedPath(candidate, root, field) {
|
|
385
|
+
if (!pathIsWithin(candidate, root)) {
|
|
386
|
+
throw new BuilderBuildRunInputError(field, "must remain within its expected artifact root");
|
|
387
|
+
}
|
|
388
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
389
|
+
const realRoot = fs.realpathSync(root);
|
|
390
|
+
if (!pathIsWithin(realCandidate, realRoot)) {
|
|
391
|
+
throw new BuilderBuildRunInputError(field, "must not escape its expected artifact root");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function pathIsWithin(candidate, root) {
|
|
395
|
+
const relative = path.relative(root, candidate);
|
|
396
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
397
|
+
}
|
|
398
|
+
function writeExistingFileNoFollow(file, content) {
|
|
399
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
|
|
400
|
+
try {
|
|
401
|
+
fs.writeFileSync(descriptor, content);
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
fs.closeSync(descriptor);
|
|
249
405
|
}
|
|
250
406
|
}
|
|
251
407
|
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
|
-
:
|
|
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,
|
|
@@ -1692,12 +1692,16 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
|
|
|
1692
1692
|
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1693
1693
|
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
1694
1694
|
const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
|
|
1695
|
+
const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
|
|
1696
|
+
const nextActionPayload = typeof nextAction === "string"
|
|
1697
|
+
? { status: "continue", summary: nextAction || summary }
|
|
1698
|
+
: { status: "continue", ...nextAction, summary: nextActionSummary };
|
|
1695
1699
|
writeJson(path.join(dir, "state.json"), {
|
|
1696
1700
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1697
1701
|
...(branch ? { branch } : {}),
|
|
1698
1702
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1699
1703
|
artifact_paths: relArtifacts(dir),
|
|
1700
|
-
next_action:
|
|
1704
|
+
next_action: nextActionPayload,
|
|
1701
1705
|
});
|
|
1702
1706
|
writeJson(path.join(dir, "acceptance.json"), {
|
|
1703
1707
|
...sidecarBase(slug), source_request: sourceRequest,
|
|
@@ -1705,7 +1709,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
|
|
|
1705
1709
|
goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
|
|
1706
1710
|
});
|
|
1707
1711
|
writeJson(path.join(dir, "handoff.json"), {
|
|
1708
|
-
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps:
|
|
1712
|
+
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
|
|
1709
1713
|
});
|
|
1710
1714
|
}
|
|
1711
1715
|
/** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
|
|
@@ -1964,9 +1968,16 @@ function ensureSession(p) {
|
|
|
1964
1968
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
1965
1969
|
const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
|
|
1966
1970
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
1971
|
+
const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
|
|
1967
1972
|
const nextAction = entry.flowId
|
|
1968
1973
|
? entry.flowId === "builder.build"
|
|
1969
|
-
?
|
|
1974
|
+
? {
|
|
1975
|
+
status: "continue",
|
|
1976
|
+
summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
|
|
1977
|
+
skills: ["pull-work"],
|
|
1978
|
+
command: startCommand,
|
|
1979
|
+
enforcement: "before_tool_use",
|
|
1980
|
+
}
|
|
1970
1981
|
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
1971
1982
|
: opt(p, "next-action", "Continue.");
|
|
1972
1983
|
initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), nextAction, timestamp, md, [workItem.ref], initialPhase ? { status: "new", phase: initialPhase } : undefined);
|
|
@@ -363,6 +363,7 @@ 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")] });
|
|
366
367
|
hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
|
|
367
368
|
return `${JSON.stringify({
|
|
368
369
|
statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
|
|
@@ -380,6 +381,7 @@ function exportCodexHooks() {
|
|
|
380
381
|
hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
381
382
|
hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
382
383
|
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")] });
|
|
383
385
|
return `${JSON.stringify({ hooks }, null, 2)}\n`;
|
|
384
386
|
}
|
|
385
387
|
function copySharedContent(targetRoot, targetName, token) {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* survive context loss instead of relying on the model voluntarily re-reading
|
|
10
10
|
* the sidecar.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Advisory by default. A structured next action may explicitly require its
|
|
13
|
+
* projected command before unrelated tool use; only that PreToolUse case blocks.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
'use strict';
|
|
@@ -282,6 +283,32 @@ function stateSteering(root) {
|
|
|
282
283
|
return parts.join(' ');
|
|
283
284
|
}
|
|
284
285
|
|
|
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
|
+
|
|
285
312
|
function contextMapSteering(root) {
|
|
286
313
|
const mapPath = path.join(root, 'docs', 'context-map.md');
|
|
287
314
|
if (!fs.existsSync(mapPath)) return '';
|
|
@@ -572,6 +599,8 @@ function run(rawInput) {
|
|
|
572
599
|
const toolInput = input.tool_input || {};
|
|
573
600
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
574
601
|
const current = latestWorkflowState(root);
|
|
602
|
+
const enforcement = beforeToolUseEnforcement(input, current);
|
|
603
|
+
if (enforcement) return enforcement;
|
|
575
604
|
const hints = [];
|
|
576
605
|
let shouldAppendWorkflowContext = false;
|
|
577
606
|
|
|
@@ -648,7 +677,14 @@ if (require.main === module) {
|
|
|
648
677
|
data += chunk;
|
|
649
678
|
});
|
|
650
679
|
process.stdin.on('end', () => {
|
|
651
|
-
|
|
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
|
+
}
|
|
652
688
|
});
|
|
653
689
|
}
|
|
654
690
|
|
|
@@ -667,4 +703,6 @@ module.exports = {
|
|
|
667
703
|
promptText,
|
|
668
704
|
looksLikeImplementationWork,
|
|
669
705
|
kitWorkflowSteering,
|
|
706
|
+
beforeToolUseEnforcement,
|
|
707
|
+
toolCommands,
|
|
670
708
|
};
|
|
@@ -35,6 +35,12 @@ workflow sidecar. Start the canonical run with:
|
|
|
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.
|
|
43
|
+
|
|
38
44
|
`start` requires exactly one `state.work_item_refs` entry and uses that stable Work
|
|
39
45
|
Item reference as the Flow run subject. It is idempotent for an existing canonical
|
|
40
46
|
run. A direct primitive session without a Builder Flow stamp remains independent and
|
|
@@ -51,6 +57,28 @@ Synchronization is digest-idempotent. The same trust bundle is not attached twic
|
|
|
51
57
|
Inspection loads the run without evaluating it, so invalid evidence is rejected
|
|
52
58
|
before Flow mutation.
|
|
53
59
|
|
|
60
|
+
## Recovery
|
|
61
|
+
|
|
62
|
+
Recover an interrupted canonical Builder session with:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
flow-agents builder-run recover --session-dir .kontourai/flow-agents/<slug>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`recover` derives the Flow run id from the session directory slug; callers cannot
|
|
69
|
+
select a run id or step. It requires exactly one non-empty
|
|
70
|
+
`state.work_item_refs` entry, loads the existing run through Flow's canonical load
|
|
71
|
+
API, and verifies that the ref matches persisted `state.subject` and, when present,
|
|
72
|
+
`state.params.subject`. A missing, foreign, or corrupt run fails closed and is not
|
|
73
|
+
created or repaired.
|
|
74
|
+
|
|
75
|
+
Recovery is load/validate/project only. It computes the complete projection before
|
|
76
|
+
updating `state.json`, the matching global `current.json`, and matching per-actor
|
|
77
|
+
current pointers. It does not inspect or attach `trust.bundle`, evaluate gates, or
|
|
78
|
+
write any file in `.kontourai/flow/runs/<slug>/`; the complete Flow run tree remains
|
|
79
|
+
byte-identical. Use `sync`, not `recover`, to attach recorded evidence and evaluate
|
|
80
|
+
the current gate.
|
|
81
|
+
|
|
54
82
|
## Trust Binding
|
|
55
83
|
|
|
56
84
|
Claims relevant to the current gate must carry
|
|
@@ -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), and `preToolUse` when the projected `next_action` explicitly declares `enforcement: "before_tool_use"`
|
|
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**: 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.
|
|
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 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.
|
|
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 | 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) |
|
|
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.
|
|
@@ -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.
|
|
@@ -68,7 +68,10 @@ if (state.status !== 'new' || state.phase !== 'pickup') process.exit(1);
|
|
|
68
68
|
if (JSON.stringify(state.work_item_refs) !== JSON.stringify(['local:local-request'])) process.exit(1);
|
|
69
69
|
if (workItem.id !== 'local-request' || workItem.title !== 'Local request') process.exit(1);
|
|
70
70
|
if (workItem.source_provider?.kind !== 'local' || workItem.source_provider?.path !== 'work-item.json') process.exit(1);
|
|
71
|
-
if (
|
|
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);
|
|
73
|
+
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);
|
|
72
75
|
NODE
|
|
73
76
|
then
|
|
74
77
|
pass "providerless request creates a local Work Item and starts at pull-work"
|
|
@@ -592,13 +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
596
|
}
|
|
596
597
|
console.log("ok");
|
|
597
598
|
NODE
|
|
598
599
|
then
|
|
599
|
-
_pass "installed bundles wire prompt-submit
|
|
600
|
+
_pass "installed bundles wire prompt-submit steering and pre-tool entry enforcement across Claude Code, Codex, and Kiro"
|
|
600
601
|
else
|
|
601
|
-
_fail "installed bundles do not wire
|
|
602
|
+
_fail "installed bundles do not wire workflow steering consistently"
|
|
602
603
|
fi
|
|
603
604
|
|
|
604
605
|
if [[ -f "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" ]] && node - "$OPENCODE_DEST/.opencode/plugins/flow-agents.js" <<'NODE'
|