Commit 9f9c8ea8ea6a

Vincent Demeester <vincent@sbr.pm>
2026-03-17 14:45:29
feat(gh-ext): add 3-way approval gate for writes
Replaced binary confirm/cancel with Accept/Modify/Reject selection on all 18 write operation approval gates. Reject explicitly tells the LLM not to retry. Modify tells it to ask the user what to change before retrying. This resolves the ambiguity where cancel gave no signal about user intent.
1 parent 6296d05
Changed files (7)
dots/pi/agent/extensions/github/actions/checks.ts
@@ -4,7 +4,7 @@
 
 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import type { GhDetails } from "../types";
-import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh } from "../utils";
+import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh, approvalGate, buildModifyResult, buildRejectResult } from "../utils";
 
 /**
  * Show check status for a PR
@@ -192,16 +192,18 @@ export async function handleChecksRestart(
 
 	// APPROVAL GATE
 	if (ctx.hasUI) {
-		const confirmed = await ctx.ui.confirm(
+		const approval = await approvalGate(
+			ctx,
 			`Restart run ${params.runId}?`,
 			`This will rerun${params.failedOnly !== false ? " failed jobs in" : ""} workflow run ${params.runId}.`,
 		);
-		if (!confirmed) {
-			ctx.ui.notify("Restart cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Restart cancelled by user" }],
-				details: { action: "checks-restart", cancelled: true, runId: params.runId } as GhDetails,
-			};
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Restart paused for modifications", "info");
+			return buildModifyResult("restart run", { action: "checks-restart", runId: params.runId });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Restart rejected", "info");
+			return buildRejectResult("restart run", { action: "checks-restart", runId: params.runId });
 		}
 	}
 
dots/pi/agent/extensions/github/actions/issue.ts
@@ -17,6 +17,9 @@ import {
 	resolveIssueNodeId,
 	addSubIssue,
 	removeSubIssue,
+	approvalGate,
+	buildModifyResult,
+	buildRejectResult,
 } from "../utils";
 
 const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
@@ -191,47 +194,49 @@ export async function handleIssueCreate(
 		
 		// If body is long, offer to preview full content
 		if (params.body && params.body.length > 200) {
-			confirmMessage += `\n\n๐Ÿ“„ Body: ${params.body.length} characters (truncated in preview)`;
+			confirmMessage += `\n\n๐Ÿ“ Body: ${params.body.length} characters (truncated in preview)`;
 			
 			const choice = await ctx.ui.select(
-				"Create Issue?",
-				["Create issue", "Preview body first", "Cancel"]
+				`Create Issue?\n\n${confirmMessage}`,
+				["โœ“ Accept", "๐Ÿ‘ Preview body first", "โœŽ Modify", "โœ— Reject"]
 			);
 			
-			if (choice === undefined || choice === "Cancel") {
-				ctx.ui.notify("Issue creation cancelled", "info");
-				return {
-					content: [{ type: "text", text: "Issue creation cancelled by user" }],
-					details: { action: "issue-create", cancelled: true } as GhDetails,
-				};
+			if (choice === undefined || choice === "โœ— Reject") {
+				ctx.ui.notify("Issue creation rejected", "info");
+				return buildRejectResult("issue creation", { action: "issue-create" });
 			}
 			
-			if (choice === "Preview body first") {
-				// Show full body in editor (read-only preview via input with initial value)
+			if (choice === "โœŽ Modify") {
+				ctx.ui.notify("Issue creation paused for modifications", "info");
+				return buildModifyResult("issue creation", { action: "issue-create" });
+			}
+			
+			if (choice === "๐Ÿ‘ Preview body first") {
 				await ctx.ui.editor(
 					`Issue Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
 					params.body
 				);
 				
 				// Ask again after preview
-				const finalConfirm = await ctx.ui.confirm("Create Issue?", confirmMessage);
-				if (!finalConfirm) {
-					ctx.ui.notify("Issue creation cancelled", "info");
-					return {
-						content: [{ type: "text", text: "Issue creation cancelled by user" }],
-						details: { action: "issue-create", cancelled: true } as GhDetails,
-					};
+				const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
+				if (approval.outcome === "modify") {
+					ctx.ui.notify("Issue creation paused for modifications", "info");
+					return buildModifyResult("issue creation", { action: "issue-create" });
+				}
+				if (approval.outcome === "rejected") {
+					ctx.ui.notify("Issue creation rejected", "info");
+					return buildRejectResult("issue creation", { action: "issue-create" });
 				}
 			}
 		} else {
-			// Short body or no body - simple confirm
-			const confirmed = await ctx.ui.confirm("Create Issue?", confirmMessage);
-			if (!confirmed) {
-				ctx.ui.notify("Issue creation cancelled", "info");
-				return {
-					content: [{ type: "text", text: "Issue creation cancelled by user" }],
-					details: { action: "issue-create", cancelled: true } as GhDetails,
-				};
+			const approval = await approvalGate(ctx, "Create Issue?", confirmMessage);
+			if (approval.outcome === "modify") {
+				ctx.ui.notify("Issue creation paused for modifications", "info");
+				return buildModifyResult("issue creation", { action: "issue-create" });
+			}
+			if (approval.outcome === "rejected") {
+				ctx.ui.notify("Issue creation rejected", "info");
+				return buildRejectResult("issue creation", { action: "issue-create" });
 			}
 		}
 	}
@@ -325,16 +330,14 @@ export async function handleIssueClose(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const reason = params.reason || "completed";
-		const confirmed = await ctx.ui.confirm(
-			`Close issue #${params.number}?`,
-			`This will close the issue as "${reason}".`,
-		);
-		if (!confirmed) {
-			ctx.ui.notify("Close cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Close cancelled by user" }],
-				details: { action: "issue-close", cancelled: true, issueNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Close issue #${params.number}?`, `This will close the issue as "${reason}".`);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Close paused for modifications", "info");
+			return buildModifyResult("issue close", { action: "issue-close", issueNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Close rejected", "info");
+			return buildRejectResult("issue close", { action: "issue-close", issueNumber: params.number });
 		}
 	}
 
@@ -386,13 +389,14 @@ export async function handleIssueComment(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildCommentConfirmation("Issue", params.number, params.body);
-		const confirmed = await ctx.ui.confirm(`Comment on issue #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Comment cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Comment cancelled by user" }],
-				details: { action: "issue-comment", cancelled: true, issueNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Comment on issue #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Comment paused for modifications", "info");
+			return buildModifyResult("comment", { action: "issue-comment", issueNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Comment rejected", "info");
+			return buildRejectResult("comment", { action: "issue-comment", issueNumber: params.number });
 		}
 	}
 
@@ -456,13 +460,14 @@ export async function handleIssueEdit(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = `Issue: #${params.number}\n\nChanges:\n${changes.join("\n")}\n\nThis will modify the issue.`;
-		const confirmed = await ctx.ui.confirm(`Edit issue #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Edit cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Edit cancelled by user" }],
-				details: { action: "issue-edit", cancelled: true, issueNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Edit issue #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Edit paused for modifications", "info");
+			return buildModifyResult("issue edit", { action: "issue-edit", issueNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Edit rejected", "info");
+			return buildRejectResult("issue edit", { action: "issue-edit", issueNumber: params.number });
 		}
 	}
 
@@ -535,13 +540,14 @@ export async function handleIssueAddSubIssue(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildSubIssueConfirmation("add", params.number, params.subIssueNumber);
-		const confirmed = await ctx.ui.confirm(`Add sub-issue #${params.subIssueNumber} to #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Add sub-issue cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Add sub-issue cancelled by user" }],
-				details: { action: "issue-add-sub-issue", cancelled: true } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Add sub-issue #${params.subIssueNumber} to #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Add sub-issue paused for modifications", "info");
+			return buildModifyResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Add sub-issue rejected", "info");
+			return buildRejectResult("add sub-issue", { action: "issue-add-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
 		}
 	}
 
@@ -614,13 +620,14 @@ export async function handleIssueRemoveSubIssue(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildSubIssueConfirmation("remove", params.number, params.subIssueNumber);
-		const confirmed = await ctx.ui.confirm(`Remove sub-issue #${params.subIssueNumber} from #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Remove sub-issue cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Remove sub-issue cancelled by user" }],
-				details: { action: "issue-remove-sub-issue", cancelled: true } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Remove sub-issue #${params.subIssueNumber} from #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Remove sub-issue paused for modifications", "info");
+			return buildModifyResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Remove sub-issue rejected", "info");
+			return buildRejectResult("remove sub-issue", { action: "issue-remove-sub-issue", parentNumber: params.number, subIssueNumber: params.subIssueNumber });
 		}
 	}
 
dots/pi/agent/extensions/github/actions/pr.ts
@@ -27,6 +27,9 @@ import {
 	execGh,
 	resolveGitCwd,
 	findPRTemplate,
+	approvalGate,
+	buildModifyResult,
+	buildRejectResult,
 } from "../utils";
 
 const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -254,48 +257,49 @@ export async function handlePRCreate(
 		
 		// If body is long, offer to preview full content
 		if (params.body && params.body.length > 200) {
-			confirmMessage += `\n\n๐Ÿ“„ Body: ${params.body.length} characters (truncated in preview)`;
-			confirmMessage += `\n   Press 'y' to create, 'p' to preview full body, 'n' to cancel`;
+			confirmMessage += `\n\n๐Ÿ“ Body: ${params.body.length} characters (truncated in preview)`;
 			
 			const choice = await ctx.ui.select(
-				"Create Pull Request?",
-				["Create PR", "Preview body first", "Cancel"]
+				`Create Pull Request?\n\n${confirmMessage}`,
+				["โœ“ Accept", "๐Ÿ‘ Preview body first", "โœŽ Modify", "โœ— Reject"]
 			);
 			
-			if (choice === undefined || choice === "Cancel") {
-				ctx.ui.notify("PR creation cancelled", "info");
-				return {
-					content: [{ type: "text", text: "PR creation cancelled by user" }],
-					details: { action: "pr-create", cancelled: true } as GhDetails,
-				};
+			if (choice === undefined || choice === "โœ— Reject") {
+				ctx.ui.notify("PR creation rejected", "info");
+				return buildRejectResult("PR creation", { action: "pr-create" });
 			}
 			
-			if (choice === "Preview body first") {
-				// Show full body in editor (read-only preview via input with initial value)
+			if (choice === "โœŽ Modify") {
+				ctx.ui.notify("PR creation paused for modifications", "info");
+				return buildModifyResult("PR creation", { action: "pr-create" });
+			}
+			
+			if (choice === "๐Ÿ‘ Preview body first") {
 				await ctx.ui.editor(
 					`PR Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
 					params.body
 				);
 				
 				// Ask again after preview
-				const finalConfirm = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
-				if (!finalConfirm) {
-					ctx.ui.notify("PR creation cancelled", "info");
-					return {
-						content: [{ type: "text", text: "PR creation cancelled by user" }],
-						details: { action: "pr-create", cancelled: true } as GhDetails,
-					};
+				const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
+				if (approval.outcome === "modify") {
+					ctx.ui.notify("PR creation paused for modifications", "info");
+					return buildModifyResult("PR creation", { action: "pr-create" });
+				}
+				if (approval.outcome === "rejected") {
+					ctx.ui.notify("PR creation rejected", "info");
+					return buildRejectResult("PR creation", { action: "pr-create" });
 				}
 			}
 		} else {
-			// Short body or no body - simple confirm
-			const confirmed = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
-			if (!confirmed) {
-				ctx.ui.notify("PR creation cancelled", "info");
-				return {
-					content: [{ type: "text", text: "PR creation cancelled by user" }],
-					details: { action: "pr-create", cancelled: true } as GhDetails,
-				};
+			const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
+			if (approval.outcome === "modify") {
+				ctx.ui.notify("PR creation paused for modifications", "info");
+				return buildModifyResult("PR creation", { action: "pr-create" });
+			}
+			if (approval.outcome === "rejected") {
+				ctx.ui.notify("PR creation rejected", "info");
+				return buildRejectResult("PR creation", { action: "pr-create" });
 			}
 		}
 	}
@@ -452,13 +456,14 @@ export async function handlePRMerge(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildPRMergeConfirmation(params);
-		const confirmed = await ctx.ui.confirm(`Merge PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Merge cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Merge cancelled by user" }],
-				details: { action: "pr-merge", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Merge PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Merge paused for modifications", "info");
+			return buildModifyResult("merge", { action: "pr-merge", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Merge rejected", "info");
+			return buildRejectResult("merge", { action: "pr-merge", prNumber: params.number });
 		}
 	}
 
@@ -518,13 +523,14 @@ export async function handlePRReview(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildReviewConfirmation(params);
-		const confirmed = await ctx.ui.confirm(`Submit review on PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Review cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Review cancelled by user" }],
-				details: { action: "pr-review", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Submit review on PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Review paused for modifications", "info");
+			return buildModifyResult("review", { action: "pr-review", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Review rejected", "info");
+			return buildRejectResult("review", { action: "pr-review", prNumber: params.number });
 		}
 	}
 
@@ -596,13 +602,14 @@ export async function handlePRComment(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildCommentConfirmation("PR", params.number, params.body);
-		const confirmed = await ctx.ui.confirm(`Comment on PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Comment cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Comment cancelled by user" }],
-				details: { action: "pr-comment", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Comment on PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Comment paused for modifications", "info");
+			return buildModifyResult("comment", { action: "pr-comment", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Comment rejected", "info");
+			return buildRejectResult("comment", { action: "pr-comment", prNumber: params.number });
 		}
 	}
 
@@ -651,16 +658,14 @@ export async function handlePRReady(
 		const description = title
 			? `"${truncate(title, 80)}"\n\nThis will mark the draft PR as ready for review.`
 			: "This will mark the draft PR as ready for review.";
-		const confirmed = await ctx.ui.confirm(
-			`Mark PR #${params.number} as ready?`,
-			description,
-		);
-		if (!confirmed) {
-			ctx.ui.notify("Cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Operation cancelled by user" }],
-				details: { action: "pr-ready", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Mark PR #${params.number} as ready?`, description);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Paused for modifications", "info");
+			return buildModifyResult("mark-ready", { action: "pr-ready", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Rejected", "info");
+			return buildRejectResult("mark-ready", { action: "pr-ready", prNumber: params.number });
 		}
 	}
 
@@ -706,16 +711,14 @@ export async function handlePRClose(
 		const description = title
 			? `"${truncate(title, 80)}"\n\nThis will close the pull request without merging.`
 			: "This will close the pull request without merging.";
-		const confirmed = await ctx.ui.confirm(
-			`Close PR #${params.number}?`,
-			description,
-		);
-		if (!confirmed) {
-			ctx.ui.notify("Cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Close cancelled by user" }],
-				details: { action: "pr-close", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Close PR #${params.number}?`, description);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Paused for modifications", "info");
+			return buildModifyResult("close PR", { action: "pr-close", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Rejected", "info");
+			return buildRejectResult("close PR", { action: "pr-close", prNumber: params.number });
 		}
 	}
 
@@ -813,13 +816,14 @@ export async function handlePRLineComment(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildLineCommentConfirmation(params.number, params.path, params.line, params.body, params.startLine);
-		const confirmed = await ctx.ui.confirm(`Add inline comment on PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Comment cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Inline comment cancelled by user" }],
-				details: { action: "pr-line-comment", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Add inline comment on PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Inline comment paused for modifications", "info");
+			return buildModifyResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Inline comment rejected", "info");
+			return buildRejectResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
 		}
 	}
 
@@ -929,13 +933,14 @@ export async function handlePRReviewWithComments(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildReviewWithCommentsConfirmation(params.number, params.reviewAction, params.body, params.comments.length);
-		const confirmed = await ctx.ui.confirm(`Submit review with ${params.comments.length} inline comment(s) on PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Review cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Review with comments cancelled by user" }],
-				details: { action: "pr-review-comments", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Submit review with ${params.comments.length} inline comment(s) on PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Review paused for modifications", "info");
+			return buildModifyResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Review rejected", "info");
+			return buildRejectResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
 		}
 	}
 
@@ -1122,13 +1127,14 @@ export async function handlePRReviewEdit(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildReviewEditConfirmation(params.number, params.reviewId, params.body);
-		const confirmed = await ctx.ui.confirm(`Edit review ${params.reviewId} on PR #${params.number}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Review edit cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Review edit cancelled by user" }],
-				details: { action: "pr-review-edit", cancelled: true, prNumber: params.number } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Edit review ${params.reviewId} on PR #${params.number}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Review edit paused for modifications", "info");
+			return buildModifyResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Review edit rejected", "info");
+			return buildRejectResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
 		}
 	}
 
@@ -1260,13 +1266,14 @@ export async function handlePRReviewCommentEdit(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildReviewCommentEditConfirmation(params.commentId, params.body);
-		const confirmed = await ctx.ui.confirm(`Edit review comment ${params.commentId}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Edit cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Review comment edit cancelled by user" }],
-				details: { action: "pr-review-comment-edit", cancelled: true, commentId: params.commentId } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Edit review comment ${params.commentId}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Comment edit paused for modifications", "info");
+			return buildModifyResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Comment edit rejected", "info");
+			return buildRejectResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
 		}
 	}
 
@@ -1330,13 +1337,14 @@ export async function handlePRReviewCommentDelete(
 	// APPROVAL GATE
 	if (ctx.hasUI) {
 		const confirmMessage = buildReviewCommentDeleteConfirmation(params.commentId);
-		const confirmed = await ctx.ui.confirm(`Delete review comment ${params.commentId}?`, confirmMessage);
-		if (!confirmed) {
-			ctx.ui.notify("Delete cancelled", "info");
-			return {
-				content: [{ type: "text", text: "Review comment delete cancelled by user" }],
-				details: { action: "pr-review-comment-delete", cancelled: true, commentId: params.commentId } as GhDetails,
-			};
+		const approval = await approvalGate(ctx, `Delete review comment ${params.commentId}?`, confirmMessage);
+		if (approval.outcome === "modify") {
+			ctx.ui.notify("Delete paused for modifications", "info");
+			return buildModifyResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
+		}
+		if (approval.outcome === "rejected") {
+			ctx.ui.notify("Delete rejected", "info");
+			return buildRejectResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
 		}
 	}
 
dots/pi/agent/extensions/github/github.test.ts
@@ -43,7 +43,11 @@ import {
 	extractIssueNumber,
 	extractPRUrl,
 	extractIssueUrl,
+	approvalGate,
+	buildModifyResult,
+	buildRejectResult,
 } from "./utils";
+import type { GhDetails } from "./types";
 
 // ============================================================================
 // Parsing Tests
@@ -826,3 +830,106 @@ describe("Auto-detection Patterns", () => {
 		expect("https://github.com/org/repo/issues/".match(pattern)).toBeNull();
 	});
 });
+
+// ============================================================================
+// Approval Gate Tests
+// ============================================================================
+
+describe("Approval Gate", () => {
+	function mockCtx(selectReturn: string | undefined) {
+		return {
+			ui: {
+				select: async (_title: string, _options: string[]) => selectReturn,
+				notify: (_msg: string, _level: string) => {},
+			},
+		} as any;
+	}
+
+	test("approvalGate returns accepted when user selects Accept", async () => {
+		const result = await approvalGate(mockCtx("โœ“ Accept"), "Test?", "Description");
+		expect(result.outcome).toBe("accepted");
+	});
+
+	test("approvalGate returns modify when user selects Modify", async () => {
+		const result = await approvalGate(mockCtx("โœŽ Modify"), "Test?", "Description");
+		expect(result.outcome).toBe("modify");
+	});
+
+	test("approvalGate returns rejected when user selects Reject", async () => {
+		const result = await approvalGate(mockCtx("โœ— Reject"), "Test?", "Description");
+		expect(result.outcome).toBe("rejected");
+	});
+
+	test("approvalGate returns rejected when user presses Escape (undefined)", async () => {
+		const result = await approvalGate(mockCtx(undefined), "Test?", "Description");
+		expect(result.outcome).toBe("rejected");
+	});
+
+	test("approvalGate passes title with description to select", async () => {
+		let capturedTitle = "";
+		let capturedOptions: string[] = [];
+		const ctx = {
+			ui: {
+				select: async (title: string, options: string[]) => {
+					capturedTitle = title;
+					capturedOptions = options;
+					return "โœ“ Accept";
+				},
+				notify: () => {},
+			},
+		} as any;
+
+		await approvalGate(ctx, "Create PR?", "Title: fix stuff\nBase: main");
+		expect(capturedTitle).toContain("Create PR?");
+		expect(capturedTitle).toContain("Title: fix stuff");
+		expect(capturedTitle).toContain("Base: main");
+		expect(capturedOptions).toEqual(["โœ“ Accept", "โœŽ Modify", "โœ— Reject"]);
+	});
+});
+
+describe("buildModifyResult", () => {
+	test("includes modify message telling LLM to ask user", () => {
+		const result = buildModifyResult("PR creation", { action: "pr-create" });
+		const text = result.content[0].text;
+		expect(text).toContain("modify");
+		expect(text).toContain("Ask the user");
+		expect(text).toContain("retry");
+	});
+
+	test("sets modifyRequested in details", () => {
+		const result = buildModifyResult("PR creation", { action: "pr-create", prNumber: 123 });
+		const details = result.details as GhDetails;
+		expect(details.modifyRequested).toBe(true);
+		expect(details.cancelled).toBeUndefined();
+		expect(details.action).toBe("pr-create");
+		expect(details.prNumber).toBe(123);
+	});
+
+	test("does not set isError", () => {
+		const result = buildModifyResult("comment", { action: "pr-comment" });
+		expect(result.isError).toBeUndefined();
+	});
+});
+
+describe("buildRejectResult", () => {
+	test("includes reject message telling LLM NOT to retry", () => {
+		const result = buildRejectResult("PR creation", { action: "pr-create" });
+		const text = result.content[0].text;
+		expect(text).toContain("rejected");
+		expect(text).toContain("Do NOT retry");
+	});
+
+	test("sets cancelled in details", () => {
+		const result = buildRejectResult("PR creation", { action: "pr-create", prNumber: 42 });
+		const details = result.details as GhDetails;
+		expect(details.cancelled).toBe(true);
+		expect(details.modifyRequested).toBeUndefined();
+		expect(details.action).toBe("pr-create");
+		expect(details.prNumber).toBe(42);
+	});
+
+	test("does not set isError", () => {
+		const result = buildRejectResult("merge", { action: "pr-merge" });
+		expect(result.isError).toBeUndefined();
+	});
+});
dots/pi/agent/extensions/github/index.ts
@@ -383,8 +383,12 @@ export default function (pi: ExtensionAPI) {
 				return new Text(theme.fg("error", `โœ— Error: ${details.error}`), 0, 0);
 			}
 
+			if (details.modifyRequested) {
+				return new Text(theme.fg("warning", "โœŽ User requested modifications"), 0, 0);
+			}
+
 			if (details.cancelled) {
-				return new Text(theme.fg("warning", "โœ— Cancelled by user"), 0, 0);
+				return new Text(theme.fg("error", "โœ— Rejected by user"), 0, 0);
 			}
 
 			switch (details.action) {
dots/pi/agent/extensions/github/types.ts
@@ -10,6 +10,7 @@ export interface GhDetails {
 	action: string;
 	output?: string;
 	cancelled?: boolean;
+	modifyRequested?: boolean;
 	error?: string;
 	// PR-specific
 	prNumber?: number;
dots/pi/agent/extensions/github/utils.ts
@@ -3,7 +3,7 @@
  */
 
 import type { Theme, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
+import type { GhDetails, GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
 
 // ============================================================================
 // Git Repository Detection (shared with handlers)
@@ -510,6 +510,65 @@ export function getReviewDecisionText(decision: string): string {
 	}
 }
 
+// ============================================================================
+// Approval gate helper
+// ============================================================================
+
+export type ApprovalResult =
+	| { outcome: "accepted" }
+	| { outcome: "modify" }
+	| { outcome: "rejected" };
+
+/**
+ * Three-way approval gate: Accept / Modify / Reject.
+ *
+ * - "Accept" proceeds with execution.
+ * - "Modify" tells the LLM the user wants to iterate on the content before
+ *   submitting. The LLM should ask the user what to change.
+ * - "Reject" means the user does not want this action at all.
+ *
+ * Returns the user's choice so the caller can build the appropriate response.
+ */
+export async function approvalGate(
+	ctx: ExtensionContext,
+	title: string,
+	description: string,
+): Promise<ApprovalResult> {
+	const prompt = description ? `${title}\n\n${description}` : title;
+	const choice = await ctx.ui.select(prompt, [
+		"โœ“ Accept",
+		"โœŽ Modify",
+		"โœ— Reject",
+	]);
+
+	if (choice === "โœ“ Accept") return { outcome: "accepted" };
+	if (choice === "โœŽ Modify") return { outcome: "modify" };
+	// undefined (Escape) or "โœ— Reject"
+	return { outcome: "rejected" };
+}
+
+/**
+ * Build a tool result for when the user wants modifications.
+ * The message clearly tells the LLM to ask the user what they want changed.
+ */
+export function buildModifyResult(action: string, details: Partial<GhDetails>): any {
+	return {
+		content: [{ type: "text", text: `User wants to modify the ${action} before submitting. Ask the user what they would like to change, then retry with the updated parameters.` }],
+		details: { action: details.action, modifyRequested: true, ...details } as GhDetails,
+	};
+}
+
+/**
+ * Build a tool result for when the user rejects the action entirely.
+ * The message explicitly tells the LLM NOT to retry.
+ */
+export function buildRejectResult(action: string, details: Partial<GhDetails>): any {
+	return {
+		content: [{ type: "text", text: `User rejected this ${action}. Do NOT retry or attempt this action again.` }],
+		details: { action: details.action, cancelled: true, ...details } as GhDetails,
+	};
+}
+
 // ============================================================================
 // Confirmation builders
 // ============================================================================