fix: single submit_plan with text/path auto-detect for OpenCode (#333)

* fix: restore uniform submit_plan and add submit_plan_file for OpenCode

PR #318 replaced the original text-based submit_plan with a file-path-only
version and split prompt injection between plan/non-plan agents. This caused
regressions: the agent couldn't figure out file paths from non-plan agents,
and the 80+ line prompt with TodoWrite replacements was fragile against
OpenCode upstream changes.

This restores the original submit_plan(plan) that accepts markdown text
directly — the contract that worked uniformly across all agents — and keeps
the file-based workflow as submit_plan_file(path) for users who want
persistent plan files.

Key changes:
- Two tools: submit_plan (text) + submit_plan_file (path)
- Unified prompt for all primary agents (not just plan mode)
- Removed aggressive TodoWrite string replacements and system-reminder
- Kept adversarial stripping of OpenCode's STRICTLY FORBIDDEN rules
- Extracted shared server helpers to reduce duplication

Addresses #328

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: single submit_plan with auto-detect text/path for OpenCode

Collapses submit_plan + submit_plan_file back into one tool that
auto-detects whether the argument is plan text or a file path.

First submission: agent passes markdown text (simple, works from any agent).
On deny: response includes the history path where the plan was saved, so the
agent can Edit the file for targeted revisions and resubmit with the path.

Key changes:
- One tool: submit_plan(plan) accepts text or absolute .md file path
- Server surfaces historyPath through waitForDecision (already saved by
  saveToHistory, just not returned before)
- Deny response includes file path hint for Edit-based revision workflow
- Unified prompt for all primary agents (~25 lines, no TodoWrite warfare)
- Still strips OpenCode's STRICTLY FORBIDDEN rules and suppresses plan_exit

Addresses #328 and rcdailey's feedback on PR #333

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove historyPath leak, error on missing file paths, restore two-tier prompt

- Remove historyPath from deny flow — internal history directory should not
  be exposed to agents. Text submissions get text feedback; file submissions
  get file feedback. No crossover.
- Error when agent passes an absolute .md path that doesn't exist instead of
  silently treating it as plan text.
- Restore two-tier prompt: plan agent gets full planning instructions, other
  primary agents get a minimal reminder (matching pre-v0.13.0 behavior).
- Clean up stale prompt copy referencing historyPath-based revision flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore todowrite override, strengthen planning prompt, remove noisy logs

- Override todowrite description to defer to submit_plan during active planning
- Tool description now instructs agent to explore and ask questions before submitting
- Sequenced planning prompt: explore, ask, then write
- Remove success logs from integration saves (stderr was bleeding through)
- Append resubmit reminder to OpenCode deny feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-18 15:31:29 -07:00
committed by GitHub
parent 9056e18af7
commit d5b6bc702b
4 changed files with 108 additions and 181 deletions
+101 -172
View File
@@ -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<boolean> {
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(
`<example>
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]
</example>`,
`<example>
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]
</example>`)
);
// 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(`<system-reminder>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.</system-reminder>`);
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.";
}
},
}),
+2 -6
View File
@@ -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}`);
}
}
+3 -1
View File
@@ -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");
});
});
+2 -2
View File
@@ -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"}`;
};