@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.9.0](https://github.com/kontourai/flow-agents/compare/v3.8.0...v3.9.0) (2026-07-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **workflow:** add bounded continuation driver ([#560](https://github.com/kontourai/flow-agents/issues/560)) ([e6365ab](https://github.com/kontourai/flow-agents/commit/e6365aba324c76ee164681c987d20916c315444e))
9
+
10
+ ## [3.8.0](https://github.com/kontourai/flow-agents/compare/v3.7.0...v3.8.0) (2026-07-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **packaging:** install portable skills under .agents ([#551](https://github.com/kontourai/flow-agents/issues/551)) ([12f12e3](https://github.com/kontourai/flow-agents/commit/12f12e3d0a32b398e47a9ac01604179c58bf14ba))
16
+
17
+
18
+ ### Fixes
19
+
20
+ * **builder:** make gate evidence sync visit-safe ([#558](https://github.com/kontourai/flow-agents/issues/558)) ([1c0e8dc](https://github.com/kontourai/flow-agents/commit/1c0e8dcea6bdfd7d54484f950b2432fcf727b0ea))
21
+
3
22
  ## [3.7.0](https://github.com/kontourai/flow-agents/compare/v3.6.0...v3.7.0) (2026-07-12)
4
23
 
5
24
 
@@ -277,9 +277,13 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
277
277
  const snapshot = stageTrustBundleSnapshot(context);
278
278
  try {
279
279
  const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
280
- const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state.subject, context.projectRoot);
280
+ const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state, run.state.subject, context.projectRoot, manifestEvidence(run.manifest));
281
281
  if (gateEvidence) {
282
- const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === snapshot.sha256);
282
+ const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id
283
+ && entry.sha256 === snapshot.sha256
284
+ && typeof entry.superseded_by !== "string"
285
+ && timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
286
+ && gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId)));
283
287
  if (!alreadyAttached) {
284
288
  const supersede = manifestEvidence(run.manifest)
285
289
  .filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
@@ -294,6 +298,7 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
294
298
  ...(supersede.length > 0 ? { supersede } : {}),
295
299
  ...(gateEvidence.failed ? { status: "failed" } : {}),
296
300
  ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
301
+ expectationIds: gateEvidence.expectationIds,
297
302
  },
298
303
  });
299
304
  attached = true;
@@ -377,15 +382,67 @@ function persistedFlowId(state) {
377
382
  function openGatesForResult(run) {
378
383
  return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
379
384
  }
380
- async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
385
+ async function bundleGateEvidence(bundle, gate, state, subject, projectRoot, manifest) {
381
386
  if (!isRecord(bundle) || !Array.isArray(bundle.claims))
382
387
  return null;
383
- const selectors = expectationsForGate(gate).map((expectation) => expectation.bundle_claim);
388
+ const expectations = expectationsForGate(gate);
389
+ const visit = currentGateVisit(state, String(gate.step));
390
+ const enteredAt = visit.enteredAt;
391
+ const synchronizedAt = Date.now();
392
+ const maxClockSkewMs = 30_000;
393
+ const priorVisitClaimIds = new Set();
394
+ const priorVisitEvidenceIds = new Set();
395
+ for (const entry of manifest) {
396
+ if (entry.gate_id !== String(gate.id))
397
+ continue;
398
+ const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
399
+ for (const historical of claims) {
400
+ if (isRecord(historical) && typeof historical.id === "string")
401
+ priorVisitClaimIds.add(historical.id);
402
+ }
403
+ const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
404
+ for (const historical of evidence) {
405
+ if (isRecord(historical) && typeof historical.id === "string")
406
+ priorVisitEvidenceIds.add(historical.id);
407
+ }
408
+ }
409
+ const claimIsCurrent = (claim) => {
410
+ if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id))
411
+ return false;
412
+ const timestamps = [];
413
+ const createdAt = parseTimestamp(claim.createdAt);
414
+ if (createdAt !== null)
415
+ timestamps.push(createdAt);
416
+ if (Array.isArray(bundle.evidence))
417
+ for (const evidence of bundle.evidence) {
418
+ if (!isRecord(evidence) || evidence.claimId !== claim.id)
419
+ continue;
420
+ if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id))
421
+ return false;
422
+ const observedAt = parseTimestamp(evidence.observedAt);
423
+ if (observedAt !== null)
424
+ timestamps.push(observedAt);
425
+ }
426
+ const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
427
+ return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
428
+ && timestamp <= synchronizedAt + maxClockSkewMs);
429
+ };
384
430
  const relevant = bundle.claims.filter((claim) => {
385
431
  if (!isRecord(claim))
386
432
  return false;
387
- return selectors.some((candidate) => candidate.claimType === claim.claimType
388
- && (!candidate.subjectType || candidate.subjectType === claim.subjectType));
433
+ if (claim.producerStatus === "superseded")
434
+ return false;
435
+ const metadata = isRecord(claim.metadata) ? claim.metadata : null;
436
+ if (metadata && typeof metadata.superseded_by === "string")
437
+ return false;
438
+ return expectations.some((expectation) => {
439
+ const candidate = expectation.bundle_claim;
440
+ return candidate
441
+ && claimIsCurrent(claim)
442
+ &&
443
+ candidate.claimType === claim.claimType
444
+ && (!candidate.subjectType || candidate.subjectType === claim.subjectType);
445
+ });
389
446
  });
