taskflow-core 0.1.3 → 0.1.5

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 (54) hide show
  1. package/README.md +861 -0
  2. package/dist/compile.d.ts.map +1 -1
  3. package/dist/compile.js +5 -1
  4. package/dist/compile.js.map +1 -1
  5. package/dist/contract.d.ts +39 -0
  6. package/dist/contract.d.ts.map +1 -0
  7. package/dist/contract.js +150 -0
  8. package/dist/contract.js.map +1 -0
  9. package/dist/detached-runner.js +17 -0
  10. package/dist/detached-runner.js.map +1 -1
  11. package/dist/host/runner-types.d.ts +3 -0
  12. package/dist/host/runner-types.d.ts.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +5 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/interpolate.d.ts +5 -0
  18. package/dist/interpolate.d.ts.map +1 -1
  19. package/dist/interpolate.js +14 -3
  20. package/dist/interpolate.js.map +1 -1
  21. package/dist/peek.d.ts +39 -0
  22. package/dist/peek.d.ts.map +1 -0
  23. package/dist/peek.js +119 -0
  24. package/dist/peek.js.map +1 -0
  25. package/dist/reflexion.d.ts +46 -0
  26. package/dist/reflexion.d.ts.map +1 -0
  27. package/dist/reflexion.js +100 -0
  28. package/dist/reflexion.js.map +1 -0
  29. package/dist/runner-core.d.ts.map +1 -1
  30. package/dist/runner-core.js +3 -0
  31. package/dist/runner-core.js.map +1 -1
  32. package/dist/runtime.d.ts.map +1 -1
  33. package/dist/runtime.js +589 -23
  34. package/dist/runtime.js.map +1 -1
  35. package/dist/schema.d.ts +21 -7
  36. package/dist/schema.d.ts.map +1 -1
  37. package/dist/schema.js +177 -4
  38. package/dist/schema.js.map +1 -1
  39. package/dist/scorer-runtime.d.ts +30 -0
  40. package/dist/scorer-runtime.d.ts.map +1 -0
  41. package/dist/scorer-runtime.js +134 -0
  42. package/dist/scorer-runtime.js.map +1 -0
  43. package/dist/scorers.d.ts +120 -0
  44. package/dist/scorers.d.ts.map +1 -0
  45. package/dist/scorers.js +313 -0
  46. package/dist/scorers.js.map +1 -0
  47. package/dist/store.d.ts +28 -1
  48. package/dist/store.d.ts.map +1 -1
  49. package/dist/store.js.map +1 -1
  50. package/dist/verify.d.ts +1 -1
  51. package/dist/verify.d.ts.map +1 -1
  52. package/dist/verify.js +59 -0
  53. package/dist/verify.js.map +1 -1
  54. package/package.json +2 -2
package/dist/runtime.js CHANGED
@@ -12,7 +12,8 @@
12
12
  import * as path from "node:path";
13
13
  import * as fs from "node:fs";
14
14
  import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
15
- import { isFailed, isTransientError, mapWithConcurrencyLimit } from "./runner-core.js";
15
+ import { contractViolations } from "./contract.js";
16
+ import { isFailed, isTransientError, mapWithConcurrencyLimit, sanitizeErrorMessage } from "./runner-core.js";
16
17
  /** Default runner used when no host injected one: fail loudly rather than
17
18
  * silently spawn anything (core is host-neutral and cannot spawn pi/codex). */
18
19
  const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
@@ -28,6 +29,9 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
28
29
  import { aggregateUsage, emptyUsage } from "./usage.js";
29
30
  import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
30
31
  import { verifyTaskflow } from "./verify.js";
32
+ import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
33
+ import { runCodeCompilesScorer } from "./scorer-runtime.js";
34
+ import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
31
35
  import { hashInput, newRunId, runsDir } from "./store.js";
32
36
  import { CacheStore, resolveFingerprint } from "./cache.js";
33
37
  import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
@@ -64,7 +68,7 @@ export function summarizeReuse(state) {
64
68
  savedUSD,
65
69
  };
66
70
  }
67
- function buildInterpolationContext(state, previousOutput, locals, onRead) {
71
+ function buildInterpolationContext(state, previousOutput, locals, onRead, reflexion) {
68
72
  const steps = {};
69
73
  for (const [id, ps] of Object.entries(state.phases)) {
70
74
  // Include both done AND failed phases so downstream phases can see
@@ -81,7 +85,7 @@ function buildInterpolationContext(state, previousOutput, locals, onRead) {
81
85
  }
82
86
  }
83
87
  }
84
- return { args: state.args, steps, previousOutput, locals, onRead };
88
+ return { args: state.args, steps, previousOutput, locals, onRead, reflexion };
85
89
  }
