@kontourai/flow-agents 3.6.0 → 3.8.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 (96) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +407 -51
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +80 -14
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +825 -103
  11. package/build/src/cli/workflow.js +301 -43
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/build-universal-bundles.js +53 -3
  18. package/build/src/tools/generate-context-map.js +5 -3
  19. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  20. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  21. package/docs/context-map.md +22 -20
  22. package/docs/developer-architecture.md +1 -1
  23. package/docs/getting-started.md +9 -1
  24. package/docs/public-workflow-cli.md +76 -7
  25. package/docs/skills-map.md +51 -27
  26. package/docs/spec/builder-flow-runtime.md +75 -40
  27. package/docs/workflow-usage-guide.md +109 -42
  28. package/evals/fixtures/hook-influence/cases.json +2 -2
  29. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  30. package/evals/integration/test_builder_step_producers.sh +330 -6
  31. package/evals/integration/test_bundle_install.sh +318 -65
  32. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  33. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  34. package/evals/integration/test_current_json_per_actor.sh +12 -0
  35. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  36. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  37. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  38. package/evals/integration/test_gate_lockdown.sh +3 -5
  39. package/evals/integration/test_goal_fit_hook.sh +60 -2
  40. package/evals/integration/test_install_merge.sh +18 -8
  41. package/evals/integration/test_liveness_verdict.sh +14 -17
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  43. package/evals/integration/test_public_workflow_cli.sh +334 -14
  44. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  45. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  46. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  47. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  48. package/evals/run.sh +2 -0
  49. package/evals/static/test_builder_skill_coherence.sh +247 -0
  50. package/evals/static/test_library_exports.sh +5 -2
  51. package/evals/static/test_universal_bundles.sh +44 -1
  52. package/evals/static/test_workflow_skills.sh +13 -325
  53. package/kits/builder/flows/build.flow.json +22 -0
  54. package/kits/builder/flows/shape.flow.json +9 -9
  55. package/kits/builder/kit.json +70 -16
  56. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  57. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  58. package/kits/builder/skills/deliver/SKILL.md +96 -442
  59. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  60. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  61. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  62. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  63. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  64. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  65. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  66. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  67. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  68. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  69. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  70. package/kits/builder/skills/review-work/SKILL.md +89 -176
  71. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  72. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  73. package/package.json +2 -2
  74. package/packaging/README.md +1 -1
  75. package/scripts/README.md +1 -1
  76. package/scripts/hooks/lib/kit-catalog.js +1 -1
  77. package/scripts/hooks/stop-goal-fit.js +78 -9
  78. package/scripts/install-codex-home.sh +63 -23
  79. package/scripts/install-owned-files.js +18 -2
  80. package/src/builder-flow-run-adapter.ts +118 -32
  81. package/src/builder-flow-runtime.ts +426 -64
  82. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  83. package/src/cli/assignment-provider.ts +75 -14
  84. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  85. package/src/cli/builder-flow-runtime.test.mjs +436 -18
  86. package/src/cli/builder-run.ts +3 -9
  87. package/src/cli/kit-metadata-security.test.mjs +232 -6
  88. package/src/cli/public-api.test.mjs +15 -0
  89. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  90. package/src/cli/workflow-sidecar.ts +746 -103
  91. package/src/cli/workflow.ts +288 -41
  92. package/src/flow-kit/validate.ts +320 -2
  93. package/src/index.ts +6 -5
  94. package/src/lib/observed-command.ts +96 -0
  95. package/src/tools/build-universal-bundles.ts +51 -3
  96. package/src/tools/generate-context-map.ts +5 -3
@@ -3,12 +3,13 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { generateKeyPairSync, sign } from "node:crypto";
6
+ import { createHash, generateKeyPairSync, sign } from "node:crypto";
7
7
 
8
8
  import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
