pi-cohort 7.2.3 → 7.3.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.
@@ -33,7 +33,7 @@ import {
33
33
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
34
34
  import { runSync } from "./execution.ts";
35
35
  import type { PiRuntimeIdentity } from "../shared/pi-spawn.ts";
36
- import { buildChainSummary } from "../../shared/formatters.ts";
36
+ import { buildChainSummary, buildChainOutputsSection, buildChainStopSection, type ChainSummaryOptions } from "../../shared/formatters.ts";
37
37
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, resolveChildCwd } from "../../shared/utils.ts";
38
38
  import { recordRun } from "../shared/run-history.ts";
39
39
  import {
@@ -44,6 +44,8 @@ import {
44
44
  formatWorktreeDiffSummary,
45
45
  formatWorktreeTaskCwdConflict,
46
46
  type WorktreeSetup,
47
+ type WorktreeDiff,
48
+ patchPathForDiff,
47
49
  } from "../shared/worktree.ts";
48
50
  import {
49
51
  type ActivityState,
@@ -66,7 +68,8 @@ import { ChainOutputValidationError, outputEntryFromResult, resolveOutputReferen
66
68
  import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
67
69
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection, type DynamicCollectedResult } from "../shared/dynamic-fanout.ts";
68
70
  import { acceptanceFailureMessage, aggregateAcceptanceReport, evaluateAcceptance, resolveEffectiveAcceptance } from "../shared/acceptance.ts";
69
- import type { ChainOutputMap } from "../../shared/types.ts";
71
+ import { buildStopRecord, evaluateStopCondition } from "../shared/stop-conditions.ts";
72
+ import type { ChainOutputMap, ChainTaskOutputEntry, StopRecord } from "../../shared/types.ts";
70
73
 
71
74
  interface ChainExecutionDetailsInput {
72
75
  results: SingleResult[];
@@ -80,6 +83,7 @@ interface ChainExecutionDetailsInput {
80
83
  currentStepIndex?: number;
81
84
  runId: string;
82
85
  outputs?: ChainOutputMap;
86
+ stop?: StopRecord;
83
87
  currentFlatIndex?: number;
84
88
  dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
85
89
  dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused"; error?: string; acceptance?: SingleResult["acceptance"] }>;
@@ -147,7 +151,8 @@ function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details
147
151
  totalSteps: input.totalSteps,
148
152
  currentStepIndex: input.currentStepIndex,
149
153
  outputs: input.outputs,
150
- workflowGraph: buildWorkflowGraphSnapshot({
154
+ stop: input.stop,
155
+ workflowGraph: buildWorkflowGraphSnapshot({
151
156
  runId: input.runId,
152
157
  mode: "chain",
153
158
  steps: input.chainSteps,
@@ -160,9 +165,12 @@ function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details
160
165
  });
161
166
  }
162
167
 
163
- function buildChainExecutionErrorResult(message: string, input: ChainExecutionDetailsInput): ChainExecutionResult {
168
+ function buildChainExecutionErrorResult(message: string, input: ChainExecutionDetailsInput, summaryOptions?: ChainSummaryOptions): ChainExecutionResult {
169
+ const sections: string[] = [];
170
+ if (summaryOptions?.stop) sections.push(buildChainStopSection(summaryOptions.stop));
171
+ if (summaryOptions?.outputs) sections.push(buildChainOutputsSection(summaryOptions.outputs));
164
172
  return {
165
- content: [{ type: "text", text: message }],
173
+ content: [{ type: "text", text: sections.length ? `${message}\n\n${sections.join("\n\n")}\n` : message }],
166
174
  isError: true,
167
175
  details: buildChainExecutionDetails(input),
168
176
  };
@@ -180,17 +188,21 @@ function ensureParallelProgressFile(
180
188
  return true;
181
189
  }
182
190
 
183
- function appendParallelWorktreeSummary(
184
- output: string,
185
- worktreeSetup: WorktreeSetup | undefined,
186
- diffsDir: string,
187
- agents: string[],
188
- ): string {
189
- if (!worktreeSetup) return output;
190
- const diffs = diffWorktrees(worktreeSetup, agents, diffsDir);
191
- const diffSummary = formatWorktreeDiffSummary(diffs);
192
- if (!diffSummary) return output;
193
- return `${output}\n\n${diffSummary}`;
191
+ function captureParallelWorktreeDiffs(worktreeSetup: WorktreeSetup | undefined, diffsDir: string, agents: string[]): WorktreeDiff[] {
192
+ return worktreeSetup ? diffWorktrees(worktreeSetup, agents, diffsDir) : [];
193
+ }
194
+
195
+ function appendParallelWorktreeSummary(output: string, diffs: WorktreeDiff[]): string {
196
+ if (diffs.length === 0) return output;
197
+ const summary = formatWorktreeDiffSummary(diffs);
198
+ return summary ? `${output}\n\n${summary}` : output;
199
+ }
200
+
201
+ function patchInfoForDiff(diff: WorktreeDiff | undefined): { patchPath?: string; patchError?: string } | undefined {
202
+ if (!diff) return undefined;
203
+ if (diff.captureError) return { patchError: diff.captureError };
204
+ const patchPath = patchPathForDiff(diff);
205
+ return patchPath ? { patchPath } : undefined;
194
206
  }
195
207
 
196
208
  async function runParallelChainTasks(input: ParallelChainRunInput): Promise<SingleResult[]> {
@@ -223,7 +235,22 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
223
235
  templateHasPrevious ? undefined : input.prev,
224
236
  );
225
237
 
226
- let taskStr = resolveOutputReferences(taskTemplate, input.outputs);
238
+ let taskStr: string;
239
+ try {
240
+ taskStr = resolveOutputReferences(taskTemplate, input.outputs);
241
+ } catch (error) {
242
+ if (!(error instanceof ChainOutputValidationError)) throw error;
243
+ if (failFast) aborted = true;
244
+ return {
245
+ agent: task.agent,
246
+ task: taskTemplate,
247
+ exitCode: 1,
248
+ finalOutput: error.message,
249
+ error: error.message,
250
+ messages: [],
251
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
252
+ } as SingleResult;
253
+ }
227
254
  taskStr = taskStr.replace(/\{task\}/g, input.originalTask);
228
255
  taskStr = taskStr.replace(/\{previous\}/g, input.prev);
229
256
  taskStr = taskStr.replace(/\{chain_dir\}/g, input.chainDir);
@@ -432,6 +459,21 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
432
459
 
433
460
  const results: SingleResult[] = [];
434
461
  const outputs: ChainOutputMap = {};
462
+ const chainHasStopOn = chainSteps.some((step) => step.stopOn !== undefined);
463
+ const outputLog: ChainTaskOutputEntry[] = [];
464
+ let stopRecord: StopRecord | undefined;
465
+ const summaryOptions = (): ChainSummaryOptions | undefined =>
466
+ chainHasStopOn ? { ...(stopRecord ? { stop: stopRecord } : {}), outputs: outputLog } : undefined;
467
+ const failureSummaryOptions = (): ChainSummaryOptions | undefined => stopRecord ? summaryOptions() : undefined;
468
+ const logTaskOutput = (stepIndex: number, taskIndex: number, result: SingleResult, as?: string, label?: string) => {
469
+ outputLog.push({
470
+ stepIndex, taskIndex, agent: result.agent,
471
+ ...(as ? { as } : {}), ...(label ? { label } : {}),
472
+ text: getSingleResultOutput(result),
473
+ ...(result.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
474
+ exitCode: result.exitCode, ...(result.error ? { error: result.error } : {}),
475
+ });
476
+ };
435
477
  const dynamicChildren: ChainExecutionDetailsInput["dynamicChildren"] = {};
436
478
  const dynamicGroupStatuses: ChainExecutionDetailsInput["dynamicGroupStatuses"] = {};
437
479
  const allProgress: AgentProgress[] = [];
@@ -459,6 +501,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
459
501
  outputs,
460
502
  dynamicChildren,
461
503
  dynamicGroupStatuses,
504
+ stop: stopRecord,
462
505
  ...overrides,
463
506
  });
464
507
 
@@ -602,6 +645,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
602
645
  return buildChainExecutionErrorResult(
603
646
  `parallel chain step ${stepIndex + 1}: ${formatWorktreeTaskCwdConflict(worktreeTaskCwdConflict, parallelCwd)}`,
604
647
  makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }),
648
+ failureSummaryOptions(),
605
649
  );
606
650
  }
607
651
  try {
@@ -613,7 +657,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
613
657
  });
614
658
  } catch (error) {
615
659
  const message = error instanceof Error ? error.message : String(error);
616
- return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
660
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
617
661
  }
618
662
  }
619
663
 
@@ -627,7 +671,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
627
671
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
628
672
  : undefined;
629
673
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Parallel chain step ${stepIndex + 1} task ${taskIndex + 1} (${step.parallel[taskIndex]!.agent})`);
630
- if (validationError) return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
674
+ if (validationError) return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }), failureSummaryOptions());
631
675
  }
632
676
  progressCreated = ensureParallelProgressFile(chainDir, progressCreated, parallelBehaviors);
633
677
  createParallelDirs(chainDir, stepIndex, step.parallel.length, agentNames);
@@ -689,6 +733,19 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
689
733
  };
690
734
  }
691
735
 
736
+ const diffs = captureParallelWorktreeDiffs(worktreeSetup, path.join(chainDir, "worktree-diffs", `step-${stepIndex}`), agentNames);
737
+ for (let taskIndex = 0; taskIndex < parallelResults.length; taskIndex++) {
738
+ const result = parallelResults[taskIndex]!;
739
+ const task = step.parallel[taskIndex];
740
+ logTaskOutput(stepIndex, taskIndex, result, task?.as, task?.label);
741
+ if (task?.as && result.exitCode === 0) outputs[task.as] = outputEntryFromResult(result, stepIndex, patchInfoForDiff(diffs[taskIndex]));
742
+ }
743
+ if (step.stopOn) {
744
+ const matches = evaluateStopCondition(step.stopOn, stepIndex, parallelResults.map((result, i) => ({
745
+ agent: result.agent, as: step.parallel[i]?.as, label: step.parallel[i]?.label, exitCode: result.exitCode, structuredOutput: result.structuredOutput,
746
+ })));
747
+ if (matches.length > 0) stopRecord = buildStopRecord(step.stopOn, stepIndex, matches);
748
+ }
692
749
  const failures = parallelResults
693
750
  .map((result, originalIndex) => ({ ...result, originalIndex }))
694
751
  .filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
@@ -700,7 +757,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
700
757
  const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
701
758
  index: stepIndex,
702
759
  error: errorMsg,
703
- });
760
+ }, failureSummaryOptions());
704
761
  return {
705
762
  content: [{ type: "text", text: summary }],
706
763
  isError: true,
@@ -711,11 +768,6 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
711
768
  };
712
769
  }
713
770
 
714
- for (let taskIndex = 0; taskIndex < parallelResults.length; taskIndex++) {
715
- const outputName = step.parallel[taskIndex]?.as;
716
- if (outputName) outputs[outputName] = outputEntryFromResult(parallelResults[taskIndex]!, stepIndex);
717
- }
718
-
719
771
  const taskResults: ParallelTaskResult[] = parallelResults.map((result, i) => {
720
772
  const outputTarget = parallelBehaviors[i]?.output;
721
773
  const outputTargetPath = typeof outputTarget === "string"
@@ -732,12 +784,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
732
784
  };
733
785
  });
734
786
  prev = aggregateParallelOutputs(taskResults);
735
- prev = appendParallelWorktreeSummary(
736
- prev,
737
- worktreeSetup,
738
- path.join(chainDir, "worktree-diffs", `step-${stepIndex}`),
739
- agentNames,
740
- );
787
+ prev = appendParallelWorktreeSummary(prev, diffs);
788
+ if (stopRecord) {
789
+ return {
790
+ content: [{ type: "text", text: buildChainSummary(chainSteps, results, chainDir, "stopped", undefined, summaryOptions()) }],
791
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex })),
792
+ };
793
+ }
794
+
741
795
  } finally {
742
796
  if (worktreeSetup) cleanupWorktrees(worktreeSetup);
743
797
  }
@@ -748,7 +802,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
748
802
  } catch (error) {
749
803
  const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
750
804
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
751
- return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
805
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
752
806
  }
753
807
 
754
808
  dynamicChildren[stepIndex] = materialized.items.map((item, itemIndex) => ({
@@ -761,19 +815,19 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
761
815
 
762
816
  if (materialized.parallel.length === 0) {
763
817
  const collection: DynamicCollectedResult[] = [];
764
- try {
765
- validateDynamicCollection(step.collect.outputSchema, collection);
766
- } catch (error) {
767
- const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
768
- dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
769
- return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
770
- }
771
818
  outputs[step.collect.as] = {
772
819
  text: JSON.stringify(collection),
773
820
  structured: collection,
774
821
  agent: step.parallel.agent,
775
822
  stepIndex,
776
823
  };
824
+ try {
825
+ validateDynamicCollection(step.collect.outputSchema, collection);
826
+ } catch (error) {
827
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
828
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
829
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
830
+ }
777
831
  dynamicGroupStatuses[stepIndex] = { status: "completed" };
778
832
  if (step.acceptance !== undefined) {
779
833
  const effectiveGroupAcceptance = resolveEffectiveAcceptance({
@@ -796,7 +850,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
796
850
  const groupAcceptanceFailure = acceptanceFailureMessage(groupAcceptance);
797
851
  if (groupAcceptanceFailure) {
798
852
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: groupAcceptanceFailure, acceptance: groupAcceptance };
799
- return buildChainExecutionErrorResult(groupAcceptanceFailure, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
853
+ return buildChainExecutionErrorResult(groupAcceptanceFailure, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
800
854
  }
801
855
  }
802
856
  prev = "Dynamic fanout produced 0 results.";
@@ -820,7 +874,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
820
874
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Dynamic chain step ${stepIndex + 1} item ${taskIndex + 1} (${dynamicParallelStep.parallel[taskIndex]!.agent})`);
821
875
  if (validationError) {
822
876
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: validationError };
823
- return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
877
+ return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }), failureSummaryOptions());
824
878
  }
