@mrclrchtr/supi-review 2.6.1 → 2.8.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
@@ -134,9 +134,29 @@ Every `/supi-review` command run asks you to choose the reviewer model.
134
134
  - the picker only shows **scoped models** from Pi's `enabledModels` configuration
135
135
  - the current session model is preselected only when it is inside that scoped set
136
136
  - the selected model is used for both brief synthesis and the final review
137
- - no review model is persisted in settings
137
+ - the command selection is not persisted
138
138
 
139
- Agent-driven tool runs use the current session model for preparation and all focused reviewers. The selected model is retained in the prepared plan so a model change between tool calls cannot silently alter the run.
139
+ Agent-driven tool runs use the **Review Agent tool model** setting in `/supi-settings` when `@mrclrchtr/supi-settings` is installed:
140
+
141
+ - `current session model` is the default and preserves the active-session behavior
142
+ - an explicit choice stores a canonical `provider/model-id` from Pi's scoped `enabledModels` set
143
+ - the configured model is used for brief synthesis and every focused reviewer
144
+ - project settings override global settings through the normal SuPi settings scopes
145
+ - a configured model that is no longer scoped or available causes preparation to fail with a corrective error
146
+
147
+ The resolved model is retained in the prepared plan, so changing the setting or active session model between tool calls cannot silently alter the run.
148
+
149
+ For a standalone `supi-review` install without `supi-settings`, set the same value directly in the global `~/.pi/agent/supi/config.json` or project `.pi/supi/config.json` file:
150
+
151
+ ```json
152
+ {
153
+ "review": {
154
+ "agentModel": "openai/gpt-5"
155
+ }
156
+ }
157
+ ```
158
+
159
+ Use `"current"` instead of a canonical model id to follow the active session model.
140
160
 
141
161
  ## Result shape
142
162
 
@@ -175,7 +195,8 @@ When a successful review contains review items, `supi-review` also injects an ag
175
195
  ## Source
176
196
 
177
197
  - `src/review.ts` — command orchestration and interactive flow
178
- - `src/model.ts` — explicit model selection helpers
198
+ - `src/config.ts` — persisted agent-tool model setting and `/supi-settings` registration
199
+ - `src/model.ts` — explicit and configured model selection helpers
179
200
  - `src/git.ts` — git snapshot resolution
180
201
  - `src/history/collect.ts` — compaction-style session-context serialization
181
202
  - `src/history/synthesize.ts` — brief synthesis orchestration
@@ -43,7 +43,7 @@ Config file locations:
43
43
  - `registerSettingsCommand(pi)` — register `/supi-settings` (used by `@mrclrchtr/supi-settings`)
44
44
  - `openSettingsOverlay(pi, ctx)` — open the shared settings UI directly
45
45
  - `createInputSubmenu()` — helper for simple text-entry submenus
46
- - `createModelPickerSubmenu()` — helper for scoped model selection submenus
46
+ - `createModelPickerSubmenu()` — helper for scoped model selection submenus, with optional host-owned choices and `disabled` control
47
47
 
48
48
  The built-in settings UI supports:
49
49
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -324,6 +324,7 @@ export class ScopedSettingsList {
324
324
  this.tui.requestRender();
325
325
  },
326
326
  this.ctx,
327
+ row.field.field,
327
328
  ),
