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