9
9
  import {
10
10
  archiveBuilderFlowSession,
11
11
  cancelBuilderFlowSession,
12
+ captureReviewWorkspaceSnapshot,
12
13
  pauseBuilderFlowSession,
13
14
  recoverBuilderFlowSession,
14
15
  releaseBuilderFlowAssignment,
@@ -18,8 +19,9 @@ import {
18
19
  } from "../../build/src/builder-flow-runtime.js";
19
20
  import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
20
21
  import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
21
- import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
22
+ import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
22
23
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
24
+ import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
23
25
 
24
26
  const SUBJECT = "local:work-item/runtime-projection";
25
27
  const NOW = "2026-07-09T20:00:00.000Z";
@@ -51,6 +53,9 @@ function makeSession(slug = "runtime-projection") {
51
53
  schema_version: "1.0",
52
54
  keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
53
55
  });
56
+ fs.mkdirSync(path.join(projectRoot, "review-target"), { recursive: true });
57
+ fs.writeFileSync(path.join(projectRoot, "review-target", "implementation.txt"), "reviewed implementation\n");
58
+ fs.writeFileSync(path.join(projectRoot, "review-target", "delivery.md"), "reviewed delivery artifact\n");
54
59
  return { projectRoot, artifactRoot, sessionDir, slug };
55
60
  }
56
61
 
@@ -59,6 +64,15 @@ function writeJson(file, value) {
59
64
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
60
65
  }
61
66
 
67
+ test("shell output cannot spoof an executed-test count", () => {
68
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-test-count-"));
69
+ fs.mkdirSync(path.join(root, "checks"), { recursive: true });
70
+ fs.writeFileSync(path.join(root, "checks", "fake-test.sh"), "#!/bin/sh\nset -e\nprintf '1 passed\\n'\n");
71
+ fs.writeFileSync(path.join(root, "checks", "real-test.sh"), "#!/bin/sh\nset -e\ntest -f checks/real-test.sh\n");
72
+ assert.equal(inferExecutedTestCount("sh checks/fake-test.sh", root, "1 passed\n"), 0);
73
+ assert.equal(inferExecutedTestCount("sh checks/real-test.sh", root, "1..1\nok 1 - file exists\n"), 1);
74
+ });
75
+
62
76
  function readJson(file) {
63
77
  return JSON.parse(fs.readFileSync(file, "utf8"));
64
78
  }
@@ -200,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
200
214
  assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
201
215
  }
202
216
 
203
- function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
217
+ function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = new Date().toISOString() }) {
204
218
  const claimId = `claim.${expectation}`;
205
219
  return {
206
220
  claim: {
@@ -212,6 +226,11 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
212
226
  value: status,
213
227
  metadata: {
214
228
  workflow_subject_ref: subject,
229
+ ...(expectation === "tests-evidence" ? {
230
+ recorded_by: "implementation-actor",
231
+ artifact_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }],
232
+ observed_commands: [{ command: "node --test src/cli/builder-flow-runtime.test.mjs", exit_code: 0, test_count: testCount, output_sha256: "0".repeat(64) }],
233
+ } : {}),
215
234
  gate_claim: {
216
235
  expectation_id: expectation,
217
236
  claim_type: claimType,
@@ -219,8 +238,8 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
219
238
  ...(routeReason ? { route_reason: routeReason } : {}),
220
239
  },
221
240
  },
222
- createdAt: NOW,
223
- updatedAt: NOW,
241
+ createdAt: timestamp,
242
+ updatedAt: timestamp,
224
243
  },
225
244
  evidence: {
226
245
  id: `evidence.${expectation}`,
@@ -229,7 +248,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
229
248
  method: "attestation",
230
249
  sourceRef: "src/cli/builder-flow-runtime.test.mjs",
231
250
  excerptOrSummary: `${expectation} fixture`,
232
- observedAt: NOW,
251
+ observedAt: timestamp,
233
252
  collectedBy: "flow-agents-test",
234
253
  },
