@kontourai/flow-agents 3.7.0 → 3.9.0

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 (37) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/src/builder-flow-runtime.js +118 -17
  3. package/build/src/cli/assignment-provider.js +13 -1
  4. package/build/src/cli/continuation-adapter.d.ts +24 -0
  5. package/build/src/cli/continuation-adapter.js +225 -0
  6. package/build/src/cli/workflow-sidecar.js +29 -11
  7. package/build/src/cli/workflow.js +67 -11
  8. package/build/src/continuation-driver.d.ts +92 -0
  9. package/build/src/continuation-driver.js +444 -0
  10. package/build/src/index.d.ts +2 -0
  11. package/build/src/index.js +1 -0
  12. package/build/src/tools/build-universal-bundles.js +53 -3
  13. package/docs/getting-started.md +9 -1
  14. package/docs/public-workflow-cli.md +54 -0
  15. package/docs/spec/builder-flow-runtime.md +36 -2
  16. package/docs/workflow-usage-guide.md +7 -0
  17. package/evals/integration/test_builder_step_producers.sh +37 -4
  18. package/evals/integration/test_bundle_install.sh +108 -4
  19. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  20. package/evals/integration/test_install_merge.sh +18 -8
  21. package/evals/integration/test_public_workflow_cli.sh +11 -5
  22. package/evals/static/test_universal_bundles.sh +44 -1
  23. package/package.json +4 -4
  24. package/packaging/README.md +1 -1
  25. package/scripts/README.md +1 -1
  26. package/scripts/install-codex-home.sh +63 -23
  27. package/scripts/install-owned-files.js +18 -2
  28. package/src/builder-flow-runtime.ts +108 -10
  29. package/src/cli/assignment-provider.ts +13 -1
  30. package/src/cli/builder-flow-runtime.test.mjs +378 -20
  31. package/src/cli/continuation-adapter.ts +239 -0
  32. package/src/cli/continuation-driver.test.mjs +564 -0
  33. package/src/cli/workflow-sidecar.ts +27 -11
  34. package/src/cli/workflow.ts +68 -11
  35. package/src/continuation-driver.ts +532 -0
  36. package/src/index.ts +20 -0
  37. package/src/tools/build-universal-bundles.ts +51 -3
@@ -18,10 +18,12 @@ import {
18
18
  syncBuilderFlowSession,
19
19
  } from "../../build/src/builder-flow-runtime.js";
20
20
  import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
21
+ import { driveBuilderFlowSession } from "../../build/src/continuation-driver.js";
21
22
  import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
22
- import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
23
+ import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
23
24
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
24
- import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
25
+ import { main as workflowMain } from "../../build/src/cli/workflow.js";
26
+ import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
25
27
 
26
28
  const SUBJECT = "local:work-item/runtime-projection";
27
29
  const NOW = "2026-07-09T20:00:00.000Z";
@@ -88,7 +90,7 @@ function claimSessionAssignment(session) {
88
90
  ttlSeconds: 1800,
89
91
  actorKey: ACTOR_KEY,
90
92
  branch: `agent/${session.slug}`,
91
- artifactDir: session.sessionDir,
93
+ artifactDir: session.slug,
92
94
  workItemRef: SUBJECT,
93
95
  reason: "test",
94
96
  });
@@ -100,7 +102,7 @@ function claimAmbientSessionAssignment(session) {
100
102
  ttlSeconds: 1800,
101
103
  actorKey: ambient.actorKey,
102
104
  branch: `agent/${session.slug}`,
103
- artifactDir: session.sessionDir,
105
+ artifactDir: session.slug,
104
106
  workItemRef: SUBJECT,
105
107
  reason: "test",
106
108
  });
@@ -214,7 +216,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
214
216
  assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
215
217
  }
216
218
 