825
879
  }
826
880
 
@@ -882,6 +936,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
882
936
  })),
883
937
  };
884
938
  }
939
+ for (let taskIndex = 0; taskIndex < parallelResults.length; taskIndex++) {
940
+ const result = parallelResults[taskIndex]!;
941
+ const task = materialized.parallel[taskIndex];
942
+ logTaskOutput(stepIndex, taskIndex, result, undefined, task?.label);
943
+ }
944
+ if (step.stopOn) {
945
+ const matches = evaluateStopCondition(step.stopOn, stepIndex, parallelResults.map((result, i) => ({
946
+ agent: result.agent, label: materialized.parallel[i]?.label, exitCode: result.exitCode, structuredOutput: result.structuredOutput,
947
+ })));
948
+ if (matches.length > 0) stopRecord = buildStopRecord(step.stopOn, stepIndex, matches);
949
+ }
950
+ outputs[step.collect.as] = {
951
+ text: JSON.stringify(collected),
952
+ structured: collected,
953
+ agent: step.parallel.agent,
954
+ stepIndex,
955
+ };
885
956
  const failures = parallelResults
886
957
  .map((result, originalIndex) => ({ ...result, originalIndex }))
887
958
  .filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
@@ -894,7 +965,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
894
965
  const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
895
966
  index: stepIndex,