390
447
  if (relevant.length === 0)
391
448
  return null;
@@ -393,6 +450,11 @@ async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
393
450
  throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
394
451
  }
395
452
  const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
453
+ const expectationIds = expectations.filter((expectation) => relevant.some((claim) => {
454
+ const selector = expectation.bundle_claim;
455
+ return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
456
+ })).map((expectation) => expectation.id);
457
+ const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
396
458
  const routeReasons = [...new Set(relevant.flatMap((claim) => {
397
459
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
398
460
  const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
@@ -402,6 +464,13 @@ async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
402
464
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
403
465
  }
404
466
  const routeReason = routeReasons[0] ?? null;
467
+ if (failed && !routeReason)
468
+ return null;
469
+ // Passing evidence waits for the complete expectation set. A failing
470
+ // snapshot is complete only when a gate producer explicitly declares its
471
+ // route reason; report-only disputed critique state remains pending.
472
+ if (!failed && missingRequired.length > 0)
473
+ return null;
405
474
  if (routeReason && !failed) {
406
475
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
407
476
  }
@@ -412,7 +481,33 @@ async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
412
481
  if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
413
482
  await assertVerifiedTestsTrust(bundle.claims, projectRoot);
414
483
  }
415
- return { failed, routeReason };
484
+ return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
485
+ }
486
+ function currentGateVisit(state, step) {
487
+ let enteredAt = null;
488
+ for (const transition of state.transitions ?? []) {
489
+ if (transition.to_step !== step)
490
+ continue;
491
+ const parsed = parseTimestamp(transition.at);
492
+ if (parsed !== null)
493
+ enteredAt = parsed;
494
+ }
495
+ const initial = parseTimestamp(state.updated_at);
496
+ if (enteredAt !== null)
497
+ return { enteredAt, initial: false };
498
+ if (initial !== null)
499
+ return { enteredAt: initial, initial: true };
500
+ throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
501
+ }
502
+ function parseTimestamp(value) {
503
+ if (typeof value !== "string")
504
+ return null;
505
+ const parsed = Date.parse(value);
506
+ return Number.isFinite(parsed) ? parsed : null;
507
+ }
508
+ function timestampAtOrAfter(value, boundary) {
509
+ const parsed = parseTimestamp(value);
510
+ return parsed !== null && parsed >= boundary;
416
511
  }
417
512
  async function assertVerifiedTestsTrust(claims, projectRoot) {
418
513
  const testClaims = claims.filter((claim) => isRecord(claim)
@@ -665,7 +760,9 @@ function projectFlowRun(context, run, sidecar) {
665
760
  const complete = run.state.status === "completed";
666
761
  const paused = run.state.status === "paused";
667
762
  const canceled = run.state.status === "canceled";
668
- const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
763
+ const needsDecision = run.state.status === "needs_decision";
764
+ const failed = run.state.status === "failed";
765
+ const action = complete || paused || canceled || needsDecision || failed ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
669
766
  if (!action) {
670
767
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
671
768
  }
@@ -690,18 +787,22 @@ function projectFlowRun(context, run, sidecar) {
690
787
  ? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
691
788
  : paused
692
789
  ? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
693
- : {
694
- status: "continue",
695
- summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
696
- skills,
697
- operations,
698
- command: syncCommand,
699
- };
790
+ : needsDecision
791
+ ? { status: "blocked", summary: "Canonical Flow requires an external decision before continuation." }
792
+ : failed
793
+ ? { status: "failed", summary: "Canonical Flow run failed; no continuation turn is allowed." }
794
+ : {
795
+ status: "continue",
796
+ summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
797
+ skills,
798
+ operations,
799
+ command: syncCommand,
800
+ };
700
801
  const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
701
802
  return {
702
803
  ...sidecar,
703
- status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
704
- phase: complete || canceled ? "done" : phase,
804
+ status: complete ? "delivered" : canceled ? "canceled" : failed ? "failed" : (paused || needsDecision) ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
805
+ phase: complete || canceled || failed ? "done" : phase,
705
806
  updated_at: run.state.updated_at,
706
807
  flow_run: {
707
808
  run_id: run.runId,
@@ -626,7 +626,19 @@ export function performLocalReleaseUnderLock(artifactRoot, subjectId, releasedBy
626
626
  // seam, relocated to this write path).
627
627
  const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
628
628
  const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
629
- if (holderActorKey !== releasedByActorKey) {
629
+ // Pre-3.7 lifecycle events could persist the derived ancestry actor before
630
+ // sanitizeSegment removed ':' separators. Modern explicit/env release paths
631
+ // always use the sanitized form. Accept only that one-way legacy migration;
632
+ // never normalize two modern keys or relax ownership to a prefix match.
633
+ const sameActorStruct = existing.actor.runtime === releasedBy.runtime
634
+ && existing.actor.session_id === releasedBy.session_id
635
+ && existing.actor.host === releasedBy.host
636
+ && (existing.actor.human ?? null) === (releasedBy.human ?? null);
637
+ const legacyActorKeyMatches = holderActorKey.includes(":")
638
+ && holderActorKey === helper.serializeActor(existing.actor)
639
+ && helper.sanitizeSegment(holderActorKey) === releasedByActorKey
640
+ && sameActorStruct;
641
+ if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
630
642
  if (tolerateNoActiveClaim)
631
643
  return null;
632
644
  throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
@@ -0,0 +1,24 @@
1
+ import type { ContinuationBarrier, ContinuationTurnRequest, ContinuationTurnResult } from "../continuation-driver.js";
2
+ export type ContinuationAdapterCommand = {
3
+ argv: string[];
4
+ identity: string;
5
+ integrity: Array<{
6
+ file: string;
7
+ sha256: string;
8
+ }>;
9
+ };
10
+ export declare function loadContinuationAdapterCommand(commandFileInput: string): ContinuationAdapterCommand;
11
+ export declare function executeContinuationAdapter(commandFileInput: string, request: ContinuationTurnRequest, options: {
12
+ cwd: string;
13
+ timeoutMs: number;
14
+ }): Promise<ContinuationTurnResult>;
15
+ export declare function executeLoadedContinuationAdapter(command: ContinuationAdapterCommand, request: ContinuationTurnRequest, options: {
16
+ cwd: string;
17
+ timeoutMs: number;
18
+ }): Promise<ContinuationTurnResult>;
19
+ export declare function waitForContinuationBarrier(barrier: ContinuationBarrier, options: {
20
+ maxWaitMs: number;
21
+ pollMs: number;
22
+ now?: () => number;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ }): Promise<"ready" | "pending">;
@@ -0,0 +1,225 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ export function loadContinuationAdapterCommand(commandFileInput) {
6
+ const commandFile = path.resolve(commandFileInput);
7
+ const command = validateAdapterCommand(JSON.parse(readRegularFileNoFollow(commandFile, "continuation adapter command file")));
8
+ if (!path.isAbsolute(command.argv[0]))
9
+ throw new Error("continuation adapter executable must be an absolute path");
10
+ const integrity = [...new Set(command.argv.filter((entry) => path.isAbsolute(entry) && regularFileExists(entry)))]
11
+ .map((file) => ({ file, sha256: sha256File(file, "continuation adapter integrity file") }));
12
+ if (!integrity.some((entry) => entry.file === command.argv[0]))
13
+ throw new Error("continuation adapter executable must be a regular file");
14
+ const identity = createHash("sha256").update(JSON.stringify({ ...command, integrity })).digest("hex");
15
+ return { ...command, identity, integrity };
16
+ }
17
+ export async function executeContinuationAdapter(commandFileInput, request, options) {
18
+ const command = loadContinuationAdapterCommand(commandFileInput);
19
+ return executeLoadedContinuationAdapter(command, request, options);
20
+ }
21
+ export async function executeLoadedContinuationAdapter(command, request, options) {
22
+ for (const entry of command.integrity) {
23
+ if (sha256File(entry.file, "continuation adapter integrity file") !== entry.sha256) {
24
+ throw new Error(`continuation adapter integrity changed after mission binding: ${entry.file}`);
25
+ }
26
+ }
27
+ assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
28
+ return await spawnAdapter(command, request, options);
29
+ }
30
+ export async function waitForContinuationBarrier(barrier, options) {
31
+ assertPositiveInteger(options.maxWaitMs, "continuation barrier maxWaitMs", 0, 86_400_000);
32
+ assertPositiveInteger(options.pollMs, "continuation barrier pollMs", 1, 60_000);
33
+ const now = options.now ?? Date.now;
34
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
35
+ const stopAt = now() + options.maxWaitMs;
36
+ if (barrier.kind === "deadline") {
37
+ const deadline = Date.parse(barrier.at);
38
+ if (deadline <= now())
39
+ return "ready";
40
+ const waitMs = Math.min(deadline - now(), Math.max(0, stopAt - now()));
41
+ if (waitMs > 0)
42
+ await sleep(waitMs);
43
+ return Date.parse(barrier.at) <= now() ? "ready" : "pending";
44
+ }
45
+ while (pidAlive(barrier.pid)) {
46
+ const remaining = stopAt - now();
47
+ if (remaining <= 0)
48
+ return "pending";
49
+ await sleep(Math.min(options.pollMs, remaining));
50
+ }
51
+ return "ready";
52
+ }
53
+ function spawnAdapter(command, request, options) {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(command.argv[0], command.argv.slice(1), {
56
+ cwd: options.cwd,
57
+ env: process.env,
58
+ detached: process.platform !== "win32",
59
+ shell: false,
60
+ stdio: ["pipe", "pipe", "pipe"],
61
+ });
62
+ const stdout = [];
63
+ const stderr = [];
64
+ let stdoutBytes = 0;
65
+ let stderrBytes = 0;
66
+ let settled = false;
67
+ let forcedKill;
68
+ let terminationError;
69
+ const maxBytes = 4 * 1024 * 1024;
70
+ const timeout = setTimeout(() => {
71
+ if (settled)
72
+ return;
73
+ terminationError = new Error(`continuation adapter timed out after ${options.timeoutMs}ms`);
74
+ terminateProcessGroup(child.pid, "SIGTERM");
75
+ forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
76
+ }, options.timeoutMs);
77
+ child.stdout.on("data", (chunk) => {
78
+ stdoutBytes += chunk.length;
79
+ if (stdoutBytes > maxBytes) {
80
+ if (!terminationError) {
81
+ terminationError = new Error("continuation adapter stdout exceeded 4 MiB");
82
+ clearTimeout(timeout);
83
+ terminateProcessGroup(child.pid, "SIGTERM");
84
+ forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
85
+ }
86
+ return;
87
+ }
88
+ stdout.push(chunk);
89
+ });
90
+ child.stderr.on("data", (chunk) => {
91
+ stderrBytes += chunk.length;
92
+ if (stderrBytes <= maxBytes)
93
+ stderr.push(chunk);
94
+ });
95
+ child.on("error", (error) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timeout);
100
+ if (forcedKill)
101
+ clearTimeout(forcedKill);
102
+ reject(error);
103
+ });
104
+ child.on("close", (code, signal) => {
105
+ if (settled)
106
+ return;
107
+ settled = true;
108
+ clearTimeout(timeout);
109
+ if (forcedKill)
110
+ clearTimeout(forcedKill);
111
+ if (terminationError) {
112
+ reject(terminationError);
113
+ return;
114
+ }
115
+ const stderrText = Buffer.concat(stderr).toString("utf8").trim();
116
+ if (code !== 0) {
117
+ scheduleProcessGroupTermination(child.pid);
118
+ reject(new Error(`continuation adapter exited ${code ?? signal ?? "unknown"}${stderrText ? `: ${stderrText}` : ""}`));
119
+ return;
120
+ }
121
+ const output = Buffer.concat(stdout).toString("utf8").trim();
122
+ try {
123
+ const result = JSON.parse(output);
124
+ if (!isValidWaitResult(result))
125
+ scheduleProcessGroupTermination(child.pid);
126
+ resolve(result);
127
+ }
128
+ catch {
129
+ scheduleProcessGroupTermination(child.pid);
130
+ reject(new Error("continuation adapter must emit exactly one JSON result on stdout"));
131
+ }
132
+ });
133
+ child.stdin.on("error", (error) => {
134
+ if (error.code !== "EPIPE" && !settled) {
135
+ settled = true;
136
+ clearTimeout(timeout);
137
+ if (forcedKill)
138
+ clearTimeout(forcedKill);
139
+ scheduleProcessGroupTermination(child.pid);
140
+ reject(error);
141
+ }
142
+ });
143
+ child.stdin.end(`${JSON.stringify(request)}\n`);
144
+ });
145
+ }
146
+ function validateAdapterCommand(value) {
147
+ if (!value || typeof value !== "object" || Array.isArray(value))
148
+ throw new Error("continuation adapter command file must contain an object");
149
+ const argv = value.argv;
150
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > 128 || argv.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.includes("\0"))) {
151
+ throw new Error("continuation adapter command argv must contain 1 through 128 non-empty strings");
152
+ }
153
+ return { argv: [...argv] };
154
+ }
155
+ function assertRegularFile(file, label) {
156
+ const stat = fs.lstatSync(file);
157
+ if (stat.isSymbolicLink() || !stat.isFile())
158
+ throw new Error(`${label} must be a regular file`);
159
+ }
160
+ function readRegularFileNoFollow(file, label) {
161
+ return readRegularFileBufferNoFollow(file, label).toString("utf8");
162
+ }
163
+ function readRegularFileBufferNoFollow(file, label) {
164
+ assertRegularFile(file, label);
165
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
166
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
167
+ try {
168
+ if (!fs.fstatSync(fd).isFile())
169
+ throw new Error(`${label} must be a regular file`);
170
+ return fs.readFileSync(fd);
171
+ }
172
+ finally {
173
+ fs.closeSync(fd);
174
+ }
175
+ }
176
+ function assertPositiveInteger(value, label, min, max) {
177
+ if (!Number.isSafeInteger(value) || value < min || value > max)
178
+ throw new Error(`${label} must be an integer from ${min} through ${max}`);
179
+ }
180
+ function pidAlive(pid) {
181
+ try {
182
+ process.kill(pid, 0);
183
+ return true;
184
+ }
185
+ catch (error) {
186
+ return error.code === "EPERM";
187
+ }
188
+ }
189
+ function terminateProcessGroup(pid, signal) {
190
+ if (!pid)
191
+ return;
192
+ try {
193
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
194
+ }
195
+ catch (error) {
196
+ if (error.code !== "ESRCH")
197
+ throw error;
198
+ }
199
+ }
200
+ function isValidWaitResult(result) {
201
+ if (result?.status !== "wait" || !result.barrier || typeof result.barrier !== "object")
202
+ return false;
203
+ if (result.barrier.kind === "pid")
204
+ return Number.isSafeInteger(result.barrier.pid) && result.barrier.pid > 0;
205
+ return result.barrier.kind === "deadline" && typeof result.barrier.at === "string" && Number.isFinite(Date.parse(result.barrier.at));
206
+ }
207
+ function scheduleProcessGroupTermination(pid) {
208
+ terminateProcessGroup(pid, "SIGTERM");
209
+ const forcedKill = setTimeout(() => terminateProcessGroup(pid, "SIGKILL"), 250);
210
+ forcedKill.unref();
211
+ }
212
+ function regularFileExists(file) {
213
+ try {
214
+ const stat = fs.lstatSync(file);
215
+ return !stat.isSymbolicLink() && stat.isFile();
216
+ }
217
+ catch (error) {
218
+ if (error.code === "ENOENT")
219
+ return false;
220
+ throw error;
221
+ }
222
+ }
223
+ function sha256File(file, label) {
224
+ return createHash("sha256").update(readRegularFileBufferNoFollow(file, label)).digest("hex");
225
+ }
@@ -880,7 +880,9 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
880
880
  continue;
881
881
  const subjectId = `${slug}/${check.id}`;
882
882
  const fieldOrBehavior = String(check.summary ?? check.id);
883
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
883
+ const gateClaimIdentityVersion = check._gate_claim_identity_version === 2 ? 2 : 1;
884
+ const gateClaimRecordedAt = gateClaimIdentityVersion === 2 && typeof check._gate_claim_recorded_at === "string" ? check._gate_claim_recorded_at : null;
885
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", gateClaimRecordedAt ? `${fieldOrBehavior}::gate-visit::${gateClaimRecordedAt}` : fieldOrBehavior);
884
886
  const evId = `ev:${claimId}`;
885
887
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
886
888
  const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
@@ -1034,7 +1036,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1034
1036
  // restored stamp) takes the currently-active step's id.
1035
1037
  const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
1036
1038
  const declaredMetadata = gateClaimExpectationId
1037
- ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1039
+ ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(gateClaimRecordedAt ? { recorded_at: gateClaimRecordedAt } : {}), ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1038
1040
  : claimMetadata;
1039
1041
  const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(declaredMetadata ? { metadata: declaredMetadata } : {}) };
1040
1042
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
@@ -1053,7 +1055,9 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1053
1055
  continue;
1054
1056
  const subjectId = `${slug}/${criterion.id}`;
1055
1057
  const fieldOrBehavior = String(criterion.description ?? criterion.id);
1056
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
1058
+ const criterionIdentityVersion = criterion.identity_version === 2 ? 2 : 1;
1059
+ const criterionVerifiedAt = criterionIdentityVersion === 2 && typeof criterion.verified_at === "string" ? criterion.verified_at : null;
1060
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", criterionVerifiedAt ? `${fieldOrBehavior}::verified::${criterionVerifiedAt}` : fieldOrBehavior);
1057
1061
  const legacyClaimType = "workflow.acceptance.criterion";
1058
1062
  const policy = ensurePolicy(legacyClaimType, "high", []);
1059
1063
  const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
@@ -1068,7 +1072,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1068
1072
  if (declared) {
1069
1073
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1070
1074
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
1071
- const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1075
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [], ...(criterionIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(criterionVerifiedAt ? { verified_at: criterionVerifiedAt } : {}) }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1072
1076
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
1073
1077
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1074
1078
  }
@@ -1092,10 +1096,12 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1092
1096
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1093
1097
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1094
1098
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
1099
+ const critiqueIdentityVersion = c.identity_version === 2 ? 2 : 1;
1095
1100
  const critMeta = {
1096
1101
  origin: "critique",
1097
1102
  reviewer: critiqueReviewer,
1098
1103
  reviewed_at: critiqueReviewedAt,
1104
+ ...(critiqueIdentityVersion === 2 ? { identity_version: 2 } : {}),
1099
1105
  findings: Array.isArray(c.findings) ? c.findings : [],
1100
1106
  lanes: Array.isArray(c.lanes) ? c.lanes : [],
1101
1107
  review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
@@ -1107,7 +1113,9 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1107
1113
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1108
1114
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1109
1115
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
1110
- const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
1116
+ const claimIdSalt = critiqueIdentityVersion === 2
1117
+ ? `${fieldOrBehavior}::reviewed::${critiqueReviewedAt}${supersededBy ? `::superseded::${supersededBy}` : ""}`
1118
+ : (supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior);
1111
1119
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
1112
1120
  const legacyClaimType = "workflow.critique.review";
1113
1121
  const policy = ensurePolicy(legacyClaimType, "medium", []);
@@ -2770,7 +2778,7 @@ function requireObservedCommandRefs(refs, observedCommands, label, requireAll =
2770
2778
  die(`${label} requires a top-level command evidence ref for every successful observed command`);
2771
2779
  }
2772
2780
  }
2773
- function completePassingCriteria(existing, raw, observedCommands) {
2781
+ function completePassingCriteria(existing, raw, observedCommands, verifiedAt) {
2774
2782
  if (raw.length === 0)
2775
2783
  die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2776
2784
  const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
@@ -2794,7 +2802,7 @@ function completePassingCriteria(existing, raw, observedCommands) {
2794
2802
  if (refs.length === 0)
2795
2803
  die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2796
2804
  requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2797
- return { ...expectedById.get(ids[index]), status: "pass", evidence_refs: refs };
2805
+ return { ...expectedById.get(ids[index]), status: "pass", evidence_refs: refs, identity_version: 2, verified_at: verifiedAt };
2798
2806
  });
2799
2807
  }
2800
2808
  const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
@@ -3234,6 +3242,10 @@ function checksFromBundle(dir) {
3234
3242
  check._gate_claim_declared_subject = gc.subject_type;
3235
3243
  if (typeof gc.step_id === "string")
3236
3244
  check._gate_claim_declared_step_id = gc.step_id;
3245
+ if (typeof gc.recorded_at === "string")
3246
+ check._gate_claim_recorded_at = gc.recorded_at;
3247
+ if (gc.identity_version === 2)
3248
+ check._gate_claim_identity_version = 2;
3237
3249
  if (typeof gc.route_reason === "string")
3238
3250
  check._gate_claim_route_reason = gc.route_reason;
3239
3251
  };
@@ -3423,6 +3435,7 @@ function critiquesFromBundle(dir) {
3423
3435
  review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
3424
3436
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
3425
3437
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
3438
+ ...(md.identity_version === 2 ? { identity_version: 2 } : {}),
3426
3439
  artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
3427
3440
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
3428
3441
  };
@@ -3444,6 +3457,8 @@ function criteriaFromBundle(dir) {
3444
3457
  description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3445
3458
  status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3446
3459
  evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3460
+ ...(typeof saved.verified_at === "string" ? { verified_at: saved.verified_at } : {}),
3461
+ ...(saved.identity_version === 2 ? { identity_version: 2 } : {}),
3447
3462
  };
3448
3463
  })