86
90
  function resultToPhaseState(id, r, inputHash, parseJson) {
87
91
  const failed = isFailed(r);
@@ -100,6 +104,7 @@ function resultToPhaseState(id, r, inputHash, parseJson) {
100
104
  usage: r.usage,
101
105
  model: r.model,
102
106
  attempts: attempts > 1 ? attempts : undefined,
107
+ timedOut: r.phaseTimeout || undefined,
103
108
  error: failed ? r.errorMessage || r.stderr || r.output : undefined,
104
109
  inputHash,
105
110
  endedAt: Date.now(),
@@ -314,6 +319,7 @@ function mergePhaseState(id, results, inputHash, parseJson) {
314
319
  usage,
315
320
  model,
316
321
  attempts: attempts > results.length ? attempts : undefined,
322
+ timedOut: ran.some((r) => r.phaseTimeout) || undefined,
317
323
  budgetTruncated: budgetSkips.length > 0 || undefined,
318
324
  subProgress: { done: ran.length, total: results.length, running: 0, failed: failedCount },
319
325
  error: errors.length ? errors.join("; ") : undefined,
@@ -550,9 +556,26 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
550
556
  return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
551
557
  }
552
558
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
559
+ // Side-effect classification: stamp the marker at the single exit point so
560
+ // every type branch inside executePhaseInner is covered. A skipped phase ran
561
+ // nothing — no side effect to record.
562
+ const stamp = (ps) => {
563
+ if (phase.idempotent === false && ps.status !== "skipped") {
564
+ ps.sideEffect = true;
565
+ // Resume double-fire warning (issue #20): a non-idempotent phase is never
566
+ // cached, so on resume it RE-EXECUTES even though a prior attempt already
567
+ // completed — re-firing its side effect. Surface it so operators aren't
568
+ // surprised by a second webhook/deploy. Only when a prior DONE state
569
+ // existed (the resume signal) and this run actually re-ran (status done).
570
+ if (prior?.status === "done" && ps.status === "done" && !ps.cacheHit) {
571
+ ps.warnings = [...(ps.warnings ?? []), "idempotent:false phase re-executed on resume (a prior attempt had completed) — its side effect fired again"];
572
+ }
573
+ }
574
+ return ps;
575
+ };
553
576
  // Non-keyword cwd (or none): no workspace lifecycle — run directly.
554
577
  if (!isWorkspaceKeyword(phase.cwd)) {
555
- return executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts);
578
+ return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts));
556
579
  }
557
580
  let ws;
558
581
  try {
@@ -574,7 +597,7 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
574
597
  const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
575
598
  ps.warnings = [...(ps.warnings ?? []), msg];
576
599
  }
577
- return ps;
600
+ return stamp(ps);
578
601
  }
579
602
  finally {
580
603
  try {
@@ -648,13 +671,21 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
648
671
  // each run (schema already rejects explicit cross-run, but the default-scope
649
672
  // path must also be blocked). If flowDefHash failed, cross-run is unsafe
650
673
  // because the key degrades to flowName-only and reopens cross-flow collisions.
651
- const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament"]);
674
+ const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]);
652
675
  if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
653
676
  cacheScope = "run-only";
654
677
  }
655
678
  if (state.flowDefHash === "failed" && cacheScope === "cross-run") {
656
679
  cacheScope = "run-only";
657
680
  }
681
+ // Side-effect classification: a non-idempotent phase is NEVER cached — not
682
+ // served from within-run resume, not served from or written to the cross-run
683
+ // store (cachedPhase and recordCache both gate on scope). One assignment
684
+ // covers every cache path, including the map per-item path (perItemCacheable
685
+ // requires scope === "cross-run").
686
+ if (phase.idempotent === false) {
687
+ cacheScope = "off";
688
+ }
658
689
  const cc = {
659
690
  scope: cacheScope,
660
691
  ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
@@ -671,12 +702,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
671
702
  tools: phase.tools,
672
703
  preRead,
673
704
  };
