@kontourai/flow-agents 3.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.8.0](https://github.com/kontourai/flow-agents/compare/v3.7.0...v3.8.0) (2026-07-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **packaging:** install portable skills under .agents ([#551](https://github.com/kontourai/flow-agents/issues/551)) ([12f12e3](https://github.com/kontourai/flow-agents/commit/12f12e3d0a32b398e47a9ac01604179c58bf14ba))
9
+
10
+
11
+ ### Fixes
12
+
13
+ * **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))
14
+
3
15
  ## [3.7.0](https://github.com/kontourai/flow-agents/compare/v3.6.0...v3.7.0) (2026-07-12)
4
16
 
5
17
 
@@ -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)
@@ -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`);
@@ -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,
@@ -200,7 +200,7 @@ async function evidence(sessionDir, argv, json) {
200
200
  // session state cannot change mid-invocation.
201
201
  const caller = assertMatchingAssignmentActor(sessionDir, slug);
202
202
  const repaired = await recoverBuilderFlowSession({ sessionDir });
203
- const beforeEvidence = manifestEvidenceDigests(repaired.run.manifest);
203
+ const beforeEvidence = manifestEvidenceIdentity(repaired.run.manifest);
204
204
  await mainFromPublicWorkflow([
205
205
  "record-gate-claim",
206
206
  sessionDir,
@@ -208,27 +208,34 @@ async function evidence(sessionDir, argv, json) {
208
208
  "--actor",
209
209
  caller.actorKey,
210
210
  ]);
211
- await syncBuilderFlowSession({ sessionDir });
211
+ const synchronized = await syncBuilderFlowSession({ sessionDir });
212
212
  const digest = fileSha256(path.join(sessionDir, "trust.bundle"));
213
213
  const run = await loadBuilderFlowRun({ cwd: repaired.projectRoot, runId: slug });
214
- const afterEvidence = manifestEvidenceDigests(run.manifest);
215
- const newEvidence = afterEvidence.filter((value) => !beforeEvidence.includes(value));
216
- if (newEvidence.length !== 1 || newEvidence[0] !== digest) {
214
+ const afterEvidence = manifestEvidenceIdentity(run.manifest);
215
+ const beforeIds = new Set(beforeEvidence.map((entry) => entry.id));
216
+ const newEvidence = afterEvidence.filter((entry) => !beforeIds.has(entry.id));
217
+ if (synchronized.attached && (newEvidence.length !== 1 || newEvidence[0]?.sha256 !== digest)) {
217
218
  throw new Error("workflow evidence did not attach exactly this invocation's resulting trust.bundle digest");
218
219
  }
220
+ if (!synchronized.attached && newEvidence.length !== 0) {
221
+ throw new Error("workflow evidence changed the canonical manifest while synchronization reported no attachment");
222
+ }
219
223
  const updatedSidecar = readBoundSession(sessionDir).sidecar;
220
224
  return immutableReport({
221
225
  run_id: run.runId,
222
226
  status: run.state.status,
223
227
  current_step: run.state.current_step,
224
- attached: true,
228
+ attached: synchronized.attached,
229
+ awaiting_evidence: !synchronized.attached,
225
230
  next_action: updatedSidecar.next_action ?? null,
226
231
  });
227
232
  });
228
233
  if (json)
229
234
  console.log(JSON.stringify(report));
230
235
  else
231
- console.log(`Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`);
236
+ console.log(report.attached
237
+ ? `Recorded evidence; canonical run is ${report.status} at ${report.current_step}.`
238
+ : `Recorded evidence; canonical run is awaiting the remaining gate expectations at ${report.current_step}.`);
232
239
  return 0;
233
240
  }
234
241
  async function critique(sessionDir, argv, json) {
@@ -541,11 +548,14 @@ function immutableReport(value) {
541
548
  immutableReport(nested);
542
549
  return Object.freeze(value);
543
550
  }
544
- function manifestEvidenceDigests(manifest) {
551
+ function manifestEvidenceIdentity(manifest) {
545
552
  const evidence = Array.isArray(manifest.evidence) ? manifest.evidence : [];
546
553
  return evidence
547
- .map((entry) => entry && typeof entry === "object" ? entry.sha256 : null)
548
- .filter((digest) => typeof digest === "string");
554
+ .flatMap((entry) => entry && typeof entry === "object"
555
+ && typeof entry.id === "string"
556
+ && typeof entry.sha256 === "string"
557
+ ? [{ id: String(entry.id), sha256: String(entry.sha256) }]
558
+ : []);
549
559
  }
550
560
  function optionalFileDigest(file) {
551
561
  try {
@@ -137,6 +137,55 @@ function copyTree(src, dest, target, rootReplacement) {
137
137
  }
138
138
  }
139
139
  }
140
+ const skillResourcePattern = /(?:^|[`("'\s])(context\/contracts\/[A-Za-z0-9._/-]+\.md)(?=$|[`),"'\s])/gm;
141
+ /** Export a skill as a self-contained package. Shared instruction resources
142
+ * retain the relative paths used by SKILL.md so runtime resolution is local. */
143
+ function exportSkillPackage(src, dest, target) {
144
+ const sourceDir = path.dirname(src);
145
+ copyTree(sourceDir, dest, target, "<bundle-root>");
146
+ const skillText = readText(src);
147
+ const resources = new Set();
148
+ for (const match of skillText.matchAll(skillResourcePattern))
149
+ resources.add(match[1]);
150
+ for (const rel of [...resources].sort()) {
151
+ if (path.isAbsolute(rel) || rel.split("/").includes("..")) {
152
+ throw new Error(`skill '${path.basename(sourceDir)}': unsafe local resource '${rel}'`);
153
+ }
154
+ const source = path.resolve(root, rel);
155
+ const output = path.resolve(dest, rel);
156
+ if (!source.startsWith(`${path.resolve(root)}${path.sep}`) || !output.startsWith(`${path.resolve(dest)}${path.sep}`)) {
157
+ throw new Error(`skill '${path.basename(sourceDir)}': local resource escapes package '${rel}'`);
158
+ }
159
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
160
+ throw new Error(`skill '${path.basename(sourceDir)}': missing local resource '${rel}'`);
161
+ }
162
+ if (fs.existsSync(output)) {
163
+ const existing = fs.readFileSync(output);
164
+ const incoming = fs.readFileSync(source);
165
+ if (!existing.equals(incoming))
166
+ throw new Error(`skill '${path.basename(sourceDir)}': resource collision '${rel}'`);
167
+ continue;
168
+ }
169
+ writeText(output, sanitizeText(readText(source), target, "<bundle-root>"));
170
+ }
171
+ }
172
+ function validateExportedSkillPackages(skillsRoot) {
173
+ if (!fs.existsSync(skillsRoot))
174
+ return;
175
+ for (const skillName of fs.readdirSync(skillsRoot).sort()) {
176
+ const skillDir = path.join(skillsRoot, skillName);
177
+ const skillFile = path.join(skillDir, "SKILL.md");
178
+ if (!fs.existsSync(skillFile))
179
+ continue;
180
+ for (const match of readText(skillFile).matchAll(skillResourcePattern)) {
181
+ const rel = match[1];
182
+ const resolved = path.resolve(skillDir, rel);
183
+ if (!resolved.startsWith(`${path.resolve(skillDir)}${path.sep}`) || !fs.existsSync(resolved)) {
184
+ throw new Error(`skill '${skillName}': unresolved exported resource '${rel}'`);
185
+ }
186
+ }
187
+ }
188
+ }
140
189
  function resolveSourcePath(pathText) {
141
190
  let normalized = pathText;
142
191
  for (const alias of manifest.source_root_aliases)
@@ -473,9 +522,10 @@ function buildCodex(agents) {
473
522
  writeText(path.join(bundle, ".codex/hooks.json"), exportCodexHooks());
474
523
  for (const spec of targetAgents)
475
524
  writeText(path.join(bundle, ".codex/agents", `${spec.name}.toml`), exportCodexAgent(spec));
476
- for (const { name, src } of collectAllSkills()) {
477
- writeText(path.join(bundle, ".codex/skills", name, "SKILL.md"), sanitizeText(readText(src), "codex", "<bundle-root>"));
478
- }
525
+ const skillsRoot = path.join(bundle, ".agents/skills");
526
+ for (const { name, src } of collectAllSkills())
527
+ exportSkillPackage(src, path.join(skillsRoot, name), "codex");
528
+ validateExportedSkillPackages(skillsRoot);
479
529
  writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Codex", targetAgents, manifest.codex.task_dir));
480
530
  writeText(path.join(bundle, "README.md"), exportTargetReadme("Codex", "bash install.sh /path/to/workspace", CODEX_LIVE_HOOKS_README));
481
531
  writeText(path.join(bundle, "install.sh"), installScript("Codex", "/path/to/workspace", undefined, undefined, { configRelPath: ".codex/hooks.json", managedConfigRelPath: ".codex/hooks.json", runtime: "codex", version: pkgVersion }));
@@ -30,7 +30,15 @@ For a normal Codex global install, target the Codex home instead of a project wo
30
30
  npx @kontourai/flow-agents init --runtime codex --global --activate-kits --yes
31
31
  ```
32
32
 
33
- That installs into `CODEX_HOME` when it is set, otherwise `~/.codex`. Use `--dest /path/to/codex-home` only when you need an explicit override for an isolated install, CI fixture, or test.
33
+ That splits installation by ownership: Codex-only runtime assets go into `CODEX_HOME` when set (otherwise `~/.codex`), while portable skills go into Codex's documented user catalog at `$HOME/.agents/skills`. A repository bundle install similarly exposes skills at `<repo>/.agents/skills`.
34
+
35
+ Pass a positional runtime destination and `--skills-dir PATH` when both roots must be isolated, or set `FLOW_AGENTS_SKILLS_DIR` for headless environments:
36
+
37
+ ```bash
38
+ bash scripts/install-codex-home.sh /tmp/codex-home --skills-dir /tmp/agents/skills
39
+ ```
40
+
41
+ Installer output reports both destinations. Reinstall preserves unknown user skills and migrates only unchanged Flow Agents-owned files from the former `CODEX_HOME/skills` layout; modified legacy content is retained. Flow Agents intentionally does not create a compatibility symlink because the Codex runtime home must not own or mask a universal user catalog, and the installer refuses to write through symlinked destinations.
34
42
 
35
43
  Keep generated Codex base config lean. Put profile-specific model, provider, and approval settings in separate `<profile>.config.toml` files and select them with `codex --profile <name>`.
36
44
 
@@ -63,8 +63,36 @@ does not create a Flow run.
63
63
 
64
64
  After a gate producer writes `trust.bundle`, the public evidence path
65
65
  synchronizes the existing run while holding the assignment subject lock.
66
- Synchronization is digest-idempotent: the same trust bundle is not attached
67
- twice.
66
+ Synchronization selects only live claims: producer-superseded claims and claims
67
+ carrying `metadata.superseded_by` remain auditable history but never determine
68
+ the current outcome. Passing evidence is published atomically only when every
69
+ required expectation for the gate is present; this prevents sequential critique
70
+ writes from attaching an intermediate partial snapshot and consuming a
71
+ route-back attempt. Failed evidence may still synchronize immediately when it
72
+ carries a route reason declared by the gate; a disputed report-only critique is
73
+ not itself a routed gate decision and remains pending.
74
+
75
+ Attachments carry the exact expectation ids selected from the current bundle.
76
+ Digest idempotence applies only while an unsuperseded attachment for that gate
77
+ and expectation set remains live. After route-back, claims must be current for
78
+ the new gate visit before synchronization, and claim/evidence identities used
79
+ by any earlier attachment to that gate can never satisfy the later visit.
80
+ Gate claims, verified criteria, and critiques carry producer-recorded version
81
+ timestamps plus `identity_version: 2` in their identity derivation so legitimate
82
+ re-verification creates new identities. Unmarked pre-upgrade records retain the
83
+ legacy identity formula during rebuild; installing new code alone can never
84
+ manufacture a fresh identity. Because those identities are embedded in TrustBundle bytes, a
85
+ genuinely new identity necessarily changes the bundle SHA-256; byte-identical
86
+ replay remains pending rather than consuming another attempt.
87
+
88
+ Every gate visit has a canonical boundary: the latest Flow transition into the
89
+ step, or the run's initial timestamp for its entry step. Claim creation and
90
+ observation timestamps must fall within that visit and may not be more than 30
91
+ seconds ahead of synchronization. The sole pre-boundary allowance is the
92
+ 30-second acquisition window for the assignment-backed `selected-work` claim,
93
+ which is intentionally produced immediately before the canonical run starts.
94
+ Previously attached claim and evidence identities are excluded from every later
95
+ visit to the same gate regardless of timestamp skew.
68
96
 
69
97
  ## Public Status And Recovery
70
98
 
@@ -119,6 +147,12 @@ Workflow steering surfaces these fields on session start and prompt submission.
119
147
  Stop hook treats an unfinished canonical Flow run as active even during pickup or
120
148
  planning, blocks a premature stop in block mode, and does not release its liveness
121
149
  claim. A run is complete only when Flow reaches its terminal step.
150
+
151
+ Projection is always derived from the canonical run's `current_step`, including
152
+ composed steps that have no legacy sidecar phase (`merge-ready-ci` and `learn`).
153
+ Both the legacy `current.json` pointer and every matching per-actor pointer are
154
+ updated from that canonical step; `phase_map` is presentation metadata, not the
155
+ authority for pointer navigation.
122
156
  # Builder Lifecycle Authority
123
157
 
124
158
  The canonical Flow run owns pause, resume, and cancellation. The current assignment actor may
@@ -456,6 +456,9 @@ record_public_expectation() {
456
456
  *) _fail "public producer fixture has no durable artifact for $expectation"; return ;;
457
457
  esac
458
458
  local -a args=(evidence --session-dir "$PUBLIC_SESSION" --expectation "$expectation" --status "$status" --summary "public producer fixture records a reviewable durable artifact" --evidence-ref-json "{\"kind\":\"artifact\",\"file\":\"$artifact\",\"summary\":\"Fixture artifact for $expectation.\"}")
459
+ if [ "$expectation" = "tests-evidence" ] && [ "$status" = "fail" ]; then
460
+ args+=(--route-reason implementation_defect)
461
+ fi
459
462
  if [ "$expectation" = "tests-evidence" ] && [ "$status" = "pass" ]; then
460
463
  local test_command criterion_one criterion_two command_ref
461
464
  test_command="bash checks/check-public-session.sh .kontourai/flow-agents/$slug/state.json"
@@ -614,16 +617,46 @@ ROUTE_CRITIQUE_OUTPUT="$(public_review critique --session-dir "$PUBLIC_SESSION"
614
617
  --artifact-ref "$PUBLIC_SESSION/$(basename "$PUBLIC_SESSION")--deliver.md" \
615
618
  --lane-json "{\"id\":\"code-review\",\"status\":\"pass\",\"summary\":\"Public fixture code review completed.\",\"evidence_refs\":[{\"kind\":\"artifact\",\"file\":\"$PUBLIC_SESSION/$(basename "$PUBLIC_SESSION")--deliver.md\",\"summary\":\"Reviewed public fixture delivery artifact.\"}]}" 2>&1)" \
616
619
  || _fail "public authenticated critique failed before failed tests-evidence: $ROUTE_CRITIQUE_OUTPUT"
620
+ # Seed current verified acceptance prerequisites through the private producer
621
+ # without synchronizing Flow. The subsequent public failed tests claim replaces
622
+ # the provisional passing tests check, preserves these criteria + the clean
623
+ # critique, and is the only attachment/evaluation for this gate visit.
624
+ ROUTE_TEST_COMMAND="bash checks/check-public-session.sh .kontourai/flow-agents/$(basename "$PUBLIC_SESSION")/state.json"
625
+ ROUTE_COMMAND_REF="$(node - "$ROUTE_TEST_COMMAND" <<'NODE'
626
+ const command = process.argv[2];
627
+ process.stdout.write(JSON.stringify({ kind: 'command', excerpt: command, summary: 'Current route-back prerequisite command.' }));
628
+ NODE
629
+ )"
630
+ ROUTE_CRITERION_ONE="$(node - "$ROUTE_TEST_COMMAND" <<'NODE'
631
+ const command = process.argv[2];
632
+ process.stdout.write(JSON.stringify({ id: 'AC-1', status: 'pass', evidence_refs: [{ kind: 'command', excerpt: command, summary: 'Current AC-1 prerequisite.' }] }));
633
+ NODE
634
+ )"
635
+ ROUTE_CRITERION_TWO="$(node - "$ROUTE_TEST_COMMAND" <<'NODE'
636
+ const command = process.argv[2];
637
+ process.stdout.write(JSON.stringify({ id: 'AC-2', status: 'pass', evidence_refs: [{ kind: 'command', excerpt: command, summary: 'Current AC-2 prerequisite.' }] }));
638
+ NODE
639
+ )"
640
+ ROUTE_OBSERVED="$(node - "$ROUTE_TEST_COMMAND" <<'NODE'
641
+ const command = process.argv[2];
642
+ process.stdout.write(JSON.stringify({ command, exit_code: 0, test_count: 1, output_sha256: '0'.repeat(64) }));
643
+ NODE
644
+ )"
645
+ CODEX_SESSION_ID=builder-public-producers flow_agents_node "workflow-sidecar" record-gate-claim "$PUBLIC_SESSION" \
646
+ --expectation tests-evidence --status pass --summary "Seed complete current acceptance prerequisites before routed failure." \
647
+ --command "$ROUTE_TEST_COMMAND" --observed-command-json "$ROUTE_OBSERVED" \
648
+ --evidence-ref-json "$ROUTE_COMMAND_REF" --criterion-json "$ROUTE_CRITERION_ONE" --criterion-json "$ROUTE_CRITERION_TWO" >/dev/null 2>&1 \
649
+ || _fail "failed to seed current acceptance prerequisites for routed failure"
617
650
  record_public_expectation "tests-evidence" "fail"
618
651
  ROUTE_REPORT="$(public_flow status --session-dir "$PUBLIC_SESSION" --json 2>/dev/null)"
619
652
  if node - "$ROUTE_REPORT" <<'NODE'
620
653
  const report = JSON.parse(process.argv[2]);
621
- if (report.current_step !== 'verify') process.exit(1);
622
- if ((report.next_action?.skills || []).join(',') !== 'review-work,verify-work') process.exit(2);
623
- if (!/attempt 1\/3 returned to `verify`/.test(report.next_action?.summary || '')) process.exit(3);
654
+ if (report.current_step !== 'execute') process.exit(1);
655
+ if ((report.next_action?.skills || []).join(',') !== 'execute-plan') process.exit(2);
656
+ if (!/attempt 1\/3 returned to `execute` for `implementation_defect`/.test(report.next_action?.summary || '')) process.exit(3);
624
657
  NODE
625
658
  then
626
- _pass "failed verify evidence routes back through Flow with attempt 1/3 and canonical producers"
659
+ _pass "complete failed verify evidence routes back through Flow once to execute for implementation_defect"
627
660
  else
628
661
  _fail "public failed-verify route-back was not projected correctly: $ROUTE_REPORT"
629
662
  fi