@alexeiled/pi-fusion 0.6.2 → 0.7.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.
@@ -1,4 +1,8 @@
1
1
  import { applyClaudeAliasShorthand } from "./claude-aliases.js";
2
+ import {
3
+ detectCallerOutputContract,
4
+ validateCallerOutput,
5
+ } from "./caller-contract.js";
2
6
  import {
3
7
  buildInlinePanelProfile,
4
8
  loadFusionConfig,
@@ -17,6 +21,10 @@ import {
17
21
  extractPanelResults,
18
22
  type ExtractPanelResultsSuccess,
19
23
  } from "./result-extract.js";
24
+ import {
25
+ reconcileIndexedLifecycleResult,
26
+ reconcilePanelResults,
27
+ } from "./lifecycle-reconcile.js";
20
28
  import { appendThinkingSuffix, buildPanelSpawnParams } from "./run-builder.js";
21
29
  import { FusionRunStore, FusionRunStoreError } from "./run-store.js";
22
30
  import {
@@ -188,6 +196,8 @@ export class FusionOrchestrator {
188
196
  return { status: "failed", error: message };
189
197
  }
190
198
 
199
+ const outputContract =
200
+ args.outputContract ?? detectCallerOutputContract(args.prompt);
191
201
  let run: FusionRun;
192
202
  try {
193
203
  run = this.runStore.startRun({
@@ -199,6 +209,7 @@ export class FusionOrchestrator {
199
209
  ...(args.operationId !== undefined
200
210
  ? { operationId: args.operationId }
201
211
  : {}),
212
+ ...(outputContract ? { outputContract } : {}),
202
213
  phase: "panel",
203
214
  });
204
215
  } catch (error: unknown) {
@@ -219,7 +230,7 @@ export class FusionOrchestrator {
219
230
 
220
231
  try {
221
232
  const spawnResult = await this.rpc.spawn(
222
- buildPanelSpawnParams(resolved.profile, args.prompt),
233
+ buildPanelSpawnParams(resolved.profile, args.prompt, outputContract),
223
234
  );
224
235
  const spawnError = extractSubagentFailure(spawnResult);
225
236
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -585,19 +596,37 @@ export class FusionOrchestrator {
585
596
  );
586
597
  }
587
598
 
588
- const observedPanels = mergePanelObservations(
599
+ const observedPanels = reconcilePanelResults(
589
600
  extracted,
590
601
  snapshot.statusPayload,
591
602
  profile,
603
+ lifecyclePayload,
604
+ { allowedTrailingResults: 1 },
592
605
  );
606
+ if (!observedPanels.ok) {
607
+ return this.failActiveRun(
608
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
609
+ );
610
+ }
593
611
  const updated = this.storePanelResults(
594
612
  active.id,
595
613
  observedPanels.outputs,
596
614
  observedPanels.failures,
597
615
  );
598
616
 
599
- if (hasJudgeResult(snapshot.resultPayload, profile.panel.length)) {
600
- const output = extractJudgeOutput(snapshot.resultPayload, {
617
+ const judgePayload =
618
+ hasJudgeResult(snapshot.resultPayload, profile.panel.length)
619
+ ? snapshot.resultPayload
620
+ : snapshot.statusPayload;
621
+ if (hasJudgeResult(judgePayload, profile.panel.length)) {
622
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
623
+ snapshot.resultPayload,
624
+ snapshot.statusPayload,
625
+ profile.panel.length,
626
+ "judge",
627
+ );
628
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
629
+ const output = extractJudgeOutput(judgePayload, {
601
630
  resultIndex: profile.panel.length,
602
631
  });
603
632
  if (!output.ok) return this.failActiveRun(output.error);
@@ -608,13 +637,18 @@ export class FusionOrchestrator {
608
637
  snapshot.statusPayload,
609
638
  ),
610
639
  extractRunObservation(
611
- findResult(snapshot.resultPayload, profile.panel.length) ??
612
- snapshot.resultPayload,
640
+ findResult(judgePayload, profile.panel.length) ?? judgePayload,
613
641
  ),
614
642
  );
615
643
  const observed = this.runStore.updateRun(updated.id, {
616
644
  judgeObservation,
617
645
  });
646
+ const callerContract =
647
+ observed.outputContract ?? detectCallerOutputContract(observed.prompt);
648
+ if (callerContract) {
649
+ const validation = validateCallerOutput(callerContract, output.output);
650
+ if (!validation.ok) return this.failActiveRun(validation.error);
651
+ }
618
652
  const report = renderJudgeReport({
619
653
  run: observed,
620
654
  judgeOutput: output.output,
@@ -713,11 +747,18 @@ export class FusionOrchestrator {
713
747
  );
714
748
  }
715
749
 
716
- const observedPanels = mergePanelObservations(
750
+ const observedPanels = reconcilePanelResults(
717
751
  extracted,
718
752
  snapshot.statusPayload,
719
753
  profile,
754
+ lifecyclePayload,
755
+ { ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}) },
720
756
  );
757
+ if (!observedPanels.ok) {
758
+ return this.failActiveRun(
759
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
760
+ );
761
+ }
721
762
  const stored = this.storePanelResults(
722
763
  active.id,
723
764
  observedPanels.outputs,
@@ -875,8 +916,25 @@ export class FusionOrchestrator {
875
916
  return this.failActiveRun(lifecycleError);
876
917
  }
877
918
 
919
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
920
+ snapshot.resultPayload,
921
+ snapshot.statusPayload,
922
+ 0,
923
+ "judge",
924
+ );
925
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
926
+
878
927
  const output = extractJudgeOutput(lifecyclePayload);
879
- if (!output.ok) return this.failActiveRun(output.error);
928
+ if (!output.ok) {
929
+ const stepError = extractSubagentFailure(
930
+ findStepsArray(snapshot.statusPayload)[0],
931
+ );
932
+ const errors = [lifecycleError, stepError, output.error].filter(
933
+ (error, index, all): error is string =>
934
+ Boolean(error) && all.indexOf(error) === index,
935
+ );
936
+ return this.failActiveRun(errors.join(" "));
937
+ }
880
938
 
881
939
  // The panel and chain handlers already treat a missing profile as fatal.
882
940
  // Without the same guard here the run "succeeds" with a degraded report:
@@ -888,6 +946,13 @@ export class FusionOrchestrator {
888
946
  );
889
947
  }
890
948
 
949
+ const callerContract =
950
+ active.outputContract ?? detectCallerOutputContract(active.prompt);
951
+ if (callerContract) {
952
+ const validation = validateCallerOutput(callerContract, output.output);
953
+ if (!validation.ok) return this.failActiveRun(validation.error);
954
+ }
955
+
891
956
  const judgeModel = configuredJudgeModel(profile);
892
957
  const judgeObservation = mergeRunObservations(
893
958
  extractRunObservation(
@@ -1575,15 +1640,17 @@ function hasResultsArray(payload: unknown): boolean {
1575
1640
  }
1576
1641
 
1577
1642
  function hasJudgeResult(payload: unknown, panelCount: number): boolean {
1578
- return (findResultsArray(payload)?.length ?? 0) > panelCount;
1643
+ const count =
1644
+ findResultsArray(payload)?.length ?? findStepsArray(payload).length;
1645
+ return count > panelCount;
1579
1646
  }
1580
1647
 
1581
1648
  function findResult(
1582
1649
  payload: unknown,
1583
1650
  resultIndex?: number,
1584
1651
  ): Record<string, unknown> | undefined {
1585
- const results = findResultsArray(payload);
1586
- if (!results || results.length === 0) return undefined;
1652
+ const results = findResultsArray(payload) ?? findStepsArray(payload);
1653
+ if (results.length === 0) return undefined;
1587
1654
  if (resultIndex !== undefined) {
1588
1655
  const indexed = results[resultIndex];
1589
1656
  return isRecord(indexed) ? indexed : undefined;
@@ -1752,56 +1819,6 @@ function extractArtifactPath(
1752
1819
  return undefined;
1753
1820
  }
1754
1821
 
1755
- function mergePanelObservations(
1756
- result: ExtractPanelResultsSuccess,
1757
- statusPayload: unknown,
1758
- profile: FusionProfile,
1759
- ): ExtractPanelResultsSuccess {
1760
- const status = extractPanelResults(statusPayload, {
1761
- panel: profile.panel,
1762
- completedOnly: true,
1763
- limit: profile.panel.length,
1764
- });
1765
- if (!status.ok) return result;
1766
-
1767
- const observations = new Map<number, PanelOutput["observation"]>();
1768
- for (const output of status.outputs) {
1769
- observations.set(output.index, output.observation);
1770
- }
1771
- for (const failure of status.failures) {
1772
- observations.set(failure.index, failure.observation);
1773
- }
1774
-
1775
- return {
1776
- ...result,
1777
- outputs: result.outputs.map((output) =>
1778
- withMergedObservation(output, observations.get(output.index)),
1779
- ),
1780
- failures: result.failures.map((failure) =>
1781
- withMergedObservation(failure, observations.get(failure.index)),
1782
- ),
1783
- };
1784
- }
1785
-
1786
- function withMergedObservation<T extends PanelOutput | FailedPanelSummary>(
1787
- item: T,
1788
- statusObservation: PanelOutput["observation"] | undefined,
1789
- ): T {
1790
- const observation = mergeRunObservations(statusObservation, item.observation);
1791
- return hasObservationData(observation) ? { ...item, observation } : item;
1792
- }
1793
-
1794
- function hasObservationData(observation: PanelOutput["observation"]): boolean {
1795
- return Boolean(
1796
- observation &&
1797
- (observation.model ||
1798
- observation.durationMs !== undefined ||
1799
- observation.usage ||
1800
- observation.attempts ||
1801
- observation.providerFailures),
1802
- );
1803
- }
1804
-
1805
1822
  function shouldStopWhenPanelAgrees(
1806
1823
  profile: FusionProfile,
1807
1824
  outputs: readonly PanelOutput[],
@@ -1,4 +1,12 @@
1
- import { renderPanelFailureReport, renderSinglePanelReport } from "./report.js";
1
+ import {
2
+ detectCallerOutputContract,
3
+ validateCallerOutput,
4
+ } from "./caller-contract.js";
5
+ import {
6
+ renderFailureReport,
7
+ renderPanelFailureReport,
8
+ renderSinglePanelReport,
9
+ } from "./report.js";
2
10
  import {
3
11
  appendThinkingSuffix,
4
12
  buildJudgeSpawnParams,
@@ -50,14 +58,53 @@ export function decidePanelCompletion(
50
58
  };
51
59
  }
52
60
 
53
- // Under `select` every panelist answered the whole question, so a lone
54
- // survivor is a complete if thin answer. Under `merge` it answered ONE facet:
55
- // returning it as the answer would be wrong, not thin. Run the composer so
56
- // the report names the facets nobody covered.
61
+ const intentionalStops =
62
+ input.profile.stopWhenPanelAgrees === true &&
63
+ input.panelOutputs.length >= 2 &&
64
+ input.panelFailures.length > 0 &&
65
+ input.panelFailures.every(
66
+ ({ reason }) => reason === "stopped-after-agreement",
67
+ );
57
68
  if (
58
- input.panelOutputs.length === 1 &&
59
- resolveSynthesisMode(input.profile) !== "merge"
69
+ input.panelOutputs.length < input.profile.panel.length &&
70
+ !intentionalStops
60
71
  ) {
72
+ const error = `Only ${input.panelOutputs.length} of ${input.profile.panel.length} fusion panelists completed successfully; ${input.panelFailures.length} panelist result(s) are also missing.`;
73
+ const report = renderFailureReport({
74
+ run: input.run,
75
+ error,
76
+ panelOutputs: input.panelOutputs,
77
+ failures: input.panelFailures,
78
+ ...withJudgeModel(judgeModel),
79
+ synthesis: resolveSynthesisMode(input.profile),
80
+ panel: input.profile.panel,
81
+ });
82
+ return { kind: "fail", error, report };
83
+ }
84
+
85
+ if (input.panelOutputs.length === 1) {
86
+ const callerContract =
87
+ input.run.outputContract ??
88
+ detectCallerOutputContract(input.run.prompt);
89
+ if (callerContract) {
90
+ const validation = validateCallerOutput(
91
+ callerContract,
92
+ input.panelOutputs[0]!.output,
93
+ );
94
+ if (!validation.ok) {
95
+ const report = renderFailureReport({
96
+ run: input.run,
97
+ error: validation.error,
98
+ panelOutputs: input.panelOutputs,
99
+ failures: input.panelFailures,
100
+ ...withJudgeModel(judgeModel),
101
+ synthesis: resolveSynthesisMode(input.profile),
102
+ panel: input.profile.panel,
103
+ });
104
+ return { kind: "fail", error: validation.error, report };
105
+ }
106
+ }
107
+
61
108
  const report = renderSinglePanelReport({
62
109
  run: input.run,
63
110
  output: input.panelOutputs[0]!,
@@ -75,6 +122,9 @@ export function decidePanelCompletion(
75
122
  panelOutputs: input.panelOutputs,
76
123
  failedPanelists: input.panelFailures,
77
124
  runId: input.run.id,
125
+ ...(input.run.outputContract
126
+ ? { callerContract: input.run.outputContract }
127
+ : {}),
78
128
  }),
79
129
  missingRunIdError: input.fallbackJudge
80
130
  ? "pi-subagents spawn did not return a fallback judge run ID."
package/src/report.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ detectCallerOutputContract,
3
+ validateCallerOutput,
4
+ } from "./caller-contract.js";
1
5
  import {
2
6
  buildBlindLabelMap,
3
7
  type FailedPanelSummary,
@@ -23,6 +27,7 @@ type ReportRun = Pick<
23
27
  | "panelRunId"
24
28
  | "judgeRunId"
25
29
  | "panelStopReason"
30
+ | "outputContract"
26
31
  > &
27
32
  Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
28
33
 
@@ -227,6 +232,9 @@ export function renderPanelFailureReport(
227
232
  export function renderSinglePanelReport(
228
233
  input: RenderSinglePanelReportInput,
229
234
  ): string {
235
+ const exactOutput = validExactCallerOutput(input.run, input.output.output);
236
+ if (exactOutput) return exactOutput;
237
+
230
238
  const panelName = formatPanelName(input.output);
231
239
  const sections: ReportSection[] = [
232
240
  {
@@ -291,6 +299,15 @@ export function renderSinglePanelReport(
291
299
  * Rewrites the neutral names the judge saw back to the configured member names.
292
300
  * Longest label first so "Candidate AA" is not partly replaced by "Candidate A".
293
301
  */
302
+ function validExactCallerOutput(
303
+ run: ReportRun,
304
+ output: string,
305
+ ): string | undefined {
306
+ const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
307
+ if (!contract) return undefined;
308
+ return validateCallerOutput(contract, output).ok ? output.trim() : undefined;
309
+ }
310
+
294
311
  function restoreBlindLabels(
295
312
  judgeOutput: string,
296
313
  panelOutputs: readonly PanelOutput[],
@@ -317,6 +334,9 @@ function restoreBlindLabels(
317
334
  export function renderJudgeReport(input: RenderJudgeReportInput): string {
318
335
  const panelOutputs = input.panelOutputs ?? [];
319
336
  const failures = input.failures ?? [];
337
+ const exactOutput = validExactCallerOutput(input.run, input.judgeOutput);
338
+ if (exactOutput) return exactOutput;
339
+
320
340
  const judgeOutput = input.blindPanelLabels
321
341
  ? restoreBlindLabels(input.judgeOutput, panelOutputs, failures)
322
342
  : input.judgeOutput;
@@ -63,13 +63,19 @@ export function extractPanelResults(
63
63
 
64
64
  const outputs: PanelOutput[] = [];
65
65
  const failures: FailedPanelSummary[] = [];
66
+ const fallbackFailureReason = failureReason(container.payload);
66
67
  const results =
67
68
  options.limit === undefined
68
69
  ? container.results
69
70
  : container.results.slice(0, options.limit);
70
71
  for (const [index, rawResult] of results.entries()) {
71
72
  if (options.completedOnly && !isCompletedResult(rawResult)) continue;
72
- const child = normalizeChildResult(rawResult, index, options);
73
+ const child = normalizeChildResult(
74
+ rawResult,
75
+ index,
76
+ options,
77
+ fallbackFailureReason,
78
+ );
73
79
  if (!child.ok) return child;
74
80
  if (child.status === "success") outputs.push(child.output);
75
81
  else failures.push(child.failure);
@@ -84,6 +90,7 @@ export function extractPanelResults(
84
90
  },
85
91
  index,
86
92
  options,
93
+ fallbackFailureReason,
87
94
  );
88
95
  if (!child.ok) return child;
89
96
  if (child.status === "failed") failures.push(child.failure);
@@ -197,6 +204,7 @@ function normalizeChildResult(
197
204
  rawResult: unknown,
198
205
  index: number,
199
206
  options: ExtractPanelResultsOptions,
207
+ fallbackFailureReason?: FailedPanelSummary["reason"],
200
208
  ):
201
209
  | { ok: true; status: "success"; output: PanelOutput }
202
210
  | { ok: true; status: "failed"; failure: FailedPanelSummary }
@@ -275,7 +283,9 @@ function normalizeChildResult(
275
283
  summary: stoppedAfterAgreement
276
284
  ? "Stopped after strong panel agreement."
277
285
  : failureSummary(rawResult, artifactPath),
278
- reason: failureReason(rawResult, stoppedAfterAgreement),
286
+ reason:
287
+ failureReason(rawResult, stoppedAfterAgreement) ??
288
+ fallbackFailureReason,
279
289
  observation,
280
290
  artifactPath,
281
291
  sessionPath,
@@ -392,7 +402,12 @@ function failureReason(
392
402
  stoppedAfterAgreement = false,
393
403
  ): FailedPanelSummary["reason"] {
394
404
  if (stoppedAfterAgreement) return "stopped-after-agreement";
395
- if (result.timedOut === true) return "timeout";
405
+ if (
406
+ result.timedOut === true ||
407
+ /(?:timed out|timeout)/i.test(firstNonBlankString(result.error) ?? "")
408
+ ) {
409
+ return "timeout";
410
+ }
396
411
  if (result.interrupted === true) return "interrupted";
397
412
  return undefined;
398
413
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ callerOutputContractInstructions,
3
+ detectCallerOutputContract,
4
+ } from "./caller-contract.js";
1
5
  import {
2
6
  PANEL_DECISION_CLOSE,
3
7
  PANEL_DECISION_OPEN,
@@ -8,6 +12,7 @@ import {
8
12
  memberLabel,
9
13
  panelItemLabel,
10
14
  resolveSynthesisMode,
15
+ type CallerOutputContract,
11
16
  THINKING_LEVELS,
12
17
  type FailedPanelSummary,
13
18
  type FusionProfile,
@@ -25,6 +30,13 @@ export const FUSION_ACCEPTANCE_DISABLED = {
25
30
 
26
31
  export type FusionAcceptanceDisabled = typeof FUSION_ACCEPTANCE_DISABLED;
27
32
 
33
+ const DEFAULT_STAGE_TIMEOUT_MS = 900_000;
34
+ const DEFAULT_TOOL_BUDGET: ToolBudget = {
35
+ soft: 8,
36
+ hard: 12,
37
+ block: "*",
38
+ };
39
+
28
40
  export interface PanelSubagentTaskParams {
29
41
  agent: string;
30
42
  task: string;
@@ -34,6 +46,8 @@ export interface PanelSubagentTaskParams {
34
46
  skill: false;
35
47
  acceptance: FusionAcceptanceDisabled;
36
48
  model?: string;
49
+ /** Per-task cap so a panelist finalises before the workflow timeout. */
50
+ toolBudget: ToolBudget;
37
51
  }
38
52
 
39
53
  export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
@@ -85,6 +99,7 @@ export interface BuildJudgeSpawnParamsInput {
85
99
  * and its position bias.
86
100
  */
87
101
  runId: string;
102
+ callerContract?: CallerOutputContract;
88
103
  }
89
104
 
90
105
  const PANEL_OUTPUT_CONTRACT = [
@@ -152,6 +167,7 @@ export function appendThinkingSuffix(
152
167
  export function buildPanelSpawnParams(
153
168
  profile: FusionProfile,
154
169
  prompt: string,
170
+ callerContract?: CallerOutputContract,
155
171
  ): PanelSpawnParams {
156
172
  const concurrency = profile.concurrency ?? profile.panel.length;
157
173
  const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
@@ -161,6 +177,8 @@ export function buildPanelSpawnParams(
161
177
  member,
162
178
  prompt,
163
179
  profile.stopWhenPanelAgrees === true,
180
+ profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
181
+ callerContract,
164
182
  ),
165
183
  }),
166
184
  );
@@ -176,9 +194,7 @@ export function buildPanelSpawnParams(
176
194
  output: true,
177
195
  outputMode: "inline",
178
196
  acceptance: FUSION_ACCEPTANCE_DISABLED,
179
- ...(profile.timeoutMs !== undefined
180
- ? { timeoutMs: profile.timeoutMs }
181
- : {}),
197
+ timeoutMs: resolveStageTimeout(profile.panelTimeoutMs, profile.timeoutMs),
182
198
  };
183
199
  }
184
200
 
@@ -197,9 +213,7 @@ export function buildJudgeSpawnParams(
197
213
  skill: false,
198
214
  acceptance: FUSION_ACCEPTANCE_DISABLED,
199
215
  ...(model ? { model } : {}),
200
- ...(input.profile.judgeToolBudget
201
- ? { toolBudget: input.profile.judgeToolBudget }
202
- : {}),
216
+ toolBudget: input.profile.judgeToolBudget ?? DEFAULT_TOOL_BUDGET,
203
217
  };
204
218
 
205
219
  return {
@@ -209,9 +223,10 @@ export function buildJudgeSpawnParams(
209
223
  output: true,
210
224
  outputMode: "inline",
211
225
  acceptance: FUSION_ACCEPTANCE_DISABLED,
212
- ...(input.profile.timeoutMs !== undefined
213
- ? { timeoutMs: input.profile.timeoutMs }
214
- : {}),
226
+ timeoutMs: resolveStageTimeout(
227
+ input.profile.judgeTimeoutMs,
228
+ input.profile.timeoutMs,
229
+ ),
215
230
  };
216
231
  }
217
232
 
@@ -260,20 +275,35 @@ function buildPanelWorkflowScript(
260
275
  .join("\n");
261
276
  }
262
277
 
278
+ function resolveStageTimeout(
279
+ stageTimeoutMs: number | undefined,
280
+ legacyTimeoutMs: number | undefined,
281
+ ): number {
282
+ return stageTimeoutMs ?? legacyTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
283
+ }
284
+
263
285
  function buildPanelTaskParams(
264
286
  member: PanelMemberConfig,
265
287
  prompt: string,
266
288
  includeDecisionRecord: boolean,
289
+ toolBudget: ToolBudget,
290
+ callerContract?: CallerOutputContract,
267
291
  ): PanelSubagentTaskParams {
268
292
  const model = appendThinkingSuffix(member.model, member.thinking);
269
293
  return {
270
294
  agent: member.agent,
271
- task: buildPanelTask(member, prompt, includeDecisionRecord),
295
+ task: buildPanelTask(
296
+ member,
297
+ prompt,
298
+ includeDecisionRecord,
299
+ callerContract,
300
+ ),
272
301
  output: true,
273
302
  outputMode: "inline",
274
303
  progress: true,
275
304
  skill: false,
276
305
  acceptance: FUSION_ACCEPTANCE_DISABLED,
306
+ toolBudget,
277
307
  ...(model ? { model } : {}),
278
308
  };
279
309
  }
@@ -282,8 +312,11 @@ function buildPanelTask(
282
312
  member: PanelMemberConfig,
283
313
  prompt: string,
284
314
  includeDecisionRecord: boolean,
315
+ callerContractOverride?: CallerOutputContract,
285
316
  ): string {
286
317
  const role = member.role?.trim() || "independent analysis and critique";
318
+ const callerContract =
319
+ callerContractForPrompt(prompt, callerContractOverride);
287
320
  return [
288
321
  `Panel member: ${memberLabel(member)} (${member.id})`,
289
322
  `Role: ${role}`,
@@ -299,8 +332,10 @@ function buildPanelTask(
299
332
  "- Be concise and cite evidence when you inspect files.",
300
333
  "",
301
334
  "Output contract:",
302
- ...PANEL_OUTPUT_CONTRACT,
303
- ...(includeDecisionRecord
335
+ ...(callerContract
336
+ ? callerOutputContractInstructions(callerContract)
337
+ : PANEL_OUTPUT_CONTRACT),
338
+ ...(includeDecisionRecord && !callerContract
304
339
  ? [
305
340
  "",
306
341
  "Decision record:",
@@ -356,6 +391,13 @@ function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
356
391
  ];
357
392
  }
358
393
 
394
+ function callerContractForPrompt(
395
+ prompt: string,
396
+ explicit?: CallerOutputContract,
397
+ ): CallerOutputContract | undefined {
398
+ return explicit ?? detectCallerOutputContract(prompt);
399
+ }
400
+
359
401
  function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
360
402
  const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
361
403
  const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
@@ -366,6 +408,10 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
366
408
  ? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
367
409
  : undefined;
368
410
  const merging = resolveSynthesisMode(input.profile) === "merge";
411
+ const callerContract = callerContractForPrompt(
412
+ input.prompt,
413
+ input.callerContract,
414
+ );
369
415
  return [
370
416
  ...(merging
371
417
  ? COMPOSER_INSTRUCTIONS
@@ -399,7 +445,11 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
399
445
  // right" wording contradicts the composer's "do not rank panelists".
400
446
  ...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
401
447
  "Output contract:",
402
- ...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
448
+ ...(callerContract
449
+ ? callerOutputContractInstructions(callerContract)
450
+ : merging
451
+ ? COMPOSER_OUTPUT_CONTRACT
452
+ : JUDGE_OUTPUT_CONTRACT),
403
453
  ].join("\n");
404
454
  }
405
455
 
package/src/run-store.ts CHANGED
@@ -24,6 +24,7 @@ export type FusionRunSummary = Omit<
24
24
  | "prompt"
25
25
  | "profileName"
26
26
  | "operationId"
27
+ | "outputContract"
27
28
  | "phase"
28
29
  | "createdAt"
29
30
  | "updatedAt"
@@ -43,6 +44,7 @@ export interface FusionRunStartInput {
43
44
  inlinePanel?: string[];
44
45
  baseProfileName?: string;
45
46
  operationId?: string;
47
+ outputContract?: FusionRun["outputContract"];
46
48
  phase?: Exclude<FusionPhase, FusionTerminalPhase>;
47
49
  createdAt?: number;
48
50
  }
@@ -162,6 +164,9 @@ export class FusionRunStore {
162
164
  ...(input.operationId !== undefined
163
165
  ? { operationId: input.operationId }
164
166
  : {}),
167
+ ...(input.outputContract !== undefined
168
+ ? { outputContract: input.outputContract }
169
+ : {}),
165
170
  phase: input.phase ?? "chain",
166
171
  createdAt,
167
172
  updatedAt: createdAt,
@@ -379,6 +384,9 @@ function toRunSummary(
379
384
  prompt: run.prompt,
380
385
  profileName: run.profileName,
381
386
  ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
387
+ ...(run.outputContract !== undefined
388
+ ? { outputContract: run.outputContract }
389
+ : {}),
382
390
  phase: run.phase,
383
391
  createdAt: run.createdAt,
384
392
  updatedAt: run.updatedAt,
@@ -404,6 +412,9 @@ function cloneRun(run: FusionRun): FusionRun {
404
412
  ? { baseProfileName: run.baseProfileName }
405
413
  : {}),
406
414
  ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
415
+ ...(run.outputContract !== undefined
416
+ ? { outputContract: run.outputContract }
417
+ : {}),
407
418
  phase: run.phase,
408
419
  createdAt: run.createdAt,
409
420
  updatedAt: run.updatedAt,
@@ -462,6 +473,12 @@ function isFusionRunState(value: unknown): value is FusionRun {
462
473
  if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) {
463
474
  return false;
464
475
  }
476
+ if (
477
+ value.outputContract !== undefined &&
478
+ value.outputContract !== "plan-review-v1"
479
+ ) {
480
+ return false;
481
+ }
465
482
  if (
466
483
  value.inlinePanel !== undefined &&
467
484
  (!Array.isArray(value.inlinePanel) ||