674
- const baseRun = (agentName, task, onLive, ctxNodeId) => run(effCwd, deps.agents, agentName, task, {
705
+ const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
675
706
  model: phase.model,
676
707
  thinking: phase.thinking,
677
708
  tools: phase.tools,
678
709
  cwd: effCwd,
679
- signal: deps.signal,
710
+ signal: signal ?? deps.signal,
680
711
  onLive,
681
712
  ctxDir: ctxDir,
682
713
  nodeId: ctxDir ? ctxNodeId : undefined,
@@ -693,7 +724,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
693
724
  const DEFAULT_TRANSIENT_RETRIES = 3;
694
725
  const DEFAULT_TRANSIENT_BACKOFF_MS = 2000;
695
726
  const DEFAULT_TRANSIENT_FACTOR = 2;
696
- const runOne = async (agentName, task, onLive, ctxNodeId) => {
727
+ // Per-phase timeout: caps EACH subagent call of an agent-running phase.
728
+ // (script phases enforce their own child-process timeout — see the script branch.)
729
+ const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
730
+ ? phase.timeout
731
+ : undefined;
732
+ const runOne = async (agentName, task, onLive, ctxNodeId, check) => {
697
733
  const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
698
734
  // Allow enough attempts to cover whichever policy applies on a given attempt.
699
735
  const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
@@ -702,8 +738,65 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
702
738
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
703
739
  if (deps.signal?.aborted)
704
740
  break;
705
- last = await baseRun(agentName, task, onLive, ctxNodeId);
741
+ // Phase timeout — an AbortController chained to the run's signal that also
742
+ // fires after `phase.timeout` ms. Deterministic: a timed-out call is never
743
+ // retried (retrying a call that just burned its full cap would double-spend).
744
+ let timedOut = false;
745
+ let timer;
746
+ let onParentAbort;
747
+ let callSignal;
748
+ if (phaseTimeoutMs) {
749
+ const ac = new AbortController();
750
+ callSignal = ac.signal;
751
+ if (deps.signal?.aborted)
752
+ ac.abort();
753
+ else if (deps.signal) {
754
+ onParentAbort = () => ac.abort();
755
+ deps.signal.addEventListener("abort", onParentAbort, { once: true });
756
+ }
757
+ timer = setTimeout(() => {
758
+ timedOut = true;
759
+ ac.abort();
760
+ }, phaseTimeoutMs);
761
+ }
762
+ try {
763
+ last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal);
764
+ }
765
+ finally {
766
+ if (timer)
767
+ clearTimeout(timer);
768
+ if (onParentAbort)
769
+ deps.signal?.removeEventListener("abort", onParentAbort);
770
+ }
771
+ if (timedOut) {
772
+ // Reclassify the abort as a phase timeout: a distinct, deterministic
773
+ // failure (stopReason "error" keeps it in the failed bucket; the
774
+ // phaseTimeout marker excludes it from transient retry).
775
+ last = {
776
+ ...last,
777
+ exitCode: last.exitCode === 0 ? 1 : last.exitCode,
778
+ stopReason: "error",
779
+ errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
780
+ phaseTimeout: true,
781
+ };
782
+ usages.push(last.usage);
783
+ break;
784
+ }
706
785
  usages.push(last.usage);
786
+ // Output contract (`expect`): a successful attempt whose JSON output
787
+ // violates the declared contract is a failure — eligible for the phase's
788
+ // explicit retry policy (never the transient fallback).
789
+ if (check && !isFailed(last)) {
790
+ const violations = check(last);
791
+ if (violations.length > 0) {
792
+ last = {
793
+ ...last,
794
+ exitCode: last.exitCode === 0 ? 1 : last.exitCode,
795
+ stopReason: "error",
796
+ errorMessage: `Output contract violated:\n- ${violations.join("\n- ")}`,
797
+ };
798
+ }
799
+ }
707
800
  // B6: aggregate and surface cumulative usage before the retry decision,
708
801
  // so the TUI / budget guard see the in-flight spend on every attempt.
709
802
  const liveRetry = state.phases[phase.id];
@@ -720,7 +813,13 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
720
813
  // policy stops immediately (no point burning attempts on a hard error).
721
814
  const withinExplicit = attempt < explicitMax - 1;
722
815
  const transient = isTransientError(last);
723
- const withinTransient = transient && attempt < DEFAULT_TRANSIENT_RETRIES;
816
+ // Side-effect classification: the implicit transient retry is a runtime
817
+ // safety net the author did not ask for — repeating a side-effecting
818
+ // call behind the author's back is exactly the hazard idempotent:false
819
+ // exists to prevent. Explicit retry{} (withinExplicit) stays honored:
820
+ // it is the author's declaration that a repeat is acceptable.
821
+ const allowTransient = phase.idempotent !== false;
822
+ const withinTransient = transient && allowTransient && attempt < DEFAULT_TRANSIENT_RETRIES;
724
823
  if (!withinExplicit && !withinTransient)
725
824
  break;
726
825
  // Backoff: prefer the explicit policy's curve when the phase defines one
@@ -759,6 +858,17 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
759
858
  return last;
760
859
  };
761
860
  const parseJson = phase.output === "json";
861
+ // Output contract (`expect`): validates the parsed JSON output of a finished
862
+ // subagent call. Wired into `runOne` so a violation counts as a failed attempt
863
+ // (retryable under the phase's explicit `retry` policy).
864
+ const contractCheck = phase.expect !== undefined && parseJson
865
+ ? (r) => {
866
+ const parsed = safeParse(r.output);
867
+ if (parsed === undefined)
868
+ return ["$: output is not valid JSON (contract could not be checked)"];
869
+ return contractViolations(parsed, phase.expect);
870
+ }
871
+ : undefined;
762
872
  // Runs a list of sub-tasks with live fan-out progress + aggregate live usage/activity.
763
873
  // `perItem` (map only) enables per-item cross-run caching: each item is looked
764
874
  // up in the cache before spawning a subagent, and a successful fresh item is
@@ -949,6 +1059,216 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
949
1059
  return ps;
950
1060
  }
951
1061
  }
