@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.
package/README.md CHANGED
@@ -156,16 +156,18 @@ Other Pi extensions can control Fusion through the versioned event-bus contract
156
156
  Methods:
157
157
 
158
158
  - `ping` — return the RPC version and supported methods
159
- - `start` — requires `prompt` and a non-empty `operationId`. It accepts an optional `profile`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
159
+ - `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile` and versioned `outputContract` (`plan-review-v1`). Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
160
160
  - `status` — return structured run state by `operationId`, `runId`, or the current/last run
161
161
  - `result` — return a terminal run and report. An active run returns `not_ready`
162
162
  - `cancel` — cancel the selected active run, or report that the selected terminal run was not cancelled
163
163
  - `adopt` — verify and return a run from restored session history by `runId`
164
164
 
165
- `start` returns `{ operationId, replayed, run }`. `status` and `result`
166
- return `{ run }`. `cancel` returns `{ cancelled, run? }`. `adopt` returns
167
- `{ adopted: true, run }`. Run state contains `runId`, optional `operationId`,
168
- `phase`, `terminal`, and optional `report` or `error`.
165
+ `start` returns `{ operationId, replayed, run }`. `status` returns `{ run }`.
166
+ `result` returns `{ run, callerOutput? }`; `callerOutput` is present only when
167
+ Fusion validated a strict caller contract, and contains `{ contract, output }`.
168
+ `cancel` returns `{ cancelled, run? }`. `adopt` returns `{ adopted: true, run }`.
169
+ Run state contains `runId`, optional `operationId`, `phase`, `terminal`, and
170
+ optional `report` or `error`.
169
171
 
170
172
  Failure codes are `invalid_request`, `unsupported_method`, `busy`, `not_found`,
171
173
  `not_ready`, `unavailable`, `start_failed`, `cancel_failed`, and `internal`.
@@ -28,6 +28,9 @@ a winner.
28
28
  Read-only synthesis. Do not edit files. Do not ask other agents. Do not run
29
29
  subagents.
30
30
 
31
+ If the task defines an exact caller output contract, follow it instead of the
32
+ sections below. Return only the caller's required syntax.
33
+
31
34
  Return final Markdown with these sections:
32
35
 
33
36
  # Fusion Report
@@ -20,6 +20,9 @@ yourself rather than choosing the more confident wording. You have read tools;
20
20
  a factual conflict is settled by looking, not by weighing prose. Cite `file:line`
21
21
  for what you find, and say plainly when a claim could not be verified.
22
22
 
23
+ If the task defines an exact caller output contract, follow it instead of the
24
+ sections below. Return only the caller's required syntax.
25
+
23
26
  Return final Markdown with these sections:
24
27
 
25
28
  # Fusion Report
@@ -17,6 +17,9 @@ You have no web access: answer from the repository and your own knowledge, and s
17
17
  so plainly when a question turns on external facts you cannot retrieve.
18
18
  Do not edit files. Do not ask other agents. Do not run subagents.
19
19
 
20
+ If the task defines an exact caller output contract, follow it instead of the
21
+ sections and decision record below. Return only the caller's required syntax.
22
+
20
23
  Return concise Markdown with these sections.
21
24
 
22
25
  When the task includes a decision-record contract:
@@ -130,7 +130,10 @@ Run this inside a trusted project:
130
130
  "agent": "pi-fusion.fusion-judge"
131
131
  },
132
132
  "concurrency": 3,
133
- "timeoutMs": 300000,
133
+ "panelTimeoutMs": 900000,
134
+ "judgeTimeoutMs": 900000,
135
+ "panelToolBudget": { "soft": 8, "hard": 12, "block": "*" },
136
+ "judgeToolBudget": { "soft": 8, "hard": 12, "block": "*" },
134
137
  "context": "fresh",
135
138
  "stopWhenPanelAgrees": false
136
139
  }
@@ -150,12 +153,17 @@ Profile:
150
153
  - `panel`: one or more panel members
151
154
  - `judge`: judge agent config