896
967
  error: errorMsg,
897
- });
968
+ }, failureSummaryOptions());
898
969
  return {
899
970
  content: [{ type: "text", text: summary }],
900
971
  isError: true,
@@ -909,14 +980,11 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
909
980
  } catch (error) {
910
981
  const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
911
982
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
912
- return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }));
983
+ return buildChainExecutionErrorResult(
984
+ stopRecord ? buildChainSummary(chainSteps, results, chainDir, "failed", { index: stepIndex, error: message }, failureSummaryOptions()) : message,
985
+ makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }),
986
+ );
913
987
  }
914
- outputs[step.collect.as] = {
915
- text: JSON.stringify(collected),
916
- structured: collected,
917
- agent: step.parallel.agent,
918
- stepIndex,
919
- };
920
988
  dynamicGroupStatuses[stepIndex] = { status: "completed" };
921
989
  const effectiveGroupAcceptance = resolveEffectiveAcceptance({
922
990
  explicit: step.acceptance,
@@ -938,7 +1006,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
938
1006
  const groupAcceptanceFailure = acceptanceFailureMessage(groupAcceptance);
939
1007
  if (groupAcceptanceFailure) {
940
1008
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: groupAcceptanceFailure, acceptance: groupAcceptance };
941
- return buildChainExecutionErrorResult(groupAcceptanceFailure, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }));
1009
+ return buildChainExecutionErrorResult(
1010
+ stopRecord ? buildChainSummary(chainSteps, results, chainDir, "failed", { index: stepIndex, error: groupAcceptanceFailure }, failureSummaryOptions()) : groupAcceptanceFailure,
1011
+ makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }),
1012
+ );
942
1013
  }
