pi-workflow-engine 0.10.2 → 0.11.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/.pi/extensions/pi-workflow-engine/index.ts +199 -120
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
2
|
+
import { isCommentableIssue, type ReviewIssue } from "./review-issues.ts";
|
|
3
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
2
4
|
|
|
3
5
|
export interface ExecResultLike {
|
|
4
6
|
readonly stdout: string;
|
|
@@ -34,12 +36,13 @@ export async function resolveGitHubPrContext(
|
|
|
34
36
|
exec: ExecLike,
|
|
35
37
|
cwd: string,
|
|
36
38
|
reviewContext: ReviewContext | undefined,
|
|
39
|
+
signal?: AbortSignal,
|
|
37
40
|
): Promise<ResolveGitHubPrContextResult> {
|
|
38
|
-
const parsedNumber =
|
|
41
|
+
const parsedNumber = reviewContext?.diffTarget.kind === "pull-request" ? reviewContext.diffTarget.number : undefined;
|
|
39
42
|
const prArgs = parsedNumber
|
|
40
43
|
? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
|
|
41
44
|
: ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
|
|
42
|
-
const prView = await runJson(exec, cwd, prArgs);
|
|
45
|
+
const prView = await runJson(exec, cwd, prArgs, signal);
|
|
43
46
|
if (!prView.ok) return { ok: false, reason: prView.reason };
|
|
44
47
|
|
|
45
48
|
const number = numberField(prView.value, "number") ?? parsedNumber;
|
|
@@ -52,7 +55,7 @@ export async function resolveGitHubPrContext(
|
|
|
52
55
|
let owner = baseFromUrl?.owner;
|
|
53
56
|
let repo = baseFromUrl?.repo;
|
|
54
57
|
if (!owner || !repo) {
|
|
55
|
-
const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"]);
|
|
58
|
+
const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"], signal);
|
|
56
59
|
if (!repoView.ok) return { ok: false, reason: repoView.reason };
|
|
57
60
|
const nameWithOwner = stringField(repoView.value, "nameWithOwner");
|
|
58
61
|
const parsed = parseNameWithOwner(nameWithOwner);
|
|
@@ -84,6 +87,7 @@ export async function postInlineComment(
|
|
|
84
87
|
cwd: string,
|
|
85
88
|
prContext: GitHubPrContext,
|
|
86
89
|
issue: ReviewIssue,
|
|
90
|
+
signal?: AbortSignal,
|
|
87
91
|
): Promise<InlineCommentStatus> {
|
|
88
92
|
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
89
93
|
|
|
@@ -100,7 +104,7 @@ export async function postInlineComment(
|
|
|
100
104
|
`line=${issue.line}`,
|
|
101
105
|
"-f",
|
|
102
106
|
"side=RIGHT",
|
|
103
|
-
]);
|
|
107
|
+
], signal);
|
|
104
108
|
if (!result.ok) return { issueId: issue.id, status: "failed", reason: result.reason };
|
|
105
109
|
return { issueId: issue.id, status: "posted", url: parsePostedCommentUrl(result.stdout) };
|
|
106
110
|
}
|
|
@@ -110,24 +114,49 @@ export async function postInlineComments(
|
|
|
110
114
|
cwd: string,
|
|
111
115
|
prContext: GitHubPrContext,
|
|
112
116
|
issues: readonly ReviewIssue[],
|
|
117
|
+
signal?: AbortSignal,
|
|
113
118
|
): Promise<InlineCommentStatus[]> {
|
|
119
|
+
throwIfAborted(signal);
|
|
120
|
+
const existing = await loadExistingInlineCommentKeys(exec, cwd, prContext, signal);
|
|
121
|
+
if (!existing.ok) {
|
|
122
|
+
return issues.map((issue) => ({ issueId: issue.id, status: "failed", reason: existing.reason }));
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
const statuses: InlineCommentStatus[] = [];
|
|
115
126
|
for (const issue of issues) {
|
|
116
|
-
|
|
127
|
+
throwIfAborted(signal);
|
|
128
|
+
const key = inlineCommentKey(issue, prContext.headSha);
|
|
129
|
+
if (key && existing.keys.has(key)) {
|
|
130
|
+
statuses.push({ issueId: issue.id, status: "skipped", reason: "An identical inline comment already exists on this PR head." });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const status = await postInlineComment(exec, cwd, prContext, issue, signal);
|
|
134
|
+
statuses.push(status);
|
|
135
|
+
if (key && status.status === "posted") existing.keys.add(key);
|
|
117
136
|
}
|
|
118
137
|
return statuses;
|
|
119
138
|
}
|
|
120
139
|
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
async function loadExistingInlineCommentKeys(
|
|
141
|
+
exec: ExecLike,
|
|
142
|
+
cwd: string,
|
|
143
|
+
prContext: GitHubPrContext,
|
|
144
|
+
signal: AbortSignal | undefined,
|
|
145
|
+
): Promise<{ readonly ok: true; readonly keys: Set<string> } | { readonly ok: false; readonly reason: string }> {
|
|
146
|
+
const endpoint = `repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`;
|
|
147
|
+
const result = await runJson(exec, cwd, ["api", endpoint, "--paginate", "--slurp"], signal);
|
|
148
|
+
if (!result.ok) return { ok: false, reason: `Could not inspect existing PR comments: ${result.reason}` };
|
|
149
|
+
const keys = parseExistingInlineCommentKeys(result.value);
|
|
150
|
+
return keys ? { ok: true, keys } : { ok: false, reason: "Could not inspect existing PR comments: unexpected GitHub response." };
|
|
127
151
|
}
|
|
128
152
|
|
|
129
|
-
async function runJson(
|
|
130
|
-
|
|
153
|
+
async function runJson(
|
|
154
|
+
exec: ExecLike,
|
|
155
|
+
cwd: string,
|
|
156
|
+
args: string[],
|
|
157
|
+
signal?: AbortSignal,
|
|
158
|
+
): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string }> {
|
|
159
|
+
const result = await runCommand(exec, cwd, args, signal);
|
|
131
160
|
if (!result.ok) return result;
|
|
132
161
|
try {
|
|
133
162
|
return { ok: true, value: JSON.parse(result.stdout) };
|
|
@@ -136,16 +165,46 @@ async function runJson(exec: ExecLike, cwd: string, args: string[]): Promise<{ r
|
|
|
136
165
|
}
|
|
137
166
|
}
|
|
138
167
|
|
|
139
|
-
async function runCommand(
|
|
168
|
+
async function runCommand(
|
|
169
|
+
exec: ExecLike,
|
|
170
|
+
cwd: string,
|
|
171
|
+
args: string[],
|
|
172
|
+
signal?: AbortSignal,
|
|
173
|
+
): Promise<{ readonly ok: true; readonly stdout: string } | { readonly ok: false; readonly reason: string }> {
|
|
174
|
+
throwIfAborted(signal);
|
|
140
175
|
try {
|
|
141
|
-
const result = await exec("gh", args, { cwd, timeout: 30_000 });
|
|
176
|
+
const result = await exec("gh", args, { cwd, timeout: 30_000, signal });
|
|
177
|
+
throwIfAborted(signal);
|
|
142
178
|
if (result.code !== 0) return { ok: false, reason: result.stderr?.trim() || `gh exited with code ${result.code}` };
|
|
143
179
|
return { ok: true, stdout: result.stdout };
|
|
144
180
|
} catch (error) {
|
|
181
|
+
throwIfAborted(signal);
|
|
145
182
|
return { ok: false, reason: error instanceof Error ? error.message : "gh command failed." };
|
|
146
183
|
}
|
|
147
184
|
}
|
|
148
185
|
|
|
186
|
+
function parseExistingInlineCommentKeys(value: unknown): Set<string> | undefined {
|
|
187
|
+
if (!Array.isArray(value)) return undefined;
|
|
188
|
+
const comments = value.every(Array.isArray) ? value.flat() : value;
|
|
189
|
+
const keys = new Set<string>();
|
|
190
|
+
for (const comment of comments) {
|
|
191
|
+
const body = stringField(comment, "body");
|
|
192
|
+
const path = stringField(comment, "path");
|
|
193
|
+
const line = numberField(comment, "line");
|
|
194
|
+
const headSha = stringField(comment, "commit_id");
|
|
195
|
+
if (body && path && line !== undefined && headSha) keys.add(commentKey(body, path, line, headSha));
|
|
196
|
+
}
|
|
197
|
+
return keys;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inlineCommentKey(issue: ReviewIssue, headSha: string): string | undefined {
|
|
201
|
+
return isCommentableIssue(issue) ? commentKey(buildInlineCommentBody(issue), issue.file, issue.line, headSha) : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function commentKey(body: string, path: string, line: number, headSha: string): string {
|
|
205
|
+
return JSON.stringify([body, path, line, headSha]);
|
|
206
|
+
}
|
|
207
|
+
|
|
149
208
|
function numberField(value: unknown, key: string): number | undefined {
|
|
150
209
|
if (!isRecord(value)) return undefined;
|
|
151
210
|
const field = value[key];
|
|
@@ -1,40 +1,69 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
3
|
+
import type { LoadedWorkflow } from "../types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
5
|
+
import type { WorktreeBaseline } from "../worktree.ts";
|
|
2
6
|
import { postInlineComments, resolveGitHubPrContext, type ExecLike, type InlineCommentStatus } from "./github-pr-comments.ts";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
7
|
+
import { createReviewFixWorkflow } from "./review-fix-workflow.ts";
|
|
8
|
+
import { buildCommentHandoffPrompt } from "./review-handoff.ts";
|
|
9
|
+
import { isCommentableIssue, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
10
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
11
|
+
import { resolveReviewWorktreeBaseline, ReviewSnapshotUnavailableError } from "./review-snapshot.ts";
|
|
5
12
|
|
|
6
13
|
export type ReviewActionPi = Pick<ExtensionAPI, "sendUserMessage" | "exec">;
|
|
7
14
|
export interface ReviewActionContext {
|
|
8
15
|
readonly cwd: string;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
9
17
|
readonly ui: {
|
|
10
18
|
confirm(title: string, message: string): Promise<boolean>;
|
|
11
19
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
12
20
|
};
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
export type ReviewBaselineResolver = (
|
|
24
|
+
context: ReviewContext | undefined,
|
|
25
|
+
cwd: string,
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
) => Promise<WorktreeBaseline>;
|
|
28
|
+
|
|
15
29
|
export async function handleReviewViewerAction(
|
|
16
30
|
pi: ReviewActionPi,
|
|
17
31
|
ctx: ReviewActionContext,
|
|
18
32
|
action: ReviewIssueSelection | undefined,
|
|
19
33
|
issues: readonly ReviewIssue[],
|
|
20
34
|
context: ReviewContext | undefined,
|
|
21
|
-
|
|
35
|
+
resolveBaseline: ReviewBaselineResolver = resolveReviewWorktreeBaseline,
|
|
36
|
+
): Promise<LoadedWorkflow | undefined> {
|
|
22
37
|
if (!action || action.action === "close") return;
|
|
23
38
|
const selected = selectedIssues(issues, action.issueIds);
|
|
24
39
|
if (action.action === "fix") {
|
|
25
|
-
handleFixAction(
|
|
26
|
-
return;
|
|
40
|
+
return await handleFixAction(ctx, selected, context, resolveBaseline);
|
|
27
41
|
}
|
|
28
|
-
await handleCommentAction(pi, ctx, selected, context);
|
|
42
|
+
await handleCommentAction(pi, ctx, selected, context, resolveBaseline);
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
function handleFixAction(
|
|
45
|
+
async function handleFixAction(
|
|
46
|
+
ctx: ReviewActionContext,
|
|
47
|
+
selected: readonly ReviewIssue[],
|
|
48
|
+
context: ReviewContext | undefined,
|
|
49
|
+
resolveBaseline: ReviewBaselineResolver,
|
|
50
|
+
): Promise<LoadedWorkflow | undefined> {
|
|
51
|
+
throwIfAborted(ctx.signal);
|
|
32
52
|
if (selected.length === 0) {
|
|
33
53
|
ctx.ui.notify("No selected findings to fix", "warning");
|
|
34
54
|
return;
|
|
35
55
|
}
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
ctx.ui.notify("Verifying the reviewed snapshot before generating patch previews", "info");
|
|
57
|
+
let baseline: WorktreeBaseline;
|
|
58
|
+
try {
|
|
59
|
+
baseline = await resolveBaseline(context, ctx.cwd, ctx.signal);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throwIfAborted(ctx.signal);
|
|
62
|
+
ctx.ui.notify(formatSnapshotError("Patch preview", error), "warning");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
ctx.ui.notify(`Generating isolated patch previews for ${selected.length} selected finding(s)`, "info");
|
|
66
|
+
return createReviewFixWorkflow(selected, context, baseline);
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
async function handleCommentAction(
|
|
@@ -42,53 +71,84 @@ async function handleCommentAction(
|
|
|
42
71
|
ctx: ReviewActionContext,
|
|
43
72
|
selected: readonly ReviewIssue[],
|
|
44
73
|
context: ReviewContext | undefined,
|
|
74
|
+
resolveBaseline: ReviewBaselineResolver,
|
|
45
75
|
): Promise<void> {
|
|
46
76
|
const commentable = selected.filter(isCommentableIssue);
|
|
47
77
|
if (commentable.length === 0) {
|
|
48
78
|
ctx.ui.notify("No selected findings have file and line information for inline PR comments", "warning");
|
|
49
79
|
return;
|
|
50
80
|
}
|
|
81
|
+
if (context?.diffTarget.kind !== "pull-request") {
|
|
82
|
+
ctx.ui.notify("PR comments are available only for findings captured from a verified pull-request diff.", "warning");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
51
85
|
|
|
86
|
+
throwIfAborted(ctx.signal);
|
|
52
87
|
const confirmed = await ctx.ui.confirm(
|
|
53
88
|
"Post inline PR comments?",
|
|
54
89
|
`Post ${commentable.length} selected finding(s) to the upstream PR using GitHub CLI if available?`,
|
|
55
90
|
);
|
|
56
91
|
if (!confirmed) {
|
|
57
|
-
|
|
92
|
+
ctx.ui.notify("PR comment posting cancelled", "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ctx.ui.notify("Verifying the reviewed snapshot before posting PR comments", "info");
|
|
97
|
+
let baseline: WorktreeBaseline;
|
|
98
|
+
try {
|
|
99
|
+
baseline = await resolveBaseline(context, ctx.cwd, ctx.signal);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throwIfAborted(ctx.signal);
|
|
102
|
+
ctx.ui.notify(formatSnapshotError("PR comments", error), "warning");
|
|
58
103
|
return;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
const exec = toExecLike(pi);
|
|
62
|
-
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context);
|
|
107
|
+
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context, ctx.signal);
|
|
63
108
|
if (!resolved.ok) {
|
|
64
|
-
queueCommentFallback(pi, ctx, commentable, context, resolved.reason);
|
|
109
|
+
await queueCommentFallback(pi, ctx, commentable, context, baseline.ref, resolved.reason);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (resolved.context.headSha !== baseline.ref) {
|
|
113
|
+
ctx.ui.notify("PR comments unavailable because the pull request head changed after snapshot verification.", "warning");
|
|
65
114
|
return;
|
|
66
115
|
}
|
|
67
116
|
|
|
68
|
-
const statuses = await postInlineComments(exec, ctx.cwd, resolved.context, commentable);
|
|
117
|
+
const statuses = await postInlineComments(exec, ctx.cwd, resolved.context, commentable, ctx.signal);
|
|
69
118
|
const summary = summarizeStatuses(statuses);
|
|
70
119
|
ctx.ui.notify(`PR comments: ${summary.posted} posted, ${summary.skipped} skipped, ${summary.failed} failed`, summary.failed > 0 ? "warning" : "info");
|
|
71
120
|
if (summary.failed > 0) {
|
|
72
121
|
const failedIds = new Set(statuses.filter((status) => status.status === "failed").map((status) => status.issueId));
|
|
73
|
-
queueCommentFallback(
|
|
122
|
+
await queueCommentFallback(
|
|
74
123
|
pi,
|
|
75
124
|
ctx,
|
|
76
125
|
commentable.filter((issue) => failedIds.has(issue.id)),
|
|
77
126
|
context,
|
|
127
|
+
baseline.ref,
|
|
78
128
|
"Direct gh posting failed for one or more selected findings.",
|
|
79
129
|
);
|
|
80
130
|
}
|
|
81
131
|
}
|
|
82
132
|
|
|
83
|
-
function queueCommentFallback(
|
|
133
|
+
async function queueCommentFallback(
|
|
84
134
|
pi: ReviewActionPi,
|
|
85
135
|
ctx: ReviewActionContext,
|
|
86
136
|
issues: readonly ReviewIssue[],
|
|
87
137
|
context: ReviewContext | undefined,
|
|
138
|
+
verifiedHead: string,
|
|
88
139
|
reason: string,
|
|
89
|
-
): void {
|
|
140
|
+
): Promise<void> {
|
|
90
141
|
if (issues.length === 0) return;
|
|
91
|
-
|
|
142
|
+
const confirmed = await ctx.ui.confirm(
|
|
143
|
+
"Queue PR comment fallback?",
|
|
144
|
+
`${reason}\n\nQueue ${issues.length} finding(s) for the parent agent to post after rechecking the PR head?`,
|
|
145
|
+
);
|
|
146
|
+
throwIfAborted(ctx.signal);
|
|
147
|
+
if (!confirmed) {
|
|
148
|
+
ctx.ui.notify("PR comment fallback cancelled", "info");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
pi.sendUserMessage(buildCommentHandoffPrompt(issues, context, verifiedHead, reason), { deliverAs: "followUp" });
|
|
92
152
|
ctx.ui.notify(`Queued PR comment request for ${issues.length} selected finding(s)`, "info");
|
|
93
153
|
}
|
|
94
154
|
|
|
@@ -112,3 +172,9 @@ function summarizeStatuses(statuses: readonly InlineCommentStatus[]): { readonly
|
|
|
112
172
|
}
|
|
113
173
|
return { posted, skipped, failed };
|
|
114
174
|
}
|
|
175
|
+
|
|
176
|
+
function formatSnapshotError(action: "Patch preview" | "PR comments", error: unknown): string {
|
|
177
|
+
return error instanceof ReviewSnapshotUnavailableError
|
|
178
|
+
? `${action} unavailable because ${error.message}`
|
|
179
|
+
: `${action} unavailable: ${unknownErrorMessage(error)}`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WorkflowUsageSnapshot } from "../usage.ts";
|
|
2
|
+
|
|
3
|
+
export type ReviewFixLease =
|
|
4
|
+
| { readonly ok: true; readonly release: () => void }
|
|
5
|
+
| { readonly ok: false; readonly reason: "busy" | "exhausted" };
|
|
6
|
+
|
|
7
|
+
/** Session-local budget ledger that serializes fix previews and records every finalized run. */
|
|
8
|
+
export class ReviewFixBudgetLedger {
|
|
9
|
+
private inFlight = false;
|
|
10
|
+
private remainingBudget: number | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(budget: number | null | undefined, initialUsage: WorkflowUsageSnapshot | undefined) {
|
|
13
|
+
this.remainingBudget = remainingWorkflowBudget(budget, initialUsage);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get remaining(): number | undefined {
|
|
17
|
+
return this.remainingBudget;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
acquire(): ReviewFixLease {
|
|
21
|
+
if (this.remainingBudget === 0) return { ok: false, reason: "exhausted" };
|
|
22
|
+
if (this.inFlight) return { ok: false, reason: "busy" };
|
|
23
|
+
this.inFlight = true;
|
|
24
|
+
let released = false;
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
release: () => {
|
|
28
|
+
if (released) return;
|
|
29
|
+
released = true;
|
|
30
|
+
this.inFlight = false;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
record(usage: WorkflowUsageSnapshot): void {
|
|
36
|
+
this.remainingBudget = remainingWorkflowBudget(this.remainingBudget, usage);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function remainingWorkflowBudget(
|
|
41
|
+
budget: number | null | undefined,
|
|
42
|
+
usage: WorkflowUsageSnapshot | undefined,
|
|
43
|
+
): number | undefined {
|
|
44
|
+
return budget == null ? undefined : Math.max(0, budget - (usage?.totals.output ?? 0));
|
|
45
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ParallelSettledError } from "../concurrency.ts";
|
|
2
|
+
import type { LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
3
|
+
import type { WorktreeBaseline } from "../worktree.ts";
|
|
4
|
+
import { loadWorkflow } from "../workflow-module.ts";
|
|
5
|
+
import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
|
|
6
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
7
|
+
import { serializeReviewContext } from "./review-report.ts";
|
|
8
|
+
|
|
9
|
+
const REVIEW_FIX_PHASE = "Generate patch previews";
|
|
10
|
+
const REVIEW_FIX_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
11
|
+
|
|
12
|
+
export interface ReviewFixPreview {
|
|
13
|
+
readonly findingId: string;
|
|
14
|
+
readonly result: string;
|
|
15
|
+
readonly patch: string;
|
|
16
|
+
readonly changed: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReviewFixFailure {
|
|
20
|
+
readonly findingId: string;
|
|
21
|
+
readonly error: ParallelSettledError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ReviewFixOutcome = ReviewFixPreview | ReviewFixFailure;
|
|
25
|
+
|
|
26
|
+
export interface ReviewFixWorkflowResult {
|
|
27
|
+
readonly summary: string;
|
|
28
|
+
readonly fixes: readonly ReviewFixOutcome[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase">;
|
|
32
|
+
|
|
33
|
+
/** Build an ephemeral workflow that generates one isolated patch preview per finding. */
|
|
34
|
+
export function createReviewFixWorkflow(
|
|
35
|
+
issues: readonly ReviewIssue[],
|
|
36
|
+
context: ReviewContext | undefined,
|
|
37
|
+
baseline: WorktreeBaseline,
|
|
38
|
+
): LoadedWorkflow {
|
|
39
|
+
const module: WorkflowModule = {
|
|
40
|
+
meta: {
|
|
41
|
+
name: "code-review-fix-previews",
|
|
42
|
+
description: "Generate isolated patch previews for selected code-review findings.",
|
|
43
|
+
phases: [{ title: REVIEW_FIX_PHASE }],
|
|
44
|
+
},
|
|
45
|
+
default: (api) => runReviewFixWorkflow(api, issues, context),
|
|
46
|
+
};
|
|
47
|
+
return loadWorkflow(
|
|
48
|
+
module,
|
|
49
|
+
{
|
|
50
|
+
kind: "unverifiable",
|
|
51
|
+
reason: "ephemeral review-fix workflows capture runtime findings and do not have immutable module provenance",
|
|
52
|
+
},
|
|
53
|
+
baseline,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildFixAgentPrompt(issue: ReviewIssue, context: ReviewContext | undefined): string {
|
|
58
|
+
return `Generate a patch preview for exactly one verified code-review finding in your disposable git worktree.
|
|
59
|
+
|
|
60
|
+
Selected finding JSON:
|
|
61
|
+
\`\`\`json
|
|
62
|
+
${JSON.stringify({ context: serializeReviewContext(context), issue: serializeReviewIssue(issue) })}
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
Instructions:
|
|
66
|
+
- Inspect the finding and cited evidence before editing.
|
|
67
|
+
- Make the smallest edit that addresses only this finding.
|
|
68
|
+
- Preserve unrelated user changes and avoid broad refactors.
|
|
69
|
+
- Run focused validation if an appropriate local check is available.
|
|
70
|
+
- Do not create commits or branches; the engine captures your worktree diff automatically.
|
|
71
|
+
- Do not post GitHub PR comments or any upstream review comments.
|
|
72
|
+
- Finish with a concise summary of changed files and validation results; this text is returned alongside the captured patch.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runReviewFixWorkflow(
|
|
76
|
+
api: ReviewFixWorkflowApi,
|
|
77
|
+
issues: readonly ReviewIssue[],
|
|
78
|
+
context: ReviewContext | undefined,
|
|
79
|
+
): Promise<ReviewFixWorkflowResult> {
|
|
80
|
+
api.phase(REVIEW_FIX_PHASE);
|
|
81
|
+
const settled = await api.parallel(
|
|
82
|
+
issues.map((issue) => async (): Promise<ReviewFixPreview> => {
|
|
83
|
+
const isolated = await api.agent(buildFixAgentPrompt(issue, context), {
|
|
84
|
+
isolation: "worktree",
|
|
85
|
+
label: `fix:${issue.id}`,
|
|
86
|
+
phase: REVIEW_FIX_PHASE,
|
|
87
|
+
thinkingLevel: "medium",
|
|
88
|
+
cacheKey: `review-fix:${issue.id}`,
|
|
89
|
+
tools: [...REVIEW_FIX_TOOLS],
|
|
90
|
+
toolHints: ["search"],
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
findingId: issue.id,
|
|
94
|
+
result: isolated.result,
|
|
95
|
+
patch: isolated.patch,
|
|
96
|
+
changed: isolated.changed,
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
{ settled: true },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const fixes = settled.map((entry, index): ReviewFixOutcome =>
|
|
103
|
+
entry.ok ? entry.value : { findingId: issues[index]!.id, error: entry.error },
|
|
104
|
+
);
|
|
105
|
+
const successful = fixes.filter(isReviewFixPreview);
|
|
106
|
+
const changed = successful.filter((fix) => fix.changed).length;
|
|
107
|
+
const failed = fixes.length - successful.length;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
summary: `Generated ${changed} patch preview(s); ${successful.length - changed} finding(s) needed no changes; ${failed} attempt(s) failed.`,
|
|
111
|
+
fixes,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
|
|
116
|
+
return "patch" in outcome;
|
|
117
|
+
}
|
|
@@ -1,47 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
readonly category: string;
|
|
12
|
-
readonly severity: string;
|
|
13
|
-
readonly confidence: string;
|
|
14
|
-
readonly location: {
|
|
15
|
-
readonly file?: string;
|
|
16
|
-
readonly line?: number;
|
|
17
|
-
readonly symbol?: string;
|
|
18
|
-
readonly display: string;
|
|
19
|
-
};
|
|
20
|
-
readonly impact: string;
|
|
21
|
-
readonly evidence: readonly string[];
|
|
22
|
-
readonly recommendation: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function buildFixHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined): string {
|
|
26
|
-
return `Use the workflow-code-review-actions skill if available.
|
|
27
|
-
|
|
28
|
-
Mode: fix selected code-review findings.
|
|
29
|
-
|
|
30
|
-
Selected findings JSON:
|
|
31
|
-
\`\`\`json
|
|
32
|
-
${JSON.stringify(toHandoffPayload(issues, context))}
|
|
33
|
-
\`\`\`
|
|
34
|
-
|
|
35
|
-
Instructions:
|
|
36
|
-
- Inspect the selected issue JSON before editing.
|
|
37
|
-
- Make minimal edits that address only the selected findings.
|
|
38
|
-
- Preserve unrelated user changes and avoid broad refactors.
|
|
39
|
-
- Run focused validation if an appropriate local check is available.
|
|
40
|
-
- Do not post GitHub PR comments or any upstream review comments.
|
|
41
|
-
- Summarize changed files and validation results when done.`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildCommentHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined, reason: string): string {
|
|
1
|
+
import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
|
|
2
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
3
|
+
import { serializeReviewContext } from "./review-report.ts";
|
|
4
|
+
|
|
5
|
+
export function buildCommentHandoffPrompt(
|
|
6
|
+
issues: readonly ReviewIssue[],
|
|
7
|
+
context: ReviewContext | undefined,
|
|
8
|
+
verifiedHead: string,
|
|
9
|
+
reason: string,
|
|
10
|
+
): string {
|
|
45
11
|
return `Use the workflow-code-review-actions skill if available.
|
|
46
12
|
|
|
47
13
|
Mode: post inline GitHub PR comments for selected code-review findings.
|
|
@@ -56,6 +22,7 @@ ${JSON.stringify(toHandoffPayload(issues, context))}
|
|
|
56
22
|
Instructions:
|
|
57
23
|
- Do not edit files or make code changes.
|
|
58
24
|
- Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
|
|
25
|
+
- Before posting, resolve the current PR head and require it to equal the verified reviewed head \`${verifiedHead}\`; stop if it differs.
|
|
59
26
|
- With gh, resolve the PR using \`gh pr view\` and \`gh repo view\`, then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
|
|
60
27
|
- Do not post duplicate comments.
|
|
61
28
|
- Do not post line-less findings as inline comments.
|
|
@@ -63,28 +30,9 @@ Instructions:
|
|
|
63
30
|
- Summarize posted, skipped, and failed comments when done.`;
|
|
64
31
|
}
|
|
65
32
|
|
|
66
|
-
function toHandoffPayload(issues: readonly ReviewIssue[], context: ReviewContext | undefined)
|
|
67
|
-
return {
|
|
68
|
-
context,
|
|
69
|
-
issues: issues.map(toHandoffIssue),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function toHandoffIssue(issue: ReviewIssue): ReviewHandoffIssue {
|
|
33
|
+
function toHandoffPayload(issues: readonly ReviewIssue[], context: ReviewContext | undefined) {
|
|
74
34
|
return {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
category: issue.finding.category,
|
|
78
|
-
severity: issue.finding.severity,
|
|
79
|
-
confidence: issue.finding.confidence,
|
|
80
|
-
location: {
|
|
81
|
-
file: issue.file,
|
|
82
|
-
line: issue.line,
|
|
83
|
-
symbol: issue.symbol,
|
|
84
|
-
display: formatIssueLocation(issue),
|
|
85
|
-
},
|
|
86
|
-
impact: issue.finding.impact,
|
|
87
|
-
evidence: issue.finding.evidence,
|
|
88
|
-
recommendation: issue.finding.recommendation,
|
|
35
|
+
context: serializeReviewContext(context),
|
|
36
|
+
issues: issues.map(serializeReviewIssue),
|
|
89
37
|
};
|
|
90
38
|
}
|
|
@@ -7,14 +7,6 @@ export interface ReviewIssueSelection {
|
|
|
7
7
|
readonly issueIds: readonly string[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export interface ReviewContext {
|
|
11
|
-
readonly workflowName: string;
|
|
12
|
-
readonly target: string;
|
|
13
|
-
readonly diffCommand: string;
|
|
14
|
-
readonly files: readonly string[];
|
|
15
|
-
readonly summary?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
export interface ReviewIssue {
|
|
19
11
|
readonly id: string;
|
|
20
12
|
readonly index: number;
|
|
@@ -25,9 +17,22 @@ export interface ReviewIssue {
|
|
|
25
17
|
readonly finding: AdvisoryFinding;
|
|
26
18
|
}
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly
|
|
20
|
+
/** Stable prompt-facing representation shared by review follow-up actions. */
|
|
21
|
+
export interface SerializedReviewIssue {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly summary: string;
|
|
24
|
+
readonly category: string;
|
|
25
|
+
readonly severity: string;
|
|
26
|
+
readonly confidence: string;
|
|
27
|
+
readonly location: {
|
|
28
|
+
readonly file?: string;
|
|
29
|
+
readonly line?: number;
|
|
30
|
+
readonly symbol?: string;
|
|
31
|
+
readonly display: string;
|
|
32
|
+
};
|
|
33
|
+
readonly impact: string;
|
|
34
|
+
readonly evidence: readonly string[];
|
|
35
|
+
readonly recommendation: string;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
|
|
@@ -52,7 +57,28 @@ export function formatIssueLocation(issue: ReviewIssue): string {
|
|
|
52
57
|
return `${issue.file}${line}${symbol}`;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
export function
|
|
60
|
+
export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue {
|
|
61
|
+
return {
|
|
62
|
+
id: issue.id,
|
|
63
|
+
summary: issue.finding.summary,
|
|
64
|
+
category: issue.finding.category,
|
|
65
|
+
severity: issue.finding.severity,
|
|
66
|
+
confidence: issue.finding.confidence,
|
|
67
|
+
location: {
|
|
68
|
+
file: issue.file,
|
|
69
|
+
line: issue.line,
|
|
70
|
+
symbol: issue.symbol,
|
|
71
|
+
display: formatIssueLocation(issue),
|
|
72
|
+
},
|
|
73
|
+
impact: issue.finding.impact,
|
|
74
|
+
evidence: issue.finding.evidence,
|
|
75
|
+
recommendation: issue.finding.recommendation,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isCommentableIssue(
|
|
80
|
+
issue: ReviewIssue,
|
|
81
|
+
): issue is ReviewIssue & { readonly file: string; readonly line: number } {
|
|
56
82
|
return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
|
|
57
83
|
}
|
|
58
84
|
|