@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.
Files changed (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/build/src/builder-flow-runtime.d.ts +1 -0
  3. package/build/src/builder-flow-runtime.js +192 -30
  4. package/build/src/cli/builder-run.js +13 -4
  5. package/build/src/cli/workflow-sidecar.js +90 -7
  6. package/docs/spec/builder-flow-runtime.md +35 -2
  7. package/docs/spec/runtime-hook-surface.md +2 -2
  8. package/docs/workflow-usage-guide.md +12 -0
  9. package/evals/integration/test_builder_entry_enforcement.sh +139 -5
  10. package/evals/integration/test_builder_step_producers.sh +1 -1
  11. package/evals/integration/test_bundle_install.sh +2 -1
  12. package/evals/integration/test_current_json_per_actor.sh +2 -2
  13. package/evals/integration/test_dual_emit_flow_step.sh +41 -9
  14. package/evals/integration/test_flowdef_session_activation.sh +11 -5
  15. package/evals/integration/test_install_merge.sh +24 -0
  16. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
  17. package/evals/integration/test_resolvefirststep_security.sh +1 -1
  18. package/evals/integration/test_sidecar_field_preservation.sh +4 -2
  19. package/evals/integration/test_takeover_protocol.sh +1 -1
  20. package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
  21. package/kits/builder/skills/continue-work/SKILL.md +7 -0
  22. package/package.json +1 -1
  23. package/schemas/workflow-state.schema.json +8 -0
  24. package/scripts/install-merge.js +4 -1
  25. package/src/builder-flow-runtime.ts +231 -29
  26. package/src/cli/builder-flow-runtime.test.mjs +374 -0
  27. package/src/cli/builder-run.ts +13 -4
  28. package/src/cli/workflow-sidecar.ts +91 -10
@@ -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 {
@@ -131,6 +172,38 @@ test("small-model client can start and advance from projected actions without ch
131
172
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
132
173
  });
133
174
 
175
+ test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
176
+ const session = makeSession("start-subject-mismatch");
177
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
178
+ const sidecar = readJson(path.join(session.sessionDir, "state.json"));
179
+ sidecar.work_item_refs = ["local:work-item/other"];
180
+ writeJson(path.join(session.sessionDir, "state.json"), sidecar);
181
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
182
+ const beforeProjection = snapshotProjectionTargets(session);
183
+
184
+ await assert.rejects(
185
+ () => startBuilderFlowSession({ sessionDir: session.sessionDir }),
186
+ /flow_run\.state\.subject.*selected Work Item/,
187
+ );
188
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
189
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
190
+ });
191
+
192
+ test("automatic start refuses a sidecar changed after its immutable subject snapshot", async () => {
193
+ const session = makeSession("start-sidecar-race");
194
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
195
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
196
+ const startup = startBuilderFlowSession({ sessionDir: session.sessionDir });
197
+ const changed = readJson(path.join(session.sessionDir, "state.json"));
198
+ changed.next_action = { status: "continue", summary: "concurrent change" };
199
+ writeJson(path.join(session.sessionDir, "state.json"), changed);
200
+ const beforeProjection = snapshotProjectionTargets(session);
201
+
202
+ await assert.rejects(() => startup, /state\.json.*changed during projection/);
203
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
204
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
205
+ });
206
+
134
207
  test("wrong workflow subject is rejected before canonical Flow mutation", async () => {
135
208
  const session = makeSession("wrong-subject");
136
209
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -210,4 +283,305 @@ test("verified sidecar claims drive the composed publish and learning prefix to
210
283
  assert.equal(completed.run.state.status, "completed");
211
284
  assert.equal(completed.projection.status, "delivered");
212
285
  assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
286
+
287
+ const flowDirectory = runDir(session.slug, session.projectRoot);
288
+ const beforeFlow = snapshotTree(flowDirectory);
289
+ const staleState = readJson(path.join(session.sessionDir, "state.json"));
290
+ staleState.status = "in_progress";
291
+ staleState.phase = "verification";
292
+ staleState.next_action = { status: "continue", summary: "stale" };
293
+ writeJson(path.join(session.sessionDir, "state.json"), staleState);
294
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
295
+ assert.equal(recovered.attached, false);
296
+ assert.equal(recovered.projection.status, "delivered");
297
+ assert.equal(recovered.projection.phase, "done");
298
+ assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
299
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
300
+ });
301
+
302
+ test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {
303
+ const session = makeSession("recover-active");
304
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
305
+ const advanced = await writeAndSync(session, [bundleClaim({
306
+ expectation: "selected-work",
307
+ claimType: "builder.pull-work.selected",
308
+ subjectType: "work-item",
309
+ })]);
310
+ assert.equal(advanced.run.state.current_step, "design-probe");
311
+
312
+ writeJson(path.join(session.artifactRoot, "current", "codex.json"), {
313
+ active_slug: session.slug,
314
+ active_step_id: "stale-step",
315
+ updated_at: NOW,
316
+ });
317
+ writeJson(path.join(session.artifactRoot, "current", "other.json"), {
318
+ active_slug: "another-session",
319
+ active_step_id: "untouched-step",
320
+ updated_at: NOW,
321
+ });
322
+ const staleState = readJson(path.join(session.sessionDir, "state.json"));
323
+ staleState.status = "planned";
324
+ staleState.phase = "planning";
325
+ staleState.flow_run.current_step = "pull-work";
326
+ staleState.next_action = { status: "continue", summary: "stale" };
327
+ writeJson(path.join(session.sessionDir, "state.json"), staleState);
328
+ const flowDirectory = runDir(session.slug, session.projectRoot);
329
+ const beforeFlow = snapshotTree(flowDirectory);
330
+ const beforeOther = snapshotFile(path.join(session.artifactRoot, "current", "other.json"));
331
+ const bundleBefore = snapshotFile(path.join(session.sessionDir, "trust.bundle"));
332
+
333
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
334
+
335
+ assert.equal(recovered.attached, false);
336
+ assert.equal(recovered.run.runId, session.slug);
337
+ assert.equal(recovered.projection.flow_run.current_step, "design-probe");
338
+ assert.equal(recovered.projection.phase, advanced.projection.phase);
339
+ assert.deepEqual(recovered.projection.flow_run.open_gate_ids, advanced.projection.flow_run.open_gate_ids);
340
+ assert.deepEqual(recovered.projection.next_action.skills, ["pickup-probe"]);
341
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "design-probe");
342
+ assert.equal(readJson(path.join(session.artifactRoot, "current", "codex.json")).active_step_id, "design-probe");
343
+ assert.equal(snapshotFile(path.join(session.artifactRoot, "current", "other.json")), beforeOther);
344
+ assert.equal(snapshotFile(path.join(session.sessionDir, "trust.bundle")), bundleBefore);
345
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
346
+ });
347
+
348
+ test("recovery fails before any write for invalid Work Item cardinality and Flow subject bindings", async (t) => {
349
+ const cases = [
350
+ ["zero refs", [], /state\.work_item_refs.*exactly one/],
351
+ ["empty ref", [""], /state\.work_item_refs.*exactly one/],
352
+ ["blank ref", [" "], /state\.work_item_refs.*exactly one/],
353
+ ["two refs", [SUBJECT, "local:work-item/other"], /state\.work_item_refs.*exactly one/],
354
+ ];
355
+ for (const [name, refs, pattern] of cases) {
356
+ await t.test(name, async () => {
357
+ const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
358
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
359
+ const state = readJson(path.join(session.sessionDir, "state.json"));
360
+ state.work_item_refs = refs;
361
+ writeJson(path.join(session.sessionDir, "state.json"), state);
362
+ await assertRecoveryRejectsWithoutWrites(session, pattern);
363
+ });
364
+ }
365
+
366
+ await t.test("persisted state subject mismatch", async () => {
367
+ const session = makeSession("recover-state-subject-mismatch");
368
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
369
+ const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
370
+ const state = readJson(file);
371
+ state.subject = "local:work-item/other";
372
+ writeJson(file, state);
373
+ await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.subject.*selected Work Item/);
374
+ });
375
+
376
+ await t.test("persisted params subject mismatch", async () => {
377
+ const session = makeSession("recover-params-subject-mismatch");
378
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
379
+ const file = path.join(runDir(session.slug, session.projectRoot), "state.json");
380
+ const state = readJson(file);
381
+ state.params.subject = "local:work-item/other";
382
+ writeJson(file, state);
383
+ await assertRecoveryRejectsWithoutWrites(session, /flow_run\.state\.params\.subject.*selected Work Item/);
384
+ });
385
+
386
+ await t.test("absent persisted params subject is allowed", async () => {
387
+ const session = makeSession("recover-params-subject-absent");
388
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
389
+ const flowDirectory = runDir(session.slug, session.projectRoot);
390
+ const file = path.join(flowDirectory, "state.json");
391
+ const state = readJson(file);
392
+ delete state.params.subject;
393
+ writeJson(file, state);
394
+ const beforeFlow = snapshotTree(flowDirectory);
395
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
396
+ assert.equal(recovered.attached, false);
397
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
398
+ });
399
+ });
400
+
401
+ test("recovery fails closed for invalid sidecars and missing or corrupt canonical runs", async (t) => {
402
+ await t.test("invalid session path", async () => {
403
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-invalid-session-"));
404
+ await assert.rejects(
405
+ () => recoverBuilderFlowSession({ sessionDir: path.join(projectRoot, "not-a-session") }),
406
+ /sessionDir.*must be \.kontourai\/flow-agents/,
407
+ );
408
+ assert.equal(fs.existsSync(path.join(projectRoot, ".kontourai")), false);
409
+ });
410
+
411
+ await t.test("missing run remains missing", async () => {
412
+ const session = makeSession("recover-missing-run");
413
+ await assertRecoveryRejectsWithoutWrites(session, /not_found|not found/i);
414
+ assert.equal(fs.existsSync(runDir(session.slug, session.projectRoot)), false);
415
+ });
416
+
417
+ await t.test("task slug mismatch", async () => {
418
+ const session = makeSession("recover-task-slug-mismatch");
419
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
420
+ const state = readJson(path.join(session.sessionDir, "state.json"));
421
+ state.task_slug = "other";
422
+ writeJson(path.join(session.sessionDir, "state.json"), state);
423
+ await assertRecoveryRejectsWithoutWrites(session, /task_slug.*match/);
424
+ });
425
+
426
+ await t.test("missing sidecar state", async () => {
427
+ const session = makeSession("recover-missing-sidecar-state");
428
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
429
+ fs.rmSync(path.join(session.sessionDir, "state.json"));
430
+ await assertRecoveryRejectsWithoutWrites(session, /sessionDir.*state\.json/);
431
+ });
432
+
433
+ await t.test("corrupt sidecar state", async () => {
434
+ const session = makeSession("recover-corrupt-sidecar-state");
435
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
436
+ fs.writeFileSync(path.join(session.sessionDir, "state.json"), "not-json\n");
437
+ await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|Unexpected/i);
438
+ });
439
+
440
+ for (const [name, relativeFile] of [
441
+ ["corrupt Flow state", "state.json"],
442
+ ["corrupt Flow manifest", FLOW_RUN_EVIDENCE_MANIFEST_PATH],
443
+ ]) {
444
+ await t.test(name, async () => {
445
+ const session = makeSession(`recover-${name.replaceAll(" ", "-").toLowerCase()}`);
446
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
447
+ fs.writeFileSync(path.join(runDir(session.slug, session.projectRoot), relativeFile), "not-json\n");
448
+ await assertRecoveryRejectsWithoutWrites(session, /JSON|parse|invalid|Unexpected/i);
449
+ });
450
+ }
451
+
452
+ for (const [name, mutate] of [
453
+ ["foreign definition identity", (definition) => { definition.id = "foreign.build"; }],
454
+ ["foreign definition content", (definition) => { definition.steps[0].description = "foreign content"; }],
455
+ ]) {
456
+ await t.test(name, async () => {
457
+ const session = makeSession(`recover-${name.replaceAll(" ", "-")}`);
458
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
459
+ const file = path.join(runDir(session.slug, session.projectRoot), "definition.json");
460
+ const definition = readJson(file);
461
+ mutate(definition);
462
+ writeJson(file, definition);
463
+ await assertRecoveryRejectsWithoutWrites(session, /definition|foreign|canonical|identity|content|invalid/i);
464
+ });
465
+ }
466
+ });
467
+
468
+ test("recover and sync remain separate: recovery leaves bundle unattached and sync evaluates it", async () => {
469
+ const session = makeSession("recover-then-sync");
470
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
471
+ writeBundle(session.sessionDir, [bundleClaim({
472
+ expectation: "selected-work",
473
+ claimType: "builder.pull-work.selected",
474
+ subjectType: "work-item",
475
+ })]);
476
+ const manifestFile = path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH);
477
+ const manifestBefore = snapshotFile(manifestFile);
478
+
479
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
480
+ assert.equal(recovered.attached, false);
481
+ assert.equal(snapshotFile(manifestFile), manifestBefore);
482
+ assert.equal(recovered.run.state.current_step, "pull-work");
483
+
484
+ const synced = await syncBuilderFlowSession({ sessionDir: session.sessionDir });
485
+ assert.equal(synced.attached, true);
486
+ assert.equal(synced.run.state.current_step, "design-probe");
487
+ assert.notEqual(snapshotFile(manifestFile), manifestBefore);
488
+ });
489
+
490
+ test("builder-run recover accepts only a session directory and rejects caller-selected identity", async () => {
491
+ const session = makeSession("recover-cli");
492
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
493
+ assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir]), 0);
494
+ assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--run-id", "other"]), 64);
495
+ assert.equal(await builderRunMain(["recover", "--session-dir", session.sessionDir, "--step-id", "verify"]), 64);
496
+ assert.equal(await builderRunMain(["recover", "extra", "--session-dir", session.sessionDir]), 64);
497
+ });
498
+
499
+ test("recovery refuses a sidecar changed after its immutable subject snapshot", async () => {
500
+ const session = makeSession("recover-sidecar-race");
501
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
502
+ const flowDirectory = runDir(session.slug, session.projectRoot);
503
+ const beforeFlow = snapshotTree(flowDirectory);
504
+ const recovery = recoverBuilderFlowSession({ sessionDir: session.sessionDir });
505
+ const changed = readJson(path.join(session.sessionDir, "state.json"));
506
+ changed.work_item_refs = ["local:work-item/raced"];
507
+ writeJson(path.join(session.sessionDir, "state.json"), changed);
508
+ const beforeProjection = snapshotProjectionTargets(session);
509
+
510
+ await assert.rejects(() => recovery, /state\.json.*changed during recovery/);
511
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
512
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
513
+ });
514
+
515
+ test("recovery parses every projection target before writing any target", async (t) => {
516
+ for (const target of ["global", "actor"]) {
517
+ await t.test(`malformed ${target} pointer`, async () => {
518
+ const session = makeSession(`recover-malformed-${target}-pointer`);
519
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
520
+ const state = readJson(path.join(session.sessionDir, "state.json"));
521
+ state.next_action = { status: "continue", summary: "stale projection" };
522
+ writeJson(path.join(session.sessionDir, "state.json"), state);
523
+ const pointer = target === "global"
524
+ ? path.join(session.artifactRoot, "current.json")
525
+ : path.join(session.artifactRoot, "current", "codex.json");
526
+ fs.mkdirSync(path.dirname(pointer), { recursive: true });
527
+ fs.writeFileSync(pointer, "not-json\n");
528
+ await assertRecoveryRejectsWithoutWrites(session, /projection target.*JSON|JSON|parse|Unexpected/i);
529
+ });
530
+ }
531
+ });
532
+
533
+ test("recovery rejects symlinked session and projection targets before writes", async (t) => {
534
+ await t.test("session directory symlink", async () => {
535
+ const session = makeSession("recover-session-symlink");
536
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
537
+ const target = path.join(session.artifactRoot, "recover-session-symlink-target");
538
+ fs.renameSync(session.sessionDir, target);
539
+ fs.symlinkSync(target, session.sessionDir, "dir");
540
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
541
+ const beforeState = snapshotFile(path.join(target, "state.json"));
542
+ await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /sessionDir.*symbolic link/);
543
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
544
+ assert.equal(snapshotFile(path.join(target, "state.json")), beforeState);
545
+ });
546
+
547
+ await t.test("state.json symlink", async () => {
548
+ const session = makeSession("recover-state-symlink");
549
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
550
+ const stateFile = path.join(session.sessionDir, "state.json");
551
+ const target = path.join(session.sessionDir, "state-target.json");
552
+ fs.renameSync(stateFile, target);
553
+ fs.symlinkSync(target, stateFile);
554
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
555
+ const beforeTarget = snapshotFile(target);
556
+ await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /state\.json.*symbolic link/);
557
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
558
+ assert.equal(snapshotFile(target), beforeTarget);
559
+ });
560
+
561
+ for (const targetKind of ["global pointer", "actor pointer", "dangling actor pointer", "actor directory"]) {
562
+ await t.test(targetKind, async () => {
563
+ const session = makeSession(`recover-${targetKind.replaceAll(" ", "-")}-symlink`);
564
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
565
+ const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-pointer-symlink-"));
566
+ const externalPointer = path.join(externalRoot, "current.json");
567
+ writeJson(externalPointer, { active_slug: session.slug, active_step_id: "stale", updated_at: NOW });
568
+ if (targetKind === "global pointer") {
569
+ fs.rmSync(path.join(session.artifactRoot, "current.json"));
570
+ fs.symlinkSync(externalPointer, path.join(session.artifactRoot, "current.json"));
571
+ } else if (targetKind === "actor pointer" || targetKind === "dangling actor pointer") {
572
+ const actorRoot = path.join(session.artifactRoot, "current");
573
+ fs.mkdirSync(actorRoot, { recursive: true });
574
+ fs.symlinkSync(targetKind === "dangling actor pointer" ? path.join(externalRoot, "missing.json") : externalPointer, path.join(actorRoot, "codex.json"));
575
+ } else {
576
+ fs.symlinkSync(externalRoot, path.join(session.artifactRoot, "current"), "dir");
577
+ }
578
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
579
+ const beforeState = snapshotFile(path.join(session.sessionDir, "state.json"));
580
+ const beforeExternal = snapshotFile(externalPointer);
581
+ await assert.rejects(() => recoverBuilderFlowSession({ sessionDir: session.sessionDir }), /current.*symbolic link|projection target.*symbolic link/);
582
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
583
+ assert.equal(snapshotFile(path.join(session.sessionDir, "state.json")), beforeState);
584
+ assert.equal(snapshotFile(externalPointer), beforeExternal);
585
+ });
586
+ }
213
587
  });
