@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
@@ -915,7 +915,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
915
915
  if (!check.id) continue;
916
916
  const subjectId = `${slug}/${check.id}`;
917
917
  const fieldOrBehavior = String(check.summary ?? check.id);
918
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
918
+ const gateClaimIdentityVersion = check._gate_claim_identity_version === 2 ? 2 : 1;
919
+ const gateClaimRecordedAt = gateClaimIdentityVersion === 2 && typeof check._gate_claim_recorded_at === "string" ? check._gate_claim_recorded_at : null;
920
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", gateClaimRecordedAt ? `${fieldOrBehavior}::gate-visit::${gateClaimRecordedAt}` : fieldOrBehavior);
919
921
  const evId = `ev:${claimId}`;
920
922
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
921
923
 
@@ -1071,7 +1073,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1071
1073
  // restored stamp) takes the currently-active step's id.
1072
1074
  const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
1073
1075
  const declaredMetadata: AnyObj = gateClaimExpectationId
1074
- ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1076
+ ? { ...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 } : {}) } }
1075
1077
  : claimMetadata;
1076
1078
  const declaredClaimObj: AnyObj = { 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 } : {}) };
1077
1079
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
@@ -1089,7 +1091,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1089
1091
  if (!criterion.id) continue;
1090
1092
  const subjectId = `${slug}/${criterion.id}`;
1091
1093
  const fieldOrBehavior = String(criterion.description ?? criterion.id);
1092
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
1094
+ const criterionIdentityVersion = criterion.identity_version === 2 ? 2 : 1;
1095
+ const criterionVerifiedAt = criterionIdentityVersion === 2 && typeof criterion.verified_at === "string" ? criterion.verified_at : null;
1096
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", criterionVerifiedAt ? `${fieldOrBehavior}::verified::${criterionVerifiedAt}` : fieldOrBehavior);
1093
1097
  const legacyClaimType = "workflow.acceptance.criterion";
1094
1098
  const policy = ensurePolicy(legacyClaimType, "high", []);
1095
1099
  const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
@@ -1105,7 +1109,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1105
1109
  if (declared) {
1106
1110
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1107
1111
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
1108
- const declaredClaimObj: AnyObj = { 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 } : {}) } };
1112
+ const declaredClaimObj: AnyObj = { 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 } : {}) } };
1109
1113
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
1110
1114
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1111
1115
  } else {
@@ -1128,10 +1132,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1128
1132
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1129
1133
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1130
1134
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
1135
+ const critiqueIdentityVersion = c.identity_version === 2 ? 2 : 1;
1131
1136
  const critMeta: AnyObj = {
1132
1137
  origin: "critique",
1133
1138
  reviewer: critiqueReviewer,
1134
1139
  reviewed_at: critiqueReviewedAt,
1140
+ ...(critiqueIdentityVersion === 2 ? { identity_version: 2 } : {}),
1135
1141
  findings: Array.isArray(c.findings) ? c.findings : [],
1136
1142
  lanes: Array.isArray(c.lanes) ? c.lanes : [],
1137
1143
  review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
@@ -1143,7 +1149,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1143
1149
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1144
1150
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1145
1151
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
1146
- const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
1152
+ const claimIdSalt = critiqueIdentityVersion === 2
1153
+ ? `${fieldOrBehavior}::reviewed::${critiqueReviewedAt}${supersededBy ? `::superseded::${supersededBy}` : ""}`
1154
+ : (supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior);
1147
1155
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
1148
1156
  const legacyClaimType = "workflow.critique.review";
1149
1157
  const policy = ensurePolicy(legacyClaimType, "medium", []);
@@ -2727,7 +2735,7 @@ function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySe
2727
2735
  }
2728
2736
  }
2729
2737
 
2730
- function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>): AnyObj[] {
2738
+ function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>, verifiedAt: string): AnyObj[] {
2731
2739
  if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2732
2740
  const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
2733
2741
  const expectedById = new Map<string, AnyObj>();
@@ -2746,7 +2754,7 @@ function completePassingCriteria(existing: AnyObj[], raw: string[], observedComm
2746
2754
  const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
2747
2755
  if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2748
2756
  requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2749
- return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs };
2757
+ return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs, identity_version: 2, verified_at: verifiedAt };
2750
2758
  });
2751
2759
  }
2752
2760
 