328
329
  onDone: () => {
329
330
  this.submenu = null;
@@ -91,9 +91,23 @@ export interface StringListField extends BaseField {
91
91
  kind: "stringList";
92
92
  }
93
93
 
94
+ /** One non-model choice shown before the scoped models in a model picker. */
95
+ export interface ModelPickerStaticOption {
96
+ /** Persisted value for the choice. */
97
+ value: string;
98
+ /** Human-readable picker label. */
99
+ label: string;
100
+ /** Optional explanation shown alongside the label. */
101
+ description?: string;
102
+ }
103
+
94
104
  /** Model picker backed by the scoped model set. */
95
105
  export interface ModelPickerField extends BaseField {
96
106
  kind: "modelPicker";
107
+ /** Additional host-owned choices shown before scoped models. */
108
+ staticOptions?: ModelPickerStaticOption[];
109
+ /** Whether to include the built-in `disabled` choice. Defaults to true. */
110
+ includeDisabled?: boolean;
97
111
  }
98
112
 
99
113
  /**
@@ -14,6 +14,7 @@ import {
14
14
  Text,
15
15
  } from "@earendil-works/pi-tui";
16
16
  import { getSelectableModels } from "../model-selection.ts";
17
+ import type { ModelPickerField } from "./settings-schema.ts";
17
18
 
18
19
  /**
19
20
  * Creates a pi-tui Input-backed submenu component with enter-to-confirm
@@ -57,25 +58,26 @@ export function createInputSubmenu(
57
58
  }
58
59
 
59
60
  /**
60
- * Creates a model picker submenu for settings with "disabled" as the first option.
61
+ * Creates a model picker submenu backed by the scoped model set.
62
+ *
63
+ * The built-in `disabled` choice remains enabled by default. Callers can add
64
+ * host-owned static choices or omit `disabled` through the field options.
61
65
  */
62
66
  export function createModelPickerSubmenu(
63
67
  currentValue: string,
64
68
  done: (selectedValue?: string) => void,
65
69
  ctx?: ExtensionContext,
70
+ options: Pick<ModelPickerField, "includeDisabled" | "staticOptions"> = {},
66
71
  ): {
67
72
  render: (width: number) => string[];
68
73
  invalidate: () => void;
69
74
  handleInput: (data: string) => boolean;
70
75
  } {
71
- const items = buildModelItems(ctx);
72
- const initialIndex =
73
- currentValue === "disabled"
74
- ? 0
75
- : Math.max(
76
- 0,
77
- items.findIndex((item) => item.value === currentValue),
78
- );
76
+ const items = buildModelItems(ctx, options);
77
+ const initialIndex = Math.max(
78
+ 0,
79
+ items.findIndex((item) => item.value === currentValue),
80
+ );
79
81
 
80
82
  const container = new Container();
81
83
  container.addChild(new Text(" Select model", 1, 0));
@@ -105,20 +107,35 @@ export function createModelPickerSubmenu(
105
107
  };
106
108
  }
107
109
 
108
- /** Build selectable model items with "disabled" as the first option. */
109
- function buildModelItems(ctx?: ExtensionContext): SelectItem[] {
110
- const items: SelectItem[] = [
111
- { value: "disabled", label: "disabled", description: "No model selected" },
112
- ];
110
+ /** Build static choices followed by the selectable scoped models. */
111
+ function buildModelItems(
112
+ ctx: ExtensionContext | undefined,
113
+ options: Pick<ModelPickerField, "includeDisabled" | "staticOptions">,
114
+ ): SelectItem[] {
115
+ const items: SelectItem[] = [];
116
+ const seen = new Set<string>();
117
+ for (const option of options.staticOptions ?? []) {
118
+ if (seen.has(option.value)) continue;
119
+ items.push({ ...option });
120
+ seen.add(option.value);
121
+ }
122
+
123
+ if (options.includeDisabled !== false && !seen.has("disabled")) {
124
+ items.push({ value: "disabled", label: "disabled", description: "No model selected" });
125
+ seen.add("disabled");
126
+ }
127
+
113
128
  if (!ctx) return items;
114
129
  const models = getSelectableModels(ctx);
115
130
  for (const model of models) {
131
+ if (seen.has(model.canonicalId)) continue;
116
132
  const suffix = model.isCurrent ? " [current]" : "";
117
133
  items.push({
118
134
  value: model.canonicalId,
119
135
  label: `${model.canonicalId}${suffix}`,
120
136
  description: model.label !== model.canonicalId ? model.label : undefined,
121
137
  });
138
+ seen.add(model.canonicalId);
122
139
  }
123
140
  return items;
124
141
  }
@@ -20,6 +20,7 @@ export type {
20
20
  DeclarativeSettingsOptions,
21
21
  EnumField,
22
22
  ModelPickerField,
23
+ ModelPickerStaticOption,
23
24
  NumberField,
24
25
  ScopedFieldValue,
25
26
  SettingsField,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "description": "SuPi Review extension — session-aware review command and agent tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@mrclrchtr/supi-core": "2.6.1"
34
+ "@mrclrchtr/supi-core": "2.8.0"
35
35
  },
36
36
  "bundledDependencies": [
37
37
  "@mrclrchtr/supi-core"
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadSupiConfig } from "@mrclrchtr/supi-core/config";
3
+ import { registerDeclarativeSettings } from "@mrclrchtr/supi-core/settings";
4
+ import { CURRENT_SESSION_REVIEW_MODEL } from "./model.ts";
5
+
6
+ /** Persisted configuration for agent-driven Session-Aware Review. */
7
+ export interface ReviewConfig extends Record<string, unknown> {
8
+ /** Canonical `provider/model-id`, or `current` to use the active session model. */
9
+ agentModel: string;
10
+ }
11
+
12
+ /** Shared SuPi config section owned by supi-review. */
13
+ export const REVIEW_CONFIG_SECTION = "review";
14
+
15
+ /** Package defaults for supi-review configuration. */
16
+ export const REVIEW_DEFAULTS: ReviewConfig = {
17
+ agentModel: CURRENT_SESSION_REVIEW_MODEL,
18
+ };
19
+
20
+ /** Load merged, validated supi-review configuration for a workspace. */
21
+ export function loadReviewConfig(cwd: string, homeDir?: string): ReviewConfig {
22
+ const raw = loadSupiConfig(REVIEW_CONFIG_SECTION, cwd, REVIEW_DEFAULTS, { homeDir });
23
+ const agentModel =
24
+ typeof raw.agentModel === "string" && raw.agentModel.trim()
25
+ ? raw.agentModel.trim()
26
+ : REVIEW_DEFAULTS.agentModel;
27
+ return { agentModel };
28
+ }
29
+
30
+ /** Register the Review section contributed to `/supi-settings`. */
31
+ export function registerReviewSettings(pi: ExtensionAPI, homeDir?: string): void {
32
+ registerDeclarativeSettings(pi, {
33
+ id: REVIEW_CONFIG_SECTION,
34
+ label: "Review",
35
+ section: REVIEW_CONFIG_SECTION,
36
+ defaults: REVIEW_DEFAULTS,
37
+ fields: [
38
+ {
39
+ kind: "modelPicker",
40
+ key: "agentModel",
41
+ label: "Agent tool model",
42
+ description: "Model used for brief synthesis and reviewers started by supi_review_prepare.",
43
+ includeDisabled: false,
44
+ staticOptions: [
45
+ {
46
+ value: CURRENT_SESSION_REVIEW_MODEL,
47
+ label: "current session model",
48
+ description: "Use the model active when supi_review_prepare starts",
49
+ },
50
+ ],
51
+ },
52
+ ],
53
+ ...(homeDir ? { homeDir } : {}),
54
+ });
55
+ }
package/src/model.ts CHANGED
@@ -2,6 +2,9 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
3
3
  import type { ReviewModelSelection } from "./types.ts";
4
4
 
5
+ /** Sentinel that resolves to the model active when review preparation starts. */
6
+ export const CURRENT_SESSION_REVIEW_MODEL = "current";
7
+
5
8
  /** Build the canonical `provider/modelId` string used throughout the review flow. */
6
9
  export { toCanonicalModelId } from "@mrclrchtr/supi-core/model-selection";
7
10
 
@@ -35,3 +38,33 @@ export function getCurrentReviewModel(
35
38
  isCurrent: true,
36
39
  };
37
40
  }
41
+
42
+ /**
43
+ * Resolve the configured model for an agent-driven review.
44
+ *
45
+ * `current` preserves the historical behavior. Explicit canonical model ids
46
+ * must be both available and present in Pi's current scoped model set.
47
+ */
48
+ export function resolveAgentReviewModel(
49
+ ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
50
+ configuredModelId: string,
51
+ enabledModelPatterns?: string[],
52
+ ): ReviewModelSelection | undefined {
53
+ const modelId = configuredModelId.trim();
54
+ if (modelId === CURRENT_SESSION_REVIEW_MODEL) {
55
+ return getCurrentReviewModel(ctx);
56
+ }
57
+
58
+ const selection = getSelectableReviewModels(
59
+ { cwd: ctx.cwd, modelRegistry: ctx.modelRegistry, model: undefined },
60
+ enabledModelPatterns,
61
+ ).find((candidate) => candidate.canonicalId === modelId);
62
+ if (!selection) return undefined;
63
+
64
+ return {
65
+ ...selection,
66
+ isCurrent: ctx.model
67
+ ? `${ctx.model.provider}/${ctx.model.id}` === selection.canonicalId
68
+ : false,
69
+ };
70
+ }
package/src/review.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { WidgetProgress } from "@mrclrchtr/supi-core/progress-widget";
3
3
  import { runWithProgressWidget } from "@mrclrchtr/supi-core/tool-framework";
4
+ import { registerReviewSettings } from "./config.ts";
4
5
  import { resolveBranchSnapshot, resolveCommitSnapshot, resolveWorkingTreeSnapshot } from "./git.ts";
5
6
  import { serializeSessionContext } from "./history/collect.ts";
6
7
  import { synthesizeReviewBrief } from "./history/synthesize.ts";
7
8
  import { normalizeReviewResult } from "./review-result.ts";
8
9
  import { buildReviewPacket } from "./target/packet.ts";
9
10
  import { registerAgentReviewTools } from "./tool/agent-review-tools.ts";
11
+ import { createUnobservedChildFailureDiagnostics } from "./tool/child-failure-diagnostics.ts";
10
12
  import { runReviewer } from "./tool/review-runner.ts";
11
13
  import type {
12
14
  BriefSynthesisRunResult,
@@ -17,13 +19,18 @@ import type {
17
19
  ReviewTargetSpec,
18
20
  } from "./types.ts";
19
21
  import { collectReviewNote, previewReviewPlan, selectModel, selectTarget } from "./ui/flow.ts";
20
- import { formatReviewContent } from "./ui/format-content.ts";
22
+ import {
23
+ formatBriefSynthesisFailureContent,
24
+ formatBriefSynthesisFailureCopy,
25
+ formatReviewContent,
26
+ } from "./ui/format-content.ts";
21
27
  import { registerReviewRenderer } from "./ui/renderer.ts";
22
28
 
23
29
  type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
24
30
 
25
31
  export default function reviewExtension(pi: ExtensionAPI) {
26
32
  registerReviewRenderer(pi);
33
+ registerReviewSettings(pi);
27
34
  registerAgentReviewTools(pi);
28
35
 
29
36
  pi.registerCommand("supi-review", {
@@ -74,22 +81,21 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
74
81
  );
75
82
 
76
83
  if (!synthesis) {
77
- notifyBriefDone(
78
- pi,
79
- {
80
- kind: "failed",
81
- reason: "Brief synthesis encountered an unexpected error",
82
- } as BriefSynthesisRunResult,
83
- snapshot,
84
- model.canonicalId,
85
- );
86
- ctx.ui.notify("Brief synthesis was canceled or failed", "warning");
84
+ const failure: BriefSynthesisRunResult = {
85
+ kind: "failed",
86
+ failureCode: "unexpected-runner-failure",
87
+ diagnostics: createUnobservedChildFailureDiagnostics(),
88
+ };
89
+ notifyBriefDone(pi, failure, snapshot, model.canonicalId);
90
+ injectBriefSynthesisFailureMessage(pi, failure, snapshot, model.canonicalId);
91
+ ctx.ui.notify(formatBriefSynthesisFailureCopy(failure), "warning");
87
92
  return;
88
93
  }
89
94
 
90
95
  notifyBriefDone(pi, synthesis, snapshot, model.canonicalId);
91
96
 
92
97
  if (synthesis.kind !== "success") {
98
+ injectBriefSynthesisFailureMessage(pi, synthesis, snapshot, model.canonicalId);
93
99
  notifySynthesisFailure(synthesis, ctx);
94
100
  return;
95
101
  }
@@ -122,14 +128,17 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
122
128
  );
123
129
 
124
130
  if (!rawResult) {
125
- notifyReviewDone(pi, {
131
+ const failure: ReviewResult = {
126
132
  kind: "failed",
127
- reason: "Review encountered an unexpected error",
133
+ failureCode: "unexpected-runner-failure",
134
+ diagnostics: createUnobservedChildFailureDiagnostics(),
128
135
  snapshot,
129
136
  brief,
130
137
  modelId: model.canonicalId,
131
- } as ReviewResult);
132
- ctx.ui.notify("Review was canceled or failed", "warning");
138
+ };
139
+ notifyReviewDone(pi, failure);
140
+ injectReviewMessage(pi, failure);
141
+ ctx.ui.notify("Reviewer ended unexpectedly.", "warning");
133
142
  return;
134
143
  }
135
144
 
@@ -172,20 +181,27 @@ function notifySynthesisFailure(
172
181
  result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
173
182
  ctx: CommandContext,
174
183
  ): void {
175
- switch (result.kind) {
176
- case "failed":
177
- ctx.ui.notify(result.reason, "error");
178
- break;
179
- case "timeout":
180
- ctx.ui.notify(
181
- `Brief synthesis timed out after ${(result.timeoutMs / 1000).toFixed(0)}s`,
182
- "warning",
183
- );
184
- break;
185
- case "canceled":
186
- ctx.ui.notify("Review canceled", "warning");
187
- break;
188
- }
184
+ ctx.ui.notify(
185
+ formatBriefSynthesisFailureCopy(result),
186
+ result.kind === "failed" ? "error" : "warning",
187
+ );
188
+ }
189
+
190
+ /** Persist a brief-synthesis failure so bounded diagnostics remain inspectable in parent history. */
191
+ function injectBriefSynthesisFailureMessage(
192
+ pi: ExtensionAPI,
193
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
194
+ snapshot: ReviewSnapshot,
195
+ modelId: string,
196
+ ): void {
197
+ pi.sendMessage({
198
+ customType: "supi-review",
199
+ content: formatBriefSynthesisFailureContent(result),
200
+ display: true,
201
+ details: {
202
+ briefSynthesisFailure: { result, snapshot, modelId },
203
+ },
204
+ });
189
205
  }
190
206
 
191
207
  /** Ring the terminal bell so the user knows to check back. */
@@ -10,9 +10,10 @@ import {
10
10
  truncateHead,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
13
+ import { loadReviewConfig } from "../config.ts";
13
14
  import { isCommitObjectId, summarizeReviewSnapshot } from "../git.ts";
14
15
  import { serializeSessionContext } from "../history/collect.ts";
15
- import { getCurrentReviewModel } from "../model.ts";
16
+ import { CURRENT_SESSION_REVIEW_MODEL, resolveAgentReviewModel } from "../model.ts";
16
17
  import { ReviewPlanStore } from "../session/review-plan-store.ts";
17
18
  import type {
18
19
  BriefEvaluation,
@@ -20,6 +21,7 @@ import type {
20
21
  ReviewProgress,
21
22
  ReviewTargetSpec,
22
23
  } from "../types.ts";
24
+ import { formatBriefSynthesisFailureCopy } from "../ui/format-content.ts";
23
25
  import { formatAgentReviewBatch, formatPreparedAgentReview } from "../ui/review-tool-format.ts";
24
26
  import {
25
27
  type AgentReviewProgressDetails,
@@ -35,6 +37,7 @@ import {
35
37
  runAgentReviewSchema,
36
38
  } from "./agent-review-schemas.ts";
37
39
  import { prepareAgentReviewPlan, runAgentReviewBatch } from "./agent-review-workflow.ts";
40
+ import { formatChildLifecycleTrace } from "./child-lifecycle-trace.ts";
38
41
  import {
39
42
  PREPARE_REVIEW_TOOL_NAME,
40
43
  prepareReviewPromptGuidelines,
@@ -62,8 +65,16 @@ export function registerAgentReviewTools(
62
65
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
63
66
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
64
67
  const input = params as PrepareAgentReviewInput;
65
- const model = getCurrentReviewModel(ctx);
66
- if (!model) throw new Error("No current session model is available for review preparation.");
68
+ const configuredModelId = loadReviewConfig(ctx.cwd).agentModel;
69
+ const model = resolveAgentReviewModel(ctx, configuredModelId);
70
+ if (!model) {
71
+ if (configuredModelId === CURRENT_SESSION_REVIEW_MODEL) {
72
+ throw new Error("No current session model is available for review preparation.");
73
+ }
74
+ throw new Error(
75
+ `Configured agent review model "${configuredModelId}" is not available in Pi's scoped model set. Choose another model in /supi-settings.`,
76
+ );
77
+ }
67
78
 
68
79
  const target = parseTarget(input);
69
80
  const sessionContext = buildSessionContext(
@@ -228,14 +239,12 @@ function formatPreparationFailure(
228
239
  outcome: Exclude<Awaited<ReturnType<typeof prepareAgentReviewPlan>>, { kind: "prepared" }>,
229
240
  ): string {
230
241
  if (outcome.kind === "no-snapshot") return outcome.reason;
231
- switch (outcome.result.kind) {
232
- case "failed":
233
- return `Brief synthesis failed: ${outcome.result.reason}`;
234
- case "canceled":
235
- return "Brief synthesis was canceled.";
236
- case "timeout":
237
- return `Brief synthesis timed out after ${(outcome.result.timeoutMs / 1_000).toFixed(0)}s.`;
238
- }
242
+
243
+ const { result } = outcome;
244
+ const trace = result.diagnostics?.lifecycleTrace;
245
+ return trace
246
+ ? `${formatBriefSynthesisFailureCopy(result)}\n\n${formatChildLifecycleTrace(trace)}`
247
+ : formatBriefSynthesisFailureCopy(result);
239
248
  }
240
249
 
241
250
  function activateRunTool(pi: ExtensionAPI): void {
@@ -22,8 +22,8 @@ import type {
22
22
  ReviewTargetSpec,
23
23
  SynthesizedReviewBrief,
24
24
  } from "../types.ts";
25
+ import { createUnobservedChildFailureDiagnostics } from "./child-failure-diagnostics.ts";
25
26
  import { runReviewer } from "./review-runner.ts";
26
-
27
27
  /** Inputs required to synthesize and retain one agent-driven review plan. */
28
28
  export interface PrepareAgentReviewWorkflowInput {
29
29
  cwd: string;
@@ -35,13 +35,11 @@ export interface PrepareAgentReviewWorkflowInput {
35
35
  onProgress?: (progress: ReviewProgress) => void;
36
36
  planStore: ReviewPlanStore;
37
37
  }
38
-
39
38
  /** Typed preparation outcome before any reviewer child session starts. */
40
39
  export type PrepareAgentReviewWorkflowOutcome =
41
40
  | { kind: "prepared"; plan: StoredAgentReviewPlan }
42
41
  | { kind: "no-snapshot"; reason: string }
43
42
  | { kind: "synthesis-failed"; result: Exclude<BriefSynthesisRunResult, { kind: "success" }> };
44
-
45
43
  /** Inputs required to critique and execute one prepared review plan. */
46
44
  export interface RunAgentReviewWorkflowInput {
47
45
  cwd: string;
@@ -55,13 +53,11 @@ export interface RunAgentReviewWorkflowInput {
55
53
  onReviewerDone?: (reviewerId: string) => void;
56
54
  planStore: ReviewPlanStore;
57
55
  }
58
-
59
56
  /** Typed batch outcome after validation and snapshot freshness checks. */
60
57
  export type RunAgentReviewWorkflowOutcome =
61
58
  | { kind: "completed"; details: AgentReviewBatchDetails }
62
59
  | { kind: "invalid"; reason: string }
63
60
  | { kind: "stale"; reason: string };
64
-
65
61
  /** Prepare one session-scoped review plan without starting reviewer sessions. */
66
62
  export async function prepareAgentReviewPlan(
67
63
  input: PrepareAgentReviewWorkflowInput,
@@ -74,15 +70,27 @@ export async function prepareAgentReviewPlan(
74
70
  };
75
71
  }
76
72
 
77
- const synthesis = await synthesizeReviewBrief({
78
- model: input.model,
79
- cwd: input.cwd,
80
- snapshot,
81
- serializedContext: input.serializedContext,
82
- note: input.note,
83
- signal: input.signal,
84
- onProgress: input.onProgress,
85
- });
73
+ let synthesis: BriefSynthesisRunResult;
74
+ try {
75
+ synthesis = await synthesizeReviewBrief({
76
+ model: input.model,
77
+ cwd: input.cwd,
78
+ snapshot,
79
+ serializedContext: input.serializedContext,
80
+ note: input.note,
81
+ signal: input.signal,
82
+ onProgress: input.onProgress,
83
+ });
84
+ } catch {
85
+ return {
86
+ kind: "synthesis-failed",
87
+ result: {
88
+ kind: "failed",
89
+ failureCode: "unexpected-runner-failure",
90
+ diagnostics: createUnobservedChildFailureDiagnostics(),
91
+ },
92
+ };
93
+ }
86
94
  if (synthesis.kind !== "success") {
87
95
  return { kind: "synthesis-failed", result: synthesis };
88
96
  }
@@ -195,13 +203,14 @@ async function runAssignment(
195
203
  onProgress: (progress) => input.onReviewerProgress?.(assignment.id, progress),
196
204
  });
197
205
  return { assignment, result: compactReviewResult(normalizeReviewResult(rawResult)) };
198
- } catch (error) {
206
+ } catch {
199
207
  return {
200
208
  assignment,
201
209
  result: {
202
210
  kind: "failed",
203
- reason: `Reviewer session failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
211
+ failureCode: "unexpected-runner-failure",
204
212
  modelId: plan.model.canonicalId,
213
+ diagnostics: createUnobservedChildFailureDiagnostics(),
205
214
  },
206
215
  };
207
216
  }
@@ -212,21 +221,30 @@ function compactReviewResult(result: ReviewResult): AgentReviewerResult {
212
221
  case "success":
213
222
  return { kind: "success", output: result.output, modelId: result.modelId };
214
223
  case "failed":
224
+ return result.failureCode === "session-creation-failed"
225
+ ? {
226
+ kind: "failed",
227
+ failureCode: result.failureCode,
228
+ modelId: result.modelId,
229
+ }
230
+ : {
231
+ kind: "failed",
232
+ failureCode: result.failureCode,
233
+ modelId: result.modelId,
234
+ diagnostics: result.diagnostics,
235
+ };
236
+ case "canceled":
215
237
  return {
216
- kind: "failed",
217
- reason: result.reason,
238
+ kind: "canceled",
218
239
  modelId: result.modelId,
219
- debug: result.debug,
240
+ diagnostics: result.diagnostics,
220
241
  };
221
- case "canceled":
222
- return { kind: "canceled", modelId: result.modelId, debug: result.debug };
223
242
  case "timeout":
224
243
  return {
225
244
  kind: "timeout",
226
245
  timeoutMs: result.timeoutMs,
227
- partialOutput: result.partialOutput,
228
246
  modelId: result.modelId,
229
- debug: result.debug,
247
+ diagnostics: result.diagnostics,
230
248
  };
231
249
  }
232
250
  }