@@ -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
- : await syncBuilderFlowSession({ sessionDir });
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,
@@ -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, type ActiveFlowStep } 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
@@ -1312,8 +1312,15 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
1312
1312
  const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
1313
1313
  const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
1314
1314
  const deadline = Date.now() + 30000;
1315
+ let acquiredRoot: fs.Stats | null = null;
1316
+ let acquiredLock: fs.Stats | null = null;
1315
1317
  while (true) {
1316
- try { fs.mkdirSync(lockDir); break; }
1318
+ try {
1319
+ fs.mkdirSync(lockDir);
1320
+ acquiredRoot = fs.lstatSync(dir);
1321
+ acquiredLock = fs.lstatSync(lockDir);
1322
+ break;
1323
+ }
1317
1324
  catch (error) {
1318
1325
  const lockError = error as NodeJS.ErrnoException;
1319
1326
  if (lockError.code !== "EEXIST") {
@@ -1338,7 +1345,28 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
1338
1345
  if (delay) await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
1339
1346
  return await body();
1340
1347
  } finally {
1341
- fs.rmSync(lockDir, { recursive: true, force: true });
1348
+ try {
1349
+ const currentRoot = fs.lstatSync(dir);
1350
+ const currentLock = fs.lstatSync(lockDir);
1351
+ const sameIdentity = (left: fs.Stats, right: fs.Stats): boolean =>
1352
+ left.dev === right.dev && left.ino === right.ino;
1353
+ if (acquiredRoot
1354
+ && acquiredLock
1355
+ && !currentRoot.isSymbolicLink()
1356
+ && currentRoot.isDirectory()
1357
+ && !currentLock.isSymbolicLink()
1358
+ && currentLock.isDirectory()
1359
+ && sameIdentity(currentRoot, acquiredRoot)
1360
+ && sameIdentity(currentLock, acquiredLock)) {
1361
+ fs.rmdirSync(lockDir);
1362
+ } else {
1363
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
1364
+ }
1365
+ } catch (error) {
1366
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1367
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
1368
+ }
1369
+ }
1342
1370
  }
1343
1371
  }