@@ -3133,6 +3141,8 @@ function checksFromBundle(dir: string): AnyObj[] {
3133
3141
  if (typeof gc.claim_type === "string") check._gate_claim_declared_type = gc.claim_type;
3134
3142
  if (typeof gc.subject_type === "string") check._gate_claim_declared_subject = gc.subject_type;
3135
3143
  if (typeof gc.step_id === "string") check._gate_claim_declared_step_id = gc.step_id;
3144
+ if (typeof gc.recorded_at === "string") check._gate_claim_recorded_at = gc.recorded_at;
3145
+ if (gc.identity_version === 2) check._gate_claim_identity_version = 2;
3136
3146
  if (typeof gc.route_reason === "string") check._gate_claim_route_reason = gc.route_reason;
3137
3147
  };
3138
3148
  // #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
@@ -3294,6 +3304,7 @@ function critiquesFromBundle(dir: string): AnyObj[] {
3294
3304
  review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
3295
3305
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
3296
3306
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
3307
+ ...(md.identity_version === 2 ? { identity_version: 2 } : {}),
3297
3308
  artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
3298
3309
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
3299
3310
  };
@@ -3314,6 +3325,8 @@ function criteriaFromBundle(dir: string): AnyObj[] {
3314
3325
  description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3315
3326
  status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3316
3327
  evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3328
+ ...(typeof saved.verified_at === "string" ? { verified_at: saved.verified_at } : {}),
3329
+ ...(saved.identity_version === 2 ? { identity_version: 2 } : {}),
3317
3330
  };
3318
3331
  })
3319
3332
  .filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
@@ -3369,7 +3382,7 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
3369
3382
  if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
3370
3383
  validateAcceptanceEvidenceRefs(dir, p);
3371
3384
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
3372
- const ts = opt(p, "timestamp", now());
3385
+ const ts = opt(p, "timestamp", new Date().toISOString());
3373
3386
  // #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
3374
3387
  // record-evidence previously never called checksFromBundle(dir), so it dropped every check
3375
3388
  // recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