217
- function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = NOW }) {
219
+ function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = new Date().toISOString() }) {
218
220
  const claimId = `claim.${expectation}`;
219
221
  return {
220
222
  claim: {
@@ -264,7 +266,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
264
266
  };
265
267
  }
266
268
 
267
- function verifiedTestsPrerequisites(session, timestamp = NOW) {
269
+ function verifiedTestsPrerequisites(session, timestamp = new Date().toISOString()) {
268
270
  const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
269
271
  const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
270
272
  const implementationFile = path.relative(session.projectRoot, implementation);
@@ -312,6 +314,24 @@ function writeBundle(sessionDir, entries) {
312
314
  });
313
315
  }
314
316
 
317
+ function historicalProducerSuperseded(entry, suffix = "historical") {
318
+ const copy = withIdentitySuffix(entry, suffix);
319
+ copy.claim.producerStatus = "superseded";
320
+ return copy;
321
+ }
322
+
323
+ function withIdentitySuffix(entry, suffix) {
324
+ const copy = structuredClone(entry);
325
+ const priorId = copy.claim.id;
326
+ copy.claim.id = `${priorId}.${suffix}`;
327
+ copy.evidence.id = `${copy.evidence.id}.${suffix}`;
328
+ copy.evidence.claimId = copy.claim.id;
329
+ copy.event.id = `${copy.event.id}.${suffix}`;
330
+ copy.event.claimId = copy.claim.id;
331
+ copy.event.evidenceIds = [copy.evidence.id];
332
+ return copy;
333
+ }
334
+
315
335
  async function writeAndSync(session, entries) {
316
336
  writeBundle(session.sessionDir, entries);
317
337
  return syncBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -347,6 +367,130 @@ test("small-model client can start and advance from projected actions without ch
347
367
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
348
368
  });
349
369
 
370
+ test("Builder projection preserves Flow continuation semantics for exceptional statuses", async () => {
371
+ const session = makeSession("continuation-status-projection");
372
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
373
+ const stateFile = path.join(runDir(session.slug, session.projectRoot), "state.json");
374
+ const original = readJson(stateFile);
375
+ for (const [status, expected] of [
376
+ ["blocked", "continue"],
377
+ ["accepted_by_exception", "continue"],
378
+ ["needs_decision", "blocked"],
379
+ ["failed", "failed"],
380
+ ]) {
381
+ writeJson(stateFile, { ...original, status, next_action: `fixture ${status}` });
382
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
383
+ assert.equal(recovered.projection.next_action.status, expected, status);
384
+ }
385
+ });
386
+
387
+ test("continuation driver advances two real FlowDefinition steps unattended", async () => {
388
+ const session = makeSession("continuation-driver-two-steps");
389
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
390
+ const visited = [];
391
+
392
+ const result = await driveBuilderFlowSession({
393
+ sessionDir: session.sessionDir,
394
+ maxTurns: 2,
395
+ execute: async (request) => {
396
+ visited.push(request.current_step);
397
+ if (request.current_step === "pull-work") {
398
+ writeBundle(session.sessionDir, [bundleClaim({
399
+ expectation: "selected-work",
400
+ claimType: "builder.pull-work.selected",
401
+ subjectType: "work-item",
402
+ })]);
403
+ } else if (request.current_step === "design-probe") {
404
+ writeBundle(session.sessionDir, [
405
+ bundleClaim({
406
+ expectation: "pickup-probe-readiness",
407
+ claimType: "builder.design-probe.pickup-readiness",
408
+ subjectType: "work-item",
409
+ }),
410
+ bundleClaim({
411
+ expectation: "probe-decisions-or-accepted-gaps",
412
+ claimType: "builder.design-probe.decisions",
413
+ subjectType: "decision",
414
+ }),
415
+ ]);
416
+ } else {
417
+ assert.fail(`unexpected continuation step ${request.current_step}`);
418
+ }
419
+ return { status: "completed", summary: `completed ${request.current_step}` };
420
+ },
421
+ });
422
+
423
+ assert.equal(result.outcome, "budget_exhausted");
424
+ assert.equal(result.turns_started, 2);
425
+ assert.deepEqual(visited, ["pull-work", "design-probe"]);
426
+ assert.equal(result.snapshot.current_step, "plan");
427
+ assert.equal(result.snapshot.next_action.skills[0], "plan-work");
428
+ const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
429
+ assert.equal(driverState.status, "budget_exhausted");
430
+ assert.equal(driverState.turns_started, 2);
431
+ const events = fs.readFileSync(path.join(session.sessionDir, "continuation-driver", "events.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
432
+ assert.deepEqual(events.filter((event) => event.type === "turn_started").map((event) => event.current_step), ["pull-work", "design-probe"]);
433
+ });
434
+
435
+ test("public workflow drive invokes an explicit adapter but preserves canonical completion authority", async () => {
436
+ const session = makeSession("continuation-driver-cli");
437
+ claimAmbientSessionAssignment(session);
438
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
439
+ const adapter = path.join(session.projectRoot, "adapter.mjs");
440
+ const commandFile = path.join(session.projectRoot, "adapter-command.json");
441
+ fs.writeFileSync(adapter, `
442
+ import fs from "node:fs";
443
+ let input = "";
444
+ for await (const chunk of process.stdin) input += chunk;
445
+ fs.writeFileSync("adapter-request.json", input);
446
+ process.stdout.write(JSON.stringify({ status: "completed", summary: "adapter says done" }));
447
+ `);
448
+ writeJson(commandFile, { argv: [process.execPath, adapter] });
449
+
450
+ const rc = await workflowMain([
451
+ "drive",
452
+ "--session-dir", session.sessionDir,
453
+ "--adapter-command-file", commandFile,
454
+ "--max-turns", "1",
455
+ "--turn-timeout-ms", "5000",
456
+ "--barrier-wait-ms", "0",
457
+ "--json",
458
+ ]);
459
+
460
+ assert.equal(rc, 0);
461
+ const request = readJson(path.join(session.projectRoot, "adapter-request.json"));
462
+ assert.equal(request.current_step, "pull-work");
463
+ assert.deepEqual(request.next_action.skills, ["pull-work"]);
464
+ assert.equal(Object.hasOwn(request, "system_prompt"), false);
465
+ const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
466
+ assert.equal(driverState.status, "budget_exhausted");
467
+ const canonical = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
468
+ assert.equal(canonical.run.state.status, "active");
469
+ assert.equal(canonical.run.state.current_step, "pull-work");
470
+ });
471
+
472
+ test("public workflow drive rejects a non-owner before adapter execution", async () => {
473
+ const session = makeSession("continuation-driver-owner");
474
+ claimSessionAssignment(session);
475
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
476
+ const marker = path.join(session.projectRoot, "adapter-ran");
477
+ const adapter = path.join(session.projectRoot, "adapter.mjs");
478
+ const commandFile = path.join(session.projectRoot, "adapter-command.json");
479
+ fs.writeFileSync(adapter, `
480
+ import fs from "node:fs";
481
+ fs.writeFileSync(${JSON.stringify(marker)}, "ran");
482
+ process.stdout.write(JSON.stringify({ status: "completed" }));
483
+ `);
484
+ writeJson(commandFile, { argv: [process.execPath, adapter] });
485
+
486
+ await assert.rejects(
487
+ workflowMain(["drive", "--session-dir", session.sessionDir, "--adapter-command-file", commandFile]),
488
+ /active, matching assignment actor/,
489
+ );
490
+ assert.equal(fs.existsSync(marker), false);
491
+ assert.equal(fs.existsSync(path.join(session.sessionDir, "continuation-driver")), false);
492
+ });
493
+
350
494
  test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
351
495
  const session = makeSession("snapshot-attachment");
352
496
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -736,13 +880,15 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
736
880
  const verify = await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
737
881
  assert.equal(verify.run.state.current_step, "verify");
738
882
 
883
+ const failureTimestamp = new Date().toISOString();
739
884
  const routed = await writeAndSync(session, [bundleClaim({
740
885
  expectation: "tests-evidence",
741
886
  claimType: "builder.verify.tests",
742
887
  subjectType: "flow-step",
743
888
  status: "fail",
744
889
  routeReason: "implementation_defect",
745
- })]);
890
+ timestamp: failureTimestamp,
891
+ }), ...verifiedTestsPrerequisites(session, failureTimestamp)]);
746
892
 