235
254
  event: {
@@ -239,10 +258,47 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
239
258
  actor: "flow-agents-test",
240
259
  method: "attestation",
241
260
  evidenceIds: [`evidence.${expectation}`],
242
- createdAt: NOW,
243
- verifiedAt: NOW,
261
+ createdAt: timestamp,
262
+ verifiedAt: timestamp,
263
+ },
264
+ };
265
+ }
266
+
267
+ function verifiedTestsPrerequisites(session, timestamp = new Date().toISOString()) {
268
+ const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
269
+ const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
270
+ const implementationFile = path.relative(session.projectRoot, implementation);
271
+ const implementationBytes = fs.readFileSync(implementation);
272
+ const implementationSha256 = createHash("sha256").update(implementationBytes).digest("hex");
273
+ const workspaceDigest = createHash("sha256")
274
+ .update("flow-agents:reviewed-files:v1\0")
275
+ .update(implementationFile).update("\0").update(implementationBytes).update("\0")
276
+ .digest("hex");
277
+ const critique = bundleClaim({ expectation: "clean-critique", claimType: "workflow.critique.review", subjectType: "workflow-critique", timestamp });
278
+ critique.claim.metadata = {
279
+ workflow_subject_ref: SUBJECT,
280
+ origin: "critique",
281
+ reviewer: "reviewer-actor",
282
+ findings: [],
283
+ lanes: [{ id: "code", status: "pass" }],
284
+ review_target: {
285
+ artifacts: [{ file: path.relative(session.projectRoot, reviewArtifact), sha256: createHash("sha256").update(fs.readFileSync(reviewArtifact)).digest("hex") }],
286
+ workspace_snapshot: {
287
+ version: 1,
288
+ kind: "reviewed-files",
289
+ algorithm: "sha256",
290
+ digest: workspaceDigest,
291
+ files: [{ file: implementationFile, sha256: implementationSha256 }],
292
+ },
244
293
  },
245
294
  };
295
+ const criterion = bundleClaim({ expectation: "verified-criterion", claimType: "workflow.acceptance.criterion", subjectType: "flow-step", timestamp });
296
+ criterion.claim.metadata = {
297
+ workflow_subject_ref: SUBJECT,
298
+ origin: "acceptance",
299
+ criterion: { id: "ac-runtime", status: "pass", evidence_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }] },
300
+ };
301
+ return [critique, criterion];
246
302
  }
247
303
 
248
304
  function writeBundle(sessionDir, entries) {
@@ -256,6 +312,24 @@ function writeBundle(sessionDir, entries) {
256
312
  });
257
313
  }
258
314
 
315
+ function historicalProducerSuperseded(entry, suffix = "historical") {
316
+ const copy = withIdentitySuffix(entry, suffix);
317
+ copy.claim.producerStatus = "superseded";
318
+ return copy;
319
+ }
320
+
321
+ function withIdentitySuffix(entry, suffix) {
322
+ const copy = structuredClone(entry);
323
+ const priorId = copy.claim.id;
324
+ copy.claim.id = `${priorId}.${suffix}`;
325
+ copy.evidence.id = `${copy.evidence.id}.${suffix}`;
326
+ copy.evidence.claimId = copy.claim.id;
327
+ copy.event.id = `${copy.event.id}.${suffix}`;
328
+ copy.event.claimId = copy.claim.id;
329
+ copy.event.evidenceIds = [copy.evidence.id];
330
+ return copy;
331
+ }
332
+
259
333
  async function writeAndSync(session, entries) {
260
334
  writeBundle(session.sessionDir, entries);
261
335
  return syncBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -291,6 +365,53 @@ test("small-model client can start and advance from projected actions without ch
291
365
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
292
366
  });
293
367
 
