feat: implement plan approval gate before execution
Adds plan approval workflow per GitHub issue #111: - Register plan_approval tool in tools index - Add plan approval handling in message handler - Check for pending plans on each message - Detect approval/rejection messages - Handle plan approval flow - Create plan approval modal TUI component - Update system prompts to instruct agents to use plan_approval tool - Balanced tier: Updated to require plan_approval for complex tasks - Thorough tier: Updated with plan_approval workflow Workflow: 1. Agent analyzes task complexity with plan_approval 2. Agent creates plan with steps, context, risks 3. Agent submits plan for user approval 4. User approves/rejects/provides feedback 5. Only after approval, agent proceeds with execution Closes #111
This commit is contained in:
@@ -91,47 +91,55 @@ For multi-step tasks (3+ steps), use todowrite to track progress:
|
||||
|
||||
export const BALANCED_TIER_PLAN_GATE = `## Plan Mode (Balanced Tier)
|
||||
|
||||
For COMPLEX tasks that meet these criteria, enter plan mode:
|
||||
- Multi-file refactoring
|
||||
For COMPLEX tasks that meet these criteria, use the plan_approval tool:
|
||||
- Multi-file refactoring (3+ files)
|
||||
- New feature implementation
|
||||
- Architectural changes
|
||||
- Security-related changes
|
||||
- Database modifications
|
||||
- Tasks that could take 10+ tool calls
|
||||
|
||||
### Plan Mode Workflow
|
||||
### CRITICAL: Plan Approval Workflow
|
||||
|
||||
1. **Explore Phase** - Gather context with glob, grep, read
|
||||
2. **Plan Phase** - Create structured plan with steps
|
||||
3. **Approval Gate** - Present plan, wait for user signal to proceed
|
||||
4. **Execute Phase** - Implement the plan
|
||||
5. **Verify Phase** - Test and confirm
|
||||
You MUST use the plan_approval tool for complex tasks. DO NOT execute file modifications until the user approves the plan.
|
||||
|
||||
### Plan Format
|
||||
1. **Analyze Task** - Use plan_approval with action="analyze_task" to check if plan approval is needed
|
||||
2. **Create Plan** - Use plan_approval with action="create" to start a plan
|
||||
3. **Add Context** - Use plan_approval with action="add_context" to document files analyzed
|
||||
4. **Add Steps** - Use plan_approval with action="add_step" for each implementation step
|
||||
5. **Add Risks** - Use plan_approval with action="add_risk" for identified risks
|
||||
6. **Submit Plan** - Use plan_approval with action="submit" to present plan to user
|
||||
7. **Wait for Approval** - DO NOT PROCEED until user says "yes", "proceed", "approve", or similar
|
||||
8. **Execute** - Only after approval, implement the plan step by step
|
||||
|
||||
### Example Plan Approval Flow
|
||||
|
||||
\`\`\`
|
||||
## Plan: [Task Name]
|
||||
// Step 1: Analyze the task
|
||||
plan_approval action="analyze_task" task_description="Implement user authentication system"
|
||||
|
||||
### Context
|
||||
- [Key file 1]: [purpose]
|
||||
- [Key file 2]: [purpose]
|
||||
// Step 2: Create plan
|
||||
plan_approval action="create" title="User Authentication System" summary="Add JWT-based auth with login, register, and protected routes"
|
||||
|
||||
### Steps
|
||||
1. [First change] - [file affected]
|
||||
2. [Second change] - [file affected]
|
||||
3. [Verification] - [command]
|
||||
// Step 3: Add context
|
||||
plan_approval action="add_context" plan_id="<id>" files_analyzed=["src/routes/", "src/middleware/"]
|
||||
|
||||
### Risks
|
||||
- [Potential issue and mitigation]
|
||||
// Step 4: Add steps
|
||||
plan_approval action="add_step" plan_id="<id>" step_title="Create auth middleware" step_description="Add JWT verification middleware" files_affected=["src/middleware/auth.ts"] risk_level="medium"
|
||||
|
||||
Ready to proceed? (Continue with implementation or provide feedback)
|
||||
// Step 5: Submit for approval
|
||||
plan_approval action="submit" plan_id="<id>" testing_strategy="Run auth tests" rollback_plan="Revert commits"
|
||||
|
||||
// STOP HERE - Wait for user to approve
|
||||
\`\`\`
|
||||
|
||||
### When to Skip Plan Mode
|
||||
|
||||
Skip plan mode for:
|
||||
Skip plan approval for:
|
||||
- Single file changes
|
||||
- Simple bug fixes
|
||||
- Adding a function or component
|
||||
- Configuration changes
|
||||
- Simple bug fixes with obvious solutions
|
||||
- Adding comments or documentation
|
||||
- Formatting changes
|
||||
- Tasks with clear, limited scope`;
|
||||
|
||||
export const BALANCED_TIER_AGENTS = `## Agent Delegation
|
||||
|
||||
@@ -110,67 +110,64 @@ Only consult when:
|
||||
- Security/compliance decisions
|
||||
- Changes that affect external systems or users`;
|
||||
|
||||
export const THOROUGH_TIER_PLAN_MODE = `## Advanced Plan Mode
|
||||
export const THOROUGH_TIER_PLAN_MODE = `## Advanced Plan Mode with Approval Gate
|
||||
|
||||
For complex tasks, use structured planning with approval:
|
||||
For complex tasks, you MUST use the plan_approval tool before executing file modifications.
|
||||
|
||||
### Plan Document Format
|
||||
### CRITICAL: Use plan_approval Tool
|
||||
|
||||
\`\`\`markdown
|
||||
# Implementation Plan: [Feature Name]
|
||||
Even with full autonomy, you MUST:
|
||||
1. Use plan_approval action="analyze_task" to check if approval is needed
|
||||
2. If needed, create and submit a plan using plan_approval
|
||||
3. WAIT for user approval before executing any file modifications
|
||||
4. Only proceed after user says "yes", "proceed", "approve", or similar
|
||||
|
||||
## Executive Summary
|
||||
[1-2 sentence overview]
|
||||
### Plan Approval Tool Workflow
|
||||
|
||||
## Context Analysis
|
||||
### Files Analyzed
|
||||
- \`path/to/file.ts\`: [purpose and relevance]
|
||||
\`\`\`
|
||||
// 1. Analyze if plan approval is needed
|
||||
plan_approval action="analyze_task" task_description="..."
|
||||
|
||||
### Current Architecture
|
||||
[Brief description of existing patterns]
|
||||
// 2. Create the plan
|
||||
plan_approval action="create" title="Feature Name" summary="What we'll do"
|
||||
|
||||
### Dependencies
|
||||
- [External dependency 1]
|
||||
- [Internal module 1]
|
||||
// 3. Add context (files analyzed, architecture)
|
||||
plan_approval action="add_context" plan_id="<id>" files_analyzed=[...] current_architecture="..."
|
||||
|
||||
## Implementation Strategy
|
||||
// 4. Add each step
|
||||
plan_approval action="add_step" plan_id="<id>" step_title="Phase 1" step_description="..." files_affected=[...] risk_level="medium"
|
||||
|
||||
### Phase 1: [Name]
|
||||
**Objective**: [Goal]
|
||||
**Files affected**: [list]
|
||||
**Changes**:
|
||||
1. [Specific change]
|
||||
2. [Specific change]
|
||||
// 5. Add risks
|
||||
plan_approval action="add_risk" plan_id="<id>" risk_description="..." risk_impact="high" risk_mitigation="..."
|
||||
|
||||
### Phase 2: [Name]
|
||||
...
|
||||
// 6. Submit for approval
|
||||
plan_approval action="submit" plan_id="<id>" testing_strategy="..." rollback_plan="..."
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| [Risk 1] | [High/Med/Low] | [Strategy] |
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests: [approach]
|
||||
- Integration tests: [approach]
|
||||
- Manual verification: [steps]
|
||||
|
||||
## Rollback Plan
|
||||
[How to undo if needed]
|
||||
|
||||
---
|
||||
**Awaiting approval to proceed with implementation.**
|
||||
// STOP - Wait for user approval before any file modifications!
|
||||
\`\`\`
|
||||
|
||||
### Plan Approval Workflow
|
||||
### After Approval
|
||||
|
||||
1. Generate comprehensive plan
|
||||
2. Present to user with "Awaiting approval"
|
||||
3. User can:
|
||||
- Approve: "proceed", "go ahead", "looks good"
|
||||
- Modify: "change X to Y"
|
||||
- Reject: "stop", "don't do this"
|
||||
4. On approval, execute with full autonomy`;
|
||||
Once user approves:
|
||||
- Execute with full autonomy
|
||||
- Use agent orchestration for parallel implementation
|
||||
- Complete the entire feature end-to-end
|
||||
- Handle errors and edge cases
|
||||
|
||||
### When Plan Approval is Required
|
||||
|
||||
ALWAYS use plan_approval for:
|
||||
- Multi-file refactoring
|
||||
- New feature implementation
|
||||
- Architectural changes
|
||||
- Security-related changes
|
||||
- Database modifications
|
||||
- Any task affecting 3+ files
|
||||
|
||||
You MAY skip for:
|
||||
- Single file fixes with obvious solutions
|
||||
- Adding comments or documentation
|
||||
- Simple configuration changes`;
|
||||
|
||||
export const THOROUGH_TIER_AGENTS = `## Agent Orchestration System
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ import {
|
||||
executeDetectedCommand,
|
||||
} from "@services/command-detection";
|
||||
import { detectSkillCommand, executeSkill } from "@services/skill-service";
|
||||
import {
|
||||
getActivePlans,
|
||||
isApprovalMessage,
|
||||
isRejectionMessage,
|
||||
approvePlan,
|
||||
rejectPlan,
|
||||
startPlanExecution,
|
||||
formatPlanForDisplay,
|
||||
} from "@services/plan-mode/plan-service";
|
||||
|
||||
// Track last response for feedback learning
|
||||
let lastResponseContext: {
|
||||
@@ -408,6 +417,56 @@ export const handleMessage = async (
|
||||
// Check for feedback on previous response
|
||||
await checkUserFeedback(message, callbacks);
|
||||
|
||||
// Check for pending plan approvals
|
||||
const pendingPlans = getActivePlans().filter((p) => p.status === "pending");
|
||||
if (pendingPlans.length > 0) {
|
||||
const plan = pendingPlans[0];
|
||||
|
||||
// Check if this is an approval message
|
||||
if (isApprovalMessage(message)) {
|
||||
approvePlan(plan.id, message);
|
||||
startPlanExecution(plan.id);
|
||||
callbacks.onLog("system", `Plan "${plan.title}" approved. Proceeding with implementation.`);
|
||||
addDebugLog("state", `Plan ${plan.id} approved by user`);
|
||||
|
||||
// Continue with agent execution - the agent will see the approved status
|
||||
// and proceed with implementation
|
||||
state.messages.push({
|
||||
role: "user",
|
||||
content: `The user approved the plan. Proceed with the implementation of plan "${plan.title}".`,
|
||||
});
|
||||
// Fall through to normal agent processing
|
||||
} else if (isRejectionMessage(message)) {
|
||||
rejectPlan(plan.id, message);
|
||||
callbacks.onLog("system", `Plan "${plan.title}" rejected. Please provide feedback or a new approach.`);
|
||||
addDebugLog("state", `Plan ${plan.id} rejected by user`);
|
||||
|
||||
// Add rejection to messages so agent can respond
|
||||
state.messages.push({
|
||||
role: "user",
|
||||
content: `The user rejected the plan with feedback: "${message}". Please revise the approach.`,
|
||||
});
|
||||
// Fall through to normal agent processing to get revised plan
|
||||
} else {
|
||||
// Neither approval nor rejection - treat as feedback/modification request
|
||||
callbacks.onLog("system", `Plan "${plan.title}" awaiting approval. Reply 'yes' to approve or 'no' to reject.`);
|
||||
|
||||
// Show the plan again with the feedback
|
||||
const planDisplay = formatPlanForDisplay(plan);
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: planDisplay,
|
||||
});
|
||||
|
||||
// Add user's feedback to messages
|
||||
state.messages.push({
|
||||
role: "user",
|
||||
content: `The user provided feedback on the pending plan: "${message}". Please address this feedback and update the plan.`,
|
||||
});
|
||||
// Fall through to normal agent processing
|
||||
}
|
||||
}
|
||||
|
||||
// Check for skill commands (e.g., /review, /commit)
|
||||
const skillMatch = await detectSkillCommand(message);
|
||||
if (skillMatch) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { multiEditTool } from "@tools/multi-edit/execute";
|
||||
import { lspTool } from "@tools/lsp";
|
||||
import { applyPatchTool } from "@tools/apply-patch";
|
||||
import { taskAgentTool } from "@tools/task-agent/execute";
|
||||
import { planApprovalTool } from "@tools/plan-approval/execute";
|
||||
import {
|
||||
isMCPTool,
|
||||
executeMCPTool,
|
||||
@@ -42,6 +43,7 @@ export const tools: ToolDefinition[] = [
|
||||
lspTool,
|
||||
applyPatchTool,
|
||||
taskAgentTool,
|
||||
planApprovalTool,
|
||||
];
|
||||
|
||||
// Tools that are read-only (allowed in chat mode)
|
||||
|
||||
268
src/tui-solid/components/modals/plan-approval-modal.tsx
Normal file
268
src/tui-solid/components/modals/plan-approval-modal.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { ImplementationPlan, PlanStep } from "@/types/plan-mode";
|
||||
|
||||
/**
|
||||
* Plan approval request for the TUI
|
||||
*/
|
||||
export interface PlanApprovalRequest {
|
||||
plan: ImplementationPlan;
|
||||
resolve?: (response: { approved: boolean; message?: string }) => void;
|
||||
}
|
||||
|
||||
interface PlanApprovalModalProps {
|
||||
request: PlanApprovalRequest;
|
||||
onRespond?: (approved: boolean, message?: string) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface ApprovalOption {
|
||||
key: string;
|
||||
label: string;
|
||||
approved: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const APPROVAL_OPTIONS: ApprovalOption[] = [
|
||||
{ key: "y", label: "Approve - proceed with implementation", approved: true },
|
||||
{ key: "e", label: "Edit - modify plan before execution", approved: false, message: "edit" },
|
||||
{ key: "n", label: "Reject - cancel this plan", approved: false },
|
||||
];
|
||||
|
||||
export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
const theme = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const MAX_VISIBLE_STEPS = 5;
|
||||
|
||||
const plan = () => props.request.plan;
|
||||
|
||||
const visibleSteps = createMemo(() => {
|
||||
const steps = plan().steps;
|
||||
const offset = scrollOffset();
|
||||
return steps.slice(offset, offset + MAX_VISIBLE_STEPS);
|
||||
});
|
||||
|
||||
const canScrollUp = () => scrollOffset() > 0;
|
||||
const canScrollDown = () => scrollOffset() + MAX_VISIBLE_STEPS < plan().steps.length;
|
||||
|
||||
const handleResponse = (approved: boolean, message?: string): void => {
|
||||
if (props.request.resolve) {
|
||||
props.request.resolve({ approved, message });
|
||||
}
|
||||
props.onRespond?.(approved, message);
|
||||
};
|
||||
|
||||
const getRiskIcon = (risk: PlanStep["riskLevel"]): string => {
|
||||
const icons: Record<PlanStep["riskLevel"], string> = {
|
||||
high: "\u26A0\uFE0F",
|
||||
medium: "\u26A1",
|
||||
low: "\u2713",
|
||||
};
|
||||
return icons[risk];
|
||||
};
|
||||
|
||||
const getRiskColor = (risk: PlanStep["riskLevel"]): string => {
|
||||
const colors: Record<PlanStep["riskLevel"], string> = {
|
||||
high: theme.colors.error,
|
||||
medium: theme.colors.warning,
|
||||
low: theme.colors.success,
|
||||
};
|
||||
return colors[risk];
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
evt.stopPropagation();
|
||||
|
||||
if (evt.name === "up") {
|
||||
if (evt.shift) {
|
||||
// Scroll plan view
|
||||
if (canScrollUp()) {
|
||||
setScrollOffset((prev) => prev - 1);
|
||||
}
|
||||
} else {
|
||||
// Navigate options
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : APPROVAL_OPTIONS.length - 1,
|
||||
);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
if (evt.shift) {
|
||||
// Scroll plan view
|
||||
if (canScrollDown()) {
|
||||
setScrollOffset((prev) => prev + 1);
|
||||
}
|
||||
} else {
|
||||
// Navigate options
|
||||
setSelectedIndex((prev) =>
|
||||
prev < APPROVAL_OPTIONS.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const option = APPROVAL_OPTIONS[selectedIndex()];
|
||||
handleResponse(option.approved, option.message);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "escape") {
|
||||
handleResponse(false, "cancelled");
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle shortcut keys
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
const charLower = evt.name.toLowerCase();
|
||||
const option = APPROVAL_OPTIONS.find((o) => o.key === charLower);
|
||||
if (option) {
|
||||
handleResponse(option.approved, option.message);
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.warning}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
maxHeight={25}
|
||||
>
|
||||
{/* Header */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
Plan Approval Required
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Title and Summary */}
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
{plan().title}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>{plan().summary}</text>
|
||||
</box>
|
||||
|
||||
{/* Estimated Changes */}
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text fg={theme.colors.success}>
|
||||
+{plan().estimatedChanges.filesCreated} create{" "}
|
||||
</text>
|
||||
<text fg={theme.colors.warning}>
|
||||
~{plan().estimatedChanges.filesModified} modify{" "}
|
||||
</text>
|
||||
<text fg={theme.colors.error}>
|
||||
-{plan().estimatedChanges.filesDeleted} delete
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Steps */}
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.text} attributes={TextAttributes.BOLD}>
|
||||
Implementation Steps ({plan().steps.length} total):
|
||||
</text>
|
||||
<Show when={canScrollUp()}>
|
||||
<text fg={theme.colors.textDim}> {"\u25B2"} more above...</text>
|
||||
</Show>
|
||||
<For each={visibleSteps()}>
|
||||
{(step, index) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={getRiskColor(step.riskLevel)}>
|
||||
{getRiskIcon(step.riskLevel)}{" "}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{scrollOffset() + index() + 1}.{" "}
|
||||
</text>
|
||||
<text fg={theme.colors.text}>
|
||||
{step.title.substring(0, 50)}
|
||||
{step.title.length > 50 ? "..." : ""}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={canScrollDown()}>
|
||||
<text fg={theme.colors.textDim}> {"\u25BC"} more below...</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Risks */}
|
||||
<Show when={plan().risks.length > 0}>
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.error} attributes={TextAttributes.BOLD}>
|
||||
Risks ({plan().risks.length}):
|
||||
</text>
|
||||
<For each={plan().risks.slice(0, 2)}>
|
||||
{(risk) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.error}> - </text>
|
||||
<text fg={theme.colors.text}>
|
||||
{risk.description.substring(0, 60)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={plan().risks.length > 2}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
... and {plan().risks.length - 2} more
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Options */}
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<For each={APPROVAL_OPTIONS}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const keyColor = () =>
|
||||
option.approved ? theme.colors.success : theme.colors.error;
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.primary : undefined}
|
||||
attributes={
|
||||
isSelected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={keyColor()}>[{option.key}] </text>
|
||||
<text fg={isSelected() ? theme.colors.primary : undefined}>
|
||||
{option.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
{/* Footer */}
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{"\u2191\u2193"} options | Shift+{"\u2191\u2193"} scroll steps | Enter select | y/e/n shortcut
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user