943
1014
  const taskResults: ParallelTaskResult[] = parallelResults.map((result, i) => ({
944
1015
  agent: result.agent,
@@ -948,6 +1019,12 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
948
1019
  error: result.error,
949
1020
  }));
950
1021
  prev = aggregateParallelOutputs(taskResults, (i, agent) => `=== Dynamic Item ${i + 1} (${agent}, key ${materialized.items[i]?.key ?? i}) ===`);
1022
+ if (stopRecord) {
1023
+ return {
1024
+ content: [{ type: "text", text: buildChainSummary(chainSteps, results, chainDir, "stopped", undefined, summaryOptions()) }],
1025
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex })),
1026
+ };
1027
+ }
951
1028
  } else {
952
1029
  const seqStep = step as SequentialStep;
953
1030
  const stepTemplate = stepTemplates as string;
@@ -988,7 +1065,13 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
988
1065
  templateHasPrevious ? undefined : prev,
989
1066
  );
990
1067
 
991
- let stepTask = resolveOutputReferences(stepTemplate, outputs);
1068
+ let stepTask: string;
1069
+ try {
1070
+ stepTask = resolveOutputReferences(stepTemplate, outputs);
1071
+ } catch (error) {
1072
+ if (!(error instanceof ChainOutputValidationError)) throw error;
1073
+ return buildChainExecutionErrorResult(error.message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
1074
+ }
992
1075
  stepTask = stepTask.replace(/\{task\}/g, originalTask);
993
1076
  stepTask = stepTask.replace(/\{previous\}/g, prev);
994
1077
  stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
@@ -1005,7 +1088,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1005
1088
  : undefined;
1006
1089
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
1007
1090
  if (validationError) {
1008
- return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
1091
+ return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }), failureSummaryOptions());
1009
1092
  }