3449
3464
  .filter((criterion) => typeof criterion.id === "string" && criterion.id.length > 0);
@@ -3503,7 +3518,7 @@ async function recordEvidence(p) {
3503
3518
  die("record-evidence requires at least one --check-json or --surface-trust-json");
3504
3519
  validateAcceptanceEvidenceRefs(dir, p);
3505
3520
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
3506
- const ts = opt(p, "timestamp", now());
3521
+ const ts = opt(p, "timestamp", new Date().toISOString());
3507
3522
  // #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
3508
3523
  // record-evidence previously never called checksFromBundle(dir), so it dropped every check
3509
3524
  // recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
@@ -3710,7 +3725,7 @@ function diagnostic(dir, code, summary) {
3710
3725
  async function recordGateClaim(p) {
3711
3726
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3712
3727
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3713
- const ts = opt(p, "timestamp", now());
3728
+ const ts = opt(p, "timestamp", new Date().toISOString());
3714
3729
  const statusVal = opt(p, "status");
3715
3730
  if (!["pass", "fail", "not_verified"].includes(statusVal))
3716
3731
  die("--status must be one of: pass, fail, not_verified");
@@ -3795,6 +3810,8 @@ async function recordGateClaim(p) {
3795
3810
  status: statusVal,
3796
3811
  summary,
3797
3812
  _gate_claim_expectation_id: targetExpectation.id,
3813
+ _gate_claim_identity_version: 2,
3814
+ _gate_claim_recorded_at: ts,
3798
3815
  ...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
3799
3816
  };
3800
3817
  // Include structured evidence refs if provided
@@ -3836,7 +3853,7 @@ async function recordGateClaim(p) {
3836
3853
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3837
3854
  // gate claim against a different expectation is additive.
3838
3855
  const _existingState = readBundleState(dir);
3839
- const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
3856
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames, ts) : _existingState.criteria;
3840
3857
  if (mustRunTests) {
3841
3858
  const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3842
3859
  if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
@@ -4062,7 +4079,8 @@ async function recordCritique(p) {
4062
4079
  const critique = {
4063
4080
  id: critiqueId,
4064
4081
  reviewer: opt(p, "reviewer", "tool-code-reviewer"),
4065
- reviewed_at: opt(p, "timestamp", now()),
4082
+ reviewed_at: opt(p, "timestamp", new Date().toISOString()),
4083
+ identity_version: 2,
4066
4084
  verdict,
4067
4085
  summary,
4068
4086
  lanes,