@@ -3576,7 +3589,7 @@ function diagnostic(dir: string, code: string, summary: string): never {
3576
3589
  async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number> {
3577
3590
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3578
3591
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3579
- const ts = opt(p, "timestamp", now());
3592
+ const ts = opt(p, "timestamp", new Date().toISOString());
3580
3593
  const statusVal = opt(p, "status");
3581
3594
  if (!["pass", "fail", "not_verified"].includes(statusVal)) die("--status must be one of: pass, fail, not_verified");
3582
3595
  const summary = opt(p, "summary") || die("--summary is required");
@@ -3652,6 +3665,8 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3652
3665
  status: statusVal,
3653
3666
  summary,
3654
3667
  _gate_claim_expectation_id: targetExpectation.id,
3668
+ _gate_claim_identity_version: 2,
3669
+ _gate_claim_recorded_at: ts,
3655
3670
  ...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
3656
3671
  };
3657
3672
 
@@ -3690,7 +3705,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3690
3705
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3691
3706
  // gate claim against a different expectation is additive.
3692
3707
  const _existingState = readBundleState(dir);
3693
- const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
3708
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames, ts) : _existingState.criteria;
3694
3709
  if (mustRunTests) {
3695
3710
  const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3696
3711
  if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
@@ -3907,7 +3922,8 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3907
3922
  const critique = {
3908
3923
  id: critiqueId,
3909
3924
  reviewer: opt(p, "reviewer", "tool-code-reviewer"),
3910
- reviewed_at: opt(p, "timestamp", now()),
3925
+ reviewed_at: opt(p, "timestamp", new Date().toISOString()),
3926
+ identity_version: 2,
3911
3927
  verdict,
3912
3928
  summary,
3913
3929
  lanes,
@@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { validateDefinition } from "@kontourai/flow";
8
8
  import { loadBuilderFlowRun } from "../builder-flow-run-adapter.js";
9
+ import { driveBuilderFlowSession, withContinuationDriverLock } from "../continuation-driver.js";
9
10
  import { inspectBuilderFlowSession, recoverBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
10
11
  import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
11
12
  import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
@@ -14,6 +15,7 @@ import { flagBool, flagList, flagString, parseArgs } from "../lib/args.js";
14
15
  import { main as builderRun } from "./builder-run.js";
15
16
  import { currentWorkflowSessionDir, isMeaningfulTestCommand, mainFromPublicWorkflow, WORKFLOW_WRITER_CONTRACT_VERSION } from "./workflow-sidecar.js";
16
17
  import { resolveCurrentAssignmentActor, withSubjectLock } from "./assignment-provider.js";
18
+ import { executeLoadedContinuationAdapter, loadContinuationAdapterCommand, waitForContinuationBarrier } from "./continuation-adapter.js";
17
19
 
18
20
  type JsonRecord = Record<string, unknown>;
19
21
 
@@ -22,7 +24,7 @@ const PACKAGE_ROOT = flowAgentsPackageRoot();
22
24
  const REQUIRE = createRequire(import.meta.url);
23
25
  const PACKAGE_METADATA = readJsonFile(path.join(PACKAGE_ROOT, "package.json"), "Flow Agents package metadata");
24
26
  const CLI_VERSION = flowAgentsPackageVersion();
25
- const PUBLIC_VERBS = ["start", "status", "evidence", "critique", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
27
+ const PUBLIC_VERBS = ["start", "status", "evidence", "critique", "drive", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
26
28
 
27
29
  function usage(): void {
28
30
  console.log(`Usage: flow-agents workflow <verb> [options]
@@ -32,6 +34,7 @@ Public workflow verbs:
32
34
  status Show the current canonical run and projected next action.
33
35
  evidence Record evidence for the current Flow gate and synchronize it.
34
36
  critique Record review critique directly into the current trust bundle.
37
+ drive Continue the canonical run through an explicit runtime adapter.
35
38
  pause Pause the current run as its assignment actor.
36
39
  resume Resume the current paused run as its assignment actor.
37
40
  release Release the current assignment without canceling the run.
@@ -61,12 +64,41 @@ export async function main(argv: string[]): Promise<number> {
61
64
  if (verb === "status") return status(sessionDir, flagBool(parsed.flags, "json"));
62
65
  if (verb === "evidence") return evidence(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
63
66
  if (verb === "critique") return critique(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
67
+ if (verb === "drive") return drive(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
64
68
 
65
69
  const forwarded = stripPublicFlags(argv.slice(1), new Set(["artifact-root", "session-dir", "json"]));
66
70
  if (verb === "release" && !flagString(parsed.flags, "reason")) throw new Error("workflow release requires --reason <text>");
67
71
  return builderRun([verb === "release" ? "release-assignment" : verb, "--session-dir", sessionDir, ...forwarded]);
68
72
  }
69
73
 
74
+ async function drive(sessionDir: string, argv: string[], json: boolean): Promise<number> {
75
+ const parsed = parseArgs(argv);
76
+ assertOnlyFlags(parsed.flags, new Set(["artifact-root", "session-dir", "json", "adapter-command-file", "max-turns", "turn-timeout-ms", "barrier-wait-ms", "barrier-poll-ms"]), "workflow drive");
77
+ const adapterCommandFile = flagString(parsed.flags, "adapter-command-file");
78
+ if (!adapterCommandFile) throw new Error("workflow drive requires --adapter-command-file <path>");
79
+ const maxTurns = integerFlag(parsed.flags, "max-turns", 4, 1, 100);
80
+ const turnTimeoutMs = integerFlag(parsed.flags, "turn-timeout-ms", 900_000, 1, 86_400_000);
81
+ const barrierWaitMs = integerFlag(parsed.flags, "barrier-wait-ms", 300_000, 0, 86_400_000);
82
+ const barrierPollMs = integerFlag(parsed.flags, "barrier-poll-ms", 1_000, 1, 60_000);
83
+ const { slug, projectRoot } = readBoundSession(sessionDir);
84
+ assertMatchingAssignmentActor(sessionDir, slug);
85
+ const adapterCommand = loadContinuationAdapterCommand(adapterCommandFile);
86
+ const result = await withContinuationDriverLock(sessionDir, async () => {
87
+ assertMatchingAssignmentActor(sessionDir, slug);
88
+ return driveBuilderFlowSession({
89
+ sessionDir,
90
+ maxTurns,
91
+ adapterCommandIdentity: adapterCommand.identity,
92
+ authorizeTurn: async () => { assertMatchingAssignmentActor(sessionDir, slug); },
93
+ execute: async (request) => executeLoadedContinuationAdapter(adapterCommand, request, { cwd: projectRoot, timeoutMs: turnTimeoutMs }),
94
+ waitForBarrier: async (barrier) => waitForContinuationBarrier(barrier, { maxWaitMs: barrierWaitMs, pollMs: barrierPollMs }),
95
+ });
96
+ });
97
+ if (json) console.log(JSON.stringify(result));
98
+ else console.log(`Continuation driver ${result.outcome} after ${result.turns_started} turn(s); canonical Flow is ${result.snapshot.status} at ${result.snapshot.current_step}.`);
99
+ return 0;
100
+ }
101
+
70
102
  async function start(argv: string[]): Promise<number> {
71
103
  const parsed = parseArgs(argv);
72
104
  assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion", "assignment-provider", "effective-state-json"]), "workflow start");
@@ -193,7 +225,7 @@ async function evidence(sessionDir: string, argv: string[], json: boolean): Prom
193
225
  // session state cannot change mid-invocation.
194
226
  const caller = assertMatchingAssignmentActor(sessionDir, slug);
195
227
  const repaired = await recoverBuilderFlowSession({ sessionDir });
196
- const beforeEvidence = manifestEvidenceDigests(repaired.run.manifest);
228
+ const beforeEvidence = manifestEvidenceIdentity(repaired.run.manifest);
197
229
  await mainFromPublicWorkflow([
198
230
  "record-gate-claim",
199
231
  sessionDir,
@@ -202,26 +234,33 @@ async function evidence(sessionDir: string, argv: string[], json: boolean): Prom
202
234
  caller.actorKey,
203
235
  ]);
204
236
 
205
- await syncBuilderFlowSession({ sessionDir });
237
+ const synchronized = await syncBuilderFlowSession({ sessionDir });
206
238
 
207
239
  const digest = fileSha256(path.join(sessionDir, "trust.bundle"));
208
240
  const run = await loadBuilderFlowRun({ cwd: repaired.projectRoot, runId: slug });
209
- const afterEvidence = manifestEvidenceDigests(run.manifest);
210
- const newEvidence = afterEvidence.filter((value) => !beforeEvidence.includes(value));
211
- if (newEvidence.length !== 1 || newEvidence[0] !== digest) {
241
+ const afterEvidence = manifestEvidenceIdentity(run.manifest);
242
+ const beforeIds = new Set(beforeEvidence.map((entry) => entry.id));
243
+ const newEvidence = afterEvidence.filter((entry) => !beforeIds.has(entry.id));
244
+ if (synchronized.attached && (newEvidence.length !== 1 || newEvidence[0]?.sha256 !== digest)) {
212
245
  throw new Error("workflow evidence did not attach exactly this invocation's resulting trust.bundle digest");
213
246
  }
247
+ if (!synchronized.attached && newEvidence.length !== 0) {
248
+ throw new Error("workflow evidence changed the canonical manifest while synchronization reported no attachment");
249
+ }
214
250
  const updatedSidecar = readBoundSession(sessionDir).sidecar;
215
251
  return immutableReport({
216
252
  run_id: run.runId,
217
253
  status: run.state.status,
218
254
  current_step: run.state.current_step,
219
- attached: true,
255
+ attached: synchronized.attached,
256
+ awaiting_evidence: !synchronized.attached,
220
257
  next_action: updatedSidecar.next_action ?? null,
221
258
  });
222
259
  });
223
260
  if (json) console.log(JSON.stringify(report));
224
- else console.log(`Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`);
261
+ else console.log(report.attached
262
+ ? `Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`
263
+ : `Recorded evidence; canonical run is awaiting the remaining gate expectations at ${report.current_step}.`);
225
264
  return 0;
226
265
  }
227
266
 
@@ -511,11 +550,14 @@ function immutableReport<T>(value: T): T {
511
550
  return Object.freeze(value);
512
551
  }
513
552
 
514
- function manifestEvidenceDigests(manifest: JsonRecord): string[] {
553
+ function manifestEvidenceIdentity(manifest: JsonRecord): Array<{ id: string; sha256: string }> {
515
554
  const evidence = Array.isArray(manifest.evidence) ? manifest.evidence : [];
516
555
  return evidence
517
- .map((entry) => entry && typeof entry === "object" ? (entry as JsonRecord).sha256 : null)
518
- .filter((digest): digest is string => typeof digest === "string");
556
+ .flatMap((entry) => entry && typeof entry === "object"
557
+ && typeof (entry as JsonRecord).id === "string"
558
+ && typeof (entry as JsonRecord).sha256 === "string"
559
+ ? [{ id: String((entry as JsonRecord).id), sha256: String((entry as JsonRecord).sha256) }]
560
+ : []);
519
561
  }
520
562
 
521
563
  function optionalFileDigest(file: string): string | null {
@@ -560,6 +602,21 @@ function assertOnlyFlags(flags: ReturnType<typeof parseArgs>["flags"], allowed:
560
602
  if (unsupported) throw new Error(`${command} does not support --${unsupported}`);
561
603
  }
562
604
 
605
+ function integerFlag(
606
+ flags: ReturnType<typeof parseArgs>["flags"],
607
+ name: string,
608
+ fallback: number,
609
+ min: number,
610
+ max: number,
611
+ ): number {
612
+ const raw = flagString(flags, name);
613
+ if (raw === undefined) return fallback;
614
+ if (!/^(0|[1-9][0-9]*)$/.test(raw)) throw new Error(`workflow drive --${name} must be an integer from ${min} through ${max}`);
615
+ const value = Number(raw);
616
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`workflow drive --${name} must be an integer from ${min} through ${max}`);
617
+ return value;
618
+ }
619
+
563
620
  function isWithin(candidate: string, root: string): boolean {
564
621
  const relative = path.relative(root, candidate);
565
622
  return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));