1010
1093
  const maxSubagentDepth = resolveChildMaxSubagentDepth(params.maxSubagentDepth, agentConfig.maxSubagentDepth);
1011
1094
  const interruptController = new AbortController();
@@ -1106,6 +1189,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1106
1189
 
1107
1190
  globalTaskIndex++;
1108
1191
  results.push(r);
1192
+ logTaskOutput(stepIndex, 0, r, seqStep.as, seqStep.label);
1109
1193
  if (r.progress) allProgress.push(r.progress);
1110
1194
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1111
1195
 
@@ -1120,7 +1204,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1120
1204
  const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
1121
1205
  index: stepIndex,
1122
1206
  error: r.error || "Chain failed",
1123
- });
1207
+ }, failureSummaryOptions());
1124
1208
  return {
1125
1209
  content: [{ type: "text", text: summary }],
1126
1210
  details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
@@ -1148,10 +1232,20 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1148
1232
 
1149
1233
  if (seqStep.as) outputs[seqStep.as] = outputEntryFromResult(r, stepIndex);
1150
1234
  prev = getSingleResultOutput(r);
1235
+ if (seqStep.stopOn) {
1236
+ const matches = evaluateStopCondition(seqStep.stopOn, stepIndex, [{ agent: r.agent, as: seqStep.as, label: seqStep.label, exitCode: r.exitCode, structuredOutput: r.structuredOutput }], { sequential: true });
1237
+ if (matches.length > 0) stopRecord = buildStopRecord(seqStep.stopOn, stepIndex, matches);
1238
+ }
1239
+ if (stopRecord) {
1240
+ return {
1241
+ content: [{ type: "text", text: buildChainSummary(chainSteps, results, chainDir, "stopped", undefined, summaryOptions()) }],
1242
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex })),
1243
+ };
1244
+ }
1151
1245
  }
