Skip to content

Commit 807cc5f

Browse files
feat(submit-plan): replace dual-mode with edit-based interface (#730)
* feat(submit-plan): replace text/file-path mode with edit-based interface Switches the OpenCode submit_plan tool from a dual-mode interface (inline text or file path) to an edit-based one. The plugin now owns a backing file at .opencode/plans/_active-plan.md; the agent never reads or writes it directly. On denial, the response includes the current plan with line numbers so the agent can apply surgical edits instead of resubmitting the entire document, reducing token waste on iterative revisions. - Add applyEdits, validateEdits, formatWithLineNumbers, and getPlanBackingPath helpers to the plugin - Validate edit ranges (bounds, overlap, size limit) before mutating the backing file - Return line-numbered plan in denial responses to anchor targeted edits - Remove getPlanDirectory, validatePlanPath, and file-path auto- detection from plan-mode.ts - Replace plan-mode.test.ts path-validation coverage with submit- plan.test.ts for the edit engine - Update custom-feedback.md and opencode.md docs for edit-based semantics Refs #365 * chore(opencode): drop unused buildPlanFileRule import Removed in PR #730 deny path along with the only call site, but the import was left behind. --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent 57fa1c6 commit 807cc5f

6 files changed

Lines changed: 350 additions & 280 deletions

File tree

apps/marketing/src/content/docs/guides/custom-feedback.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ Templates use `{{variable}}` placeholders. Here's what each one contains:
6767
|----------|-------------|
6868
| `{{feedback}}` | Your annotations, exported as structured text. This is the main content. |
6969
| `{{toolName}}` | The tool the agent needs to call to resubmit (`ExitPlanMode`, `submit_plan`, etc.). Varies by runtime. |
70-
| `{{planFileRule}}` | A conditional line telling the agent where the plan file is saved and how to edit it. Empty string when there's no file path. |
71-
| `{{planFilePath}}` | Path to the plan file being reviewed. |
70+
| `{{planFileRule}}` | A conditional line about the plan file location. In edit-based mode (OpenCode), this is always empty since the plugin manages the backing file internally. |
71+
| `{{planFilePath}}` | Path to the plan's backing file. In edit-based mode (OpenCode), this points to the plugin-managed backing file. |
7272
| `{{doneMsg}}` | Optional checklist instruction or save-path info, depending on the runtime. |
7373
| `{{fileHeader}}` | Either `"File"` or `"Folder"`, depending on what was annotated. |
7474
| `{{filePath}}` | Path to the annotated file or folder. |
@@ -137,6 +137,7 @@ Here's a config that customizes several messages at once:
137137
If you don't set any `prompts` config, everything works the same as it always has. The built-in defaults are the exact messages Plannotator has always sent. Here are the key ones for reference:
138138

139139
**Plan denied (default):**
140+
140141
```
141142
YOUR PLAN WAS NOT APPROVED.
142143
@@ -152,12 +153,14 @@ Rules:
152153
```
153154

154155
**Plan approved (default, Pi runtime):**
156+
155157
```
156158
Plan approved. You now have full tool access (read, bash, edit,
157159
write). Execute the plan in {{planFilePath}}. {{doneMsg}}
158160
```
159161

160162
**Annotate file feedback (default):**
163+
161164
```
162165
# Markdown Annotations
163166

apps/marketing/src/content/docs/guides/opencode.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ If you are upgrading from an older OpenCode setup, read the [0.19.1 migration gu
1515
The OpenCode plugin (`@plannotator/opencode`) hooks into OpenCode's plugin system:
1616

1717
1. The plugin registers a `submit_plan` tool for OpenCode's built-in `plan` agent and any extra planning agents you configure
18-
2. When `submit_plan` is called with a plan, Plannotator starts a local server and opens the browser
18+
2. When `submit_plan` is called, Plannotator starts a local server and opens the browser
1919
3. The user reviews and annotates the plan
2020
4. On approval, the plugin returns a success response to the agent
21-
5. On denial, the plugin returns the feedback for the agent to revise
21+
5. On denial, the plugin returns feedback with the current plan state, and the agent applies targeted edits
2222

2323
## Workflow modes
2424

apps/opencode-plugin/index.ts

Lines changed: 167 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
/**
22
* Plannotator Plugin for OpenCode
33
*
4-
* Provides interactive browser-based plan review via a single tool:
5-
* submit_plan(plan) — accepts either markdown text or a file path
6-
*
7-
* First submission: agent passes plan as text. On deny, the response includes
8-
* the path where the plan was saved, enabling the agent to use Edit for targeted
9-
* revisions and resubmit with the file path.
4+
* POC: Edit-based submit_plan. The tool accepts line-range edits instead of
5+
* full plan text or file paths. A backing file is managed by the plugin;
6+
* the agent never touches it directly. On denial, the tool response includes
7+
* the plan with line numbers so the agent can target surgical edits.
108
*
119
* Environment variables:
1210
* PLANNOTATOR_REMOTE - Set to "1"/"true" for remote, "0"/"false" for local
@@ -18,7 +16,7 @@
1816
*/
1917

2018
import { type Plugin, tool } from "@opencode-ai/plugin";
21-
import { existsSync, readFileSync } from "fs";
19+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2220
import path from "path";
2321

2422
// OpenCode's @hono/node-server patches global.Response with a polyfill that
@@ -61,7 +59,6 @@ import {
6159
getPlanApprovedPrompt,
6260
getPlanApprovedWithNotesPrompt,
6361
getPlanToolName,
64-
buildPlanFileRule,
6562
getAnnotateMessageFeedbackPrompt,
6663
} from "@plannotator/shared/prompts";
6764
import { loadConfig } from "@plannotator/shared/config";
@@ -119,35 +116,107 @@ function getReviewHtml(): string {
119116
}
120117

121118
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
119+
const MAX_PLAN_SIZE = 5 * 1024 * 1024; // 5MB
120+
121+
// ── Edit-based plan management ────────────────────────────────────────────
122122

123-
// ── Auto-detection ────────────────────────────────────────────────────────
123+
interface PlanEdit {
124+
start: number;
125+
end?: number | null;
126+
content: string;
127+
}
124128

125129
/**
126-
* Detect whether the submit_plan argument is a file path.
127-
* Must be an absolute path, end in .md, and exist on disk.
128-
* Anything that doesn't match is treated as plan text.
130+
* Backing file for the current plan. Managed entirely by the plugin;
131+
* the agent never sees or touches this file directly.
129132
*/
130-
function isFilePath(value: string): boolean {
131-
return path.isAbsolute(value) && value.endsWith(".md") && existsSync(value);
133+
export function getPlanBackingPath(directory: string): string {
134+
const planDir = path.join(directory, ".opencode", "plans");
135+
return path.join(planDir, "_active-plan.md");
136+
}
137+
138+
/**
139+
* Apply line-range edits to a plan stored as an array of lines.
140+
*
141+
* Edit semantics:
142+
* - start/end are 1-indexed line numbers (inclusive)
143+
* - end omitted or null: replace from start through end of file
144+
* (on first call with start=1, this writes the entire plan)
145+
* - content="" with start/end: delete those lines
146+
* - edits are applied in order; line numbers refer to the document
147+
* state BEFORE any edits in this batch (offsets are adjusted internally)
148+
*/
149+
export function applyEdits(existingLines: string[], edits: PlanEdit[]): string[] {
150+
// Sort by start ascending so offset adjustment works correctly
151+
const sorted = [...edits].sort((a, b) => a.start - b.start);
152+
const lines = [...existingLines];
153+
let offset = 0;
154+
155+
for (const edit of sorted) {
156+
const start = edit.start - 1 + offset; // convert to 0-indexed + adjust
157+
const end = edit.end != null
158+
? edit.end + offset // end is inclusive, so this becomes the exclusive upper bound
159+
: lines.length; // null/omitted = through end of file
160+
161+
const newLines = edit.content ? edit.content.split("\n") : [];
162+
const removedCount = end - start;
163+
lines.splice(start, removedCount, ...newLines);
164+
offset += newLines.length - removedCount;
165+
}
166+
167+
return lines;
132168
}
133169

134170
/**
135-
* Resolve the plan content from the submit_plan argument.
136-
* Returns the markdown text and optionally the source file path.
171+
* Validate a batch of edits against the current file state.
172+
* Returns an error string if invalid, or null if all edits are acceptable.
137173
*/
138-
function resolvePlanContent(plan: string): { content: string; filePath?: string } {
139-
if (isFilePath(plan)) {
140-
const content = readFileSync(plan, "utf-8");
141-
if (!content.trim()) {
142-
throw new Error(`Plan file at ${plan} is empty. Write your plan content first, then call submit_plan.`);
174+
export function validateEdits(existingLines: string[], edits: PlanEdit[]): string | null {
175+
const lineCount = existingLines.length;
176+
177+
for (const edit of edits) {
178+
if (!Number.isInteger(edit.start) || edit.start < 1) {
179+
return `start must be a positive integer >= 1, got ${edit.start}`;
180+
}
181+
if (edit.start > lineCount + 1) {
182+
return `start (${edit.start}) exceeds file length + 1 (${lineCount + 1})`;
183+
}
184+
if (edit.end != null) {
185+
if (!Number.isInteger(edit.end) || edit.end < edit.start) {
186+
return `end (${edit.end}) must be >= start (${edit.start})`;
187+
}
188+
if (edit.end > lineCount) {
189+
return `end (${edit.end}) exceeds file length (${lineCount})`;
190+
}
143191
}
144-
return { content, filePath: plan };
145192
}
146-
// Catch typos: looks like a file path but doesn't exist
147-
if (path.isAbsolute(plan) && plan.endsWith(".md")) {
148-
throw new Error(`File not found: ${plan}. Check the path and try again.`);
193+
194+
// Check for overlapping ranges (sorted by start ascending)
195+
const sorted = [...edits].sort((a, b) => a.start - b.start);
196+
for (let i = 1; i < sorted.length; i++) {
197+
const prev = sorted[i - 1];
198+
const curr = sorted[i];
199+
// Appending edits (start > lineCount) have no range that can overlap
200+
if (prev.start > lineCount) continue;
201+
const prevEnd = prev.end ?? lineCount;
202+
if (curr.start <= prevEnd) {
203+
return `edits overlap: [${prev.start},${prevEnd}] and [${curr.start},${curr.end ?? "end"}]`;
204+
}
149205
}
150-
return { content: plan };
206+
207+
return null;
208+
}
209+
210+
/**
211+
* Format the plan content with line numbers for the agent's reference.
212+
* Returned in the tool response so the agent can track line positions.
213+
*/
214+
export function formatWithLineNumbers(content: string): string {
215+
const lines = content.split("\n");
216+
const width = String(lines.length).length;
217+
return lines
218+
.map((line, i) => `${String(i + 1).padStart(width)}| ${line}`)
219+
.join("\n");
151220
}
152221

153222
// ── Planning prompt ───────────────────────────────────────────────────────
@@ -159,7 +228,7 @@ function resolvePlanContent(plan: string): { content: string; filePath?: string
159228
* - Explain the WHY — the model is smart, give it context
160229
* - Keep it lean — every line should pull its weight
161230
* - Don't overfit — let the agent and user dictate the workflow
162-
* - One tool, two modes — text for first submission, file path for revisions
231+
* - Edit-based: all submissions use line-range edits against a backing file
163232
*/
164233
function getPlanningPrompt(): string {
165234
return `## Plannotator — Plan Review
@@ -168,10 +237,26 @@ You have a plan submission tool called \`submit_plan\`. It opens an interactive
168237
169238
**How to use it:**
170239
171-
- Pass your plan as markdown text — \`submit_plan(plan: "# My Plan\\n...")\`.
172-
- Or pass an absolute file path to a .md file — \`submit_plan(plan: "/path/to/plan.md")\`.
240+
\`submit_plan\` accepts an array of line-range edits. On first submission, pass the full plan as a single edit starting at line 1:
241+
242+
\`\`\`json
243+
{ "edits": [{ "start": 1, "content": "# My Plan\\n\\n## Goals\\n..." }] }
244+
\`\`\`
173245
174-
The tool auto-detects whether you passed text or a file path. Both open the same review UI.
246+
If the user denies and requests changes, apply surgical edits using line ranges. The tool response includes your plan with line numbers so you can target specific ranges:
247+
248+
\`\`\`json
249+
{ "edits": [
250+
{ "start": 12, "end": 14, "content": "revised section content" },
251+
{ "start": 30, "end": 30, "content": "" }
252+
] }
253+
\`\`\`
254+
255+
Edit semantics:
256+
- \`start\` and \`end\` are 1-indexed, inclusive line numbers
257+
- Omit \`end\` to replace from \`start\` through end of file (use this for the initial full write)
258+
- Empty \`content\` with \`start\`/\`end\` deletes those lines
259+
- Multiple edits in one call are applied in order; line numbers refer to the state before edits
175260
176261
### Before you write a plan
177262
@@ -376,9 +461,9 @@ tools (except writing markdown files), or otherwise make changes to the system.
376461

377462
output.system.push(`## Plan Submission
378463
379-
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.
464+
When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your full plan as a single edit: \`{ "edits": [{ "start": 1, "content": "..." }] }\`.
380465
381-
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.
466+
The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, the response includes your plan with line numbers; use targeted edits to revise specific sections.
382467
383468
Do NOT proceed with implementation until your plan is approved.`);
384469
},
@@ -449,11 +534,17 @@ Do NOT proceed with implementation until your plan is approved.`);
449534
plugin.tool = {
450535
submit_plan: tool({
451536
description:
452-
"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.",
537+
"Submit a plan for user review via line-range edits. First call: pass a single edit with start=1 and your full plan as content (omit end). Subsequent calls after denial: pass targeted edits using the line numbers from the previous response. The tool manages a backing file; you never touch the file directly.",
453538
args: {
454-
plan: tool.schema
455-
.string()
456-
.describe("The plan — either markdown text or an absolute path to a .md file on disk."),
539+
edits: tool.schema
540+
.array(
541+
tool.schema.object({
542+
start: tool.schema.number().describe("1-indexed start line (inclusive)"),
543+
end: tool.schema.number().optional().describe("1-indexed end line (inclusive). Omit to replace from start through end of file."),
544+
content: tool.schema.string().describe("Replacement content. Empty string deletes the line range."),
545+
}),
546+
)
547+
.describe("Array of line-range edits to apply to the plan."),
457548
},
458549

459550
async execute(args, context) {
@@ -464,21 +555,46 @@ Do NOT proceed with implementation until your plan is approved.`);
464555
Use /plannotator-last or /plannotator-annotate for manual review, or set workflow to all-agents to allow broader submit_plan access.`;
465556
}
466557

467-
// Auto-detect: file path or plan text
468-
let planContent: string;
469-
let sourceFilePath: string | undefined;
558+
if (!args.edits || args.edits.length === 0) {
559+
return "Error: No edits provided. Pass at least one edit with start and content.";
560+
}
561+
562+
// Read existing backing file (empty on first call)
563+
const backingPath = getPlanBackingPath(ctx.directory);
564+
const backingDir = path.dirname(backingPath);
565+
mkdirSync(backingDir, { recursive: true });
566+
567+
let existingContent = "";
568+
if (existsSync(backingPath)) {
569+
existingContent = readFileSync(backingPath, "utf-8");
570+
}
571+
572+
// Validate and apply edits
573+
const existingLines = existingContent ? existingContent.split("\n") : [];
574+
575+
const validationError = validateEdits(existingLines, args.edits);
576+
if (validationError) {
577+
return `Error: ${validationError}`;
578+
}
579+
580+
let resultLines: string[];
470581
try {
471-
const resolved = resolvePlanContent(args.plan);
472-
planContent = resolved.content;
473-
sourceFilePath = resolved.filePath;
582+
resultLines = applyEdits(existingLines, args.edits);
474583
} catch (err) {
475-
return `Error: ${err instanceof Error ? err.message : String(err)}`;
584+
return `Error applying edits: ${err instanceof Error ? err.message : String(err)}`;
476585
}
477586

587+
const planContent = resultLines.join("\n");
588+
if (planContent.length > MAX_PLAN_SIZE) {
589+
return `Error: Plan content exceeds the maximum size of ${MAX_PLAN_SIZE / (1024 * 1024)}MB.`;
590+
}
478591
if (!planContent.trim()) {
479-
return "Error: Plan content is empty. Write your plan first, then call submit_plan.";
592+
return "Error: Plan content is empty after applying edits.";
480593
}
481594

595+
// Write backing file
596+
writeFileSync(backingPath, planContent, "utf-8");
597+
482598
const sharingEnabled = await getSharingEnabled();
483599
const server = await startPlannotatorServer({
484600
plan: planContent,
@@ -540,7 +656,7 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo
540656

541657
if (result.feedback) {
542658
return getPlanApprovedWithNotesPrompt("opencode", undefined, {
543-
planFilePath: sourceFilePath,
659+
planFilePath: backingPath,
544660
doneMsg: result.savedPath ? `Saved to: ${result.savedPath}` : "",
545661
feedback: result.feedback,
546662
proceedSuffix: shouldSwitchAgent
@@ -550,15 +666,18 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo
550666
}
551667

552668
return getPlanApprovedPrompt("opencode", undefined, {
553-
planFilePath: sourceFilePath,
669+
planFilePath: backingPath,
554670
doneMsg: result.savedPath ? ` Saved to: ${result.savedPath}` : "",
555671
});
556672
} else {
673+
const lineNumberedPlan = formatWithLineNumbers(planContent);
674+
const totalLines = planContent.split("\n").length;
675+
557676
return getPlanDeniedPrompt("opencode", undefined, {
558677
toolName: getPlanToolName("opencode"),
559-
planFileRule: buildPlanFileRule(getPlanToolName("opencode"), sourceFilePath),
678+
planFileRule: "",
560679
feedback: result.feedback || "Plan changes requested",
561-
}) + "\n\nAfter making your revisions, call `submit_plan` again to resubmit for review.";
680+
}) + `\n\n## Current Plan (${totalLines} lines)\n\nThe plan below shows the current state with line numbers. Use these exact line numbers in your next \`submit_plan\` call:\n\n\`\`\`\n${lineNumberedPlan}\n\`\`\`\n\nCall \`submit_plan\` with targeted edits to address the feedback above.`;
562681
}
563682
},
564683
}),

0 commit comments

Comments
 (0)