1062
+ // Scoring gate (`score`): deterministic scorers → zero-token auto-pass /
1063
+ // LLM judge / task fallback. Self-contained (including its own onBlock:retry
1064
+ // mirror) so the non-score gate path below stays byte-identical.
1065
+ const scoreRaw = type === "gate" ? phase.score : undefined;
1066
+ if (scoreRaw !== undefined) {
1067
+ const shapeErrs = scorerShapeErrors(scoreRaw);
1068
+ if (shapeErrs.length === 0) {
1069
+ const sc = scoreRaw;
1070
+ const combine = sc.combine ?? "all";
1071
+ const threshold = combine === "weighted" ? (sc.threshold ?? SCORE_DEFAULT_THRESHOLD) : undefined;
1072
+ const scoreId = JSON.stringify(scoreRaw);
1073
+ // One full score evaluation (deterministics → auto-pass | judge | task |
1074
+ // deterministic BLOCK). Re-invoked by the onBlock:retry loop, so it
1075
+ // rebuilds its interpolation context from the CURRENT state each call.
1076
+ const evaluateScore = async () => {
1077
+ const freshPrev = lastCompletedOutput(state, phase);
1078
+ const freshCtx = buildInterpolationContext(state, freshPrev, undefined, onRead);
1079
+ const tInterp = interpolate(sc.target ?? "{previous.output}", freshCtx);
1080
+ const targetResolved = tInterp.missing.length === 0;
1081
+ const target = tInterp.text;
1082
+ // Deterministic scorers — pure ones inline, code-compiles via the
1083
+ // impure runtime module. Skipped entirely when the target ref did not
1084
+ // resolve (scoring a literal placeholder would be noise).
1085
+ const results = [];
1086
+ if (targetResolved) {
1087
+ for (let i = 0; i < sc.scorers.length; i++) {
1088
+ const s = sc.scorers[i];
1089
+ results.push(s.type === "code-compiles"
1090
+ ? await runCodeCompilesScorer(s, i, target)
1091
+ : evaluatePureScorer(s, i, target));
1092
+ }
1093
+ }
1094
+ // Weighted + judge: the judge's weight enlarges the denominator so the
1095
+ // deterministic combination is a LOWER BOUND — clearing the threshold
1096
+ // without the judge means the judge could not change the outcome.
1097
+ const judgeWeight = combine === "weighted" && sc.judge ? (sc.weights?.[sc.scorers.length] ?? 1) : 0;
1098
+ const det = combineScores(results, combine, sc.weights, threshold ?? SCORE_DEFAULT_THRESHOLD, judgeWeight);
1099
+ // Auto-pass is only sound when the judge could not veto it:
1100
+ // - no judge configured → the deterministics ARE the decision;
1101
+ // - weighted + judge → det.passed used the judge-inflated denominator
1102
+ // (lower bound), so the judge's score cannot drop it below threshold.
1103
+ // all/any WITH a judge must NOT auto-skip: there the judge's verdict is
1104
+ // authoritative (it may check what scorers cannot — e.g. factuality) and
1105
+ // skipping it would silently bypass a configured quality check.
1106
+ const judgeCannotVeto = !sc.judge || combine === "weighted";
1107
+ if (targetResolved && det.passed && judgeCannotVeto) {
1108
+ // AUTO-PASS — zero LLM tokens (mirrors the eval-skip fast-path).
1109
+ const inputHash = cacheKeys(cc, [phase.id, "score-skip", scoreId, target]).key;
1110
+ const scores = { results, combined: det.combined, threshold };
1111
+ const ps = {
1112
+ id: phase.id,
1113
+ status: "done",
1114
+ output: "PASS (scorers passed — no LLM call)",
1115
+ gate: { verdict: "pass", scores },
1116
+ json: scoreResultJSON(results, det.combined, "pass", threshold),
1117
+ usage: emptyUsage(),
1118
+ inputHash,
1119
+ endedAt: Date.now(),
1120
+ };
1121
+ if (readRefs.length)
1122
+ ps.reads = readRefsToReads(readRefs, state);
1123
+ return ps;
1124
+ }
1125
+ const report = targetResolved
1126
+ ? formatScorerReport(results, det.combined, threshold)
1127
+ : `## Deterministic scorer report\n(scorers skipped — score.target did not resolve: ${tInterp.missing.join(", ")})`;
1128
+ // Judge fallback — the LLM-as-judge decides, with the target and the
1129
+ // deterministic report in evidence. Fail-open on unparseable output.
1130
+ if (sc.judge) {
1131
+ const judgeAgent = resolveAgent(sc.judge.agent ?? phase.agent, deps, state);
1132
+ const judgeText = interpolate(sc.judge.task, freshCtx).text;
1133
+ // Neutralize fences in the (model-produced) target so it cannot
1134
+ // close the evidence block and inject instructions at prompt level.
1135
+ const safeTarget = target.replace(/```/g, "`\u200b``");
1136
+ const fullJudgeTask = `${preRead}${judgeText}\n\n---\n\n## Target under evaluation\n\`\`\`\n${safeTarget}\n\`\`\`\n\n${report}\n\n` +
1137
+ `Return JSON {"score": 0.0-1.0, "verdict": "pass"|"block", "reason": "..."} (or end with VERDICT: PASS|BLOCK).`;
1138
+ const ckJ = cacheKeys(cc, [phase.id, judgeAgent, phase.model ?? "", fullJudgeTask, scoreId]);
1139
+ const inputHash = ckJ.key;
1140
+ const cachedJ = cachedPhase(cc, ckJ);
1141
+ if (cachedJ)
1142
+ return cachedJ;
1143
+ const r = await runOne(judgeAgent, fullJudgeTask, liveSink(state, phase.id, emitProgress), nodeIdFor("judge"));
1144
+ const ps = resultToPhaseState(phase.id, r, inputHash, false);
1145
+ if (ps.status === "done") {
1146
+ const judged = parseJudgeOutput(r.output);
1147
+ // Weighted: the judge's score folds into the combination and the
1148
+ // threshold decides. all/any: the judge's verdict is authoritative.
1149
+ const final = combine === "weighted"
1150
+ ? combineWithJudge(results, sc.weights, threshold ?? SCORE_DEFAULT_THRESHOLD, judged.score)
1151
+ : { combined: judged.score, passed: judged.verdict === "pass" };
1152
+ const verdict = final.passed ? "pass" : "block";
1153
+ ps.gate = { verdict, reason: judged.reason, scores: { results, combined: final.combined, threshold } };
1154
+ ps.json = scoreResultJSON(results, final.combined, verdict, threshold, { score: judged.score, reason: judged.reason });
1155
+ }
1156
+ if (readRefs.length)
1157
+ ps.reads = readRefsToReads(readRefs, state);
1158
+ return ps;
1159
+ }
1160
+ // Task fallback — the gate's own LLM task runs with the scorer report
1161
+ // appended, verdict parsed as usual.
1162
+ if (phase.task) {
1163
+ const agentName = resolveAgent(phase.agent, deps, state);
1164
+ const text = interpolate(phase.task, freshCtx).text;
1165
+ const fullTask = `${preRead}${text}\n\n---\n\n${report}`;
1166
+ const ckT = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask, scoreId]);
1167
+ const inputHash = ckT.key;
1168
+ const cachedT = cachedPhase(cc, ckT);
1169
+ if (cachedT)
1170
+ return cachedT;
1171
+ const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
1172
+ const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
1173
+ if (ps.status === "done") {
1174
+ const v = parseGateVerdict(r.output);
1175
+ ps.gate = { ...v, scores: { results, combined: det.combined, threshold } };
1176
+ ps.json = scoreResultJSON(results, det.combined, v.verdict, threshold);
1177
+ }
1178
+ if (readRefs.length)
1179
+ ps.reads = readRefsToReads(readRefs, state);
1180
+ return ps;
1181
+ }
1182
+ // No LLM fallback configured.
1183
+ if (!targetResolved) {
1184
+ // Unresolved target with no judge/task is AMBIGUITY, not an explicit
1185
+ // failure — fail-open PASS with a warning (project invariant).
1186
+ const inputHash = cacheKeys(cc, [phase.id, "score-unresolved", scoreId]).key;
1187
+ return {
1188
+ id: phase.id,
1189
+ status: "done",
1190
+ output: "PASS (score.target did not resolve — fail-open)",
1191
+ gate: { verdict: "pass", reason: `score.target unresolved: ${tInterp.missing.join(", ")}` },
1192
+ json: scoreResultJSON([], 0, "pass", threshold),
1193
+ warnings: [`gate score.target did not resolve (${tInterp.missing.join(", ")}) — fail-open PASS`],
1194
+ usage: emptyUsage(),
1195
+ inputHash,
1196
+ endedAt: Date.now(),
1197
+ };
1198
+ }
1199
+ // Deterministic explicit failure is NOT ambiguity: BLOCK.
1200
+ const inputHash = cacheKeys(cc, [phase.id, "score-block", scoreId, target]).key;
1201
+ const scores = { results, combined: det.combined, threshold };
1202
+ const ps = {
1203
+ id: phase.id,
1204
+ status: "done",
1205
+ output: `BLOCK (deterministic scorers failed — no LLM fallback)\n\n${report}`,
1206
+ gate: { verdict: "block", reason: "deterministic scorers below threshold", scores },
1207
+ json: scoreResultJSON(results, det.combined, "block", threshold),
1208
+ usage: emptyUsage(),
1209
+ inputHash,
1210
+ endedAt: Date.now(),
1211
+ };
1212
+ if (readRefs.length)
1213
+ ps.reads = readRefsToReads(readRefs, state);
1214
+ return ps;
1215
+ };
1216
+ let ps = await evaluateScore();
1217
+ // onBlock:retry — mirrors the non-score gate loop below (re-run upstream
1218
+ // deps, then RE-SCORE), sharing its depth cap and budget/abort guards.
1219
+ if (ps.gate?.verdict === "block") {
1220
+ const onBlockV = phase.onBlock ?? "halt";
1221
+ const MAX_RETRY_DEPTH = 3;
1222
+ let attempt = 0;
1223
+ while (onBlockV === "retry" && attempt < (phase.retry?.max ?? 1)) {
1224
+ if (deps.signal?.aborted || overBudget(state).over)
1225
+ break;
1226
+ attempt++;
1227
+ if (_retryDepth < MAX_RETRY_DEPTH) {
1228
+ const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
1229
+ for (const depId of phase.dependsOn ?? []) {
1230
+ const d = state.def.phases.find((p) => p.id === depId);
1231
+ if (!d)
1232
+ continue;
1233
+ const dPs = await executePhase(d, state, depsForUpstream, prior, emitProgress, _retryDepth + 1, undefined);
1234
+ state.phases[depId] = dPs;
1235
+ }
1236
+ }
1237
+ const prevAttempts = ps.attempts ?? 0;
1238
+ ps = await evaluateScore();
1239
+ ps.attempts = prevAttempts + (ps.attempts ?? 0);
1240
+ if (ps.gate?.verdict !== "block" || overBudget(state).over)
1241
+ break;
1242
+ }
1243
+ if (attempt > 0)
1244
+ ps.attempts = Math.max(ps.attempts ?? 0, attempt);
1245
+ }
1246
+ recordCache(cc, ps);
1247
+ return ps;
1248
+ }
1249
+ // Malformed score (validation reports it) — fail-open: fall through to
1250
+ // the plain LLM gate with a warning so an authoring slip degrades to the
1251
+ // historical behavior instead of crashing the phase.
1252
+ const scoreWarning = `gate 'score' is malformed and was ignored: ${shapeErrs[0]}`;
1253
+ const interpM = interpolate(phase.task ?? "", ctx);
1254
+ const textM = interpM.text;
1255
+ const refWarningM = warnUnresolvedRefs(phase.id, interpM.missing);
1256
+ const fullTaskM = preRead + textM;
1257
+ const agentNameM = resolveAgent(phase.agent, deps, state);
1258
+ const ckM = cacheKeys(cc, [phase.id, agentNameM, phase.model ?? "", fullTaskM]);
1259
+ const cachedM = cachedPhase(cc, ckM);
1260
+ if (cachedM)
1261
+ return cachedM;
1262
+ const rM = await runOne(agentNameM, fullTaskM, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
1263
+ const psM = resultToPhaseState(phase.id, rM, ckM.key, parseJson);
1264
+ if (readRefs.length)
1265
+ psM.reads = readRefsToReads(readRefs, state);
1266
+ psM.warnings = [...(psM.warnings ?? []), scoreWarning, ...(refWarningM ? [refWarningM] : [])];
1267
+ if (psM.status === "done")
1268
+ psM.gate = parseGateVerdict(rM.output);
1269
+ recordCache(cc, psM);
1270
+ return psM;
1271
+ }
952
1272
  const interp = interpolate(phase.task ?? "", ctx);