747
893
  assert.equal(routed.run.state.current_step, "execute");
748
894
  assert.equal(routed.projection.flow_run.route_back_attempt, 1);
@@ -750,17 +896,28 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
750
896
  assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
751
897
  assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
752
898
 
753
- const reentered = await writeAndSync(session, [bundleClaim({
899
+ const reentered = await writeAndSync(session, [withIdentitySuffix(bundleClaim({
754
900
  expectation: "implementation-scope",
755
901
  claimType: "builder.execute.scope",
756
902
  subjectType: "change",
757
903
  timestamp: new Date().toISOString(),
758
- })]);
904
+ }), "reentry")]);
759
905
  assert.equal(reentered.run.state.current_step, "verify");
906
+ const staleRetry = await writeAndSync(session, [bundleClaim({
907
+ expectation: "tests-evidence",
908
+ claimType: "builder.verify.tests",
909
+ subjectType: "flow-step",
910
+ status: "fail",
911
+ routeReason: "implementation_defect",
912
+ timestamp: NOW,
913
+ })]);
914
+ assert.equal(staleRetry.attached, false);
915
+ assert.equal(staleRetry.run.state.current_step, "verify");
916
+ assert.equal(staleRetry.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
760
917
  const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
761
918
  const corrected = await writeAndSync(session, [
762
- bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
763
- ...verifiedTestsPrerequisites(session, correctedAt),
919
+ withIdentitySuffix(bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }), "corrected"),
920
+ ...verifiedTestsPrerequisites(session, correctedAt).map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`)),
764
921
  ]);
765
922
  assert.equal(corrected.run.state.current_step, "merge-ready");
766
923
  const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
@@ -768,6 +925,190 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
768
925
  assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
769
926
  });
770
927
 
928
+ test("producer-superseded FAIL is audit history and live PASS drives verify", async () => {
929
+ const session = makeSession("producer-superseded-verify");
930
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
931
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
932
+ await writeAndSync(session, [
933
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
934
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
935
+ ]);
936
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
937
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
938
+
939
+ const livePass = bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" });
940
+ const historicalFail = historicalProducerSuperseded(bundleClaim({
941
+ expectation: "tests-evidence",
942
+ claimType: "builder.verify.tests",
943
+ subjectType: "flow-step",
944
+ status: "fail",
945
+ routeReason: "implementation_defect",
946
+ }));
947
+ const result = await writeAndSync(session, [historicalFail, livePass, ...verifiedTestsPrerequisites(session)]);
948
+
949
+ assert.equal(result.run.state.current_step, "merge-ready");
950
+ const attached = result.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate" && !entry.superseded_by);
951
+ assert.equal(attached.length, 1);
952
+ assert.deepEqual(attached[0].expectation_ids.sort(), ["acceptance-criteria", "clean-critique", "tests-evidence"]);
953
+ });
954
+
955
+ test("partial passing review snapshot waits for the complete verify expectation set", async () => {
956
+ const session = makeSession("atomic-review-sync");
957
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
958
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
959
+ await writeAndSync(session, [
960
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
961
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
962
+ ]);
963
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
964
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
965
+
966
+ const disputedCritique = verifiedTestsPrerequisites(session)[0];
967
+ disputedCritique.claim.value = "fail";
968
+ disputedCritique.claim.status = "disputed";
969
+ disputedCritique.event.status = "disputed";
970
+ const disputedPartial = await writeAndSync(session, [disputedCritique]);
971
+ assert.equal(disputedPartial.attached, false);
972
+ assert.equal(disputedPartial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
973
+
974
+ const partial = await writeAndSync(session, verifiedTestsPrerequisites(session).slice(0, 1));
975
+ assert.equal(partial.attached, false);
976
+ assert.equal(partial.run.state.current_step, "verify");
977
+ assert.equal(partial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
978
+
979
+ const complete = await writeAndSync(session, [
980
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }),
981
+ ...verifiedTestsPrerequisites(session),
982
+ ]);
983
+ assert.equal(complete.run.state.current_step, "merge-ready");
984
+ });
985
+
986
+ test("initial gate boundary rejects pre-run and far-future claims", async () => {
987
+ for (const [name, timestamp] of [
988
+ ["pre-run", NOW],
989
+ ["far-future", new Date(Date.now() + 60 * 60_000).toISOString()],
990
+ ]) {
991
+ const session = makeSession(`freshness-${name}`);
992
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
993
+ const result = await writeAndSync(session, [bundleClaim({
994
+ expectation: "selected-work",
995
+ claimType: "builder.pull-work.selected",
996
+ subjectType: "work-item",
997
+ timestamp,
998
+ })]);
999
+ assert.equal(result.attached, false, name);
1000
+ assert.equal(result.run.state.current_step, "pull-work", name);
1001
+ }
1002
+ });
1003
+
1004
+ test("prior claim identity is rejected after re-entry and a new identity can recover", async () => {
1005
+ const session = makeSession("same-digest-reentry");
1006
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
1007
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
1008
+ await writeAndSync(session, [
1009
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
1010
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
1011
+ ]);
1012
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
1013
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
1014
+ const future = new Date(Date.now() + 10_000).toISOString();
1015
+ const failure = [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", status: "fail", routeReason: "implementation_defect", timestamp: future }), ...verifiedTestsPrerequisites(session, future)];
1016
+ const first = await writeAndSync(session, failure);
1017
+ assert.equal(first.run.state.current_step, "execute");
1018
+ await writeAndSync(session, [withIdentitySuffix(bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change", timestamp: future }), "reentry")]);
1019
+ const replay = await writeAndSync(session, failure);
1020
+ assert.equal(replay.attached, false);
1021
+ assert.equal(replay.run.state.current_step, "verify");
1022
+ assert.equal(replay.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
1023
+ const newIdentity = failure.map((entry, index) => withIdentitySuffix(entry, `visit-2-${index}`));
1024
+ const second = await writeAndSync(session, newIdentity);
1025
+ assert.equal(second.run.state.current_step, "execute");
1026
+ const verifyEvidence = second.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
1027
+ assert.equal(verifyEvidence.length, 2);
1028
+ assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
1029
+ });
1030
+
1031
+ test("legacy colon-bearing assignment actor releases only through its normalized equivalent", () => {
1032
+ const session = makeSession("legacy-actor-release");
1033
+ const legacyActor = { runtime: "unknown", session_id: "legacy-session", host: "Kontour", human: null };
1034
+ performLocalClaim(session.artifactRoot, session.slug, legacyActor, {
1035
+ ttlSeconds: 1800,
1036
+ actorKey: "unknown:legacy-session:Kontour",
1037
+ branch: `agent/${session.slug}`,
1038
+ artifactDir: session.sessionDir,
1039
+ workItemRef: SUBJECT,
1040
+ reason: "legacy fixture",
1041
+ });
1042
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
1043
+ actorKey: "different-actor",
1044
+ }), /refusing to release/);
1045
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, { ...legacyActor, session_id: "different" }, {
1046
+ actorKey: "unknownlegacy-sessionKontour",
1047
+ }), /refusing to release/);
1048
+ const released = performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
1049
+ actorKey: "unknownlegacy-sessionKontour",
1050
+ });
1051
+ assert.equal(released.status, "released");
1052
+ });
1053
+
1054
+ test("string-only legacy liveness identity cannot be released by a colliding modern actor", async () => {
1055
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-legacy-liveness-"));
1056
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
1057
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
1058
+ fs.writeFileSync(eventsFile, `${JSON.stringify({
1059
+ type: "claim",
1060
+ subjectId: "legacy-subject",
1061
+ actor: "unknown:anc-123456789abc:Kontour",
1062
+ at: NOW,
1063
+ ttlSeconds: 1800,
1064
+ })}\n`);
1065
+
1066
+ await assert.rejects(() => workflowSidecarMain([
1067
+ "liveness", "release", "legacy-subject",
1068
+ "--actor", "unknownanc-123456789abcKontour",
1069
+ "--artifact-root", root,
1070
+ "--at", "2026-07-09T20:01:00.000Z",
1071
+ ]), /prior claim/);
1072
+ const events = fs.readFileSync(eventsFile, "utf8").trim().split("\n").map(JSON.parse);
1073
+ assert.equal(events.length, 1);
1074
+ });
1075
+
1076
+ test("lossy modern liveness key collision cannot release a non-reversible legacy actor", async () => {
1077
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lossy-liveness-"));
1078
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
1079
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
1080
+ fs.writeFileSync(eventsFile, `${JSON.stringify({ type: "claim", subjectId: "collision", actor: "a:b", at: NOW, ttlSeconds: 1800 })}\n`);
1081
+ await assert.rejects(() => workflowSidecarMain([
1082
+ "liveness", "release", "collision", "--actor", "ab", "--artifact-root", root,
1083
+ ]), /prior claim/);
1084
+ assert.equal(fs.readFileSync(eventsFile, "utf8").trim().split("\n").length, 1);
1085
+ });
1086
+
1087
+ test("upgrade rebuild preserves legacy critique identity until a versioned new review", async () => {
1088
+ const legacy = {
1089
+ id: "upgrade-review",
1090
+ reviewer: "reviewer",
1091
+ reviewed_at: "2026-07-01T00:00:00Z",
1092
+ verdict: "pass",
1093
+ summary: "Legacy clean review",
1094
+ findings: [],
1095
+ lanes: [{ id: "code", status: "pass" }],
1096
+ review_target: { artifacts: [] },
1097
+ artifact_refs: [],
1098
+ };
1099
+ const beforeUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-01T00:00:00Z", [], [], [legacy]);
1100
+ const rebuiltAfterUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:00:00Z", [], [], [legacy]);
1101
+ const newReview = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:01:00Z", [], [], [{
1102
+ ...legacy,
1103
+ reviewed_at: "2026-07-12T00:01:00Z",
1104
+ identity_version: 2,
1105
+ }]);
1106
+ assert.equal(beforeUpgrade.claims[0].id, rebuiltAfterUpgrade.claims[0].id);
1107
+ assert.notEqual(newReview.claims[0].id, beforeUpgrade.claims[0].id);
1108
+ assert.equal(rebuiltAfterUpgrade.claims[0].metadata.identity_version, undefined);
1109
+ assert.equal(newReview.claims[0].metadata.identity_version, 2);
1110
+ });
1111
+
771
1112
  test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
772
1113
  const session = makeSession("stale-critique-artifact");
773
1114
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -837,25 +1178,30 @@ test("verified sidecar claims drive the composed publish and learning prefix to
837
1178
  claimSessionAssignment(session);
838
1179
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
839
1180
  const steps = [
840
- [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
841
- [
1181
+ () => [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
1182
+ () => [
842
1183
  bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
843
1184
  bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
844
1185
  ],
845
- [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
846
- [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
847
- [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
848
- [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
1186
+ () => [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
1187
+ () => [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
1188
+ () => [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
1189
+ () => [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
849
1190
  ];
850
- for (const entries of steps) await writeAndSync(session, entries);
1191
+ for (const entries of steps) await writeAndSync(session, entries());
851
1192
 
852
1193
  const prOpen = readJson(path.join(session.sessionDir, "state.json"));
853
1194
  assert.equal(prOpen.flow_run.current_step, "pr-open");
1195
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "pr-open");
854
1196
  assert.deepEqual(prOpen.next_action.skills, []);
855
1197
  assert.deepEqual(prOpen.next_action.operations, ["publish-change"]);
856
1198
 
857
- await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
858
- await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1199
+ const mergeReadyCi = await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
1200
+ assert.equal(mergeReadyCi.run.state.current_step, "merge-ready-ci");
1201
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "merge-ready-ci");
1202
+ const learn = await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1203
+ assert.equal(learn.run.state.current_step, "learn");
1204
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "learn");
859
1205
  const completed = await writeAndSync(session, [
860
1206
  bundleClaim({ expectation: "decision-evidence", claimType: "builder.learn.decisions", subjectType: "decision" }),
861
1207
  bundleClaim({ expectation: "learning-evidence", claimType: "builder.learn.evidence", subjectType: "release" }),
@@ -865,6 +1211,18 @@ test("verified sidecar claims drive the composed publish and learning prefix to
865
1211
  assert.equal(completed.run.state.status, "completed");
866
1212
  assert.equal(completed.projection.status, "delivered");
867
1213
  assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
1214
+ const liveEvidence = completed.run.manifest.evidence.filter((entry) => !entry.superseded_by);
1215
+ assert.deepEqual(liveEvidence.map((entry) => entry.expectation_ids), [
1216
+ ["selected-work"],
1217
+ ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"],
1218
+ ["implementation-plan"],
1219
+ ["implementation-scope"],
1220
+ ["clean-critique", "acceptance-criteria", "tests-evidence"],
1221
+ ["merge-readiness"],
1222
+ ["pull-request-opened"],
1223
+ ["ci-merge-readiness"],
1224
+ ["decision-evidence", "learning-evidence"],
1225
+ ]);
868
1226
 
869
1227
  const flowDirectory = runDir(session.slug, session.projectRoot);
870
1228
  const beforeFlow = snapshotTree(flowDirectory);