@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
@@ -16,14 +16,21 @@ Options:
16
16
  --console-token-file PATH
17
17
  Read Console telemetry bearer token from a file.
18
18
  --console-tenant ID Persist Console tenant identifier.
19
+ --skills-dir PATH Install portable skills here (default: $HOME/.agents/skills).
19
20
  EOF
20
21
  }
21
22
 
22
23
  DEST="${CODEX_HOME:-$HOME/.codex}"
24
+ SKILLS_DIR="${FLOW_AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
23
25
  DEST_SET=0
24
26
  CONSOLE_CONFIG_ARGS=()
25
27
  while [[ $# -gt 0 ]]; do
26
28
  case "$1" in
29
+ --skills-dir)
30
+ [[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
31
+ SKILLS_DIR="$2"
32
+ shift 2
33
+ ;;
27
34
  --telemetry-sink|--telemetry-sinks|--console-url|--console-endpoint|--console-endpoint-url|--console-token-file|--console-tenant|--console-tenant-id)
28
35
  [[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
29
36
  CONSOLE_CONFIG_ARGS+=("$1" "$2")
@@ -69,9 +76,26 @@ NODE
69
76
  }
70
77
 
71
78
  [[ ! -L "$DEST" ]] || { echo "install-codex-home.sh: refusing symlink destination root: $DEST" >&2; exit 1; }
79
+ [[ ! -L "$SKILLS_DIR" ]] || { echo "install-codex-home.sh: refusing symlink skills destination root: $SKILLS_DIR" >&2; exit 1; }
72
80
  ROOT_REAL="$(canonicalize_path "$ROOT_DIR")"
73
81
  BUNDLE_SOURCE_REAL="$(canonicalize_path "$ROOT_DIR/dist/codex")"
74
82
  DEST_REAL="$(canonicalize_path "$DEST")"
83
+ SKILLS_DIR_REAL="$(canonicalize_path "$SKILLS_DIR")"
84
+ if ! node - "$SKILLS_DIR" <<'NODE'
85
+ const fs = require("node:fs"); const path = require("node:path");
86
+ const absolute = path.resolve(process.argv[2]);
87
+ let current = path.parse(absolute).root;
88
+ for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
89
+ current = path.join(current, part);
90
+ // macOS exposes /tmp as the system-managed /private/tmp symlink. Treat that
91
+ // mount alias as trusted while rejecting caller-controlled components.
92
+ if (current !== "/tmp" && fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) process.exit(1);
93
+ }
94
+ NODE
95
+ then
96
+ echo "install-codex-home.sh: refusing symlink component in skills destination: $SKILLS_DIR" >&2
97
+ exit 1
98
+ fi
75
99
  REAL_CODEX_HOME_REAL="$(canonicalize_path "$REAL_CODEX_HOME")"
76
100
  case "$DEST_REAL/" in
77
101
  "$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*)
@@ -85,6 +109,17 @@ case "$ROOT_REAL/" in
85
109
  exit 1
86
110
  ;;
87
111
  esac
112
+ case "$SKILLS_DIR_REAL/" in
113
+ "$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*) echo "install-codex-home.sh: skills destination overlaps Flow Agents source: $SKILLS_DIR_REAL" >&2; exit 1 ;;
114
+ esac
115
+ case "$ROOT_REAL/" in
116
+ "$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: Flow Agents source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
117
+ esac
118
+ case "$BUNDLE_SOURCE_REAL/" in
119
+ "$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: generated bundle source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
120
+ esac
121
+ SKILLS_PATH_ROOT="$(node -e 'process.stdout.write(require("node:path").parse(process.argv[1]).root)' "$SKILLS_DIR_REAL")"
122
+ [[ "$SKILLS_DIR_REAL" != "$SKILLS_PATH_ROOT" ]] || { echo "install-codex-home.sh: refusing filesystem-root skills destination: $SKILLS_DIR_REAL" >&2; exit 1; }
88
123
 
89
124
  if command -v npm >/dev/null 2>&1; then
90
125
  (cd "$ROOT_DIR" && FLOW_AGENTS_EXPORT_DIAGNOSTICS=0 npm run build:bundles --silent >/dev/null)
@@ -93,10 +128,6 @@ else
93
128
  exit 1
94
129
  fi
95
130
 
96
- mkdir -p "$DEST"
97
- DEST="$(cd "$DEST" && pwd -P)"
98
- DEST_REAL="$DEST"
99
-
100
131
  assert_safe_dest_path() {
101
132
  local rel="$1"
102
133
  local current="$DEST"
@@ -122,22 +153,17 @@ assert_safe_dest_path() {
122
153
  esac
123
154
  }
124
155
 
125
- # Stash the user's existing hooks.json (if any), so the merge step below can
126
- # preserve user hooks across installs while replacing Flow Agents-managed groups.
127
156
  FA_USER_HOOKS_STASH=""
128
- assert_safe_dest_path "hooks.json"
129
- if [[ -f "$DEST/hooks.json" ]]; then
130
- FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
131
- cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
132
- fi
133
157
 
134
158
  # A real Codex home can contain user-owned files in every shared directory.
135
159
  # Prepare an exact Flow Agents overlay, then synchronize only files recorded in
136
160
  # the ownership manifest. The synchronizer never uses directory-wide --delete:
137
161
  # it removes only unchanged files from its prior manifest and refuses collisions.
138
162
  FA_OWNED_OVERLAY="$(mktemp -d /tmp/fa-codex-overlay.XXXXXX)"
163
+ FA_SKILLS_OVERLAY="$(mktemp -d /tmp/fa-codex-skills-overlay.XXXXXX)"
139
164
  cleanup_install_temps() {
140
165
  rm -rf "$FA_OWNED_OVERLAY"
166
+ rm -rf "$FA_SKILLS_OVERLAY"
141
167
  [[ -z "${FA_USER_HOOKS_STASH:-}" ]] || rm -f "$FA_USER_HOOKS_STASH"
142
168
  }
143
169
  trap cleanup_install_temps EXIT
@@ -157,22 +183,14 @@ for managed_dir in \
157
183
  scripts
158
184
  do
159
185
  if [[ -d "$ROOT_DIR/dist/codex/$managed_dir" ]]; then
160
- assert_safe_dest_path "$managed_dir"
161
186
  mkdir -p "$FA_OWNED_OVERLAY/$managed_dir"
162
187
  rsync -a "$ROOT_DIR/dist/codex/$managed_dir/" "$FA_OWNED_OVERLAY/$managed_dir/"
163
188
  fi
164
189
  done
165
190
 
166
- # Skills are user-extensible in a real Codex home. Merge both bundle skill
167
- # layers into the flattened destination without deleting user-owned skills.
168
- if [[ -d "$ROOT_DIR/dist/codex/skills" ]]; then
169
- mkdir -p "$FA_OWNED_OVERLAY/skills"
170
- rsync -a "$ROOT_DIR/dist/codex/skills/" "$FA_OWNED_OVERLAY/skills/"
171
- fi
172
-
173
- if [[ -d "$ROOT_DIR/dist/codex/.codex/skills" ]]; then
174
- mkdir -p "$FA_OWNED_OVERLAY/skills"
175
- rsync -a "$ROOT_DIR/dist/codex/.codex/skills/" "$FA_OWNED_OVERLAY/skills/"
191
+ # Portable skills use Codex's universal catalog, independently of CODEX_HOME.
192
+ if [[ -d "$ROOT_DIR/dist/codex/.agents/skills" ]]; then
193
+ rsync -a "$ROOT_DIR/dist/codex/.agents/skills/" "$FA_SKILLS_OVERLAY/"
176
194
  fi
177
195
 
178
196
  if [[ -d "$ROOT_DIR/dist/codex/kits" ]]; then
@@ -193,10 +211,31 @@ for bundle_file in README.md console.telemetry.json install.sh; do
193
211
  fi
194
212
  done
195
213
 
196
- assert_safe_dest_path ".flow-agents/codex-install-manifest.json"
214
+ # Check both destinations with the exact synchronizer before either can mutate.
215
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
216
+ --check "$FA_SKILLS_OVERLAY" "$SKILLS_DIR_REAL" ".flow-agents/codex-universal-skills-install-manifest.json"
217
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
218
+ --check "$FA_OWNED_OVERLAY" "$DEST_REAL" ".flow-agents/codex-install-manifest.json"
219
+
220
+ mkdir -p "$SKILLS_DIR"
221
+ SKILLS_DIR="$(cd "$SKILLS_DIR" && pwd -P)"
222
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
223
+ "$FA_SKILLS_OVERLAY" "$SKILLS_DIR" ".flow-agents/codex-universal-skills-install-manifest.json"
224
+
225
+ mkdir -p "$DEST"
226
+ DEST="$(cd "$DEST" && pwd -P)"
227
+ DEST_REAL="$DEST"
197
228
  node "$ROOT_DIR/scripts/install-owned-files.js" \
198
229
  "$FA_OWNED_OVERLAY" "$DEST" ".flow-agents/codex-install-manifest.json"
199
230
 
231
+ # Stash the user's existing hooks.json (if any), so the merge step below can
232
+ # preserve user hooks across installs while replacing Flow Agents-managed groups.
233
+ assert_safe_dest_path "hooks.json"
234
+ if [[ -f "$DEST/hooks.json" ]]; then
235
+ FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
236
+ cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
237
+ fi
238
+
200
239
  atomic_copy() {
201
240
  local source="$1"
202
241
  local rel="$2"
@@ -289,6 +328,7 @@ if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" |
289
328
  fi
290
329
 
291
330
  echo "Installed Flow Agents into Codex home at $DEST"
331
+ echo "Installed portable skills at $SKILLS_DIR"
292
332
  if [[ "${#profile_names[@]}" -gt 0 ]]; then
293
333
  echo "Profiles: ${profile_names[*]}"
294
334
  fi
@@ -9,10 +9,24 @@ function fail(message) {
9
9
  process.exit(1);
10
10
  }
11
11
 
12
- const [sourceArg, destArg, manifestArg] = process.argv.slice(2);
12
+ const args = process.argv.slice(2);
13
+ const checkOnly = args[0] === "--check";
14
+ if (checkOnly) args.shift();
15
+ const [sourceArg, destArg, manifestArg] = args;
13
16
  if (!sourceArg || !destArg || !manifestArg) fail("usage: install-owned-files.js <overlay> <destination> <manifest-relative-path>");
14
17
  const source = fs.realpathSync(sourceArg);
15
- const dest = fs.realpathSync(destArg);
18
+ function canonicalizeMissing(value) {
19
+ let current = path.resolve(value);
20
+ const missing = [];
21
+ while (!fs.existsSync(current)) {
22
+ missing.unshift(path.basename(current));
23
+ const parent = path.dirname(current);
24
+ if (parent === current) break;
25
+ current = parent;
26
+ }
27
+ return path.resolve(fs.realpathSync(current), ...missing);
28
+ }
29
+ const dest = canonicalizeMissing(destArg);
16
30
  const manifestPath = path.join(dest, manifestArg);
17
31
 
18
32
  function hashFile(file) {
@@ -132,6 +146,8 @@ for (const [rel] of previous) {
132
146
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) fail(`refusing to remove symlink replacing owned file: ${target}`);
133
147
  }
134
148
 
149
+ if (checkOnly) process.exit(0);
150
+
135
151
  for (const entry of incoming) {
136
152
  const target = targetFor(entry.rel);
137
153
  ensureSafeParent(target, true);
@@ -367,10 +367,21 @@ async function syncAndProject(
367
367
  const snapshot = stageTrustBundleSnapshot(context);
368
368
  try {
369
369
  const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
370
- const gateEvidence = await bundleGateEvidence(rawBundle, gates[0]!, run.state.subject, context.projectRoot);
370
+ const gateEvidence = await bundleGateEvidence(
371
+ rawBundle,
372
+ gates[0]!,
373
+ run.state,
374
+ run.state.subject,
375
+ context.projectRoot,
376
+ manifestEvidence(run.manifest),
377
+ );
371
378
  if (gateEvidence) {
372
379
  const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
373
- entry.gate_id === gates[0]!.id && entry.sha256 === snapshot.sha256
380
+ entry.gate_id === gates[0]!.id
381
+ && entry.sha256 === snapshot.sha256
382
+ && typeof entry.superseded_by !== "string"
383
+ && timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
384
+ && gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId))
374
385
  );
375
386
  if (!alreadyAttached) {
376
387
  const supersede = manifestEvidence(run.manifest)
@@ -386,6 +397,7 @@ async function syncAndProject(
386
397
  ...(supersede.length > 0 ? { supersede } : {}),
387
398
  ...(gateEvidence.failed ? { status: "failed" } : {}),
388
399
  ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
400
+ expectationIds: gateEvidence.expectationIds,
389
401
  },
390
402
  });
391
403
  attached = true;
@@ -478,21 +490,72 @@ function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: s
478
490
  ) as Array<FlowGate & { id: string }>;
479
491
  }
480
492
 
481
- async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string, projectRoot: string): Promise<{ failed: boolean; routeReason: string | null } | null> {
493
+ async function bundleGateEvidence(
494
+ bundle: unknown,
495
+ gate: FlowGate,
496
+ state: FlowRunState,
497
+ subject: string,
498
+ projectRoot: string,
499
+ manifest: AnyRecord[],
500
+ ): Promise<{ failed: boolean; routeReason: string | null; expectationIds: string[]; visitEnteredAt: number } | null> {
482
501
  if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
483
- const selectors = (expectationsForGate(gate) as FlowExpectation[]).map((expectation) => expectation.bundle_claim);
502
+ const expectations = expectationsForGate(gate) as FlowExpectation[];
503
+ const visit = currentGateVisit(state, String((gate as AnyRecord).step));
504
+ const enteredAt = visit.enteredAt;
505
+ const synchronizedAt = Date.now();
506
+ const maxClockSkewMs = 30_000;
507
+ const priorVisitClaimIds = new Set<string>();
508
+ const priorVisitEvidenceIds = new Set<string>();
509
+ for (const entry of manifest) {
510
+ if (entry.gate_id !== String((gate as AnyRecord).id)) continue;
511
+ const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
512
+ for (const historical of claims) {
513
+ if (isRecord(historical) && typeof historical.id === "string") priorVisitClaimIds.add(historical.id);
514
+ }
515
+ const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
516
+ for (const historical of evidence) {
517
+ if (isRecord(historical) && typeof historical.id === "string") priorVisitEvidenceIds.add(historical.id);
518
+ }
519
+ }
520
+ const claimIsCurrent = (claim: AnyRecord): boolean => {
521
+ if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id)) return false;
522
+ const timestamps: number[] = [];
523
+ const createdAt = parseTimestamp(claim.createdAt);
524
+ if (createdAt !== null) timestamps.push(createdAt);
525
+ if (Array.isArray((bundle as AnyRecord).evidence)) for (const evidence of (bundle as AnyRecord).evidence) {
526
+ if (!isRecord(evidence) || evidence.claimId !== claim.id) continue;
527
+ if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id)) return false;
528
+ const observedAt = parseTimestamp(evidence.observedAt);
529
+ if (observedAt !== null) timestamps.push(observedAt);
530
+ }
531
+ const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
532
+ return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
533
+ && timestamp <= synchronizedAt + maxClockSkewMs);
534
+ };
484
535
  const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