953
1273
  const text = interp.text;
954
1274
  const refWarning = warnUnresolvedRefs(phase.id, interp.missing);
@@ -959,7 +1279,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
959
1279
  const cached = cachedPhase(cc, ck);
960
1280
  if (cached)
961
1281
  return cached;
962
- const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor());
1282
+ const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
963
1283
  const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
964
1284
  if (readRefs.length)
965
1285
  ps.reads = readRefsToReads(readRefs, state);
@@ -1021,7 +1341,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1021
1341
  const retryText = interpolate(phase.task ?? "", retryCtx).text;
1022
1342
  const retryTask = preRead + retryText;
1023
1343
  const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key;
1024
- const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress));
1344
+ const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
1025
1345
  gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson);
1026
1346
  if (gatePs.status === "done")
1027
1347
  gatePs.gate = parseGateVerdict(retryR.output);
@@ -1035,6 +1355,157 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1035
1355
  recordCache(cc, ps);
1036
1356
  return ps;
1037
1357
  }
1358
+ // script — zero-token shell command. Spawns a child process, pipes
1359
+ // interpolated `input` to stdin, captures stdout as the phase output.
1360
+ if (type === "script") {
1361
+ const cmd = phase.run;
1362
+ if (!cmd) {
1363
+ return {
1364
+ id: phase.id,
1365
+ status: "failed",
1366
+ error: "script phase requires 'run'",
1367
+ endedAt: Date.now(),
1368
+ usage: emptyUsage(),
1369
+ };
1370
+ }
1371
+ // Interpolate the command.
1372
+ // Array form: interpolate each element (safe — schema rejects placeholders in string form).
1373
+ // String form: skip interpolation — schema already guarantees no {placeholders}.
1374
+ const interpRun = Array.isArray(cmd)
1375
+ ? cmd.map((s) => interpolate(s, ctx))
1376
+ : [{ text: cmd, missing: [] }];
1377
+ // Warn unresolved references.
1378
+ for (const r of interpRun) {
1379
+ if (r.missing.length)
1380
+ warnUnresolvedRefs(phase.id, r.missing);
1381
+ }
1382
+ const interpRunText = interpRun.map((r) => r.text);
1383
+ // Interpolate stdin input if provided.
1384
+ const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
1385
+ if (stdinInterp?.missing.length)
1386
+ warnUnresolvedRefs(phase.id, stdinInterp.missing);
1387
+ const stdinInput = stdinInterp?.text;
1388
+ const ck = cacheKeys(cc, [phase.id, JSON.stringify(interpRunText), stdinInput ?? ""]);
1389
+ const inputHash = ck.key;
1390
+ const cached = cachedPhase(cc, ck);
1391
+ if (cached)
1392
+ return cached;
1393
+ const MAX_STDOUT = 1_048_576; // 1 MB cap
1394
+ const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout
1395
+ const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
1396
+ try {
1397
+ const { spawn } = await import("node:child_process");
1398
+ const result = await new Promise((resolve, reject) => {
1399
+ // Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders).
1400
+ const child = Array.isArray(cmd)
1401
+ ? spawn(interpRunText[0], interpRunText.slice(1), {
1402
+ cwd: effCwd,
1403
+ env: process.env,
1404
+ shell: false,
1405
+ signal: deps.signal,
1406
+ })
1407
+ : spawn(interpRunText[0], [], {
1408
+ cwd: effCwd,
1409
+ env: process.env,
1410
+ shell: true,
1411
+ signal: deps.signal,
1412
+ });
1413
+ let stdout = "";
1414
+ let stderr = "";
1415
+ let stdoutOversize = false;
1416
+ let timedOut = false;
1417
+ child.stdout?.on("data", (d) => {
1418
+ if (stdout.length < MAX_STDOUT) {
1419
+ const need = MAX_STDOUT - stdout.length;
1420
+ stdout += d.toString().slice(0, need);
1421
+ if (stdout.length >= MAX_STDOUT)
1422
+ stdoutOversize = true;
1423
+ }
1424
+ });
1425
+ child.stderr?.on("data", (d) => {
1426
+ if (stderr.length < 500) {
1427
+ stderr += d.toString().slice(0, 500 - stderr.length);
1428
+ }
1429
+ });
1430
+ // Timeout guard: SIGTERM first, then SIGKILL after grace period.
1431
+ let sigkillTimer;
1432
+ const timer = setTimeout(() => {
1433
+ timedOut = true;
1434
+ child.kill("SIGTERM");
1435
+ // SIGKILL fallback if process ignores SIGTERM.
1436
+ sigkillTimer = setTimeout(() => {
1437
+ try {
1438
+ child.kill("SIGKILL");
1439
+ }
1440
+ catch { /* already dead */ }
1441
+ }, SIGKILL_GRACE_MS);
1442
+ }, SCRIPT_TIMEOUT_MS);
1443
+ child.on("error", (err) => {
1444
+ clearTimeout(timer);
1445
+ clearTimeout(sigkillTimer);
1446
+ reject(err);
1447
+ });
1448
+ child.on("close", (code) => {
1449
+ clearTimeout(timer);
1450
+ clearTimeout(sigkillTimer);
1451
+ resolve({ stdout, stderr, code, stdoutOversize, timedOut });
1452
+ });
1453
+ if (stdinInput !== undefined) {
1454
+ child.stdin?.on("error", () => { }); // swallow EPIPE when child closes stdin early
1455
+ child.stdin?.write(stdinInput);
1456
+ child.stdin?.end();
1457
+ }
1458
+ });
1459
+ if (result.code !== 0 || result.timedOut) {
1460
+ const ps = {
1461
+ id: phase.id,
1462
+ status: "failed",
1463
+ output: result.stdout,
1464
+ error: result.timedOut
1465
+ ? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms`
1466
+ : `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`,
1467
+ timedOut: result.timedOut || undefined,
1468
+ usage: emptyUsage(),
1469
+ inputHash,
1470
+ endedAt: Date.now(),
1471
+ };
1472
+ if (readRefs.length)
1473
+ ps.reads = readRefsToReads(readRefs, state);
1474
+ // Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors).
1475
+ if (!result.timedOut)
1476
+ recordCache(cc, ps);
1477
+ return ps;
1478
+ }
1479
+ const ps = {
1480
+ id: phase.id,
1481
+ status: "done",
1482
+ output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""),
1483
+ usage: emptyUsage(),
1484
+ inputHash,
1485
+ endedAt: Date.now(),
1486
+ };
1487
+ if (readRefs.length)
1488
+ ps.reads = readRefsToReads(readRefs, state);
1489
+ recordCache(cc, ps);
1490
+ return ps;
1491
+ }
1492
+ catch (err) {
1493
+ const msg = err instanceof Error ? err.message : String(err);
1494
+ // Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.)
1495
+ // are treated as transient. The phase will re-execute on resume/retry.
1496
+ const ps = {
1497
+ id: phase.id,
1498
+ status: "failed",
1499
+ error: `Script error: ${msg}`,
1500
+ usage: emptyUsage(),
1501
+ inputHash,
1502
+ endedAt: Date.now(),
1503
+ };
1504
+ if (readRefs.length)
1505
+ ps.reads = readRefsToReads(readRefs, state);
1506
+ return ps;
1507
+ }
1508
+ }
1038
1509
  if (type === "parallel") {
1039
1510
  const branches = (phase.branches ?? []).map((b) => {
1040
1511
  const r = interpolate(b.task, ctx);
@@ -1390,14 +1861,17 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1390
1861
  const rawMax = phase.maxIterations ?? LOOP_DEFAULT_MAX_ITERATIONS;
1391
1862
  const maxIters = Math.max(1, Math.min(LOOP_HARD_MAX_ITERATIONS, Math.floor(rawMax)));
1392
1863
  const convergence = phase.convergence ?? true;
1864
+ const reflexionOn = phase.reflexion === true;
1393
1865
  // Canonical first-iteration body for the cache key. It must fold in the
1394
1866
  // interpolated task/upstream refs so that a changed upstream changes the
1395
1867
  // key and recompute no longer silently reuses a stale loop (critic finding).
1868
+ // Reflexion loops resolve {reflexion} to the SENTINEL here so the key
1869
+ // reflects the true first prompt (not a literal placeholder).
1396
1870
  const firstBodyCtx = buildInterpolationContext(state, previousOutput, {
1397
1871
  loop: { iteration: 1, lastOutput: "", maxIterations: maxIters },
1398
- }, (ref) => readRefs.push(ref));
1872
+ }, (ref) => readRefs.push(ref), reflexionOn ? REFLEXION_SENTINEL : undefined);
1399
1873
  const firstBody = preRead + interpolate(phase.task ?? "", firstBodyCtx).text;
1400
- const inputHash = hashInput(phase.id, "loop", phase.until ?? "", firstBody, String(maxIters));
1874
+ const inputHash = hashInput(phase.id, "loop", phase.until ?? "", firstBody, String(maxIters), reflexionOn ? "reflexion" : "");
1401
1875
  const usages = [];
1402
1876
  const loopWarnings = [];
1403
1877
  let lastOutput = "";
@@ -1405,24 +1879,103 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1405
1879
  let iterations = 0;
1406
1880
  let stop = "maxIterations";
1407
1881
  let failedResult;
1882
+ // Bounded history of failed iterations (issue #17): when a reflexion loop
1883
+ // continues past failures, only the terminal one survives in `error` — keep
1884
+ // the rest for post-hoc debugging. Capped to avoid unbounded state growth.
1885
+ const LOOP_FAILURE_HISTORY_CAP = 20;
1886
+ const loopFailures = [];
1887
+ const recordLoopFailure = (iteration, r) => {
1888
+ const err = isContractViolation(r.errorMessage)
1889
+ ? r.errorMessage
1890
+ : sanitizeErrorMessage(r.errorMessage || r.stderr || "") || `iteration ${iteration} failed`;
1891
+ loopFailures.push({ iteration, error: err });
1892
+ if (loopFailures.length > LOOP_FAILURE_HISTORY_CAP)
1893
+ loopFailures.shift();
1894
+ };
1895
+ // Reflexion state: what the NEXT iteration should be told about THIS one.
1896
+ let reflexionNext;
1897
+ let lastReflexion;
1898
+ let reflexionAppendWarned = false;
1899
+ // With reflexion on, a failed iteration continues (as feedback) instead of
1900
+ // terminating — track whether the LAST iteration failed so an exhausted
1901
+ // loop still fails (reflexion defers failure, it does not erase it).
1902
+ let lastIterationFailed = false;
1408
1903
  for (let i = 1; i <= maxIters; i++) {
1409
1904
  if (deps.signal?.aborted) {
1410
1905
  stop = "aborted";
1411
1906
  break;
1412
1907
  }
1413
1908
  iterations = i;
1909
+ // Assemble the reflexion summary for this iteration (fail-open: a
1910
+ // reflexion assembly bug must never sink the phase).
1911
+ let reflexionStr;
1912
+ if (reflexionOn) {
1913
+ if (i === 1 || !reflexionNext) {
1914
+ reflexionStr = REFLEXION_SENTINEL;
1915
+ }
1916
+ else {
1917
+ try {
1918
+ reflexionStr = buildReflexionSummary(reflexionNext);
1919
+ }
1920
+ catch (e) {
1921
+ reflexionStr = REFLEXION_SENTINEL;
1922
+ loopWarnings.push(`reflexion summary failed to assemble (iteration ${i}): ${e instanceof Error ? e.message : String(e)}`);
1923
+ }
1924
+ lastReflexion = reflexionStr;
1925
+ }
1926
+ }
1414
1927
  // The body sees its iteration number and the prior iteration's output.
1415
1928
  const bodyCtx = buildInterpolationContext(state, previousOutput, {
1416
1929
  loop: { iteration: i, lastOutput, maxIterations: maxIters },
1417
- }, (ref) => readRefs.push(ref));
1418
- const body = preRead + interpolate(phase.task ?? "", bodyCtx).text;
1419
- const r = await runOne(agentName, body, liveSink(state, phase.id, emitProgress));
1930
+ }, (ref) => readRefs.push(ref), reflexionStr);
1931
+ let body = preRead + interpolate(phase.task ?? "", bodyCtx).text;
1932
+ // Auto-append: reflexion is on but the task never mentions {reflexion} —
1933
+ // inject the summary anyway (opt-in via the flag IS the author's intent)
1934
+ // and tell them once how to control placement.
1935
+ if (reflexionOn && reflexionStr && reflexionStr !== REFLEXION_SENTINEL && !(phase.task ?? "").includes("{reflexion}")) {
1936
+ body = `${body}\n\n---\n\n${reflexionStr}`;
1937
+ if (!reflexionAppendWarned) {
1938
+ reflexionAppendWarned = true;
1939
+ loopWarnings.push("reflexion: true but the task has no {reflexion} placeholder — the summary was auto-appended; add {reflexion} to control placement");
1940
+ }
1941
+ }
1942
+ const r = await runOne(agentName, body, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
1420
1943
  usages.push(r.usage);
1944
+ // Fold cumulative loop spend into the live phase state so the run-level
1945
+ // budget guard (overBudget reads state.phases[*].usage) sees the loop's
1946
+ // accrual mid-phase — otherwise each iteration would overwrite the last
1947
+ // and a reflexion loop could spend past the ceiling unnoticed.
1948
+ const livePs = state.phases[phase.id];
1949
+ if (livePs)
1950
+ livePs.usage = aggregateUsage(usages);
1421
1951
  if (isFailed(r)) {
1952
+ // Reflexion mode: a body failure becomes feedback for the next
1953
+ // iteration instead of terminating the loop. Timeout, abort, and an
1954
+ // exhausted budget still hard-stop (consistent with "timedOut is never
1955
+ // retried"; continuing past the budget would spend past the ceiling).
1956
+ const hardStop = !reflexionOn || r.phaseTimeout === true || deps.signal?.aborted === true || overBudget(state).over;
1957
+ if (hardStop) {
1958
+ failedResult = r;
1959
+ stop = "failed";
1960
+ recordLoopFailure(i, r);
1961
+ break;
1962
+ }
1422
1963
  failedResult = r;
1423
- stop = "failed";
1424
- break;
1964
+ lastIterationFailed = true;
1965
+ recordLoopFailure(i, r);
1966
+ // Sanitize before injecting into the next prompt: raw provider errors
1967
+ // can carry HTML/transport noise (same policy as the transcript path).
1968
+ const rawErr = r.errorMessage || r.stderr || undefined;
1969
+ reflexionNext = {
1970
+ iteration: i,
1971
+ outcome: isContractViolation(r.errorMessage) ? "contract-violation" : "subagent-error",
1972
+ output: r.output,
1973
+ errorMessage: isContractViolation(r.errorMessage) ? r.errorMessage : rawErr ? sanitizeErrorMessage(rawErr) : undefined,
1974
+ };
1975
+ continue;
1425
1976
  }
1977
+ lastIterationFailed = false;
1978
+ failedResult = undefined;
1426
1979
  prevOutput = lastOutput;
1427
1980
  lastOutput = r.output;
1428
1981
  // Expose this iteration's output as {steps.<thisId>.output|json} so the
@@ -1449,16 +2002,28 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1449
2002
  stop = "converged";
1450
2003
  break;
1451
2004
  }
2005
+ // Succeeded but not done — the next iteration reflects on the unmet
2006
+ // stop condition (until-not-met is a signal too, not just failures).
2007
+ if (reflexionOn) {
2008
+ reflexionNext = { iteration: i, outcome: "until-not-met", output: lastOutput, until: phase.until };
2009
+ }
1452
2010
  }
1453
2011
  const aggUsage = usages.length ? aggregateUsage(usages) : emptyUsage();
1454
- if (failedResult || stop === "failed" || stop === "aborted") {
2012
+ // Reflexion: an exhausted loop whose LAST iteration failed is a failure —
2013
+ // reflexion defers termination for feedback, it does not convert a failing
2014
+ // loop into a success.
2015
+ if (reflexionOn && stop === "maxIterations" && lastIterationFailed) {
2016
+ stop = "failed";
2017
+ }
2018
+ if (stop === "failed" || stop === "aborted") {
1455
2019
  return {
1456
2020
  id: phase.id,
1457
2021
  status: "failed",
1458
2022
  output: lastOutput || undefined,
1459
2023
  usage: aggUsage,
2024
+ timedOut: failedResult?.phaseTimeout || undefined,
1460
2025
  error: failedResult?.errorMessage || failedResult?.stderr || (stop === "aborted" ? "Aborted" : `loop '${phase.id}' iteration ${iterations} failed`),
1461
- loop: { iterations, stop },
2026
+ loop: { iterations, stop, ...(lastReflexion ? { reflexion: lastReflexion } : {}), ...(loopFailures.length ? { failures: [...loopFailures] } : {}) },
1462
2027
  warnings: loopWarnings.length ? loopWarnings : undefined,
1463
2028
  inputHash,
1464
2029
  reads: readRefsToReads(readRefs, state),
@@ -1471,7 +2036,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1471
2036
  output: lastOutput,
1472
2037
  json: parseJson ? safeParse(lastOutput) : undefined,
1473
2038
  usage: aggUsage,
1474
- loop: { iterations, stop },
2039
+ loop: { iterations, stop, ...(lastReflexion ? { reflexion: lastReflexion } : {}), ...(loopFailures.length ? { failures: [...loopFailures] } : {}) },
1475
2040
  warnings: loopWarnings.length ? loopWarnings : undefined,
1476
2041
  inputHash,
1477
2042
  reads: readRefsToReads(readRefs, state),
@@ -1518,6 +2083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1518
2083
  status: "failed",
1519
2084
  usage: variantUsage,
1520
2085
  error: `tournament '${phase.id}': all ${competitors.length} variants failed`,
2086
+ timedOut: ran.some((r) => r.phaseTimeout) || undefined,
1521
2087
  budgetTruncated: budgetSkipCount > 0 || undefined,
1522
2088
  tournament: { variants: competitors.length, winner: 0, mode },
1523
2089
  inputHash,