@kontourai/flow-agents 3.4.0 → 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 +14 -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/lib/flow-resolver.d.ts +2 -2
- package/build/src/lib/flow-resolver.js +39 -6
- 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/flow-resolver-composition.test.mjs +30 -1
- package/src/cli/workflow-sidecar.ts +16 -5
- package/src/lib/flow-resolver.ts +36 -6
- package/src/tools/build-universal-bundles.ts +2 -0
|
@@ -6,9 +6,11 @@ import path from "node:path";
|
|
|
6
6
|
|
|
7
7
|
import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
|
|
8
8
|
import {
|
|
9
|
+
recoverBuilderFlowSession,
|
|
9
10
|
startBuilderFlowSession,
|
|
10
11
|
syncBuilderFlowSession,
|
|
11
12
|
} from "../../build/src/builder-flow-runtime.js";
|
|
13
|
+
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
12
14
|
|
|
13
15
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
14
16
|
const NOW = "2026-07-09T20:00:00.000Z";
|
|
@@ -43,6 +45,45 @@ function readJson(file) {
|
|
|
43
45
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function snapshotFile(file) {
|
|
49
|
+
return fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function snapshotTree(directory) {
|
|
53
|
+
if (!fs.existsSync(directory)) return null;
|
|
54
|
+
const files = [];
|
|
55
|
+
function visit(current) {
|
|
56
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
57
|
+
const absolute = path.join(current, entry.name);
|
|
58
|
+
if (entry.isDirectory()) visit(absolute);
|
|
59
|
+
else files.push([path.relative(directory, absolute), fs.readFileSync(absolute).toString("base64")]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
visit(directory);
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshotProjectionTargets(session) {
|
|
67
|
+
const actorRoot = path.join(session.artifactRoot, "current");
|
|
68
|
+
const actors = fs.existsSync(actorRoot)
|
|
69
|
+
? fs.readdirSync(actorRoot).filter((name) => name.endsWith(".json")).sort()
|
|
70
|
+
: [];
|
|
71
|
+
return {
|
|
72
|
+
state: snapshotFile(path.join(session.sessionDir, "state.json")),
|
|
73
|
+
current: snapshotFile(path.join(session.artifactRoot, "current.json")),
|
|
74
|
+
actors: actors.map((name) => [name, snapshotFile(path.join(actorRoot, name))]),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function assertRecoveryRejectsWithoutWrites(session, pattern) {
|
|
79
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
80
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
81
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
82
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), pattern);
|
|
83
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
84
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
|
|
47
88
|
const claimId = `claim.${expectation}`;
|
|
48
89
|
return {
|
|
@@ -210,4 +251,305 @@ test("verified sidecar claims drive the composed publish and learning prefix to
|
|
|
210
251
|
assert.equal(completed.run.state.status, "completed");
|
|
211
252
|
assert.equal(completed.projection.status, "delivered");
|
|
212
253
|
assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
254
|
+
|
|
255
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
256
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
257
|
+
const staleState = readJson(path.join(session.sessionDir, "state.json"));
|
|
258
|
+
staleState.status = "in_progress";
|
|
259
|
+
staleState.phase = "verification";
|
|
260
|
+
staleState.next_action = { status: "continue", summary: "stale" };
|
|
261
|
+
writeJson(path.join(session.sessionDir, "state.json"), staleState);
|
|
262
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
263
|
+
assert.equal(recovered.attached, false);
|
|
264
|
+
assert.equal(recovered.projection.status, "delivered");
|
|
265
|
+
assert.equal(recovered.projection.phase, "done");
|
|
266
|
+
assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
|
|
267
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {
|
|
271
|
+
const session = makeSession("recover-active");
|
|
272
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
273
|
+
const advanced = await writeAndSync(session, [bundleClaim({
|
|
274
|
+
expectation: "selected-work",
|
|
275
|
+
claimType: "builder.pull-work.selected",
|
|
276
|
+
subjectType: "work-item",
|
|
277
|
+
})]);
|
|
278
|
+
assert.equal(advanced.run.state.current_step, "design-probe");
|
|
279
|
+
|
|
280
|
+
writeJson(path.join(session.artifactRoot, "current", "codex.json"), {
|
|
281
|
+
active_slug: session.slug,
|
|
282
|
+
active_step_id: "stale-step",
|
|
283
|
+
updated_at: NOW,
|
|
284
|
+
});
|
|
285
|
+
writeJson(path.join(session.artifactRoot, "current", "other.json"), {
|
|
286
|
+
active_slug: "another-session",
|
|
287
|
+
active_step_id: "untouched-step",
|
|
288
|
+
updated_at: NOW,
|
|
289
|
+
});
|
|
290
|
+
const staleState = readJson(path.join(session.sessionDir, "state.json"));
|
|
291
|
+
staleState.status = "planned";
|
|
292
|
+
staleState.phase = "planning";
|
|
293
|
+
staleState.flow_run.current_step = "pull-work";
|
|
294
|
+
staleState.next_action = { status: "continue", summary: "stale" };
|
|
295
|
+
writeJson(path.join(session.sessionDir, "state.json"), staleState);
|
|
296
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
297
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
298
|
+
const beforeOther = snapshotFile(path.join(session.artifactRoot, "current", "other.json"));
|
|
299
|
+
const bundleBefore = snapshotFile(path.join(session.sessionDir, "trust.bundle"));
|
|
300
|
+
|
|
301
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
302
|
+
|
|
303
|
+
assert.equal(recovered.attached, false);
|
|
304
|
+
assert.equal(recovered.run.runId, session.slug);
|
|
305
|
+
assert.equal(recovered.projection.flow_run.current_step, "design-probe");
|
|
306
|
+
assert.equal(recovered.projection.phase, advanced.projection.phase);
|
|
307
|
+
assert.deepEqual(recovered.projection.flow_run.open_gate_ids, advanced.projection.flow_run.open_gate_ids);
|
|
308
|
+
assert.deepEqual(recovered.projection.next_action.skills, ["pickup-probe"]);
|
|
309
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "design-probe");
|
|
310
|
+
assert.equal(readJson(path.join(session.artifactRoot, "current", "codex.json")).active_step_id, "design-probe");
|
|
311
|
+
assert.equal(snapshotFile(path.join(session.artifactRoot, "current", "other.json")), beforeOther);
|
|
312
|
+
assert.equal(snapshotFile(path.join(session.sessionDir, "trust.bundle")), bundleBefore);
|
|
313
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("recovery fails before any write for invalid Work Item cardinality and Flow subject bindings", async (t) => {
|
|
317
|
+
const cases = [
|
|
318
|
+
["zero refs", [], /state\.work_item_refs.*exactly one/],
|
|
319
|
+
["empty ref", [""], /state\.work_item_refs.*exactly one/],
|
|
320
|
+
["blank ref", [" "], /state\.work_item_refs.*exactly one/],
|
|
321
|
+
["two refs", [SUBJECT, "local:work-item/other"], /state\.work_item_refs.*exactly one/],
|
|
322
|
+
];
|
|
323
|
+
for (const [name, refs, pattern] of cases) {
|
|
324
|
+
await t.test(name, async () => {
|
|
325
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
|
|
326
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
327
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
328
|
+
state.work_item_refs = refs;
|
|
329
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
330
|
+
await assertRecoveryRejectsWithoutWrites(session, pattern);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await t.test("persisted state subject mismatch", async () => {
|
|
335
|
+
const session = makeSession("recover-state-subject-mismatch");
|
|
336
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
337
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
|
|
338
|
+
const state = readJson(file);
|
|
339
|
+
state.subject = "local:work-item/other";
|
|
340
|
+
writeJson(file, state);
|
|
341
|
+
await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.subject.*selected Work Item/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await t.test("persisted params subject mismatch", async () => {
|
|
345
|
+
const session = makeSession("recover-params-subject-mismatch");
|
|
346
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
347
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
|
|
348
|
+
const state = readJson(file);
|
|
349
|
+
state.params.subject = "local:work-item/other";
|
|
350
|
+
writeJson(file, state);
|
|
351
|
+
await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.params\.subject.*selected Work Item/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await t.test("absent persisted params subject is allowed", async () => {
|
|
355
|
+
const session = makeSession("recover-params-subject-absent");
|
|
356
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
357
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
358
|
+
const file = path.join(flowDirectory, "state.json");
|
|
359
|
+
const state = readJson(file);
|
|
360
|
+
delete state.params.subject;
|
|
361
|
+
writeJson(file, state);
|
|
362
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
363
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
364
|
+
assert.equal(recovered.attached, false);
|
|
365
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("recovery fails closed for invalid sidecars and missing or corrupt canonical runs", async (t) => {
|
|
370
|
+
await t.test("invalid session path", async () => {
|
|
371
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-invalid-session-"));
|
|
372
|
+
await assert.rejects(
|
|
373
|
+
() => recoverBuilderFlowSession({ sessionDir: path.join(projectRoot, "not-a-session") }),
|
|
374
|
+
/sessionDir.*must be \.kontourai\/flow-agents/,
|
|
375
|
+
);
|
|
376
|
+
assert.equal(fs.existsSync(path.join(projectRoot, ".kontourai")), false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await t.test("missing run remains missing", async () => {
|
|
380
|
+
const session = makeSession("recover-missing-run");
|
|
381
|
+
await assertRecoveryRejectsWithoutWrites(session, /not_found|not found/i);
|
|
382
|
+
assert.equal(fs.existsSync(runDir(session.slug, session.projectRoot)), false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await t.test("task slug mismatch", async () => {
|
|
386
|
+
const session = makeSession("recover-task-slug-mismatch");
|
|
387
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
388
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
389
|
+
state.task_slug = "other";
|
|
390
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
391
|
+
await assertRecoveryRejectsWithoutWrites(session, /task_slug.*match/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t.test("missing sidecar state", async () => {
|
|
395
|
+
const session = makeSession("recover-missing-sidecar-state");
|
|
396
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
397
|
+
fs.rmSync(path.join(session.sessionDir, "state.json"));
|
|
398
|
+
await assertRecoveryRejectsWithoutWrites(session, /sessionDir.*state\.json/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await t.test("corrupt sidecar state", async () => {
|
|
402
|
+
const session = makeSession("recover-corrupt-sidecar-state");
|
|
403
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
404
|
+
fs.writeFileSync(path.join(session.sessionDir, "state.json"), "not-json\n");
|
|
405
|
+
await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|Unexpected/i);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
for (const [name, relativeFile] of [
|
|
409
|
+
["corrupt Flow state", "state.json"],
|
|
410
|
+
["corrupt Flow manifest", FLOW_RUN_EVIDENCE_MANIFEST_PATH],
|
|
411
|
+
]) {
|
|
412
|
+
await t.test(name, async () => {
|
|
413
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-").toLowerCase()}`);
|
|
414
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
415
|
+
fs.writeFileSync(path.join(runDir(session.slug, session.projectRoot), relativeFile), "not-json\n");
|
|
416
|
+
await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|invalid|Unexpected/i);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const [name, mutate] of [
|
|
421
|
+
["foreign definition identity", (definition) => { definition.id = "foreign.build"; }],
|
|
422
|
+
["foreign definition content", (definition) => { definition.steps[0].description = "foreign content"; }],
|
|
423
|
+
]) {
|
|
424
|
+
await t.test(name, async () => {
|
|
425
|
+
const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
|
|
426
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
427
|
+
const file = path.join(runDir(session.slug, session.projectRoot), "definition.json");
|
|
428
|
+
const definition = readJson(file);
|
|
429
|
+
mutate(definition);
|
|
430
|
+
writeJson(file, definition);
|
|
431
|
+
await assertRecoveryRejectsWithoutWrites(session, /definition|foreign|canonical|identity|content|invalid/i);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("recover and sync remain separate: recovery leaves bundle unattached and sync evaluates it", async () => {
|
|
437
|
+
const session = makeSession("recover-then-sync");
|
|
438
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
439
|
+
writeBundle(session.sessionDir, [bundleClaim({
|
|
440
|
+
expectation: "selected-work",
|
|
441
|
+
claimType: "builder.pull-work.selected",
|
|
442
|
+
subjectType: "work-item",
|
|
443
|
+
})]);
|
|
444
|
+
const manifestFile = path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH);
|
|
445
|
+
const manifestBefore = snapshotFile(manifestFile);
|
|
446
|
+
|
|
447
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
448
|
+
assert.equal(recovered.attached, false);
|
|
449
|
+
assert.equal(snapshotFile(manifestFile), manifestBefore);
|
|
450
|
+
assert.equal(recovered.run.state.current_step, "pull-work");
|
|
451
|
+
|
|
452
|
+
const synced = await syncBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
453
|
+
assert.equal(synced.attached, true);
|
|
454
|
+
assert.equal(synced.run.state.current_step, "design-probe");
|
|
455
|
+
assert.notEqual(snapshotFile(manifestFile), manifestBefore);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("builder-run recover accepts only a session directory and rejects caller-selected identity", async () => {
|
|
459
|
+
const session = makeSession("recover-cli");
|
|
460
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
461
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir]), 0);
|
|
462
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--run-id", "other"]), 64);
|
|
463
|
+
assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--step-id", "verify"]), 64);
|
|
464
|
+
assert.equal(await builderRunMain(["recover", "extra", "--session-dir", session.sessionDir]), 64);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("recovery refuses a sidecar changed after its immutable subject snapshot", async () => {
|
|
468
|
+
const session = makeSession("recover-sidecar-race");
|
|
469
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
470
|
+
const flowDirectory = runDir(session.slug, session.projectRoot);
|
|
471
|
+
const beforeFlow = snapshotTree(flowDirectory);
|
|
472
|
+
const recovery = recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
473
|
+
const changed = readJson(path.join(session.sessionDir, "state.json"));
|
|
474
|
+
changed.work_item_refs = ["local:work-item/raced"];
|
|
475
|
+
writeJson(path.join(session.sessionDir, "state.json"), changed);
|
|
476
|
+
const beforeProjection = snapshotProjectionTargets(session);
|
|
477
|
+
|
|
478
|
+
await assert.rejects(() => recovery, /state\.json.*changed during recovery/);
|
|
479
|
+
assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
|
|
480
|
+
assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("recovery parses every projection target before writing any target", async (t) => {
|
|
484
|
+
for (const target of ["global", "actor"]) {
|
|
485
|
+
await t.test(`malformed ${target} pointer`, async () => {
|
|
486
|
+
const session = makeSession(`recover-malformed-${target}-pointer`);
|
|
487
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
488
|
+
const state = readJson(path.join(session.sessionDir, "state.json"));
|
|
489
|
+
state.next_action = { status: "continue", summary: "stale projection" };
|
|
490
|
+
writeJson(path.join(session.sessionDir, "state.json"), state);
|
|
491
|
+
const pointer = target === "global"
|
|
492
|
+
? path.join(session.artifactRoot, "current.json")
|
|
493
|
+
: path.join(session.artifactRoot, "current", "codex.json");
|
|
494
|
+
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
495
|
+
fs.writeFileSync(pointer, "not-json\n");
|
|
496
|
+
await assertRecoveryRejectsWithoutWrites(session, /projection target.*JSON|JSON|parse|Unexpected/i);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("recovery rejects symlinked session and projection targets before writes", async (t) => {
|
|
502
|
+
await t.test("session directory symlink", async () => {
|
|
503
|
+
const session = makeSession("recover-session-symlink");
|
|
504
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
505
|
+
const target = path.join(session.artifactRoot, "recover-session-symlink-target");
|
|
506
|
+
fs.renameSync(session.sessionDir, target);
|
|
507
|
+
fs.symlinkSync(target, session.sessionDir, "dir");
|
|
508
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
509
|
+
const beforeState = snapshotFile(path.join(target, "state.json"));
|
|
510
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /sessionDir.*symbolic link/);
|
|
511
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
512
|
+
assert.equal(snapshotFile(path.join(target, "state.json")), beforeState);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await t.test("state.json symlink", async () => {
|
|
516
|
+
const session = makeSession("recover-state-symlink");
|
|
517
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
518
|
+
const stateFile = path.join(session.sessionDir, "state.json");
|
|
519
|
+
const target = path.join(session.sessionDir, "state-target.json");
|
|
520
|
+
fs.renameSync(stateFile, target);
|
|
521
|
+
fs.symlinkSync(target, stateFile);
|
|
522
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
523
|
+
const beforeTarget = snapshotFile(target);
|
|
524
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /state\.json.*symbolic link/);
|
|
525
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
526
|
+
assert.equal(snapshotFile(target), beforeTarget);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
for (const targetKind of ["global pointer", "actor pointer", "dangling actor pointer", "actor directory"]) {
|
|
530
|
+
await t.test(targetKind, async () => {
|
|
531
|
+
const session = makeSession(`recover-${targetKind.replaceAll(" ", "-")}-symlink`);
|
|
532
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
533
|
+
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-pointer-symlink-"));
|
|
534
|
+
const externalPointer = path.join(externalRoot, "current.json");
|
|
535
|
+
writeJson(externalPointer, { active_slug: session.slug, active_step_id: "stale", updated_at: NOW });
|
|
536
|
+
if (targetKind === "global pointer") {
|
|
537
|
+
fs.rmSync(path.join(session.artifactRoot, "current.json"));
|
|
538
|
+
fs.symlinkSync(externalPointer, path.join(session.artifactRoot, "current.json"));
|
|
539
|
+
} else if (targetKind === "actor pointer" || targetKind === "dangling actor pointer") {
|
|
540
|
+
const actorRoot = path.join(session.artifactRoot, "current");
|
|
541
|
+
fs.mkdirSync(actorRoot, { recursive: true });
|
|
542
|
+
fs.symlinkSync(targetKind === "dangling actor pointer" ? path.join(externalRoot, "missing.json") : externalPointer, path.join(actorRoot, "codex.json"));
|
|
543
|
+
} else {
|
|
544
|
+
fs.symlinkSync(externalRoot, path.join(session.artifactRoot, "current"), "dir");
|
|
545
|
+
}
|
|
546
|
+
const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
|
|
547
|
+
const beforeState = snapshotFile(path.join(session.sessionDir, "state.json"));
|
|
548
|
+
const beforeExternal = snapshotFile(externalPointer);
|
|
549
|
+
await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /current.*symbolic link|projection target.*symbolic link/);
|
|
550
|
+
assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
|
|
551
|
+
assert.equal(snapshotFile(path.join(session.sessionDir, "state.json")), beforeState);
|
|
552
|
+
assert.equal(snapshotFile(externalPointer), beforeExternal);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
213
555
|
});
|
package/src/cli/builder-run.ts
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
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
|
|
|
4
4
|
export async function main(argv: string[]): Promise<number> {
|
|
5
5
|
const parsed = parseArgs(argv);
|
|
6
6
|
const action = parsed.positionals[0];
|
|
7
7
|
const sessionDir = flagString(parsed.flags, "session-dir");
|
|
8
|
+
const validRecoveryArguments = parsed.positionals.length === 1
|
|
9
|
+
&& Object.keys(parsed.flags).length === 1
|
|
10
|
+
&& typeof parsed.flags["session-dir"] === "string";
|
|
11
|
+
if (action === "recover" && !validRecoveryArguments) {
|
|
12
|
+
console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
|
|
13
|
+
return 64;
|
|
14
|
+
}
|
|
8
15
|
if (!sessionDir) {
|
|
9
16
|
console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
|
|
10
17
|
return 64;
|
|
11
18
|
}
|
|
12
|
-
if (action !== "start" && action !== "sync") {
|
|
13
|
-
console.error("Usage: flow-agents builder-run <start|sync> --session-dir <path>");
|
|
19
|
+
if (action !== "start" && action !== "sync" && action !== "recover") {
|
|
20
|
+
console.error("Usage: flow-agents builder-run <start|sync|recover> --session-dir <path>");
|
|
14
21
|
return 64;
|
|
15
22
|
}
|
|
16
23
|
const result = action === "start"
|
|
17
24
|
? await startBuilderFlowSession({ sessionDir })
|
|
18
|
-
:
|
|
25
|
+
: action === "sync"
|
|
26
|
+
? await syncBuilderFlowSession({ sessionDir })
|
|
27
|
+
: await recoverBuilderFlowSession({ sessionDir });
|
|
19
28
|
console.log(JSON.stringify({
|
|
20
29
|
run_id: result.run.runId,
|
|
21
30
|
definition_id: result.run.definitionId,
|
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
|
-
import { resolveEffectiveFlowDefinition } from "../../build/src/lib/flow-resolver.js";
|
|
7
|
+
import { resolveEffectiveFlowDefinition, resolveFlowFilePath } from "../../build/src/lib/flow-resolver.js";
|
|
8
8
|
|
|
9
9
|
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
|
10
10
|
|
|
@@ -26,6 +26,35 @@ test("effective Builder definition materializes uses_flow and Flow-native comple
|
|
|
26
26
|
);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
test("installed package definitions resolve when a consumer repo has no kits directory", () => {
|
|
30
|
+
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-consumer-"));
|
|
31
|
+
const resolved = resolveFlowFilePath("builder", "build", "builder.build", consumer, false);
|
|
32
|
+
assert.ok(resolved);
|
|
33
|
+
assert.equal(fs.realpathSync(resolved), fs.realpathSync(path.join(REPO_ROOT, "kits", "builder", "flows", "build.flow.json")));
|
|
34
|
+
assert.equal(resolveEffectiveFlowDefinition("builder.build", consumer, { allowOverride: false })?.version, "1.1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("consumer-vendored definitions remain authoritative over package fallback", () => {
|
|
38
|
+
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-consumer-vendored-"));
|
|
39
|
+
const vendored = path.join(consumer, "kits", "builder", "flows", "build.flow.json");
|
|
40
|
+
writeJson(vendored, { id: "builder.build", version: "consumer", steps: [{ id: "local", next: null }], gates: {} });
|
|
41
|
+
assert.equal(resolveFlowFilePath("builder", "build", "builder.build", consumer, false), fs.realpathSync(vendored));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("unsafe explicit overrides fail closed instead of using package fallback", () => {
|
|
45
|
+
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-consumer-unsafe-"));
|
|
46
|
+
const unsafeDefinitions = path.join(consumer, ".kontourai", "flow-agents", "definitions");
|
|
47
|
+
fs.mkdirSync(unsafeDefinitions, { recursive: true });
|
|
48
|
+
const prior = process.env.FLOW_AGENTS_FLOW_DEFS_DIR;
|
|
49
|
+
process.env.FLOW_AGENTS_FLOW_DEFS_DIR = unsafeDefinitions;
|
|
50
|
+
try {
|
|
51
|
+
assert.equal(resolveFlowFilePath("builder", "build", "builder.build", consumer), null);
|
|
52
|
+
} finally {
|
|
53
|
+
if (prior === undefined) delete process.env.FLOW_AGENTS_FLOW_DEFS_DIR;
|
|
54
|
+
else process.env.FLOW_AGENTS_FLOW_DEFS_DIR = prior;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
29
58
|
test("effective definition compilation rejects uses_flow cycles", () => {
|
|
30
59
|
const definitions = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-composition-cycle-"));
|
|
31
60
|
writeJson(path.join(definitions, "loop.one.flow.json"), {
|
|
@@ -1661,7 +1661,7 @@ function initSidecars(
|
|
|
1661
1661
|
slug: string,
|
|
1662
1662
|
sourceRequest: string,
|
|
1663
1663
|
summary: string,
|
|
1664
|
-
nextAction: string,
|
|
1664
|
+
nextAction: string | AnyObj,
|
|
1665
1665
|
timestamp: string,
|
|
1666
1666
|
markdown?: string,
|
|
1667
1667
|
workItemRefs: string[] = [],
|
|
@@ -1698,12 +1698,16 @@ function initSidecars(
|
|
|
1698
1698
|
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1699
1699
|
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
1700
1700
|
const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
|
|
1701
|
+
const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
|
|
1702
|
+
const nextActionPayload = typeof nextAction === "string"
|
|
1703
|
+
? { status: "continue", summary: nextAction || summary }
|
|
1704
|
+
: { status: "continue", ...nextAction, summary: nextActionSummary };
|
|
1701
1705
|
writeJson(path.join(dir, "state.json"), {
|
|
1702
1706
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1703
1707
|
...(branch ? { branch } : {}),
|
|
1704
1708
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1705
1709
|
artifact_paths: relArtifacts(dir),
|
|
1706
|
-
next_action:
|
|
1710
|
+
next_action: nextActionPayload,
|
|
1707
1711
|
});
|
|
1708
1712
|
writeJson(path.join(dir, "acceptance.json"), {
|
|
1709
1713
|
...sidecarBase(slug), source_request: sourceRequest,
|
|
@@ -1711,7 +1715,7 @@ function initSidecars(
|
|
|
1711
1715
|
goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
|
|
1712
1716
|
});
|
|
1713
1717
|
writeJson(path.join(dir, "handoff.json"), {
|
|
1714
|
-
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps:
|
|
1718
|
+
...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
|
|
1715
1719
|
});
|
|
1716
1720
|
}
|
|
1717
1721
|
|
|
@@ -1982,9 +1986,16 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1982
1986
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
1983
1987
|
const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
|
|
1984
1988
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
1985
|
-
const
|
|
1989
|
+
const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
|
|
1990
|
+
const nextAction: string | AnyObj = entry.flowId
|
|
1986
1991
|
? entry.flowId === "builder.build"
|
|
1987
|
-
?
|
|
1992
|
+
? {
|
|
1993
|
+
status: "continue",
|
|
1994
|
+
summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
|
|
1995
|
+
skills: ["pull-work"],
|
|
1996
|
+
command: startCommand,
|
|
1997
|
+
enforcement: "before_tool_use",
|
|
1998
|
+
}
|
|
1988
1999
|
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
1989
2000
|
: opt(p, "next-action", "Continue.");
|
|
1990
2001
|
initSidecars(
|
package/src/lib/flow-resolver.ts
CHANGED
|
@@ -57,6 +57,33 @@ function isAgentWritableDir(resolvedDir: string): boolean {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function installedPackageRoot(): string | null {
|
|
61
|
+
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
while (true) {
|
|
63
|
+
if (fs.existsSync(path.join(directory, "package.json")) && fs.existsSync(path.join(directory, "kits"))) {
|
|
64
|
+
return directory;
|
|
65
|
+
}
|
|
66
|
+
const parent = path.dirname(directory);
|
|
67
|
+
if (parent === directory) return null;
|
|
68
|
+
directory = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function packagedFlowFile(kitId: string, flowName: string, consumerRoot: string): string | null {
|
|
73
|
+
const packageRoot = installedPackageRoot();
|
|
74
|
+
if (!packageRoot || path.resolve(packageRoot) === path.resolve(consumerRoot)) return null;
|
|
75
|
+
const kitsRoot = path.resolve(packageRoot, "kits");
|
|
76
|
+
const candidate = path.resolve(kitsRoot, kitId, "flows", `${flowName}.flow.json`);
|
|
77
|
+
if (!candidate.startsWith(kitsRoot + path.sep)) return null;
|
|
78
|
+
try {
|
|
79
|
+
const realKitsRoot = fs.realpathSync.native(kitsRoot);
|
|
80
|
+
const realCandidate = fs.realpathSync.native(candidate);
|
|
81
|
+
return realCandidate.startsWith(realKitsRoot + path.sep) ? realCandidate : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
/**
|
|
61
88
|
* Build and validate the FlowDefinition file path.
|
|
62
89
|
*
|
|
@@ -65,8 +92,8 @@ function isAgentWritableDir(resolvedDir: string): boolean {
|
|
|
65
92
|
* - FLOW_AGENTS_FLOW_DEFS_DIR resolves into a runtime artifact directory
|
|
66
93
|
* - The resolved path escapes the expected root (belt-and-suspenders)
|
|
67
94
|
*
|
|
68
|
-
*
|
|
69
|
-
* canonical
|
|
95
|
+
* An unsafe explicit override fails closed. Package fallback applies only to
|
|
96
|
+
* canonical lookup when no override was supplied.
|
|
70
97
|
*/
|
|
71
98
|
export function resolveFlowFilePath(
|
|
72
99
|
kitId: string,
|
|
@@ -82,14 +109,12 @@ export function resolveFlowFilePath(
|
|
|
82
109
|
|
|
83
110
|
let expectedRoot: string;
|
|
84
111
|
let flowFilePath: string;
|
|
112
|
+
let canonicalLookup = false;
|
|
85
113
|
|
|
86
114
|
if (override) {
|
|
87
115
|
const resolvedOverride = path.resolve(override);
|
|
88
116
|
if (isAgentWritableDir(resolvedOverride)) {
|
|
89
|
-
|
|
90
|
-
// the canonical kit root. The session will resolve the real kit flow.
|
|
91
|
-
expectedRoot = path.resolve(repoRoot, "kits");
|
|
92
|
-
flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
|
|
117
|
+
return null;
|
|
93
118
|
} else {
|
|
94
119
|
expectedRoot = resolvedOverride;
|
|
95
120
|
// flowId = kitId + "." + flowName; after slug validation this contains only
|
|
@@ -99,6 +124,7 @@ export function resolveFlowFilePath(
|
|
|
99
124
|
} else {
|
|
100
125
|
expectedRoot = path.resolve(repoRoot, "kits");
|
|
101
126
|
flowFilePath = path.join(repoRoot, "kits", kitId, "flows", `${flowName}.flow.json`);
|
|
127
|
+
canonicalLookup = true;
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
// Belt-and-suspenders: confirm the resolved path stays within the expected root.
|
|
@@ -120,6 +146,10 @@ export function resolveFlowFilePath(
|
|
|
120
146
|
}
|
|
121
147
|
return realPath;
|
|
122
148
|
} catch {
|
|
149
|
+
if (canonicalLookup) {
|
|
150
|
+
const packaged = packagedFlowFile(kitId, flowName, repoRoot);
|
|
151
|
+
if (packaged) return packaged;
|
|
152
|
+
}
|
|
123
153
|
return resolvedPath;
|
|
124
154
|
}
|
|
125
155
|
}
|
|
@@ -337,6 +337,7 @@ function exportClaudeSettings(): string {
|
|
|
337
337
|
hooks.UserPromptSubmit.push({ hooks: [shellHook(claudePolicy("UserPromptSubmit", "workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
338
338
|
hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "quality-gate.js"), 30, "Running Flow Agents hook policy")] });
|
|
339
339
|
hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
|
|
340
|
+
hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
|
|
340
341
|
hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
|
|
341
342
|
return `${JSON.stringify({
|
|
342
343
|
statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
|
|
@@ -354,6 +355,7 @@ function exportCodexHooks(): string {
|
|
|
354
355
|
hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
355
356
|
hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
|
|
356
357
|
hooks.PostToolUse.push({ hooks: [shellHook(codexPolicy("evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
|
|
358
|
+
hooks.PreToolUse.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
|
|
357
359
|
return `${JSON.stringify({ hooks }, null, 2)}\n`;
|
|
358
360
|
}
|
|
359
361
|
|