1152
1246
  }
1153
1247
 
1154
- const summary = buildChainSummary(chainSteps, results, chainDir, "completed");
1248
+ const summary = buildChainSummary(chainSteps, results, chainDir, "completed", undefined, summaryOptions());
1155
1249
 
1156
1250
  return {
1157
1251
  content: [{ type: "text", text: summary }],
@@ -2,9 +2,20 @@ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialS
2
2
  import type { ChainOutputMap, ChainOutputMapEntry, SingleResult } from "../../shared/types.ts";
3
3
  import { getSingleResultOutput } from "../../shared/utils.ts";
4
4
  import { DynamicFanoutError, hasDynamicFanoutFields, type DynamicFanoutConfig, validateDynamicStepShape } from "./dynamic-fanout.ts";
5
+ import { validateStopConditions } from "./stop-conditions.ts";
5
6
 
6
7
  const OUTPUT_REF_PATTERN = /\{outputs\.([^}]*)\}/g;
7
8
  const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ const PATCH_PATH_FIELD = "patchPath";
10
+
11
+ /** `name` or `name.patchPath`; anything else is invalid. */
12
+ function parseOutputReference(raw: string): { name: string; field?: typeof PATCH_PATH_FIELD } | undefined {
13
+ const dot = raw.indexOf(".");
14
+ const name = dot === -1 ? raw : raw.slice(0, dot);
15
+ if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) return undefined;
16
+ if (dot === -1) return { name };
17
+ return raw.slice(dot + 1) === PATCH_PATH_FIELD ? { name, field: PATCH_PATH_FIELD } : undefined;
18
+ }
8
19
 
9
20
  export class ChainOutputValidationError extends Error {}
10
21
 
@@ -22,6 +33,10 @@ function taskTemplatesForStep(step: ChainStep): string[] {
22
33
  }
23
34
 
24
35
  export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
36
+ const stopError = validateStopConditions(steps);
37
+ if (stopError) throw new ChainOutputValidationError(stopError);
38
+ const worktreeProducers = new Map<string, number>();
39
+ const producerSteps = new Map<string, number>();
25
40
  const available = new Set<string>();
26
41
  const seen = new Set<string>();
27
42
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
@@ -52,29 +67,39 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
52
67
  for (const template of taskTemplatesForStep(step)) {
53
68
  for (const match of template.matchAll(OUTPUT_REF_PATTERN)) {
54
69
  const rawReference = match[0];
55
- const name = match[1]!;
56
- if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
70
+ const parsed = parseOutputReference(match[1]!);
71
+ if (!parsed) {
57
72
  throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
58
73
  }
59
- if (!available.has(name)) {
74
+ if (!available.has(parsed.name)) {
60
75
  throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
61
76
  }
77
+ if (parsed.field === PATCH_PATH_FIELD && !worktreeProducers.has(parsed.name)) {
78
+ const producerStep = producerSteps.get(parsed.name)!;
79
+ throw new ChainOutputValidationError(`Chain step ${stepIndex + 1} references ${rawReference} but step ${producerStep + 1} is not a worktree step.`);
80
+ }
62
81
  }
63
82
  }
64
83
  for (const name of outputNamesForStep(step)) {
65
84
  available.add(name);
85
+ producerSteps.set(name, stepIndex);
86
+ if (isParallelStep(step) && step.worktree) worktreeProducers.set(name, stepIndex);
66
87
  }
67
88
  }
68
89
  }
69
90
 
