@mrclrchtr/supi-review 3.0.0 → 3.1.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "SuPi Review extension — caller-defined read-only review tasks for users and agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@mrclrchtr/supi-core": "3.0.0"
34
+ "@mrclrchtr/supi-core": "3.1.0"
35
35
  },
36
36
  "bundledDependencies": [
37
37
  "@mrclrchtr/supi-core"
package/src/git.ts CHANGED
@@ -17,7 +17,6 @@ import type {
17
17
  ReviewTargetSpec,
18
18
  } from "./types.ts";
19
19
 
20
- const FULL_COMMIT_ID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
21
20
  const DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--binary"] as const;
22
21
 
23
22
  function parseNullList(text: string): string[] {
@@ -40,17 +39,12 @@ export function parseDiffStats(text: string, fileCount?: number): DiffStats {
40
39
  return { files: fileCount ?? files, additions, deletions };
41
40
  }
42
41
 
43
- /** Validate the agent-facing full Git object-id syntax before invoking Git. */
44
- export function isFullCommitId(value: string): boolean {
45
- return FULL_COMMIT_ID_RE.test(value);
46
- }
47
-
48
42
  async function resolveCommit(cwd: string, value: string): Promise<string | undefined> {
49
- if (!isFullCommitId(value)) return undefined;
50
- const type = (await gitAllowExit(cwd, ["cat-file", "-t", value], [128])).trim();
43
+ const canonical = (await gitAllowExit(cwd, ["rev-parse", "--verify", value], [1, 128])).trim();
44
+ if (!canonical) return undefined;
45
+ const type = (await gitAllowExit(cwd, ["cat-file", "-t", canonical], [128])).trim();
51
46
  if (type !== "commit") return undefined;
52
- const canonical = (await git(cwd, ["rev-parse", "--verify", value])).trim().toLowerCase();
53
- return canonical === value.toLowerCase() ? canonical : undefined;
47
+ return canonical.toLowerCase();
54
48
  }
55
49
 
56
50
  async function headCommit(cwd: string): Promise<string | undefined> {
@@ -131,9 +125,16 @@ async function comparisonSnapshot(
131
125
  resolveCommit(cwd, requested.baseCommit),
132
126
  headCommit(cwd),
133
127
  ]);
134
- if (!base || !head) return undefined;
128
+ if (!base) {
129
+ throw new Error(`Commit ${requested.baseCommit.slice(0, 7)} not found in this repository.`);
130
+ }
131
+ if (!head) {
132
+ throw new Error("No HEAD commit found in this repository.");
133
+ }
135
134
  const mergeBase = (await gitAllowExit(cwd, ["merge-base", base, head], [1])).trim();
136
- if (!mergeBase) return undefined;
135
+ if (!mergeBase) {
136
+ throw new Error(`No common ancestor between ${base.slice(0, 7)} and ${head.slice(0, 7)}.`);
137
+ }
137
138
  const [diffText, names] = await Promise.all([
138
139
  git(cwd, ["diff", ...DIFF_FLAGS, mergeBase, head]),
139
140
  git(cwd, ["diff", "--name-only", "-z", mergeBase, head]),
@@ -160,7 +161,9 @@ async function commitSnapshot(
160
161
  requested: Extract<ReviewTargetSpec, { kind: "commit" }>,
161
162
  ): Promise<ReviewSnapshot | undefined> {
162
163
  const commit = await resolveCommit(cwd, requested.commit);
163
- if (!commit) return undefined;
164
+ if (!commit) {
165
+ throw new Error(`Commit ${requested.commit.slice(0, 7)} not found in this repository.`);
166
+ }
164
167
  const parent = await commitParent(cwd, commit);
165
168
  const [diffText, names] = await Promise.all([
166
169
  parent
package/src/review.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Box } from "@earendil-works/pi-tui";
3
- import { Value } from "typebox/value";
4
3
  import { loadReviewConfig, registerReviewSettings } from "./config.ts";
5
4
  import { listLocalBranches, listRecentCommits } from "./git.ts";
6
5
  import { collectPlannerContext } from "./history/collect.ts";
@@ -8,8 +7,7 @@ import { getSelectableReviewModels, resolveAgentReviewModel } from "./model.ts";
8
7
  import { ReviewPlanStore } from "./session/review-plan-store.ts";
9
8
  import { formatReviewBatch, registerAgentReviewTools } from "./tool/agent-review-tools.ts";
10
9
  import { pageText } from "./tool/output-page.ts";
11
- import { normalizeReviewInput, prepareReview, runReview } from "./tool/review-workflow.ts";
12
- import { reviewInputSchema } from "./tool/schemas.ts";
10
+ import { prepareReview, runReview } from "./tool/review-workflow.ts";
13
11
  import { renderRunResult } from "./tui/run.ts";
14
12
  import type { ReviewInput, ReviewModelSelection, ReviewTargetSpec } from "./types.ts";
15
13
 
@@ -58,25 +56,59 @@ async function selectReviewerModel(ctx: CommandContext): Promise<ReviewModelSele
58
56
  return models.find((model) => model.canonicalId === id);
59
57
  }
60
58
 
61
- /** Parse editor JSON through the same structural and semantic contract as agent input. */
62
- export async function editReview(
59
+ /** Edit one task's instructions via a focused editor. Returns undefined on cancel. */
60
+ async function editTaskInstructions(
61
+ ctx: CommandContext,
62
+ opts: { index: number; taskCount: number; id: string; defaultInstructions: string },
63
+ ): Promise<string | undefined> {
64
+ const { index, taskCount, id, defaultInstructions } = opts;
65
+ const label = taskCount > 1 ? `Task ${index + 1} of ${taskCount}: ${id}` : `Task: ${id}`;
66
+ const instructions = await ctx.ui.editor(label, defaultInstructions);
67
+ if (instructions === undefined) return undefined;
68
+ if (!instructions.trim()) {
69
+ ctx.ui.notify("Task instructions must not be blank.", "error");
70
+ return undefined;
71
+ }
72
+ return instructions.trim();
73
+ }
74
+
75
+ /** Step-by-step wizard: edit each task's instructions in its own focused editor. */
76
+ async function editReviewInteractive(
63
77
  ctx: CommandContext,
64
78
  review: ReviewInput,
79
+ { allowResize }: { allowResize: boolean },
65
80
  ): Promise<ReviewInput | undefined> {
66
- const text = await ctx.ui.editor("Edit review tasks (JSON)", JSON.stringify(review, null, 2));
67
- if (text === undefined) return undefined;
68
- try {
69
- const parsed: unknown = JSON.parse(text);
70
- if (!Value.Check(reviewInputSchema, parsed)) {
71
- ctx.ui.notify("Review tasks do not match the required review schema.", "error");
72
- return undefined;
73
- }
74
- return normalizeReviewInput(parsed);
75
- } catch (error) {
76
- const message = error instanceof Error ? error.message : "Review tasks must be valid JSON.";
77
- ctx.ui.notify(message, "error");
78
- return undefined;
81
+ let taskCount = review.tasks.length;
82
+
83
+ if (allowResize) {
84
+ const countStr = await ctx.ui.select("How many review tasks?", ["1", "2", "3", "4"]);
85
+ if (!countStr) return undefined;
86
+ taskCount = Number(countStr);
79
87
  }
88
+
89
+ const tasks: ReviewInput["tasks"] = [];
90
+ for (let i = 0; i < taskCount; i++) {
91
+ const existing = review.tasks[i];
92
+ const id = existing?.id ?? `task-${i + 1}`;
93
+ const defaultInstructions = existing?.instructions ?? "";
94
+
95
+ const instructions = await editTaskInstructions(ctx, {
96
+ index: i,
97
+ taskCount,
98
+ id,
99
+ defaultInstructions,
100
+ });
101
+ if (instructions === undefined) return undefined;
102
+ tasks.push({ id, instructions });
103
+ }
104
+
105
+ const result: ReviewInput = { tasks };
106
+ if (review.sharedContext) {
107
+ const sharedContext = await ctx.ui.editor("Shared context (optional)", review.sharedContext);
108
+ if (sharedContext === undefined) return undefined;
109
+ if (sharedContext.trim()) result.sharedContext = sharedContext.trim();
110
+ }
111
+ return result;
80
112
  }
81
113
 
82
114
  async function runCommand(
@@ -89,12 +121,15 @@ async function runCommand(
89
121
  if (!target) return;
90
122
  const reviewerModel = await selectReviewerModel(ctx);
91
123
  if (!reviewerModel) return;
92
- const planning = await ctx.ui.select("Review planning", ["Write tasks", "Suggest tasks"]);
124
+ const planning = await ctx.ui.select("Review planning", [
125
+ "Write my own tasks",
126
+ "AI suggests tasks",
127
+ ]);
93
128
  if (!planning) return;
94
129
 
95
130
  let review = DEFAULT_REVIEW;
96
131
  let planId: string | undefined;
97
- if (planning === "Suggest tasks") {
132
+ if (planning === "AI suggests tasks") {
98
133
  const config = loadReviewConfig(ctx.cwd);
99
134
  const plannerModel = resolveAgentReviewModel(ctx, config.plannerModel);
100
135
  if (!plannerModel) {
@@ -122,7 +157,9 @@ async function runCommand(
122
157
  planId = outcome.plan.id;
123
158
  }
124
159
 
125
- const edited = await editReview(ctx, review);
160
+ const edited = await editReviewInteractive(ctx, review, {
161
+ allowResize: planning === "Write my own tasks",
162
+ });
126
163
  if (!edited) return;
127
164
  const approved = await ctx.ui.confirm(
128
165
  "Run review?",
@@ -5,10 +5,11 @@ import type { ReviewInput, ReviewTargetSpec } from "../types.ts";
5
5
  import { reviewInputSchema } from "./schemas.ts";
6
6
 
7
7
  const commitId = Type.String({
8
- minLength: 40,
8
+ minLength: 7,
9
9
  maxLength: 64,
10
- pattern: "^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$",
11
- description: "Full hexadecimal Git commit object id (40 or 64 characters).",
10
+ pattern: "^[0-9a-fA-F]{7,64}$",
11
+ description:
12
+ "Git commit hash (7-64 hex characters). Short hashes are resolved to full commit ids.",
12
13
  });
13
14
  const targetSchema = Type.Object(
14
15
  {
@@ -73,16 +74,22 @@ function parseTarget(input: {
73
74
  baseCommit?: string;
74
75
  commit?: string;
75
76
  }): ReviewTargetSpec {
76
- if (input.kind === "working-tree" && !input.baseCommit && !input.commit) {
77
+ if (input.kind === "working-tree") {
77
78
  return { kind: "working-tree" };
78
79
  }
79
- if (input.kind === "comparison" && input.baseCommit && !input.commit) {
80
+ if (input.kind === "comparison") {
81
+ if (!input.baseCommit) {
82
+ throw new Error(`Comparison targets require a baseCommit (full 40- or 64-char commit id).`);
83
+ }
80
84
  return { kind: "comparison", baseCommit: input.baseCommit };
81
85
  }
82
- if (input.kind === "commit" && input.commit && !input.baseCommit) {
86
+ if (input.kind === "commit") {
87
+ if (!input.commit) {
88
+ throw new Error(`Commit targets require a commit field (full 40- or 64-char commit id).`);
89
+ }
83
90
  return { kind: "commit", commit: input.commit };
84
91
  }
85
- throw new Error(`Target fields do not match target kind "${input.kind}".`);
92
+ throw new Error(`Unknown target kind "${input.kind}".`);
86
93
  }
87
94
 
88
95
  /** Validate and narrow preparation input after provider-level JSON parsing. */
@@ -15,6 +15,7 @@ import {
15
15
  prepareReviewSchema,
16
16
  runReviewSchema,
17
17
  } from "./agent-review-schemas.ts";
18
+ import { formatChildFailureDiagnostics } from "./child-failure-diagnostics.ts";
18
19
  import { pageText } from "./output-page.ts";
19
20
  import { prepareReview, runReview } from "./review-workflow.ts";
20
21
 
@@ -54,10 +55,26 @@ function formatTaskResult(result: ReviewTaskResult): string[] {
54
55
  `Model: ${result.modelId}`,
55
56
  `Packet SHA-256: ${result.packetHash}`,
56
57
  ];
57
- if (result.status === "failed") return [...lines, `Status: failed (${result.failureCode})`];
58
- if (result.status === "canceled") return [...lines, "Status: canceled"];
58
+ if (result.status === "failed") {
59
+ lines.push(`Status: failed (${result.failureCode})`);
60
+ if (result.diagnostics) {
61
+ lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
62
+ }
63
+ return lines;
64
+ }
65
+ if (result.status === "canceled") {
66
+ lines.push("Status: canceled");
67
+ if (result.diagnostics) {
68
+ lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
69
+ }
70
+ return lines;
71
+ }
59
72
  if (result.status === "timeout") {
60
- return [...lines, `Status: timeout (${result.timeoutMs} ms)`];
73
+ lines.push(`Status: timeout (${result.timeoutMs} ms)`);
74
+ if (result.diagnostics) {
75
+ lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
76
+ }
77
+ return lines;
61
78
  }
62
79
  lines.push(`Verdict: ${result.verdict.toUpperCase()}`, "", result.summary);
63
80
  for (const finding of result.findings) {
@@ -8,6 +8,7 @@ import type {
8
8
  import {
9
9
  type ChildLifecycleTrace,
10
10
  ChildLifecycleTraceCollector,
11
+ extractLastLifecycleErrorText,
11
12
  formatChildLifecycleTrace,
12
13
  getRegisteredToolNames,
13
14
  toSafeAssistantStopReason,
@@ -40,6 +41,8 @@ export function buildChildFailureDiagnostics(
40
41
  recentActivity: input.recentActivity.length > 0 ? [...input.recentActivity] : undefined,
41
42
  lastAssistantStopReason: lastAssistant?.stopReason,
42
43
  lastAssistantToolCalls: lastAssistant?.toolCalls,
44
+ lastAssistantErrorText: lastAssistant?.errorText,
45
+ lastLifecycleErrorText: extractLastLifecycleErrorText(input.lifecycleTrace),
43
46
  };
44
47
  }
45
48
 
@@ -95,6 +98,13 @@ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnosti
95
98
  diagnostics.lastAssistantToolCalls && diagnostics.lastAssistantToolCalls.length > 0
96
99
  ? `- Last assistant tools: ${diagnostics.lastAssistantToolCalls.join(", ")}`
97
100
  : undefined,
101
+ diagnostics.lastAssistantErrorText
102
+ ? `- Last assistant error: ${diagnostics.lastAssistantErrorText}`
103
+ : undefined,
104
+ diagnostics.lastLifecycleErrorText &&
105
+ diagnostics.lastLifecycleErrorText !== diagnostics.lastAssistantErrorText
106
+ ? `- Lifecycle error: ${diagnostics.lastLifecycleErrorText}`
107
+ : undefined,
98
108
  `- ${formatChildLifecycleTrace(diagnostics.lifecycleTrace)}`,
99
109
  ];
100
110
  return lines.filter((line): line is string => !!line);
@@ -103,6 +113,7 @@ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnosti
103
113
  interface LastAssistantMetadata {
104
114
  stopReason?: string;
105
115
  toolCalls?: string[];
116
+ errorText?: string;
106
117
  }
107
118
 
108
119
  function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetadata | undefined {
@@ -117,9 +128,11 @@ function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetad
117
128
 
118
129
  const stopReason = toSafeAssistantStopReason(message.stopReason);
119
130
  const toolCalls = extractAssistantToolCalls(message.content, registeredToolNames);
131
+ const errorText = stopReason === "error" ? extractAssistantErrorText(message) : undefined;
120
132
  return {
121
133
  stopReason,
122
134
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
135
+ errorText,
123
136
  };
124
137
  }
125
138
  return undefined;
@@ -143,3 +156,48 @@ function extractAssistantToolCalls(content: unknown, registeredToolNames: Set<st
143
156
  })
144
157
  .filter((name): name is string => !!name);
145
158
  }
159
+
160
+ /** Extract error text from content text parts when the model embeds an error message. */
161
+ function extractContentErrorText(content: unknown): string | undefined {
162
+ if (!Array.isArray(content)) return undefined;
163
+ const textParts = content
164
+ .filter(
165
+ (part): part is { type: "text"; text: string } =>
166
+ typeof part === "object" &&
167
+ part !== null &&
168
+ (part as { type?: unknown }).type === "text" &&
169
+ typeof (part as { text?: unknown }).text === "string",
170
+ )
171
+ .map((part) => part.text);
172
+ if (textParts.length === 0) return undefined;
173
+ const joined = textParts.join("\n").trim();
174
+ return joined || undefined;
175
+ }
176
+
177
+ /** Scan enumerable message keys for any error-like string property as a fallback. */
178
+ function scanMessageKeysForError(message: Record<string, unknown>): string | undefined {
179
+ for (const key of Object.keys(message)) {
180
+ if (!/error|message|reason/i.test(key)) continue;
181
+ const value = message[key];
182
+ if (typeof value === "string" && value.trim()) return value;
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /** Extract error text from an errored assistant message, trying content text parts first then top-level error fields. */
188
+ function extractAssistantErrorText(message: Record<string, unknown>): string | undefined {
189
+ // 1. Try content text parts (model may embed error in text)
190
+ const contentError = extractContentErrorText(message.content);
191
+ if (contentError) return contentError;
192
+
193
+ // 2. Try top-level error message (provider-level error payload)
194
+ if (typeof message.errorMessage === "string" && message.errorMessage.trim()) {
195
+ return message.errorMessage;
196
+ }
197
+ if (typeof message.error === "string" && message.error.trim()) {
198
+ return message.error;
199
+ }
200
+
201
+ // 3. Fallback: scan enumerable keys for any error-like string property
202
+ return scanMessageKeysForError(message);
203
+ }
@@ -53,9 +53,16 @@ export type ChildLifecycleTraceEntry =
53
53
  willRetry: boolean;
54
54
  hasResult: boolean;
55
55
  hasError: boolean;
56
+ errorText?: string;
56
57
  }
57
58
  | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number }
58
- | { type: "auto_retry_end"; success: boolean; attempt: number; hasFinalError: boolean }
59
+ | {
60
+ type: "auto_retry_end";
61
+ success: boolean;
62
+ attempt: number;
63
+ hasFinalError: boolean;
64
+ finalErrorText?: string;
65
+ }
59
66
  | {
60
67
  type: "summarization_retry_scheduled";
61
68
  attempt: number;
@@ -161,6 +168,20 @@ export class ChildLifecycleTraceCollector {
161
168
  }
162
169
  }
163
170
 
171
+ /**
172
+ * Extract error text from the most recent lifecycle entry that carries an error.
173
+ * Checks compaction_end.errorText and auto_retry_end.finalErrorText.
174
+ */
175
+ export function extractLastLifecycleErrorText(trace: ChildLifecycleTrace): string | undefined {
176
+ // Walk backwards to find the most recent error
177
+ for (let i = trace.entries.length - 1; i >= 0; i--) {
178
+ const entry = trace.entries[i];
179
+ if (entry.type === "compaction_end" && entry.errorText) return entry.errorText;
180
+ if (entry.type === "auto_retry_end" && entry.finalErrorText) return entry.finalErrorText;
181
+ }
182
+ return undefined;
183
+ }
184
+
164
185
  /** Format the complete retained Child Lifecycle Trace for parent-facing diagnostics. */
165
186
  export function formatChildLifecycleTrace(trace: ChildLifecycleTrace): string {
166
187
  const tail =
@@ -185,11 +206,11 @@ function formatChildLifecycleTraceEntry(entry: ChildLifecycleTraceEntry): string
185
206
  case "compaction_start":
186
207
  return `compaction_start(reason=${entry.reason})`;
187
208
  case "compaction_end":
188
- return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError})`;
209
+ return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError}${entry.errorText ? `, error=${JSON.stringify(entry.errorText)}` : ""})`;
189
210
  case "auto_retry_start":
190
211
  return `auto_retry_start(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
191
212
  case "auto_retry_end":
192
- return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError})`;
213
+ return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError}${entry.finalErrorText ? `, error=${JSON.stringify(entry.finalErrorText)}` : ""})`;
193
214
  case "summarization_retry_scheduled":
194
215
  return `summarization_retry_scheduled(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
195
216
  case "summarization_retry_attempt_start":
@@ -269,6 +290,10 @@ function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry |
269
290
  willRetry: event.willRetry,
270
291
  hasResult: event.result !== undefined,
271
292
  hasError: event.errorMessage !== undefined,
293
+ errorText:
294
+ typeof event.errorMessage === "string" && event.errorMessage.trim()
295
+ ? event.errorMessage
296
+ : undefined,
272
297
  }
273
298
  : undefined;
274
299
  case "auto_retry_start":
@@ -289,6 +314,10 @@ function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry |
289
314
  success: event.success,
290
315
  attempt: event.attempt,
291
316
  hasFinalError: event.finalError !== undefined,
317
+ finalErrorText:
318
+ typeof event.finalError === "string" && event.finalError.trim()
319
+ ? event.finalError
320
+ : undefined,
292
321
  }
293
322
  : undefined;
294
323
  case "summarization_retry_scheduled":
@@ -119,13 +119,24 @@ function plannerPrompt(snapshot: ReviewSnapshot, context: string): string {
119
119
  ].join("\n");
120
120
  }
121
121
 
122
+ /** Translate a resolveReviewSnapshot error to a no-target reason. */
123
+ function snapshotErrorReason(error: unknown): string {
124
+ if (error instanceof Error) return error.message;
125
+ return "No reviewable changes found.";
126
+ }
127
+
122
128
  /** Resolve and store a one-shot plan, optionally asking the advisory Planner for tasks. */
123
129
  export async function prepareReview(input: PrepareReviewInput) {
124
130
  input.onUpdate?.({
125
131
  content: [{ type: "text", text: "Resolving target…" }],
126
132
  details: {},
127
133
  });
128
- const snapshot = await resolveReviewSnapshot(input.cwd, input.target);
134
+ let snapshot: Awaited<ReturnType<typeof resolveReviewSnapshot>>;
135
+ try {
136
+ snapshot = await resolveReviewSnapshot(input.cwd, input.target);
137
+ } catch (error) {
138
+ return { kind: "no-target" as const, reason: snapshotErrorReason(error) };
139
+ }
129
140
  if (!snapshot) return { kind: "no-target" as const, reason: "No reviewable changes found." };
130
141
  let plannerDraft: PlannerDraft | undefined;
131
142
  if (input.planning === "suggest") {
@@ -258,7 +269,12 @@ export async function runReview(input: RunReviewInput) {
258
269
  let provenance: ReviewBatchDetails["provenance"] = "caller-supplied";
259
270
 
260
271
  if (input.mode === "direct") {
261
- const resolved = await resolveReviewSnapshot(input.cwd, input.target);
272
+ let resolved: Awaited<ReturnType<typeof resolveReviewSnapshot>>;
273
+ try {
274
+ resolved = await resolveReviewSnapshot(input.cwd, input.target);
275
+ } catch (error) {
276
+ return { kind: "no-target" as const, reason: snapshotErrorReason(error) };
277
+ }
262
278
  if (!resolved) return { kind: "no-target" as const, reason: "No reviewable changes found." };
263
279
  snapshot = resolved;
264
280
  review = normalizeReviewInput(input.review);
package/src/tui/common.ts CHANGED
@@ -9,6 +9,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
9
9
  import { type Container, Spacer, Text } from "@earendil-works/pi-tui";
10
10
  import { formatEvidenceBadge } from "@mrclrchtr/supi-core/evidence-badge";
11
11
  import type {
12
+ ChildFailureDiagnostics,
12
13
  FindingEffort,
13
14
  FindingImpact,
14
15
  ReviewFinding,
@@ -118,6 +119,64 @@ function renderFinding(container: Container, finding: ReviewFinding, theme: Them
118
119
  container.addChild(new Spacer(1));
119
120
  }
120
121
 
122
+ /** Build a compact diagnostics section for non-completed (failed/canceled/timeout) task results. */
123
+ function buildNonCompletedSection(
124
+ container: Container,
125
+ result: ReviewTaskResult & { status: "failed" | "canceled" | "timeout" },
126
+ theme: Theme,
127
+ ): void {
128
+ const diagnostics: ChildFailureDiagnostics | undefined =
129
+ "diagnostics" in result ? result.diagnostics : undefined;
130
+ if (!diagnostics) return;
131
+
132
+ container.addChild(new Spacer(1));
133
+ container.addChild(
134
+ new Text(
135
+ theme.fg("dim", `${diagnostics.turns} turns · ${diagnostics.toolUses} tool uses`),
136
+ 1,
137
+ 0,
138
+ ),
139
+ );
140
+
141
+ if (diagnostics.lastAssistantStopReason) {
142
+ container.addChild(
143
+ new Text(
144
+ theme.fg(
145
+ diagnostics.lastAssistantStopReason === "error" ? "error" : "dim",
146
+ `Last assistant stop: ${diagnostics.lastAssistantStopReason}`,
147
+ ),
148
+ 1,
149
+ 0,
150
+ ),
151
+ );
152
+ }
153
+
154
+ if (diagnostics.lastAssistantErrorText) {
155
+ const truncated =
156
+ diagnostics.lastAssistantErrorText.length > 200
157
+ ? `${diagnostics.lastAssistantErrorText.slice(0, 200)}…`
158
+ : diagnostics.lastAssistantErrorText;
159
+ container.addChild(new Text(theme.fg("error", truncated), 1, 0));
160
+ } else if (diagnostics.lastLifecycleErrorText) {
161
+ const truncated =
162
+ diagnostics.lastLifecycleErrorText.length > 200
163
+ ? `${diagnostics.lastLifecycleErrorText.slice(0, 200)}…`
164
+ : diagnostics.lastLifecycleErrorText;
165
+ container.addChild(new Text(theme.fg("error", truncated), 1, 0));
166
+ } else if (diagnostics.lastAssistantStopReason === "error") {
167
+ container.addChild(
168
+ new Text(
169
+ theme.fg(
170
+ "dim",
171
+ "The model produced an error with no further details — check provider quota, auth, or configuration.",
172
+ ),
173
+ 1,
174
+ 0,
175
+ ),
176
+ );
177
+ }
178
+ }
179
+
121
180
  /** Build an expanded-view section for a single review task result. */
122
181
  export function buildTaskSection(
123
182
  container: Container,
@@ -159,7 +218,10 @@ export function buildTaskSection(
159
218
  container.addChild(new Text(metaParts.join(" "), 1, 0));
160
219
 
161
220
  // Completed task — show summary and findings
162
- if (result.status !== "completed") return;
221
+ if (result.status !== "completed") {
222
+ buildNonCompletedSection(container, result, theme);
223
+ return;
224
+ }
163
225
 
164
226
  if (result.summary) {
165
227
  container.addChild(new Spacer(1));
package/src/types.ts CHANGED
@@ -109,6 +109,10 @@ export interface ChildFailureDiagnostics {
109
109
  recentActivity?: string[];
110
110
  lastAssistantStopReason?: string;
111
111
  lastAssistantToolCalls?: string[];
112
+ /** Error text extracted from the last assistant message when stopReason is "error". */
113
+ lastAssistantErrorText?: string;
114
+ /** Error text extracted from the most recent lifecycle event (compaction_end, auto_retry_end). */
115
+ lastLifecycleErrorText?: string;
112
116
  }
113
117
 
114
118
  export type ChildFailedResult =