diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index b12018ca..c1630a90 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -1,24 +1,25 @@ /** * Plannotator Plugin for OpenCode * - * Provides iterative planning with interactive browser-based plan review. + * Provides interactive browser-based plan review via a single tool: + * submit_plan(plan) — accepts either markdown text or a file path * - * When the agent is in plan mode: - * - Injects planning prompt directing the agent to write plans in $XDG_DATA_HOME/opencode/plans/ - * - Agent creates a uniquely-named plan file, revises it on feedback - * - submit_plan(path) reads the plan from disk and opens browser UI - * - plan_exit suppressed in favor of submit_plan (experimental mode compatibility) + * First submission: agent passes plan as text. On deny, the response includes + * the path where the plan was saved, enabling the agent to use Edit for targeted + * revisions and resubmit with the file path. * * Environment variables: * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH) * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) - * PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for submit_plan approval (default: 345600, set 0 to disable) - * PLANNOTATOR_ALLOW_SUBAGENTS - Set to "1" to allow subagents to see submit_plan tool + * PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for approval (default: 345600, set 0 to disable) + * PLANNOTATOR_ALLOW_SUBAGENTS - Set to "1" to allow subagents to see submit_plan * * @packageDocumentation */ import { type Plugin, tool } from "@opencode-ai/plugin"; +import { existsSync, readFileSync } from "fs"; +import path from "path"; import { startPlannotatorServer, handleServerReady, @@ -40,8 +41,6 @@ import { } from "./commands"; import { planDenyFeedback } from "@plannotator/shared/feedback-templates"; import { - getPlanDirectory, - validatePlanPath, stripConflictingPlanModeRules, } from "./plan-mode"; @@ -55,91 +54,80 @@ const reviewHtmlContent = reviewHtml as unknown as string; const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours +// ── Auto-detection ──────────────────────────────────────────────────────── + +/** + * Detect whether the submit_plan argument is a file path. + * Must be an absolute path, end in .md, and exist on disk. + * Anything that doesn't match is treated as plan text. + */ +function isFilePath(value: string): boolean { + return path.isAbsolute(value) && value.endsWith(".md") && existsSync(value); +} + +/** + * Resolve the plan content from the submit_plan argument. + * Returns the markdown text and optionally the source file path. + */ +function resolvePlanContent(plan: string): { content: string; filePath?: string } { + if (isFilePath(plan)) { + const content = readFileSync(plan, "utf-8"); + if (!content.trim()) { + throw new Error(`Plan file at ${plan} is empty. Write your plan content first, then call submit_plan.`); + } + return { content, filePath: plan }; + } + // Catch typos: looks like a file path but doesn't exist + if (path.isAbsolute(plan) && plan.endsWith(".md")) { + throw new Error(`File not found: ${plan}. Check the path and try again.`); + } + return { content: plan }; +} + // ── Planning prompt ─────────────────────────────────────────────────────── -function getPlanningPrompt(planDir: string): string { - return `## Plannotator — Iterative Planning +/** + * Unified planning prompt injected for all primary agents. + * + * Design principles: + * - Explain the WHY — the model is smart, give it context + * - Keep it lean — every line should pull its weight + * - Don't overfit — let the agent and user dictate the workflow + * - One tool, two modes — text for first submission, file path for revisions + */ +function getPlanningPrompt(): string { + return `## Plannotator — Plan Review -**CRITICAL: Do NOT use TodoWrite for planning. Do NOT write plans to the current working directory.** +You have a plan submission tool called \`submit_plan\`. It opens an interactive review UI where the user can annotate, approve, or request changes. -Write all plan files to this global directory: +**How to use it:** -${planDir} +- Pass your plan as markdown text — \`submit_plan(plan: "# My Plan\\n...")\`. +- Or pass an absolute file path to a .md file — \`submit_plan(plan: "/path/to/plan.md")\`. -Create the directory with \`mkdir -p ${planDir}\` if it does not exist. +The tool auto-detects whether you passed text or a file path. Both open the same review UI. -You must not edit the codebase during planning. The only files you may create or edit are plan markdown files inside that directory. Do not run destructive shell commands (rm, git push, npm install, etc.). +### Before you write a plan -### Step 1 — Explore +Do not jump straight to writing a plan. First: -Before writing anything, understand the task and the code it touches. +1. **Explore** — Read the relevant code, trace dependencies, and look at existing patterns. The depth should match the task. +2. **Ask questions** — If you need information only the user can provide (requirements, preferences, tradeoffs), ask using the \`question\` tool. Don't guess at ambiguous requirements. -- Read the relevant source files. Trace call paths, data flow, and dependencies. -- Look at existing patterns, utilities, and conventions in the codebase — your plan should reuse them. -- Check related tests to understand expected behavior and edge cases. -- Scale depth to the task: a vague feature request needs deep exploration; a focused bug fix may only need a few files. +Only write and submit a plan once you have sufficient context. -Do not jump to writing a plan or asking questions until you have the context you need. If the conversation already provided sufficient context, or the task is greenfield with no code to explore, move on to the next step. +### What NOT to do -### Step 2 — Ask (if needed) - -If there are things only the user can answer — requirements, preferences, tradeoffs, edge-case priorities — use the \`question\` tool. Do not ask via plain text output. - -- Never ask what you could find out by reading the code. -- Batch related questions into a single \`question\` call. -- For greenfield tasks, this may be your first step. - -### Step 3 — Write the plan - -Once you understand the task, create exactly one markdown plan file in the directory above with a unique, descriptive filename (e.g. \`auth-refactor.md\`, \`fix-upload-timeout.md\`). Do not overwrite or reuse filenames from existing plans. - -Structure the plan with: -- **Context** — Why this change is being made. -- **Approach** — Your recommended approach only, not all alternatives considered. -- **Files to modify** — List the critical file paths that will be changed. -- **Reuse** — Reference existing functions and utilities you found, with their file paths. -- **Steps** — Implementation checklist with \`- [ ]\` items. -- **Verification** — How to test the changes end-to-end. - -Keep it concise enough to scan quickly, but detailed enough to execute effectively. - -### Step 4 — Submit - -Call \`submit_plan(path: "/absolute/path/to/your-plan.md")\` to open the plan in a visual review UI. Do not submit plan text directly — submit the file path. - -### If the user requests changes - -1. Read the same plan file you previously submitted. -2. Edit that same file to address the feedback. -3. Call \`submit_plan\` again with the same \`path\`. -4. Never create a new file in response to feedback — always revise the existing one. - -### Ending your turn - -Your turn should only end by either: -- Using the question tool to ask the user for information. -- Calling submit_plan when the plan is ready for review. - -Do not end your turn without doing one of these two things. - -### Summary — Required workflow - -1. **Explore** the codebase to understand the task. -2. **Ask** the user clarifying questions using the \`question\` tool (not plain text). -3. **Write** a plan markdown file to \`${planDir}\` — never the current working directory, never TodoWrite. -4. **Submit** by calling \`submit_plan\` with the absolute file path. - -Do not skip or reorder these steps. Do not use TodoWrite as a substitute for writing a plan file.`; +- Don't proceed with implementation until the plan is approved. +- Don't use \`plan_exit\` — use \`submit_plan\` instead. +- Don't end your turn without either submitting a plan or asking the user a question.`; } // ── Plugin ──────────────────────────────────────────────────────────────── export const PlannotatorPlugin: Plugin = async (ctx) => { - // Agents list is static for the session lifetime — fetch once and cache let cachedAgents: any[] | null = null; - // Helper to determine if sharing is enabled (lazy evaluation) - // Priority: OpenCode config > env var > default (enabled) async function getSharingEnabled(): Promise { try { const response = await ctx.client.config.get({ query: { directory: ctx.directory } }); @@ -195,7 +183,7 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { // Allow the plan agent to write .md files anywhere. // OpenCode's built-in plan agent uses relative-path globs that break // when worktree != cwd (non-git projects). Per-agent config merges - // last (agent.ts:232), so this only affects the plan agent. + // last, so this only affects the plan agent. opencodeConfig.agent ??= {}; opencodeConfig.agent.plan ??= {}; opencodeConfig.agent.plan.permission ??= {}; @@ -205,9 +193,9 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { }; }, - // Strip OpenCode's built-in "STRICTLY FORBIDDEN" plan mode prompt from - // synthetic user message parts. The plugin's system prompt injection is - // the full replacement — this removes the conflicting original. + // Strip OpenCode's "STRICTLY FORBIDDEN" plan mode prompt from synthetic + // user messages. OpenCode injects these to prevent file edits in plan mode, + // but we need the agent to be able to write plan files. "experimental.chat.messages.transform": async (input, output) => { for (const message of output.messages) { if (message.info.role !== "user") continue; @@ -217,7 +205,8 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { } }, - // Suppress plan_exit in favor of submit_plan + // Suppress plan_exit — redirect to submit_plan + // Override todowrite — defer to submit_plan during planning "tool.definition": async (input, output) => { if (input.toolID === "plan_exit") { output.description = @@ -225,13 +214,12 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { } if (input.toolID === "todowrite") { output.description = - "Track implementation progress by creating and updating task checklists. Only use this after a plan has been approved — not for planning itself. For planning, write a plan file and call submit_plan."; + "While actively planning with the user, use submit_plan instead. Only use todos once implementation begins or unless the user explicitly asks."; } }, // Inject planning instructions into system prompt "experimental.chat.system.transform": async (input, output) => { - // Skip for title generation requests const systemText = output.system.join("\n"); if (systemText.toLowerCase().includes("title generator") || systemText.toLowerCase().includes("generate a title")) { return; @@ -239,14 +227,12 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { let lastUserAgent: string | undefined; try { - // Fetch session messages to determine current agent const messagesResponse = await ctx.client.session.messages({ // @ts-ignore - sessionID exists on input path: { id: input.sessionID } }); const messages = messagesResponse.data; - // Find last user message (reverse iteration) if (messages) { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -258,13 +244,12 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { } } - // Skip if agent detection fails (safer) if (!lastUserAgent) return; - // Hardcoded exclusion: build agent + // Build agent doesn't need planning instructions if (lastUserAgent === "build") return; - // Agents list is static — cache after first fetch + // Cache agents list (static per session) if (!cachedAgents) { const agentsResponse = await ctx.client.app.agents({ query: { directory: ctx.directory } @@ -273,97 +258,35 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { } const agent = cachedAgents.find((a: { name: string }) => a.name === lastUserAgent); - // Skip if agent is a sub-agent + // Skip sub-agents // @ts-ignore - Agent has mode field if (agent?.mode === "subagent") return; } catch { - // Skip injection on any error (safer) return; } + + // Plan agent: strip conflicting OpenCode rules, inject full prompt if (lastUserAgent === "plan") { - const planDir = getPlanDirectory(); output.system = stripConflictingPlanModeRules(output.system); - // Replace conflicting instructions in the base prompt - output.system = output.system.map((s: string) => - s - .replace("This includes markdown files.", `Exception: you must create plan markdown files in ${planDir}.`) - .replace("These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.", `Do not use TodoWrite for planning. Instead, write your plan as a markdown file in ${planDir} and call submit_plan.`) - .replace("Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.", "TodoWrite is for tracking implementation progress only, not for planning.") - .replace("- Use the TodoWrite tool to plan the task if required", `- Write your plan to ${planDir} and call submit_plan`) - .replace("IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.", `IMPORTANT: In plan mode, always write a plan file to ${planDir} and call submit_plan. Do not use TodoWrite for planning.`) - .replace("Let me first use the TodoWrite tool to plan this task.", "Let me first write a plan file and submit it for review.") - .replace("You have access to the TodoWrite tools to help you manage and plan tasks.", "You have access to the TodoWrite tools to help you track implementation tasks.") - .replace( - ` -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first write a plan file and submit it for review. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] -`, - ` -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll write a plan for this feature. Let me first explore the codebase to understand existing patterns. - -[Assistant explores code, asks clarifying questions using the question tool] - -Now I'll write the plan file. - -[Assistant writes plan markdown to ${planDir}/usage-metrics.md] - -[Assistant calls submit_plan with the absolute path to open the review UI] -`) - ); - // Final sweep: replace any remaining TodoWrite/todo references in plan mode - output.system = output.system.map((s: string) => - s - .replace(/TodoWrite/g, "submit_plan") - .replace(/todowrite/gi, "submit_plan") - .replace(/todo list/gi, "plan file") - .replace(/todo items/gi, "plan steps") - .replace(/todos/gi, "plan steps") - ); - const prompt = getPlanningPrompt(planDir); - output.system.push(prompt); - // Append a short reinforcement reminder at the very end of system prompt - output.system.push(`You are in PLAN MODE. The user has asked you to plan, not execute. Explore the codebase, ask clarifying questions using the question tool, and finalize your plan in a markdown file written to ${planDir}. Then call submit_plan with the absolute path. Do not use the todowrite tool. Do not create todos. The plan file is your only output.`); + output.system.push(getPlanningPrompt()); return; } - // Other primary agents: inject minimal submission reminder - output.system.push(` -## Plan Submission + // Other primary agents: minimal reminder about the tool + output.system.push(`## Plan Submission -When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review. -The user will be able to: -- Review your plan visually in a dedicated UI -- Annotate specific sections with feedback -- Approve the plan to proceed with implementation -- Request changes with detailed feedback +When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your plan as markdown text, or pass an absolute file path to a .md file. -If your plan is rejected, you will receive the user's annotated feedback. Revise your plan -based on their feedback and call submit_plan again. +The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, revise based on their feedback and call submit_plan again. -Do NOT proceed with implementation until your plan is approved. -`); +Do NOT proceed with implementation until your plan is approved.`); }, // Intercept plannotator-last before the agent sees the command "command.execute.before": async (input, output) => { if (input.command !== "plannotator-last") return; - // Clear parts so the agent doesn't respond to the command body output.parts = []; const deps: CommandDeps = { @@ -375,13 +298,11 @@ Do NOT proceed with implementation until your plan is approved. directory: ctx.directory, }; - // Fetch last message, run annotation server, get feedback const feedback = await handleAnnotateLastCommand( { properties: { sessionID: input.sessionID } }, deps ); - // Send feedback as a new prompt — same pattern as review/annotate if (feedback) { try { await ctx.client.session.prompt({ @@ -428,22 +349,29 @@ Do NOT proceed with implementation until your plan is approved. tool: { submit_plan: tool({ description: - "Use this tool to create and submit plans. When the user asks you to plan something, follow the planning process: explore the codebase, ask clarifying questions, then write your plan as a markdown file. After following the planning process, call this tool with the absolute file path to open an interactive review UI where the user can annotate, approve, or request changes.", + "Planning tool used to submit a plan to the user for review. Before calling this tool you must conduct interactive and exploratory analysis in order to submit a quality plan. Ask questions. Explore the codebase for context if needed. Only call submit_plan once you have enough details to create a quality plan. Work with the user to get those details. Pass either markdown text or an absolute path to a .md file.", args: { - path: tool.schema + plan: tool.schema .string() - .describe("Absolute path to the plan markdown file on disk."), + .describe("The plan — either markdown text or an absolute path to a .md file on disk."), }, async execute(args, context) { - const planDir = getPlanDirectory(); - const validation = validatePlanPath(args.path, planDir); - - if (!validation.ok) { - return `Error: ${validation.error}`; + // Auto-detect: file path or plan text + let planContent: string; + let sourceFilePath: string | undefined; + try { + const resolved = resolvePlanContent(args.plan); + planContent = resolved.content; + sourceFilePath = resolved.filePath; + } catch (err) { + return `Error: ${err instanceof Error ? err.message : String(err)}`; + } + + if (!planContent.trim()) { + return "Error: Plan content is empty. Write your plan first, then call submit_plan."; } - const planContent = validation.content; const sharingEnabled = await getSharingEnabled(); const server = await startPlannotatorServer({ plan: planContent, @@ -484,7 +412,6 @@ Do NOT proceed with implementation until your plan is approved. server.stop(); if (result.approved) { - // Check agent switch setting const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; const targetAgent = result.agentSwitch || 'build'; @@ -526,7 +453,9 @@ Proceed with implementation, incorporating these notes where applicable.`; return `Plan approved!${result.savedPath ? ` Saved to: ${result.savedPath}` : ""}`; } else { - return planDenyFeedback(result.feedback || "", "submit_plan", { planFilePath: args.path }); + return planDenyFeedback(result.feedback || "", "submit_plan", { + planFilePath: sourceFilePath, + }) + "\n\nAfter making your revisions, call `submit_plan` again to resubmit for review."; } }, }), diff --git a/packages/server/index.ts b/packages/server/index.ts index 1a2ba6a7..22bfadc2 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -299,9 +299,7 @@ export async function startPlannotatorServer( await Promise.allSettled(promises); for (const [name, result] of Object.entries(results)) { - if (result?.success) { - console.error(`[${name}] Saved plan${result.path ? ` to: ${result.path}` : ''}`); - } else if (result) { + if (!result?.success && result) { console.error(`[${name}] Save failed: ${result.error}`); } } @@ -368,9 +366,7 @@ export async function startPlannotatorServer( await Promise.allSettled(integrationPromises); for (const [name, result] of Object.entries(integrationResults)) { - if (result?.success) { - console.error(`[${name}] Saved plan${result.path ? ` to: ${result.path}` : ''}`); - } else if (result) { + if (!result?.success && result) { console.error(`[${name}] Save failed: ${result.error}`); } } diff --git a/packages/shared/feedback-templates.test.ts b/packages/shared/feedback-templates.test.ts index 0240c765..ad4b9b95 100644 --- a/packages/shared/feedback-templates.test.ts +++ b/packages/shared/feedback-templates.test.ts @@ -57,7 +57,9 @@ describe("feedback-templates", () => { planFilePath: "plans/auth.md", }); - expect(result).toContain("Read plans/auth.md to see the current plan before editing it."); + expect(result).toContain("plans/auth.md"); + expect(result).toContain("edit this file"); expect(result).toContain("exit_plan_mode"); }); + }); diff --git a/packages/shared/feedback-templates.ts b/packages/shared/feedback-templates.ts index ae14a04f..8d6c3426 100644 --- a/packages/shared/feedback-templates.ts +++ b/packages/shared/feedback-templates.ts @@ -15,8 +15,8 @@ export const planDenyFeedback = ( options?: PlanDenyFeedbackOptions, ): string => { const planFileRule = options?.planFilePath - ? `- Read ${options.planFilePath} to see the current plan before editing it.\n` + ? `- Your plan is saved at: ${options.planFilePath}\n You can edit this file to make targeted changes, then pass its path to ${toolName}.\n` : ""; - return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Use the Edit tool to make targeted changes to the plan — do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; + return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; };