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.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -120
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. package/package.json +9 -9
@@ -1,4 +1,6 @@
1
- import { isCommentableIssue, type ReviewContext, type ReviewIssue } from "./review-issues.ts";
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 = parsePrNumber(reviewContext?.diffCommand);
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
- statuses.push(await postInlineComment(exec, cwd, prContext, issue));
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 parsePrNumber(diffCommand: string | undefined): number | undefined {
122
- if (!diffCommand) return undefined;
123
- const match = /(?:^|\s)gh\s+pr\s+diff\s+(\d+)(?:\s|$)/.exec(diffCommand);
124
- if (!match) return undefined;
125
- const parsed = Number(match[1]);
126
- return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
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(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string }> {
130
- const result = await runCommand(exec, cwd, args);
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(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly stdout: string } | { readonly ok: false; readonly reason: string }> {
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 { buildCommentHandoffPrompt, buildFixHandoffPrompt } from "./review-handoff.ts";
4
- import { isCommentableIssue, type ReviewContext, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
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
- ): Promise<void> {
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(pi, ctx, selected, context);
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(pi: ReviewActionPi, ctx: ReviewActionContext, selected: readonly ReviewIssue[], context: ReviewContext | undefined): void {
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
- pi.sendUserMessage(buildFixHandoffPrompt(selected, context), { deliverAs: "followUp" });
37
- ctx.ui.notify(`Queued fix request for ${selected.length} selected finding(s)`, "info");
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
- queueCommentFallback(pi, ctx, commentable, context, "User chose parent-agent fallback instead of direct gh posting.");
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
- pi.sendUserMessage(buildCommentHandoffPrompt(issues, context, reason), { deliverAs: "followUp" });
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 { formatIssueLocation, type ReviewContext, type ReviewIssue } from "./review-issues.ts";
2
-
3
- export interface ReviewHandoffPayload {
4
- readonly context: ReviewContext | undefined;
5
- readonly issues: readonly ReviewHandoffIssue[];
6
- }
7
-
8
- export interface ReviewHandoffIssue {
9
- readonly id: string;
10
- readonly summary: string;
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): ReviewHandoffPayload {
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
- id: issue.id,
76
- summary: issue.finding.summary,
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
- export interface ReviewReportWithContext extends AdvisoryReport {
29
- readonly stats?: Record<string, string | number>;
30
- readonly reviewContext?: ReviewContext;
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 isCommentableIssue(issue: ReviewIssue): boolean {
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