@mrclrchtr/supi-review 2.5.0 → 2.6.1
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 +35 -5
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +3 -3
- package/src/git.ts +192 -15
- package/src/history/synthesize.ts +4 -5
- package/src/model.ts +19 -0
- package/src/review.ts +2 -2
- package/src/session/review-plan-store.ts +69 -0
- package/src/target/packet.ts +15 -1
- package/src/tool/agent-review-schemas.ts +126 -0
- package/src/tool/agent-review-tools.ts +333 -0
- package/src/tool/agent-review-workflow.ts +380 -0
- package/src/tool/brief-runner.ts +6 -10
- package/src/tool/guidance.ts +21 -0
- package/src/tool/review-debug.ts +2 -0
- package/src/tool/review-handlers.ts +7 -8
- package/src/tool/review-runner.ts +1 -2
- package/src/tool/schemas.ts +34 -38
- package/src/tool/session-lifecycle.ts +7 -3
- package/src/tool/snapshot-tools.ts +2 -1
- package/src/types.ts +104 -3
- package/src/ui/review-tool-format.ts +138 -0
- package/src/ui/review-tool-renderer.ts +203 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkReviewSnapshotFreshness,
|
|
3
|
+
fingerprintReviewSnapshot,
|
|
4
|
+
resolveReviewSnapshot,
|
|
5
|
+
summarizeReviewSnapshot,
|
|
6
|
+
} from "../git.ts";
|
|
7
|
+
import { BRIEF_SYNTHESIS_PROMPT_VERSION, synthesizeReviewBrief } from "../history/synthesize.ts";
|
|
8
|
+
import { normalizeReviewResult } from "../review-result.ts";
|
|
9
|
+
import type { ReviewPlanStore, StoredAgentReviewPlan } from "../session/review-plan-store.ts";
|
|
10
|
+
import { buildReviewPacket } from "../target/packet.ts";
|
|
11
|
+
import type {
|
|
12
|
+
AgentReviewBatchDetails,
|
|
13
|
+
AgentReviewerResult,
|
|
14
|
+
BriefCritique,
|
|
15
|
+
BriefEvaluation,
|
|
16
|
+
BriefSynthesisRunResult,
|
|
17
|
+
ReviewerAssignment,
|
|
18
|
+
ReviewerAssignmentResult,
|
|
19
|
+
ReviewModelSelection,
|
|
20
|
+
ReviewProgress,
|
|
21
|
+
ReviewResult,
|
|
22
|
+
ReviewTargetSpec,
|
|
23
|
+
SynthesizedReviewBrief,
|
|
24
|
+
} from "../types.ts";
|
|
25
|
+
import { runReviewer } from "./review-runner.ts";
|
|
26
|
+
|
|
27
|
+
/** Inputs required to synthesize and retain one agent-driven review plan. */
|
|
28
|
+
export interface PrepareAgentReviewWorkflowInput {
|
|
29
|
+
cwd: string;
|
|
30
|
+
target: ReviewTargetSpec;
|
|
31
|
+
note?: string;
|
|
32
|
+
serializedContext: string;
|
|
33
|
+
model: ReviewModelSelection;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
onProgress?: (progress: ReviewProgress) => void;
|
|
36
|
+
planStore: ReviewPlanStore;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Typed preparation outcome before any reviewer child session starts. */
|
|
40
|
+
export type PrepareAgentReviewWorkflowOutcome =
|
|
41
|
+
| { kind: "prepared"; plan: StoredAgentReviewPlan }
|
|
42
|
+
| { kind: "no-snapshot"; reason: string }
|
|
43
|
+
| { kind: "synthesis-failed"; result: Exclude<BriefSynthesisRunResult, { kind: "success" }> };
|
|
44
|
+
|
|
45
|
+
/** Inputs required to critique and execute one prepared review plan. */
|
|
46
|
+
export interface RunAgentReviewWorkflowInput {
|
|
47
|
+
cwd: string;
|
|
48
|
+
planId: string;
|
|
49
|
+
critique: BriefCritique;
|
|
50
|
+
revisedBrief?: Omit<SynthesizedReviewBrief, "note">;
|
|
51
|
+
reviewers: ReviewerAssignment[];
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
onBriefEvaluation?: (evaluation: BriefEvaluation) => void;
|
|
54
|
+
onReviewerProgress?: (reviewerId: string, progress: ReviewProgress) => void;
|
|
55
|
+
onReviewerDone?: (reviewerId: string) => void;
|
|
56
|
+
planStore: ReviewPlanStore;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Typed batch outcome after validation and snapshot freshness checks. */
|
|
60
|
+
export type RunAgentReviewWorkflowOutcome =
|
|
61
|
+
| { kind: "completed"; details: AgentReviewBatchDetails }
|
|
62
|
+
| { kind: "invalid"; reason: string }
|
|
63
|
+
| { kind: "stale"; reason: string };
|
|
64
|
+
|
|
65
|
+
/** Prepare one session-scoped review plan without starting reviewer sessions. */
|
|
66
|
+
export async function prepareAgentReviewPlan(
|
|
67
|
+
input: PrepareAgentReviewWorkflowInput,
|
|
68
|
+
): Promise<PrepareAgentReviewWorkflowOutcome> {
|
|
69
|
+
const snapshot = await resolveReviewSnapshot(input.cwd, input.target);
|
|
70
|
+
if (!snapshot) {
|
|
71
|
+
return {
|
|
72
|
+
kind: "no-snapshot",
|
|
73
|
+
reason: `No reviewable changes found for ${formatTarget(input.target)}.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
});
|
|
86
|
+
if (synthesis.kind !== "success") {
|
|
87
|
+
return { kind: "synthesis-failed", result: synthesis };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const generatedBrief: SynthesizedReviewBrief = {
|
|
91
|
+
...synthesis.brief,
|
|
92
|
+
note: input.note,
|
|
93
|
+
};
|
|
94
|
+
const snapshotFingerprint = await fingerprintReviewSnapshot(input.cwd, snapshot, input.signal);
|
|
95
|
+
const plan = input.planStore.create({
|
|
96
|
+
snapshot,
|
|
97
|
+
snapshotFingerprint,
|
|
98
|
+
generatedBrief,
|
|
99
|
+
model: input.model,
|
|
100
|
+
briefPromptVersion: BRIEF_SYNTHESIS_PROMPT_VERSION,
|
|
101
|
+
});
|
|
102
|
+
return { kind: "prepared", plan };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Validate, atomically consume, freshness-check, and execute one prepared review batch. */
|
|
106
|
+
export async function runAgentReviewBatch(
|
|
107
|
+
input: RunAgentReviewWorkflowInput,
|
|
108
|
+
): Promise<RunAgentReviewWorkflowOutcome> {
|
|
109
|
+
const plan = input.planStore.get(input.planId);
|
|
110
|
+
if (!plan) {
|
|
111
|
+
return {
|
|
112
|
+
kind: "invalid",
|
|
113
|
+
reason: `Review plan "${input.planId}" was not found in this session.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const validation = validateRunInput(input, plan);
|
|
118
|
+
if (validation.kind === "invalid") return validation;
|
|
119
|
+
|
|
120
|
+
const claimedPlan = input.planStore.take(input.planId);
|
|
121
|
+
if (!claimedPlan) {
|
|
122
|
+
return {
|
|
123
|
+
kind: "invalid",
|
|
124
|
+
reason: `Review plan "${input.planId}" has already been consumed.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const evaluation: BriefEvaluation = {
|
|
129
|
+
planId: claimedPlan.id,
|
|
130
|
+
briefPromptVersion: claimedPlan.briefPromptVersion,
|
|
131
|
+
generatedBrief: claimedPlan.generatedBrief,
|
|
132
|
+
critique: validation.critique,
|
|
133
|
+
effectiveBrief: validation.effectiveBrief,
|
|
134
|
+
synthesizerModelId: claimedPlan.model.canonicalId,
|
|
135
|
+
snapshotFingerprint: claimedPlan.snapshotFingerprint,
|
|
136
|
+
};
|
|
137
|
+
input.onBriefEvaluation?.(evaluation);
|
|
138
|
+
|
|
139
|
+
const freshness = await checkReviewSnapshotFreshness(
|
|
140
|
+
input.cwd,
|
|
141
|
+
claimedPlan.snapshot,
|
|
142
|
+
claimedPlan.snapshotFingerprint,
|
|
143
|
+
input.signal,
|
|
144
|
+
);
|
|
145
|
+
if (!freshness.fresh) return { kind: "stale", reason: freshness.reason };
|
|
146
|
+
|
|
147
|
+
const results = await Promise.all(
|
|
148
|
+
validation.reviewers.map(async (assignment) => {
|
|
149
|
+
const result = await runAssignment(assignment, claimedPlan, validation.effectiveBrief, input);
|
|
150
|
+
input.onReviewerDone?.(assignment.id);
|
|
151
|
+
return result;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const finalFreshness = await checkReviewSnapshotFreshness(
|
|
156
|
+
input.cwd,
|
|
157
|
+
claimedPlan.snapshot,
|
|
158
|
+
claimedPlan.snapshotFingerprint,
|
|
159
|
+
input.signal,
|
|
160
|
+
);
|
|
161
|
+
if (!finalFreshness.fresh) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "stale",
|
|
164
|
+
reason: "The review target changed while reviewer sessions were running. Prepare a new plan.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
kind: "completed",
|
|
170
|
+
details: {
|
|
171
|
+
kind: "review-batch",
|
|
172
|
+
evaluation,
|
|
173
|
+
snapshot: summarizeReviewSnapshot(claimedPlan.snapshot),
|
|
174
|
+
results,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runAssignment(
|
|
180
|
+
assignment: ReviewerAssignment,
|
|
181
|
+
plan: StoredAgentReviewPlan,
|
|
182
|
+
effectiveBrief: SynthesizedReviewBrief,
|
|
183
|
+
input: RunAgentReviewWorkflowInput,
|
|
184
|
+
): Promise<ReviewerAssignmentResult> {
|
|
185
|
+
const packet = buildReviewPacket(plan.snapshot, effectiveBrief, plan.model, assignment);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const rawResult = await runReviewer({
|
|
189
|
+
prompt: packet.prompt,
|
|
190
|
+
model: plan.model,
|
|
191
|
+
cwd: input.cwd,
|
|
192
|
+
signal: input.signal,
|
|
193
|
+
snapshot: plan.snapshot,
|
|
194
|
+
brief: effectiveBrief,
|
|
195
|
+
onProgress: (progress) => input.onReviewerProgress?.(assignment.id, progress),
|
|
196
|
+
});
|
|
197
|
+
return { assignment, result: compactReviewResult(normalizeReviewResult(rawResult)) };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return {
|
|
200
|
+
assignment,
|
|
201
|
+
result: {
|
|
202
|
+
kind: "failed",
|
|
203
|
+
reason: `Reviewer session failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
204
|
+
modelId: plan.model.canonicalId,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function compactReviewResult(result: ReviewResult): AgentReviewerResult {
|
|
211
|
+
switch (result.kind) {
|
|
212
|
+
case "success":
|
|
213
|
+
return { kind: "success", output: result.output, modelId: result.modelId };
|
|
214
|
+
case "failed":
|
|
215
|
+
return {
|
|
216
|
+
kind: "failed",
|
|
217
|
+
reason: result.reason,
|
|
218
|
+
modelId: result.modelId,
|
|
219
|
+
debug: result.debug,
|
|
220
|
+
};
|
|
221
|
+
case "canceled":
|
|
222
|
+
return { kind: "canceled", modelId: result.modelId, debug: result.debug };
|
|
223
|
+
case "timeout":
|
|
224
|
+
return {
|
|
225
|
+
kind: "timeout",
|
|
226
|
+
timeoutMs: result.timeoutMs,
|
|
227
|
+
partialOutput: result.partialOutput,
|
|
228
|
+
modelId: result.modelId,
|
|
229
|
+
debug: result.debug,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateRunInput(
|
|
235
|
+
input: RunAgentReviewWorkflowInput,
|
|
236
|
+
plan: StoredAgentReviewPlan,
|
|
237
|
+
):
|
|
238
|
+
| {
|
|
239
|
+
kind: "valid";
|
|
240
|
+
critique: BriefCritique;
|
|
241
|
+
effectiveBrief: SynthesizedReviewBrief;
|
|
242
|
+
reviewers: ReviewerAssignment[];
|
|
243
|
+
}
|
|
244
|
+
| { kind: "invalid"; reason: string } {
|
|
245
|
+
if (input.critique.verdict === "revise" && !input.revisedBrief) {
|
|
246
|
+
return {
|
|
247
|
+
kind: "invalid",
|
|
248
|
+
reason: 'revisedBrief is required when critique.verdict is "revise".',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (input.critique.verdict === "accept" && input.revisedBrief) {
|
|
252
|
+
return {
|
|
253
|
+
kind: "invalid",
|
|
254
|
+
reason: 'revisedBrief must be omitted when critique.verdict is "accept".',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const critiqueResult = normalizeBriefCritique(input.critique);
|
|
259
|
+
if (critiqueResult.kind === "invalid") return critiqueResult;
|
|
260
|
+
const critique = critiqueResult.critique;
|
|
261
|
+
|
|
262
|
+
if (input.reviewers.length < 1 || input.reviewers.length > 4) {
|
|
263
|
+
return { kind: "invalid", reason: "Provide between one and four reviewer assignments." };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const reviewers = input.reviewers.map((assignment) => ({
|
|
267
|
+
id: assignment.id.trim(),
|
|
268
|
+
focus: assignment.focus.trim(),
|
|
269
|
+
}));
|
|
270
|
+
const invalidAssignment = reviewers.find((assignment) => !assignment.id || !assignment.focus);
|
|
271
|
+
if (invalidAssignment) {
|
|
272
|
+
return { kind: "invalid", reason: "Reviewer ids and focus instructions must not be blank." };
|
|
273
|
+
}
|
|
274
|
+
const ids = new Set(reviewers.map((assignment) => assignment.id));
|
|
275
|
+
if (ids.size !== reviewers.length) {
|
|
276
|
+
return { kind: "invalid", reason: "Reviewer assignment ids must be unique." };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const effectiveBriefResult = input.revisedBrief
|
|
280
|
+
? normalizeRevisedBrief(input.revisedBrief, plan.generatedBrief.note)
|
|
281
|
+
: { kind: "valid" as const, brief: plan.generatedBrief };
|
|
282
|
+
if (effectiveBriefResult.kind === "invalid") return effectiveBriefResult;
|
|
283
|
+
const effectiveBrief = effectiveBriefResult.brief;
|
|
284
|
+
const invalidRiskyFile = effectiveBrief.riskyFiles.find(
|
|
285
|
+
(file) => !plan.snapshot.changedFiles.includes(file),
|
|
286
|
+
);
|
|
287
|
+
if (invalidRiskyFile) {
|
|
288
|
+
return {
|
|
289
|
+
kind: "invalid",
|
|
290
|
+
reason: `Revised brief riskyFiles contains "${invalidRiskyFile}", which is not in the prepared snapshot.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { kind: "valid", critique, effectiveBrief, reviewers };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeBriefCritique(
|
|
298
|
+
input: BriefCritique,
|
|
299
|
+
): { kind: "valid"; critique: BriefCritique } | { kind: "invalid"; reason: string } {
|
|
300
|
+
if (input.findings.length > 20) {
|
|
301
|
+
return { kind: "invalid", reason: "Brief critique supports at most 20 findings." };
|
|
302
|
+
}
|
|
303
|
+
if (input.verdict === "revise" && input.findings.length === 0) {
|
|
304
|
+
return {
|
|
305
|
+
kind: "invalid",
|
|
306
|
+
reason: 'critique.findings must contain evidence when critique.verdict is "revise".',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const critique: BriefCritique = {
|
|
311
|
+
verdict: input.verdict,
|
|
312
|
+
summary: input.summary.trim(),
|
|
313
|
+
findings: input.findings.map((finding) => ({
|
|
314
|
+
...finding,
|
|
315
|
+
explanation: finding.explanation.trim(),
|
|
316
|
+
evidence: finding.evidence.trim(),
|
|
317
|
+
proposedChange: finding.proposedChange.trim(),
|
|
318
|
+
})),
|
|
319
|
+
};
|
|
320
|
+
if (!critique.summary) {
|
|
321
|
+
return { kind: "invalid", reason: "Brief critique summary must not be blank." };
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
critique.findings.some(
|
|
325
|
+
(finding) => !finding.explanation || !finding.evidence || !finding.proposedChange,
|
|
326
|
+
)
|
|
327
|
+
) {
|
|
328
|
+
return { kind: "invalid", reason: "Brief critique findings must contain non-blank text." };
|
|
329
|
+
}
|
|
330
|
+
return { kind: "valid", critique };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeRevisedBrief(
|
|
334
|
+
brief: Omit<SynthesizedReviewBrief, "note">,
|
|
335
|
+
note: string | undefined,
|
|
336
|
+
): { kind: "valid"; brief: SynthesizedReviewBrief } | { kind: "invalid"; reason: string } {
|
|
337
|
+
const summary = brief.summary.trim();
|
|
338
|
+
const intendedOutcome = brief.intendedOutcome.trim();
|
|
339
|
+
if (!summary || !intendedOutcome) {
|
|
340
|
+
return {
|
|
341
|
+
kind: "invalid",
|
|
342
|
+
reason: "revisedBrief.summary and revisedBrief.intendedOutcome must not be blank.",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const constraints = brief.constraints.map((item) => item.trim());
|
|
347
|
+
const focusAreas = brief.focusAreas.map((item) => item.trim());
|
|
348
|
+
const riskyFiles = brief.riskyFiles.map((item) => item.trim());
|
|
349
|
+
const unresolvedQuestions = brief.unresolvedQuestions.map((item) => item.trim());
|
|
350
|
+
if (
|
|
351
|
+
[...constraints, ...focusAreas, ...riskyFiles, ...unresolvedQuestions].some((item) => !item)
|
|
352
|
+
) {
|
|
353
|
+
return { kind: "invalid", reason: "revisedBrief arrays must not contain blank entries." };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
kind: "valid",
|
|
358
|
+
brief: {
|
|
359
|
+
summary,
|
|
360
|
+
intendedOutcome,
|
|
361
|
+
constraints,
|
|
362
|
+
focusAreas,
|
|
363
|
+
riskyFiles,
|
|
364
|
+
unresolvedQuestions,
|
|
365
|
+
reviewInstructionBlockIds: [...brief.reviewInstructionBlockIds],
|
|
366
|
+
note,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatTarget(target: ReviewTargetSpec): string {
|
|
372
|
+
switch (target.kind) {
|
|
373
|
+
case "working-tree":
|
|
374
|
+
return "the working tree";
|
|
375
|
+
case "branch":
|
|
376
|
+
return `changes against ${target.base}`;
|
|
377
|
+
case "commit":
|
|
378
|
+
return `commit ${target.sha}`;
|
|
379
|
+
}
|
|
380
|
+
}
|
package/src/tool/brief-runner.ts
CHANGED
|
@@ -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,
|
|
@@ -68,7 +67,6 @@ async function createBriefSession(
|
|
|
68
67
|
const { session } = await createAgentSession({
|
|
69
68
|
cwd: invocation.cwd,
|
|
70
69
|
model: invocation.model,
|
|
71
|
-
modelRegistry: invocation.modelRegistry,
|
|
72
70
|
thinkingLevel: clampThinkingLevel(invocation.model, "max"),
|
|
73
71
|
tools: ["submit_review_brief"],
|
|
74
72
|
customTools: [submitBriefTool],
|
|
@@ -88,16 +86,15 @@ function emitBriefProgress(
|
|
|
88
86
|
invocation.onProgress?.(ctx.progress);
|
|
89
87
|
}
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
/** Finalize after retries and compaction recovery, never at the earlier `agent_end` boundary. */
|
|
90
|
+
function handleAgentSettled(options: {
|
|
93
91
|
session: AgentSession;
|
|
94
92
|
brief: SynthesizedReviewBrief | undefined;
|
|
95
93
|
state: { settled: boolean; aborting: boolean };
|
|
96
94
|
cleanup: (result: BriefSynthesisRunResult) => BriefSynthesisRunResult;
|
|
97
95
|
}): BriefSynthesisRunResult | undefined {
|
|
98
|
-
const {
|
|
99
|
-
|
|
100
|
-
if (retryAwareEvent.willRetry || state.settled || state.aborting) {
|
|
96
|
+
const { session, brief, state, cleanup } = options;
|
|
97
|
+
if (state.settled || state.aborting) {
|
|
101
98
|
return undefined;
|
|
102
99
|
}
|
|
103
100
|
if (brief) {
|
|
@@ -156,9 +153,8 @@ export async function runBriefSynthesis(
|
|
|
156
153
|
ctx.progress.currentFocus = undefined;
|
|
157
154
|
emitBriefProgress(ctx, invocation);
|
|
158
155
|
break;
|
|
159
|
-
case "
|
|
160
|
-
const result =
|
|
161
|
-
event,
|
|
156
|
+
case "agent_settled": {
|
|
157
|
+
const result = handleAgentSettled({
|
|
162
158
|
session,
|
|
163
159
|
brief: resultHolder.value,
|
|
164
160
|
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.";
|
package/src/tool/review-debug.ts
CHANGED
|
@@ -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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 "
|
|
297
|
-
|
|
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:
|
|
33
|
+
noExtensions: true,
|
|
34
34
|
noSkills: true,
|
|
35
35
|
noPromptTemplates: true,
|
|
36
36
|
noThemes: true,
|
|
@@ -42,7 +42,6 @@ async function createReviewerSession(
|
|
|
42
42
|
const { session } = await createAgentSession({
|
|
43
43
|
cwd: invocation.cwd,
|
|
44
44
|
model: invocation.model.model,
|
|
45
|
-
modelRegistry: invocation.modelRegistry,
|
|
46
45
|
thinkingLevel: clampThinkingLevel(invocation.model.model, "max"),
|
|
47
46
|
tools: [
|
|
48
47
|
"read",
|
package/src/tool/schemas.ts
CHANGED
|
@@ -1,40 +1,33 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
|
|
3
|
-
const reviewItemCategorySchema =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]);
|
|
19
|
+
const reviewItemRecommendedActionSchema = StringEnum([
|
|
20
|
+
"must-fix",
|
|
21
|
+
"should-fix",
|
|
22
|
+
"consider",
|
|
23
|
+
] as const);
|
|
31
24
|
|
|
32
|
-
const reviewInstructionBlockIdSchema =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
68
|
-
intendedOutcome:
|
|
69
|
-
constraints: Type.Array(
|
|
70
|
-
focusAreas: Type.Array(
|
|
71
|
-
riskyFiles: Type.Array(
|
|
72
|
-
unresolvedQuestions: Type.Array(
|
|
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
|
|
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:
|
|
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" };
|