70
91
  export function resolveOutputReferences(template: string, outputs: ChainOutputMap): string {
71
- return template.replace(OUTPUT_REF_PATTERN, (rawReference, name: string) => {
72
- if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
92
+ return template.replace(OUTPUT_REF_PATTERN, (rawReference, rawName: string) => {
93
+ const parsed = parseOutputReference(rawName);
94
+ if (!parsed) {
73
95
  throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}'. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
74
96
  }
75
- const entry = outputs[name];
97
+ const entry = outputs[parsed.name];
76
98
  if (!entry) throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}'.`);
77
- return entry.text;
99
+ if (parsed.field !== PATCH_PATH_FIELD) return entry.text;
100
+ if (entry.patchPath) return entry.patchPath;
101
+ const reason = entry.patchError ? `capture failed: ${entry.patchError}` : "no changes";
102
+ throw new ChainOutputValidationError(`Output '${parsed.name}' has no patch (${reason}).`);
78
103
  });
79
104
  }
80
105
 
@@ -82,8 +107,22 @@ function compactStructuredText(value: unknown): string {
82
107
  return JSON.stringify(value);
83
108
  }
84
109
 
85
- export function outputEntryFromResult(result: SingleResult, stepIndex: number): ChainOutputMapEntry {
110
+ export interface OutputEntryPatchInfo {
111
+ patchPath?: string;
112
+ patchError?: string;
113
+ }
114
+
115
+ function patchFields(patch: OutputEntryPatchInfo | undefined): Pick<ChainOutputMapEntry, "patchPath" | "patchError"> {
116
+ if (!patch) return {};
117
+ return {
118
+ ...(patch.patchPath ? { patchPath: patch.patchPath } : {}),
119
+ ...(patch.patchError ? { patchError: patch.patchError } : {}),
120
+ };
121
+ }
122
+
123
+ export function outputEntryFromResult(result: SingleResult, stepIndex: number, patch?: OutputEntryPatchInfo): ChainOutputMapEntry {
86
124
  return {
125
+ ...patchFields(patch),
87
126
  text: result.structuredOutput !== undefined ? compactStructuredText(result.structuredOutput) : getSingleResultOutput(result),
88
127
  ...(result.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
89
128
  agent: result.agent,
@@ -91,8 +130,9 @@ export function outputEntryFromResult(result: SingleResult, stepIndex: number):
91
130
  };
92
131
  }
93
132
 
94
- export function outputEntryFromAsyncResult(result: { agent: string; output: string; structuredOutput?: unknown }, stepIndex: number): ChainOutputMapEntry {
133
+ export function outputEntryFromAsyncResult(result: { agent: string; output: string; structuredOutput?: unknown }, stepIndex: number, patch?: OutputEntryPatchInfo): ChainOutputMapEntry {
95
134
  return {
135
+ ...patchFields(patch),
96
136
  text: result.structuredOutput !== undefined ? compactStructuredText(result.structuredOutput) : result.output,
97
137
  ...(result.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
98
138
  agent: result.agent,
@@ -40,7 +40,7 @@ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
40
40
  const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
41
  const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
42
42
  const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
43
- const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
43
+ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance", "stopOn"]);
44
44
  const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
45
45
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
46
46
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
@@ -28,6 +28,7 @@ export interface RunnerSubagentStep {
28
28
  };
29
29
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
30
30
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
31
+ stopOn?: import("../../shared/types.ts").StopCondition;
31
32
  }
32
33
 
33
34
  export interface ParallelStepGroup {
@@ -35,6 +36,7 @@ export interface ParallelStepGroup {
35
36
  concurrency?: number;
36
37
  failFast?: boolean;
37
38
  worktree?: boolean;
39
+ stopOn?: import("../../shared/types.ts").StopCondition;
38
40
  }
39
41
 
40
42
  export interface DynamicRunnerGroup {
@@ -46,6 +48,7 @@ export interface DynamicRunnerGroup {
46
48
  phase?: string;
47
49
  label?: string;
48
50
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
51
+ stopOn?: import("../../shared/types.ts").StopCondition;
49
52
  }
50
53
 
51
54
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
@@ -0,0 +1,100 @@
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
+ import type { StopCondition, StopMatch, StopRecord } from "../../shared/types.ts";
3
+ import { assertJsonPointer, DynamicFanoutError, resolveJsonPointer } from "./dynamic-fanout.ts";
4
+
5
+ export interface StopEvaluationTask {
6
+ agent: string;
7
+ as?: string;
8
+ label?: string;
9
+ exitCode: number | null | undefined;
10
+ structuredOutput?: unknown;
11
+ }
12
+
13
+ /** Shape check for one condition value; `label` prefixes the message (`Chain step 1 stopOn`, `config.steps[0].stopOn`). */
14
+ export function stopConditionShapeError(value: unknown, label: string): string | undefined {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${label} must be an object with path and in.`;
16
+ const record = value as Record<string, unknown>;
17
+ try {
18
+ if (typeof record.path !== "string") throw new DynamicFanoutError("Invalid JSON Pointer type.");
19
+ assertJsonPointer(record.path, `${label}.path`);
20
+ } catch (error) {
21
+ if (!(error instanceof DynamicFanoutError)) throw error;
22
+ return `${label}.path '${String(record.path)}' is not a JSON Pointer (must be "" or start with "/").`;
23
+ }
24
+ const list = record.in;
25
+ if (!Array.isArray(list) || list.length === 0 || list.some((member) => typeof member !== "string")) {
26
+ return `${label}.in must be a non-empty array of strings.`;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ /** Pre-run check across a chain; returns the first error message or undefined. */
32
+ export function validateStopConditions(steps: ChainStep[]): string | undefined {
33
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
34
+ const step = steps[stepIndex]!;
35
+ const stopOn = (step as { stopOn?: unknown }).stopOn;
36
+ if (stopOn === undefined) continue;
37
+ const stepNumber = stepIndex + 1;
38
+ const shapeError = stopConditionShapeError(stopOn, `Chain step ${stepNumber} stopOn`);
39
+ if (shapeError) return shapeError;
40
+ if (isParallelStep(step)) {
41
+ for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
42
+ if (!step.parallel[taskIndex]!.outputSchema) {
43
+ return `Chain step ${stepNumber} task ${taskIndex + 1} has no outputSchema; stopOn requires one on every task.`;
44
+ }
45
+ }
46
+ continue;
47
+ }
48
+ if (isDynamicParallelStep(step)) {
49
+ if (!step.parallel.outputSchema) return `Chain step ${stepNumber} task 1 has no outputSchema; stopOn requires one on every task.`;
50
+ continue;
51
+ }
52
+ if (!(step as SequentialStep).outputSchema) return `Chain step ${stepNumber} has stopOn but no outputSchema.`;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ function resolveStringAt(value: unknown, pointer: string): string | undefined {
58
+ let resolved: unknown;
59
+ try {
60
+ resolved = resolveJsonPointer(value, pointer, "stopOn.path");
61
+ } catch (error) {
62
+ if (error instanceof DynamicFanoutError) return undefined;
63
+ throw error;
64
+ }
65
+ return typeof resolved === "string" ? resolved : undefined;
66
+ }
67
+
68
+ /** Matches in task order; only exit-0 tasks with structured output are considered. Never throws for data. */
69
+ export function evaluateStopCondition(
70
+ condition: StopCondition,
71
+ stepIndex: number,
72
+ tasks: StopEvaluationTask[],
73
+ options: { sequential?: boolean } = {},
74
+ ): StopMatch[] {
75
+ const matches: StopMatch[] = [];
76
+ for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
77
+ const task = tasks[taskIndex]!;
78
+ if (task.exitCode !== 0 || task.structuredOutput === undefined) continue;
79
+ const value = resolveStringAt(task.structuredOutput, condition.path);
80
+ if (value === undefined || !condition.in.includes(value)) continue;
81
+ matches.push({
82
+ stepIndex,
83
+ ...(options.sequential ? {} : { taskIndex }),
84
+ ...(task.as ? { as: task.as } : {}),
85
+ ...(task.label ? { label: task.label } : {}),
86
+ agent: task.agent,
87
+ value,
88
+ });
89
+ }
90
+ return matches;
91
+ }
92
+
93
+ export function buildStopRecord(condition: StopCondition, stepIndex: number, matches: StopMatch[]): StopRecord {
94
+ return { reason: "condition", stepIndex, condition, matches };
95
+ }
96
+
97
+ export function formatStopCondition(condition: StopCondition): string {
98
+ const pointer = condition.path === "" ? '""' : condition.path;
99
+ return `${pointer} in [${condition.in.join(", ")}]`;
100
+ }