368
+ test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
369
+ const session = makeSession("snapshot-attachment");
370
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
371
+ const originalEntries = [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })];
372
+ writeBundle(session.sessionDir, originalEntries);
373
+ const bundleFile = path.join(session.sessionDir, "trust.bundle");
374
+ const originalDigest = createHash("sha256").update(fs.readFileSync(bundleFile)).digest("hex");
375
+ let replaceBundle;
376
+ const replaced = new Promise((resolve) => { replaceBundle = resolve; });
377
+ const watcher = fs.watch(session.sessionDir, (_event, filename) => {
378
+ if (String(filename) !== ".trust-bundle-snapshots") return;
379
+ fs.writeFileSync(bundleFile, "{\"claims\":[]}");
380
+ replaceBundle();
381
+ });
382
+ try {
383
+ const syncing = syncBuilderFlowSession({ sessionDir: session.sessionDir });
384
+ await Promise.race([
385
+ replaced,
386
+ new Promise((_, reject) => setTimeout(() => reject(new Error("trust.bundle snapshot was not staged")), 2_000)),
387
+ ]);
388
+ const synced = await syncing;
389
+ assert.equal(synced.attached, true);
390
+ } finally {
391
+ watcher.close();
392
+ }
393
+ const manifest = readJson(path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH));
394
+ assert.equal(manifest.evidence.at(-1).sha256, originalDigest);
395
+ assert.equal(fs.existsSync(path.join(session.sessionDir, ".trust-bundle-snapshots")), false);
396
+ });
397
+
398
+ test("start rejects a requested Builder flow that differs from the existing run before projection mutation", async (t) => {
399
+ for (const [existingFlowId, requestedFlowId] of [["builder.shape", "builder.build"], ["builder.build", "builder.shape"]]) {
400
+ await t.test(`${existingFlowId} cannot resume as ${requestedFlowId}`, async () => {
401
+ const session = makeSession(`flow-mismatch-${existingFlowId.replace('.', '-')}`);
402
+ await startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: existingFlowId });
403
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
404
+ const beforeProjection = snapshotProjectionTargets(session);
405
+ await assert.rejects(
406
+ () => startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: requestedFlowId }),
407
+ new RegExp(`requested ${requestedFlowId} does not match the existing ${existingFlowId} run`),
408
+ );
409
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
410
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
411
+ });
412
+ }
413
+ });
414
+
294
415
  test("pause and resume preserve the current Flow step and active assignment", async () => {
295
416
  const session = makeSession("lifecycle-pause-resume");
296
417
  claimAmbientSessionAssignment(session);
@@ -546,6 +667,8 @@ test("builder-run exposes lifecycle actions without caller-selected Flow identit
546
667
  const ambient = resolveCurrentAssignmentActor();
547
668
  performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
548
669
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
670
+ assert.equal(await builderRunMain(["start", "--session-dir", session.sessionDir]), 64);
671
+ assert.equal(await builderRunMain(["sync", "--session-dir", session.sessionDir]), 64);
549
672
  assert.equal(await builderRunMain([
550
673
  "pause", "--session-dir", session.sessionDir,
551
674
  "--reason", "cli pause",
@@ -631,19 +754,297 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
631
754
  const verify = await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
632
755
  assert.equal(verify.run.state.current_step, "verify");
633
756
 
757
+ const failureTimestamp = new Date().toISOString();
634
758
  const routed = await writeAndSync(session, [bundleClaim({
635
759
  expectation: "tests-evidence",
636
760
  claimType: "builder.verify.tests",
637
761
  subjectType: "flow-step",
638
762
  status: "fail",
639
763
  routeReason: "implementation_defect",
640
- })]);
764
+ timestamp: failureTimestamp,
765
+ }), ...verifiedTestsPrerequisites(session, failureTimestamp)]);
641
766
 
642
767
  assert.equal(routed.run.state.current_step, "execute");
643
768
  assert.equal(routed.projection.flow_run.route_back_attempt, 1);
644
769
  assert.equal(routed.projection.flow_run.route_back_max_attempts, 3);
645
770
  assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
646
771
  assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
772
+
773
+ const reentered = await writeAndSync(session, [withIdentitySuffix(bundleClaim({
774
+ expectation: "implementation-scope",
775
+ claimType: "builder.execute.scope",
776
+ subjectType: "change",
777
+ timestamp: new Date().toISOString(),
778
+ }), "reentry")]);
779
+ assert.equal(reentered.run.state.current_step, "verify");
780
+ const staleRetry = await writeAndSync(session, [bundleClaim({
781
+ expectation: "tests-evidence",
782
+ claimType: "builder.verify.tests",
783
+ subjectType: "flow-step",
784
+ status: "fail",
785
+ routeReason: "implementation_defect",
786
+ timestamp: NOW,
787
+ })]);
788
+ assert.equal(staleRetry.attached, false);
789
+ assert.equal(staleRetry.run.state.current_step, "verify");
790
+ assert.equal(staleRetry.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
791
+ const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
792
+ const corrected = await writeAndSync(session, [
793
+ withIdentitySuffix(bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }), "corrected"),
794
+ ...verifiedTestsPrerequisites(session, correctedAt).map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`)),
795
+ ]);
796
+ assert.equal(corrected.run.state.current_step, "merge-ready");
797
+ const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
798
+ assert.equal(verifyEvidence.length, 2);
799
+ assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
800
+ });
801
+
802
+ test("producer-superseded FAIL is audit history and live PASS drives verify", async () => {
803
+ const session = makeSession("producer-superseded-verify");
804
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
805
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
806
+ await writeAndSync(session, [
807
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
808
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
809
+ ]);
810
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
811
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
812
+
813
+ const livePass = bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" });
814
+ const historicalFail = historicalProducerSuperseded(bundleClaim({
815
+ expectation: "tests-evidence",
816
+ claimType: "builder.verify.tests",
817
+ subjectType: "flow-step",
818
+ status: "fail",
819
+ routeReason: "implementation_defect",
820
+ }));
821
+ const result = await writeAndSync(session, [historicalFail, livePass, ...verifiedTestsPrerequisites(session)]);
822
+
823
+ assert.equal(result.run.state.current_step, "merge-ready");
824
+ const attached = result.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate" && !entry.superseded_by);
825
+ assert.equal(attached.length, 1);
826
+ assert.deepEqual(attached[0].expectation_ids.sort(), ["acceptance-criteria", "clean-critique", "tests-evidence"]);
827
+ });
828
+
829
+ test("partial passing review snapshot waits for the complete verify expectation set", async () => {
830
+ const session = makeSession("atomic-review-sync");
831
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
832
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
833
+ await writeAndSync(session, [
834
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
835
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
836
+ ]);
837
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
838
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
839
+
840
+ const disputedCritique = verifiedTestsPrerequisites(session)[0];
841
+ disputedCritique.claim.value = "fail";
842
+ disputedCritique.claim.status = "disputed";
843
+ disputedCritique.event.status = "disputed";
844
+ const disputedPartial = await writeAndSync(session, [disputedCritique]);
845
+ assert.equal(disputedPartial.attached, false);
846
+ assert.equal(disputedPartial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
847
+
848
+ const partial = await writeAndSync(session, verifiedTestsPrerequisites(session).slice(0, 1));
849
+ assert.equal(partial.attached, false);
850
+ assert.equal(partial.run.state.current_step, "verify");
851
+ assert.equal(partial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
852
+
853
+ const complete = await writeAndSync(session, [
854
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }),
855
+ ...verifiedTestsPrerequisites(session),
856
+ ]);
857
+ assert.equal(complete.run.state.current_step, "merge-ready");
858
+ });
859
+
860
+ test("initial gate boundary rejects pre-run and far-future claims", async () => {
861
+ for (const [name, timestamp] of [
862
+ ["pre-run", NOW],
863
+ ["far-future", new Date(Date.now() + 60 * 60_000).toISOString()],
864
+ ]) {
865
+ const session = makeSession(`freshness-${name}`);
866
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
867
+ const result = await writeAndSync(session, [bundleClaim({
868
+ expectation: "selected-work",
869
+ claimType: "builder.pull-work.selected",
870
+ subjectType: "work-item",
871
+ timestamp,
872
+ })]);
873
+ assert.equal(result.attached, false, name);
874
+ assert.equal(result.run.state.current_step, "pull-work", name);
875
+ }
876
+ });
877
+
878
+ test("prior claim identity is rejected after re-entry and a new identity can recover", async () => {
879
+ const session = makeSession("same-digest-reentry");
880
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
881
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
882
+ await writeAndSync(session, [
883
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
884
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
885
+ ]);
886
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
887
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
888
+ const future = new Date(Date.now() + 10_000).toISOString();
889
+ const failure = [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", status: "fail", routeReason: "implementation_defect", timestamp: future }), ...verifiedTestsPrerequisites(session, future)];
890
+ const first = await writeAndSync(session, failure);
891
+ assert.equal(first.run.state.current_step, "execute");
892
+ await writeAndSync(session, [withIdentitySuffix(bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change", timestamp: future }), "reentry")]);
893
+ const replay = await writeAndSync(session, failure);
894
+ assert.equal(replay.attached, false);
895
+ assert.equal(replay.run.state.current_step, "verify");
896
+ assert.equal(replay.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
897
+ const newIdentity = failure.map((entry, index) => withIdentitySuffix(entry, `visit-2-${index}`));
898
+ const second = await writeAndSync(session, newIdentity);
899
+ assert.equal(second.run.state.current_step, "execute");
900
+ const verifyEvidence = second.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
901
+ assert.equal(verifyEvidence.length, 2);
902
+ assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
903
+ });
904
+
905
+ test("legacy colon-bearing assignment actor releases only through its normalized equivalent", () => {
906
+ const session = makeSession("legacy-actor-release");
907
+ const legacyActor = { runtime: "unknown", session_id: "legacy-session", host: "Kontour", human: null };
908
+ performLocalClaim(session.artifactRoot, session.slug, legacyActor, {
909
+ ttlSeconds: 1800,
910
+ actorKey: "unknown:legacy-session:Kontour",
911
+ branch: `agent/${session.slug}`,
912
+ artifactDir: session.sessionDir,
913
+ workItemRef: SUBJECT,
914
+ reason: "legacy fixture",
915
+ });
916
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
917
+ actorKey: "different-actor",
918
+ }), /refusing to release/);
919
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, { ...legacyActor, session_id: "different" }, {
920
+ actorKey: "unknownlegacy-sessionKontour",
921
+ }), /refusing to release/);
922
+ const released = performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
923
+ actorKey: "unknownlegacy-sessionKontour",
924
+ });
925
+ assert.equal(released.status, "released");
926
+ });
927
+
928
+ test("string-only legacy liveness identity cannot be released by a colliding modern actor", async () => {
929
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-legacy-liveness-"));
930
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
931
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
932
+ fs.writeFileSync(eventsFile, `${JSON.stringify({
933
+ type: "claim",
934
+ subjectId: "legacy-subject",
935
+ actor: "unknown:anc-123456789abc:Kontour",
936
+ at: NOW,
937
+ ttlSeconds: 1800,
938
+ })}\n`);
939
+
940
+ await assert.rejects(() => workflowSidecarMain([
941
+ "liveness", "release", "legacy-subject",
942
+ "--actor", "unknownanc-123456789abcKontour",
943
+ "--artifact-root", root,
944
+ "--at", "2026-07-09T20:01:00.000Z",
945
+ ]), /prior claim/);
946
+ const events = fs.readFileSync(eventsFile, "utf8").trim().split("\n").map(JSON.parse);
947
+ assert.equal(events.length, 1);
948
+ });
949
+
950
+ test("lossy modern liveness key collision cannot release a non-reversible legacy actor", async () => {
951
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lossy-liveness-"));
952
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
953
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
954
+ fs.writeFileSync(eventsFile, `${JSON.stringify({ type: "claim", subjectId: "collision", actor: "a:b", at: NOW, ttlSeconds: 1800 })}\n`);
955
+ await assert.rejects(() => workflowSidecarMain([
956
+ "liveness", "release", "collision", "--actor", "ab", "--artifact-root", root,
957
+ ]), /prior claim/);
958
+ assert.equal(fs.readFileSync(eventsFile, "utf8").trim().split("\n").length, 1);
959
+ });
960
+
961
+ test("upgrade rebuild preserves legacy critique identity until a versioned new review", async () => {
962
+ const legacy = {
963
+ id: "upgrade-review",
964
+ reviewer: "reviewer",
965
+ reviewed_at: "2026-07-01T00:00:00Z",
966
+ verdict: "pass",
967
+ summary: "Legacy clean review",
968
+ findings: [],
969
+ lanes: [{ id: "code", status: "pass" }],
970
+ review_target: { artifacts: [] },
971
+ artifact_refs: [],
972
+ };
973
+ const beforeUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-01T00:00:00Z", [], [], [legacy]);
974
+ const rebuiltAfterUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:00:00Z", [], [], [legacy]);
975
+ const newReview = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:01:00Z", [], [], [{
976
+ ...legacy,
977
+ reviewed_at: "2026-07-12T00:01:00Z",
978
+ identity_version: 2,
979
+ }]);
980
+ assert.equal(beforeUpgrade.claims[0].id, rebuiltAfterUpgrade.claims[0].id);
981
+ assert.notEqual(newReview.claims[0].id, beforeUpgrade.claims[0].id);
982
+ assert.equal(rebuiltAfterUpgrade.claims[0].metadata.identity_version, undefined);
983
+ assert.equal(newReview.claims[0].metadata.identity_version, 2);
984
+ });
985
+
986
+ test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
987
+ const session = makeSession("stale-critique-artifact");
988
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
989
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
990
+ await writeAndSync(session, [
991
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
992
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
993
+ ]);
994
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
995
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
996
+ const prerequisites = verifiedTestsPrerequisites(session);
997
+ fs.writeFileSync(path.join(session.projectRoot, "review-target", "delivery.md"), "changed after review\n");
998
+ await assert.rejects(
999
+ () => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
1000
+ /review_target\.artifacts\.sha256.*does not match/,
1001
+ );
1002
+ });
1003
+
1004
+ test("passing tests-evidence rejects a successful command that executed zero tests", async () => {
1005
+ const session = makeSession("zero-test-evidence");
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
+ await assert.rejects(
1015
+ () => writeAndSync(session, [
1016
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", testCount: 0 }),
1017
+ ...verifiedTestsPrerequisites(session),
1018
+ ]),
1019
+ /positive executed-test count/,
1020
+ );
1021
+ });
1022
+
1023
+ test("passing tests-evidence rejects a critique after implementation source changed", async () => {
1024
+ const session = makeSession("stale-critique-workspace");
1025
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
1026
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
1027
+ await writeAndSync(session, [
1028
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
1029
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
1030
+ ]);
1031
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
1032
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
1033
+ const prerequisites = verifiedTestsPrerequisites(session);
1034
+ fs.writeFileSync(path.join(session.projectRoot, "review-target", "implementation.txt"), "source changed after review\n");
1035
+ await assert.rejects(
1036
+ () => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
1037
+ /review_target\.workspace_snapshot\.digest.*does not match/,
1038
+ );
1039
+ });
1040
+
1041
+ test("workspace review fails closed when a Git worktree marker cannot be inspected", () => {
1042
+ const session = makeSession("git-inspection-unavailable");
1043
+ fs.mkdirSync(path.join(session.projectRoot, ".git"));
1044
+ assert.throws(
1045
+ () => captureReviewWorkspaceSnapshot(session.projectRoot, [{ file: "review-target/delivery.md", sha256: createHash("sha256").update("reviewed delivery\n").digest("hex") }]),
1046
+ /could not inspect the Git worktree/,
1047
+ );
647
1048
  });
648
1049
 
649
1050
  test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
@@ -651,25 +1052,30 @@ test("verified sidecar claims drive the composed publish and learning prefix to
651
1052
  claimSessionAssignment(session);
652
1053
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
653
1054
  const steps = [
654
- [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
655
- [
1055
+ () => [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
1056
+ () => [
656
1057
  bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
657
1058
  bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
658
1059
  ],
659
- [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
660
- [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
661
- [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" })],
662
- [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
1060
+ () => [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
1061
+ () => [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
1062
+ () => [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
1063
+ () => [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
663
1064
  ];
664
- for (const entries of steps) await writeAndSync(session, entries);
1065
+ for (const entries of steps) await writeAndSync(session, entries());
665
1066
 
666
1067
  const prOpen = readJson(path.join(session.sessionDir, "state.json"));
667
1068
  assert.equal(prOpen.flow_run.current_step, "pr-open");
1069
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "pr-open");
668
1070
  assert.deepEqual(prOpen.next_action.skills, []);
669
1071
  assert.deepEqual(prOpen.next_action.operations, ["publish-change"]);
670
1072
 
671
- await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
672
- await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1073
+ const mergeReadyCi = await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
1074
+ assert.equal(mergeReadyCi.run.state.current_step, "merge-ready-ci");
1075
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "merge-ready-ci");
1076
+ const learn = await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1077
+ assert.equal(learn.run.state.current_step, "learn");
1078
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "learn");
673
1079
  const completed = await writeAndSync(session, [
674
1080
  bundleClaim({ expectation: "decision-evidence", claimType: "builder.learn.decisions", subjectType: "decision" }),
675
1081
  bundleClaim({ expectation: "learning-evidence", claimType: "builder.learn.evidence", subjectType: "release" }),
@@ -679,6 +1085,18 @@ test("verified sidecar claims drive the composed publish and learning prefix to
679
1085
  assert.equal(completed.run.state.status, "completed");
680
1086
  assert.equal(completed.projection.status, "delivered");
681
1087
  assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
1088
+ const liveEvidence = completed.run.manifest.evidence.filter((entry) => !entry.superseded_by);
1089
+ assert.deepEqual(liveEvidence.map((entry) => entry.expectation_ids), [
1090
+ ["selected-work"],
1091
+ ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"],
1092
+ ["implementation-plan"],
1093
+ ["implementation-scope"],
1094
+ ["clean-critique", "acceptance-criteria", "tests-evidence"],
1095
+ ["merge-readiness"],
1096
+ ["pull-request-opened"],
1097
+ ["ci-merge-readiness"],
1098
+ ["decision-evidence", "learning-evidence"],
1099
+ ]);
682
1100
 
683
1101
  const flowDirectory = runDir(session.slug, session.projectRoot);
684
1102
  const beforeFlow = snapshotTree(flowDirectory);
@@ -6,11 +6,9 @@ import {
6
6
  recoverBuilderFlowSession,
7
7
  releaseBuilderFlowAssignment,
8
8
  resumeBuilderFlowSession,
9
- startBuilderFlowSession,
10
- syncBuilderFlowSession,
11
9
  } from "../builder-flow-runtime.js";
12
10
 
13
- const USAGE = "Usage: flow-agents builder-run <start|sync|recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
11
+ const USAGE = "Usage: flow-agents builder-run <recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
14
12
 
15
13
  export async function main(argv: string[]): Promise<number> {
16
14
  const parsed = parseArgs(argv);
@@ -29,7 +27,7 @@ export async function main(argv: string[]): Promise<number> {
29
27
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
30
28
  return 64;
31
29
  }
32
- if (!action || !["start", "sync", "recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
30
+ if (!action || !["recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
33
31
  console.error(USAGE);
34
32
  return 64;
35
33
  }
@@ -53,11 +51,7 @@ export async function main(argv: string[]): Promise<number> {
53
51
  console.error(USAGE);
54
52
  return 64;
55
53
  }
56
- const result = action === "start"
57
- ? await startBuilderFlowSession({ sessionDir })
58
- : action === "sync"
59
- ? await syncBuilderFlowSession({ sessionDir })
60
- : action === "recover"
54
+ const result = action === "recover"
61
55
  ? await recoverBuilderFlowSession({ sessionDir })
62
56
  : action === "pause"
63
57
  ? await pauseBuilderFlowSession({ sessionDir, reason: reason! })