1344
1372
 
@@ -1661,7 +1689,7 @@ function initSidecars(
1661
1689
  slug: string,
1662
1690
  sourceRequest: string,
1663
1691
  summary: string,
1664
- nextAction: string,
1692
+ nextAction: string | AnyObj,
1665
1693
  timestamp: string,
1666
1694
  markdown?: string,
1667
1695
  workItemRefs: string[] = [],
@@ -1698,12 +1726,16 @@ function initSidecars(
1698
1726
  // existing state.json's created_at when present; stamp only on true first-creation.
1699
1727
  // updated_at still reflects "now" on every call — that field is intentionally mutable.
1700
1728
  const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
1729
+ const nextActionSummary = typeof nextAction === "string" ? nextAction : String(nextAction.summary ?? summary);
1730
+ const nextActionPayload = typeof nextAction === "string"
1731
+ ? { status: "continue", summary: nextAction || summary }
1732
+ : { status: "continue", ...nextAction, summary: nextActionSummary };
1701
1733
  writeJson(path.join(dir, "state.json"), {
1702
1734
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1703
1735
  ...(branch ? { branch } : {}),
1704
1736
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1705
1737
  artifact_paths: relArtifacts(dir),
1706
- next_action: { status: "continue", summary: nextAction || summary },
1738
+ next_action: nextActionPayload,
1707
1739
  });
1708
1740
  writeJson(path.join(dir, "acceptance.json"), {
1709
1741
  ...sidecarBase(slug), source_request: sourceRequest,
@@ -1711,7 +1743,7 @@ function initSidecars(
1711
1743
  goal_fit: { status: "pending", summary: "Goal fit has not been verified yet." },
1712
1744
  });
1713
1745
  writeJson(path.join(dir, "handoff.json"), {
1714
- ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextAction ? [nextAction] : [], blockers: [], warnings: [],
1746
+ ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextActionSummary ? [nextActionSummary] : [], blockers: [], warnings: [],
1715
1747
  });
1716
1748
  }
1717
1749
 
@@ -1943,19 +1975,53 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
1943
1975
  return { flowId, stepId: explicitStep || firstStep, firstStep };
1944
1976
  }
1945
1977
 
1978
+ function assertCanonicalBuilderArtifactRoot(root: string): void {
1979
+ const kontouraiRoot = path.dirname(root);
1980
+ const projectRoot = path.dirname(kontouraiRoot);
1981
+ if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
1982
+ die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
1983
+ }
1984
+
1985
+ const statIfPresent = (candidate: string): fs.Stats | null => {
1986
+ try {
1987
+ return fs.lstatSync(candidate);
1988
+ } catch (error) {
1989
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1990
+ throw error;
1991
+ }
1992
+ };
1993
+ const projectStat = statIfPresent(projectRoot);
1994
+ if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
1995
+ die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
1996
+ }
1997
+ const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
1998
+ for (const [candidate, expected, label] of [
1999
+ [kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
2000
+ [root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
2001
+ ] as const) {
2002
+ const stat = statIfPresent(candidate);
2003
+ if (!stat) continue;
2004
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2005
+ die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2006
+ }
2007
+ }
2008
+ }
2009
+
1946
2010
  function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
