@mrclrchtr/supi-review 2.4.1 → 2.6.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.
@@ -0,0 +1,385 @@
1
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ checkReviewSnapshotFreshness,
4
+ fingerprintReviewSnapshot,
5
+ resolveReviewSnapshot,
6
+ summarizeReviewSnapshot,
7
+ } from "../git.ts";
8
+ import { BRIEF_SYNTHESIS_PROMPT_VERSION, synthesizeReviewBrief } from "../history/synthesize.ts";
9
+ import { normalizeReviewResult } from "../review-result.ts";
10
+ import type { ReviewPlanStore, StoredAgentReviewPlan } from "../session/review-plan-store.ts";
11
+ import { buildReviewPacket } from "../target/packet.ts";
12
+ import type {
13
+ AgentReviewBatchDetails,
14
+ AgentReviewerResult,
15
+ BriefCritique,
16
+ BriefEvaluation,
17
+ BriefSynthesisRunResult,
18
+ ReviewerAssignment,
19
+ ReviewerAssignmentResult,
20
+ ReviewModelSelection,
21
+ ReviewProgress,
22
+ ReviewResult,
23
+ ReviewTargetSpec,
24
+ SynthesizedReviewBrief,
25
+ } from "../types.ts";
26
+ import { runReviewer } from "./review-runner.ts";
27
+
28
+ /** Inputs required to synthesize and retain one agent-driven review plan. */
29
+ export interface PrepareAgentReviewWorkflowInput {
30
+ cwd: string;
31
+ target: ReviewTargetSpec;
32
+ note?: string;
33
+ serializedContext: string;
34
+ model: ReviewModelSelection;
35
+ modelRegistry: ModelRegistry;
36
+ signal?: AbortSignal;
37
+ onProgress?: (progress: ReviewProgress) => void;
38
+ planStore: ReviewPlanStore;
39
+ }
40
+
41
+ /** Typed preparation outcome before any reviewer child session starts. */
42
+ export type PrepareAgentReviewWorkflowOutcome =
43
+ | { kind: "prepared"; plan: StoredAgentReviewPlan }
44
+ | { kind: "no-snapshot"; reason: string }
45
+ | { kind: "synthesis-failed"; result: Exclude<BriefSynthesisRunResult, { kind: "success" }> };
46
+
47
+ /** Inputs required to critique and execute one prepared review plan. */
48
+ export interface RunAgentReviewWorkflowInput {
49
+ cwd: string;
50
+ planId: string;
51
+ critique: BriefCritique;
52
+ revisedBrief?: Omit<SynthesizedReviewBrief, "note">;
53
+ reviewers: ReviewerAssignment[];
54
+ modelRegistry: ModelRegistry;
55
+ signal?: AbortSignal;
56
+ onBriefEvaluation?: (evaluation: BriefEvaluation) => void;
57
+ onReviewerProgress?: (reviewerId: string, progress: ReviewProgress) => void;
58
+ onReviewerDone?: (reviewerId: string) => void;
59
+ planStore: ReviewPlanStore;
60
+ }
61
+
62
+ /** Typed batch outcome after validation and snapshot freshness checks. */
63
+ export type RunAgentReviewWorkflowOutcome =
64
+ | { kind: "completed"; details: AgentReviewBatchDetails }
65
+ | { kind: "invalid"; reason: string }
66
+ | { kind: "stale"; reason: string };
67
+
68
+ /** Prepare one session-scoped review plan without starting reviewer sessions. */
69
+ export async function prepareAgentReviewPlan(
70
+ input: PrepareAgentReviewWorkflowInput,
71
+ ): Promise<PrepareAgentReviewWorkflowOutcome> {
72
+ const snapshot = await resolveReviewSnapshot(input.cwd, input.target);
73
+ if (!snapshot) {
74
+ return {
75
+ kind: "no-snapshot",
76
+ reason: `No reviewable changes found for ${formatTarget(input.target)}.`,
77
+ };
78
+ }
79
+
80
+ const synthesis = await synthesizeReviewBrief({
81
+ model: input.model,
82
+ modelRegistry: input.modelRegistry,
83
+ cwd: input.cwd,
84
+ snapshot,
85
+ serializedContext: input.serializedContext,
86
+ note: input.note,
87
+ signal: input.signal,
88
+ onProgress: input.onProgress,
89
+ });
90
+ if (synthesis.kind !== "success") {
91
+ return { kind: "synthesis-failed", result: synthesis };
92
+ }
93
+
94
+ const generatedBrief: SynthesizedReviewBrief = {
95
+ ...synthesis.brief,
96
+ note: input.note,
97
+ };
98
+ const snapshotFingerprint = await fingerprintReviewSnapshot(input.cwd, snapshot, input.signal);
99
+ const plan = input.planStore.create({
100
+ snapshot,
101
+ snapshotFingerprint,
102
+ generatedBrief,
103
+ model: input.model,
104
+ briefPromptVersion: BRIEF_SYNTHESIS_PROMPT_VERSION,
105
+ });
106
+ return { kind: "prepared", plan };
107
+ }
108
+
109
+ /** Validate, atomically consume, freshness-check, and execute one prepared review batch. */
110
+ export async function runAgentReviewBatch(
111
+ input: RunAgentReviewWorkflowInput,
112
+ ): Promise<RunAgentReviewWorkflowOutcome> {
113
+ const plan = input.planStore.get(input.planId);
114
+ if (!plan) {
115
+ return {
116
+ kind: "invalid",
117
+ reason: `Review plan "${input.planId}" was not found in this session.`,
118
+ };
119
+ }
120
+
121
+ const validation = validateRunInput(input, plan);
122
+ if (validation.kind === "invalid") return validation;
123
+
124
+ const claimedPlan = input.planStore.take(input.planId);
125
+ if (!claimedPlan) {
126
+ return {
127
+ kind: "invalid",
128
+ reason: `Review plan "${input.planId}" has already been consumed.`,
129
+ };
130
+ }
131
+
132
+ const evaluation: BriefEvaluation = {
133
+ planId: claimedPlan.id,
134
+ briefPromptVersion: claimedPlan.briefPromptVersion,
135
+ generatedBrief: claimedPlan.generatedBrief,
136
+ critique: validation.critique,
137
+ effectiveBrief: validation.effectiveBrief,
138
+ synthesizerModelId: claimedPlan.model.canonicalId,
139
+ snapshotFingerprint: claimedPlan.snapshotFingerprint,
140
+ };
141
+ input.onBriefEvaluation?.(evaluation);
142
+
143
+ const freshness = await checkReviewSnapshotFreshness(
144
+ input.cwd,
145
+ claimedPlan.snapshot,
146
+ claimedPlan.snapshotFingerprint,
147
+ input.signal,
148
+ );
149
+ if (!freshness.fresh) return { kind: "stale", reason: freshness.reason };
150
+
151
+ const results = await Promise.all(
152
+ validation.reviewers.map(async (assignment) => {
153
+ const result = await runAssignment(assignment, claimedPlan, validation.effectiveBrief, input);
154
+ input.onReviewerDone?.(assignment.id);
155
+ return result;
156
+ }),
157
+ );
158
+
159
+ const finalFreshness = await checkReviewSnapshotFreshness(
160
+ input.cwd,
161
+ claimedPlan.snapshot,
162
+ claimedPlan.snapshotFingerprint,
163
+ input.signal,
164
+ );
165
+ if (!finalFreshness.fresh) {
166
+ return {
167
+ kind: "stale",
168
+ reason: "The review target changed while reviewer sessions were running. Prepare a new plan.",
169
+ };
170
+ }
171
+
172
+ return {
173
+ kind: "completed",
174
+ details: {
175
+ kind: "review-batch",
176
+ evaluation,
177
+ snapshot: summarizeReviewSnapshot(claimedPlan.snapshot),
178
+ results,
179
+ },
180
+ };
181
+ }
182
+
183
+ async function runAssignment(
184
+ assignment: ReviewerAssignment,
185
+ plan: StoredAgentReviewPlan,
186
+ effectiveBrief: SynthesizedReviewBrief,
187
+ input: RunAgentReviewWorkflowInput,
188
+ ): Promise<ReviewerAssignmentResult> {
189
+ const packet = buildReviewPacket(plan.snapshot, effectiveBrief, plan.model, assignment);
190
+
191
+ try {
192
+ const rawResult = await runReviewer({
193
+ prompt: packet.prompt,
194
+ model: plan.model,
195
+ modelRegistry: input.modelRegistry,
196
+ cwd: input.cwd,
197
+ signal: input.signal,
198
+ snapshot: plan.snapshot,
199
+ brief: effectiveBrief,
200
+ onProgress: (progress) => input.onReviewerProgress?.(assignment.id, progress),
201
+ });
202
+ return { assignment, result: compactReviewResult(normalizeReviewResult(rawResult)) };
203
+ } catch (error) {
204
+ return {
205
+ assignment,
206
+ result: {
207
+ kind: "failed",
208
+ reason: `Reviewer session failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
209
+ modelId: plan.model.canonicalId,
210
+ },
211
+ };
212
+ }
213
+ }
214
+
215
+ function compactReviewResult(result: ReviewResult): AgentReviewerResult {
216
+ switch (result.kind) {
217
+ case "success":
218
+ return { kind: "success", output: result.output, modelId: result.modelId };
219
+ case "failed":
220
+ return {
221
+ kind: "failed",
222
+ reason: result.reason,
223
+ modelId: result.modelId,
224
+ debug: result.debug,
225
+ };
226
+ case "canceled":
227
+ return { kind: "canceled", modelId: result.modelId, debug: result.debug };
228
+ case "timeout":
229
+ return {
230
+ kind: "timeout",
231
+ timeoutMs: result.timeoutMs,
232
+ partialOutput: result.partialOutput,
233
+ modelId: result.modelId,
234
+ debug: result.debug,
235
+ };
236
+ }
237
+ }
238
+
239
+ function validateRunInput(
240
+ input: RunAgentReviewWorkflowInput,
241
+ plan: StoredAgentReviewPlan,
242
+ ):
243
+ | {
244
+ kind: "valid";
245
+ critique: BriefCritique;
246
+ effectiveBrief: SynthesizedReviewBrief;
247
+ reviewers: ReviewerAssignment[];
248
+ }
249
+ | { kind: "invalid"; reason: string } {
250
+ if (input.critique.verdict === "revise" && !input.revisedBrief) {
251
+ return {
252
+ kind: "invalid",
253
+ reason: 'revisedBrief is required when critique.verdict is "revise".',
254
+ };
255
+ }
256
+ if (input.critique.verdict === "accept" && input.revisedBrief) {
257
+ return {
258
+ kind: "invalid",
259
+ reason: 'revisedBrief must be omitted when critique.verdict is "accept".',
260
+ };
261
+ }
262
+
263
+ const critiqueResult = normalizeBriefCritique(input.critique);
264
+ if (critiqueResult.kind === "invalid") return critiqueResult;
265
+ const critique = critiqueResult.critique;
266
+
267
+ if (input.reviewers.length < 1 || input.reviewers.length > 4) {
268
+ return { kind: "invalid", reason: "Provide between one and four reviewer assignments." };
269
+ }
270
+
271
+ const reviewers = input.reviewers.map((assignment) => ({
272
+ id: assignment.id.trim(),
273
+ focus: assignment.focus.trim(),
274
+ }));
275
+ const invalidAssignment = reviewers.find((assignment) => !assignment.id || !assignment.focus);
276
+ if (invalidAssignment) {
277
+ return { kind: "invalid", reason: "Reviewer ids and focus instructions must not be blank." };
278
+ }
279
+ const ids = new Set(reviewers.map((assignment) => assignment.id));
280
+ if (ids.size !== reviewers.length) {
281
+ return { kind: "invalid", reason: "Reviewer assignment ids must be unique." };
282
+ }
283
+
284
+ const effectiveBriefResult = input.revisedBrief
285
+ ? normalizeRevisedBrief(input.revisedBrief, plan.generatedBrief.note)
286
+ : { kind: "valid" as const, brief: plan.generatedBrief };
287
+ if (effectiveBriefResult.kind === "invalid") return effectiveBriefResult;
288
+ const effectiveBrief = effectiveBriefResult.brief;
289
+ const invalidRiskyFile = effectiveBrief.riskyFiles.find(
290
+ (file) => !plan.snapshot.changedFiles.includes(file),
291
+ );
292
+ if (invalidRiskyFile) {
293
+ return {
294
+ kind: "invalid",
295
+ reason: `Revised brief riskyFiles contains "${invalidRiskyFile}", which is not in the prepared snapshot.`,
296
+ };
297
+ }
298
+
299
+ return { kind: "valid", critique, effectiveBrief, reviewers };
300
+ }
301
+
302
+ function normalizeBriefCritique(
303
+ input: BriefCritique,
304
+ ): { kind: "valid"; critique: BriefCritique } | { kind: "invalid"; reason: string } {
305
+ if (input.findings.length > 20) {
306
+ return { kind: "invalid", reason: "Brief critique supports at most 20 findings." };
307
+ }
308
+ if (input.verdict === "revise" && input.findings.length === 0) {
309
+ return {
310
+ kind: "invalid",
311
+ reason: 'critique.findings must contain evidence when critique.verdict is "revise".',
312
+ };
313
+ }
314
+
315
+ const critique: BriefCritique = {
316
+ verdict: input.verdict,
317
+ summary: input.summary.trim(),
318
+ findings: input.findings.map((finding) => ({
319
+ ...finding,
320
+ explanation: finding.explanation.trim(),
321
+ evidence: finding.evidence.trim(),
322
+ proposedChange: finding.proposedChange.trim(),
323
+ })),
324
+ };
325
+ if (!critique.summary) {
326
+ return { kind: "invalid", reason: "Brief critique summary must not be blank." };
327
+ }
328
+ if (
329
+ critique.findings.some(
330
+ (finding) => !finding.explanation || !finding.evidence || !finding.proposedChange,
331
+ )
332
+ ) {
333
+ return { kind: "invalid", reason: "Brief critique findings must contain non-blank text." };
334
+ }
335
+ return { kind: "valid", critique };
336
+ }
337
+
338
+ function normalizeRevisedBrief(
339
+ brief: Omit<SynthesizedReviewBrief, "note">,
340
+ note: string | undefined,
341
+ ): { kind: "valid"; brief: SynthesizedReviewBrief } | { kind: "invalid"; reason: string } {
342
+ const summary = brief.summary.trim();
343
+ const intendedOutcome = brief.intendedOutcome.trim();
344
+ if (!summary || !intendedOutcome) {
345
+ return {
346
+ kind: "invalid",
347
+ reason: "revisedBrief.summary and revisedBrief.intendedOutcome must not be blank.",
348
+ };
349
+ }
350
+
351
+ const constraints = brief.constraints.map((item) => item.trim());
352
+ const focusAreas = brief.focusAreas.map((item) => item.trim());
353
+ const riskyFiles = brief.riskyFiles.map((item) => item.trim());
354
+ const unresolvedQuestions = brief.unresolvedQuestions.map((item) => item.trim());
355
+ if (
356
+ [...constraints, ...focusAreas, ...riskyFiles, ...unresolvedQuestions].some((item) => !item)
357
+ ) {
358
+ return { kind: "invalid", reason: "revisedBrief arrays must not contain blank entries." };
359
+ }
360
+
361
+ return {
362
+ kind: "valid",
363
+ brief: {
364
+ summary,
365
+ intendedOutcome,
366
+ constraints,
367
+ focusAreas,
368
+ riskyFiles,
369
+ unresolvedQuestions,
370
+ reviewInstructionBlockIds: [...brief.reviewInstructionBlockIds],
371
+ note,
372
+ },
373
+ };
374
+ }
375
+
376
+ function formatTarget(target: ReviewTargetSpec): string {
377
+ switch (target.kind) {
378
+ case "working-tree":
379
+ return "the working tree";
380
+ case "branch":
381
+ return `changes against ${target.base}`;
382
+ case "commit":
383
+ return `commit ${target.sha}`;
384
+ }
385
+ }
@@ -1,7 +1,6 @@
1
1
  import { clampThinkingLevel } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  type AgentSession,
4
- type AgentSessionEvent,
5
4
  createAgentSession,
6
5
  DefaultResourceLoader,
7
6
  defineTool,
@@ -88,16 +87,15 @@ function emitBriefProgress(
88
87
  invocation.onProgress?.(ctx.progress);
89
88
  }
90
89
 
91
- function handleAgentEnd(options: {
92
- event: Extract<AgentSessionEvent, { type: "agent_end" }>;
90
+ /** Finalize after retries and compaction recovery, never at the earlier `agent_end` boundary. */
91
+ function handleAgentSettled(options: {
93
92
  session: AgentSession;
94
93
  brief: SynthesizedReviewBrief | undefined;
95
94
  state: { settled: boolean; aborting: boolean };
96
95
  cleanup: (result: BriefSynthesisRunResult) => BriefSynthesisRunResult;
97
96
  }): BriefSynthesisRunResult | undefined {
98
- const { event, session, brief, state, cleanup } = options;
99
- const retryAwareEvent = event as typeof event & { willRetry?: boolean };
100
- if (retryAwareEvent.willRetry || state.settled || state.aborting) {
97
+ const { session, brief, state, cleanup } = options;
98
+ if (state.settled || state.aborting) {
101
99
  return undefined;
102
100
  }
103
101
  if (brief) {
@@ -156,9 +154,8 @@ export async function runBriefSynthesis(
156
154
  ctx.progress.currentFocus = undefined;
157
155
  emitBriefProgress(ctx, invocation);
158
156
  break;
159
- case "agent_end": {
160
- const result = handleAgentEnd({
161
- event,
157
+ case "agent_settled": {
158
+ const result = handleAgentSettled({
162
159
  session,
163
160
  brief: resultHolder.value,
164
161
  state: ctx.state,
@@ -0,0 +1,21 @@
1
+ export const PREPARE_REVIEW_TOOL_NAME = "supi_review_prepare";
2
+ export const RUN_REVIEW_TOOL_NAME = "supi_review_run";
3
+
4
+ export const prepareReviewToolDescription =
5
+ "Prepare a session-aware review brief for a freshness-checked git target and return a session-scoped planId. " +
6
+ "After this tool returns, critically compare the generated brief with the user request, session evidence, and snapshot before calling supi_review_run. Does not run reviewers.";
7
+
8
+ export const prepareReviewPromptSnippet =
9
+ "Prepare a session-aware review plan for main-agent critique before independent reviewers run";
10
+
11
+ export const prepareReviewPromptGuidelines = [
12
+ "Use supi_review_prepare when an independent review is useful; do not combine it in the same tool batch with edit, write, or mutating bash calls.",
13
+ "After supi_review_prepare, assess the generated brief for omissions, unsupported inferences, misplaced priorities, and unclear wording, then call supi_review_run with an evidence-backed critique.",
14
+ "Do not mutate the review target between supi_review_prepare and supi_review_run, and call supi_review_run without sibling mutation tools; stale or mid-review changes are rejected.",
15
+ ];
16
+
17
+ export const runReviewToolDescription =
18
+ "Run one to four independent read-only reviewer child sessions from a prepared plan. " +
19
+ "Requires the planId from supi_review_prepare and a structured main-agent critique. " +
20
+ 'When critique.verdict is "revise", revisedBrief must contain the full corrected brief. ' +
21
+ "The prepared snapshot is freshness-checked and the plan is consumed once execution begins.";
@@ -77,6 +77,8 @@ export function summarizeSessionEvent(event: AgentSessionEvent): string | undefi
77
77
  return "turn:end";
78
78
  case "agent_end":
79
79
  return `agent:end${event.willRetry ? ":retry" : ""}`;
80
+ case "agent_settled":
81
+ return "agent:settled";
80
82
  case "auto_retry_start":
81
83
  return `retry:start:${event.attempt}/${event.maxAttempts}`;
82
84
  case "auto_retry_end":
@@ -236,13 +236,12 @@ export function handleMessageEnd(
236
236
  ctx.session.steer(STEER_SUBMIT_MESSAGE).catch(() => {});
237
237
  }
238
238
 
239
- export function handleAgentEnd(
240
- event: Extract<AgentSessionEvent, { type: "agent_end" }>,
241
- ctx: RunnerContext,
242
- ): void {
239
+ /**
240
+ * Finalize only after Pi has no retry, compaction recovery, or queued continuation left.
241
+ * `agent_end` is a low-level boundary and is not terminal for a managed session.
242
+ */
243
+ export function handleAgentSettled(ctx: RunnerContext): void {
243
244
  if (ctx.state.settled || ctx.state.aborting) return;
244
- const retryAwareEvent = event as typeof event & { willRetry?: boolean };
245
- if (retryAwareEvent.willRetry) return;
246
245
 
247
246
  if (ctx.resultHolder.value) {
248
247
  ctx.resolve(
@@ -293,8 +292,8 @@ export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext)
293
292
  handleMessageEnd(event, ctx);
294
293
  break;
295
294
  }
296
- case "agent_end":
297
- handleAgentEnd(event, ctx);
295
+ case "agent_settled":
296
+ handleAgentSettled(ctx);
298
297
  break;
299
298
  default:
300
299
  break;
@@ -30,7 +30,7 @@ async function createReviewerSession(
30
30
  const resourceLoader = new DefaultResourceLoader({
31
31
  cwd: invocation.cwd,
32
32
  agentDir: process.env.PI_CODING_AGENT_DIR || "",
33
- noExtensions: false,
33
+ noExtensions: true,
34
34
  noSkills: true,
35
35
  noPromptTemplates: true,
36
36
  noThemes: true,
@@ -1,40 +1,33 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { Type } from "typebox";
2
3
 
3
- const reviewItemCategorySchema = Type.Union([
4
- Type.Literal("correctness"),
5
- Type.Literal("security"),
6
- Type.Literal("performance"),
7
- Type.Literal("api"),
8
- Type.Literal("test-gap"),
9
- Type.Literal("docs"),
10
- Type.Literal("cleanup"),
11
- Type.Literal("maintainer"),
12
- ]);
4
+ const reviewItemCategorySchema = StringEnum([
5
+ "correctness",
6
+ "security",
7
+ "performance",
8
+ "api",
9
+ "test-gap",
10
+ "docs",
11
+ "cleanup",
12
+ "maintainer",
13
+ ] as const);
13
14
 
14
- const reviewItemImpactSchema = Type.Union([
15
- Type.Literal("low"),
16
- Type.Literal("medium"),
17
- Type.Literal("high"),
18
- ]);
15
+ const reviewItemImpactSchema = StringEnum(["low", "medium", "high"] as const);
19
16
 
20
- const reviewItemEffortSchema = Type.Union([
21
- Type.Literal("low"),
22
- Type.Literal("medium"),
23
- Type.Literal("high"),
24
- ]);
17
+ const reviewItemEffortSchema = StringEnum(["low", "medium", "high"] as const);
25
18
 
26
- const reviewItemRecommendedActionSchema = Type.Union([
27
- Type.Literal("must-fix"),
28
- Type.Literal("should-fix"),
29
- Type.Literal("consider"),
30
- ]);
19
+ const reviewItemRecommendedActionSchema = StringEnum([
20
+ "must-fix",
21
+ "should-fix",
22
+ "consider",
23
+ ] as const);
31
24
 
32
- const reviewInstructionBlockIdSchema = Type.Union([
33
- Type.Literal("public-surface"),
34
- Type.Literal("cross-layer"),
35
- Type.Literal("schema-widening"),
36
- Type.Literal("cleanup"),
37
- ]);
25
+ const reviewInstructionBlockIdSchema = StringEnum([
26
+ "public-surface",
27
+ "cross-layer",
28
+ "schema-widening",
29
+ "cleanup",
30
+ ] as const);
38
31
 
39
32
  export const reviewItemSchema = Type.Object({
40
33
  title: Type.String(),
@@ -63,12 +56,15 @@ export const reviewOutputSchema = Type.Object({
63
56
  overall_confidence_score: Type.Number({ minimum: 0, maximum: 1 }),
64
57
  });
65
58
 
59
+ const briefRequiredTextSchema = Type.String({ minLength: 1, maxLength: 4_000 });
60
+ const briefListItemSchema = Type.String({ minLength: 1, maxLength: 2_000 });
61
+
66
62
  export const reviewBriefSchema = Type.Object({
67
- summary: Type.String(),
68
- intendedOutcome: Type.String(),
69
- constraints: Type.Array(Type.String()),
70
- focusAreas: Type.Array(Type.String()),
71
- riskyFiles: Type.Array(Type.String()),
72
- unresolvedQuestions: Type.Array(Type.String()),
73
- reviewInstructionBlockIds: Type.Array(reviewInstructionBlockIdSchema),
63
+ summary: briefRequiredTextSchema,
64
+ intendedOutcome: briefRequiredTextSchema,
65
+ constraints: Type.Array(briefListItemSchema, { maxItems: 50 }),
66
+ focusAreas: Type.Array(briefListItemSchema, { maxItems: 50 }),
67
+ riskyFiles: Type.Array(briefListItemSchema, { maxItems: 200 }),
68
+ unresolvedQuestions: Type.Array(briefListItemSchema, { maxItems: 50 }),
69
+ reviewInstructionBlockIds: Type.Array(reviewInstructionBlockIdSchema, { maxItems: 4 }),
74
70
  });
@@ -17,7 +17,7 @@ export interface LifecycleCtx<TResult> {
17
17
  /**
18
18
  * Shared lifecycle state.
19
19
  * - `settled`: true once cleanup has been called
20
- * - `aborting`: true once abort/timeout begins (prevents agent_end from resolving)
20
+ * - `aborting`: true once abort/timeout begins (prevents agent_settled from resolving)
21
21
  */
22
22
  state: { settled: boolean; aborting: boolean };
23
23
  /** The managed agent session. */
@@ -142,7 +142,7 @@ export function runWithLifecycle<TResult>(
142
142
 
143
143
  // Abort signal handler
144
144
  const onAbort = () => {
145
- if (state.settled) return;
145
+ if (state.settled || state.aborting) return;
146
146
  state.aborting = true;
147
147
  void session
148
148
  .abort()
@@ -154,11 +154,15 @@ export function runWithLifecycle<TResult>(
154
154
  if (signal) {
155
155
  signal.addEventListener("abort", onAbort, { once: true });
156
156
  addTeardown(() => signal.removeEventListener("abort", onAbort));
157
+ if (signal.aborted) {
158
+ onAbort();
159
+ return;
160
+ }
157
161
  }
158
162
 
159
163
  // Timeout handler
160
164
  const onTimeoutExpired = () => {
161
- if (state.settled) return;
165
+ if (state.settled || state.aborting) return;
162
166
 
163
167
  if (onTimeout) {
164
168
  onTimeout(ctx);
@@ -1,3 +1,4 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import { getSnapshotFileContent, getSnapshotFileDiff } from "../git.ts";
@@ -70,7 +71,7 @@ export function createSnapshotFileTool(
70
71
  '"after" shows the file after the change. The file must be in the snapshot\'s changed-files list.',
71
72
  parameters: Type.Object({
72
73
  file: Type.String(),
73
- side: Type.Union([Type.Literal("before"), Type.Literal("after")]),
74
+ side: StringEnum(["before", "after"] as const),
74
75
  }),
75
76
  execute: async (_toolCallId, args) => {
76
77
  const { file, side } = args as { file: string; side: "before" | "after" };