152
155
  - `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion evaluates the first two panelists before launching another batch so it can avoid work after strong agreement.
153
- - `timeoutMs`: async subagent timeout in milliseconds
156
+ - `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It remains supported as the fallback for both stages. New profiles default to 15 minutes through the stage-specific fields; existing explicit `timeoutMs` values are preserved. Use a stage-specific field when panel and synthesis need different deadlines.
157
+ - `panelTimeoutMs`: panel workflow wall-clock timeout. It overrides `timeoutMs` for the panel. The default profile uses 15 minutes.
158
+ - `judgeTimeoutMs`: synthesis workflow wall-clock timeout. It overrides `timeoutMs` for the judge or composer. The default profile uses 15 minutes.
154
159
  - `context`: `fresh` or `fork`
155
160
  - `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet. All four conditions must hold: two or more finished panelists give the same normalized recommendation, every one of them reports `high` confidence, none of them asks for more evidence, and work remains. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
156
161
  - `synthesis`: rarely needed. Inferred from the panel — any member with a `question` means `merge`, otherwise `select`. Set it only to override that. See [Synthesis modes](#synthesis-modes).
157
162
  - `blindPanelLabels`: optional boolean, default `false`. When it is on, the judge sees `Candidate A`, `Candidate B`, and so on, instead of the configured labels. Fusion also withholds agent names and artifact paths, because they contain the member id. A role label reads as an authority cue before the judge compares any content. Your report always shows the real names.
158
- - `judgeToolBudget`: optional `{ "soft": n, "hard": n }`. It caps the tool calls the judge can spend to verify contested claims. `soft` is a nudge. After `hard`, Fusion blocks further tool use, so the judge still produces a report. Both numbers must be positive integers, and `soft` must not be larger than `hard`.
163
+ - `panelToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` applied to each panelist. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. After `hard`, the selected tools are blocked so the panelist can still finalise.
164
+ - `judgeToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` for the judge or composer. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. `soft` is a nudge. After `hard`, the selected tools are blocked so synthesis can still finalise. `soft` or `hard` must be positive integers when present, and `soft` must not be larger than `hard` when both are present. Legacy soft-only budgets remain valid.
165
+
166
+ Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion keeps completed panel slots and failed panel slots separate, never relabels a compact completion payload, and fails closed when lifecycle sources disagree. A timed-out judge never becomes a panel-only success. Synthesis also fails closed when any configured panelist is missing or failed, unless the missing slots were explicitly stopped after strong agreement.
159
167
 
160
168
  Panel member:
161
169
 
@@ -357,7 +365,8 @@ Deliberate review:
357
365
  "thinking": "high"
358
366
  },
359
367
  "concurrency": 2,
360
- "timeoutMs": 300000,
368
+ "panelTimeoutMs": 900000,
369
+ "judgeTimeoutMs": 900000,
361
370
  "context": "fresh"
362
371
  }
363
372
  }
@@ -368,6 +377,8 @@ Deliberate review:
368
377
 
369
378
  When agreement stopping is on, each panelist appends one tagged JSON decision record. The record holds a short recommendation, a confidence level, and whether the panelist needs more evidence. Fusion uses it only to decide whether an unfinished panel can stop early. A record that is malformed, missing, or not final turns early stopping off. You see the Markdown answer above the record, not the record itself.
370
379
 
380
+ An original task can define the strict plan-review contract: exactly `NO_FINDINGS`, or complete `FINDING`/`Evidence`/`Fix` blocks with no other prose. That caller contract overrides Fusion's normal report headings and agreement record. Fusion validates the synthesis, returns it unchanged, and fails rather than fabricating a clean result when the judge violates the contract. RPC `result` also exposes a validated value as `callerOutput`.
381
+
371
382
  The judge returns:
372
383
 
373
384
  - summary
@@ -467,6 +478,13 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
467
478
  - verify the requested `--profile` name
468
479
  - run `/fusion init` to regenerate a known-good template
469
480
 
481
+ `Workflow script timed out` or judge exit 143
482
+
483
+ - raise `panelTimeoutMs` or `judgeTimeoutMs` for slower models
484
+ - keep `panelToolBudget` and `judgeToolBudget` bounded so agents finalise before the deadline
485
+ - inspect `/fusion status`; panel failures and the workflow timeout must both be present
486
+ - retry only after the run is terminal
487
+
470
488
  Run is stuck or no longer useful:
471
489
 
472
490
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,80 @@
1
+ import type { CallerOutputContract } from "./types.js";
2
+
3
+ export type CallerOutputValidation =
4
+ | { ok: true }
5
+ | { ok: false; error: string };
6
+
7
+ const PLAN_REVIEW_HEADER = /^FINDING:\s*(CRITICAL|MAJOR|MINOR)\s*\|\s*\S.*$/;
8
+
9
+ export function isCallerOutputContract(
10
+ value: unknown,
11
+ ): value is CallerOutputContract {
12
+ return value === "plan-review-v1";
13
+ }
14
+
15
+ export function detectCallerOutputContract(
16
+ prompt: string,
17
+ ): CallerOutputContract | undefined {
18
+ const hasExactCleanToken = /exact line\s+`?NO_FINDINGS`?/i.test(prompt);
19
+ const hasFindingContract =
20
+ /exact format[\s\S]*FINDING:\s*(?:CRITICAL\|MAJOR\|MINOR|CRITICAL|MAJOR|MINOR)/i.test(
21
+ prompt,
22
+ );
23
+ const forbidsProse = /do not write any other prose/i.test(prompt);
24
+ return hasExactCleanToken && hasFindingContract && forbidsProse
25
+ ? "plan-review-v1"
26
+ : undefined;
27
+ }
28
+
29
+ export function validateCallerOutput(
30
+ contract: CallerOutputContract,
31
+ output: string,
32
+ ): CallerOutputValidation {
33
+ switch (contract) {
34
+ case "plan-review-v1":
35
+ return validatePlanReviewOutput(output);
36
+ }
37
+ }
38
+
39
+ export function callerOutputContractInstructions(
40
+ contract: CallerOutputContract,
41
+ ): readonly string[] {
42
+ switch (contract) {
43
+ case "plan-review-v1":
44
+ return [
45
+ "The exact output contract in the original task takes priority over Fusion's normal Markdown sections.",
46
+ "Return only NO_FINDINGS or complete FINDING/Evidence/Fix blocks as requested by the original task.",
47
+ "Do not add Fusion headings, commentary, or a panel decision record.",
48
+ ];
49
+ }
50
+ }
51
+
52
+ function validatePlanReviewOutput(output: string): CallerOutputValidation {
53
+ const trimmed = output.trim();
54
+ if (trimmed === "NO_FINDINGS") return { ok: true };
55
+
56
+ const blocks = trimmed.split(/(?=^[ \t]*FINDING:\s)/m);
57
+ if (
58
+ blocks.length > 0 &&
59
+ blocks.every((block) => {
60
+ const lines = block
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trim())
63
+ .filter(Boolean);
64
+ return (
65
+ lines.length === 3 &&
66
+ PLAN_REVIEW_HEADER.test(lines[0] ?? "") &&
67
+ /^Evidence:\s*\S.*$/.test(lines[1] ?? "") &&
68
+ /^Fix:\s*\S.*$/.test(lines[2] ?? "")
69
+ );
70
+ })
71
+ ) {
72
+ return { ok: true };
73
+ }
74
+
75
+ return {
76
+ ok: false,
77
+ error:
78
+ "Fusion synthesis violated the exact caller output contract. Expected NO_FINDINGS or complete FINDING/Evidence/Fix blocks with no other prose.",
79
+ };
80
+ }
package/src/config.ts CHANGED
@@ -115,7 +115,10 @@ export function createDefaultFusionConfig(): FusionConfig {
115
115
  thinking: "high",
116
116
  },
117
117
  concurrency: 3,
118
- timeoutMs: 300_000,
118
+ panelTimeoutMs: 900_000,
119
+ judgeTimeoutMs: 900_000,
120
+ panelToolBudget: { soft: 8, hard: 12, block: "*" },
121
+ judgeToolBudget: { soft: 8, hard: 12, block: "*" },
119
122
  context: "fresh",
120
123
  stopWhenPanelAgrees: false,
121
124
  },
@@ -322,6 +325,18 @@ function isFusionProfile(value: unknown): value is FusionProfile {
322
325
  return false;
323
326
  if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
324
327
  return false;
328
+ if (
329
+ value.panelTimeoutMs !== undefined &&
330
+ !isPositiveInteger(value.panelTimeoutMs)
331
+ ) {
332
+ return false;
333
+ }
334
+ if (
335
+ value.judgeTimeoutMs !== undefined &&
336
+ !isPositiveInteger(value.judgeTimeoutMs)
337
+ ) {
338
+ return false;
339
+ }
325
340
  if (value.context !== undefined && !isFusionContextMode(value.context))
326
341
  return false;
327
342
  if (
@@ -336,6 +351,12 @@ function isFusionProfile(value: unknown): value is FusionProfile {
336
351
  ) {
337
352
  return false;
338
353
  }
354
+ if (
355
+ value.panelToolBudget !== undefined &&
356
+ !isToolBudget(value.panelToolBudget)
357
+ ) {
358
+ return false;
359
+ }
339
360
  if (
340
361
  value.judgeToolBudget !== undefined &&
341
362
  !isToolBudget(value.judgeToolBudget)
@@ -354,9 +375,9 @@ function isFusionProfile(value: unknown): value is FusionProfile {
354
375
 
355
376
  function isToolBudget(value: unknown): value is ToolBudget {
356
377
  if (!isRecord(value)) return false;
378
+ if (value.soft === undefined && value.hard === undefined) return false;
357
379
  if (value.soft !== undefined && !isPositiveInteger(value.soft)) return false;
358
380
  if (value.hard !== undefined && !isPositiveInteger(value.hard)) return false;
359
- if (value.soft === undefined && value.hard === undefined) return false;
360
381
  if (
361
382
  isPositiveInteger(value.soft) &&
362
383
  isPositiveInteger(value.hard) &&
@@ -364,6 +385,15 @@ function isToolBudget(value: unknown): value is ToolBudget {
364
385
  ) {
365
386
  return false;
366
387
  }
388
+ if (
389
+ value.block !== undefined &&
390
+ value.block !== "*" &&
391
+ (!Array.isArray(value.block) ||
392
+ value.block.length === 0 ||
393
+ !value.block.every(isNonEmptyString))
394
+ ) {
395
+ return false;
396
+ }
367
397
  return true;
368
398
  }
369
399
 
package/src/fusion-rpc.ts CHANGED
@@ -1,9 +1,19 @@
1
+ import {
2
+ detectCallerOutputContract,
3
+ isCallerOutputContract,
4
+ validateCallerOutput,
5
+ } from "./caller-contract.js";
1
6
  import type {
2
7
  FusionCommandContext,
3
8
  FusionCommandResult,
4
9
  } from "./orchestrator.js";
5
10
  import type { FusionRunStore } from "./run-store.js";
6
- import type { FusionPhase, FusionRun, ParsedFusionArgs } from "./types.js";
11
+ import type {
12
+ CallerOutputContract,
13
+ FusionPhase,
14
+ FusionRun,
15
+ ParsedFusionArgs,
16
+ } from "./types.js";
7
17
  import { isNonEmptyString, isRecord } from "./utils.js";
8
18
 
9
19
  export const FUSION_RPC_VERSION = 1;
@@ -70,8 +80,14 @@ export interface FusionRpcStatusData {
70
80
  run: FusionRunState;
71
81
  }
72
82
 
83
+ export interface FusionRpcCallerOutput {
84
+ contract: CallerOutputContract;
85
+ output: string;
86
+ }
87
+
73
88
  export interface FusionRpcResultData {
74
89
  run: FusionRunState;
90
+ callerOutput?: FusionRpcCallerOutput;
75
91
  }
76
92
 
77
93
  export interface FusionRpcCancelData {
@@ -134,6 +150,7 @@ interface StartParams {
134
150
  prompt: string;
135
151
  profile?: string;
136
152
  operationId: string;
153
+ outputContract?: CallerOutputContract;
137
154
  }
138
155
 
139
156
  interface RunParams {
@@ -143,7 +160,13 @@ interface RunParams {
143
160
 
144
161
  type ObservableRun = Pick<
145
162
  FusionRun,
146
- "id" | "operationId" | "phase" | "report" | "error"
163
+ | "id"
164
+ | "operationId"
165
+ | "phase"
166
+ | "prompt"
167
+ | "outputContract"
168
+ | "report"
169
+ | "error"
147
170
  >;
148
171
 
149
172
  const TERMINAL_PHASES = new Set<FusionPhase>(["done", "failed", "cancelled"]);
@@ -252,7 +275,8 @@ export function registerFusionRpc({
252
275
  details: { run: state },
253
276
  });
254
277
  }
255
- return { run: state };
278
+ const callerOutput = validatedCallerOutput(run);
279
+ return { run: state, ...(callerOutput ? { callerOutput } : {}) };
256
280
  }
257
281
 
258
282
  async function cancel(params: unknown): Promise<FusionRpcCancelData> {
@@ -401,19 +425,30 @@ function parseStartParams(input: unknown): StartParams {
401
425
  );
402
426
  }
403
427
 
404
- return profile === undefined
405
- ? { prompt, operationId }
406
- : { prompt, operationId, profile };
428
+ const outputContract = input.outputContract;
429
+ if (outputContract !== undefined && !isCallerOutputContract(outputContract)) {
430
+ throw invalidParams(
431
+ "start outputContract must be a supported caller output contract.",
432
+ );
433
+ }
434
+
435
+ return {
436
+ prompt,
437
+ operationId,
438
+ ...(profile === undefined ? {} : { profile }),
439
+ ...(outputContract === undefined ? {} : { outputContract }),
440
+ };
407
441
  }
408
442
 
409
443
  function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
410
- return input.profile === undefined
411
- ? { prompt: input.prompt, operationId: input.operationId }
412
- : {
413
- prompt: input.prompt,
414
- profile: input.profile,
415
- operationId: input.operationId,
416
- };
444
+ return {
445
+ prompt: input.prompt,
446
+ operationId: input.operationId,
447
+ ...(input.profile === undefined ? {} : { profile: input.profile }),
448
+ ...(input.outputContract === undefined
449
+ ? {}
450
+ : { outputContract: input.outputContract }),
451
+ };
417
452
  }
418
453
 
419
454
  function findRun(
@@ -548,6 +583,17 @@ function startFailure(message: string, run?: ObservableRun): RpcFailure {
548
583
  });
549
584
  }
550
585
 
586
+ function validatedCallerOutput(
587
+ run: ObservableRun,
588
+ ): FusionRpcCallerOutput | undefined {
589
+ if (!run.report) return undefined;
590
+ const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
591
+ if (!contract || !validateCallerOutput(contract, run.report).ok) {
592
+ return undefined;
593
+ }
594
+ return { contract, output: run.report.trim() };
595
+ }
596
+
551
597
  function stateFor(run: ObservableRun): FusionRunState {
552
598
  const state: FusionRunState = {
553
599
  runId: run.id,
@@ -0,0 +1,248 @@
1
+ import type { FailedPanelSummary, PanelOutput } from "./types.js";
2
+ import { mergeRunObservations } from "./run-observations.js";
3
+ import {
4
+ extractPanelResults,
5
+ type ExtractPanelResultsResult,
6
+ type ExtractPanelResultsSuccess,
7
+ } from "./result-extract.js";
8
+ import type { FusionProfile } from "./types.js";
9
+ import { isRecord } from "./utils.js";
10
+
11
+ export function reconcileIndexedLifecycleResult(
12
+ eventPayload: unknown,
13
+ statusPayload: unknown,
14
+ index: number,
15
+ label: "judge" | "panel",
16
+ ): string | undefined {
17
+ const eventResults = findLifecycleArray(eventPayload, "results");
18
+ const eventResult = eventResults?.[index];
19
+ if (!isRecord(eventResult)) return undefined;
20
+
21
+ const statusSteps = findLifecycleArray(statusPayload, "steps");
22
+ const rawStatusResults = findLifecycleArray(statusPayload, "results");
23
+ const statusResults =
24
+ statusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
25
+ const statusResult = statusResults?.[index];
26
+ if (statusResults && !isRecord(statusResult)) {
27
+ return `Subagents event includes ${label} result ${index + 1}, but status does not.`;
28
+ }
29
+ if (!isRecord(statusResult)) return undefined;
30
+ if (isFailedLifecycleResult(eventResult) === isFailedLifecycleResult(statusResult)) {
31
+ return undefined;
32
+ }
33
+ return `Subagents event and status disagree about ${label} result ${index + 1}.`;
34
+ }
35
+
36
+ export interface ReconcilePanelResultsOptions {
37
+ /** Extra terminal result allowed after the configured panel (the judge). */
38
+ allowedTrailingResults?: number;
39
+ /** Indices intentionally absent from status after early agreement. */
40
+ stoppedPanelIndices?: readonly number[];
41
+ }
42
+
43
+ /**
44
+ * Reconciles compact completion events with the richer lifecycle snapshot.
45
+ * Status is authoritative when it describes every configured panel member.
46
+ * Incomplete or contradictory lifecycle data fails closed.
47
+ */
48
+ export function reconcilePanelResults(
49
+ eventResults: ExtractPanelResultsSuccess,
50
+ statusPayload: unknown,
51
+ profile: FusionProfile,
52
+ resultPayload: unknown,
53
+ options: ReconcilePanelResultsOptions = {},
54
+ ): ExtractPanelResultsResult {
55
+ const expectedCount = profile.panel.length;
56
+ const allowedTrailing = options.allowedTrailingResults ?? 0;
57
+ const rawEvent = findLifecycleArray(resultPayload, "results");
58
+ if (rawEvent && rawEvent.length > expectedCount + allowedTrailing) {
59
+ return error(
60
+ `Terminal subagents data contained ${rawEvent.length} results for ${expectedCount + allowedTrailing} expected workflow steps.`,
61
+ "$.results",
62
+ );
63
+ }
64
+
65
+ const rawStatusSteps = findLifecycleArray(statusPayload, "steps");
66
+ const rawStatusResults = findLifecycleArray(statusPayload, "results");
67
+ const rawStatus =
68
+ rawStatusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
69
+ if (!rawStatus) {
70
+ if (eventResults.outputs.length + eventResults.failures.length !== expectedCount) {
71
+ return error(
72
+ `Terminal subagents data described ${eventResults.outputs.length + eventResults.failures.length} of ${expectedCount} configured panel members.`,
73
+ "$.results",
74
+ );
75
+ }
76
+ return eventResults;
77
+ }
78
+
79
+ if (rawStatus.length > expectedCount + allowedTrailing) {
80
+ return error(
81
+ `Terminal subagents status contained ${rawStatus.length} steps for ${expectedCount + allowedTrailing} expected workflow steps.`,
82
+ "$.steps",
83
+ );
84
+ }
85
+
86
+ const statusResults = extractPanelResults(statusPayload, {
87
+ panel: profile.panel,
88
+ completedOnly: true,
89
+ limit: expectedCount,
90
+ ...(options.stoppedPanelIndices
91
+ ? { stoppedPanelIndices: options.stoppedPanelIndices }
92
+ : {}),
93
+ });
94
+ if (!statusResults.ok) {
95
+ return error(
96
+ `${statusResults.error.message} (${statusResults.error.path})`,
97
+ statusResults.error.path,
98
+ );
99
+ }
100
+
101
+ const statusCount =
102
+ statusResults.outputs.length + statusResults.failures.length;
103
+ const eventCount =
104
+ eventResults.outputs.length + eventResults.failures.length;
105
+ const statusIndices = new Set([
106
+ ...statusResults.outputs.map(({ index }) => index),
107
+ ...statusResults.failures.map(({ index }) => index),
108
+ ]);
109
+ const stopped = new Set(options.stoppedPanelIndices ?? []);
110
+ const missingStatusIndices = Array.from(
111
+ { length: expectedCount },
112
+ (_, index) => index,
113
+ ).filter((index) => !statusIndices.has(index));
114
+
115
+ if (statusCount === expectedCount) {
116
+ return preserveAgreementReasons(statusResults, eventResults);
117
+ }
118
+
119
+ const missingWereStopped =
120
+ eventCount === expectedCount &&
121
+ missingStatusIndices.length > 0 &&
122
+ missingStatusIndices.every((index) => stopped.has(index));
123
+ if (!missingWereStopped) {
124
+ return error(
125
+ `Terminal subagents status described ${statusCount} of ${expectedCount} configured panel members.`,
126
+ "$.steps",
127
+ );
128
+ }
129
+
130
+ if (eventCount !== expectedCount) {
131
+ return error(
132
+ `Terminal subagents data described ${eventCount} of ${expectedCount} configured panel members.`,
133
+ "$.results",
134
+ );
135
+ }
136
+
137
+ return mergeObservations(eventResults, statusResults);
138
+ }
139
+
140
+ function preserveAgreementReasons(
141
+ status: ExtractPanelResultsSuccess,
142
+ event: ExtractPanelResultsSuccess,
143
+ ): ExtractPanelResultsSuccess {
144
+ const eventFailures = new Map(
145
+ event.failures.map((failure) => [failure.index, failure]),
146
+ );
147
+ return {
148
+ ...status,
149
+ failures: status.failures.map((failure) => {
150
+ const eventFailure = eventFailures.get(failure.index);
151
+ return eventFailure?.reason === "stopped-after-agreement"
152
+ ? { ...failure, reason: eventFailure.reason }
153
+ : failure;
154
+ }),
155
+ };
156
+ }
157
+
158
+ function mergeObservations(
159
+ event: ExtractPanelResultsSuccess,
160
+ status: ExtractPanelResultsSuccess,
161
+ ): ExtractPanelResultsSuccess {
162
+ const observations = new Map<number, PanelOutput["observation"]>();
163
+ for (const output of status.outputs) {
164
+ observations.set(output.index, output.observation);
165
+ }
166
+ for (const failure of status.failures) {
167
+ observations.set(failure.index, failure.observation);
168
+ }
169
+
170
+ return {
171
+ ...event,
172
+ outputs: event.outputs.map((output) =>
173
+ withObservation(output, observations.get(output.index)),
174
+ ),
175
+ failures: event.failures.map((failure) =>
176
+ withObservation(failure, observations.get(failure.index)),
177
+ ),
178
+ };
179
+ }
180
+
181
+ function withObservation<T extends PanelOutput | FailedPanelSummary>(
182
+ item: T,
183
+ statusObservation: PanelOutput["observation"] | undefined,
184
+ ): T {
185
+ const observation = mergeRunObservations(statusObservation, item.observation);
186
+ return hasObservationData(observation) ? { ...item, observation } : item;
187
+ }
188
+
189
+ function hasObservationData(
190
+ observation: PanelOutput["observation"] | undefined,
191
+ ): boolean {
192
+ return Boolean(
193
+ observation &&
194
+ (observation.model ||
195
+ observation.durationMs !== undefined ||
196
+ observation.usage ||
197
+ observation.attempts ||
198
+ observation.providerFailures),
199
+ );
200
+ }
201
+
202
+ function findLifecycleArray(
203
+ payload: unknown,
204
+ key: "results" | "steps",
205
+ ): readonly unknown[] | undefined {
206
+ if (!isRecord(payload)) return undefined;
207
+ const direct = unknownArray(payload[key]);
208
+ if (direct) return direct;
209
+ if (isRecord(payload.details)) {
210
+ const nested = unknownArray(payload.details[key]);
211
+ if (nested) return nested;
212
+ }
213
+ if (isRecord(payload.data)) return findLifecycleArray(payload.data, key);
214
+ return undefined;
215
+ }
216
+
217
+ function isFailedLifecycleResult(result: Record<string, unknown>): boolean {
218
+ if (result.success === false) return true;
219
+ if (result.timedOut === true || result.interrupted === true) return true;
220
+ if (typeof result.error === "string" && result.error.trim()) return true;
221
+ const status = firstString(result.status, result.state);
222
+ return status === "failed" || status === "paused" || status === "detached";
223
+ }
224
+
225
+ function firstString(...values: readonly unknown[]): string | undefined {
226
+ for (const value of values) {
227
+ if (typeof value === "string") return value;
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ function unknownArray(value: unknown): readonly unknown[] | undefined {
233
+ return Array.isArray(value) ? value : undefined;
234
+ }
235
+
236
+ function error(
237
+ message: string,
238
+ path: string,
239
+ ): ExtractPanelResultsResult {
240
+ return {
241
+ ok: false,
242
+ error: {
243
+ code: "unknown-result-shape",
244
+ message,
245
+ path,
246
+ },
247
+ };
248
+ }