1947
2011
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1948
2012
  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)"));
1949
2013
  const dir = sessionDirFor(root, slug);
1950
- resolveEnsureSessionEntry(p, dir);
2014
+ const entry = resolveEnsureSessionEntry(p, dir);
2015
+ if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
1951
2016
  sessionWorkItem(p, slug, dir);
1952
2017
  }
1953
2018
 
1954
- function ensureSession(p: ReturnType<typeof parseArgs>): number {
2019
+ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
1955
2020
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1956
2021
  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)"));
1957
2022
  const dir = sessionDirFor(root, slug);
1958
2023
  const entry = resolveEnsureSessionEntry(p, dir);
2024
+ if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
1959
2025
  const workItem = sessionWorkItem(p, slug, dir);
1960
2026
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1961
2027
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -1982,9 +2048,15 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1982
2048
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
1983
2049
  const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
1984
2050
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
1985
- const nextAction = entry.flowId
2051
+ const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2052
+ const nextAction: string | AnyObj = entry.flowId
1986
2053
  ? entry.flowId === "builder.build"
1987
- ? `Start the canonical Flow run with \`flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}\`; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`
2054
+ ? {
2055
+ status: "continue",
2056
+ summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
2057
+ skills: ["pull-work"],
2058
+ command: startCommand,
2059
+ }
1988
2060
  : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1989
2061
  : opt(p, "next-action", "Continue.");
1990
2062
  initSidecars(
@@ -2014,6 +2086,15 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
2014
2086
  ? persistedCurrent.active_step_id
2015
2087
  : entry.stepId;
2016
2088
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2089
+ if (entry.flowId === "builder.build") {
2090
+ try {
2091
+ await startBuilderFlowSession({ sessionDir: dir });
2092
+ } catch (error) {
2093
+ const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2094
+ process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
2095
+ throw error;
2096
+ }
2097
+ }
2017
2098
  console.log(dir);
2018
2099
  return 0;
2019
2100
  }