485
536
  if (!isRecord(claim)) return false;
486
- return selectors.some((candidate: FlowExpectation["bundle_claim"]) =>
537
+ if (claim.producerStatus === "superseded") return false;
538
+ const metadata = isRecord(claim.metadata) ? claim.metadata : null;
539
+ if (metadata && typeof metadata.superseded_by === "string") return false;
540
+ return expectations.some((expectation) => {
541
+ const candidate = expectation.bundle_claim;
542
+ return candidate
543
+ && claimIsCurrent(claim)
544
+ &&
487
545
  candidate.claimType === claim.claimType
488
546
  && (!candidate.subjectType || candidate.subjectType === claim.subjectType)
489
- );
547
+ });
490
548
  });
491
549
  if (relevant.length === 0) return null;
492
550
  if (relevant.some((claim) => workflowSubjectRef(claim) !== subject)) {
493
551
  throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
494
552
  }
495
553
  const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
554
+ const expectationIds = expectations.filter((expectation) => relevant.some((claim: AnyRecord) => {
555
+ const selector = expectation.bundle_claim;
556
+ return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
557
+ })).map((expectation) => expectation.id);
558
+ const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
496
559
  const routeReasons = [...new Set(relevant.flatMap((claim) => {
497
560
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
498
561
  const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
@@ -502,6 +565,11 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
502
565
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
503
566
  }
504
567
  const routeReason = routeReasons[0] ?? null;
568
+ if (failed && !routeReason) return null;
569
+ // Passing evidence waits for the complete expectation set. A failing
570
+ // snapshot is complete only when a gate producer explicitly declares its
571
+ // route reason; report-only disputed critique state remains pending.
572
+ if (!failed && missingRequired.length > 0) return null;
505
573
  if (routeReason && !failed) {
506
574
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
507
575
  }
@@ -512,7 +580,31 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
512
580
  if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
513
581
  await assertVerifiedTestsTrust(bundle.claims, projectRoot);
514
582
  }
515
- return { failed, routeReason };
583
+ return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
584
+ }
585
+
586
+ function currentGateVisit(state: FlowRunState, step: string): { enteredAt: number; initial: boolean } {
587
+ let enteredAt: number | null = null;
588
+ for (const transition of state.transitions ?? []) {
589
+ if (transition.to_step !== step) continue;
590
+ const parsed = parseTimestamp(transition.at);
591
+ if (parsed !== null) enteredAt = parsed;
592
+ }
593
+ const initial = parseTimestamp(state.updated_at);
594
+ if (enteredAt !== null) return { enteredAt, initial: false };
595
+ if (initial !== null) return { enteredAt: initial, initial: true };
596
+ throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
597
+ }
598
+
599
+ function parseTimestamp(value: unknown): number | null {
600
+ if (typeof value !== "string") return null;
601
+ const parsed = Date.parse(value);
602
+ return Number.isFinite(parsed) ? parsed : null;
603
+ }
604
+
605
+ function timestampAtOrAfter(value: unknown, boundary: number): boolean {
606
+ const parsed = parseTimestamp(value);
607
+ return parsed !== null && parsed >= boundary;
516
608
  }
517
609
 
518
610
  async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
@@ -769,7 +861,9 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
769
861
  const complete = run.state.status === "completed";
770
862
  const paused = run.state.status === "paused";
771
863
  const canceled = run.state.status === "canceled";
772
- const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
864
+ const needsDecision = run.state.status === "needs_decision";
865
+ const failed = run.state.status === "failed";
866
+ const action = complete || paused || canceled || needsDecision || failed ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
773
867
  if (!action) {
774
868
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
775
869
  }
@@ -794,6 +888,10 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
794
888
  ? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
795
889
  : paused
796
890
  ? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
891
+ : needsDecision
892
+ ? { status: "blocked", summary: "Canonical Flow requires an external decision before continuation." }
893
+ : failed
894
+ ? { status: "failed", summary: "Canonical Flow run failed; no continuation turn is allowed." }
797
895
  : {
798
896
  status: "continue",
799
897
  summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
@@ -804,8 +902,8 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
804
902
  const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
805
903
  return {
806
904
  ...sidecar,
807
- status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
808
- phase: complete || canceled ? "done" : phase,
905
+ status: complete ? "delivered" : canceled ? "canceled" : failed ? "failed" : (paused || needsDecision) ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
906
+ phase: complete || canceled || failed ? "done" : phase,
809
907
  updated_at: run.state.updated_at,
810
908
  flow_run: {
811
909
  run_id: run.runId,
@@ -775,7 +775,19 @@ export function performLocalReleaseUnderLock(
775
775
  // seam, relocated to this write path).
776
776
  const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
777
777
  const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
778
- if (holderActorKey !== releasedByActorKey) {
778
+ // Pre-3.7 lifecycle events could persist the derived ancestry actor before
779
+ // sanitizeSegment removed ':' separators. Modern explicit/env release paths
780
+ // always use the sanitized form. Accept only that one-way legacy migration;
781
+ // never normalize two modern keys or relax ownership to a prefix match.
782
+ const sameActorStruct = existing.actor.runtime === releasedBy.runtime
783
+ && existing.actor.session_id === releasedBy.session_id
784
+ && existing.actor.host === releasedBy.host
785
+ && (existing.actor.human ?? null) === (releasedBy.human ?? null);
786
+ const legacyActorKeyMatches = holderActorKey.includes(":")
787
+ && holderActorKey === helper.serializeActor(existing.actor)
788
+ && helper.sanitizeSegment(holderActorKey) === releasedByActorKey
789
+ && sameActorStruct;
790
+ if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
779
791
  if (tolerateNoActiveClaim) return null;
780
792
  throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
781
793
  }